| author | |
| committer | |
| log | 3d17862e58554460b0baae7bae0e03d36b8ce5ab |
| tree | 40306f5ac4508e3de2d230f7cfd95f07ab6d470b |
| parent | 64275ae39653379e63f49f87be021c190fd20e81 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>246 files changed, 21426 insertions(+), 21466 deletions(-)
.cursor/rules/cursor-cloud.mdc+1-1| ... | @@ -69,4 +69,4 @@ Gradle auto-downloads any additional pinned SDK packages on demand. | ... | @@ -69,4 +69,4 @@ Gradle auto-downloads any additional pinned SDK packages on demand. |
| 69 | ## Known flaky tests (unrelated to setup) | 69 | ## Known flaky tests (unrelated to setup) |
| 70 | 70 | ||
| 71 | - `apps/server/src/git/GitManager.test.ts` (cross-repo PR metadata, can time out at 12s). | 71 | - `apps/server/src/git/GitManager.test.ts` (cross-repo PR metadata, can time out at 12s). |
| 72 | - `apps/server/src/provider/Layers/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering). | 72 | - `apps/server/src/provider/ProviderRegistry.test.ts` (codex binaryPath re-probe ordering). |
apps/server/integration/transferBudgetV2.integration.test.ts+2-2| ... | @@ -35,8 +35,8 @@ import * as HttpApi from "effect/http-api/HttpApi"; | ... | @@ -35,8 +35,8 @@ import * as HttpApi from "effect/http-api/HttpApi"; |
| 35 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; | 35 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 36 | import { HttpRouter, HttpServer } from "effect/http"; | 36 | import { HttpRouter, HttpServer } from "effect/http"; |
| 37 | import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc"; | 37 | import { Rpc, RpcGroup, RpcServer, RpcSerialization } from "effect/rpc"; |
| 38 | import * as SqlitePersistence from "../src/persistence/Layers/Sqlite.ts"; | 38 | import * as SqlitePersistence from "../src/persistence/Sqlite.ts"; |
| 39 | import * as OrchestrationEventStore from "../src/persistence/Layers/OrchestrationEventStore.ts"; | 39 | import * as OrchestrationEventStore from "../src/persistence/OrchestrationEventStore.ts"; |
| 40 | import * as EventStore from "../src/orchestration-v2/EventStore.ts"; | 40 | import * as EventStore from "../src/orchestration-v2/EventStore.ts"; |
| 41 | import * as EventSink from "../src/orchestration-v2/EventSink.ts"; | 41 | import * as EventSink from "../src/orchestration-v2/EventSink.ts"; |
| 42 | import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; | 42 | import * as ProjectionStore from "../src/orchestration-v2/ProjectionStore.ts"; |
apps/server/scripts/migrate-dev-db.ts+1-1| ... | @@ -476,7 +476,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( | ... | @@ -476,7 +476,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( |
| 476 | yield* Console.log("Running migrations on the snapshot..."); | 476 | yield* Console.log("Running migrations on the snapshot..."); |
| 477 | const executed = yield* Effect.gen(function* () { | 477 | const executed = yield* Effect.gen(function* () { |
| 478 | const sql = yield* SqlClient.SqlClient; | 478 | const sql = yield* SqlClient.SqlClient; |
| 479 | // Mirror server boot (persistence/Layers/Sqlite.ts). | 479 | // Mirror server boot (persistence/Sqlite.ts). |
| 480 | yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; | 480 | yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared; |
| 481 | return yield* runMigrations(); | 481 | return yield* runMigrations(); |
| 482 | }).pipe( | 482 | }).pipe( |
apps/server/scripts/record-codex-app-server-replay-fixture.ts+1-1| ... | @@ -2,7 +2,7 @@ import * as NodeOS from "node:os"; | ... | @@ -2,7 +2,7 @@ import * as NodeOS from "node:os"; |
| 2 | 2 | ||
| 3 | import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts"; | 3 | import { CODEX_THREAD_CONFIG } from "../src/orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 4 | import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts"; | 4 | import { revertCodexThread } from "../src/provider/CodexThreadRevert.ts"; |
| 5 | import { buildCodexInitializeParams } from "../src/provider/Layers/CodexProvider.ts"; | 5 | import { buildCodexInitializeParams } from "../src/provider/CodexProvider.ts"; |
| 6 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 6 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 8 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
apps/server/src/auth/EnvironmentAuth.test.ts+1-1| ... | @@ -10,7 +10,7 @@ import * as SqlClient from "effect/sql/SqlClient"; | ... | @@ -10,7 +10,7 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 10 | import * as ServerConfig from "../config.ts"; | 10 | import * as ServerConfig from "../config.ts"; |
| 11 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 11 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 12 | import * as PersistenceErrors from "../persistence/Errors.ts"; | 12 | import * as PersistenceErrors from "../persistence/Errors.ts"; |
| 13 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 13 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 14 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 14 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 15 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 15 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 16 | 16 |
apps/server/src/auth/EnvironmentAuth.ts+1-1| ... | @@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; | ... | @@ -38,7 +38,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 38 | import * as SessionStore from "./SessionStore.ts"; | 38 | import * as SessionStore from "./SessionStore.ts"; |
| 39 | import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; | 39 | import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; |
| 40 | import { verifyRequestDpopProof } from "./dpop.ts"; | 40 | import { verifyRequestDpopProof } from "./dpop.ts"; |
| 41 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 41 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 42 | 42 | ||
| 43 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | 43 | const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; |
| 44 | export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; | 44 | export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+1-1| ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import * as ServerConfig from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 8 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 8 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 9 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 9 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 10 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 10 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 11 | import * as SessionStore from "./SessionStore.ts"; | 11 | import * as SessionStore from "./SessionStore.ts"; |
apps/server/src/auth/PairingGrantStore.test.ts+1-1| ... | @@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 10 | import * as ServerConfig from "../config.ts"; | 10 | import * as ServerConfig from "../config.ts"; |
| 11 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; | 11 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; |
| 12 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | 12 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 13 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 13 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 14 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 14 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 15 | 15 | ||
| 16 | const layerServerConfig = ( | 16 | const layerServerConfig = ( |
apps/server/src/auth/SessionStore.test.ts+1-1| ... | @@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -16,7 +16,7 @@ import * as ServerConfig from "../config.ts"; |
| 16 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 16 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 17 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | 17 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 18 | 18 | ||
| 19 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 19 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 20 | import * as AuthSessions from "../persistence/AuthSessions.ts"; | 20 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 21 | import * as SessionStore from "./SessionStore.ts"; | 21 | import * as SessionStore from "./SessionStore.ts"; |
| 22 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 22 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
apps/server/src/auth/http.test.ts+1-1| ... | @@ -15,7 +15,7 @@ import * as HttpRouter from "effect/http/HttpRouter"; | ... | @@ -15,7 +15,7 @@ import * as HttpRouter from "effect/http/HttpRouter"; |
| 15 | 15 | ||
| 16 | import * as ServerConfig from "../config.ts"; | 16 | import * as ServerConfig from "../config.ts"; |
| 17 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 17 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 18 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 18 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 19 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 19 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 20 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 20 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 21 | import * as AuthHttp from "./http.ts"; | 21 | import * as AuthHttp from "./http.ts"; |
apps/server/src/cli/project.test.ts+1-1| ... | @@ -31,7 +31,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; | ... | @@ -31,7 +31,7 @@ import * as EventSink from "../orchestration-v2/EventSink.ts"; |
| 31 | import * as EventStore from "../orchestration-v2/EventStore.ts"; | 31 | import * as EventStore from "../orchestration-v2/EventStore.ts"; |
| 32 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | 32 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 33 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 33 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 34 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 34 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 35 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 35 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 36 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 36 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 37 | import * as ProjectService from "../project/ProjectService.ts"; | 37 | import * as ProjectService from "../project/ProjectService.ts"; |
apps/server/src/cli/project.ts+1-1| ... | @@ -24,7 +24,7 @@ import * as HttpApiClient from "effect/http-api/HttpApiClient"; | ... | @@ -24,7 +24,7 @@ import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 24 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | 24 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 25 | 25 | ||
| 26 | import * as ServerConfig from "../config.ts"; | 26 | import * as ServerConfig from "../config.ts"; |
| 27 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 27 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 28 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 28 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 29 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 29 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 30 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 30 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
apps/server/src/git/GitManager.test.ts+2-2| ... | @@ -53,9 +53,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr | ... | @@ -53,9 +53,9 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr |
| 53 | import * as ServerConfig from "../config.ts"; | 53 | import * as ServerConfig from "../config.ts"; |
| 54 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | 54 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 55 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | 55 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 56 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 56 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 57 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 57 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 58 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 58 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 59 | import * as ServerSettings from "../serverSettings.ts"; | 59 | import * as ServerSettings from "../serverSettings.ts"; |
| 60 | import * as GitManager from "./GitManager.ts"; | 60 | import * as GitManager from "./GitManager.ts"; |
| 61 | 61 |
apps/server/src/git/GitManager.ts+1-1| ... | @@ -66,7 +66,7 @@ import { | ... | @@ -66,7 +66,7 @@ import { |
| 66 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | 66 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 67 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | 67 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 68 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 68 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 69 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 69 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 70 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; | 70 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; |
| 71 | import { detachStackFrame } from "./detachStackFrame.ts"; | 71 | import { detachStackFrame } from "./detachStackFrame.ts"; |
| 72 | import * as ServerSettings from "../serverSettings.ts"; | 72 | import * as ServerSettings from "../serverSettings.ts"; |
apps/server/src/mcp/OrchestratorMcpService.activity.test.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | ... | @@ -17,7 +17,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 17 | import { expect, it } from "vite-plus/test"; | 17 | import { expect, it } from "vite-plus/test"; |
| 18 | 18 | ||
| 19 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 19 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 20 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 20 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 21 | import * as ProjectService from "../project/ProjectService.ts"; | 21 | import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 23 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 23 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
apps/server/src/mcp/OrchestratorMcpService.test.ts+1-1| ... | @@ -20,7 +20,7 @@ import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts | ... | @@ -20,7 +20,7 @@ import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts |
| 20 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; | 20 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; |
| 21 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; | 21 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 23 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 23 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 24 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; | 24 | import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; |
| 25 | import * as ProjectService from "../project/ProjectService.ts"; | 25 | import * as ProjectService from "../project/ProjectService.ts"; |
| 26 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 26 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
apps/server/src/mcp/OrchestratorMcpService.ts+1-1| ... | @@ -76,7 +76,7 @@ import { | ... | @@ -76,7 +76,7 @@ import { |
| 76 | } from "../orchestration-v2/SubagentProjection.ts"; | 76 | } from "../orchestration-v2/SubagentProjection.ts"; |
| 77 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 77 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 78 | import * as ProjectService from "../project/ProjectService.ts"; | 78 | import * as ProjectService from "../project/ProjectService.ts"; |
| 79 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 79 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 80 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 80 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 81 | import { | 81 | import { |
| 82 | type McpInvocationScope, | 82 | type McpInvocationScope, |
apps/server/src/mcp/toolkits/core.test.ts+1-1| ... | @@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator | ... | @@ -22,7 +22,7 @@ import { OrchestratorProjectionError } from "../../orchestration-v2/Orchestrator |
| 22 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; | 22 | import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 23 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; | 23 | import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; |
| 24 | import * as ProjectService from "../../project/ProjectService.ts"; | 24 | import * as ProjectService from "../../project/ProjectService.ts"; |
| 25 | import * as ProviderRegistry from "../../provider/Services/ProviderRegistry.ts"; | 25 | import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; |
| 26 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; | 26 | import * as SecretRequests from "../../secrets/SecretRequests.ts"; |
| 27 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; | 27 | import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; |
| 28 | import * as McpHttpServer from "../McpHttpServer.ts"; | 28 | import * as McpHttpServer from "../McpHttpServer.ts"; |
apps/server/src/mcp/toolkits/worktree/registration.test.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap | ... | @@ -17,7 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap |
| 17 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; | 17 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 18 | import * as ProjectService from "../../../project/ProjectService.ts"; | 18 | import * as ProjectService from "../../../project/ProjectService.ts"; |
| 19 | import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; | 19 | import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; |
| 20 | import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts"; | 20 | import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; |
| 21 | import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; | 21 | import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; |
| 22 | import * as SecretRequests from "../../../secrets/SecretRequests.ts"; | 22 | import * as SecretRequests from "../../../secrets/SecretRequests.ts"; |
| 23 | import * as ServerSettings from "../../../serverSettings.ts"; | 23 | import * as ServerSettings from "../../../serverSettings.ts"; |
apps/server/src/observability/Layers/Observability.ts deleted-102| ... | @@ -1,102 +0,0 @@ | ||
| 1 | import * as HttpObservability from "@t3tools/shared/httpObservability"; | ||
| 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | ||
| 3 | import * as SharedObservability from "@t3tools/shared/observability"; | ||
| 4 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as References from "effect/References"; | ||
| 8 | import * as Tracer from "effect/Tracer"; | ||
| 9 | import * as OtlpExporter from "effect/observability/OtlpExporter"; | ||
| 10 | import * as OtlpMetrics from "effect/observability/OtlpMetrics"; | ||
| 11 | import * as OtlpTracer from "effect/observability/OtlpTracer"; | ||
| 12 | |||
| 13 | import * as ServerConfig from "../../config.ts"; | ||
| 14 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 15 | import * as ServerLogger from "../../serverLogger.ts"; | ||
| 16 | import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; | ||
| 17 | |||
| 18 | export const layer = Layer.unwrap( | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const config = yield* ServerConfig.ServerConfig; | ||
| 21 | |||
| 22 | const traces = config.otlpTracesExport; | ||
| 23 | const metrics = config.otlpMetricsExport; | ||
| 24 | // The trace serializer stays in the returned context because the browser | ||
| 25 | // trace forwarder exports on the same signal. | ||
| 26 | const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); | ||
| 27 | const resource = ServerConfig.otlpResource(config); | ||
| 28 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 29 | |||
| 30 | const layerTraceReferences = Layer.mergeAll( | ||
| 31 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), | ||
| 32 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), | ||
| 33 | HttpObservability.layer, | ||
| 34 | ); | ||
| 35 | |||
| 36 | const layerTracer = Layer.unwrap( | ||
| 37 | Effect.gen(function* () { | ||
| 38 | const sink = yield* makeTraceSink({ | ||
| 39 | filePath: config.serverTracePath, | ||
| 40 | maxBytes: config.traceMaxBytes, | ||
| 41 | maxFiles: config.traceMaxFiles, | ||
| 42 | batchWindowMs: config.traceBatchWindowMs, | ||
| 43 | onFlush: (stats) => | ||
| 44 | attribution.record({ | ||
| 45 | component: "server-trace", | ||
| 46 | operation: "append", | ||
| 47 | logicalWriteBytes: stats.logicalWriteBytes, | ||
| 48 | count: stats.count, | ||
| 49 | durationMs: stats.durationMs, | ||
| 50 | }), | ||
| 51 | }); | ||
| 52 | const delegate = | ||
| 53 | config.otlpTracesUrl === undefined | ||
| 54 | ? undefined | ||
| 55 | : yield* OtlpTracer.make({ | ||
| 56 | url: config.otlpTracesUrl, | ||
| 57 | exportInterval: `${traces.exportIntervalMs} millis`, | ||
| 58 | headers: traces.headers, | ||
| 59 | resource, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const tracer = yield* makeLocalFileTracer({ | ||
| 63 | filePath: config.serverTracePath, | ||
| 64 | maxBytes: config.traceMaxBytes, | ||
| 65 | maxFiles: config.traceMaxFiles, | ||
| 66 | batchWindowMs: config.traceBatchWindowMs, | ||
| 67 | sink, | ||
| 68 | ...(delegate ? { delegate } : {}), | ||
| 69 | }); | ||
| 70 | |||
| 71 | return Layer.mergeAll( | ||
| 72 | Layer.succeed(Tracer.Tracer, tracer), | ||
| 73 | BrowserTraceCollector.layer(sink), | ||
| 74 | ); | ||
| 75 | }), | ||
| 76 | ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization)); | ||
| 77 | |||
| 78 | const metricsLayer = | ||
| 79 | config.otlpMetricsUrl === undefined | ||
| 80 | ? Layer.empty | ||
| 81 | : OtlpMetrics.layer({ | ||
| 82 | url: config.otlpMetricsUrl, | ||
| 83 | exportInterval: `${metrics.exportIntervalMs} millis`, | ||
| 84 | headers: metrics.headers, | ||
| 85 | resource, | ||
| 86 | }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol))); | ||
| 87 | |||
| 88 | // Logged once the server's loggers are installed, so the warnings use them. | ||
| 89 | const layerOtelWarnings = Layer.effectDiscard( | ||
| 90 | Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), | ||
| 91 | ); | ||
| 92 | |||
| 93 | return layerOtelWarnings.pipe( | ||
| 94 | Layer.provideMerge( | ||
| 95 | Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer), | ||
| 96 | ), | ||
| 97 | Layer.provide( | ||
| 98 | OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | }), | ||
| 102 | ); | ||
apps/server/src/observability/Observability.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import * as HttpObservability from "@t3tools/shared/httpObservability"; | ||
| 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | ||
| 3 | import * as SharedObservability from "@t3tools/shared/observability"; | ||
| 4 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as References from "effect/References"; | ||
| 8 | import * as Tracer from "effect/Tracer"; | ||
| 9 | import * as OtlpExporter from "effect/observability/OtlpExporter"; | ||
| 10 | import * as OtlpMetrics from "effect/observability/OtlpMetrics"; | ||
| 11 | import * as OtlpTracer from "effect/observability/OtlpTracer"; | ||
| 12 | |||
| 13 | import * as ServerConfig from "../config.ts"; | ||
| 14 | import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; | ||
| 15 | import * as ServerLogger from "../serverLogger.ts"; | ||
| 16 | import * as BrowserTraceCollector from "./BrowserTraceCollector.ts"; | ||
| 17 | |||
| 18 | export const layer = Layer.unwrap( | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const config = yield* ServerConfig.ServerConfig; | ||
| 21 | |||
| 22 | const traces = config.otlpTracesExport; | ||
| 23 | const metrics = config.otlpMetricsExport; | ||
| 24 | // The trace serializer stays in the returned context because the browser | ||
| 25 | // trace forwarder exports on the same signal. | ||
| 26 | const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); | ||
| 27 | const resource = ServerConfig.otlpResource(config); | ||
| 28 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 29 | |||
| 30 | const layerTraceReferences = Layer.mergeAll( | ||
| 31 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), | ||
| 32 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), | ||
| 33 | HttpObservability.layer, | ||
| 34 | ); | ||
| 35 | |||
| 36 | const layerTracer = Layer.unwrap( | ||
| 37 | Effect.gen(function* () { | ||
| 38 | const sink = yield* makeTraceSink({ | ||
| 39 | filePath: config.serverTracePath, | ||
| 40 | maxBytes: config.traceMaxBytes, | ||
| 41 | maxFiles: config.traceMaxFiles, | ||
| 42 | batchWindowMs: config.traceBatchWindowMs, | ||
| 43 | onFlush: (stats) => | ||
| 44 | attribution.record({ | ||
| 45 | component: "server-trace", | ||
| 46 | operation: "append", | ||
| 47 | logicalWriteBytes: stats.logicalWriteBytes, | ||
| 48 | count: stats.count, | ||
| 49 | durationMs: stats.durationMs, | ||
| 50 | }), | ||
| 51 | }); | ||
| 52 | const delegate = | ||
| 53 | config.otlpTracesUrl === undefined | ||
| 54 | ? undefined | ||
| 55 | : yield* OtlpTracer.make({ | ||
| 56 | url: config.otlpTracesUrl, | ||
| 57 | exportInterval: `${traces.exportIntervalMs} millis`, | ||
| 58 | headers: traces.headers, | ||
| 59 | resource, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const tracer = yield* makeLocalFileTracer({ | ||
| 63 | filePath: config.serverTracePath, | ||
| 64 | maxBytes: config.traceMaxBytes, | ||
| 65 | maxFiles: config.traceMaxFiles, | ||
| 66 | batchWindowMs: config.traceBatchWindowMs, | ||
| 67 | sink, | ||
| 68 | ...(delegate ? { delegate } : {}), | ||
| 69 | }); | ||
| 70 | |||
| 71 | return Layer.mergeAll( | ||
| 72 | Layer.succeed(Tracer.Tracer, tracer), | ||
| 73 | BrowserTraceCollector.layer(sink), | ||
| 74 | ); | ||
| 75 | }), | ||
| 76 | ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(layerSerialization)); | ||
| 77 | |||
| 78 | const metricsLayer = | ||
| 79 | config.otlpMetricsUrl === undefined | ||
| 80 | ? Layer.empty | ||
| 81 | : OtlpMetrics.layer({ | ||
| 82 | url: config.otlpMetricsUrl, | ||
| 83 | exportInterval: `${metrics.exportIntervalMs} millis`, | ||
| 84 | headers: metrics.headers, | ||
| 85 | resource, | ||
| 86 | }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol))); | ||
| 87 | |||
| 88 | // Logged once the server's loggers are installed, so the warnings use them. | ||
| 89 | const layerOtelWarnings = Layer.effectDiscard( | ||
| 90 | Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), | ||
| 91 | ); | ||
| 92 | |||
| 93 | return layerOtelWarnings.pipe( | ||
| 94 | Layer.provideMerge( | ||
| 95 | Layer.mergeAll(ServerLogger.layer, layerTraceReferences, layerTracer, metricsLayer), | ||
| 96 | ), | ||
| 97 | Layer.provide( | ||
| 98 | OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), | ||
| 99 | ), | ||
| 100 | ); | ||
| 101 | }), | ||
| 102 | ); | ||
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+4-4| ... | @@ -15,7 +15,7 @@ import { | ... | @@ -15,7 +15,7 @@ import { |
| 15 | import * as Console from "effect/Console"; | 15 | import * as Console from "effect/Console"; |
| 16 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; | 18 | import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 19 | import { FetchHttpClient } from "effect/http"; | 19 | import { FetchHttpClient } from "effect/http"; |
| 20 | import { describe } from "vite-plus/test"; | 20 | import { describe } from "vite-plus/test"; |
| 21 | 21 | ||
| ... | @@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | ... | @@ -23,13 +23,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 23 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 23 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 24 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; | 24 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 25 | import * as ServerConfig from "../config.ts"; | 25 | import * as ServerConfig from "../config.ts"; |
| 26 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 26 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 27 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; | 27 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 28 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; | 28 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 29 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 29 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 30 | import * as ModelManifest from "../provider/ModelManifest.ts"; | 30 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 31 | import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | 31 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 32 | import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; | 32 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 33 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | 33 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 34 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | 34 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; |
| 35 | import * as ServerSettings from "../serverSettings.ts"; | 35 | import * as ServerSettings from "../serverSettings.ts"; |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+1-1| ... | @@ -31,7 +31,7 @@ import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; | ... | @@ -31,7 +31,7 @@ import * as AcpRegistrySupport from "../../provider/acp/AcpRegistrySupport.ts"; |
| 31 | import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; | 31 | import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; |
| 32 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 32 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 33 | import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; | 33 | import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; |
| 34 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 34 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 35 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 35 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 36 | import * as IdAllocator from "../IdAllocator.ts"; | 36 | import * as IdAllocator from "../IdAllocator.ts"; |
| 37 | import { makeProviderFailure } from "../ProviderFailure.ts"; | 37 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+1-1| ... | @@ -59,7 +59,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; | ... | @@ -59,7 +59,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; |
| 59 | import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; | 59 | import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; |
| 60 | import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; | 60 | import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; |
| 61 | import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; | 61 | import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; |
| 62 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 62 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; |
| 63 | import { | 63 | import { |
| 64 | ProviderAdapterV2RuntimePolicy, | 64 | ProviderAdapterV2RuntimePolicy, |
| 65 | type ProviderAdapterV2Event, | 65 | type ProviderAdapterV2Event, |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+4-4| ... | @@ -106,13 +106,13 @@ import { | ... | @@ -106,13 +106,13 @@ import { |
| 106 | boundProviderEventForLogging, | 106 | boundProviderEventForLogging, |
| 107 | type EventNdjsonLogger, | 107 | type EventNdjsonLogger, |
| 108 | shouldPersistProviderEvent, | 108 | shouldPersistProviderEvent, |
| 109 | } from "../../provider/Layers/EventNdjsonLogger.ts"; | 109 | } from "../../provider/EventNdjsonLogger.ts"; |
| 110 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 110 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 111 | import { | 111 | import { |
| 112 | claudeRateLimitEventToUpdate, | 112 | claudeRateLimitEventToUpdate, |
| 113 | type ClaudeScopedLimitNames, | 113 | type ClaudeScopedLimitNames, |
| 114 | } from "../../provider/Layers/claudeUsageLimits.ts"; | 114 | } from "../../provider/claudeUsageLimits.ts"; |
| 115 | import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts"; | 115 | import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; |
| 116 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 116 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 117 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; | 117 | import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts"; |
| 118 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; | 118 | import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts"; |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+2-2| ... | @@ -51,8 +51,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ... | @@ -51,8 +51,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 51 | import packageJson from "../../../package.json" with { type: "json" }; | 51 | import packageJson from "../../../package.json" with { type: "json" }; |
| 52 | import * as ServerConfig from "../../config.ts"; | 52 | import * as ServerConfig from "../../config.ts"; |
| 53 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 53 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 54 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 54 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; |
| 55 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 55 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 56 | import * as IdAllocator from "../IdAllocator.ts"; | 56 | import * as IdAllocator from "../IdAllocator.ts"; |
| 57 | import * as EffectWorker from "../EffectWorker.ts"; | 57 | import * as EffectWorker from "../EffectWorker.ts"; |
| 58 | import * as Orchestrator from "../Orchestrator.ts"; | 58 | import * as Orchestrator from "../Orchestrator.ts"; |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+6-9| ... | @@ -12,15 +12,15 @@ import { | ... | @@ -12,15 +12,15 @@ import { |
| 12 | completeCodexTurnTokenUsage, | 12 | completeCodexTurnTokenUsage, |
| 13 | type CodexTurnTokenUsageState, | 13 | type CodexTurnTokenUsageState, |
| 14 | } from "../../provider/CodexTurnTokenUsage.ts"; | 14 | } from "../../provider/CodexTurnTokenUsage.ts"; |
| 15 | import type { ServerProviderShape } from "../../provider/Services/ServerProvider.ts"; | 15 | import type { ServerProviderShape } from "../../provider/ServerProvider.ts"; |
| 16 | import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; | 16 | import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; |
| 17 | import { buildCodexInitializeParams } from "../../provider/Layers/CodexProvider.ts"; | 17 | import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts"; |
| 18 | import { | 18 | import { |
| 19 | codexRateLimitsToUpdate, | 19 | codexRateLimitsToUpdate, |
| 20 | mergeCodexRateLimits, | 20 | mergeCodexRateLimits, |
| 21 | codexUsageLimitResetAt, | 21 | codexUsageLimitResetAt, |
| 22 | type CodexRateLimitSnapshot, | 22 | type CodexRateLimitSnapshot, |
| 23 | } from "../../provider/Layers/codexUsageLimits.ts"; | 23 | } from "../../provider/codexUsageLimits.ts"; |
| 24 | import { | 24 | import { |
| 25 | CodexSettings, | 25 | CodexSettings, |
| 26 | defaultInstanceIdForDriver, | 26 | defaultInstanceIdForDriver, |
| ... | @@ -101,12 +101,9 @@ import { | ... | @@ -101,12 +101,9 @@ import { |
| 101 | boundProviderEventForLogging, | 101 | boundProviderEventForLogging, |
| 102 | type EventNdjsonLogger, | 102 | type EventNdjsonLogger, |
| 103 | shouldPersistProviderEvent, | 103 | shouldPersistProviderEvent, |
| 104 | } from "../../provider/Layers/EventNdjsonLogger.ts"; | 104 | } from "../../provider/EventNdjsonLogger.ts"; |
| 105 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; | 105 | import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; |
| 106 | import { | 106 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; |
| 107 | codexAppServerArgs, | ||
| 108 | resolveCodexLaunchArgs, | ||
| 109 | } from "../../provider/Layers/codexLaunchArgs.ts"; | ||
| 110 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 107 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 111 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 108 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 112 | import { | 109 | import { |
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts+1-1| ... | @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import { vi } from "vite-plus/test"; | 6 | import { vi } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 8 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 10 | 10 | ||
| 11 | const cursorSdkMock = vi.hoisted(() => { | 11 | const cursorSdkMock = vi.hoisted(() => { |
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts+2-2| ... | @@ -17,8 +17,8 @@ import * as Layer from "effect/Layer"; | ... | @@ -17,8 +17,8 @@ import * as Layer from "effect/Layer"; |
| 17 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 18 | 18 | ||
| 19 | import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; | 19 | import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; |
| 20 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 20 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; |
| 21 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 21 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 22 | 22 | ||
| 23 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; | 23 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; |
| 24 | export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); | 24 | export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+2-2| ... | @@ -24,9 +24,9 @@ import type * as EffectAcpSchema from "effect-acp/compat"; | ... | @@ -24,9 +24,9 @@ import type * as EffectAcpSchema from "effect-acp/compat"; |
| 24 | 24 | ||
| 25 | import * as ServerConfig from "../../config.ts"; | 25 | import * as ServerConfig from "../../config.ts"; |
| 26 | import * as ProjectStore from "../ProjectStore.ts"; | 26 | import * as ProjectStore from "../ProjectStore.ts"; |
| 27 | import { buildInitialGrokProviderSnapshot } from "../../provider/Layers/GrokProvider.ts"; | 27 | import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; |
| 28 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; | 28 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; |
| 29 | import * as ProviderInstanceRegistry from "../../provider/Services/ProviderInstanceRegistry.ts"; | 29 | import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; |
| 30 | import * as IdAllocator from "../IdAllocator.ts"; | 30 | import * as IdAllocator from "../IdAllocator.ts"; |
| 31 | import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; | 31 | import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; |
| 32 | import * as RuntimePolicy from "../RuntimePolicy.ts"; | 32 | import * as RuntimePolicy from "../RuntimePolicy.ts"; |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+1-1| ... | @@ -56,7 +56,7 @@ import { | ... | @@ -56,7 +56,7 @@ import { |
| 56 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 56 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 57 | import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; | 57 | import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts"; |
| 58 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 58 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 59 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 59 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 60 | import * as IdAllocator from "../IdAllocator.ts"; | 60 | import * as IdAllocator from "../IdAllocator.ts"; |
| 61 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | 61 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; |
| 62 | import * as ProviderAdapter from "../ProviderAdapter.ts"; | 62 | import * as ProviderAdapter from "../ProviderAdapter.ts"; |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+1-1| ... | @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; |
| 32 | import * as TestClock from "effect/testing/TestClock"; | 32 | import * as TestClock from "effect/testing/TestClock"; |
| 33 | 33 | ||
| 34 | import * as ServerConfig from "../../config.ts"; | 34 | import * as ServerConfig from "../../config.ts"; |
| 35 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 35 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; |
| 36 | import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; | 36 | import type { OpenCodeRuntimeShape } from "../../provider/opencodeRuntime.ts"; |
| 37 | import * as IdAllocator from "../IdAllocator.ts"; | 37 | import * as IdAllocator from "../IdAllocator.ts"; |
| 38 | 38 |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+1-1| ... | @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -8,7 +8,7 @@ import * as Schema from "effect/Schema"; |
| 8 | 8 | ||
| 9 | import * as ServerConfig from "../../config.ts"; | 9 | import * as ServerConfig from "../../config.ts"; |
| 10 | import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; | 10 | import * as OpenCodeRuntime from "../../provider/opencodeRuntime.ts"; |
| 11 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 11 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 12 | import * as IdAllocator from "../IdAllocator.ts"; | 12 | import * as IdAllocator from "../IdAllocator.ts"; |
| 13 | import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; | 13 | import { ProviderAdapterDriverCreateError } from "../ProviderAdapterDriver.ts"; |
| 14 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 14 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+2-2| ... | @@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ... | @@ -58,8 +58,8 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 58 | import * as ServerConfig from "../../config.ts"; | 58 | import * as ServerConfig from "../../config.ts"; |
| 59 | import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; | 59 | import { mcpToolPresentation } from "../../provider/McpToolPresentation.ts"; |
| 60 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 60 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 61 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; | 61 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; |
| 62 | import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts"; | 62 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; |
| 63 | import { | 63 | import { |
| 64 | structuralProtocolMethod, | 64 | structuralProtocolMethod, |
| 65 | summarizeNativeProtocolPayload, | 65 | summarizeNativeProtocolPayload, |
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+1-1| ... | @@ -21,7 +21,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -21,7 +21,7 @@ import * as Layer from "effect/Layer"; |
| 21 | import * as Ref from "effect/Ref"; | 21 | import * as Ref from "effect/Ref"; |
| 22 | import * as SqlClient from "effect/sql/SqlClient"; | 22 | import * as SqlClient from "effect/sql/SqlClient"; |
| 23 | 23 | ||
| 24 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | 25 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 26 | import { VcsProcessTimeoutError } from "@t3tools/contracts"; | 26 | import { VcsProcessTimeoutError } from "@t3tools/contracts"; |
| 27 | import * as CheckpointService from "./CheckpointService.ts"; | 27 | import * as CheckpointService from "./CheckpointService.ts"; |
apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts+1-1| ... | @@ -19,7 +19,7 @@ import * as IdAllocator from "./IdAllocator.ts"; | ... | @@ -19,7 +19,7 @@ import * as IdAllocator from "./IdAllocator.ts"; |
| 19 | import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts"; | 19 | import { checkpointRefForScopeOrdinal } from "./CheckpointService.ts"; |
| 20 | import * as ProjectionStore from "./ProjectionStore.ts"; | 20 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 21 | import * as ThreadManagement from "./ThreadManagementService.ts"; | 21 | import * as ThreadManagement from "./ThreadManagementService.ts"; |
| 22 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 22 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 23 | const layerProjection = Layer.mergeAll( | 23 | const layerProjection = Layer.mergeAll( |
| 24 | ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), | 24 | ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), |
| 25 | SqlitePersistence.layerMemory, | 25 | SqlitePersistence.layerMemory, |
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1-1| ... | @@ -26,7 +26,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -26,7 +26,7 @@ import * as Stream from "effect/Stream"; |
| 26 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; | 26 | import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; |
| 27 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 27 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 28 | import * as Scheduler from "../scheduling/Scheduler.ts"; | 28 | import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 29 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 29 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 30 | import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; | 30 | import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts"; |
| 31 | import * as EffectWorker from "./EffectWorker.ts"; | 31 | import * as EffectWorker from "./EffectWorker.ts"; |
| 32 | import * as EventSink from "./EventSink.ts"; | 32 | import * as EventSink from "./EventSink.ts"; |
apps/server/src/orchestration-v2/CommandReceiptStore.ts+2-4| ... | @@ -5,9 +5,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,9 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | 8 | import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; | |
| 9 | import * as OrchestrationCommandReceiptsLayer from "../persistence/Layers/OrchestrationCommandReceipts.ts"; | ||
| 10 | import * as OrchestrationCommandReceipts from "../persistence/Services/OrchestrationCommandReceipts.ts"; | ||
| 11 | 9 | ||
| 12 | /** | 10 | /** |
| 13 | * ERRORS | 11 | * ERRORS |
| ... | @@ -209,6 +207,6 @@ const layerBase: Layer.Layer< | ... | @@ -209,6 +207,6 @@ const layerBase: Layer.Layer< |
| 209 | }), | 207 | }), |
| 210 | ); | 208 | ); |
| 211 | 209 | ||
| 212 | export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceiptsLayer.layer)); | 210 | export const layer = layerBase.pipe(Layer.provide(OrchestrationCommandReceipts.layer)); |
| 213 | 211 | ||
| 214 | export const layerFromApplicationReceipts = layerBase; | 212 | export const layerFromApplicationReceipts = layerBase; |
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+4-4| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | import * as Console from "effect/Console"; | 13 | import * as Console from "effect/Console"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; | 16 | import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 17 | import { FetchHttpClient } from "effect/http"; | 17 | import { FetchHttpClient } from "effect/http"; |
| 18 | import { describe } from "vite-plus/test"; | 18 | import { describe } from "vite-plus/test"; |
| 19 | 19 | ||
| ... | @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | ... | @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 21 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 21 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 22 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; | 22 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 23 | import * as ServerConfig from "../config.ts"; | 23 | import * as ServerConfig from "../config.ts"; |
| 24 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; | 25 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 26 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; | 26 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 28 | import * as ModelManifest from "../provider/ModelManifest.ts"; | 28 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 29 | import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | 29 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 30 | import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; | 30 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 31 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | 31 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 32 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | 32 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | 33 | import * as ServerSettings from "../serverSettings.ts"; |
apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts+2-2| ... | @@ -23,11 +23,11 @@ import * as Stream from "effect/Stream"; | ... | @@ -23,11 +23,11 @@ import * as Stream from "effect/Stream"; |
| 23 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | 23 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 24 | import * as ServerConfig from "../config.ts"; | 24 | import * as ServerConfig from "../config.ts"; |
| 25 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; | 25 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; |
| 26 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 26 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 27 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 27 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 28 | import * as ProjectService from "../project/ProjectService.ts"; | 28 | import * as ProjectService from "../project/ProjectService.ts"; |
| 29 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 29 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 30 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 30 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 31 | import * as ServerSettings from "../serverSettings.ts"; | 31 | import * as ServerSettings from "../serverSettings.ts"; |
| 32 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 32 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 33 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 33 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/EventSink.ts+1-1| ... | @@ -23,7 +23,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -23,7 +23,7 @@ import * as Stream from "effect/Stream"; |
| 23 | import * as SqlClient from "effect/sql/SqlClient"; | 23 | import * as SqlClient from "effect/sql/SqlClient"; |
| 24 | 24 | ||
| 25 | import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; | 25 | import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; |
| 26 | import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts"; | 26 | import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; |
| 27 | import { projectDomainEventForWire } from "./WireProjection.ts"; | 27 | import { projectDomainEventForWire } from "./WireProjection.ts"; |
| 28 | 28 | ||
| 29 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | 29 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
apps/server/src/orchestration-v2/EventStore.ts+2-4| ... | @@ -12,9 +12,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -12,9 +12,7 @@ import * as Layer from "effect/Layer"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 14 | import type * as SqlClient from "effect/sql/SqlClient"; | 14 | import type * as SqlClient from "effect/sql/SqlClient"; |
| 15 | 15 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | |
| 16 | import * as OrchestrationEventStoreLayer from "../persistence/Layers/OrchestrationEventStore.ts"; | ||
| 17 | import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; | ||
| 18 | 16 | ||
| 19 | export class EventStoreAppendEventsError extends Schema.TaggedError<EventStoreAppendEventsError>()( | 17 | export class EventStoreAppendEventsError extends Schema.TaggedError<EventStoreAppendEventsError>()( |
| 20 | "EventStoreAppendEventsError", | 18 | "EventStoreAppendEventsError", |
| ... | @@ -150,7 +148,7 @@ const layerBase: Layer.Layer<EventStoreV2, never, OrchestrationEventStore.Orches | ... | @@ -150,7 +148,7 @@ const layerBase: Layer.Layer<EventStoreV2, never, OrchestrationEventStore.Orches |
| 150 | ); | 148 | ); |
| 151 | 149 | ||
| 152 | export const layer: Layer.Layer<EventStoreV2, never, SqlClient.SqlClient> = layerBase.pipe( | 150 | export const layer: Layer.Layer<EventStoreV2, never, SqlClient.SqlClient> = layerBase.pipe( |
| 153 | Layer.provide(OrchestrationEventStoreLayer.layer), | 151 | Layer.provide(OrchestrationEventStore.layer), |
| 154 | ); | 152 | ); |
| 155 | 153 | ||
| 156 | export const layerFromOrchestrationEventStore = layerBase; | 154 | export const layerFromOrchestrationEventStore = layerBase; |
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+1-1| ... | @@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient"; | ... | @@ -43,7 +43,7 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 43 | import * as Statement from "effect/sql/Statement"; | 43 | import * as Statement from "effect/sql/Statement"; |
| 44 | 44 | ||
| 45 | import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts"; | 45 | import { LIVE_STREAM_MAX_ITEMS, LiveStreamBufferError } from "./LiveStreamBudget.ts"; |
| 46 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 46 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 47 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 47 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 48 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | 48 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
| 49 | import * as EffectOutbox from "./EffectOutbox.ts"; | 49 | import * as EffectOutbox from "./EffectOutbox.ts"; |
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+4-4| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | import * as Console from "effect/Console"; | 13 | import * as Console from "effect/Console"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; | 16 | import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 17 | import { FetchHttpClient } from "effect/http"; | 17 | import { FetchHttpClient } from "effect/http"; |
| 18 | import { describe } from "vite-plus/test"; | 18 | import { describe } from "vite-plus/test"; |
| 19 | 19 | ||
| ... | @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | ... | @@ -21,13 +21,13 @@ import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 21 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 21 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 22 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; | 22 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 23 | import * as ServerConfig from "../config.ts"; | 23 | import * as ServerConfig from "../config.ts"; |
| 24 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; | 25 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 26 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; | 26 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 28 | import * as ModelManifest from "../provider/ModelManifest.ts"; | 28 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 29 | import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | 29 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 30 | import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; | 30 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 31 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | 31 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 32 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | 32 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | 33 | import * as ServerSettings from "../serverSettings.ts"; |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+5-5| ... | @@ -43,18 +43,18 @@ import { FetchHttpClient, HttpClient } from "effect/http"; | ... | @@ -43,18 +43,18 @@ import { FetchHttpClient, HttpClient } from "effect/http"; |
| 43 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | 43 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 44 | import { describe } from "vite-plus/test"; | 44 | import { describe } from "vite-plus/test"; |
| 45 | 45 | ||
| 46 | import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; | 46 | import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 47 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | 47 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 48 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 48 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 49 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; | 49 | import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 50 | import * as ServerConfig from "../config.ts"; | 50 | import * as ServerConfig from "../config.ts"; |
| 51 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 51 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 52 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; | 52 | import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 53 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; | 53 | import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 54 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | 54 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 55 | import * as ModelManifest from "../provider/ModelManifest.ts"; | 55 | import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 56 | import * as ProviderInstanceRegistryHydration from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | 56 | import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 57 | import * as ProviderEventLoggers from "../provider/Layers/ProviderEventLoggers.ts"; | 57 | import * as ProviderEventLoggers from "../provider/ProviderEventLoggers.ts"; |
| 58 | import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; | 58 | import * as OpenCode2Client from "../provider/opencode2/OpenCode2Client.ts"; |
| 59 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; | 59 | import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; |
| 60 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; | 60 | import * as OpenCodeServerLedger from "../provider/OpenCodeServerLedger.ts"; |
| ... | @@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ... | @@ -63,7 +63,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 63 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 63 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 64 | import * as EffectWorker from "./EffectWorker.ts"; | 64 | import * as EffectWorker from "./EffectWorker.ts"; |
| 65 | import * as Orchestrator from "./Orchestrator.ts"; | 65 | import * as Orchestrator from "./Orchestrator.ts"; |
| 66 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 66 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 67 | import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; | 67 | import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; |
| 68 | import * as RuntimeLayer from "./runtimeLayer.ts"; | 68 | import * as RuntimeLayer from "./runtimeLayer.ts"; |
| 69 | import * as IdAllocator from "./IdAllocator.ts"; | 69 | import * as IdAllocator from "./IdAllocator.ts"; |
apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts+1-1| ... | @@ -21,7 +21,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -21,7 +21,7 @@ import * as DateTime from "effect/DateTime"; |
| 21 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as Layer from "effect/Layer"; | 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as SqlClient from "effect/sql/SqlClient"; | 23 | import * as SqlClient from "effect/sql/SqlClient"; |
| 24 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 25 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 26 | import * as Orchestrator from "./Orchestrator.ts"; | 26 | import * as Orchestrator from "./Orchestrator.ts"; |
| 27 | import * as ProjectionStore from "./ProjectionStore.ts"; | 27 | import * as ProjectionStore from "./ProjectionStore.ts"; |
apps/server/src/orchestration-v2/ProjectCommands.ts+1-1| ... | @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; |
| 13 | import * as Result from "effect/Result"; | 13 | import * as Result from "effect/Result"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | 15 | ||
| 16 | import type { UnsequencedProjectEvent } from "../persistence/Services/OrchestrationEventStore.ts"; | 16 | import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; |
| 17 | import type { ProjectRow } from "./ProjectStore.ts"; | 17 | import type { ProjectRow } from "./ProjectStore.ts"; |
| 18 | 18 | ||
| 19 | export interface ProjectCreateCommand { | 19 | export interface ProjectCreateCommand { |
apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts+2-2| ... | @@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; | ... | @@ -15,11 +15,11 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 15 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | 15 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 16 | 16 | ||
| 17 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; | 17 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; |
| 18 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 18 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 19 | import { runMigrations } from "../persistence/Migrations.ts"; | 19 | import { runMigrations } from "../persistence/Migrations.ts"; |
| 20 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 20 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 21 | import * as ProjectService from "../project/ProjectService.ts"; | 21 | import * as ProjectService from "../project/ProjectService.ts"; |
| 22 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 22 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 24 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 25 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 25 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
apps/server/src/orchestration-v2/ProjectStore.test.ts+1-1| ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import * as SqlClient from "effect/sql/SqlClient"; | 6 | import * as SqlClient from "effect/sql/SqlClient"; |
| 7 | 7 | ||
| 8 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 8 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 9 | import * as ProjectStore from "./ProjectStore.ts"; | 9 | import * as ProjectStore from "./ProjectStore.ts"; |
| 10 | 10 | ||
| 11 | it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( | 11 | it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)))( |
apps/server/src/orchestration-v2/ProjectionControlReads.test.ts+1-1| ... | @@ -20,7 +20,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -20,7 +20,7 @@ import * as Effect from "effect/Effect"; |
| 20 | import * as Layer from "effect/Layer"; | 20 | import * as Layer from "effect/Layer"; |
| 21 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 22 | import * as SqlClient from "effect/sql/SqlClient"; | 22 | import * as SqlClient from "effect/sql/SqlClient"; |
| 23 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 23 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 24 | import * as ProjectionStore from "./ProjectionStore.ts"; | 24 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 25 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | 25 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| 26 | import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; | 26 | import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; |
apps/server/src/orchestration-v2/ProjectionRecovery.test.ts+1-1| ... | @@ -22,7 +22,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -22,7 +22,7 @@ import * as Effect from "effect/Effect"; |
| 22 | import * as Layer from "effect/Layer"; | 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as SqlClient from "effect/sql/SqlClient"; | 23 | import * as SqlClient from "effect/sql/SqlClient"; |
| 24 | 24 | ||
| 25 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 26 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 27 | import * as EffectOutbox from "./EffectOutbox.ts"; | 27 | import * as EffectOutbox from "./EffectOutbox.ts"; |
| 28 | import * as ProjectionStore from "./ProjectionStore.ts"; | 28 | import * as ProjectionStore from "./ProjectionStore.ts"; |
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+1-1| ... | @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -21,7 +21,7 @@ import * as Schema from "effect/Schema"; |
| 21 | import * as SqlClient from "effect/sql/SqlClient"; | 21 | import * as SqlClient from "effect/sql/SqlClient"; |
| 22 | import * as Statement from "effect/sql/Statement"; | 22 | import * as Statement from "effect/sql/Statement"; |
| 23 | 23 | ||
| 24 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 25 | import * as ProjectionStore from "./ProjectionStore.ts"; | 25 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 26 | import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; | 26 | import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; |
| 27 | 27 |
apps/server/src/orchestration-v2/ProjectionStore.test.ts+1-1| ... | @@ -30,7 +30,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -30,7 +30,7 @@ import * as Schema from "effect/Schema"; |
| 30 | import * as SqlClient from "effect/sql/SqlClient"; | 30 | import * as SqlClient from "effect/sql/SqlClient"; |
| 31 | import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; | 31 | import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; |
| 32 | 32 | ||
| 33 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 33 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 34 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 34 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 35 | import * as ProjectionStore from "./ProjectionStore.ts"; | 35 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 36 | import { | 36 | import { |
apps/server/src/orchestration-v2/ProviderAdapterRegistry.test.ts+2-2| ... | @@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope"; | ... | @@ -19,9 +19,9 @@ import type * as Scope from "effect/Scope"; |
| 19 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 20 | 20 | ||
| 21 | import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; | 21 | import * as ProviderAuthFlow from "../provider/ProviderAuthFlow.ts"; |
| 22 | import type { ProviderAuthController } from "../provider/Services/ProviderAuthService.ts"; | 22 | import type { ProviderAuthController } from "../provider/ProviderAuthService.ts"; |
| 23 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 23 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 24 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 24 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 25 | import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; | 25 | import { ProviderAdapterOpenSessionError, type ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; |
| 26 | import { | 26 | import { |
| 27 | ProviderAdapterDriverCreateError, | 27 | ProviderAdapterDriverCreateError, |
apps/server/src/orchestration-v2/ProviderAdapterRegistry.ts+1-1| ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; |
| 13 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 14 | import * as Scope from "effect/Scope"; | 14 | import * as Scope from "effect/Scope"; |
| 15 | 15 | ||
| 16 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 16 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 17 | import { | 17 | import { |
| 18 | ProviderAdapterDriverCreateError, | 18 | ProviderAdapterDriverCreateError, |
| 19 | type AnyProviderAdapterDriver, | 19 | type AnyProviderAdapterDriver, |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+1-1| ... | @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; |
| 27 | import * as Stream from "effect/Stream"; | 27 | import * as Stream from "effect/Stream"; |
| 28 | import * as TestClock from "effect/testing/TestClock"; | 28 | import * as TestClock from "effect/testing/TestClock"; |
| 29 | 29 | ||
| 30 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 31 | import * as EventSink from "./EventSink.ts"; | 31 | import * as EventSink from "./EventSink.ts"; |
| 32 | import * as EventStore from "./EventStore.ts"; | 32 | import * as EventStore from "./EventStore.ts"; |
| 33 | import * as IdAllocator from "./IdAllocator.ts"; | 33 | import * as IdAllocator from "./IdAllocator.ts"; |
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts+1-1| ... | @@ -25,7 +25,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -25,7 +25,7 @@ import * as Effect from "effect/Effect"; |
| 25 | import * as Layer from "effect/Layer"; | 25 | import * as Layer from "effect/Layer"; |
| 26 | import * as SqlClient from "effect/sql/SqlClient"; | 26 | import * as SqlClient from "effect/sql/SqlClient"; |
| 27 | 27 | ||
| 28 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 28 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 29 | import * as ServerSettings from "../serverSettings.ts"; | 29 | import * as ServerSettings from "../serverSettings.ts"; |
| 30 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 30 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 31 | import * as EffectOutbox from "./EffectOutbox.ts"; | 31 | import * as EffectOutbox from "./EffectOutbox.ts"; |
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+1-1| ... | @@ -36,7 +36,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ... | @@ -36,7 +36,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 36 | import * as ProjectService from "../project/ProjectService.ts"; | 36 | import * as ProjectService from "../project/ProjectService.ts"; |
| 37 | import * as McpProviderSession from "../mcp/McpProviderSession.ts"; | 37 | import * as McpProviderSession from "../mcp/McpProviderSession.ts"; |
| 38 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; | 38 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; |
| 39 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 39 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 40 | import * as ServerSettings from "../serverSettings.ts"; | 40 | import * as ServerSettings from "../serverSettings.ts"; |
| 41 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 41 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 42 | import * as EventSink from "./EventSink.ts"; | 42 | import * as EventSink from "./EventSink.ts"; |
apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts+1-1| ... | @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; |
| 26 | 26 | ||
| 27 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; | 27 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 28 | import * as ProjectService from "../project/ProjectService.ts"; | 28 | import * as ProjectService from "../project/ProjectService.ts"; |
| 29 | import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts"; | 29 | import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; |
| 30 | import * as ContextHandoffService from "./ContextHandoffService.ts"; | 30 | import * as ContextHandoffService from "./ContextHandoffService.ts"; |
| 31 | import * as EventSink from "./EventSink.ts"; | 31 | import * as EventSink from "./EventSink.ts"; |
| 32 | import * as IdAllocator from "./IdAllocator.ts"; | 32 | import * as IdAllocator from "./IdAllocator.ts"; |
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+1-1| ... | @@ -23,7 +23,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -23,7 +23,7 @@ import * as Schema from "effect/Schema"; |
| 23 | 23 | ||
| 24 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 24 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 25 | import * as ProjectService from "../project/ProjectService.ts"; | 25 | import * as ProjectService from "../project/ProjectService.ts"; |
| 26 | import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts"; | 26 | import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; |
| 27 | import * as EventSink from "./EventSink.ts"; | 27 | import * as EventSink from "./EventSink.ts"; |
| 28 | import * as ContextHandoffService from "./ContextHandoffService.ts"; | 28 | import * as ContextHandoffService from "./ContextHandoffService.ts"; |
| 29 | import { | 29 | import { |
apps/server/src/orchestration-v2/RunCompletionReads.test.ts+1-1| ... | @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; |
| 13 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as SqlClient from "effect/sql/SqlClient"; | 15 | import * as SqlClient from "effect/sql/SqlClient"; |
| 16 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 16 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 17 | import * as ProjectionStore from "./ProjectionStore.ts"; | 17 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 18 | 18 | ||
| 19 | const layerDatabase = Layer.mergeAll( | 19 | const layerDatabase = Layer.mergeAll( |
apps/server/src/orchestration-v2/RuntimePolicy.test.ts+1-1| ... | @@ -15,7 +15,7 @@ import * as Option from "effect/Option"; | ... | @@ -15,7 +15,7 @@ import * as Option from "effect/Option"; |
| 15 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 16 | 16 | ||
| 17 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 17 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 18 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 18 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 19 | import * as ProjectStore from "./ProjectStore.ts"; | 19 | import * as ProjectStore from "./ProjectStore.ts"; |
| 20 | import * as RuntimePolicy from "./RuntimePolicy.ts"; | 20 | import * as RuntimePolicy from "./RuntimePolicy.ts"; |
| 21 | 21 |
apps/server/src/orchestration-v2/RuntimePolicy.ts+1-1| ... | @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; |
| 11 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | ||
| 14 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 14 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 15 | import { | 15 | import { |
| 16 | ProviderAdapterV2RuntimePolicy, | 16 | ProviderAdapterV2RuntimePolicy, |
| 17 | type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, | 17 | type ProviderAdapterV2RuntimePolicy as ProviderAdapterV2RuntimePolicyType, |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+1-1| ... | @@ -40,7 +40,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -40,7 +40,7 @@ import * as Schema from "effect/Schema"; |
| 40 | import * as TestClock from "effect/testing/TestClock"; | 40 | import * as TestClock from "effect/testing/TestClock"; |
| 41 | 41 | ||
| 42 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; | 42 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 43 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 43 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 44 | import * as ProjectStore from "./ProjectStore.ts"; | 44 | import * as ProjectStore from "./ProjectStore.ts"; |
| 45 | import * as ProjectService from "../project/ProjectService.ts"; | 45 | import * as ProjectService from "../project/ProjectService.ts"; |
| 46 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 46 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
apps/server/src/orchestration-v2/ThreadLaunchService.ts+1-1| ... | @@ -39,7 +39,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; | ... | @@ -39,7 +39,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 39 | import * as ProjectService from "../project/ProjectService.ts"; | 39 | import * as ProjectService from "../project/ProjectService.ts"; |
| 40 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | 40 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; |
| 41 | import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; | 41 | import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; |
| 42 | import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; | 42 | import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; |
| 43 | import * as ServerSettings from "../serverSettings.ts"; | 43 | import * as ServerSettings from "../serverSettings.ts"; |
| 44 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 44 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 45 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; | 45 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
apps/server/src/orchestration-v2/ThreadSearch.test.ts+1-1| ... | @@ -12,7 +12,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -12,7 +12,7 @@ import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as SqlClient from "effect/sql/SqlClient"; | 13 | import * as SqlClient from "effect/sql/SqlClient"; |
| 14 | 14 | ||
| 15 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 15 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 16 | import * as ProjectionStore from "./ProjectionStore.ts"; | 16 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 17 | import * as ProjectStore from "./ProjectStore.ts"; | 17 | import * as ProjectStore from "./ProjectStore.ts"; |
| 18 | import * as ThreadSearch from "./ThreadSearch.ts"; | 18 | import * as ThreadSearch from "./ThreadSearch.ts"; |
apps/server/src/orchestration-v2/ThreadStop.test.ts+1-1| ... | @@ -19,7 +19,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -19,7 +19,7 @@ import * as Schema from "effect/Schema"; |
| 19 | import * as SqlClient from "effect/sql/SqlClient"; | 19 | import * as SqlClient from "effect/sql/SqlClient"; |
| 20 | import * as TestClock from "effect/testing/TestClock"; | 20 | import * as TestClock from "effect/testing/TestClock"; |
| 21 | 21 | ||
| 22 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 22 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 23 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 23 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 24 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; | 24 | import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; |
| 25 | import * as Orchestrator from "./Orchestrator.ts"; | 25 | import * as Orchestrator from "./Orchestrator.ts"; |
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts+1-1| ... | @@ -16,7 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -16,7 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 16 | import * as Layer from "effect/Layer"; | 16 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 18 | 18 | ||
| 19 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 19 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 20 | import * as ProjectStore from "./ProjectStore.ts"; | 20 | import * as ProjectStore from "./ProjectStore.ts"; |
| 21 | import * as ServerSettings from "../serverSettings.ts"; | 21 | import * as ServerSettings from "../serverSettings.ts"; |
| 22 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 22 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
apps/server/src/orchestration-v2/TurnStartReads.test.ts+1-1| ... | @@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -16,7 +16,7 @@ import * as DateTime from "effect/DateTime"; |
| 16 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as SqlClient from "effect/sql/SqlClient"; | 18 | import * as SqlClient from "effect/sql/SqlClient"; |
| 19 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 19 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 20 | import * as ProjectionStore from "./ProjectionStore.ts"; | 20 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 21 | 21 | ||
| 22 | const layerDatabase = Layer.mergeAll( | 22 | const layerDatabase = Layer.mergeAll( |
apps/server/src/orchestration-v2/http.ts+1-1| ... | @@ -17,7 +17,7 @@ import { | ... | @@ -17,7 +17,7 @@ import { |
| 17 | failEnvironmentNotFound, | 17 | failEnvironmentNotFound, |
| 18 | requireEnvironmentScope, | 18 | requireEnvironmentScope, |
| 19 | } from "../auth/http.ts"; | 19 | } from "../auth/http.ts"; |
| 20 | import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; | 20 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 21 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 21 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 22 | import { | 22 | import { |
| 23 | buildBoundedThreadProjection, | 23 | buildBoundedThreadProjection, |
apps/server/src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts+1-1| ... | @@ -27,7 +27,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -27,7 +27,7 @@ import * as Stream from "effect/Stream"; |
| 27 | import * as SqlClient from "effect/sql/SqlClient"; | 27 | import * as SqlClient from "effect/sql/SqlClient"; |
| 28 | 28 | ||
| 29 | import { runMigrations } from "../../persistence/Migrations.ts"; | 29 | import { runMigrations } from "../../persistence/Migrations.ts"; |
| 30 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 30 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 31 | import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts"; | 31 | import Migration0042 from "../../persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts"; |
| 32 | import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts"; | 32 | import Migration0043 from "../../persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts"; |
| 33 | import Migration0044 from "../../persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts"; | 33 | import Migration0044 from "../../persistence/Migrations/044_ClearAutomaticProjectModelDefaults.ts"; |
apps/server/src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts+1-1| ... | @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -6,7 +6,7 @@ import * as Layer from "effect/Layer"; |
| 6 | import * as Tracer from "effect/Tracer"; | 6 | import * as Tracer from "effect/Tracer"; |
| 7 | import * as SqlClient from "effect/sql/SqlClient"; | 7 | import * as SqlClient from "effect/sql/SqlClient"; |
| 8 | 8 | ||
| 9 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 9 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 10 | import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; | 10 | import { listLinkedPullRequestThreads } from "../../pullRequest/linkedThreads.ts"; |
| 11 | import * as EventSink from "../EventSink.ts"; | 11 | import * as EventSink from "../EventSink.ts"; |
| 12 | import * as EventStore from "../EventStore.ts"; | 12 | import * as EventStore from "../EventStore.ts"; |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+3-3| ... | @@ -43,13 +43,13 @@ import * as SqlClient from "effect/sql/SqlClient"; | ... | @@ -43,13 +43,13 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 43 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; | 43 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 44 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; | 44 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 45 | import * as ServerConfig from "../config.ts"; | 45 | import * as ServerConfig from "../config.ts"; |
| 46 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 46 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 47 | import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; | 47 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 48 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | 48 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; |
| 49 | import * as ProjectService from "../project/ProjectService.ts"; | 49 | import * as ProjectService from "../project/ProjectService.ts"; |
| 50 | import * as ServerSettings from "../serverSettings.ts"; | 50 | import * as ServerSettings from "../serverSettings.ts"; |
| 51 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; | 51 | import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; |
| 52 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 52 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 53 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 53 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 54 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 54 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 55 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 55 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
apps/server/src/orchestration-v2/runtimeLayer.ts+3-3| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; | 1 | import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts"; |
| 2 | import * as Scheduler from "../scheduling/Scheduler.ts"; | 2 | import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as OrchestrationCommandReceipts from "../persistence/Layers/OrchestrationCommandReceipts.ts"; | 4 | import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; |
| 5 | import * as OrchestrationEventStore from "../persistence/Layers/OrchestrationEventStore.ts"; | 5 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 6 | import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; | 6 | import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; |
| 7 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 7 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 8 | import * as ProviderAuthService from "../provider/Layers/ProviderAuthService.ts"; | 8 | import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; |
| 9 | import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; | 9 | import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; |
| 10 | import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; | 10 | import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; |
| 11 | import * as ProjectService from "../project/ProjectService.ts"; | 11 | import * as ProjectService from "../project/ProjectService.ts"; |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+1-1| ... | @@ -21,7 +21,7 @@ import { | ... | @@ -21,7 +21,7 @@ import { |
| 21 | } from "../Adapters/CursorAdapterV2.testkit.ts"; | 21 | } from "../Adapters/CursorAdapterV2.testkit.ts"; |
| 22 | import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts"; | 22 | import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts"; |
| 23 | import * as IdAllocator from "../IdAllocator.ts"; | 23 | import * as IdAllocator from "../IdAllocator.ts"; |
| 24 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 24 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 25 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; | 25 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; |
| 26 | import { | 26 | import { |
| 27 | CLAUDE_MODEL_SELECTION, | 27 | CLAUDE_MODEL_SELECTION, |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts+1-1| ... | @@ -12,7 +12,7 @@ import { | ... | @@ -12,7 +12,7 @@ import { |
| 12 | } from "../Adapters/ClaudeAdapterV2.testkit.ts"; | 12 | } from "../Adapters/ClaudeAdapterV2.testkit.ts"; |
| 13 | import * as IdAllocator from "../IdAllocator.ts"; | 13 | import * as IdAllocator from "../IdAllocator.ts"; |
| 14 | import * as EffectWorker from "../EffectWorker.ts"; | 14 | import * as EffectWorker from "../EffectWorker.ts"; |
| 15 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 15 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 16 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; | 16 | import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; |
| 17 | import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts"; | 17 | import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./fixtures/claude_background_subagent_after_root/input.ts"; |
| 18 | import { | 18 | import { |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+2-2| ... | @@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError"; | ... | @@ -13,7 +13,7 @@ import type { SqlError } from "effect/sql/SqlError"; |
| 13 | 13 | ||
| 14 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | 14 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; |
| 15 | import * as ServerConfig from "../../config.ts"; | 15 | import * as ServerConfig from "../../config.ts"; |
| 16 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 16 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 17 | import * as ServerSettings from "../../serverSettings.ts"; | 17 | import * as ServerSettings from "../../serverSettings.ts"; |
| 18 | import * as ThreadManagementService from "../ThreadManagementService.ts"; | 18 | import * as ThreadManagementService from "../ThreadManagementService.ts"; |
| 19 | import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts"; | 19 | import * as McpSessionRegistryTestkit from "../../mcp/McpSessionRegistry.testkit.ts"; |
| ... | @@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts"; | ... | @@ -34,7 +34,7 @@ import * as Orchestrator from "../Orchestrator.ts"; |
| 34 | import * as ProjectionStore from "../ProjectionStore.ts"; | 34 | import * as ProjectionStore from "../ProjectionStore.ts"; |
| 35 | import * as ProjectStore from "../ProjectStore.ts"; | 35 | import * as ProjectStore from "../ProjectStore.ts"; |
| 36 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 36 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 37 | import * as ProviderAuthService from "../../provider/Services/ProviderAuthService.ts"; | 37 | import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; |
| 38 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; | 38 | import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; |
| 39 | import * as ProviderContinuationService from "../ProviderContinuationService.ts"; | 39 | import * as ProviderContinuationService from "../ProviderContinuationService.ts"; |
| 40 | import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; | 40 | import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; |
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1| ... | @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -32,7 +32,7 @@ import * as Stream from "effect/Stream"; |
| 32 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 33 | import * as SqlClient from "effect/sql/SqlClient"; | 33 | import * as SqlClient from "effect/sql/SqlClient"; |
| 34 | 34 | ||
| 35 | import * as SqlitePersistence from "../../persistence/Layers/Sqlite.ts"; | 35 | import * as SqlitePersistence from "../../persistence/Sqlite.ts"; |
| 36 | import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; | 36 | import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; |
| 37 | import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; | 37 | import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; |
| 38 | import { | 38 | import { |
apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs+1-1| ... | @@ -25,7 +25,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project | ... | @@ -25,7 +25,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project |
| 25 | app("orchestration-v2/ContextHandoffService"), | 25 | app("orchestration-v2/ContextHandoffService"), |
| 26 | app("git/GitWorkflowService"), | 26 | app("git/GitWorkflowService"), |
| 27 | app("project/ProjectService"), | 27 | app("project/ProjectService"), |
| 28 | app("provider/Services/ProviderAuthService"), | 28 | app("provider/ProviderAuthService"), |
| 29 | ]); | 29 | ]); |
| 30 | let current; | 30 | let current; |
| 31 | let fullReads = 0; | 31 | let fullReads = 0; |
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 2 | import * as SqlSchema from "effect/sql/SqlSchema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import { toPersistenceSqlError } from "../Errors.ts"; | ||
| 7 | |||
| 8 | import * as OrchestrationCommandReceipts from "../Services/OrchestrationCommandReceipts.ts"; | ||
| 9 | |||
| 10 | const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { | ||
| 11 | const sql = yield* SqlClient.SqlClient; | ||
| 12 | |||
| 13 | const upsertReceiptRow = SqlSchema.void({ | ||
| 14 | Request: OrchestrationCommandReceipts.OrchestrationCommandReceipt, | ||
| 15 | execute: (receipt) => | ||
| 16 | sql` | ||
| 17 | INSERT INTO orchestration_command_receipts ( | ||
| 18 | command_id, | ||
| 19 | aggregate_kind, | ||
| 20 | aggregate_id, | ||
| 21 | command_type, | ||
| 22 | accepted_at, | ||
| 23 | result_sequence, | ||
| 24 | status, | ||
| 25 | error | ||
| 26 | ) | ||
| 27 | VALUES ( | ||
| 28 | ${receipt.commandId}, | ||
| 29 | ${receipt.aggregateKind}, | ||
| 30 | ${receipt.aggregateId}, | ||
| 31 | ${receipt.commandType}, | ||
| 32 | ${receipt.acceptedAt}, | ||
| 33 | ${receipt.resultSequence}, | ||
| 34 | ${receipt.status}, | ||
| 35 | ${receipt.error} | ||
| 36 | ) | ||
| 37 | ON CONFLICT (command_id) | ||
| 38 | DO UPDATE SET | ||
| 39 | aggregate_kind = excluded.aggregate_kind, | ||
| 40 | aggregate_id = excluded.aggregate_id, | ||
| 41 | command_type = excluded.command_type, | ||
| 42 | accepted_at = excluded.accepted_at, | ||
| 43 | result_sequence = excluded.result_sequence, | ||
| 44 | status = excluded.status, | ||
| 45 | error = excluded.error | ||
| 46 | `, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const findReceiptByCommandId = SqlSchema.findOneOption({ | ||
| 50 | Request: OrchestrationCommandReceipts.GetByCommandIdInput, | ||
| 51 | Result: OrchestrationCommandReceipts.OrchestrationCommandReceipt, | ||
| 52 | execute: ({ commandId }) => | ||
| 53 | sql` | ||
| 54 | SELECT | ||
| 55 | command_id AS "commandId", | ||
| 56 | aggregate_kind AS "aggregateKind", | ||
| 57 | aggregate_id AS "aggregateId", | ||
| 58 | command_type AS "commandType", | ||
| 59 | accepted_at AS "acceptedAt", | ||
| 60 | result_sequence AS "resultSequence", | ||
| 61 | status, | ||
| 62 | error | ||
| 63 | FROM orchestration_command_receipts | ||
| 64 | WHERE command_id = ${commandId} | ||
| 65 | `, | ||
| 66 | }); | ||
| 67 | |||
| 68 | const upsert: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["upsert"] = | ||
| 69 | (receipt) => | ||
| 70 | upsertReceiptRow(receipt).pipe( | ||
| 71 | Effect.mapError( | ||
| 72 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query"), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | |||
| 76 | const insertIfAbsent: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["insertIfAbsent"] = | ||
| 77 | (receipt) => | ||
| 78 | sql<{ readonly command_id: string }>` | ||
| 79 | INSERT INTO orchestration_command_receipts ( | ||
| 80 | command_id, | ||
| 81 | aggregate_kind, | ||
| 82 | aggregate_id, | ||
| 83 | command_type, | ||
| 84 | accepted_at, | ||
| 85 | result_sequence, | ||
| 86 | status, | ||
| 87 | error | ||
| 88 | ) | ||
| 89 | VALUES ( | ||
| 90 | ${receipt.commandId}, | ||
| 91 | ${receipt.aggregateKind}, | ||
| 92 | ${receipt.aggregateId}, | ||
| 93 | ${receipt.commandType}, | ||
| 94 | ${receipt.acceptedAt}, | ||
| 95 | ${receipt.resultSequence}, | ||
| 96 | ${receipt.status}, | ||
| 97 | ${receipt.error} | ||
| 98 | ) | ||
| 99 | ON CONFLICT(command_id) DO NOTHING | ||
| 100 | RETURNING command_id | ||
| 101 | `.pipe( | ||
| 102 | Effect.map((rows) => rows.length === 1), | ||
| 103 | Effect.mapError( | ||
| 104 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"), | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const getByCommandId: OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = | ||
| 109 | (input) => | ||
| 110 | findReceiptByCommandId(input).pipe( | ||
| 111 | Effect.mapError( | ||
| 112 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), | ||
| 113 | ), | ||
| 114 | ); | ||
| 115 | |||
| 116 | return { | ||
| 117 | insertIfAbsent, | ||
| 118 | upsert, | ||
| 119 | getByCommandId, | ||
| 120 | } satisfies OrchestrationCommandReceipts.OrchestrationCommandReceiptRepositoryShape; | ||
| 121 | }); | ||
| 122 | |||
| 123 | export const layer = Layer.effect( | ||
| 124 | OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository, | ||
| 125 | makeOrchestrationCommandReceiptRepository, | ||
| 126 | ); | ||
apps/server/src/persistence/Layers/OrchestrationEventStore.sequence.test.ts deleted-275| ... | @@ -1,275 +0,0 @@ | ||
| 1 | import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as Tracer from "effect/Tracer"; | ||
| 9 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 10 | |||
| 11 | import { runMigrations } from "../Migrations.ts"; | ||
| 12 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 13 | import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; | ||
| 14 | import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts"; | ||
| 15 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 16 | |||
| 17 | const layerEventStore = OrchestrationEventStoreLayer.layer.pipe( | ||
| 18 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 19 | ); | ||
| 20 | const occurredAt = "2026-09-03T00:00:00.000Z"; | ||
| 21 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 22 | |||
| 23 | function eventRow( | ||
| 24 | ordinal: number, | ||
| 25 | aggregateKind: "project" | "thread", | ||
| 26 | version: number, | ||
| 27 | streamId = `${aggregateKind}:${ordinal}`, | ||
| 28 | commandId: string | null = null, | ||
| 29 | ) { | ||
| 30 | return { | ||
| 31 | event_id: `event:${ordinal}`, | ||
| 32 | aggregate_kind: aggregateKind, | ||
| 33 | stream_id: streamId, | ||
| 34 | stream_version: ordinal, | ||
| 35 | event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached", | ||
| 36 | occurred_at: occurredAt, | ||
| 37 | command_id: commandId, | ||
| 38 | causation_event_id: null, | ||
| 39 | correlation_id: null, | ||
| 40 | actor_kind: "server", | ||
| 41 | payload_json: encodeJson( | ||
| 42 | aggregateKind === "project" | ||
| 43 | ? { | ||
| 44 | projectId: streamId, | ||
| 45 | title: "Sequence fixture", | ||
| 46 | workspaceRoot: "/tmp/sequence-fixture", | ||
| 47 | defaultModelSelection: null, | ||
| 48 | scripts: [], | ||
| 49 | createdAt: occurredAt, | ||
| 50 | updatedAt: occurredAt, | ||
| 51 | } | ||
| 52 | : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt }, | ||
| 53 | ), | ||
| 54 | metadata_json: "{}", | ||
| 55 | application_event_version: version, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | const seedEvents = Effect.fn("test.seedSequenceEvents")(function* ( | ||
| 60 | rows: ReadonlyArray<ReturnType<typeof eventRow>>, | ||
| 61 | ) { | ||
| 62 | const sql = yield* SqlClient.SqlClient; | ||
| 63 | return yield* sql<{ | ||
| 64 | readonly sequence: number; | ||
| 65 | readonly aggregate_kind: string; | ||
| 66 | readonly application_event_version: number; | ||
| 67 | readonly stream_id: string; | ||
| 68 | readonly command_id: string | null; | ||
| 69 | }>` | ||
| 70 | INSERT INTO orchestration_events ${sql.insert(rows)} | ||
| 71 | RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id | ||
| 72 | `; | ||
| 73 | }); | ||
| 74 | |||
| 75 | it.effect("keeps application and scoped agent high-water marks separate from legacy history", () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 78 | const sql = yield* SqlClient.SqlClient; | ||
| 79 | const target = ThreadId.make("target"); | ||
| 80 | assert.equal(yield* store.latestApplicationSequence, 0); | ||
| 81 | assert.equal(yield* store.latestAgentSequence(), 0); | ||
| 82 | assert.equal(yield* store.latestAgentSequence(target), 0); | ||
| 83 | |||
| 84 | yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]); | ||
| 85 | assert.equal(yield* store.latestApplicationSequence, 0); | ||
| 86 | assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0); | ||
| 87 | |||
| 88 | const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([ | ||
| 89 | eventRow(2, "project", 1), | ||
| 90 | eventRow(3, "thread", 2, target), | ||
| 91 | eventRow(4, "thread", 2, "other"), | ||
| 92 | ]); | ||
| 93 | assert.ok(legacyProject); | ||
| 94 | assert.ok(targetEvent); | ||
| 95 | assert.ok(otherEvent); | ||
| 96 | assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence); | ||
| 97 | assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); | ||
| 98 | assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); | ||
| 99 | |||
| 100 | const [projectEvent] = yield* seedEvents([ | ||
| 101 | eventRow(5, "project", 2), | ||
| 102 | eventRow(6, "thread", 1, target), | ||
| 103 | eventRow(7, "thread", 3, target), | ||
| 104 | ]); | ||
| 105 | assert.ok(projectEvent); | ||
| 106 | assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence); | ||
| 107 | assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); | ||
| 108 | assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); | ||
| 109 | assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0); | ||
| 110 | |||
| 111 | yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`; | ||
| 112 | assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence); | ||
| 113 | assert.equal(yield* store.latestAgentSequence(), 0); | ||
| 114 | assert.equal(yield* store.latestAgentSequence(target), 0); | ||
| 115 | }).pipe(Effect.provide(Layer.fresh(layerEventStore))), | ||
| 116 | ); | ||
| 117 | |||
| 118 | it.effect( | ||
| 119 | "preserves mixed application paging, scoped replay, and the catch-up to live boundary", | ||
| 120 | () => | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 123 | const rows = yield* seedEvents( | ||
| 124 | Array.from({ length: 1_560 }, (_, index) => { | ||
| 125 | const kind = index % 3 === 1 ? "project" : "thread"; | ||
| 126 | return eventRow( | ||
| 127 | index, | ||
| 128 | kind, | ||
| 129 | index % 3 === 0 ? 1 : 2, | ||
| 130 | kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other", | ||
| 131 | index % 5 === 0 ? "selected-command" : "other-command", | ||
| 132 | ); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | const afterSequence = rows[25]!.sequence; | ||
| 136 | const throughSequence = rows[1_525]!.sequence; | ||
| 137 | const retained = rows.filter( | ||
| 138 | (row) => row.aggregate_kind === "project" || row.application_event_version === 2, | ||
| 139 | ); | ||
| 140 | const replay = yield* store | ||
| 141 | .readApplicationEvents({ afterSequence, throughSequence }) | ||
| 142 | .pipe(Stream.runCollect); | ||
| 143 | assert.deepEqual( | ||
| 144 | replay.map((event) => event.sequence), | ||
| 145 | retained | ||
| 146 | .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence) | ||
| 147 | .map((row) => row.sequence), | ||
| 148 | ); | ||
| 149 | |||
| 150 | const scoped = yield* store | ||
| 151 | .readAgentEvents({ | ||
| 152 | afterSequence, | ||
| 153 | throughSequence, | ||
| 154 | threadId: ThreadId.make("target"), | ||
| 155 | commandId: CommandId.make("selected-command"), | ||
| 156 | limit: 17, | ||
| 157 | }) | ||
| 158 | .pipe(Stream.runCollect); | ||
| 159 | assert.deepEqual( | ||
| 160 | scoped.map((event) => event.sequence), | ||
| 161 | retained | ||
| 162 | .filter( | ||
| 163 | (row) => | ||
| 164 | row.sequence > afterSequence && | ||
| 165 | row.sequence <= throughSequence && | ||
| 166 | row.aggregate_kind === "thread" && | ||
| 167 | row.stream_id === "target" && | ||
| 168 | row.command_id === "selected-command", | ||
| 169 | ) | ||
| 170 | .slice(0, 17) | ||
| 171 | .map((row) => row.sequence), | ||
| 172 | ); | ||
| 173 | |||
| 174 | const catchUp = retained.filter((row) => row.sequence > afterSequence); | ||
| 175 | const highWater = catchUp.at(-1)!.sequence; | ||
| 176 | let liveSequence = 0; | ||
| 177 | const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe( | ||
| 178 | Stream.tap((event) => | ||
| 179 | event.sequence !== highWater | ||
| 180 | ? Effect.void | ||
| 181 | : Effect.gen(function* () { | ||
| 182 | const [stored] = yield* store.appendAgentEvents({ | ||
| 183 | events: [ | ||
| 184 | { | ||
| 185 | id: EventId.make("live-after-catch-up"), | ||
| 186 | type: "provider-session.detached", | ||
| 187 | threadId: ThreadId.make("target"), | ||
| 188 | occurredAt: DateTime.makeUnsafe(occurredAt), | ||
| 189 | payload: { | ||
| 190 | providerSessionId: ProviderSessionId.make("live-session"), | ||
| 191 | detachedAt: DateTime.makeUnsafe(occurredAt), | ||
| 192 | }, | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | }); | ||
| 196 | assert.ok(stored); | ||
| 197 | liveSequence = stored.sequence; | ||
| 198 | yield* store.publishCommitted([stored]); | ||
| 199 | }), | ||
| 200 | ), | ||
| 201 | Stream.take(catchUp.length + 1), | ||
| 202 | Stream.runCollect, | ||
| 203 | ); | ||
| 204 | assert.deepEqual( | ||
| 205 | streamed.map((event) => event.sequence), | ||
| 206 | [...catchUp.map((row) => row.sequence), liveSequence], | ||
| 207 | ); | ||
| 208 | assert.isAbove(liveSequence, highWater); | ||
| 209 | }).pipe(Effect.provide(Layer.fresh(layerEventStore))), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("uses indexed high-water lookups for populated history without OR scans", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 215 | const sql = yield* SqlClient.SqlClient; | ||
| 216 | yield* runMigrations(); | ||
| 217 | yield* sql` | ||
| 218 | WITH RECURSIVE history(n) AS ( | ||
| 219 | SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 | ||
| 220 | ) | ||
| 221 | INSERT INTO orchestration_events ( | ||
| 222 | event_id, aggregate_kind, stream_id, stream_version, event_type, | ||
| 223 | occurred_at, actor_kind, payload_json, metadata_json, application_event_version | ||
| 224 | ) | ||
| 225 | SELECT 'history:' || n, 'thread', | ||
| 226 | CASE WHEN n = 1 THEN 'target' ELSE 'other' END, | ||
| 227 | n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}', | ||
| 228 | CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END | ||
| 229 | FROM history | ||
| 230 | `; | ||
| 231 | const statements: Array<string> = []; | ||
| 232 | const tracer = Tracer.make({ | ||
| 233 | span(options) { | ||
| 234 | const span = new Tracer.NativeSpan(options); | ||
| 235 | const end = span.end.bind(span); | ||
| 236 | span.end = (endTime, exit) => { | ||
| 237 | end(endTime, exit); | ||
| 238 | const query = span.attributes.get("db.query.text"); | ||
| 239 | if (typeof query === "string") statements.push(query); | ||
| 240 | }; | ||
| 241 | return span; | ||
| 242 | }, | ||
| 243 | }); | ||
| 244 | assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000); | ||
| 245 | assert.equal( | ||
| 246 | yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)), | ||
| 247 | 1, | ||
| 248 | ); | ||
| 249 | assert.equal(statements.length, 2); | ||
| 250 | const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>( | ||
| 251 | `EXPLAIN QUERY PLAN ${statements[0]}`, | ||
| 252 | ); | ||
| 253 | const agentPlan = yield* sql.unsafe<{ readonly detail: string }>( | ||
| 254 | `EXPLAIN QUERY PLAN ${statements[1]}`, | ||
| 255 | ["target"], | ||
| 256 | ); | ||
| 257 | assert.match( | ||
| 258 | applicationPlan.map((row) => row.detail).join("\n"), | ||
| 259 | /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/, | ||
| 260 | ); | ||
| 261 | assert.match( | ||
| 262 | agentPlan.map((row) => row.detail).join("\n"), | ||
| 263 | /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, | ||
| 264 | ); | ||
| 265 | for (const row of [...applicationPlan, ...agentPlan]) { | ||
| 266 | assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/); | ||
| 267 | } | ||
| 268 | }).pipe( | ||
| 269 | Effect.provide( | ||
| 270 | OrchestrationEventStoreLayer.layer.pipe( | ||
| 271 | Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ), | ||
| 275 | ); | ||
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts deleted-522| ... | @@ -1,522 +0,0 @@ | ||
| 1 | import * as NodeV8 from "node:v8"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CommandId, | ||
| 5 | EventId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | TurnItemId, | ||
| 10 | type OrchestrationV2DomainEvent, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { assert, it } from "@effect/vitest"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 21 | import * as Statement from "effect/sql/Statement"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | LIVE_STREAM_MAX_ITEMS, | ||
| 25 | LiveStreamBufferError, | ||
| 26 | } from "../../orchestration-v2/LiveStreamBudget.ts"; | ||
| 27 | import { PersistenceDecodeError } from "../Errors.ts"; | ||
| 28 | import { toShellApplicationEvent } from "../../orchestration-v2/ShellStream.ts"; | ||
| 29 | import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; | ||
| 30 | import * as OrchestrationEventStoreLayer from "./OrchestrationEventStore.ts"; | ||
| 31 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 32 | const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); | ||
| 33 | const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); | ||
| 34 | |||
| 35 | const layerTest = OrchestrationEventStoreLayer.layer.pipe( | ||
| 36 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 37 | ); | ||
| 38 | const layer = it.layer(layerTest); | ||
| 39 | |||
| 40 | layer("OrchestrationEventStore", (it) => { | ||
| 41 | it.effect("retains only shell metadata from oversized replay and live application events", () => | ||
| 42 | Effect.scoped( | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 45 | const afterSequence = yield* store.latestApplicationSequence; | ||
| 46 | const now = yield* DateTime.now; | ||
| 47 | const threadId = ThreadId.make("thread:large-shell-body"); | ||
| 48 | const input = "x".repeat(9 * 1024 * 1024); | ||
| 49 | const event: OrchestrationV2DomainEvent = { | ||
| 50 | id: EventId.make("event:large-shell-body:replay"), | ||
| 51 | type: "turn-item.updated", | ||
| 52 | threadId, | ||
| 53 | occurredAt: now, | ||
| 54 | payload: { | ||
| 55 | id: TurnItemId.make("tool:large-shell-body"), | ||
| 56 | type: "command_execution", | ||
| 57 | threadId, | ||
| 58 | runId: null, | ||
| 59 | nodeId: null, | ||
| 60 | providerThreadId: null, | ||
| 61 | providerTurnId: null, | ||
| 62 | nativeItemRef: null, | ||
| 63 | parentItemId: null, | ||
| 64 | ordinal: 1, | ||
| 65 | status: "running", | ||
| 66 | title: "Large command", | ||
| 67 | input, | ||
| 68 | startedAt: now, | ||
| 69 | completedAt: null, | ||
| 70 | updatedAt: now, | ||
| 71 | }, | ||
| 72 | }; | ||
| 73 | const [replayed] = yield* store.appendAgentEvents({ events: [event] }); | ||
| 74 | const pull = yield* Stream.toPull( | ||
| 75 | store.streamProjectedApplicationEvents({ | ||
| 76 | afterSequence, | ||
| 77 | project: toShellApplicationEvent, | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]); | ||
| 81 | const [live] = yield* store.appendAgentEvents({ | ||
| 82 | events: [ | ||
| 83 | { | ||
| 84 | ...event, | ||
| 85 | id: EventId.make("event:large-shell-body:live"), | ||
| 86 | payload: { ...event.payload, status: "completed", completedAt: now }, | ||
| 87 | }, | ||
| 88 | ], | ||
| 89 | }); | ||
| 90 | yield* store.publishCommitted([live!]); | ||
| 91 | assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]); | ||
| 92 | const durable = yield* store | ||
| 93 | .readAgentEvents({ threadId, afterSequence }) | ||
| 94 | .pipe(Stream.runCollect); | ||
| 95 | assert.lengthOf(durable, 2); | ||
| 96 | for (const stored of durable) { | ||
| 97 | assert.equal(stored.event.type, "turn-item.updated"); | ||
| 98 | if (stored.event.type !== "turn-item.updated") return; | ||
| 99 | assert.equal(stored.event.payload.type, "command_execution"); | ||
| 100 | if (stored.event.payload.type !== "command_execution") return; | ||
| 101 | assert.equal(stored.event.payload.input, input); | ||
| 102 | } | ||
| 103 | }), | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | |||
| 107 | it.effect("stores json columns as strings and replays CLI-origin events", () => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 110 | const sql = yield* SqlClient.SqlClient; | ||
| 111 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 112 | |||
| 113 | const appended = yield* eventStore.appendProjectEvent({ | ||
| 114 | type: "project.created", | ||
| 115 | eventId: EventId.make("evt-store-roundtrip"), | ||
| 116 | aggregateKind: "project", | ||
| 117 | aggregateId: ProjectId.make("project-roundtrip"), | ||
| 118 | occurredAt: now, | ||
| 119 | commandId: CommandId.make("cmd-store-roundtrip"), | ||
| 120 | causationEventId: null, | ||
| 121 | correlationId: CommandId.make("cmd-store-roundtrip"), | ||
| 122 | metadata: { | ||
| 123 | adapterKey: "codex", | ||
| 124 | origin: { | ||
| 125 | surface: "cli", | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | payload: { | ||
| 129 | projectId: ProjectId.make("project-roundtrip"), | ||
| 130 | title: "Roundtrip Project", | ||
| 131 | workspaceRoot: "/tmp/project-roundtrip", | ||
| 132 | defaultModelSelection: null, | ||
| 133 | scripts: [], | ||
| 134 | createdAt: now, | ||
| 135 | updatedAt: now, | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const storedRows = yield* sql<{ | ||
| 140 | readonly payloadJson: string; | ||
| 141 | readonly metadataJson: string; | ||
| 142 | }>` | ||
| 143 | SELECT | ||
| 144 | payload_json AS "payloadJson", | ||
| 145 | metadata_json AS "metadataJson" | ||
| 146 | FROM orchestration_events | ||
| 147 | WHERE event_id = ${appended.eventId} | ||
| 148 | `; | ||
| 149 | assert.equal(storedRows.length, 1); | ||
| 150 | assert.equal(typeof storedRows[0]?.payloadJson, "string"); | ||
| 151 | assert.equal(typeof storedRows[0]?.metadataJson, "string"); | ||
| 152 | |||
| 153 | const replayed = yield* eventStore | ||
| 154 | .readApplicationEvents({ | ||
| 155 | afterSequence: appended.sequence - 1, | ||
| 156 | throughSequence: appended.sequence, | ||
| 157 | }) | ||
| 158 | .pipe( | ||
| 159 | Stream.runCollect, | ||
| 160 | Effect.map((chunk) => Array.from(chunk)), | ||
| 161 | ); | ||
| 162 | assert.equal(replayed.length, 1); | ||
| 163 | const [project] = replayed; | ||
| 164 | if (project === undefined || !("aggregateKind" in project)) { | ||
| 165 | return assert.fail("Expected a project event"); | ||
| 166 | } | ||
| 167 | assert.equal(project.type, "project.created"); | ||
| 168 | assert.equal(project.metadata.adapterKey, "codex"); | ||
| 169 | assert.deepEqual(project.metadata.origin, { surface: "cli" }); | ||
| 170 | }), | ||
| 171 | ); | ||
| 172 | |||
| 173 | it.effect("fails with PersistenceDecodeError when stored json is invalid", () => | ||
| 174 | Effect.gen(function* () { | ||
| 175 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 176 | const sql = yield* SqlClient.SqlClient; | ||
| 177 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 178 | |||
| 179 | yield* sql` | ||
| 180 | INSERT INTO orchestration_events ( | ||
| 181 | event_id, | ||
| 182 | aggregate_kind, | ||
| 183 | stream_id, | ||
| 184 | stream_version, | ||
| 185 | event_type, | ||
| 186 | occurred_at, | ||
| 187 | command_id, | ||
| 188 | causation_event_id, | ||
| 189 | correlation_id, | ||
| 190 | actor_kind, | ||
| 191 | payload_json, | ||
| 192 | metadata_json | ||
| 193 | ) | ||
| 194 | VALUES ( | ||
| 195 | ${EventId.make("evt-store-invalid-json")}, | ||
| 196 | ${"project"}, | ||
| 197 | ${ProjectId.make("project-invalid-json")}, | ||
| 198 | ${0}, | ||
| 199 | ${"project.created"}, | ||
| 200 | ${now}, | ||
| 201 | ${CommandId.make("cmd-store-invalid-json")}, | ||
| 202 | ${null}, | ||
| 203 | ${null}, | ||
| 204 | ${"server"}, | ||
| 205 | ${"{"}, | ||
| 206 | ${"{}"} | ||
| 207 | ) | ||
| 208 | `; | ||
| 209 | |||
| 210 | const replayResult = yield* Effect.result( | ||
| 211 | Stream.runCollect( | ||
| 212 | eventStore.readApplicationEvents({ | ||
| 213 | afterSequence: 0, | ||
| 214 | throughSequence: Number.MAX_SAFE_INTEGER, | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | assert.equal(replayResult._tag, "Failure"); | ||
| 219 | if (replayResult._tag === "Failure") { | ||
| 220 | assert.ok(isPersistenceDecodeError(replayResult.failure)); | ||
| 221 | } | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect("orders project and V2 agent events in the retained application event source", () => | ||
| 226 | Effect.gen(function* () { | ||
| 227 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 228 | const projectId = ProjectId.make("project-shared-stream"); | ||
| 229 | const threadId = ThreadId.make("thread-shared-stream"); | ||
| 230 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 231 | const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"); | ||
| 232 | const now = DateTime.formatIso(occurredAt); | ||
| 233 | const baselineSequence = yield* eventStore.latestApplicationSequence; | ||
| 234 | |||
| 235 | const projectEvent = yield* eventStore.appendProjectEvent({ | ||
| 236 | type: "project.created", | ||
| 237 | eventId: EventId.make("event-project-shared-stream"), | ||
| 238 | aggregateKind: "project", | ||
| 239 | aggregateId: projectId, | ||
| 240 | occurredAt: now, | ||
| 241 | commandId: CommandId.make("command-project-shared-stream"), | ||
| 242 | causationEventId: null, | ||
| 243 | correlationId: CommandId.make("command-project-shared-stream"), | ||
| 244 | metadata: {}, | ||
| 245 | payload: { | ||
| 246 | projectId, | ||
| 247 | title: "Shared stream", | ||
| 248 | workspaceRoot: "/tmp/shared-stream", | ||
| 249 | defaultModelSelection: null, | ||
| 250 | scripts: [], | ||
| 251 | createdAt: now, | ||
| 252 | updatedAt: now, | ||
| 253 | }, | ||
| 254 | }); | ||
| 255 | const [threadEvent] = yield* eventStore.appendAgentEvents({ | ||
| 256 | commandId: CommandId.make("command-thread-shared-stream"), | ||
| 257 | events: [ | ||
| 258 | { | ||
| 259 | id: EventId.make("event-thread-shared-stream"), | ||
| 260 | type: "thread.created", | ||
| 261 | threadId, | ||
| 262 | providerInstanceId, | ||
| 263 | occurredAt, | ||
| 264 | payload: { | ||
| 265 | id: threadId, | ||
| 266 | projectId, | ||
| 267 | title: "Thread", | ||
| 268 | providerInstanceId, | ||
| 269 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, | ||
| 270 | runtimeMode: "full-access", | ||
| 271 | interactionMode: "default", | ||
| 272 | branch: null, | ||
| 273 | worktreePath: null, | ||
| 274 | activeProviderThreadId: null, | ||
| 275 | lineage: { | ||
| 276 | rootThreadId: threadId, | ||
| 277 | parentThreadId: null, | ||
| 278 | relationshipToParent: null, | ||
| 279 | }, | ||
| 280 | forkedFrom: null, | ||
| 281 | createdBy: "user", | ||
| 282 | creationSource: "web", | ||
| 283 | createdAt: occurredAt, | ||
| 284 | updatedAt: occurredAt, | ||
| 285 | archivedAt: null, | ||
| 286 | settledOverride: null, | ||
| 287 | settledAt: null, | ||
| 288 | lastVisitedAt: null, | ||
| 289 | deletedAt: null, | ||
| 290 | }, | ||
| 291 | }, | ||
| 292 | ], | ||
| 293 | }); | ||
| 294 | |||
| 295 | const applicationEvents = yield* eventStore | ||
| 296 | .streamApplicationEvents({ afterSequence: baselineSequence }) | ||
| 297 | .pipe( | ||
| 298 | Stream.take(2), | ||
| 299 | Stream.runCollect, | ||
| 300 | Effect.map((chunk) => Array.from(chunk)), | ||
| 301 | ); | ||
| 302 | assert.deepEqual( | ||
| 303 | applicationEvents.map((event) => event.sequence), | ||
| 304 | [projectEvent.sequence, threadEvent!.sequence], | ||
| 305 | ); | ||
| 306 | assert.isTrue("aggregateKind" in applicationEvents[0]!); | ||
| 307 | assert.isTrue("event" in applicationEvents[1]!); | ||
| 308 | |||
| 309 | const finiteReplay = yield* eventStore | ||
| 310 | .readApplicationEvents({ | ||
| 311 | afterSequence: baselineSequence, | ||
| 312 | throughSequence: threadEvent!.sequence, | ||
| 313 | }) | ||
| 314 | .pipe( | ||
| 315 | Stream.runCollect, | ||
| 316 | Effect.map((chunk) => Array.from(chunk)), | ||
| 317 | ); | ||
| 318 | assert.deepEqual( | ||
| 319 | finiteReplay.map((event) => event.sequence), | ||
| 320 | [projectEvent.sequence, threadEvent!.sequence], | ||
| 321 | ); | ||
| 322 | }), | ||
| 323 | ); | ||
| 324 | it.effect("measures only a bounded thread replay before decoding its payloads", () => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 327 | const sql = yield* SqlClient.SqlClient; | ||
| 328 | const threadId = ThreadId.make("measured-replay-thread"); | ||
| 329 | const baseline = yield* store.latestApplicationSequence; | ||
| 330 | const unicodePayload = '{"text":"🦊"}'; | ||
| 331 | const oversizedPayload = "{" + "x".repeat(1_048_576); | ||
| 332 | let streamVersion = 0; | ||
| 333 | const insert = Effect.fn(function* (input: { | ||
| 334 | readonly id: string; | ||
| 335 | readonly payload: string; | ||
| 336 | readonly type?: string; | ||
| 337 | readonly threadId?: string; | ||
| 338 | readonly version?: number; | ||
| 339 | }) { | ||
| 340 | const rows = yield* sql<{ readonly sequence: number }>` | ||
| 341 | INSERT INTO orchestration_events ( | ||
| 342 | event_id, aggregate_kind, stream_id, stream_version, event_type, | ||
| 343 | occurred_at, actor_kind, payload_json, metadata_json, application_event_version | ||
| 344 | ) VALUES ( | ||
| 345 | ${input.id}, 'thread', ${input.threadId ?? threadId}, | ||
| 346 | ${streamVersion++}, ${input.type ?? "thread.metadata-updated"}, | ||
| 347 | '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2} | ||
| 348 | ) RETURNING sequence | ||
| 349 | `; | ||
| 350 | return rows[0]!.sequence; | ||
| 351 | }); | ||
| 352 | const first = yield* insert({ | ||
| 353 | id: "measured-replay-1", | ||
| 354 | payload: unicodePayload, | ||
| 355 | type: "thread.created", | ||
| 356 | }); | ||
| 357 | yield* insert({ | ||
| 358 | id: "measured-unrelated", | ||
| 359 | threadId: "other-replay-thread", | ||
| 360 | payload: oversizedPayload, | ||
| 361 | }); | ||
| 362 | yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 }); | ||
| 363 | const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload }); | ||
| 364 | const later = yield* insert({ id: "measured-replay-003", payload: "{}" }); | ||
| 365 | const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 }; | ||
| 366 | assert.deepEqual(yield* store.getAgentReplayStats(range), { | ||
| 367 | eventCount: 2, | ||
| 368 | rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload), | ||
| 369 | hasCreateEvent: true, | ||
| 370 | }); | ||
| 371 | assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), { | ||
| 372 | eventCount: 1, | ||
| 373 | rawPayloadBytes: Buffer.byteLength(oversizedPayload), | ||
| 374 | hasCreateEvent: false, | ||
| 375 | }); | ||
| 376 | assert.equal( | ||
| 377 | (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 })) | ||
| 378 | .eventCount, | ||
| 379 | 2, | ||
| 380 | ); | ||
| 381 | assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), { | ||
| 382 | eventCount: 0, | ||
| 383 | rawPayloadBytes: 0, | ||
| 384 | hasCreateEvent: false, | ||
| 385 | }); | ||
| 386 | // The application range skips V1 rows, which a shell resume never replays. | ||
| 387 | assert.deepEqual( | ||
| 388 | yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }), | ||
| 389 | { | ||
| 390 | eventCount: 3, | ||
| 391 | rawPayloadBytes: | ||
| 392 | Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload), | ||
| 393 | }, | ||
| 394 | ); | ||
| 395 | }), | ||
| 396 | ); | ||
| 397 | }); | ||
| 398 | |||
| 399 | it.effect.each(["high-water", "replay"] as const)( | ||
| 400 | "bounds application live events while the %s query is blocked", | ||
| 401 | (phase) => | ||
| 402 | Effect.scoped( | ||
| 403 | Effect.gen(function* () { | ||
| 404 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 405 | const now = "2026-01-03T00:00:00.000Z"; | ||
| 406 | const projectId = ProjectId.make(`project:blocked-${phase}`); | ||
| 407 | const projectEvent = yield* store.appendProjectEvent({ | ||
| 408 | type: "project.created", | ||
| 409 | eventId: EventId.make(`event:blocked-${phase}:created`), | ||
| 410 | aggregateKind: "project", | ||
| 411 | aggregateId: projectId, | ||
| 412 | occurredAt: now, | ||
| 413 | commandId: CommandId.make(`command:blocked-${phase}`), | ||
| 414 | causationEventId: null, | ||
| 415 | correlationId: null, | ||
| 416 | metadata: {}, | ||
| 417 | payload: { | ||
| 418 | projectId, | ||
| 419 | title: "Blocked stream", | ||
| 420 | workspaceRoot: "/tmp/blocked-stream", | ||
| 421 | defaultModelSelection: null, | ||
| 422 | scripts: [], | ||
| 423 | createdAt: now, | ||
| 424 | updatedAt: now, | ||
| 425 | }, | ||
| 426 | }); | ||
| 427 | if (projectEvent.type !== "project.created") | ||
| 428 | return yield* Effect.die("Expected project.created"); | ||
| 429 | const readStarted = yield* Deferred.make<void>(); | ||
| 430 | const readClosed = yield* Deferred.make<void>(); | ||
| 431 | const blockRead: Statement.Transformer = (statement) => { | ||
| 432 | const [query] = statement.compile(); | ||
| 433 | if ( | ||
| 434 | query.includes("FROM orchestration_events") && | ||
| 435 | query.includes("MAX(sequence)") === (phase === "high-water") | ||
| 436 | ) { | ||
| 437 | return Deferred.succeed(readStarted, undefined).pipe( | ||
| 438 | Effect.andThen(Effect.never), | ||
| 439 | Effect.ensuring(Deferred.succeed(readClosed, undefined)), | ||
| 440 | Effect.as(statement), | ||
| 441 | ); | ||
| 442 | } | ||
| 443 | return Effect.succeed(statement); | ||
| 444 | }; | ||
| 445 | const reader = yield* store | ||
| 446 | .streamApplicationEvents({ afterSequence: projectEvent.sequence }) | ||
| 447 | .pipe( | ||
| 448 | Stream.provideService(Statement.CurrentTransformer, blockRead), | ||
| 449 | Stream.runDrain, | ||
| 450 | Effect.result, | ||
| 451 | Effect.forkScoped, | ||
| 452 | ); | ||
| 453 | yield* Deferred.await(readStarted); | ||
| 454 | yield* store.publishCommitted( | ||
| 455 | Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({ | ||
| 456 | ...projectEvent, | ||
| 457 | eventId: EventId.make(`event:blocked-${phase}:${index}`), | ||
| 458 | sequence: projectEvent.sequence + index + 1, | ||
| 459 | })), | ||
| 460 | ); | ||
| 461 | yield* Deferred.await(readClosed); | ||
| 462 | const result = yield* Fiber.join(reader); | ||
| 463 | assert.equal(result._tag, "Failure"); | ||
| 464 | if (result._tag === "Failure") { | ||
| 465 | assert.equal(result.failure._tag, "PersistenceSqlError"); | ||
| 466 | assert.isTrue(isLiveStreamBufferError(result.failure.cause)); | ||
| 467 | } | ||
| 468 | }), | ||
| 469 | ).pipe(Effect.provide(Layer.fresh(layerTest))), | ||
| 470 | ); | ||
| 471 | |||
| 472 | it.effect("releases consumed application replay pages", () => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 475 | const afterSequence = yield* store.latestApplicationSequence; | ||
| 476 | yield* Effect.forEach( | ||
| 477 | Array.from({ length: 1501 }, (_, index) => index), | ||
| 478 | (index) => | ||
| 479 | store.appendProjectEvent({ | ||
| 480 | type: "project.created", | ||
| 481 | eventId: EventId.make(`retention-${index}`), | ||
| 482 | aggregateKind: "project", | ||
| 483 | aggregateId: ProjectId.make(`retention-${index}`), | ||
| 484 | occurredAt: "2026-09-08T00:00:00Z", | ||
| 485 | commandId: null, | ||
| 486 | causationEventId: null, | ||
| 487 | correlationId: null, | ||
| 488 | metadata: {}, | ||
| 489 | payload: { | ||
| 490 | projectId: ProjectId.make(`retention-${index}`), | ||
| 491 | title: "Replay", | ||
| 492 | workspaceRoot: "/tmp/replay", | ||
| 493 | defaultModelSelection: null, | ||
| 494 | scripts: [], | ||
| 495 | createdAt: "2026-09-08T00:00:00Z", | ||
| 496 | updatedAt: "2026-09-08T00:00:00Z", | ||
| 497 | }, | ||
| 498 | }), | ||
| 499 | { discard: true }, | ||
| 500 | ); | ||
| 501 | // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC. | ||
| 502 | class ReplayPage {} | ||
| 503 | let count = 0; | ||
| 504 | yield* store | ||
| 505 | .readApplicationEvents({ | ||
| 506 | afterSequence, | ||
| 507 | throughSequence: yield* store.latestApplicationSequence, | ||
| 508 | }) | ||
| 509 | .pipe( | ||
| 510 | Stream.runForEach((event) => | ||
| 511 | Effect.sync(() => { | ||
| 512 | if (count % 500 === 0) { | ||
| 513 | Object.assign(event, { replayPage: new ReplayPage() }); | ||
| 514 | assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1); | ||
| 515 | } | ||
| 516 | count++; | ||
| 517 | }), | ||
| 518 | ), | ||
| 519 | ); | ||
| 520 | assert.equal(count, 1501); | ||
| 521 | }).pipe(Effect.provide(layerTest)), | ||
| 522 | ); | ||
apps/server/src/persistence/Layers/OrchestrationEventStore.ts deleted-609| ... | @@ -1,609 +0,0 @@ | ||
| 1 | import { | ||
| 2 | ApplicationEventMetadata, | ||
| 3 | ApplicationProjectEvent, | ||
| 4 | type ApplicationStoredEvent, | ||
| 5 | CommandId, | ||
| 6 | EventId, | ||
| 7 | IsoDateTime, | ||
| 8 | NonNegativeInt, | ||
| 9 | OrchestrationV2DomainEventJson, | ||
| 10 | OrchestrationV2StoredEvent, | ||
| 11 | ProjectId, | ||
| 12 | StoredProjectIcon, | ||
| 13 | ThreadId, | ||
| 14 | type OrchestrationV2DomainEvent, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 17 | import * as SqlSchema from "effect/sql/SqlSchema"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Layer from "effect/Layer"; | ||
| 20 | import * as PubSub from "effect/PubSub"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | import * as Stream from "effect/Stream"; | ||
| 24 | |||
| 25 | import { replayAndBufferProjectedLiveEvents } from "../../orchestration-v2/LiveStreamBudget.ts"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | toPersistenceDecodeError, | ||
| 29 | toPersistenceSqlError, | ||
| 30 | type OrchestrationEventStoreError, | ||
| 31 | } from "../Errors.ts"; | ||
| 32 | import * as OrchestrationEventStore from "../Services/OrchestrationEventStore.ts"; | ||
| 33 | |||
| 34 | const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); | ||
| 35 | const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); | ||
| 36 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | ||
| 37 | const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); | ||
| 38 | const ProjectEventType = Schema.Literals([ | ||
| 39 | "project.created", | ||
| 40 | "project.meta-updated", | ||
| 41 | "project.deleted", | ||
| 42 | ]); | ||
| 43 | const ActorKind = Schema.Literals(["client", "server", "provider"]); | ||
| 44 | |||
| 45 | const AppendProjectEventRequestSchema = Schema.Struct({ | ||
| 46 | eventId: EventId, | ||
| 47 | streamId: ProjectId, | ||
| 48 | type: ProjectEventType, | ||
| 49 | causationEventId: Schema.NullOr(EventId), | ||
| 50 | correlationId: Schema.NullOr(CommandId), | ||
| 51 | actorKind: ActorKind, | ||
| 52 | occurredAt: IsoDateTime, | ||
| 53 | commandId: Schema.NullOr(CommandId), | ||
| 54 | payloadJson: UnknownFromJsonString, | ||
| 55 | metadataJson: EventMetadataFromJsonString, | ||
| 56 | }); | ||
| 57 | |||
| 58 | const ProjectEventPersistedRowSchema = Schema.Struct({ | ||
| 59 | sequence: NonNegativeInt, | ||
| 60 | eventId: EventId, | ||
| 61 | type: ProjectEventType, | ||
| 62 | aggregateKind: Schema.Literal("project"), | ||
| 63 | aggregateId: ProjectId, | ||
| 64 | occurredAt: IsoDateTime, | ||
| 65 | commandId: Schema.NullOr(CommandId), | ||
| 66 | causationEventId: Schema.NullOr(EventId), | ||
| 67 | correlationId: Schema.NullOr(CommandId), | ||
| 68 | payload: UnknownFromJsonString, | ||
| 69 | metadata: EventMetadataFromJsonString, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const READ_PAGE_SIZE = 500; | ||
| 73 | |||
| 74 | interface ApplicationEventRow { | ||
| 75 | readonly sequence: number; | ||
| 76 | readonly event_id: string; | ||
| 77 | readonly command_id: string | null; | ||
| 78 | readonly aggregate_kind: "project" | "thread"; | ||
| 79 | readonly stream_id: string; | ||
| 80 | readonly event_type: string; | ||
| 81 | readonly occurred_at: string; | ||
| 82 | readonly payload_json: string; | ||
| 83 | readonly metadata_json: string; | ||
| 84 | readonly application_event_version: number; | ||
| 85 | readonly causation_event_id: string | null; | ||
| 86 | readonly correlation_id: string | null; | ||
| 87 | } | ||
| 88 | |||
| 89 | const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); | ||
| 90 | const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson); | ||
| 91 | const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent); | ||
| 92 | const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 93 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 94 | |||
| 95 | function metadataForV2Event(event: OrchestrationV2DomainEvent): Record<string, unknown> { | ||
| 96 | return { | ||
| 97 | ...(event.runId === undefined ? {} : { runId: event.runId }), | ||
| 98 | ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }), | ||
| 99 | ...(event.driver === undefined ? {} : { driver: event.driver }), | ||
| 100 | ...(event.providerInstanceId === undefined | ||
| 101 | ? {} | ||
| 102 | : { providerInstanceId: event.providerInstanceId }), | ||
| 103 | ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }), | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | |||
| 107 | const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* ( | ||
| 108 | row: ApplicationEventRow, | ||
| 109 | ) { | ||
| 110 | const payload = yield* decodeJson(row.payload_json); | ||
| 111 | const metadata = yield* decodeJson(row.metadata_json); | ||
| 112 | const values = | ||
| 113 | typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>) : {}; | ||
| 114 | const event = yield* decodeV2EventJson({ | ||
| 115 | id: row.event_id, | ||
| 116 | threadId: row.stream_id, | ||
| 117 | type: row.event_type, | ||
| 118 | occurredAt: row.occurred_at, | ||
| 119 | payload, | ||
| 120 | ...(values.runId === undefined ? {} : { runId: values.runId }), | ||
| 121 | ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }), | ||
| 122 | ...(values.driver === undefined ? {} : { driver: values.driver }), | ||
| 123 | ...(values.providerInstanceId === undefined | ||
| 124 | ? {} | ||
| 125 | : { providerInstanceId: values.providerInstanceId }), | ||
| 126 | ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }), | ||
| 127 | }); | ||
| 128 | return yield* decodeV2StoredEvent({ | ||
| 129 | sequence: row.sequence, | ||
| 130 | commandId: row.command_id, | ||
| 131 | event, | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* ( | ||
| 136 | row: ApplicationEventRow, | ||
| 137 | ) { | ||
| 138 | return yield* decodeProjectEvent({ | ||
| 139 | sequence: row.sequence, | ||
| 140 | eventId: row.event_id, | ||
| 141 | type: row.event_type, | ||
| 142 | aggregateKind: row.aggregate_kind, | ||
| 143 | aggregateId: row.stream_id, | ||
| 144 | occurredAt: row.occurred_at, | ||
| 145 | commandId: row.command_id, | ||
| 146 | causationEventId: row.causation_event_id, | ||
| 147 | correlationId: row.correlation_id, | ||
| 148 | payload: yield* decodeJson(row.payload_json), | ||
| 149 | metadata: yield* decodeJson(row.metadata_json), | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | |||
| 153 | function rowToApplicationStoredEvent( | ||
| 154 | row: ApplicationEventRow, | ||
| 155 | ): Effect.Effect<ApplicationStoredEvent, Schema.SchemaError> { | ||
| 156 | return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row); | ||
| 157 | } | ||
| 158 | |||
| 159 | function inferActorKind( | ||
| 160 | event: OrchestrationEventStore.UnsequencedProjectEvent, | ||
| 161 | ): typeof ActorKind.Type { | ||
| 162 | if (event.commandId !== null && event.commandId.startsWith("provider:")) { | ||
| 163 | return "provider"; | ||
| 164 | } | ||
| 165 | if (event.commandId !== null && event.commandId.startsWith("server:")) { | ||
| 166 | return "server"; | ||
| 167 | } | ||
| 168 | if ( | ||
| 169 | event.metadata.providerTurnId !== undefined || | ||
| 170 | event.metadata.providerItemId !== undefined || | ||
| 171 | event.metadata.adapterKey !== undefined | ||
| 172 | ) { | ||
| 173 | return "provider"; | ||
| 174 | } | ||
| 175 | if (event.commandId === null) { | ||
| 176 | return "server"; | ||
| 177 | } | ||
| 178 | return "client"; | ||
| 179 | } | ||
| 180 | |||
| 181 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | ||
| 182 | return (cause: unknown): OrchestrationEventStoreError => | ||
| 183 | Schema.isSchemaError(cause) | ||
| 184 | ? toPersistenceDecodeError(decodeOperation)(cause) | ||
| 185 | : toPersistenceSqlError(sqlOperation)(cause); | ||
| 186 | } | ||
| 187 | |||
| 188 | const makeEventStore = Effect.gen(function* () { | ||
| 189 | const sql = yield* SqlClient.SqlClient; | ||
| 190 | const committedEvents = yield* PubSub.unbounded<ApplicationStoredEvent>(); | ||
| 191 | |||
| 192 | const appendProjectEventRow = SqlSchema.findOne({ | ||
| 193 | Request: AppendProjectEventRequestSchema, | ||
| 194 | Result: ProjectEventPersistedRowSchema, | ||
| 195 | execute: (request) => | ||
| 196 | sql` | ||
| 197 | INSERT INTO orchestration_events ( | ||
| 198 | event_id, | ||
| 199 | aggregate_kind, | ||
| 200 | stream_id, | ||
| 201 | stream_version, | ||
| 202 | event_type, | ||
| 203 | occurred_at, | ||
| 204 | command_id, | ||
| 205 | causation_event_id, | ||
| 206 | correlation_id, | ||
| 207 | actor_kind, | ||
| 208 | payload_json, | ||
| 209 | metadata_json, | ||
| 210 | application_event_version | ||
| 211 | ) | ||
| 212 | VALUES ( | ||
| 213 | ${request.eventId}, | ||
| 214 | 'project', | ||
| 215 | ${request.streamId}, | ||
| 216 | COALESCE( | ||
| 217 | ( | ||
| 218 | SELECT stream_version + 1 | ||
| 219 | FROM orchestration_events | ||
| 220 | WHERE aggregate_kind = 'project' | ||
| 221 | AND stream_id = ${request.streamId} | ||
| 222 | ORDER BY stream_version DESC | ||
| 223 | LIMIT 1 | ||
| 224 | ), | ||
| 225 | 0 | ||
| 226 | ), | ||
| 227 | ${request.type}, | ||
| 228 | ${request.occurredAt}, | ||
| 229 | ${request.commandId}, | ||
| 230 | ${request.causationEventId}, | ||
| 231 | ${request.correlationId}, | ||
| 232 | ${request.actorKind}, | ||
| 233 | ${request.payloadJson}, | ||
| 234 | ${request.metadataJson}, | ||
| 235 | 2 | ||
| 236 | ) | ||
| 237 | RETURNING | ||
| 238 | sequence, | ||
| 239 | event_id AS "eventId", | ||
| 240 | event_type AS "type", | ||
| 241 | aggregate_kind AS "aggregateKind", | ||
| 242 | stream_id AS "aggregateId", | ||
| 243 | occurred_at AS "occurredAt", | ||
| 244 | command_id AS "commandId", | ||
| 245 | causation_event_id AS "causationEventId", | ||
| 246 | correlation_id AS "correlationId", | ||
| 247 | payload_json AS "payload", | ||
| 248 | metadata_json AS "metadata" | ||
| 249 | `, | ||
| 250 | }); | ||
| 251 | |||
| 252 | const appendProjectEvent: OrchestrationEventStore.OrchestrationEventStoreShape["appendProjectEvent"] = | ||
| 253 | (event) => | ||
| 254 | appendProjectEventRow({ | ||
| 255 | eventId: event.eventId, | ||
| 256 | streamId: event.aggregateId, | ||
| 257 | type: event.type, | ||
| 258 | causationEventId: event.causationEventId, | ||
| 259 | correlationId: event.correlationId, | ||
| 260 | actorKind: inferActorKind(event), | ||
| 261 | occurredAt: event.occurredAt, | ||
| 262 | commandId: event.commandId, | ||
| 263 | payloadJson: | ||
| 264 | event.type === "project.deleted" || !event.payload.projectIcon | ||
| 265 | ? event.payload | ||
| 266 | : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) }, | ||
| 267 | metadataJson: event.metadata, | ||
| 268 | }).pipe( | ||
| 269 | Effect.flatMap((row) => decodeProjectEvent(row)), | ||
| 270 | Effect.mapError( | ||
| 271 | toPersistenceSqlOrDecodeError( | ||
| 272 | "OrchestrationEventStore.appendProjectEvent:insert", | ||
| 273 | "OrchestrationEventStore.appendProjectEvent:decode", | ||
| 274 | ), | ||
| 275 | ), | ||
| 276 | ); | ||
| 277 | |||
| 278 | const readApplicationRows = (input: { | ||
| 279 | readonly afterSequence: number; | ||
| 280 | readonly throughSequence?: number; | ||
| 281 | readonly threadId?: ThreadId; | ||
| 282 | readonly commandId?: CommandId; | ||
| 283 | readonly eventType?: OrchestrationV2DomainEvent["type"]; | ||
| 284 | readonly onlyAgentEvents?: boolean; | ||
| 285 | readonly limit: number; | ||
| 286 | }) => | ||
| 287 | sql<ApplicationEventRow>` | ||
| 288 | SELECT | ||
| 289 | sequence, | ||
| 290 | event_id, | ||
| 291 | command_id, | ||
| 292 | aggregate_kind, | ||
| 293 | stream_id, | ||
| 294 | event_type, | ||
| 295 | occurred_at, | ||
| 296 | payload_json, | ||
| 297 | metadata_json, | ||
| 298 | application_event_version, | ||
| 299 | causation_event_id, | ||
| 300 | correlation_id | ||
| 301 | FROM orchestration_events | ||
| 302 | ${ | ||
| 303 | input.onlyAgentEvents === true | ||
| 304 | ? sql`` | ||
| 305 | : sql`INDEXED BY idx_orchestration_events_application_high_water` | ||
| 306 | } | ||
| 307 | WHERE sequence > ${input.afterSequence} | ||
| 308 | AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} | ||
| 309 | AND ( | ||
| 310 | ${ | ||
| 311 | input.onlyAgentEvents === true | ||
| 312 | ? sql`application_event_version = 2 AND aggregate_kind = 'thread'` | ||
| 313 | : sql`aggregate_kind = 'project' | ||
| 314 | OR (application_event_version = 2 AND aggregate_kind = 'thread')` | ||
| 315 | } | ||
| 316 | ) | ||
| 317 | AND ${sql.and([ | ||
| 318 | ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]), | ||
| 319 | ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]), | ||
| 320 | ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]), | ||
| 321 | ])} | ||
| 322 | ORDER BY sequence ASC | ||
| 323 | LIMIT ${input.limit} | ||
| 324 | `; | ||
| 325 | |||
| 326 | const appendAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["appendAgentEvents"] = | ||
| 327 | (input) => | ||
| 328 | Effect.forEach( | ||
| 329 | input.events, | ||
| 330 | (event) => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const encoded = yield* encodeV2EventJson(event); | ||
| 333 | const rows = yield* sql<{ readonly sequence: number }>` | ||
| 334 | INSERT INTO orchestration_events ( | ||
| 335 | event_id, | ||
| 336 | aggregate_kind, | ||
| 337 | stream_id, | ||
| 338 | stream_version, | ||
| 339 | event_type, | ||
| 340 | occurred_at, | ||
| 341 | command_id, | ||
| 342 | causation_event_id, | ||
| 343 | correlation_id, | ||
| 344 | actor_kind, | ||
| 345 | payload_json, | ||
| 346 | metadata_json, | ||
| 347 | application_event_version | ||
| 348 | ) | ||
| 349 | VALUES ( | ||
| 350 | ${event.id}, | ||
| 351 | 'thread', | ||
| 352 | ${event.threadId}, | ||
| 353 | COALESCE( | ||
| 354 | ( | ||
| 355 | SELECT MAX(stream_version) + 1 | ||
| 356 | FROM orchestration_events | ||
| 357 | WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId} | ||
| 358 | ), | ||
| 359 | 0 | ||
| 360 | ), | ||
| 361 | ${event.type}, | ||
| 362 | ${encoded.occurredAt}, | ||
| 363 | ${input.commandId ?? null}, | ||
| 364 | NULL, | ||
| 365 | ${input.commandId ?? null}, | ||
| 366 | ${event.rawEventId === undefined ? "server" : "provider"}, | ||
| 367 | ${yield* encodeJson(encoded.payload)}, | ||
| 368 | ${yield* encodeJson(metadataForV2Event(event))}, | ||
| 369 | 2 | ||
| 370 | ) | ||
| 371 | RETURNING sequence | ||
| 372 | `; | ||
| 373 | return yield* decodeV2StoredEvent({ | ||
| 374 | sequence: rows[0]?.sequence, | ||
| 375 | commandId: input.commandId ?? null, | ||
| 376 | event, | ||
| 377 | }); | ||
| 378 | }), | ||
| 379 | { concurrency: 1 }, | ||
| 380 | ).pipe( | ||
| 381 | Effect.mapError( | ||
| 382 | toPersistenceSqlOrDecodeError( | ||
| 383 | "OrchestrationEventStore.appendAgentEvents:insert", | ||
| 384 | "OrchestrationEventStore.appendAgentEvents:decode", | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | |||
| 389 | const readAgentEvents: OrchestrationEventStore.OrchestrationEventStoreShape["readAgentEvents"] = ( | ||
| 390 | input, | ||
| 391 | ) => { | ||
| 392 | const totalLimit = | ||
| 393 | input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit)); | ||
| 394 | if (totalLimit === 0) { | ||
| 395 | return Stream.empty; | ||
| 396 | } | ||
| 397 | return Stream.paginate( | ||
| 398 | { cursor: input?.afterSequence ?? 0, remaining: totalLimit }, | ||
| 399 | ({ cursor, remaining }) => { | ||
| 400 | const pageLimit = Math.min(remaining, READ_PAGE_SIZE); | ||
| 401 | return readApplicationRows({ | ||
| 402 | afterSequence: cursor, | ||
| 403 | ...(input?.throughSequence === undefined | ||
| 404 | ? {} | ||
| 405 | : { throughSequence: input.throughSequence }), | ||
| 406 | ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), | ||
| 407 | ...(input?.commandId === undefined ? {} : { commandId: input.commandId }), | ||
| 408 | ...(input?.eventType === undefined ? {} : { eventType: input.eventType }), | ||
| 409 | onlyAgentEvents: true, | ||
| 410 | limit: pageLimit, | ||
| 411 | }).pipe( | ||
| 412 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")), | ||
| 413 | Effect.map((rows) => { | ||
| 414 | const last = rows.at(-1); | ||
| 415 | const nextRemaining = remaining - rows.length; | ||
| 416 | return [ | ||
| 417 | rows, | ||
| 418 | last === undefined || | ||
| 419 | rows.length < pageLimit || | ||
| 420 | nextRemaining <= 0 || | ||
| 421 | (input?.throughSequence !== undefined && last.sequence >= input.throughSequence) | ||
| 422 | ? Option.none() | ||
| 423 | : Option.some({ cursor: last.sequence, remaining: nextRemaining }), | ||
| 424 | ] as const; | ||
| 425 | }), | ||
| 426 | ); | ||
| 427 | }, | ||
| 428 | ).pipe( | ||
| 429 | Stream.mapEffect((row) => | ||
| 430 | rowToV2StoredEvent(row).pipe( | ||
| 431 | Effect.mapError( | ||
| 432 | toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"), | ||
| 433 | ), | ||
| 434 | ), | ||
| 435 | ), | ||
| 436 | ); | ||
| 437 | }; | ||
| 438 | |||
| 439 | const getAgentReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getAgentReplayStats"] = | ||
| 440 | (input) => | ||
| 441 | sql<{ | ||
| 442 | readonly eventCount: number; | ||
| 443 | readonly rawPayloadBytes: number; | ||
| 444 | readonly hasCreateEvent: number; | ||
| 445 | }>` | ||
| 446 | SELECT | ||
| 447 | COUNT(*) AS "eventCount", | ||
| 448 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", | ||
| 449 | COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent" | ||
| 450 | FROM ( | ||
| 451 | SELECT payload_json, event_type | ||
| 452 | FROM orchestration_events | ||
| 453 | WHERE aggregate_kind = 'thread' | ||
| 454 | AND stream_id = ${input.threadId} | ||
| 455 | AND application_event_version = 2 | ||
| 456 | AND sequence > ${input.afterSequence} | ||
| 457 | AND sequence <= ${input.throughSequence} | ||
| 458 | ORDER BY sequence ASC | ||
| 459 | LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1} | ||
| 460 | ) | ||
| 461 | `.pipe( | ||
| 462 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")), | ||
| 463 | Effect.map((rows) => ({ | ||
| 464 | eventCount: rows[0]?.eventCount ?? 0, | ||
| 465 | rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, | ||
| 466 | hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0, | ||
| 467 | })), | ||
| 468 | ); | ||
| 469 | |||
| 470 | const getReplayStats: OrchestrationEventStore.OrchestrationEventStoreShape["getReplayStats"] = ( | ||
| 471 | input, | ||
| 472 | ) => | ||
| 473 | sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` | ||
| 474 | SELECT | ||
| 475 | COUNT(*) AS "eventCount", | ||
| 476 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes" | ||
| 477 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water | ||
| 478 | WHERE sequence > ${input.afterSequence} | ||
| 479 | AND sequence <= ${input.throughSequence} | ||
| 480 | AND ( | ||
| 481 | aggregate_kind = 'project' | ||
| 482 | OR (application_event_version = 2 AND aggregate_kind = 'thread') | ||
| 483 | ) | ||
| 484 | `.pipe( | ||
| 485 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")), | ||
| 486 | Effect.map((rows) => ({ | ||
| 487 | eventCount: rows[0]?.eventCount ?? 0, | ||
| 488 | rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, | ||
| 489 | })), | ||
| 490 | ); | ||
| 491 | |||
| 492 | const latestAgentSequence: OrchestrationEventStore.OrchestrationEventStoreShape["latestAgentSequence"] = | ||
| 493 | (threadId) => | ||
| 494 | sql<{ readonly sequence: number | null }>` | ||
| 495 | SELECT MAX(sequence) AS sequence | ||
| 496 | FROM orchestration_events | ||
| 497 | ${ | ||
| 498 | threadId === undefined | ||
| 499 | ? sql`` | ||
| 500 | : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence` | ||
| 501 | } | ||
| 502 | WHERE application_event_version = 2 | ||
| 503 | AND aggregate_kind = 'thread' | ||
| 504 | ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} | ||
| 505 | `.pipe( | ||
| 506 | Effect.map((rows) => rows[0]?.sequence ?? 0), | ||
| 507 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), | ||
| 508 | ); | ||
| 509 | |||
| 510 | // The OR planner otherwise scans every V2 event instead of seeking the final sequence. | ||
| 511 | const latestApplicationSequence = sql<{ readonly sequence: number | null }>` | ||
| 512 | SELECT MAX(sequence) AS sequence | ||
| 513 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water | ||
| 514 | WHERE aggregate_kind = 'project' | ||
| 515 | OR (application_event_version = 2 AND aggregate_kind = 'thread') | ||
| 516 | `.pipe( | ||
| 517 | Effect.map((rows) => rows[0]?.sequence ?? 0), | ||
| 518 | Effect.mapError( | ||
| 519 | toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"), | ||
| 520 | ), | ||
| 521 | ); | ||
| 522 | |||
| 523 | const readApplicationEventPage = (input: { | ||
| 524 | readonly afterSequence: number; | ||
| 525 | readonly throughSequence: number; | ||
| 526 | readonly limit: number; | ||
| 527 | }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => | ||
| 528 | Stream.fromEffect( | ||
| 529 | readApplicationRows(input).pipe( | ||
| 530 | Effect.mapError( | ||
| 531 | toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"), | ||
| 532 | ), | ||
| 533 | ), | ||
| 534 | ).pipe( | ||
| 535 | Stream.flatMap(Stream.fromIterable), | ||
| 536 | Stream.mapEffect((row) => | ||
| 537 | rowToApplicationStoredEvent(row).pipe( | ||
| 538 | Effect.mapError( | ||
| 539 | toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"), | ||
| 540 | ), | ||
| 541 | ), | ||
| 542 | ), | ||
| 543 | ); | ||
| 544 | |||
| 545 | const catchUpApplicationEvents = (input: { | ||
| 546 | readonly afterSequence: number; | ||
| 547 | readonly throughSequence: number; | ||
| 548 | }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => { | ||
| 549 | return Stream.paginate(input.afterSequence, (afterSequence) => | ||
| 550 | readApplicationEventPage({ | ||
| 551 | afterSequence, | ||
| 552 | throughSequence: input.throughSequence, | ||
| 553 | limit: READ_PAGE_SIZE, | ||
| 554 | }).pipe( | ||
| 555 | Stream.runCollect, | ||
| 556 | Effect.map((events) => { | ||
| 557 | const last = events.at(-1); | ||
| 558 | return [ | ||
| 559 | events, | ||
| 560 | last === undefined || | ||
| 561 | events.length < READ_PAGE_SIZE || | ||
| 562 | last.sequence >= input.throughSequence | ||
| 563 | ? Option.none() | ||
| 564 | : Option.some(last.sequence), | ||
| 565 | ] as const; | ||
| 566 | }), | ||
| 567 | ), | ||
| 568 | ); | ||
| 569 | }; | ||
| 570 | |||
| 571 | const streamProjectedApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamProjectedApplicationEvents"] = | ||
| 572 | (input) => | ||
| 573 | replayAndBufferProjectedLiveEvents({ | ||
| 574 | subscribe: PubSub.subscribe(committedEvents), | ||
| 575 | latestSequence: latestApplicationSequence, | ||
| 576 | afterSequence: input.afterSequence ?? 0, | ||
| 577 | project: input.project, | ||
| 578 | replay: (throughSequence) => | ||
| 579 | catchUpApplicationEvents({ | ||
| 580 | afterSequence: input?.afterSequence ?? 0, | ||
| 581 | throughSequence, | ||
| 582 | }), | ||
| 583 | }).pipe( | ||
| 584 | Stream.catchTag("LiveStreamBufferError", (cause) => | ||
| 585 | Stream.fail( | ||
| 586 | toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause), | ||
| 587 | ), | ||
| 588 | ), | ||
| 589 | ); | ||
| 590 | |||
| 591 | const streamApplicationEvents: OrchestrationEventStore.OrchestrationEventStoreShape["streamApplicationEvents"] = | ||
| 592 | (input) => streamProjectedApplicationEvents({ ...input, project: (event) => event }); | ||
| 593 | |||
| 594 | return { | ||
| 595 | appendProjectEvent, | ||
| 596 | appendAgentEvents, | ||
| 597 | readAgentEvents, | ||
| 598 | getAgentReplayStats, | ||
| 599 | getReplayStats, | ||
| 600 | latestAgentSequence, | ||
| 601 | latestApplicationSequence, | ||
| 602 | readApplicationEvents: catchUpApplicationEvents, | ||
| 603 | publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), | ||
| 604 | streamApplicationEvents, | ||
| 605 | streamProjectedApplicationEvents, | ||
| 606 | } satisfies OrchestrationEventStore.OrchestrationEventStoreShape; | ||
| 607 | }); | ||
| 608 | |||
| 609 | export const layer = Layer.effect(OrchestrationEventStore.OrchestrationEventStore, makeEventStore); | ||
apps/server/src/persistence/Layers/Sqlite.test.ts deleted-93| ... | @@ -1,93 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | |||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { assert, it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 12 | |||
| 13 | import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts"; | ||
| 14 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 15 | |||
| 16 | const lockHolderSource = ` | ||
| 17 | const { DatabaseSync } = require("node:sqlite"); | ||
| 18 | const db = new DatabaseSync(process.argv[1]); | ||
| 19 | db.exec("BEGIN IMMEDIATE"); | ||
| 20 | process.stdout.write("locked\\n"); | ||
| 21 | setTimeout(() => { | ||
| 22 | db.exec("COMMIT"); | ||
| 23 | db.close(); | ||
| 24 | }, Number(process.argv[2])); | ||
| 25 | `; | ||
| 26 | |||
| 27 | const spawnWriteLockHolder = (dbPath: string, holdMs: number) => | ||
| 28 | Effect.promise( | ||
| 29 | () => | ||
| 30 | new Promise<void>((resolve, reject) => { | ||
| 31 | const holder = NodeChildProcess.spawn( | ||
| 32 | process.execPath, | ||
| 33 | ["-e", lockHolderSource, dbPath, String(holdMs)], | ||
| 34 | { stdio: ["ignore", "pipe", "ignore"] }, | ||
| 35 | ); | ||
| 36 | holder.stdout.once("data", () => resolve()); | ||
| 37 | holder.on("error", reject); | ||
| 38 | holder.on("exit", () => | ||
| 39 | reject(new Error("lock holder exited before acquiring the write lock")), | ||
| 40 | ); | ||
| 41 | }), | ||
| 42 | ); | ||
| 43 | |||
| 44 | it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { | ||
| 45 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); | ||
| 46 | const dbPath = NodePath.join(tempDir, "state.sqlite"); | ||
| 47 | |||
| 48 | return Effect.gen(function* () { | ||
| 49 | const sql = yield* SqlClient.SqlClient; | ||
| 50 | yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; | ||
| 51 | yield* spawnWriteLockHolder(dbPath, 300); | ||
| 52 | yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; | ||
| 53 | const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; | ||
| 54 | assert.deepEqual([...rows], [{ id: 1 }]); | ||
| 55 | }).pipe( | ||
| 56 | Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), | ||
| 57 | Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), | ||
| 58 | ); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.effect("shrinks the WAL file back to the size limit after a large write", () => { | ||
| 62 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); | ||
| 63 | const dbPath = NodePath.join(tempDir, "state.sqlite"); | ||
| 64 | const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; | ||
| 65 | // About 25% more 4 KB rows than the limit holds, in one transaction. | ||
| 66 | const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); | ||
| 67 | |||
| 68 | return Effect.gen(function* () { | ||
| 69 | const sql = yield* SqlClient.SqlClient; | ||
| 70 | yield* sql`CREATE TABLE wal_probe(payload BLOB)`; | ||
| 71 | yield* sql` | ||
| 72 | WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) | ||
| 73 | INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n | ||
| 74 | `; | ||
| 75 | assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); | ||
| 76 | |||
| 77 | // The auto-checkpoint after the large commit copied every frame into the | ||
| 78 | // database, so the next commit restarts the WAL and cuts the file back. | ||
| 79 | yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; | ||
| 80 | assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); | ||
| 81 | }).pipe( | ||
| 82 | Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), | ||
| 83 | Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), | ||
| 84 | ); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it.effect("applies busy_timeout in the shared persistence setup", () => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const sql = yield* SqlClient.SqlClient; | ||
| 90 | const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; | ||
| 91 | assert.equal(rows[0]?.timeout, 5000); | ||
| 92 | }).pipe(Effect.provide(SqlitePersistence.layerMemory)), | ||
| 93 | ); | ||
apps/server/src/persistence/Layers/Sqlite.ts deleted-57| ... | @@ -1,57 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 6 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 7 | |||
| 8 | import { runMigrations } from "../Migrations.ts"; | ||
| 9 | import { initializeV2Database } from "../initializeV2Database.ts"; | ||
| 10 | import * as ServerConfig from "../../config.ts"; | ||
| 11 | |||
| 12 | // Size the -wal file is cut back to on the first commit after a WAL reset. | ||
| 13 | export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; | ||
| 14 | |||
| 15 | const layerSetup = Layer.effectDiscard( | ||
| 16 | Effect.gen(function* () { | ||
| 17 | const sql = yield* SqlClient.SqlClient; | ||
| 18 | // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. | ||
| 19 | yield* sql`PRAGMA busy_timeout = 5000;`; | ||
| 20 | yield* sql`PRAGMA foreign_keys = ON;`; | ||
| 21 | yield* sql`PRAGMA journal_mode = WAL;`; | ||
| 22 | // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its | ||
| 23 | // largest size until the last connection closes. | ||
| 24 | yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); | ||
| 25 | yield* runMigrations(); | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | |||
| 29 | export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { | ||
| 30 | const fs = yield* FileSystem.FileSystem; | ||
| 31 | const path = yield* Path.Path; | ||
| 32 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); | ||
| 33 | |||
| 34 | return Layer.provideMerge( | ||
| 35 | layerSetup, | ||
| 36 | NodeSqliteClient.layer({ | ||
| 37 | filename: dbPath, | ||
| 38 | spanAttributes: { | ||
| 39 | "db.name": path.basename(dbPath), | ||
| 40 | "service.name": "t3code-server", | ||
| 41 | }, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | }, Layer.unwrap); | ||
| 45 | |||
| 46 | export const layerMemory = Layer.provideMerge( | ||
| 47 | layerSetup, | ||
| 48 | NodeSqliteClient.layer({ filename: ":memory:" }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | export const layerConfig = Layer.unwrap( | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const { dbPath } = yield* ServerConfig.ServerConfig; | ||
| 54 | yield* initializeV2Database(dbPath); | ||
| 55 | return layerFromPath(dbPath); | ||
| 56 | }), | ||
| 57 | ); | ||
apps/server/src/persistence/OrchestrationCommandReceipts.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | /** | ||
| 2 | * OrchestrationCommandReceiptRepository - Repository interface for command receipts. | ||
| 3 | * | ||
| 4 | * Owns persistence operations for deduplication and status tracking of | ||
| 5 | * orchestration command handling. | ||
| 6 | * | ||
| 7 | * @module OrchestrationCommandReceiptRepository | ||
| 8 | */ | ||
| 9 | import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 10 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 11 | import * as SqlSchema from "effect/sql/SqlSchema"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | toPersistenceSqlError, | ||
| 20 | type OrchestrationCommandReceiptRepositoryError, | ||
| 21 | } from "./Errors.ts"; | ||
| 22 | |||
| 23 | export const OrchestrationCommandReceipt = Schema.Struct({ | ||
| 24 | commandId: CommandId, | ||
| 25 | aggregateKind: Schema.Literals(["project", "thread"]), | ||
| 26 | aggregateId: Schema.Union([ProjectId, ThreadId]), | ||
| 27 | commandType: Schema.String, | ||
| 28 | acceptedAt: IsoDateTime, | ||
| 29 | resultSequence: NonNegativeInt, | ||
| 30 | status: Schema.Literals(["accepted", "rejected"]), | ||
| 31 | error: Schema.NullOr(Schema.String), | ||
| 32 | }); | ||
| 33 | export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; | ||
| 34 | |||
| 35 | export const GetByCommandIdInput = Schema.Struct({ | ||
| 36 | commandId: CommandId, | ||
| 37 | }); | ||
| 38 | export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. | ||
| 42 | */ | ||
| 43 | export class OrchestrationCommandReceiptRepository extends Context.Service< | ||
| 44 | OrchestrationCommandReceiptRepository, | ||
| 45 | { | ||
| 46 | readonly insertIfAbsent: ( | ||
| 47 | receipt: OrchestrationCommandReceipt, | ||
| 48 | ) => Effect.Effect<boolean, OrchestrationCommandReceiptRepositoryError>; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Insert or replace a command receipt row. | ||
| 52 | * | ||
| 53 | * Upserts by `commandId` for idempotent command-result tracking. | ||
| 54 | */ | ||
| 55 | readonly upsert: ( | ||
| 56 | receipt: OrchestrationCommandReceipt, | ||
| 57 | ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>; | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Read a command receipt by command id. | ||
| 61 | */ | ||
| 62 | readonly getByCommandId: ( | ||
| 63 | input: GetByCommandIdInput, | ||
| 64 | ) => Effect.Effect< | ||
| 65 | Option.Option<OrchestrationCommandReceipt>, | ||
| 66 | OrchestrationCommandReceiptRepositoryError | ||
| 67 | >; | ||
| 68 | } | ||
| 69 | >()("t3/persistence/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} | ||
| 70 | |||
| 71 | const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { | ||
| 72 | const sql = yield* SqlClient.SqlClient; | ||
| 73 | |||
| 74 | const upsertReceiptRow = SqlSchema.void({ | ||
| 75 | Request: OrchestrationCommandReceipt, | ||
| 76 | execute: (receipt) => | ||
| 77 | sql` | ||
| 78 | INSERT INTO orchestration_command_receipts ( | ||
| 79 | command_id, | ||
| 80 | aggregate_kind, | ||
| 81 | aggregate_id, | ||
| 82 | command_type, | ||
| 83 | accepted_at, | ||
| 84 | result_sequence, | ||
| 85 | status, | ||
| 86 | error | ||
| 87 | ) | ||
| 88 | VALUES ( | ||
| 89 | ${receipt.commandId}, | ||
| 90 | ${receipt.aggregateKind}, | ||
| 91 | ${receipt.aggregateId}, | ||
| 92 | ${receipt.commandType}, | ||
| 93 | ${receipt.acceptedAt}, | ||
| 94 | ${receipt.resultSequence}, | ||
| 95 | ${receipt.status}, | ||
| 96 | ${receipt.error} | ||
| 97 | ) | ||
| 98 | ON CONFLICT (command_id) | ||
| 99 | DO UPDATE SET | ||
| 100 | aggregate_kind = excluded.aggregate_kind, | ||
| 101 | aggregate_id = excluded.aggregate_id, | ||
| 102 | command_type = excluded.command_type, | ||
| 103 | accepted_at = excluded.accepted_at, | ||
| 104 | result_sequence = excluded.result_sequence, | ||
| 105 | status = excluded.status, | ||
| 106 | error = excluded.error | ||
| 107 | `, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const findReceiptByCommandId = SqlSchema.findOneOption({ | ||
| 111 | Request: GetByCommandIdInput, | ||
| 112 | Result: OrchestrationCommandReceipt, | ||
| 113 | execute: ({ commandId }) => | ||
| 114 | sql` | ||
| 115 | SELECT | ||
| 116 | command_id AS "commandId", | ||
| 117 | aggregate_kind AS "aggregateKind", | ||
| 118 | aggregate_id AS "aggregateId", | ||
| 119 | command_type AS "commandType", | ||
| 120 | accepted_at AS "acceptedAt", | ||
| 121 | result_sequence AS "resultSequence", | ||
| 122 | status, | ||
| 123 | error | ||
| 124 | FROM orchestration_command_receipts | ||
| 125 | WHERE command_id = ${commandId} | ||
| 126 | `, | ||
| 127 | }); | ||
| 128 | |||
| 129 | const upsert: OrchestrationCommandReceiptRepository["Service"]["upsert"] = (receipt) => | ||
| 130 | upsertReceiptRow(receipt).pipe( | ||
| 131 | Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), | ||
| 132 | ); | ||
| 133 | |||
| 134 | const insertIfAbsent: OrchestrationCommandReceiptRepository["Service"]["insertIfAbsent"] = ( | ||
| 135 | receipt, | ||
| 136 | ) => | ||
| 137 | sql<{ readonly command_id: string }>` | ||
| 138 | INSERT INTO orchestration_command_receipts ( | ||
| 139 | command_id, | ||
| 140 | aggregate_kind, | ||
| 141 | aggregate_id, | ||
| 142 | command_type, | ||
| 143 | accepted_at, | ||
| 144 | result_sequence, | ||
| 145 | status, | ||
| 146 | error | ||
| 147 | ) | ||
| 148 | VALUES ( | ||
| 149 | ${receipt.commandId}, | ||
| 150 | ${receipt.aggregateKind}, | ||
| 151 | ${receipt.aggregateId}, | ||
| 152 | ${receipt.commandType}, | ||
| 153 | ${receipt.acceptedAt}, | ||
| 154 | ${receipt.resultSequence}, | ||
| 155 | ${receipt.status}, | ||
| 156 | ${receipt.error} | ||
| 157 | ) | ||
| 158 | ON CONFLICT(command_id) DO NOTHING | ||
| 159 | RETURNING command_id | ||
| 160 | `.pipe( | ||
| 161 | Effect.map((rows) => rows.length === 1), | ||
| 162 | Effect.mapError( | ||
| 163 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.insertIfAbsent:query"), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | |||
| 167 | const getByCommandId: OrchestrationCommandReceiptRepository["Service"]["getByCommandId"] = ( | ||
| 168 | input, | ||
| 169 | ) => | ||
| 170 | findReceiptByCommandId(input).pipe( | ||
| 171 | Effect.mapError( | ||
| 172 | toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), | ||
| 173 | ), | ||
| 174 | ); | ||
| 175 | |||
| 176 | return { | ||
| 177 | insertIfAbsent, | ||
| 178 | upsert, | ||
| 179 | getByCommandId, | ||
| 180 | } satisfies OrchestrationCommandReceiptRepository["Service"]; | ||
| 181 | }); | ||
| 182 | |||
| 183 | export const layer = Layer.effect( | ||
| 184 | OrchestrationCommandReceiptRepository, | ||
| 185 | makeOrchestrationCommandReceiptRepository, | ||
| 186 | ); | ||
apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts created+274| ... | @@ -0,0 +1,274 @@ | ||
| 1 | import { CommandId, EventId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as Tracer from "effect/Tracer"; | ||
| 9 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 10 | |||
| 11 | import { runMigrations } from "./Migrations.ts"; | ||
| 12 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 13 | import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; | ||
| 14 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 15 | |||
| 16 | const layerEventStore = OrchestrationEventStore.layer.pipe( | ||
| 17 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 18 | ); | ||
| 19 | const occurredAt = "2026-09-03T00:00:00.000Z"; | ||
| 20 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 21 | |||
| 22 | function eventRow( | ||
| 23 | ordinal: number, | ||
| 24 | aggregateKind: "project" | "thread", | ||
| 25 | version: number, | ||
| 26 | streamId = `${aggregateKind}:${ordinal}`, | ||
| 27 | commandId: string | null = null, | ||
| 28 | ) { | ||
| 29 | return { | ||
| 30 | event_id: `event:${ordinal}`, | ||
| 31 | aggregate_kind: aggregateKind, | ||
| 32 | stream_id: streamId, | ||
| 33 | stream_version: ordinal, | ||
| 34 | event_type: aggregateKind === "project" ? "project.created" : "provider-session.detached", | ||
| 35 | occurred_at: occurredAt, | ||
| 36 | command_id: commandId, | ||
| 37 | causation_event_id: null, | ||
| 38 | correlation_id: null, | ||
| 39 | actor_kind: "server", | ||
| 40 | payload_json: encodeJson( | ||
| 41 | aggregateKind === "project" | ||
| 42 | ? { | ||
| 43 | projectId: streamId, | ||
| 44 | title: "Sequence fixture", | ||
| 45 | workspaceRoot: "/tmp/sequence-fixture", | ||
| 46 | defaultModelSelection: null, | ||
| 47 | scripts: [], | ||
| 48 | createdAt: occurredAt, | ||
| 49 | updatedAt: occurredAt, | ||
| 50 | } | ||
| 51 | : { providerSessionId: `session:${ordinal}`, detachedAt: occurredAt }, | ||
| 52 | ), | ||
| 53 | metadata_json: "{}", | ||
| 54 | application_event_version: version, | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | |||
| 58 | const seedEvents = Effect.fn("test.seedSequenceEvents")(function* ( | ||
| 59 | rows: ReadonlyArray<ReturnType<typeof eventRow>>, | ||
| 60 | ) { | ||
| 61 | const sql = yield* SqlClient.SqlClient; | ||
| 62 | return yield* sql<{ | ||
| 63 | readonly sequence: number; | ||
| 64 | readonly aggregate_kind: string; | ||
| 65 | readonly application_event_version: number; | ||
| 66 | readonly stream_id: string; | ||
| 67 | readonly command_id: string | null; | ||
| 68 | }>` | ||
| 69 | INSERT INTO orchestration_events ${sql.insert(rows)} | ||
| 70 | RETURNING sequence, aggregate_kind, application_event_version, stream_id, command_id | ||
| 71 | `; | ||
| 72 | }); | ||
| 73 | |||
| 74 | it.effect("keeps application and scoped agent high-water marks separate from legacy history", () => | ||
| 75 | Effect.gen(function* () { | ||
| 76 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 77 | const sql = yield* SqlClient.SqlClient; | ||
| 78 | const target = ThreadId.make("target"); | ||
| 79 | assert.equal(yield* store.latestApplicationSequence, 0); | ||
| 80 | assert.equal(yield* store.latestAgentSequence(), 0); | ||
| 81 | assert.equal(yield* store.latestAgentSequence(target), 0); | ||
| 82 | |||
| 83 | yield* seedEvents([eventRow(1, "thread", 1, "legacy-only")]); | ||
| 84 | assert.equal(yield* store.latestApplicationSequence, 0); | ||
| 85 | assert.equal(yield* store.latestAgentSequence(ThreadId.make("legacy-only")), 0); | ||
| 86 | |||
| 87 | const [legacyProject, targetEvent, otherEvent] = yield* seedEvents([ | ||
| 88 | eventRow(2, "project", 1), | ||
| 89 | eventRow(3, "thread", 2, target), | ||
| 90 | eventRow(4, "thread", 2, "other"), | ||
| 91 | ]); | ||
| 92 | assert.ok(legacyProject); | ||
| 93 | assert.ok(targetEvent); | ||
| 94 | assert.ok(otherEvent); | ||
| 95 | assert.equal(yield* store.latestApplicationSequence, otherEvent.sequence); | ||
| 96 | assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); | ||
| 97 | assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); | ||
| 98 | |||
| 99 | const [projectEvent] = yield* seedEvents([ | ||
| 100 | eventRow(5, "project", 2), | ||
| 101 | eventRow(6, "thread", 1, target), | ||
| 102 | eventRow(7, "thread", 3, target), | ||
| 103 | ]); | ||
| 104 | assert.ok(projectEvent); | ||
| 105 | assert.equal(yield* store.latestApplicationSequence, projectEvent.sequence); | ||
| 106 | assert.equal(yield* store.latestAgentSequence(), otherEvent.sequence); | ||
| 107 | assert.equal(yield* store.latestAgentSequence(target), targetEvent.sequence); | ||
| 108 | assert.equal(yield* store.latestAgentSequence(ThreadId.make("missing")), 0); | ||
| 109 | |||
| 110 | yield* sql`DELETE FROM orchestration_events WHERE sequence > ${legacyProject.sequence}`; | ||
| 111 | assert.equal(yield* store.latestApplicationSequence, legacyProject.sequence); | ||
| 112 | assert.equal(yield* store.latestAgentSequence(), 0); | ||
| 113 | assert.equal(yield* store.latestAgentSequence(target), 0); | ||
| 114 | }).pipe(Effect.provide(Layer.fresh(layerEventStore))), | ||
| 115 | ); | ||
| 116 | |||
| 117 | it.effect( | ||
| 118 | "preserves mixed application paging, scoped replay, and the catch-up to live boundary", | ||
| 119 | () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 122 | const rows = yield* seedEvents( | ||
| 123 | Array.from({ length: 1_560 }, (_, index) => { | ||
| 124 | const kind = index % 3 === 1 ? "project" : "thread"; | ||
| 125 | return eventRow( | ||
| 126 | index, | ||
| 127 | kind, | ||
| 128 | index % 3 === 0 ? 1 : 2, | ||
| 129 | kind === "project" ? `project:${index}` : index % 2 === 0 ? "target" : "other", | ||
| 130 | index % 5 === 0 ? "selected-command" : "other-command", | ||
| 131 | ); | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | const afterSequence = rows[25]!.sequence; | ||
| 135 | const throughSequence = rows[1_525]!.sequence; | ||
| 136 | const retained = rows.filter( | ||
| 137 | (row) => row.aggregate_kind === "project" || row.application_event_version === 2, | ||
| 138 | ); | ||
| 139 | const replay = yield* store | ||
| 140 | .readApplicationEvents({ afterSequence, throughSequence }) | ||
| 141 | .pipe(Stream.runCollect); | ||
| 142 | assert.deepEqual( | ||
| 143 | replay.map((event) => event.sequence), | ||
| 144 | retained | ||
| 145 | .filter((row) => row.sequence > afterSequence && row.sequence <= throughSequence) | ||
| 146 | .map((row) => row.sequence), | ||
| 147 | ); | ||
| 148 | |||
| 149 | const scoped = yield* store | ||
| 150 | .readAgentEvents({ | ||
| 151 | afterSequence, | ||
| 152 | throughSequence, | ||
| 153 | threadId: ThreadId.make("target"), | ||
| 154 | commandId: CommandId.make("selected-command"), | ||
| 155 | limit: 17, | ||
| 156 | }) | ||
| 157 | .pipe(Stream.runCollect); | ||
| 158 | assert.deepEqual( | ||
| 159 | scoped.map((event) => event.sequence), | ||
| 160 | retained | ||
| 161 | .filter( | ||
| 162 | (row) => | ||
| 163 | row.sequence > afterSequence && | ||
| 164 | row.sequence <= throughSequence && | ||
| 165 | row.aggregate_kind === "thread" && | ||
| 166 | row.stream_id === "target" && | ||
| 167 | row.command_id === "selected-command", | ||
| 168 | ) | ||
| 169 | .slice(0, 17) | ||
| 170 | .map((row) => row.sequence), | ||
| 171 | ); | ||
| 172 | |||
| 173 | const catchUp = retained.filter((row) => row.sequence > afterSequence); | ||
| 174 | const highWater = catchUp.at(-1)!.sequence; | ||
| 175 | let liveSequence = 0; | ||
| 176 | const streamed = yield* store.streamApplicationEvents({ afterSequence }).pipe( | ||
| 177 | Stream.tap((event) => | ||
| 178 | event.sequence !== highWater | ||
| 179 | ? Effect.void | ||
| 180 | : Effect.gen(function* () { | ||
| 181 | const [stored] = yield* store.appendAgentEvents({ | ||
| 182 | events: [ | ||
| 183 | { | ||
| 184 | id: EventId.make("live-after-catch-up"), | ||
| 185 | type: "provider-session.detached", | ||
| 186 | threadId: ThreadId.make("target"), | ||
| 187 | occurredAt: DateTime.makeUnsafe(occurredAt), | ||
| 188 | payload: { | ||
| 189 | providerSessionId: ProviderSessionId.make("live-session"), | ||
| 190 | detachedAt: DateTime.makeUnsafe(occurredAt), | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | ], | ||
| 194 | }); | ||
| 195 | assert.ok(stored); | ||
| 196 | liveSequence = stored.sequence; | ||
| 197 | yield* store.publishCommitted([stored]); | ||
| 198 | }), | ||
| 199 | ), | ||
| 200 | Stream.take(catchUp.length + 1), | ||
| 201 | Stream.runCollect, | ||
| 202 | ); | ||
| 203 | assert.deepEqual( | ||
| 204 | streamed.map((event) => event.sequence), | ||
| 205 | [...catchUp.map((row) => row.sequence), liveSequence], | ||
| 206 | ); | ||
| 207 | assert.isAbove(liveSequence, highWater); | ||
| 208 | }).pipe(Effect.provide(Layer.fresh(layerEventStore))), | ||
| 209 | ); | ||
| 210 | |||
| 211 | it.effect("uses indexed high-water lookups for populated history without OR scans", () => | ||
| 212 | Effect.gen(function* () { | ||
| 213 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 214 | const sql = yield* SqlClient.SqlClient; | ||
| 215 | yield* runMigrations(); | ||
| 216 | yield* sql` | ||
| 217 | WITH RECURSIVE history(n) AS ( | ||
| 218 | SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 | ||
| 219 | ) | ||
| 220 | INSERT INTO orchestration_events ( | ||
| 221 | event_id, aggregate_kind, stream_id, stream_version, event_type, | ||
| 222 | occurred_at, actor_kind, payload_json, metadata_json, application_event_version | ||
| 223 | ) | ||
| 224 | SELECT 'history:' || n, 'thread', | ||
| 225 | CASE WHEN n = 1 THEN 'target' ELSE 'other' END, | ||
| 226 | n, 'provider-session.detached', ${occurredAt}, 'server', '{}', '{}', | ||
| 227 | CASE WHEN n % 3 = 0 THEN 1 ELSE 2 END | ||
| 228 | FROM history | ||
| 229 | `; | ||
| 230 | const statements: Array<string> = []; | ||
| 231 | const tracer = Tracer.make({ | ||
| 232 | span(options) { | ||
| 233 | const span = new Tracer.NativeSpan(options); | ||
| 234 | const end = span.end.bind(span); | ||
| 235 | span.end = (endTime, exit) => { | ||
| 236 | end(endTime, exit); | ||
| 237 | const query = span.attributes.get("db.query.text"); | ||
| 238 | if (typeof query === "string") statements.push(query); | ||
| 239 | }; | ||
| 240 | return span; | ||
| 241 | }, | ||
| 242 | }); | ||
| 243 | assert.equal(yield* store.latestApplicationSequence.pipe(Effect.withTracer(tracer)), 25_000); | ||
| 244 | assert.equal( | ||
| 245 | yield* store.latestAgentSequence(ThreadId.make("target")).pipe(Effect.withTracer(tracer)), | ||
| 246 | 1, | ||
| 247 | ); | ||
| 248 | assert.equal(statements.length, 2); | ||
| 249 | const applicationPlan = yield* sql.unsafe<{ readonly detail: string }>( | ||
| 250 | `EXPLAIN QUERY PLAN ${statements[0]}`, | ||
| 251 | ); | ||
| 252 | const agentPlan = yield* sql.unsafe<{ readonly detail: string }>( | ||
| 253 | `EXPLAIN QUERY PLAN ${statements[1]}`, | ||
| 254 | ["target"], | ||
| 255 | ); | ||
| 256 | assert.match( | ||
| 257 | applicationPlan.map((row) => row.detail).join("\n"), | ||
| 258 | /SEARCH orchestration_events USING INDEX idx_orchestration_events_application_high_water/, | ||
| 259 | ); | ||
| 260 | assert.match( | ||
| 261 | agentPlan.map((row) => row.detail).join("\n"), | ||
| 262 | /USING COVERING INDEX idx_orchestration_events_agent_stream_sequence \(stream_id=\?\)/, | ||
| 263 | ); | ||
| 264 | for (const row of [...applicationPlan, ...agentPlan]) { | ||
| 265 | assert.notMatch(row.detail, /MULTI-INDEX OR|TEMP B-TREE/); | ||
| 266 | } | ||
| 267 | }).pipe( | ||
| 268 | Effect.provide( | ||
| 269 | OrchestrationEventStore.layer.pipe( | ||
| 270 | Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), | ||
| 271 | ), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ); | ||
apps/server/src/persistence/OrchestrationEventStore.test.ts created+521| ... | @@ -0,0 +1,521 @@ | ||
| 1 | import * as NodeV8 from "node:v8"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CommandId, | ||
| 5 | EventId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | TurnItemId, | ||
| 10 | type OrchestrationV2DomainEvent, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { assert, it } from "@effect/vitest"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Stream from "effect/Stream"; | ||
| 20 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 21 | import * as Statement from "effect/sql/Statement"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | LIVE_STREAM_MAX_ITEMS, | ||
| 25 | LiveStreamBufferError, | ||
| 26 | } from "../orchestration-v2/LiveStreamBudget.ts"; | ||
| 27 | import { PersistenceDecodeError } from "./Errors.ts"; | ||
| 28 | import { toShellApplicationEvent } from "../orchestration-v2/ShellStream.ts"; | ||
| 29 | import * as OrchestrationEventStore from "./OrchestrationEventStore.ts"; | ||
| 30 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 31 | const isPersistenceDecodeError = Schema.is(PersistenceDecodeError); | ||
| 32 | const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); | ||
| 33 | |||
| 34 | const layerTest = OrchestrationEventStore.layer.pipe( | ||
| 35 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 36 | ); | ||
| 37 | const layer = it.layer(layerTest); | ||
| 38 | |||
| 39 | layer("OrchestrationEventStore", (it) => { | ||
| 40 | it.effect("retains only shell metadata from oversized replay and live application events", () => | ||
| 41 | Effect.scoped( | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 44 | const afterSequence = yield* store.latestApplicationSequence; | ||
| 45 | const now = yield* DateTime.now; | ||
| 46 | const threadId = ThreadId.make("thread:large-shell-body"); | ||
| 47 | const input = "x".repeat(9 * 1024 * 1024); | ||
| 48 | const event: OrchestrationV2DomainEvent = { | ||
| 49 | id: EventId.make("event:large-shell-body:replay"), | ||
| 50 | type: "turn-item.updated", | ||
| 51 | threadId, | ||
| 52 | occurredAt: now, | ||
| 53 | payload: { | ||
| 54 | id: TurnItemId.make("tool:large-shell-body"), | ||
| 55 | type: "command_execution", | ||
| 56 | threadId, | ||
| 57 | runId: null, | ||
| 58 | nodeId: null, | ||
| 59 | providerThreadId: null, | ||
| 60 | providerTurnId: null, | ||
| 61 | nativeItemRef: null, | ||
| 62 | parentItemId: null, | ||
| 63 | ordinal: 1, | ||
| 64 | status: "running", | ||
| 65 | title: "Large command", | ||
| 66 | input, | ||
| 67 | startedAt: now, | ||
| 68 | completedAt: null, | ||
| 69 | updatedAt: now, | ||
| 70 | }, | ||
| 71 | }; | ||
| 72 | const [replayed] = yield* store.appendAgentEvents({ events: [event] }); | ||
| 73 | const pull = yield* Stream.toPull( | ||
| 74 | store.streamProjectedApplicationEvents({ | ||
| 75 | afterSequence, | ||
| 76 | project: toShellApplicationEvent, | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | assert.deepEqual(yield* pull, [{ sequence: replayed!.sequence, event: { threadId } }]); | ||
| 80 | const [live] = yield* store.appendAgentEvents({ | ||
| 81 | events: [ | ||
| 82 | { | ||
| 83 | ...event, | ||
| 84 | id: EventId.make("event:large-shell-body:live"), | ||
| 85 | payload: { ...event.payload, status: "completed", completedAt: now }, | ||
| 86 | }, | ||
| 87 | ], | ||
| 88 | }); | ||
| 89 | yield* store.publishCommitted([live!]); | ||
| 90 | assert.deepEqual(yield* pull, [{ sequence: live!.sequence, event: { threadId } }]); | ||
| 91 | const durable = yield* store | ||
| 92 | .readAgentEvents({ threadId, afterSequence }) | ||
| 93 | .pipe(Stream.runCollect); | ||
| 94 | assert.lengthOf(durable, 2); | ||
| 95 | for (const stored of durable) { | ||
| 96 | assert.equal(stored.event.type, "turn-item.updated"); | ||
| 97 | if (stored.event.type !== "turn-item.updated") return; | ||
| 98 | assert.equal(stored.event.payload.type, "command_execution"); | ||
| 99 | if (stored.event.payload.type !== "command_execution") return; | ||
| 100 | assert.equal(stored.event.payload.input, input); | ||
| 101 | } | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | |||
| 106 | it.effect("stores json columns as strings and replays CLI-origin events", () => | ||
| 107 | Effect.gen(function* () { | ||
| 108 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 109 | const sql = yield* SqlClient.SqlClient; | ||
| 110 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 111 | |||
| 112 | const appended = yield* eventStore.appendProjectEvent({ | ||
| 113 | type: "project.created", | ||
| 114 | eventId: EventId.make("evt-store-roundtrip"), | ||
| 115 | aggregateKind: "project", | ||
| 116 | aggregateId: ProjectId.make("project-roundtrip"), | ||
| 117 | occurredAt: now, | ||
| 118 | commandId: CommandId.make("cmd-store-roundtrip"), | ||
| 119 | causationEventId: null, | ||
| 120 | correlationId: CommandId.make("cmd-store-roundtrip"), | ||
| 121 | metadata: { | ||
| 122 | adapterKey: "codex", | ||
| 123 | origin: { | ||
| 124 | surface: "cli", | ||
| 125 | }, | ||
| 126 | }, | ||
| 127 | payload: { | ||
| 128 | projectId: ProjectId.make("project-roundtrip"), | ||
| 129 | title: "Roundtrip Project", | ||
| 130 | workspaceRoot: "/tmp/project-roundtrip", | ||
| 131 | defaultModelSelection: null, | ||
| 132 | scripts: [], | ||
| 133 | createdAt: now, | ||
| 134 | updatedAt: now, | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | const storedRows = yield* sql<{ | ||
| 139 | readonly payloadJson: string; | ||
| 140 | readonly metadataJson: string; | ||
| 141 | }>` | ||
| 142 | SELECT | ||
| 143 | payload_json AS "payloadJson", | ||
| 144 | metadata_json AS "metadataJson" | ||
| 145 | FROM orchestration_events | ||
| 146 | WHERE event_id = ${appended.eventId} | ||
| 147 | `; | ||
| 148 | assert.equal(storedRows.length, 1); | ||
| 149 | assert.equal(typeof storedRows[0]?.payloadJson, "string"); | ||
| 150 | assert.equal(typeof storedRows[0]?.metadataJson, "string"); | ||
| 151 | |||
| 152 | const replayed = yield* eventStore | ||
| 153 | .readApplicationEvents({ | ||
| 154 | afterSequence: appended.sequence - 1, | ||
| 155 | throughSequence: appended.sequence, | ||
| 156 | }) | ||
| 157 | .pipe( | ||
| 158 | Stream.runCollect, | ||
| 159 | Effect.map((chunk) => Array.from(chunk)), | ||
| 160 | ); | ||
| 161 | assert.equal(replayed.length, 1); | ||
| 162 | const [project] = replayed; | ||
| 163 | if (project === undefined || !("aggregateKind" in project)) { | ||
| 164 | return assert.fail("Expected a project event"); | ||
| 165 | } | ||
| 166 | assert.equal(project.type, "project.created"); | ||
| 167 | assert.equal(project.metadata.adapterKey, "codex"); | ||
| 168 | assert.deepEqual(project.metadata.origin, { surface: "cli" }); | ||
| 169 | }), | ||
| 170 | ); | ||
| 171 | |||
| 172 | it.effect("fails with PersistenceDecodeError when stored json is invalid", () => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 175 | const sql = yield* SqlClient.SqlClient; | ||
| 176 | const now = "2026-01-01T00:00:00.000Z"; | ||
| 177 | |||
| 178 | yield* sql` | ||
| 179 | INSERT INTO orchestration_events ( | ||
| 180 | event_id, | ||
| 181 | aggregate_kind, | ||
| 182 | stream_id, | ||
| 183 | stream_version, | ||
| 184 | event_type, | ||
| 185 | occurred_at, | ||
| 186 | command_id, | ||
| 187 | causation_event_id, | ||
| 188 | correlation_id, | ||
| 189 | actor_kind, | ||
| 190 | payload_json, | ||
| 191 | metadata_json | ||
| 192 | ) | ||
| 193 | VALUES ( | ||
| 194 | ${EventId.make("evt-store-invalid-json")}, | ||
| 195 | ${"project"}, | ||
| 196 | ${ProjectId.make("project-invalid-json")}, | ||
| 197 | ${0}, | ||
| 198 | ${"project.created"}, | ||
| 199 | ${now}, | ||
| 200 | ${CommandId.make("cmd-store-invalid-json")}, | ||
| 201 | ${null}, | ||
| 202 | ${null}, | ||
| 203 | ${"server"}, | ||
| 204 | ${"{"}, | ||
| 205 | ${"{}"} | ||
| 206 | ) | ||
| 207 | `; | ||
| 208 | |||
| 209 | const replayResult = yield* Effect.result( | ||
| 210 | Stream.runCollect( | ||
| 211 | eventStore.readApplicationEvents({ | ||
| 212 | afterSequence: 0, | ||
| 213 | throughSequence: Number.MAX_SAFE_INTEGER, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ); | ||
| 217 | assert.equal(replayResult._tag, "Failure"); | ||
| 218 | if (replayResult._tag === "Failure") { | ||
| 219 | assert.ok(isPersistenceDecodeError(replayResult.failure)); | ||
| 220 | } | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | |||
| 224 | it.effect("orders project and V2 agent events in the retained application event source", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const eventStore = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 227 | const projectId = ProjectId.make("project-shared-stream"); | ||
| 228 | const threadId = ThreadId.make("thread-shared-stream"); | ||
| 229 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 230 | const occurredAt = DateTime.makeUnsafe("2026-01-02T00:00:00.000Z"); | ||
| 231 | const now = DateTime.formatIso(occurredAt); | ||
| 232 | const baselineSequence = yield* eventStore.latestApplicationSequence; | ||
| 233 | |||
| 234 | const projectEvent = yield* eventStore.appendProjectEvent({ | ||
| 235 | type: "project.created", | ||
| 236 | eventId: EventId.make("event-project-shared-stream"), | ||
| 237 | aggregateKind: "project", | ||
| 238 | aggregateId: projectId, | ||
| 239 | occurredAt: now, | ||
| 240 | commandId: CommandId.make("command-project-shared-stream"), | ||
| 241 | causationEventId: null, | ||
| 242 | correlationId: CommandId.make("command-project-shared-stream"), | ||
| 243 | metadata: {}, | ||
| 244 | payload: { | ||
| 245 | projectId, | ||
| 246 | title: "Shared stream", | ||
| 247 | workspaceRoot: "/tmp/shared-stream", | ||
| 248 | defaultModelSelection: null, | ||
| 249 | scripts: [], | ||
| 250 | createdAt: now, | ||
| 251 | updatedAt: now, | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | const [threadEvent] = yield* eventStore.appendAgentEvents({ | ||
| 255 | commandId: CommandId.make("command-thread-shared-stream"), | ||
| 256 | events: [ | ||
| 257 | { | ||
| 258 | id: EventId.make("event-thread-shared-stream"), | ||
| 259 | type: "thread.created", | ||
| 260 | threadId, | ||
| 261 | providerInstanceId, | ||
| 262 | occurredAt, | ||
| 263 | payload: { | ||
| 264 | id: threadId, | ||
| 265 | projectId, | ||
| 266 | title: "Thread", | ||
| 267 | providerInstanceId, | ||
| 268 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, | ||
| 269 | runtimeMode: "full-access", | ||
| 270 | interactionMode: "default", | ||
| 271 | branch: null, | ||
| 272 | worktreePath: null, | ||
| 273 | activeProviderThreadId: null, | ||
| 274 | lineage: { | ||
| 275 | rootThreadId: threadId, | ||
| 276 | parentThreadId: null, | ||
| 277 | relationshipToParent: null, | ||
| 278 | }, | ||
| 279 | forkedFrom: null, | ||
| 280 | createdBy: "user", | ||
| 281 | creationSource: "web", | ||
| 282 | createdAt: occurredAt, | ||
| 283 | updatedAt: occurredAt, | ||
| 284 | archivedAt: null, | ||
| 285 | settledOverride: null, | ||
| 286 | settledAt: null, | ||
| 287 | lastVisitedAt: null, | ||
| 288 | deletedAt: null, | ||
| 289 | }, | ||
| 290 | }, | ||
| 291 | ], | ||
| 292 | }); | ||
| 293 | |||
| 294 | const applicationEvents = yield* eventStore | ||
| 295 | .streamApplicationEvents({ afterSequence: baselineSequence }) | ||
| 296 | .pipe( | ||
| 297 | Stream.take(2), | ||
| 298 | Stream.runCollect, | ||
| 299 | Effect.map((chunk) => Array.from(chunk)), | ||
| 300 | ); | ||
| 301 | assert.deepEqual( | ||
| 302 | applicationEvents.map((event) => event.sequence), | ||
| 303 | [projectEvent.sequence, threadEvent!.sequence], | ||
| 304 | ); | ||
| 305 | assert.isTrue("aggregateKind" in applicationEvents[0]!); | ||
| 306 | assert.isTrue("event" in applicationEvents[1]!); | ||
| 307 | |||
| 308 | const finiteReplay = yield* eventStore | ||
| 309 | .readApplicationEvents({ | ||
| 310 | afterSequence: baselineSequence, | ||
| 311 | throughSequence: threadEvent!.sequence, | ||
| 312 | }) | ||
| 313 | .pipe( | ||
| 314 | Stream.runCollect, | ||
| 315 | Effect.map((chunk) => Array.from(chunk)), | ||
| 316 | ); | ||
| 317 | assert.deepEqual( | ||
| 318 | finiteReplay.map((event) => event.sequence), | ||
| 319 | [projectEvent.sequence, threadEvent!.sequence], | ||
| 320 | ); | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | it.effect("measures only a bounded thread replay before decoding its payloads", () => | ||
| 324 | Effect.gen(function* () { | ||
| 325 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 326 | const sql = yield* SqlClient.SqlClient; | ||
| 327 | const threadId = ThreadId.make("measured-replay-thread"); | ||
| 328 | const baseline = yield* store.latestApplicationSequence; | ||
| 329 | const unicodePayload = '{"text":"🦊"}'; | ||
| 330 | const oversizedPayload = "{" + "x".repeat(1_048_576); | ||
| 331 | let streamVersion = 0; | ||
| 332 | const insert = Effect.fn(function* (input: { | ||
| 333 | readonly id: string; | ||
| 334 | readonly payload: string; | ||
| 335 | readonly type?: string; | ||
| 336 | readonly threadId?: string; | ||
| 337 | readonly version?: number; | ||
| 338 | }) { | ||
| 339 | const rows = yield* sql<{ readonly sequence: number }>` | ||
| 340 | INSERT INTO orchestration_events ( | ||
| 341 | event_id, aggregate_kind, stream_id, stream_version, event_type, | ||
| 342 | occurred_at, actor_kind, payload_json, metadata_json, application_event_version | ||
| 343 | ) VALUES ( | ||
| 344 | ${input.id}, 'thread', ${input.threadId ?? threadId}, | ||
| 345 | ${streamVersion++}, ${input.type ?? "thread.metadata-updated"}, | ||
| 346 | '2026-09-04T00:00:00.000Z', 'server', ${input.payload}, '{}', ${input.version ?? 2} | ||
| 347 | ) RETURNING sequence | ||
| 348 | `; | ||
| 349 | return rows[0]!.sequence; | ||
| 350 | }); | ||
| 351 | const first = yield* insert({ | ||
| 352 | id: "measured-replay-1", | ||
| 353 | payload: unicodePayload, | ||
| 354 | type: "thread.created", | ||
| 355 | }); | ||
| 356 | yield* insert({ | ||
| 357 | id: "measured-unrelated", | ||
| 358 | threadId: "other-replay-thread", | ||
| 359 | payload: oversizedPayload, | ||
| 360 | }); | ||
| 361 | yield* insert({ id: "measured-legacy", payload: oversizedPayload, version: 1 }); | ||
| 362 | const head = yield* insert({ id: "measured-replay-02", payload: oversizedPayload }); | ||
| 363 | const later = yield* insert({ id: "measured-replay-003", payload: "{}" }); | ||
| 364 | const range = { threadId, afterSequence: baseline, throughSequence: head, maxEvents: 128 }; | ||
| 365 | assert.deepEqual(yield* store.getAgentReplayStats(range), { | ||
| 366 | eventCount: 2, | ||
| 367 | rawPayloadBytes: Buffer.byteLength(unicodePayload) + Buffer.byteLength(oversizedPayload), | ||
| 368 | hasCreateEvent: true, | ||
| 369 | }); | ||
| 370 | assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: first }), { | ||
| 371 | eventCount: 1, | ||
| 372 | rawPayloadBytes: Buffer.byteLength(oversizedPayload), | ||
| 373 | hasCreateEvent: false, | ||
| 374 | }); | ||
| 375 | assert.equal( | ||
| 376 | (yield* store.getAgentReplayStats({ ...range, throughSequence: later, maxEvents: 1 })) | ||
| 377 | .eventCount, | ||
| 378 | 2, | ||
| 379 | ); | ||
| 380 | assert.deepEqual(yield* store.getAgentReplayStats({ ...range, afterSequence: later }), { | ||
| 381 | eventCount: 0, | ||
| 382 | rawPayloadBytes: 0, | ||
| 383 | hasCreateEvent: false, | ||
| 384 | }); | ||
| 385 | // The application range skips V1 rows, which a shell resume never replays. | ||
| 386 | assert.deepEqual( | ||
| 387 | yield* store.getReplayStats({ afterSequence: baseline, throughSequence: head }), | ||
| 388 | { | ||
| 389 | eventCount: 3, | ||
| 390 | rawPayloadBytes: | ||
| 391 | Buffer.byteLength(unicodePayload) + 2 * Buffer.byteLength(oversizedPayload), | ||
| 392 | }, | ||
| 393 | ); | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | }); | ||
| 397 | |||
| 398 | it.effect.each(["high-water", "replay"] as const)( | ||
| 399 | "bounds application live events while the %s query is blocked", | ||
| 400 | (phase) => | ||
| 401 | Effect.scoped( | ||
| 402 | Effect.gen(function* () { | ||
| 403 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 404 | const now = "2026-01-03T00:00:00.000Z"; | ||
| 405 | const projectId = ProjectId.make(`project:blocked-${phase}`); | ||
| 406 | const projectEvent = yield* store.appendProjectEvent({ | ||
| 407 | type: "project.created", | ||
| 408 | eventId: EventId.make(`event:blocked-${phase}:created`), | ||
| 409 | aggregateKind: "project", | ||
| 410 | aggregateId: projectId, | ||
| 411 | occurredAt: now, | ||
| 412 | commandId: CommandId.make(`command:blocked-${phase}`), | ||
| 413 | causationEventId: null, | ||
| 414 | correlationId: null, | ||
| 415 | metadata: {}, | ||
| 416 | payload: { | ||
| 417 | projectId, | ||
| 418 | title: "Blocked stream", | ||
| 419 | workspaceRoot: "/tmp/blocked-stream", | ||
| 420 | defaultModelSelection: null, | ||
| 421 | scripts: [], | ||
| 422 | createdAt: now, | ||
| 423 | updatedAt: now, | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | if (projectEvent.type !== "project.created") | ||
| 427 | return yield* Effect.die("Expected project.created"); | ||
| 428 | const readStarted = yield* Deferred.make<void>(); | ||
| 429 | const readClosed = yield* Deferred.make<void>(); | ||
| 430 | const blockRead: Statement.Transformer = (statement) => { | ||
| 431 | const [query] = statement.compile(); | ||
| 432 | if ( | ||
| 433 | query.includes("FROM orchestration_events") && | ||
| 434 | query.includes("MAX(sequence)") === (phase === "high-water") | ||
| 435 | ) { | ||
| 436 | return Deferred.succeed(readStarted, undefined).pipe( | ||
| 437 | Effect.andThen(Effect.never), | ||
| 438 | Effect.ensuring(Deferred.succeed(readClosed, undefined)), | ||
| 439 | Effect.as(statement), | ||
| 440 | ); | ||
| 441 | } | ||
| 442 | return Effect.succeed(statement); | ||
| 443 | }; | ||
| 444 | const reader = yield* store | ||
| 445 | .streamApplicationEvents({ afterSequence: projectEvent.sequence }) | ||
| 446 | .pipe( | ||
| 447 | Stream.provideService(Statement.CurrentTransformer, blockRead), | ||
| 448 | Stream.runDrain, | ||
| 449 | Effect.result, | ||
| 450 | Effect.forkScoped, | ||
| 451 | ); | ||
| 452 | yield* Deferred.await(readStarted); | ||
| 453 | yield* store.publishCommitted( | ||
| 454 | Array.from({ length: LIVE_STREAM_MAX_ITEMS + 1 }, (_, index) => ({ | ||
| 455 | ...projectEvent, | ||
| 456 | eventId: EventId.make(`event:blocked-${phase}:${index}`), | ||
| 457 | sequence: projectEvent.sequence + index + 1, | ||
| 458 | })), | ||
| 459 | ); | ||
| 460 | yield* Deferred.await(readClosed); | ||
| 461 | const result = yield* Fiber.join(reader); | ||
| 462 | assert.equal(result._tag, "Failure"); | ||
| 463 | if (result._tag === "Failure") { | ||
| 464 | assert.equal(result.failure._tag, "PersistenceSqlError"); | ||
| 465 | assert.isTrue(isLiveStreamBufferError(result.failure.cause)); | ||
| 466 | } | ||
| 467 | }), | ||
| 468 | ).pipe(Effect.provide(Layer.fresh(layerTest))), | ||
| 469 | ); | ||
| 470 | |||
| 471 | it.effect("releases consumed application replay pages", () => | ||
| 472 | Effect.gen(function* () { | ||
| 473 | const store = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 474 | const afterSequence = yield* store.latestApplicationSequence; | ||
| 475 | yield* Effect.forEach( | ||
| 476 | Array.from({ length: 1501 }, (_, index) => index), | ||
| 477 | (index) => | ||
| 478 | store.appendProjectEvent({ | ||
| 479 | type: "project.created", | ||
| 480 | eventId: EventId.make(`retention-${index}`), | ||
| 481 | aggregateKind: "project", | ||
| 482 | aggregateId: ProjectId.make(`retention-${index}`), | ||
| 483 | occurredAt: "2026-09-08T00:00:00Z", | ||
| 484 | commandId: null, | ||
| 485 | causationEventId: null, | ||
| 486 | correlationId: null, | ||
| 487 | metadata: {}, | ||
| 488 | payload: { | ||
| 489 | projectId: ProjectId.make(`retention-${index}`), | ||
| 490 | title: "Replay", | ||
| 491 | workspaceRoot: "/tmp/replay", | ||
| 492 | defaultModelSelection: null, | ||
| 493 | scripts: [], | ||
| 494 | createdAt: "2026-09-08T00:00:00Z", | ||
| 495 | updatedAt: "2026-09-08T00:00:00Z", | ||
| 496 | }, | ||
| 497 | }), | ||
| 498 | { discard: true }, | ||
| 499 | ); | ||
| 500 | // oxlint-disable-next-line typescript/no-extraneous-class -- Counts retained page markers after full GC. | ||
| 501 | class ReplayPage {} | ||
| 502 | let count = 0; | ||
| 503 | yield* store | ||
| 504 | .readApplicationEvents({ | ||
| 505 | afterSequence, | ||
| 506 | throughSequence: yield* store.latestApplicationSequence, | ||
| 507 | }) | ||
| 508 | .pipe( | ||
| 509 | Stream.runForEach((event) => | ||
| 510 | Effect.sync(() => { | ||
| 511 | if (count % 500 === 0) { | ||
| 512 | Object.assign(event, { replayPage: new ReplayPage() }); | ||
| 513 | assert.isAtMost(NodeV8.queryObjects(ReplayPage, { format: "count" }), 1); | ||
| 514 | } | ||
| 515 | count++; | ||
| 516 | }), | ||
| 517 | ), | ||
| 518 | ); | ||
| 519 | assert.equal(count, 1501); | ||
| 520 | }).pipe(Effect.provide(layerTest)), | ||
| 521 | ); | ||
apps/server/src/persistence/OrchestrationEventStore.ts created+712| ... | @@ -0,0 +1,712 @@ | ||
| 1 | /** | ||
| 2 | * Historical name for the shared application event store. | ||
| 3 | * | ||
| 4 | * Owns durable append/replay access for project events and V2 agent-thread | ||
| 5 | * events under one global sequence. It does not reduce events into read models | ||
| 6 | * or apply command validation rules. | ||
| 7 | * | ||
| 8 | * Uses Effect `Context.Service` for dependency injection and exposes typed | ||
| 9 | * persistence/decode errors for event append and replay operations. | ||
| 10 | * | ||
| 11 | * @module OrchestrationEventStore | ||
| 12 | */ | ||
| 13 | import { | ||
| 14 | ApplicationEventMetadata, | ||
| 15 | ApplicationProjectEvent, | ||
| 16 | type ApplicationStoredEvent, | ||
| 17 | CommandId, | ||
| 18 | EventId, | ||
| 19 | IsoDateTime, | ||
| 20 | NonNegativeInt, | ||
| 21 | OrchestrationV2DomainEventJson, | ||
| 22 | OrchestrationV2StoredEvent, | ||
| 23 | ProjectId, | ||
| 24 | StoredProjectIcon, | ||
| 25 | ThreadId, | ||
| 26 | type OrchestrationV2DomainEvent, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | import * as Context from "effect/Context"; | ||
| 29 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 30 | import * as SqlSchema from "effect/sql/SqlSchema"; | ||
| 31 | import * as Effect from "effect/Effect"; | ||
| 32 | import * as Layer from "effect/Layer"; | ||
| 33 | import * as PubSub from "effect/PubSub"; | ||
| 34 | import * as Option from "effect/Option"; | ||
| 35 | import * as Schema from "effect/Schema"; | ||
| 36 | import * as Stream from "effect/Stream"; | ||
| 37 | |||
| 38 | import { replayAndBufferProjectedLiveEvents } from "../orchestration-v2/LiveStreamBudget.ts"; | ||
| 39 | |||
| 40 | import { | ||
| 41 | toPersistenceDecodeError, | ||
| 42 | toPersistenceSqlError, | ||
| 43 | type OrchestrationEventStoreError, | ||
| 44 | } from "./Errors.ts"; | ||
| 45 | |||
| 46 | /** A project event before the store assigns its sequence. */ | ||
| 47 | export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event | ||
| 48 | ? Event extends ApplicationProjectEvent | ||
| 49 | ? Omit<Event, "sequence"> | ||
| 50 | : never | ||
| 51 | : never; | ||
| 52 | |||
| 53 | /** OrchestrationEventStore - Service tag for the shared application event log. */ | ||
| 54 | export class OrchestrationEventStore extends Context.Service< | ||
| 55 | OrchestrationEventStore, | ||
| 56 | { | ||
| 57 | /** Append one project event to the shared application log. */ | ||
| 58 | readonly appendProjectEvent: ( | ||
| 59 | event: UnsequencedProjectEvent, | ||
| 60 | ) => Effect.Effect<ApplicationProjectEvent, OrchestrationEventStoreError>; | ||
| 61 | |||
| 62 | /** Append V2 agent events to the same globally ordered application log. */ | ||
| 63 | readonly appendAgentEvents: (input: { | ||
| 64 | readonly commandId?: CommandId; | ||
| 65 | readonly events: ReadonlyArray<OrchestrationV2DomainEvent>; | ||
| 66 | }) => Effect.Effect<ReadonlyArray<OrchestrationV2StoredEvent>, OrchestrationEventStoreError>; | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Read only V2 thread events from the application log. | ||
| 70 | * | ||
| 71 | * Reads in fixed-size sequence pages until the filtered range is exhausted; | ||
| 72 | * `limit` caps the total emitted events across pages. | ||
| 73 | */ | ||
| 74 | readonly readAgentEvents: (input?: { | ||
| 75 | readonly afterSequence?: number; | ||
| 76 | readonly throughSequence?: number; | ||
| 77 | readonly threadId?: ThreadId; | ||
| 78 | readonly commandId?: CommandId; | ||
| 79 | readonly eventType?: OrchestrationV2DomainEvent["type"]; | ||
| 80 | readonly limit?: number; | ||
| 81 | }) => Stream.Stream<OrchestrationV2StoredEvent, OrchestrationEventStoreError>; | ||
| 82 | |||
| 83 | /** Measure one thread's bounded replay without loading or decoding its payloads. */ | ||
| 84 | readonly getAgentReplayStats: (input: { | ||
| 85 | readonly threadId: ThreadId; | ||
| 86 | readonly afterSequence: number; | ||
| 87 | readonly throughSequence: number; | ||
| 88 | readonly maxEvents: number; | ||
| 89 | }) => Effect.Effect< | ||
| 90 | { | ||
| 91 | readonly eventCount: number; | ||
| 92 | /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ | ||
| 93 | readonly rawPayloadBytes: number; | ||
| 94 | readonly hasCreateEvent: boolean; | ||
| 95 | }, | ||
| 96 | OrchestrationEventStoreError | ||
| 97 | >; | ||
| 98 | |||
| 99 | /** | ||
| 100 | * Measure the retained application-event range `(afterSequence, throughSequence]` | ||
| 101 | * without loading or decoding its payloads. | ||
| 102 | */ | ||
| 103 | readonly getReplayStats: (input: { | ||
| 104 | readonly afterSequence: number; | ||
| 105 | readonly throughSequence: number; | ||
| 106 | }) => Effect.Effect< | ||
| 107 | { | ||
| 108 | readonly eventCount: number; | ||
| 109 | /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ | ||
| 110 | readonly rawPayloadBytes: number; | ||
| 111 | }, | ||
| 112 | OrchestrationEventStoreError | ||
| 113 | >; | ||
| 114 | |||
| 115 | readonly latestAgentSequence: ( | ||
| 116 | threadId?: ThreadId, | ||
| 117 | ) => Effect.Effect<number, OrchestrationEventStoreError>; | ||
| 118 | |||
| 119 | readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>; | ||
| 120 | |||
| 121 | /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */ | ||
| 122 | readonly readApplicationEvents: (input: { | ||
| 123 | readonly afterSequence: number; | ||
| 124 | readonly throughSequence: number; | ||
| 125 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; | ||
| 126 | |||
| 127 | /** Publish only after the surrounding event/projection transaction commits. */ | ||
| 128 | readonly publishCommitted: ( | ||
| 129 | events: ReadonlyArray<ApplicationStoredEvent>, | ||
| 130 | ) => Effect.Effect<void>; | ||
| 131 | |||
| 132 | /** Race-free replay-to-live stream for project and V2 thread events. */ | ||
| 133 | readonly streamApplicationEvents: (input?: { | ||
| 134 | readonly afterSequence?: number; | ||
| 135 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; | ||
| 136 | /** Project transport events before bounding replay and the live tail. */ | ||
| 137 | readonly streamProjectedApplicationEvents: <A extends { readonly sequence: number }>(input: { | ||
| 138 | readonly afterSequence?: number; | ||
| 139 | readonly project: (event: ApplicationStoredEvent) => A; | ||
| 140 | }) => Stream.Stream<A, OrchestrationEventStoreError>; | ||
| 141 | } | ||
| 142 | >()("t3/persistence/OrchestrationEventStore") {} | ||
| 143 | |||
| 144 | const encodeProjectIcon = Schema.encodeSync(StoredProjectIcon); | ||
| 145 | const decodeProjectEvent = Schema.decodeUnknownEffect(ApplicationProjectEvent); | ||
| 146 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | ||
| 147 | const EventMetadataFromJsonString = Schema.fromJsonString(ApplicationEventMetadata); | ||
| 148 | const ProjectEventType = Schema.Literals([ | ||
| 149 | "project.created", | ||
| 150 | "project.meta-updated", | ||
| 151 | "project.deleted", | ||
| 152 | ]); | ||
| 153 | const ActorKind = Schema.Literals(["client", "server", "provider"]); | ||
| 154 | |||
| 155 | const AppendProjectEventRequestSchema = Schema.Struct({ | ||
| 156 | eventId: EventId, | ||
| 157 | streamId: ProjectId, | ||
| 158 | type: ProjectEventType, | ||
| 159 | causationEventId: Schema.NullOr(EventId), | ||
| 160 | correlationId: Schema.NullOr(CommandId), | ||
| 161 | actorKind: ActorKind, | ||
| 162 | occurredAt: IsoDateTime, | ||
| 163 | commandId: Schema.NullOr(CommandId), | ||
| 164 | payloadJson: UnknownFromJsonString, | ||
| 165 | metadataJson: EventMetadataFromJsonString, | ||
| 166 | }); | ||
| 167 | |||
| 168 | const ProjectEventPersistedRowSchema = Schema.Struct({ | ||
| 169 | sequence: NonNegativeInt, | ||
| 170 | eventId: EventId, | ||
| 171 | type: ProjectEventType, | ||
| 172 | aggregateKind: Schema.Literal("project"), | ||
| 173 | aggregateId: ProjectId, | ||
| 174 | occurredAt: IsoDateTime, | ||
| 175 | commandId: Schema.NullOr(CommandId), | ||
| 176 | causationEventId: Schema.NullOr(EventId), | ||
| 177 | correlationId: Schema.NullOr(CommandId), | ||
| 178 | payload: UnknownFromJsonString, | ||
| 179 | metadata: EventMetadataFromJsonString, | ||
| 180 | }); | ||
| 181 | |||
| 182 | const READ_PAGE_SIZE = 500; | ||
| 183 | |||
| 184 | interface ApplicationEventRow { | ||
| 185 | readonly sequence: number; | ||
| 186 | readonly event_id: string; | ||
| 187 | readonly command_id: string | null; | ||
| 188 | readonly aggregate_kind: "project" | "thread"; | ||
| 189 | readonly stream_id: string; | ||
| 190 | readonly event_type: string; | ||
| 191 | readonly occurred_at: string; | ||
| 192 | readonly payload_json: string; | ||
| 193 | readonly metadata_json: string; | ||
| 194 | readonly application_event_version: number; | ||
| 195 | readonly causation_event_id: string | null; | ||
| 196 | readonly correlation_id: string | null; | ||
| 197 | } | ||
| 198 | |||
| 199 | const decodeV2EventJson = Schema.decodeUnknownEffect(OrchestrationV2DomainEventJson); | ||
| 200 | const encodeV2EventJson = Schema.encodeEffect(OrchestrationV2DomainEventJson); | ||
| 201 | const decodeV2StoredEvent = Schema.decodeUnknownEffect(OrchestrationV2StoredEvent); | ||
| 202 | const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 203 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 204 | |||
| 205 | function metadataForV2Event(event: OrchestrationV2DomainEvent): Record<string, unknown> { | ||
| 206 | return { | ||
| 207 | ...(event.runId === undefined ? {} : { runId: event.runId }), | ||
| 208 | ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }), | ||
| 209 | ...(event.driver === undefined ? {} : { driver: event.driver }), | ||
| 210 | ...(event.providerInstanceId === undefined | ||
| 211 | ? {} | ||
| 212 | : { providerInstanceId: event.providerInstanceId }), | ||
| 213 | ...(event.rawEventId === undefined ? {} : { rawEventId: event.rawEventId }), | ||
| 214 | }; | ||
| 215 | } | ||
| 216 | |||
| 217 | const rowToV2StoredEvent = Effect.fn("OrchestrationEventStore.rowToV2StoredEvent")(function* ( | ||
| 218 | row: ApplicationEventRow, | ||
| 219 | ) { | ||
| 220 | const payload = yield* decodeJson(row.payload_json); | ||
| 221 | const metadata = yield* decodeJson(row.metadata_json); | ||
| 222 | const values = | ||
| 223 | typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>) : {}; | ||
| 224 | const event = yield* decodeV2EventJson({ | ||
| 225 | id: row.event_id, | ||
| 226 | threadId: row.stream_id, | ||
| 227 | type: row.event_type, | ||
| 228 | occurredAt: row.occurred_at, | ||
| 229 | payload, | ||
| 230 | ...(values.runId === undefined ? {} : { runId: values.runId }), | ||
| 231 | ...(values.nodeId === undefined ? {} : { nodeId: values.nodeId }), | ||
| 232 | ...(values.driver === undefined ? {} : { driver: values.driver }), | ||
| 233 | ...(values.providerInstanceId === undefined | ||
| 234 | ? {} | ||
| 235 | : { providerInstanceId: values.providerInstanceId }), | ||
| 236 | ...(values.rawEventId === undefined ? {} : { rawEventId: values.rawEventId }), | ||
| 237 | }); | ||
| 238 | return yield* decodeV2StoredEvent({ | ||
| 239 | sequence: row.sequence, | ||
| 240 | commandId: row.command_id, | ||
| 241 | event, | ||
| 242 | }); | ||
| 243 | }); | ||
| 244 | |||
| 245 | const rowToProjectEvent = Effect.fn("OrchestrationEventStore.rowToProjectEvent")(function* ( | ||
| 246 | row: ApplicationEventRow, | ||
| 247 | ) { | ||
| 248 | return yield* decodeProjectEvent({ | ||
| 249 | sequence: row.sequence, | ||
| 250 | eventId: row.event_id, | ||
| 251 | type: row.event_type, | ||
| 252 | aggregateKind: row.aggregate_kind, | ||
| 253 | aggregateId: row.stream_id, | ||
| 254 | occurredAt: row.occurred_at, | ||
| 255 | commandId: row.command_id, | ||
| 256 | causationEventId: row.causation_event_id, | ||
| 257 | correlationId: row.correlation_id, | ||
| 258 | payload: yield* decodeJson(row.payload_json), | ||
| 259 | metadata: yield* decodeJson(row.metadata_json), | ||
| 260 | }); | ||
| 261 | }); | ||
| 262 | |||
| 263 | function rowToApplicationStoredEvent( | ||
| 264 | row: ApplicationEventRow, | ||
| 265 | ): Effect.Effect<ApplicationStoredEvent, Schema.SchemaError> { | ||
| 266 | return row.aggregate_kind === "project" ? rowToProjectEvent(row) : rowToV2StoredEvent(row); | ||
| 267 | } | ||
| 268 | |||
| 269 | function inferActorKind(event: UnsequencedProjectEvent): typeof ActorKind.Type { | ||
| 270 | if (event.commandId !== null && event.commandId.startsWith("provider:")) { | ||
| 271 | return "provider"; | ||
| 272 | } | ||
| 273 | if (event.commandId !== null && event.commandId.startsWith("server:")) { | ||
| 274 | return "server"; | ||
| 275 | } | ||
| 276 | if ( | ||
| 277 | event.metadata.providerTurnId !== undefined || | ||
| 278 | event.metadata.providerItemId !== undefined || | ||
| 279 | event.metadata.adapterKey !== undefined | ||
| 280 | ) { | ||
| 281 | return "provider"; | ||
| 282 | } | ||
| 283 | if (event.commandId === null) { | ||
| 284 | return "server"; | ||
| 285 | } | ||
| 286 | return "client"; | ||
| 287 | } | ||
| 288 | |||
| 289 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | ||
| 290 | return (cause: unknown): OrchestrationEventStoreError => | ||
| 291 | Schema.isSchemaError(cause) | ||
| 292 | ? toPersistenceDecodeError(decodeOperation)(cause) | ||
| 293 | : toPersistenceSqlError(sqlOperation)(cause); | ||
| 294 | } | ||
| 295 | |||
| 296 | const makeEventStore = Effect.gen(function* () { | ||
| 297 | const sql = yield* SqlClient.SqlClient; | ||
| 298 | const committedEvents = yield* PubSub.unbounded<ApplicationStoredEvent>(); | ||
| 299 | |||
| 300 | const appendProjectEventRow = SqlSchema.findOne({ | ||
| 301 | Request: AppendProjectEventRequestSchema, | ||
| 302 | Result: ProjectEventPersistedRowSchema, | ||
| 303 | execute: (request) => | ||
| 304 | sql` | ||
| 305 | INSERT INTO orchestration_events ( | ||
| 306 | event_id, | ||
| 307 | aggregate_kind, | ||
| 308 | stream_id, | ||
| 309 | stream_version, | ||
| 310 | event_type, | ||
| 311 | occurred_at, | ||
| 312 | command_id, | ||
| 313 | causation_event_id, | ||
| 314 | correlation_id, | ||
| 315 | actor_kind, | ||
| 316 | payload_json, | ||
| 317 | metadata_json, | ||
| 318 | application_event_version | ||
| 319 | ) | ||
| 320 | VALUES ( | ||
| 321 | ${request.eventId}, | ||
| 322 | 'project', | ||
| 323 | ${request.streamId}, | ||
| 324 | COALESCE( | ||
| 325 | ( | ||
| 326 | SELECT stream_version + 1 | ||
| 327 | FROM orchestration_events | ||
| 328 | WHERE aggregate_kind = 'project' | ||
| 329 | AND stream_id = ${request.streamId} | ||
| 330 | ORDER BY stream_version DESC | ||
| 331 | LIMIT 1 | ||
| 332 | ), | ||
| 333 | 0 | ||
| 334 | ), | ||
| 335 | ${request.type}, | ||
| 336 | ${request.occurredAt}, | ||
| 337 | ${request.commandId}, | ||
| 338 | ${request.causationEventId}, | ||
| 339 | ${request.correlationId}, | ||
| 340 | ${request.actorKind}, | ||
| 341 | ${request.payloadJson}, | ||
| 342 | ${request.metadataJson}, | ||
| 343 | 2 | ||
| 344 | ) | ||
| 345 | RETURNING | ||
| 346 | sequence, | ||
| 347 | event_id AS "eventId", | ||
| 348 | event_type AS "type", | ||
| 349 | aggregate_kind AS "aggregateKind", | ||
| 350 | stream_id AS "aggregateId", | ||
| 351 | occurred_at AS "occurredAt", | ||
| 352 | command_id AS "commandId", | ||
| 353 | causation_event_id AS "causationEventId", | ||
| 354 | correlation_id AS "correlationId", | ||
| 355 | payload_json AS "payload", | ||
| 356 | metadata_json AS "metadata" | ||
| 357 | `, | ||
| 358 | }); | ||
| 359 | |||
| 360 | const appendProjectEvent: OrchestrationEventStore["Service"]["appendProjectEvent"] = (event) => | ||
| 361 | appendProjectEventRow({ | ||
| 362 | eventId: event.eventId, | ||
| 363 | streamId: event.aggregateId, | ||
| 364 | type: event.type, | ||
| 365 | causationEventId: event.causationEventId, | ||
| 366 | correlationId: event.correlationId, | ||
| 367 | actorKind: inferActorKind(event), | ||
| 368 | occurredAt: event.occurredAt, | ||
| 369 | commandId: event.commandId, | ||
| 370 | payloadJson: | ||
| 371 | event.type === "project.deleted" || !event.payload.projectIcon | ||
| 372 | ? event.payload | ||
| 373 | : { ...event.payload, projectIcon: encodeProjectIcon(event.payload.projectIcon) }, | ||
| 374 | metadataJson: event.metadata, | ||
| 375 | }).pipe( | ||
| 376 | Effect.flatMap((row) => decodeProjectEvent(row)), | ||
| 377 | Effect.mapError( | ||
| 378 | toPersistenceSqlOrDecodeError( | ||
| 379 | "OrchestrationEventStore.appendProjectEvent:insert", | ||
| 380 | "OrchestrationEventStore.appendProjectEvent:decode", | ||
| 381 | ), | ||
| 382 | ), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const readApplicationRows = (input: { | ||
| 386 | readonly afterSequence: number; | ||
| 387 | readonly throughSequence?: number; | ||
| 388 | readonly threadId?: ThreadId; | ||
| 389 | readonly commandId?: CommandId; | ||
| 390 | readonly eventType?: OrchestrationV2DomainEvent["type"]; | ||
| 391 | readonly onlyAgentEvents?: boolean; | ||
| 392 | readonly limit: number; | ||
| 393 | }) => | ||
| 394 | sql<ApplicationEventRow>` | ||
| 395 | SELECT | ||
| 396 | sequence, | ||
| 397 | event_id, | ||
| 398 | command_id, | ||
| 399 | aggregate_kind, | ||
| 400 | stream_id, | ||
| 401 | event_type, | ||
| 402 | occurred_at, | ||
| 403 | payload_json, | ||
| 404 | metadata_json, | ||
| 405 | application_event_version, | ||
| 406 | causation_event_id, | ||
| 407 | correlation_id | ||
| 408 | FROM orchestration_events | ||
| 409 | ${ | ||
| 410 | input.onlyAgentEvents === true | ||
| 411 | ? sql`` | ||
| 412 | : sql`INDEXED BY idx_orchestration_events_application_high_water` | ||
| 413 | } | ||
| 414 | WHERE sequence > ${input.afterSequence} | ||
| 415 | AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} | ||
| 416 | AND ( | ||
| 417 | ${ | ||
| 418 | input.onlyAgentEvents === true | ||
| 419 | ? sql`application_event_version = 2 AND aggregate_kind = 'thread'` | ||
| 420 | : sql`aggregate_kind = 'project' | ||
| 421 | OR (application_event_version = 2 AND aggregate_kind = 'thread')` | ||
| 422 | } | ||
| 423 | ) | ||
| 424 | AND ${sql.and([ | ||
| 425 | ...(input.threadId === undefined ? [] : [sql`stream_id = ${input.threadId}`]), | ||
| 426 | ...(input.commandId === undefined ? [] : [sql`command_id = ${input.commandId}`]), | ||
| 427 | ...(input.eventType === undefined ? [] : [sql`event_type = ${input.eventType}`]), | ||
| 428 | ])} | ||
| 429 | ORDER BY sequence ASC | ||
| 430 | LIMIT ${input.limit} | ||
| 431 | `; | ||
| 432 | |||
| 433 | const appendAgentEvents: OrchestrationEventStore["Service"]["appendAgentEvents"] = (input) => | ||
| 434 | Effect.forEach( | ||
| 435 | input.events, | ||
| 436 | (event) => | ||
| 437 | Effect.gen(function* () { | ||
| 438 | const encoded = yield* encodeV2EventJson(event); | ||
| 439 | const rows = yield* sql<{ readonly sequence: number }>` | ||
| 440 | INSERT INTO orchestration_events ( | ||
| 441 | event_id, | ||
| 442 | aggregate_kind, | ||
| 443 | stream_id, | ||
| 444 | stream_version, | ||
| 445 | event_type, | ||
| 446 | occurred_at, | ||
| 447 | command_id, | ||
| 448 | causation_event_id, | ||
| 449 | correlation_id, | ||
| 450 | actor_kind, | ||
| 451 | payload_json, | ||
| 452 | metadata_json, | ||
| 453 | application_event_version | ||
| 454 | ) | ||
| 455 | VALUES ( | ||
| 456 | ${event.id}, | ||
| 457 | 'thread', | ||
| 458 | ${event.threadId}, | ||
| 459 | COALESCE( | ||
| 460 | ( | ||
| 461 | SELECT MAX(stream_version) + 1 | ||
| 462 | FROM orchestration_events | ||
| 463 | WHERE aggregate_kind = 'thread' AND stream_id = ${event.threadId} | ||
| 464 | ), | ||
| 465 | 0 | ||
| 466 | ), | ||
| 467 | ${event.type}, | ||
| 468 | ${encoded.occurredAt}, | ||
| 469 | ${input.commandId ?? null}, | ||
| 470 | NULL, | ||
| 471 | ${input.commandId ?? null}, | ||
| 472 | ${event.rawEventId === undefined ? "server" : "provider"}, | ||
| 473 | ${yield* encodeJson(encoded.payload)}, | ||
| 474 | ${yield* encodeJson(metadataForV2Event(event))}, | ||
| 475 | 2 | ||
| 476 | ) | ||
| 477 | RETURNING sequence | ||
| 478 | `; | ||
| 479 | return yield* decodeV2StoredEvent({ | ||
| 480 | sequence: rows[0]?.sequence, | ||
| 481 | commandId: input.commandId ?? null, | ||
| 482 | event, | ||
| 483 | }); | ||
| 484 | }), | ||
| 485 | { concurrency: 1 }, | ||
| 486 | ).pipe( | ||
| 487 | Effect.mapError( | ||
| 488 | toPersistenceSqlOrDecodeError( | ||
| 489 | "OrchestrationEventStore.appendAgentEvents:insert", | ||
| 490 | "OrchestrationEventStore.appendAgentEvents:decode", | ||
| 491 | ), | ||
| 492 | ), | ||
| 493 | ); | ||
| 494 | |||
| 495 | const readAgentEvents: OrchestrationEventStore["Service"]["readAgentEvents"] = (input) => { | ||
| 496 | const totalLimit = | ||
| 497 | input?.limit === undefined ? Number.MAX_SAFE_INTEGER : Math.max(0, Math.floor(input.limit)); | ||
| 498 | if (totalLimit === 0) { | ||
| 499 | return Stream.empty; | ||
| 500 | } | ||
| 501 | return Stream.paginate( | ||
| 502 | { cursor: input?.afterSequence ?? 0, remaining: totalLimit }, | ||
| 503 | ({ cursor, remaining }) => { | ||
| 504 | const pageLimit = Math.min(remaining, READ_PAGE_SIZE); | ||
| 505 | return readApplicationRows({ | ||
| 506 | afterSequence: cursor, | ||
| 507 | ...(input?.throughSequence === undefined | ||
| 508 | ? {} | ||
| 509 | : { throughSequence: input.throughSequence }), | ||
| 510 | ...(input?.threadId === undefined ? {} : { threadId: input.threadId }), | ||
| 511 | ...(input?.commandId === undefined ? {} : { commandId: input.commandId }), | ||
| 512 | ...(input?.eventType === undefined ? {} : { eventType: input.eventType }), | ||
| 513 | onlyAgentEvents: true, | ||
| 514 | limit: pageLimit, | ||
| 515 | }).pipe( | ||
| 516 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readAgentEvents:query")), | ||
| 517 | Effect.map((rows) => { | ||
| 518 | const last = rows.at(-1); | ||
| 519 | const nextRemaining = remaining - rows.length; | ||
| 520 | return [ | ||
| 521 | rows, | ||
| 522 | last === undefined || | ||
| 523 | rows.length < pageLimit || | ||
| 524 | nextRemaining <= 0 || | ||
| 525 | (input?.throughSequence !== undefined && last.sequence >= input.throughSequence) | ||
| 526 | ? Option.none() | ||
| 527 | : Option.some({ cursor: last.sequence, remaining: nextRemaining }), | ||
| 528 | ] as const; | ||
| 529 | }), | ||
| 530 | ); | ||
| 531 | }, | ||
| 532 | ).pipe( | ||
| 533 | Stream.mapEffect((row) => | ||
| 534 | rowToV2StoredEvent(row).pipe( | ||
| 535 | Effect.mapError( | ||
| 536 | toPersistenceDecodeError("OrchestrationEventStore.readAgentEvents:decode"), | ||
| 537 | ), | ||
| 538 | ), | ||
| 539 | ), | ||
| 540 | ); | ||
| 541 | }; | ||
| 542 | |||
| 543 | const getAgentReplayStats: OrchestrationEventStore["Service"]["getAgentReplayStats"] = (input) => | ||
| 544 | sql<{ | ||
| 545 | readonly eventCount: number; | ||
| 546 | readonly rawPayloadBytes: number; | ||
| 547 | readonly hasCreateEvent: number; | ||
| 548 | }>` | ||
| 549 | SELECT | ||
| 550 | COUNT(*) AS "eventCount", | ||
| 551 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes", | ||
| 552 | COALESCE(MAX(event_type = 'thread.created'), 0) AS "hasCreateEvent" | ||
| 553 | FROM ( | ||
| 554 | SELECT payload_json, event_type | ||
| 555 | FROM orchestration_events | ||
| 556 | WHERE aggregate_kind = 'thread' | ||
| 557 | AND stream_id = ${input.threadId} | ||
| 558 | AND application_event_version = 2 | ||
| 559 | AND sequence > ${input.afterSequence} | ||
| 560 | AND sequence <= ${input.throughSequence} | ||
| 561 | ORDER BY sequence ASC | ||
| 562 | LIMIT ${Math.max(0, Math.floor(input.maxEvents)) + 1} | ||
| 563 | ) | ||
| 564 | `.pipe( | ||
| 565 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getAgentReplayStats:query")), | ||
| 566 | Effect.map((rows) => ({ | ||
| 567 | eventCount: rows[0]?.eventCount ?? 0, | ||
| 568 | rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, | ||
| 569 | hasCreateEvent: (rows[0]?.hasCreateEvent ?? 0) !== 0, | ||
| 570 | })), | ||
| 571 | ); | ||
| 572 | |||
| 573 | const getReplayStats: OrchestrationEventStore["Service"]["getReplayStats"] = (input) => | ||
| 574 | sql<{ readonly eventCount: number; readonly rawPayloadBytes: number }>` | ||
| 575 | SELECT | ||
| 576 | COUNT(*) AS "eventCount", | ||
| 577 | COALESCE(SUM(octet_length(payload_json)), 0) AS "rawPayloadBytes" | ||
| 578 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water | ||
| 579 | WHERE sequence > ${input.afterSequence} | ||
| 580 | AND sequence <= ${input.throughSequence} | ||
| 581 | AND ( | ||
| 582 | aggregate_kind = 'project' | ||
| 583 | OR (application_event_version = 2 AND aggregate_kind = 'thread') | ||
| 584 | ) | ||
| 585 | `.pipe( | ||
| 586 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.getReplayStats:query")), | ||
| 587 | Effect.map((rows) => ({ | ||
| 588 | eventCount: rows[0]?.eventCount ?? 0, | ||
| 589 | rawPayloadBytes: rows[0]?.rawPayloadBytes ?? 0, | ||
| 590 | })), | ||
| 591 | ); | ||
| 592 | |||
| 593 | const latestAgentSequence: OrchestrationEventStore["Service"]["latestAgentSequence"] = ( | ||
| 594 | threadId, | ||
| 595 | ) => | ||
| 596 | sql<{ readonly sequence: number | null }>` | ||
| 597 | SELECT MAX(sequence) AS sequence | ||
| 598 | FROM orchestration_events | ||
| 599 | ${ | ||
| 600 | threadId === undefined | ||
| 601 | ? sql`` | ||
| 602 | : sql`INDEXED BY idx_orchestration_events_agent_stream_sequence` | ||
| 603 | } | ||
| 604 | WHERE application_event_version = 2 | ||
| 605 | AND aggregate_kind = 'thread' | ||
| 606 | ${threadId === undefined ? sql`` : sql`AND stream_id = ${threadId}`} | ||
| 607 | `.pipe( | ||
| 608 | Effect.map((rows) => rows[0]?.sequence ?? 0), | ||
| 609 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.latestAgentSequence:query")), | ||
| 610 | ); | ||
| 611 | |||
| 612 | // The OR planner otherwise scans every V2 event instead of seeking the final sequence. | ||
| 613 | const latestApplicationSequence = sql<{ readonly sequence: number | null }>` | ||
| 614 | SELECT MAX(sequence) AS sequence | ||
| 615 | FROM orchestration_events INDEXED BY idx_orchestration_events_application_high_water | ||
| 616 | WHERE aggregate_kind = 'project' | ||
| 617 | OR (application_event_version = 2 AND aggregate_kind = 'thread') | ||
| 618 | `.pipe( | ||
| 619 | Effect.map((rows) => rows[0]?.sequence ?? 0), | ||
| 620 | Effect.mapError( | ||
| 621 | toPersistenceSqlError("OrchestrationEventStore.latestApplicationSequence:query"), | ||
| 622 | ), | ||
| 623 | ); | ||
| 624 | |||
| 625 | const readApplicationEventPage = (input: { | ||
| 626 | readonly afterSequence: number; | ||
| 627 | readonly throughSequence: number; | ||
| 628 | readonly limit: number; | ||
| 629 | }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => | ||
| 630 | Stream.fromEffect( | ||
| 631 | readApplicationRows(input).pipe( | ||
| 632 | Effect.mapError( | ||
| 633 | toPersistenceSqlError("OrchestrationEventStore.readApplicationEvents:query"), | ||
| 634 | ), | ||
| 635 | ), | ||
| 636 | ).pipe( | ||
| 637 | Stream.flatMap(Stream.fromIterable), | ||
| 638 | Stream.mapEffect((row) => | ||
| 639 | rowToApplicationStoredEvent(row).pipe( | ||
| 640 | Effect.mapError( | ||
| 641 | toPersistenceDecodeError("OrchestrationEventStore.readApplicationEvents:decode"), | ||
| 642 | ), | ||
| 643 | ), | ||
| 644 | ), | ||
| 645 | ); | ||
| 646 | |||
| 647 | const catchUpApplicationEvents = (input: { | ||
| 648 | readonly afterSequence: number; | ||
| 649 | readonly throughSequence: number; | ||
| 650 | }): Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError> => { | ||
| 651 | return Stream.paginate(input.afterSequence, (afterSequence) => | ||
| 652 | readApplicationEventPage({ | ||
| 653 | afterSequence, | ||
| 654 | throughSequence: input.throughSequence, | ||
| 655 | limit: READ_PAGE_SIZE, | ||
| 656 | }).pipe( | ||
| 657 | Stream.runCollect, | ||
| 658 | Effect.map((events) => { | ||
| 659 | const last = events.at(-1); | ||
| 660 | return [ | ||
| 661 | events, | ||
| 662 | last === undefined || | ||
| 663 | events.length < READ_PAGE_SIZE || | ||
| 664 | last.sequence >= input.throughSequence | ||
| 665 | ? Option.none() | ||
| 666 | : Option.some(last.sequence), | ||
| 667 | ] as const; | ||
| 668 | }), | ||
| 669 | ), | ||
| 670 | ); | ||
| 671 | }; | ||
| 672 | |||
| 673 | const streamProjectedApplicationEvents: OrchestrationEventStore["Service"]["streamProjectedApplicationEvents"] = | ||
| 674 | (input) => | ||
| 675 | replayAndBufferProjectedLiveEvents({ | ||
| 676 | subscribe: PubSub.subscribe(committedEvents), | ||
| 677 | latestSequence: latestApplicationSequence, | ||
| 678 | afterSequence: input.afterSequence ?? 0, | ||
| 679 | project: input.project, | ||
| 680 | replay: (throughSequence) => | ||
| 681 | catchUpApplicationEvents({ | ||
| 682 | afterSequence: input?.afterSequence ?? 0, | ||
| 683 | throughSequence, | ||
| 684 | }), | ||
| 685 | }).pipe( | ||
| 686 | Stream.catchTag("LiveStreamBufferError", (cause) => | ||
| 687 | Stream.fail( | ||
| 688 | toPersistenceSqlError("OrchestrationEventStore.streamApplicationEvents:buffer")(cause), | ||
| 689 | ), | ||
| 690 | ), | ||
| 691 | ); | ||
| 692 | |||
| 693 | const streamApplicationEvents: OrchestrationEventStore["Service"]["streamApplicationEvents"] = ( | ||
| 694 | input, | ||
| 695 | ) => streamProjectedApplicationEvents({ ...input, project: (event) => event }); | ||
| 696 | |||
| 697 | return { | ||
| 698 | appendProjectEvent, | ||
| 699 | appendAgentEvents, | ||
| 700 | readAgentEvents, | ||
| 701 | getAgentReplayStats, | ||
| 702 | getReplayStats, | ||
| 703 | latestAgentSequence, | ||
| 704 | latestApplicationSequence, | ||
| 705 | readApplicationEvents: catchUpApplicationEvents, | ||
| 706 | publishCommitted: (events) => PubSub.publishAll(committedEvents, events).pipe(Effect.asVoid), | ||
| 707 | streamApplicationEvents, | ||
| 708 | streamProjectedApplicationEvents, | ||
| 709 | } satisfies OrchestrationEventStore["Service"]; | ||
| 710 | }); | ||
| 711 | |||
| 712 | export const layer = Layer.effect(OrchestrationEventStore, makeEventStore); | ||
apps/server/src/persistence/RepositoryErrorCorrelation.test.ts+1-1| ... | @@ -8,7 +8,7 @@ import * as SqlClient from "effect/sql/SqlClient"; | ... | @@ -8,7 +8,7 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 8 | import * as AuthPairingLinks from "./AuthPairingLinks.ts"; | 8 | import * as AuthPairingLinks from "./AuthPairingLinks.ts"; |
| 9 | import * as AuthSessions from "./AuthSessions.ts"; | 9 | import * as AuthSessions from "./AuthSessions.ts"; |
| 10 | import * as PersistenceErrors from "./Errors.ts"; | 10 | import * as PersistenceErrors from "./Errors.ts"; |
| 11 | import * as SqlitePersistence from "./Layers/Sqlite.ts"; | 11 | import * as SqlitePersistence from "./Sqlite.ts"; |
| 12 | import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; | 12 | import * as ProviderSessionRuntime from "./ProviderSessionRuntime.ts"; |
| 13 | 13 | ||
| 14 | const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); | 14 | const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); |
apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts deleted-68| ... | @@ -1,68 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * OrchestrationCommandReceiptRepository - Repository interface for command receipts. | ||
| 3 | * | ||
| 4 | * Owns persistence operations for deduplication and status tracking of | ||
| 5 | * orchestration command handling. | ||
| 6 | * | ||
| 7 | * @module OrchestrationCommandReceiptRepository | ||
| 8 | */ | ||
| 9 | import { CommandId, IsoDateTime, NonNegativeInt, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import type * as Effect from "effect/Effect"; | ||
| 14 | |||
| 15 | import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; | ||
| 16 | |||
| 17 | export const OrchestrationCommandReceipt = Schema.Struct({ | ||
| 18 | commandId: CommandId, | ||
| 19 | aggregateKind: Schema.Literals(["project", "thread"]), | ||
| 20 | aggregateId: Schema.Union([ProjectId, ThreadId]), | ||
| 21 | commandType: Schema.String, | ||
| 22 | acceptedAt: IsoDateTime, | ||
| 23 | resultSequence: NonNegativeInt, | ||
| 24 | status: Schema.Literals(["accepted", "rejected"]), | ||
| 25 | error: Schema.NullOr(Schema.String), | ||
| 26 | }); | ||
| 27 | export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; | ||
| 28 | |||
| 29 | export const GetByCommandIdInput = Schema.Struct({ | ||
| 30 | commandId: CommandId, | ||
| 31 | }); | ||
| 32 | export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * OrchestrationCommandReceiptRepositoryShape - Service API for command receipts. | ||
| 36 | */ | ||
| 37 | export interface OrchestrationCommandReceiptRepositoryShape { | ||
| 38 | readonly insertIfAbsent: ( | ||
| 39 | receipt: OrchestrationCommandReceipt, | ||
| 40 | ) => Effect.Effect<boolean, OrchestrationCommandReceiptRepositoryError>; | ||
| 41 | |||
| 42 | /** | ||
| 43 | * Insert or replace a command receipt row. | ||
| 44 | * | ||
| 45 | * Upserts by `commandId` for idempotent command-result tracking. | ||
| 46 | */ | ||
| 47 | readonly upsert: ( | ||
| 48 | receipt: OrchestrationCommandReceipt, | ||
| 49 | ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>; | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Read a command receipt by command id. | ||
| 53 | */ | ||
| 54 | readonly getByCommandId: ( | ||
| 55 | input: GetByCommandIdInput, | ||
| 56 | ) => Effect.Effect< | ||
| 57 | Option.Option<OrchestrationCommandReceipt>, | ||
| 58 | OrchestrationCommandReceiptRepositoryError | ||
| 59 | >; | ||
| 60 | } | ||
| 61 | |||
| 62 | /** | ||
| 63 | * OrchestrationCommandReceiptRepository - Service tag for command receipt persistence. | ||
| 64 | */ | ||
| 65 | export class OrchestrationCommandReceiptRepository extends Context.Service< | ||
| 66 | OrchestrationCommandReceiptRepository, | ||
| 67 | OrchestrationCommandReceiptRepositoryShape | ||
| 68 | >()("t3/persistence/Services/OrchestrationCommandReceipts/OrchestrationCommandReceiptRepository") {} | ||
apps/server/src/persistence/Services/OrchestrationEventStore.ts deleted-126| ... | @@ -1,126 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Historical name for the shared application event store. | ||
| 3 | * | ||
| 4 | * Owns durable append/replay access for project events and V2 agent-thread | ||
| 5 | * events under one global sequence. It does not reduce events into read models | ||
| 6 | * or apply command validation rules. | ||
| 7 | * | ||
| 8 | * Uses Effect `Context.Service` for dependency injection and exposes typed | ||
| 9 | * persistence/decode errors for event append and replay operations. | ||
| 10 | * | ||
| 11 | * @module OrchestrationEventStore | ||
| 12 | */ | ||
| 13 | import type { | ||
| 14 | ApplicationProjectEvent, | ||
| 15 | ApplicationStoredEvent, | ||
| 16 | CommandId, | ||
| 17 | OrchestrationV2DomainEvent, | ||
| 18 | OrchestrationV2StoredEvent, | ||
| 19 | ThreadId, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import * as Context from "effect/Context"; | ||
| 22 | import type * as Effect from "effect/Effect"; | ||
| 23 | import type * as Stream from "effect/Stream"; | ||
| 24 | |||
| 25 | import type { OrchestrationEventStoreError } from "../Errors.ts"; | ||
| 26 | |||
| 27 | /** A project event before the store assigns its sequence. */ | ||
| 28 | export type UnsequencedProjectEvent = ApplicationProjectEvent extends infer Event | ||
| 29 | ? Event extends ApplicationProjectEvent | ||
| 30 | ? Omit<Event, "sequence"> | ||
| 31 | : never | ||
| 32 | : never; | ||
| 33 | |||
| 34 | /** | ||
| 35 | * OrchestrationEventStoreShape - Service API for orchestration event persistence. | ||
| 36 | */ | ||
| 37 | export interface OrchestrationEventStoreShape { | ||
| 38 | /** Append one project event to the shared application log. */ | ||
| 39 | readonly appendProjectEvent: ( | ||
| 40 | event: UnsequencedProjectEvent, | ||
| 41 | ) => Effect.Effect<ApplicationProjectEvent, OrchestrationEventStoreError>; | ||
| 42 | |||
| 43 | /** Append V2 agent events to the same globally ordered application log. */ | ||
| 44 | readonly appendAgentEvents: (input: { | ||
| 45 | readonly commandId?: CommandId; | ||
| 46 | readonly events: ReadonlyArray<OrchestrationV2DomainEvent>; | ||
| 47 | }) => Effect.Effect<ReadonlyArray<OrchestrationV2StoredEvent>, OrchestrationEventStoreError>; | ||
| 48 | |||
| 49 | /** | ||
| 50 | * Read only V2 thread events from the application log. | ||
| 51 | * | ||
| 52 | * Reads in fixed-size sequence pages until the filtered range is exhausted; | ||
| 53 | * `limit` caps the total emitted events across pages. | ||
| 54 | */ | ||
| 55 | readonly readAgentEvents: (input?: { | ||
| 56 | readonly afterSequence?: number; | ||
| 57 | readonly throughSequence?: number; | ||
| 58 | readonly threadId?: ThreadId; | ||
| 59 | readonly commandId?: CommandId; | ||
| 60 | readonly eventType?: OrchestrationV2DomainEvent["type"]; | ||
| 61 | readonly limit?: number; | ||
| 62 | }) => Stream.Stream<OrchestrationV2StoredEvent, OrchestrationEventStoreError>; | ||
| 63 | |||
| 64 | /** Measure one thread's bounded replay without loading or decoding its payloads. */ | ||
| 65 | readonly getAgentReplayStats: (input: { | ||
| 66 | readonly threadId: ThreadId; | ||
| 67 | readonly afterSequence: number; | ||
| 68 | readonly throughSequence: number; | ||
| 69 | readonly maxEvents: number; | ||
| 70 | }) => Effect.Effect< | ||
| 71 | { | ||
| 72 | readonly eventCount: number; | ||
| 73 | /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ | ||
| 74 | readonly rawPayloadBytes: number; | ||
| 75 | readonly hasCreateEvent: boolean; | ||
| 76 | }, | ||
| 77 | OrchestrationEventStoreError | ||
| 78 | >; | ||
| 79 | |||
| 80 | /** | ||
| 81 | * Measure the retained application-event range `(afterSequence, throughSequence]` | ||
| 82 | * without loading or decoding its payloads. | ||
| 83 | */ | ||
| 84 | readonly getReplayStats: (input: { | ||
| 85 | readonly afterSequence: number; | ||
| 86 | readonly throughSequence: number; | ||
| 87 | }) => Effect.Effect< | ||
| 88 | { | ||
| 89 | readonly eventCount: number; | ||
| 90 | /** UTF-8 bytes in persisted payload JSON before decoding or wire projection. */ | ||
| 91 | readonly rawPayloadBytes: number; | ||
| 92 | }, | ||
| 93 | OrchestrationEventStoreError | ||
| 94 | >; | ||
| 95 | |||
| 96 | readonly latestAgentSequence: ( | ||
| 97 | threadId?: ThreadId, | ||
| 98 | ) => Effect.Effect<number, OrchestrationEventStoreError>; | ||
| 99 | |||
| 100 | readonly latestApplicationSequence: Effect.Effect<number, OrchestrationEventStoreError>; | ||
| 101 | |||
| 102 | /** Read the finite retained application-event range `(afterSequence, throughSequence]`. */ | ||
| 103 | readonly readApplicationEvents: (input: { | ||
| 104 | readonly afterSequence: number; | ||
| 105 | readonly throughSequence: number; | ||
| 106 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; | ||
| 107 | |||
| 108 | /** Publish only after the surrounding event/projection transaction commits. */ | ||
| 109 | readonly publishCommitted: (events: ReadonlyArray<ApplicationStoredEvent>) => Effect.Effect<void>; | ||
| 110 | |||
| 111 | /** Race-free replay-to-live stream for project and V2 thread events. */ | ||
| 112 | readonly streamApplicationEvents: (input?: { | ||
| 113 | readonly afterSequence?: number; | ||
| 114 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; | ||
| 115 | /** Project transport events before bounding replay and the live tail. */ | ||
| 116 | readonly streamProjectedApplicationEvents: <A extends { readonly sequence: number }>(input: { | ||
| 117 | readonly afterSequence?: number; | ||
| 118 | readonly project: (event: ApplicationStoredEvent) => A; | ||
| 119 | }) => Stream.Stream<A, OrchestrationEventStoreError>; | ||
| 120 | } | ||
| 121 | |||
| 122 | /** OrchestrationEventStore - Service tag for the shared application event log. */ | ||
| 123 | export class OrchestrationEventStore extends Context.Service< | ||
| 124 | OrchestrationEventStore, | ||
| 125 | OrchestrationEventStoreShape | ||
| 126 | >()("t3/persistence/Services/OrchestrationEventStore") {} | ||
apps/server/src/persistence/Sqlite.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFS from "node:fs"; | ||
| 4 | import * as NodeOS from "node:os"; | ||
| 5 | import * as NodePath from "node:path"; | ||
| 6 | |||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { assert, it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 12 | |||
| 13 | import { WAL_SIZE_LIMIT_BYTES } from "./Sqlite.ts"; | ||
| 14 | import * as SqlitePersistence from "./Sqlite.ts"; | ||
| 15 | |||
| 16 | const lockHolderSource = ` | ||
| 17 | const { DatabaseSync } = require("node:sqlite"); | ||
| 18 | const db = new DatabaseSync(process.argv[1]); | ||
| 19 | db.exec("BEGIN IMMEDIATE"); | ||
| 20 | process.stdout.write("locked\\n"); | ||
| 21 | setTimeout(() => { | ||
| 22 | db.exec("COMMIT"); | ||
| 23 | db.close(); | ||
| 24 | }, Number(process.argv[2])); | ||
| 25 | `; | ||
| 26 | |||
| 27 | const spawnWriteLockHolder = (dbPath: string, holdMs: number) => | ||
| 28 | Effect.promise( | ||
| 29 | () => | ||
| 30 | new Promise<void>((resolve, reject) => { | ||
| 31 | const holder = NodeChildProcess.spawn( | ||
| 32 | process.execPath, | ||
| 33 | ["-e", lockHolderSource, dbPath, String(holdMs)], | ||
| 34 | { stdio: ["ignore", "pipe", "ignore"] }, | ||
| 35 | ); | ||
| 36 | holder.stdout.once("data", () => resolve()); | ||
| 37 | holder.on("error", reject); | ||
| 38 | holder.on("exit", () => | ||
| 39 | reject(new Error("lock holder exited before acquiring the write lock")), | ||
| 40 | ); | ||
| 41 | }), | ||
| 42 | ); | ||
| 43 | |||
| 44 | it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { | ||
| 45 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); | ||
| 46 | const dbPath = NodePath.join(tempDir, "state.sqlite"); | ||
| 47 | |||
| 48 | return Effect.gen(function* () { | ||
| 49 | const sql = yield* SqlClient.SqlClient; | ||
| 50 | yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; | ||
| 51 | yield* spawnWriteLockHolder(dbPath, 300); | ||
| 52 | yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; | ||
| 53 | const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; | ||
| 54 | assert.deepEqual([...rows], [{ id: 1 }]); | ||
| 55 | }).pipe( | ||
| 56 | Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), | ||
| 57 | Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), | ||
| 58 | ); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.effect("shrinks the WAL file back to the size limit after a large write", () => { | ||
| 62 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); | ||
| 63 | const dbPath = NodePath.join(tempDir, "state.sqlite"); | ||
| 64 | const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; | ||
| 65 | // About 25% more 4 KB rows than the limit holds, in one transaction. | ||
| 66 | const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); | ||
| 67 | |||
| 68 | return Effect.gen(function* () { | ||
| 69 | const sql = yield* SqlClient.SqlClient; | ||
| 70 | yield* sql`CREATE TABLE wal_probe(payload BLOB)`; | ||
| 71 | yield* sql` | ||
| 72 | WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) | ||
| 73 | INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n | ||
| 74 | `; | ||
| 75 | assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); | ||
| 76 | |||
| 77 | // The auto-checkpoint after the large commit copied every frame into the | ||
| 78 | // database, so the next commit restarts the WAL and cuts the file back. | ||
| 79 | yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; | ||
| 80 | assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); | ||
| 81 | }).pipe( | ||
| 82 | Effect.provide(SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer))), | ||
| 83 | Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), | ||
| 84 | ); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it.effect("applies busy_timeout in the shared persistence setup", () => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const sql = yield* SqlClient.SqlClient; | ||
| 90 | const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; | ||
| 91 | assert.equal(rows[0]?.timeout, 5000); | ||
| 92 | }).pipe(Effect.provide(SqlitePersistence.layerMemory)), | ||
| 93 | ); | ||
apps/server/src/persistence/Sqlite.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 6 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 7 | |||
| 8 | import { runMigrations } from "./Migrations.ts"; | ||
| 9 | import { initializeV2Database } from "./initializeV2Database.ts"; | ||
| 10 | import * as ServerConfig from "../config.ts"; | ||
| 11 | |||
| 12 | // Size the -wal file is cut back to on the first commit after a WAL reset. | ||
| 13 | export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; | ||
| 14 | |||
| 15 | const layerSetup = Layer.effectDiscard( | ||
| 16 | Effect.gen(function* () { | ||
| 17 | const sql = yield* SqlClient.SqlClient; | ||
| 18 | // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. | ||
| 19 | yield* sql`PRAGMA busy_timeout = 5000;`; | ||
| 20 | yield* sql`PRAGMA foreign_keys = ON;`; | ||
| 21 | yield* sql`PRAGMA journal_mode = WAL;`; | ||
| 22 | // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its | ||
| 23 | // largest size until the last connection closes. | ||
| 24 | yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); | ||
| 25 | yield* runMigrations(); | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | |||
| 29 | export const layerFromPath = Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) { | ||
| 30 | const fs = yield* FileSystem.FileSystem; | ||
| 31 | const path = yield* Path.Path; | ||
| 32 | yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); | ||
| 33 | |||
| 34 | return Layer.provideMerge( | ||
| 35 | layerSetup, | ||
| 36 | NodeSqliteClient.layer({ | ||
| 37 | filename: dbPath, | ||
| 38 | spanAttributes: { | ||
| 39 | "db.name": path.basename(dbPath), | ||
| 40 | "service.name": "t3code-server", | ||
| 41 | }, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | }, Layer.unwrap); | ||
| 45 | |||
| 46 | export const layerMemory = Layer.provideMerge( | ||
| 47 | layerSetup, | ||
| 48 | NodeSqliteClient.layer({ filename: ":memory:" }), | ||
| 49 | ); | ||
| 50 | |||
| 51 | export const layerConfig = Layer.unwrap( | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const { dbPath } = yield* ServerConfig.ServerConfig; | ||
| 54 | yield* initializeV2Database(dbPath); | ||
| 55 | return layerFromPath(dbPath); | ||
| 56 | }), | ||
| 57 | ); | ||
apps/server/src/persistence/initializeV2Database.test.ts+1-1| ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; |
| 13 | import * as SqlClient from "effect/sql/SqlClient"; | 13 | import * as SqlClient from "effect/sql/SqlClient"; |
| 14 | 14 | ||
| 15 | import * as ServerConfig from "../config.ts"; | 15 | import * as ServerConfig from "../config.ts"; |
| 16 | import * as SqlitePersistence from "./Layers/Sqlite.ts"; | 16 | import * as SqlitePersistence from "./Sqlite.ts"; |
| 17 | import { runMigrations } from "./Migrations.ts"; | 17 | import { runMigrations } from "./Migrations.ts"; |
| 18 | import { initializeV2Database } from "./initializeV2Database.ts"; | 18 | import { initializeV2Database } from "./initializeV2Database.ts"; |
| 19 | import * as EventStore from "../orchestration-v2/EventStore.ts"; | 19 | import * as EventStore from "../orchestration-v2/EventStore.ts"; |
apps/server/src/project/ManagedProjectFolders.test.ts+1-1| ... | @@ -13,7 +13,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -13,7 +13,7 @@ import * as Scope from "effect/Scope"; |
| 13 | import * as ServerConfig from "../config.ts"; | 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; | 14 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 15 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 15 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 16 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 16 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 17 | import * as ProcessRunner from "../processRunner.ts"; | 17 | import * as ProcessRunner from "../processRunner.ts"; |
| 18 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 18 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 19 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 19 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
apps/server/src/project/ProjectService.deletion.test.ts+1-1| ... | @@ -27,7 +27,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ... | @@ -27,7 +27,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 27 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | 27 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 28 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; | 28 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; |
| 29 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; | 29 | import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; |
| 30 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 30 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 31 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 31 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 32 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; | 32 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 33 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 33 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
apps/server/src/project/ProjectService.test.ts+1-1| ... | @@ -18,7 +18,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ... | @@ -18,7 +18,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 18 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; | 18 | import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 19 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; | 19 | import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 20 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; | 20 | import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; |
| 21 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 21 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; | 23 | import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; |
| 24 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 24 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
apps/server/src/provider/AcpRegistryCatalog.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | |||
| 5 | import * as ServerConfig from "../config.ts"; | ||
| 6 | import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; | ||
| 7 | import * as AcpRegistryRuntimeCoordinator from "./acp/AcpRegistryRuntimeCoordinator.ts"; | ||
| 8 | |||
| 9 | /** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ | ||
| 10 | export const layer = Layer.merge( | ||
| 11 | Layer.unwrap( | ||
| 12 | Effect.gen(function* () { | ||
| 13 | const config = yield* ServerConfig.ServerConfig; | ||
| 14 | const path = yield* Path.Path; | ||
| 15 | return AcpRegistrySupport.AcpRegistryCatalog.layer({ | ||
| 16 | cacheDir: config.providerStatusCacheDir, | ||
| 17 | toolsDir: path.join(config.baseDir, "tools"), | ||
| 18 | }); | ||
| 19 | }), | ||
| 20 | ), | ||
| 21 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, | ||
| 22 | ); | ||
apps/server/src/provider/AntigravityAuth.ts+1-1| ... | @@ -28,7 +28,7 @@ import { | ... | @@ -28,7 +28,7 @@ import { |
| 28 | forwardAntigravityCallback, | 28 | forwardAntigravityCallback, |
| 29 | validateAntigravityCallbackUrl, | 29 | validateAntigravityCallbackUrl, |
| 30 | } from "./antigravityCallback.ts"; | 30 | } from "./antigravityCallback.ts"; |
| 31 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; | 31 | import type { ProviderAuthController } from "./ProviderAuthService.ts"; |
| 32 | 32 | ||
| 33 | const AUTH_TIMEOUT_MS = 300_000; | 33 | const AUTH_TIMEOUT_MS = 300_000; |
| 34 | const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; | 34 | const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; |
apps/server/src/provider/AntigravityProvider.test.ts created+661| ... | @@ -0,0 +1,661 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 4 | AntigravitySettings, | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ProviderSetupError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 17 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 18 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 19 | |||
| 20 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 21 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 22 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | ||
| 23 | import { | ||
| 24 | buildAntigravityModelsFromSession, | ||
| 25 | makeAntigravityProvider, | ||
| 26 | } from "./AntigravityProvider.ts"; | ||
| 27 | |||
| 28 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | ||
| 29 | const instanceId = ProviderInstanceId.make("antigravity-test"); | ||
| 30 | const driver = ProviderDriverKind.make("antigravity"); | ||
| 31 | |||
| 32 | const initializeResult = { | ||
| 33 | protocolVersion: 1, | ||
| 34 | agentCapabilities: { | ||
| 35 | loadSession: true, | ||
| 36 | promptCapabilities: { image: true, audio: true, embeddedContext: true }, | ||
| 37 | sessionCapabilities: { list: {}, resume: {} }, | ||
| 38 | }, | ||
| 39 | authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], | ||
| 40 | agentInfo: { | ||
| 41 | name: "antigravity-acp", | ||
| 42 | title: "Google Antigravity", | ||
| 43 | version: "agy_acp_server_20260818_01_RC01", | ||
| 44 | }, | ||
| 45 | } satisfies EffectAcpSchema.InitializeResponse; | ||
| 46 | |||
| 47 | const modelOptions = [ | ||
| 48 | { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, | ||
| 49 | { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, | ||
| 50 | { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, | ||
| 51 | { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, | ||
| 52 | { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, | ||
| 53 | { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, | ||
| 54 | { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, | ||
| 55 | { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, | ||
| 56 | { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, | ||
| 57 | { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, | ||
| 58 | { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, | ||
| 59 | ]; | ||
| 60 | |||
| 61 | const modelConfig = { | ||
| 62 | id: "model", | ||
| 63 | name: "Model", | ||
| 64 | category: "model", | ||
| 65 | type: "select", | ||
| 66 | currentValue: "gemini-3.7-flash-high", | ||
| 67 | options: modelOptions, | ||
| 68 | } satisfies EffectAcpSchema.SessionConfigOption; | ||
| 69 | |||
| 70 | const sessionSetupResult = { | ||
| 71 | sessionId: "session-1", | ||
| 72 | models: { | ||
| 73 | currentModelId: "gemini-3.7-flash-high", | ||
| 74 | availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), | ||
| 75 | }, | ||
| 76 | configOptions: [ | ||
| 77 | modelConfig, | ||
| 78 | { | ||
| 79 | id: "mode", | ||
| 80 | name: "Session Mode", | ||
| 81 | category: "mode", | ||
| 82 | type: "select", | ||
| 83 | currentValue: "default", | ||
| 84 | options: [ | ||
| 85 | { value: "default", name: "Default" }, | ||
| 86 | { value: "yolo", name: "YOLO" }, | ||
| 87 | ], | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | } satisfies EffectAcpSchema.NewSessionResponse; | ||
| 91 | |||
| 92 | const started = { | ||
| 93 | sessionId: "session-1", | ||
| 94 | initializeResult, | ||
| 95 | sessionSetupResult, | ||
| 96 | modelConfigId: "model", | ||
| 97 | } satisfies AcpSessionRuntimeStartResult; | ||
| 98 | |||
| 99 | const commands = [ | ||
| 100 | { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, | ||
| 101 | { name: "logout", description: "Sign out of Google" }, | ||
| 102 | ] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 103 | |||
| 104 | const expectedCommands = [ | ||
| 105 | { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, | ||
| 106 | { name: "logout", description: "Sign out of Google" }, | ||
| 107 | ]; | ||
| 108 | |||
| 109 | const layerTest = Layer.merge( | ||
| 110 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 111 | shouldRunScopeWork: () => Effect.succeed(false), | ||
| 112 | }), | ||
| 113 | ServerSettings.layerTest(), | ||
| 114 | ); | ||
| 115 | |||
| 116 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; | ||
| 117 | |||
| 118 | const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( | ||
| 119 | options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, | ||
| 120 | ) { | ||
| 121 | const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>(); | ||
| 122 | const probeCalls = yield* Ref.make(0); | ||
| 123 | const safetyCalls = yield* Ref.make(0); | ||
| 124 | const probe = yield* Ref.make<Effect.Effect<EffectAcpSchema.InitializeResponse, ProbeError>>( | ||
| 125 | Deferred.await(initialProbe), | ||
| 126 | ); | ||
| 127 | const safety = yield* Ref.make<Effect.Effect<boolean>>(Effect.succeed(options.safe ?? true)); | ||
| 128 | const provider = yield* makeAntigravityProvider( | ||
| 129 | decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), | ||
| 130 | { | ||
| 131 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | ||
| 132 | probe: Ref.update(probeCalls, (count) => count + 1).pipe( | ||
| 133 | Effect.andThen(Ref.get(probe)), | ||
| 134 | Effect.flatten, | ||
| 135 | ), | ||
| 136 | supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( | ||
| 137 | Effect.andThen(Ref.get(safety)), | ||
| 138 | Effect.flatten, | ||
| 139 | ), | ||
| 140 | }, | ||
| 141 | ); | ||
| 142 | const initialUpdate = yield* Stream.toPull( | ||
| 143 | provider.snapshot.streamChanges.pipe( | ||
| 144 | Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), | ||
| 145 | ), | ||
| 146 | ); | ||
| 147 | const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( | ||
| 148 | Effect.andThen(initialUpdate), | ||
| 149 | Effect.asVoid, | ||
| 150 | ); | ||
| 151 | return { | ||
| 152 | provider, | ||
| 153 | probe, | ||
| 154 | probeCalls, | ||
| 155 | safety, | ||
| 156 | safetyCalls, | ||
| 157 | initialProbe, | ||
| 158 | initialUpdate, | ||
| 159 | initialize, | ||
| 160 | }; | ||
| 161 | }); | ||
| 162 | |||
| 163 | describe("Antigravity model catalog", () => { | ||
| 164 | it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { | ||
| 165 | const models = buildAntigravityModelsFromSession(sessionSetupResult); | ||
| 166 | expect(models.map((model) => [model.slug, model.name])).toEqual( | ||
| 167 | modelOptions.map((option) => [option.value, option.name]), | ||
| 168 | ); | ||
| 169 | expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ | ||
| 170 | "gemini-3.7-flash-high", | ||
| 171 | ]); | ||
| 172 | expect( | ||
| 173 | models | ||
| 174 | .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) | ||
| 175 | .map((model) => model.slug), | ||
| 176 | ).toEqual(["gemini-3.7-flash-high"]); | ||
| 177 | expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); | ||
| 178 | expect(models.every((model) => !model.isCustom)).toBe(true); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("uses legacy session models only when model config is absent", () => { | ||
| 182 | const fromLegacy = buildAntigravityModelsFromSession({ | ||
| 183 | models: sessionSetupResult.models, | ||
| 184 | }); | ||
| 185 | expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 186 | expect( | ||
| 187 | buildAntigravityModelsFromSession({ | ||
| 188 | ...sessionSetupResult, | ||
| 189 | configOptions: [{ ...modelConfig, options: [] }], | ||
| 190 | }), | ||
| 191 | ).toEqual([]); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("flattens native option groups without combining distinct model IDs", () => { | ||
| 195 | const models = buildAntigravityModelsFromSession({ | ||
| 196 | configOptions: [ | ||
| 197 | { | ||
| 198 | ...modelConfig, | ||
| 199 | currentValue: "gemini-pro-agent", | ||
| 200 | options: [ | ||
| 201 | { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, | ||
| 202 | { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, | ||
| 203 | ], | ||
| 204 | }, | ||
| 205 | ], | ||
| 206 | }); | ||
| 207 | expect(models.map((model) => model.slug)).toEqual([ | ||
| 208 | "gemini-3.7-flash-high", | ||
| 209 | "gemini-3.7-flash-medium", | ||
| 210 | "gemini-pro-agent", | ||
| 211 | ]); | ||
| 212 | expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); | ||
| 213 | expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( | ||
| 214 | "gemini-pro-agent", | ||
| 215 | ); | ||
| 216 | }); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it.layer(layerTest)("Antigravity provider snapshots", (it) => { | ||
| 220 | it.effect("does not probe or run helper safety checks while disabled", () => | ||
| 221 | Effect.scoped( | ||
| 222 | Effect.gen(function* () { | ||
| 223 | const harness = yield* makeHarness({ enabled: false }); | ||
| 224 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 225 | expect(snapshot).toMatchObject({ | ||
| 226 | enabled: false, | ||
| 227 | installed: false, | ||
| 228 | status: "disabled", | ||
| 229 | auth: { status: "unknown" }, | ||
| 230 | models: [], | ||
| 231 | setup: { canAuthenticate: true, canInstall: true }, | ||
| 232 | showInteractionModeToggle: false, | ||
| 233 | supportsConversationRollback: false, | ||
| 234 | supportsTextGeneration: false, | ||
| 235 | }); | ||
| 236 | expect(yield* Ref.get(harness.probeCalls)).toBe(0); | ||
| 237 | expect(yield* Ref.get(harness.safetyCalls)).toBe(0); | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | |||
| 242 | it.effect("records explicit sign-in while disabled without starting a health probe", () => | ||
| 243 | Effect.scoped( | ||
| 244 | Effect.gen(function* () { | ||
| 245 | const harness = yield* makeHarness({ enabled: false }); | ||
| 246 | const signedIn = yield* Stream.toPull( | ||
| 247 | harness.provider.snapshot.streamChanges.pipe( | ||
| 248 | Stream.filter( | ||
| 249 | (snapshot) => | ||
| 250 | snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, | ||
| 251 | ), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | yield* harness.provider.onSessionStarted(started); | ||
| 255 | yield* harness.provider.onAvailableCommands(commands); | ||
| 256 | const [snapshot] = yield* signedIn; | ||
| 257 | expect(snapshot).toMatchObject({ | ||
| 258 | enabled: false, | ||
| 259 | installed: true, | ||
| 260 | status: "disabled", | ||
| 261 | auth: { status: "authenticated", type: "oauth-personal" }, | ||
| 262 | workspaceSnapshots: [], | ||
| 263 | }); | ||
| 264 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 265 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 266 | expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); | ||
| 267 | expect(yield* Ref.get(harness.probeCalls)).toBe(0); | ||
| 268 | }), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("publishes the configured sign-in method before any account is checked", () => | ||
| 273 | Effect.scoped( | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { | ||
| 276 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | ||
| 277 | probe: Effect.succeed(initializeResult), | ||
| 278 | supportsTextGeneration: Effect.succeed(true), | ||
| 279 | auth: { type: "gemini-api-key", label: "Gemini API key" }, | ||
| 280 | }); | ||
| 281 | expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ | ||
| 282 | status: "unknown", | ||
| 283 | type: "gemini-api-key", | ||
| 284 | }); | ||
| 285 | }), | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | |||
| 289 | it.effect("treats initialize as installation proof, not account or model discovery", () => | ||
| 290 | Effect.scoped( | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const harness = yield* makeHarness(); | ||
| 293 | expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ | ||
| 294 | installed: false, | ||
| 295 | status: "warning", | ||
| 296 | auth: { status: "unknown" }, | ||
| 297 | models: [], | ||
| 298 | }); | ||
| 299 | yield* harness.initialize; | ||
| 300 | const snapshot = yield* harness.provider.snapshot.getSnapshot; | ||
| 301 | expect(snapshot).toMatchObject({ | ||
| 302 | installed: true, | ||
| 303 | status: "warning", | ||
| 304 | version: "agy_acp_server_20260818_01_RC01", | ||
| 305 | auth: { status: "unknown" }, | ||
| 306 | models: [], | ||
| 307 | }); | ||
| 308 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 309 | }), | ||
| 310 | ), | ||
| 311 | ); | ||
| 312 | |||
| 313 | it.effect("publishes session metadata and native commands without another health probe", () => | ||
| 314 | Effect.scoped( | ||
| 315 | Effect.gen(function* () { | ||
| 316 | const harness = yield* makeHarness(); | ||
| 317 | yield* harness.initialize; | ||
| 318 | const nextReady = yield* Stream.toPull( | ||
| 319 | harness.provider.snapshot.streamChanges.pipe( | ||
| 320 | Stream.filter( | ||
| 321 | (snapshot) => | ||
| 322 | snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, | ||
| 323 | ), | ||
| 324 | ), | ||
| 325 | ); | ||
| 326 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 327 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 328 | const [snapshot] = yield* nextReady; | ||
| 329 | expect(snapshot).toMatchObject({ | ||
| 330 | status: "ready", | ||
| 331 | auth: { status: "authenticated", type: "oauth-personal" }, | ||
| 332 | supportsTextGeneration: true, | ||
| 333 | }); | ||
| 334 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 335 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 336 | expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( | ||
| 337 | expectedCommands, | ||
| 338 | ); | ||
| 339 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ); | ||
| 343 | |||
| 344 | it.effect("does not retain a disposable sign-in workspace", () => | ||
| 345 | Effect.scoped( | ||
| 346 | Effect.gen(function* () { | ||
| 347 | const harness = yield* makeHarness(); | ||
| 348 | yield* harness.initialize; | ||
| 349 | yield* harness.provider.onSessionStarted(started); | ||
| 350 | yield* harness.provider.onAvailableCommands(commands); | ||
| 351 | const snapshot = yield* harness.provider.snapshot.getSnapshot; | ||
| 352 | expect(snapshot.models).toHaveLength(11); | ||
| 353 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 354 | expect(snapshot.workspaceSnapshots).toEqual([]); | ||
| 355 | }), | ||
| 356 | ), | ||
| 357 | ); | ||
| 358 | |||
| 359 | it.effect("clears all account metadata on sign-out and authentication failure", () => | ||
| 360 | Effect.scoped( | ||
| 361 | Effect.gen(function* () { | ||
| 362 | const harness = yield* makeHarness(); | ||
| 363 | yield* harness.initialize; | ||
| 364 | for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { | ||
| 365 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 366 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 367 | yield* clear; | ||
| 368 | expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ | ||
| 369 | installed: true, | ||
| 370 | status: "warning", | ||
| 371 | auth: { status: "unauthenticated" }, | ||
| 372 | models: [], | ||
| 373 | slashCommands: [], | ||
| 374 | skills: [], | ||
| 375 | workspaceSnapshots: [], | ||
| 376 | supportsTextGeneration: false, | ||
| 377 | }); | ||
| 378 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 379 | yield* harness.provider.onConfigOptionsUpdated([modelConfig]); | ||
| 380 | expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); | ||
| 381 | expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); | ||
| 382 | } | ||
| 383 | const refreshed = yield* harness.provider.snapshot.refresh; | ||
| 384 | expect(refreshed.auth.status).toBe("unauthenticated"); | ||
| 385 | expect(refreshed.supportsTextGeneration).toBe(false); | ||
| 386 | }), | ||
| 387 | ), | ||
| 388 | ); | ||
| 389 | |||
| 390 | it.effect("replaces live model choices and accepts an empty catalog", () => | ||
| 391 | Effect.scoped( | ||
| 392 | Effect.gen(function* () { | ||
| 393 | const harness = yield* makeHarness(); | ||
| 394 | yield* harness.initialize; | ||
| 395 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 396 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 397 | const before = yield* harness.provider.snapshot.getSnapshot; | ||
| 398 | const configOptions = [ | ||
| 399 | { | ||
| 400 | ...modelConfig, | ||
| 401 | currentValue: "gemini-3.8-flash-high", | ||
| 402 | options: modelOptions.slice(0, 3), | ||
| 403 | }, | ||
| 404 | ]; | ||
| 405 | const nextSnapshot = yield* Stream.toPull( | ||
| 406 | harness.provider.snapshot.streamChanges.pipe( | ||
| 407 | Stream.filter((snapshot) => snapshot.models.length === 3), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | yield* harness.provider.onConfigOptionsUpdated(configOptions); | ||
| 411 | expect((yield* nextSnapshot)[0]).toMatchObject({ | ||
| 412 | models: buildAntigravityModelsFromSession({ configOptions }), | ||
| 413 | auth: before.auth, | ||
| 414 | workspaceSnapshots: before.workspaceSnapshots, | ||
| 415 | slashCommands: expectedCommands, | ||
| 416 | }); | ||
| 417 | yield* harness.provider.onConfigOptionsUpdated([]); | ||
| 418 | expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); | ||
| 419 | }), | ||
| 420 | ), | ||
| 421 | ); | ||
| 422 | |||
| 423 | it.effect("replaces one account's catalog instead of combining accounts", () => | ||
| 424 | Effect.scoped( | ||
| 425 | Effect.gen(function* () { | ||
| 426 | const harness = yield* makeHarness(); | ||
| 427 | yield* harness.initialize; | ||
| 428 | yield* harness.provider.onSessionStarted(started); | ||
| 429 | yield* harness.provider.onSignedOut; | ||
| 430 | yield* harness.provider.onSessionStarted({ | ||
| 431 | ...started, | ||
| 432 | sessionSetupResult: { | ||
| 433 | sessionId: "new-account-session", | ||
| 434 | configOptions: [ | ||
| 435 | { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, | ||
| 436 | ], | ||
| 437 | }, | ||
| 438 | }); | ||
| 439 | expect( | ||
| 440 | (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), | ||
| 441 | ).toEqual(["gemini-pro-agent"]); | ||
| 442 | }), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | |||
| 446 | it.effect("retains known account metadata when a local health check fails", () => | ||
| 447 | Effect.scoped( | ||
| 448 | Effect.gen(function* () { | ||
| 449 | const harness = yield* makeHarness(); | ||
| 450 | yield* harness.initialize; | ||
| 451 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 452 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 453 | yield* Ref.set( | ||
| 454 | harness.probe, | ||
| 455 | Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), | ||
| 456 | ); | ||
| 457 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 458 | expect(snapshot).toMatchObject({ | ||
| 459 | installed: true, | ||
| 460 | status: "error", | ||
| 461 | auth: { status: "authenticated" }, | ||
| 462 | }); | ||
| 463 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 464 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 465 | expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); | ||
| 466 | }), | ||
| 467 | ), | ||
| 468 | ); | ||
| 469 | |||
| 470 | it.effect("allows a slow packaged runtime health check to finish", () => | ||
| 471 | Effect.scoped( | ||
| 472 | Effect.gen(function* () { | ||
| 473 | const harness = yield* makeHarness(); | ||
| 474 | yield* harness.initialize; | ||
| 475 | const entered = yield* Deferred.make<void>(); | ||
| 476 | const initialized = yield* Deferred.make<EffectAcpSchema.InitializeResponse>(); | ||
| 477 | yield* Ref.set( | ||
| 478 | harness.probe, | ||
| 479 | Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), | ||
| 480 | ); | ||
| 481 | |||
| 482 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 483 | yield* Deferred.await(entered); | ||
| 484 | yield* TestClock.adjust("47 seconds"); | ||
| 485 | yield* Deferred.succeed(initialized, initializeResult); | ||
| 486 | const snapshot = yield* Fiber.join(refresh); | ||
| 487 | |||
| 488 | expect(snapshot).toMatchObject({ | ||
| 489 | installed: true, | ||
| 490 | status: "warning", | ||
| 491 | auth: { status: "unknown" }, | ||
| 492 | }); | ||
| 493 | }), | ||
| 494 | ), | ||
| 495 | ); | ||
| 496 | |||
| 497 | it.effect("closes a stalled health probe at its deadline", () => | ||
| 498 | Effect.scoped( | ||
| 499 | Effect.gen(function* () { | ||
| 500 | const harness = yield* makeHarness(); | ||
| 501 | yield* harness.initialize; | ||
| 502 | const entered = yield* Deferred.make<void>(); | ||
| 503 | const closed = yield* Deferred.make<void>(); | ||
| 504 | yield* Ref.set( | ||
| 505 | harness.probe, | ||
| 506 | Deferred.succeed(entered, undefined).pipe( | ||
| 507 | Effect.andThen(Effect.never), | ||
| 508 | Effect.ensuring(Deferred.succeed(closed, undefined)), | ||
| 509 | ), | ||
| 510 | ); | ||
| 511 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 512 | yield* Deferred.await(entered); | ||
| 513 | yield* TestClock.adjust("90 seconds"); | ||
| 514 | const snapshot = yield* Fiber.join(refresh); | ||
| 515 | yield* Deferred.await(closed); | ||
| 516 | expect(snapshot.status).toBe("error"); | ||
| 517 | expect(snapshot.message).toContain("90 seconds"); | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ); | ||
| 521 | |||
| 522 | it.effect("distinguishes missing executables from a failed installed executable", () => | ||
| 523 | Effect.scoped( | ||
| 524 | Effect.gen(function* () { | ||
| 525 | const harness = yield* makeHarness(); | ||
| 526 | yield* harness.initialize; | ||
| 527 | const failures = [ | ||
| 528 | { | ||
| 529 | error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), | ||
| 530 | installed: false, | ||
| 531 | }, | ||
| 532 | { | ||
| 533 | error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), | ||
| 534 | installed: true, | ||
| 535 | }, | ||
| 536 | { | ||
| 537 | error: new ProviderSetupError({ | ||
| 538 | instanceId, | ||
| 539 | operation: "resolve", | ||
| 540 | detail: "Antigravity is not installed.", | ||
| 541 | }), | ||
| 542 | installed: false, | ||
| 543 | }, | ||
| 544 | ]; | ||
| 545 | for (const { error, installed } of failures) { | ||
| 546 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 547 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 548 | yield* Ref.set(harness.probe, Effect.fail(error)); | ||
| 549 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 550 | expect(snapshot).toMatchObject({ | ||
| 551 | installed, | ||
| 552 | status: "error", | ||
| 553 | auth: { status: "authenticated" }, | ||
| 554 | }); | ||
| 555 | expect(snapshot.models).toHaveLength(installed ? 11 : 0); | ||
| 556 | expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); | ||
| 557 | expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); | ||
| 558 | expect(snapshot.supportsTextGeneration).toBe(installed); | ||
| 559 | } | ||
| 560 | }), | ||
| 561 | ), | ||
| 562 | ); | ||
| 563 | |||
| 564 | it.effect("does not let an old health result restore a signed-out account", () => | ||
| 565 | Effect.scoped( | ||
| 566 | Effect.gen(function* () { | ||
| 567 | const harness = yield* makeHarness(); | ||
| 568 | yield* harness.initialize; | ||
| 569 | yield* harness.provider.onSessionStarted(started); | ||
| 570 | const releaseProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse>(); | ||
| 571 | const probeEntered = yield* Deferred.make<void>(); | ||
| 572 | yield* Ref.set( | ||
| 573 | harness.probe, | ||
| 574 | Deferred.succeed(probeEntered, undefined).pipe( | ||
| 575 | Effect.andThen(Deferred.await(releaseProbe)), | ||
| 576 | ), | ||
| 577 | ); | ||
| 578 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 579 | yield* Deferred.await(probeEntered); | ||
| 580 | yield* harness.provider.onSignedOut; | ||
| 581 | yield* Deferred.succeed(releaseProbe, initializeResult); | ||
| 582 | const snapshot = yield* Fiber.join(refresh); | ||
| 583 | expect(snapshot).toMatchObject({ | ||
| 584 | auth: { status: "unauthenticated" }, | ||
| 585 | models: [], | ||
| 586 | supportsTextGeneration: false, | ||
| 587 | }); | ||
| 588 | }), | ||
| 589 | ), | ||
| 590 | ); | ||
| 591 | |||
| 592 | it.effect("exposes helper support only when the supplied safety check allows it", () => | ||
| 593 | Effect.scoped( | ||
| 594 | Effect.gen(function* () { | ||
| 595 | const harness = yield* makeHarness({ safe: false }); | ||
| 596 | yield* harness.initialize; | ||
| 597 | yield* harness.provider.onSessionStarted(started); | ||
| 598 | expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); | ||
| 599 | yield* Ref.set(harness.safety, Effect.succeed(true)); | ||
| 600 | yield* harness.provider.snapshot.refresh; | ||
| 601 | expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("keeps discovered workspace skills through session and command updates", () => | ||
| 607 | Effect.scoped( | ||
| 608 | Effect.gen(function* () { | ||
| 609 | const harness = yield* makeHarness(); | ||
| 610 | yield* harness.initialize; | ||
| 611 | const skills = [ | ||
| 612 | { | ||
| 613 | name: "deploy", | ||
| 614 | description: "Ship it", | ||
| 615 | path: "/workspace/.agent/skills/deploy", | ||
| 616 | enabled: true, | ||
| 617 | }, | ||
| 618 | ]; | ||
| 619 | const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); | ||
| 620 | expect(discovered.skills).toEqual(skills); | ||
| 621 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 622 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 623 | const after = yield* harness.provider.snapshot.getSnapshot; | ||
| 624 | expect( | ||
| 625 | after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, | ||
| 626 | ).toEqual(skills); | ||
| 627 | expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); | ||
| 628 | |||
| 629 | const rescanned = [ | ||
| 630 | ...skills, | ||
| 631 | { name: "review", path: "/workspace/.agent/skills/review", enabled: true }, | ||
| 632 | ]; | ||
| 633 | yield* harness.provider.snapshotForCwd("/workspace", rescanned); | ||
| 634 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 635 | expect( | ||
| 636 | (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find( | ||
| 637 | (entry) => entry.cwd === "/workspace", | ||
| 638 | )?.skills, | ||
| 639 | ).toEqual(rescanned); | ||
| 640 | }), | ||
| 641 | ), | ||
| 642 | ); | ||
| 643 | |||
| 644 | it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => | ||
| 645 | Effect.scoped( | ||
| 646 | Effect.gen(function* () { | ||
| 647 | const harness = yield* makeHarness(); | ||
| 648 | yield* harness.initialize; | ||
| 649 | yield* harness.provider.onSessionStarted(started); | ||
| 650 | for (let index = 0; index < 35; index++) { | ||
| 651 | yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); | ||
| 652 | } | ||
| 653 | const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); | ||
| 654 | expect(snapshot.workspaceSnapshots).toHaveLength(32); | ||
| 655 | expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); | ||
| 656 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 657 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 658 | }), | ||
| 659 | ), | ||
| 660 | ); | ||
| 661 | }); | ||
apps/server/src/provider/AntigravityProvider.ts created+415| ... | @@ -0,0 +1,415 @@ | ||
| 1 | import { | ||
| 2 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 3 | ProviderDriverKind, | ||
| 4 | type AntigravitySettings, | ||
| 5 | type ProviderSetupError, | ||
| 6 | type ServerProvider, | ||
| 7 | type ServerProviderModel, | ||
| 8 | type ServerProviderSlashCommand, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Predicate from "effect/Predicate"; | ||
| 15 | import * as Result from "effect/Result"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 19 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 20 | |||
| 21 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | ||
| 22 | import { makeManagedServerProvider } from "./makeManagedServerProvider.ts"; | ||
| 23 | import { | ||
| 24 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 25 | type ProviderMaintenanceCapabilities, | ||
| 26 | } from "./providerMaintenance.ts"; | ||
| 27 | import { | ||
| 28 | buildServerProvider, | ||
| 29 | isCommandMissingCause, | ||
| 30 | type ServerProviderDraft, | ||
| 31 | } from "./providerSnapshot.ts"; | ||
| 32 | |||
| 33 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | ||
| 34 | const MAX_WORKSPACE_SNAPSHOTS = 32; | ||
| 35 | const HEALTH_CHECK_TIMEOUT = "90 seconds"; | ||
| 36 | const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; | ||
| 37 | const AUTH_UNCHECKED_MESSAGE = | ||
| 38 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 39 | |||
| 40 | type SessionSetupResult = Pick< | ||
| 41 | AcpSessionRuntimeStartResult["sessionSetupResult"], | ||
| 42 | "configOptions" | "models" | ||
| 43 | >; | ||
| 44 | |||
| 45 | /** Keep the native model IDs, including model-specific thinking levels. */ | ||
| 46 | export function buildAntigravityModelsFromSession( | ||
| 47 | setup: SessionSetupResult, | ||
| 48 | ): ReadonlyArray<ServerProviderModel> { | ||
| 49 | const config = setup.configOptions?.find( | ||
| 50 | (option) => option.id === "model" || option.category === "model", | ||
| 51 | ); | ||
| 52 | const currentValue = | ||
| 53 | config?.type === "select" ? config.currentValue : setup.models?.currentModelId; | ||
| 54 | const entries = | ||
| 55 | config?.type === "select" | ||
| 56 | ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) | ||
| 57 | : config === undefined | ||
| 58 | ? (setup.models?.availableModels.map((model) => ({ | ||
| 59 | value: model.modelId, | ||
| 60 | name: model.name, | ||
| 61 | })) ?? []) | ||
| 62 | : []; | ||
| 63 | const seen = new Set<string>(); | ||
| 64 | return entries.flatMap((entry): ServerProviderModel[] => { | ||
| 65 | if (!entry.value.trim() || seen.has(entry.value)) return []; | ||
| 66 | seen.add(entry.value); | ||
| 67 | return [ | ||
| 68 | { | ||
| 69 | slug: entry.value, | ||
| 70 | name: entry.name.trim() ? entry.name : entry.value, | ||
| 71 | isCustom: false, | ||
| 72 | ...(entry.value === currentValue | ||
| 73 | ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } | ||
| 74 | : {}), | ||
| 75 | capabilities: EMPTY_MODEL_CAPABILITIES, | ||
| 76 | }, | ||
| 77 | ]; | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | |||
| 81 | function nativeCommands( | ||
| 82 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 83 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 84 | const seen = new Set<string>(); | ||
| 85 | return commands.flatMap((command): ServerProviderSlashCommand[] => { | ||
| 86 | if (!command.name.trim() || seen.has(command.name)) return []; | ||
| 87 | seen.add(command.name); | ||
| 88 | const description = command.description.trim(); | ||
| 89 | const hint = command.input?.hint.trim(); | ||
| 90 | return [ | ||
| 91 | { | ||
| 92 | name: command.name, | ||
| 93 | ...(description ? { description } : {}), | ||
| 94 | ...(hint ? { input: { hint } } : {}), | ||
| 95 | }, | ||
| 96 | ]; | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { | ||
| 101 | if (error._tag === "AcpSpawnError") { | ||
| 102 | return ( | ||
| 103 | isCommandMissingCause(error.cause) || | ||
| 104 | (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | return ( | ||
| 108 | error._tag === "ProviderSetupError" && | ||
| 109 | error.operation === "resolve" && | ||
| 110 | /not installed|missing|incomplete|does not publish/i.test(error.detail) | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | interface AntigravityProviderState { | ||
| 115 | readonly draft: ServerProviderDraft; | ||
| 116 | readonly authRevision: number; | ||
| 117 | } | ||
| 118 | |||
| 119 | interface AntigravityProviderOptions { | ||
| 120 | readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>; | ||
| 121 | readonly probe: Effect.Effect< | ||
| 122 | EffectAcpSchema.InitializeResponse, | ||
| 123 | EffectAcpErrors.AcpError | ProviderSetupError | ||
| 124 | >; | ||
| 125 | readonly supportsTextGeneration: Effect.Effect<boolean>; | ||
| 126 | readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; | ||
| 127 | /** Auth type and label published once a session authenticates. */ | ||
| 128 | readonly auth?: { readonly type: string; readonly label: string }; | ||
| 129 | } | ||
| 130 | |||
| 131 | /** Health uses initialize only. Session callbacks supply account-specific metadata. */ | ||
| 132 | export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( | ||
| 133 | settings: AntigravitySettings, | ||
| 134 | options: AntigravityProviderOptions, | ||
| 135 | ) { | ||
| 136 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 137 | const initialDraft = { | ||
| 138 | ...buildServerProvider({ | ||
| 139 | presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, | ||
| 140 | enabled: settings.enabled, | ||
| 141 | checkedAt, | ||
| 142 | models: [], | ||
| 143 | probe: { | ||
| 144 | installed: false, | ||
| 145 | version: null, | ||
| 146 | status: "warning", | ||
| 147 | // The configured method rides along so the registry can tell a saved | ||
| 148 | // account for this method from one left by a previous configuration. | ||
| 149 | auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, | ||
| 150 | message: settings.enabled | ||
| 151 | ? "Checking Antigravity availability." | ||
| 152 | : "Antigravity is disabled in T3 Code settings.", | ||
| 153 | }, | ||
| 154 | }), | ||
| 155 | setup: { canAuthenticate: true, canInstall: true }, | ||
| 156 | supportsConversationRollback: false, | ||
| 157 | supportsTextGeneration: false, | ||
| 158 | workspaceSnapshots: [], | ||
| 159 | } satisfies ServerProviderDraft; | ||
| 160 | const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({ | ||
| 161 | draft: initialDraft, | ||
| 162 | authRevision: 0, | ||
| 163 | }); | ||
| 164 | // Skills the driver discovered on disk per workspace. Session callbacks | ||
| 165 | // rewrite the workspace entry with native commands and must keep these, or | ||
| 166 | // the registry drops the suggestions and never re-reads the workspace. | ||
| 167 | const discoveredSkills = new Map<string, ServerProvider["skills"]>(); | ||
| 168 | const getSnapshot = SubscriptionRef.get(metadata).pipe( | ||
| 169 | Effect.flatMap((state) => options.stampIdentity(state.draft)), | ||
| 170 | ); | ||
| 171 | |||
| 172 | const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { | ||
| 173 | if (!settings.enabled) return yield* getSnapshot; | ||
| 174 | const before = yield* SubscriptionRef.get(metadata); | ||
| 175 | const result = yield* options.probe.pipe( | ||
| 176 | Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), | ||
| 177 | Effect.result, | ||
| 178 | ); | ||
| 179 | const initialized = | ||
| 180 | Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; | ||
| 181 | const failure = Result.isFailure(result) ? result.failure : undefined; | ||
| 182 | const missingInstallation = failure !== undefined && isMissingInstallation(failure); | ||
| 183 | const errorMessage = | ||
| 184 | initialized !== undefined | ||
| 185 | ? undefined | ||
| 186 | : failure?._tag === "ProviderSetupError" | ||
| 187 | ? failure.detail.trim() || "Antigravity could not complete its local health check." | ||
| 188 | : missingInstallation | ||
| 189 | ? "Antigravity is not installed or its executable could not be found." | ||
| 190 | : failure | ||
| 191 | ? "Antigravity could not complete its local health check." | ||
| 192 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; | ||
| 193 | const supportsTextGeneration = | ||
| 194 | initialized !== undefined ? yield* options.supportsTextGeneration : false; | ||
| 195 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 196 | const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { | ||
| 197 | if (state.authRevision !== before.authRevision) return state; | ||
| 198 | const { message: _previousMessage, ...draft } = state.draft; | ||
| 199 | const authenticated = draft.auth.status === "authenticated"; | ||
| 200 | const message = | ||
| 201 | errorMessage ?? | ||
| 202 | (authenticated | ||
| 203 | ? undefined | ||
| 204 | : draft.auth.status === "unauthenticated" | ||
| 205 | ? SIGN_IN_MESSAGE | ||
| 206 | : AUTH_UNCHECKED_MESSAGE); | ||
| 207 | return { | ||
| 208 | ...state, | ||
| 209 | draft: { | ||
| 210 | ...draft, | ||
| 211 | installed: !missingInstallation, | ||
| 212 | version: initialized?.agentInfo?.version || draft.version, | ||
| 213 | status: errorMessage ? "error" : authenticated ? "ready" : "warning", | ||
| 214 | checkedAt: updatedAt, | ||
| 215 | ...(missingInstallation | ||
| 216 | ? { | ||
| 217 | models: [], | ||
| 218 | slashCommands: [], | ||
| 219 | skills: [], | ||
| 220 | workspaceSnapshots: [], | ||
| 221 | supportsTextGeneration: false, | ||
| 222 | } | ||
| 223 | : {}), | ||
| 224 | ...(initialized !== undefined | ||
| 225 | ? { | ||
| 226 | supportsTextGeneration: | ||
| 227 | supportsTextGeneration && draft.auth.status !== "unauthenticated", | ||
| 228 | } | ||
| 229 | : {}), | ||
| 230 | ...(message ? { message } : {}), | ||
| 231 | }, | ||
| 232 | } satisfies AntigravityProviderState; | ||
| 233 | }); | ||
| 234 | return yield* options.stampIdentity(next.draft); | ||
| 235 | }); | ||
| 236 | |||
| 237 | const maintenanceCapabilities = | ||
| 238 | options.maintenanceCapabilities ?? | ||
| 239 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 240 | provider: ProviderDriverKind.make("antigravity"), | ||
| 241 | packageName: null, | ||
| 242 | }); | ||
| 243 | const managed = yield* makeManagedServerProvider({ | ||
| 244 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | ||
| 245 | getSettings: Effect.succeed(settings), | ||
| 246 | streamSettings: Stream.empty, | ||
| 247 | haveSettingsChanged: () => false, | ||
| 248 | initialSnapshot: () => getSnapshot, | ||
| 249 | checkProvider: checkProvider(), | ||
| 250 | enrichSnapshot: ({ publishSnapshot }) => | ||
| 251 | SubscriptionRef.changes(metadata).pipe( | ||
| 252 | Stream.runForEach((state) => | ||
| 253 | options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), | ||
| 254 | ), | ||
| 255 | ), | ||
| 256 | }); | ||
| 257 | |||
| 258 | const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( | ||
| 259 | started: AcpSessionRuntimeStartResult, | ||
| 260 | cwd?: string, | ||
| 261 | ) { | ||
| 262 | const before = yield* SubscriptionRef.get(metadata); | ||
| 263 | const supportsTextGeneration = yield* options.supportsTextGeneration; | ||
| 264 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 265 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 266 | if ( | ||
| 267 | state.authRevision !== before.authRevision && | ||
| 268 | state.draft.auth.status === "unauthenticated" | ||
| 269 | ) { | ||
| 270 | return state; | ||
| 271 | } | ||
| 272 | const { message: _previousMessage, ...draft } = state.draft; | ||
| 273 | const workspaces = draft.workspaceSnapshots ?? []; | ||
| 274 | const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; | ||
| 275 | return { | ||
| 276 | authRevision: state.authRevision + 1, | ||
| 277 | draft: { | ||
| 278 | ...draft, | ||
| 279 | installed: true, | ||
| 280 | status: settings.enabled ? "ready" : "disabled", | ||
| 281 | version: started.initializeResult.agentInfo?.version || draft.version, | ||
| 282 | auth: { | ||
| 283 | status: "authenticated", | ||
| 284 | type: options.auth?.type ?? "oauth-personal", | ||
| 285 | label: options.auth?.label ?? "Google account", | ||
| 286 | }, | ||
| 287 | checkedAt: updatedAt, | ||
| 288 | models: buildAntigravityModelsFromSession(started.sessionSetupResult), | ||
| 289 | supportsTextGeneration, | ||
| 290 | ...(cwd | ||
| 291 | ? { | ||
| 292 | workspaceSnapshots: [ | ||
| 293 | ...workspaces.filter((entry) => entry.cwd !== cwd), | ||
| 294 | { | ||
| 295 | cwd, | ||
| 296 | checkedAt: updatedAt, | ||
| 297 | slashCommands: workspace?.slashCommands ?? draft.slashCommands, | ||
| 298 | skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], | ||
| 299 | }, | ||
| 300 | ].slice(-MAX_WORKSPACE_SNAPSHOTS), | ||
| 301 | } | ||
| 302 | : {}), | ||
| 303 | }, | ||
| 304 | } satisfies AntigravityProviderState; | ||
| 305 | }); | ||
| 306 | }); | ||
| 307 | |||
| 308 | const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( | ||
| 309 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | ||
| 310 | ) { | ||
| 311 | const models = buildAntigravityModelsFromSession({ configOptions }); | ||
| 312 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 313 | if (state.draft.auth.status !== "authenticated") return state; | ||
| 314 | return { ...state, draft: { ...state.draft, models } }; | ||
| 315 | }); | ||
| 316 | }); | ||
| 317 | |||
| 318 | const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( | ||
| 319 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 320 | cwd?: string, | ||
| 321 | ) { | ||
| 322 | const slashCommands = nativeCommands(commands); | ||
| 323 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 324 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 325 | if (state.draft.auth.status === "unauthenticated") return state; | ||
| 326 | return { | ||
| 327 | ...state, | ||
| 328 | draft: { | ||
| 329 | ...state.draft, | ||
| 330 | slashCommands, | ||
| 331 | ...(cwd | ||
| 332 | ? { | ||
| 333 | workspaceSnapshots: [ | ||
| 334 | ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), | ||
| 335 | { | ||
| 336 | cwd, | ||
| 337 | checkedAt: updatedAt, | ||
| 338 | slashCommands, | ||
| 339 | skills: | ||
| 340 | state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? | ||
| 341 | discoveredSkills.get(cwd) ?? | ||
| 342 | [], | ||
| 343 | }, | ||
| 344 | ].slice(-MAX_WORKSPACE_SNAPSHOTS), | ||
| 345 | } | ||
| 346 | : {}), | ||
| 347 | }, | ||
| 348 | }; | ||
| 349 | }); | ||
| 350 | }); | ||
| 351 | |||
| 352 | const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { | ||
| 353 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 354 | yield* SubscriptionRef.update( | ||
| 355 | metadata, | ||
| 356 | (state) => | ||
| 357 | ({ | ||
| 358 | authRevision: state.authRevision + 1, | ||
| 359 | draft: { | ||
| 360 | ...state.draft, | ||
| 361 | auth: { status: "unauthenticated" }, | ||
| 362 | status: settings.enabled ? "warning" : "disabled", | ||
| 363 | message: SIGN_IN_MESSAGE, | ||
| 364 | checkedAt: updatedAt, | ||
| 365 | models: [], | ||
| 366 | slashCommands: [], | ||
| 367 | skills: [], | ||
| 368 | workspaceSnapshots: [], | ||
| 369 | supportsTextGeneration: false, | ||
| 370 | }, | ||
| 371 | }) satisfies AntigravityProviderState, | ||
| 372 | ); | ||
| 373 | discoveredSkills.clear(); | ||
| 374 | }); | ||
| 375 | |||
| 376 | const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( | ||
| 377 | cwd: string, | ||
| 378 | skills?: ServerProvider["skills"], | ||
| 379 | ) { | ||
| 380 | if (skills) { | ||
| 381 | discoveredSkills.set(cwd, skills); | ||
| 382 | // A rescan replaces the stored entry's skills. Session callbacks and | ||
| 383 | // health checks republish that entry, so it must not keep old ones. | ||
| 384 | yield* SubscriptionRef.update(metadata, (state) => | ||
| 385 | state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd) | ||
| 386 | ? { | ||
| 387 | ...state, | ||
| 388 | draft: { | ||
| 389 | ...state.draft, | ||
| 390 | workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) => | ||
| 391 | entry.cwd === cwd ? { ...entry, skills } : entry, | ||
| 392 | ), | ||
| 393 | }, | ||
| 394 | } | ||
| 395 | : state, | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | const snapshot = yield* getSnapshot; | ||
| 399 | const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); | ||
| 400 | const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; | ||
| 401 | return workspace | ||
| 402 | ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } | ||
| 403 | : { ...snapshot, skills: resolvedSkills }; | ||
| 404 | }); | ||
| 405 | |||
| 406 | return { | ||
| 407 | snapshot: { ...managed, getSnapshot }, | ||
| 408 | onSessionStarted, | ||
| 409 | onConfigOptionsUpdated, | ||
| 410 | onAvailableCommands, | ||
| 411 | onSignedOut: clearAccountMetadata(), | ||
| 412 | onAuthRequired: clearAccountMetadata(), | ||
| 413 | snapshotForCwd, | ||
| 414 | }; | ||
| 415 | }); | ||
apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts created+355| ... | @@ -0,0 +1,355 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. | ||
| 2 | import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; | ||
| 3 | import { vi } from "vite-plus/test"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as TestClock from "effect/testing/TestClock"; | ||
| 7 | import { | ||
| 8 | ClaudeSettings, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | type ServerProvider, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import * as NodeFSP from "node:fs/promises"; | ||
| 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 15 | import { assert, it } from "@effect/vitest"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as FileSystem from "effect/FileSystem"; | ||
| 18 | import * as Path from "effect/Path"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | |||
| 21 | import { | ||
| 22 | buildClaudeCapabilitiesProbeQueryOptions, | ||
| 23 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, | ||
| 24 | probeClaudeCapabilities, | ||
| 25 | probeClaudeWorkspaceSnapshot, | ||
| 26 | } from "./ClaudeProvider.ts"; | ||
| 27 | import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; | ||
| 28 | |||
| 29 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); | ||
| 30 | |||
| 31 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | ||
| 32 | |||
| 33 | it("isolates Claude capability probes without dropping workspace setting sources", () => { | ||
| 34 | const abortController = new AbortController(); | ||
| 35 | const options = buildClaudeCapabilitiesProbeQueryOptions({ | ||
| 36 | executablePath: "/usr/bin/claude", | ||
| 37 | abortController, | ||
| 38 | environment: { | ||
| 39 | HOME: "/home/user", | ||
| 40 | ENABLE_CLAUDEAI_MCP_SERVERS: "true", | ||
| 41 | FORCE_CODE_TERMINAL: "1", | ||
| 42 | }, | ||
| 43 | cwd: "/workspace/project", | ||
| 44 | }); | ||
| 45 | |||
| 46 | assert.deepEqual(options.mcpServers, {}); | ||
| 47 | assert.equal(options.strictMcpConfig, true); | ||
| 48 | assert.equal(options.cwd, "/workspace/project"); | ||
| 49 | assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); | ||
| 50 | assert.deepEqual(options.settings, { disableAllHooks: true }); | ||
| 51 | assert.deepEqual(options.allowedTools, []); | ||
| 52 | assert.equal(options.persistSession, false); | ||
| 53 | assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); | ||
| 54 | assert.equal(options.abortController, abortController); | ||
| 55 | assert.equal(options.env?.HOME, "/home/user"); | ||
| 56 | assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); | ||
| 57 | assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); | ||
| 58 | assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); | ||
| 59 | assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { | ||
| 63 | it.effect( | ||
| 64 | "discovers commands and skills separately for each cwd without replacing machine metadata", | ||
| 65 | () => | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const fs = yield* FileSystem.FileSystem; | ||
| 68 | const path = yield* Path.Path; | ||
| 69 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); | ||
| 70 | const configDir = path.join(tempDir, "claude-home"); | ||
| 71 | const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; | ||
| 72 | for (const cwd of workspaces) { | ||
| 73 | const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); | ||
| 74 | yield* fs.makeDirectory(skillDir, { recursive: true }); | ||
| 75 | yield* fs.writeFileString( | ||
| 76 | path.join(skillDir, "SKILL.md"), | ||
| 77 | "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | const machineSnapshot = { | ||
| 81 | instanceId: ProviderInstanceId.make("claude"), | ||
| 82 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 83 | enabled: true, | ||
| 84 | installed: true, | ||
| 85 | status: "ready", | ||
| 86 | auth: { status: "authenticated", email: "machine@example.com" }, | ||
| 87 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 88 | version: "2.1.288", | ||
| 89 | models: [], | ||
| 90 | slashCommands: [{ name: "server-cwd-only" }], | ||
| 91 | skills: [], | ||
| 92 | } satisfies ServerProvider; | ||
| 93 | let usageCalls = 0; | ||
| 94 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { | ||
| 95 | assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); | ||
| 96 | assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); | ||
| 97 | return { | ||
| 98 | initializationResult: async () => ({ | ||
| 99 | account: { email: "workspace@example.com" }, | ||
| 100 | commands: [ | ||
| 101 | { | ||
| 102 | name: options?.cwd === workspaces[0] ? "start-session" : "other-project", | ||
| 103 | description: "Project command", | ||
| 104 | argumentHint: "[topic]", | ||
| 105 | }, | ||
| 106 | { name: "nested:review", description: "Review changes", argumentHint: "" }, | ||
| 107 | { name: "NESTED:review", description: "", argumentHint: "[path]" }, | ||
| 108 | { name: "compact", description: "Provider compact", argumentHint: "" }, | ||
| 109 | { name: "user-command", description: "Existing user command", argumentHint: "" }, | ||
| 110 | ], | ||
| 111 | }), | ||
| 112 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { | ||
| 113 | usageCalls++; | ||
| 114 | return { rate_limits_available: false, rate_limits: null }; | ||
| 115 | }, | ||
| 116 | } as ReturnType<typeof ClaudeSdk.query>; | ||
| 117 | }); | ||
| 118 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 119 | for (const [index, cwd] of workspaces.entries()) { | ||
| 120 | const scoped = yield* probeClaudeWorkspaceSnapshot( | ||
| 121 | decodeClaudeSettings({ homePath: configDir }), | ||
| 122 | machineSnapshot, | ||
| 123 | cwd, | ||
| 124 | { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, | ||
| 125 | ); | ||
| 126 | assert.deepEqual(scoped, { | ||
| 127 | ...machineSnapshot, | ||
| 128 | slashCommandsPending: false, | ||
| 129 | slashCommands: [ | ||
| 130 | COMPACT_SLASH_COMMAND, | ||
| 131 | { | ||
| 132 | name: index === 0 ? "start-session" : "other-project", | ||
| 133 | description: "Project command", | ||
| 134 | input: { hint: "[topic]" }, | ||
| 135 | }, | ||
| 136 | { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, | ||
| 137 | { name: "user-command", description: "Existing user command" }, | ||
| 138 | ], | ||
| 139 | skills: [ | ||
| 140 | { | ||
| 141 | name: "existing-skill", | ||
| 142 | path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), | ||
| 143 | enabled: true, | ||
| 144 | scope: "project", | ||
| 145 | description: "Existing project skill", | ||
| 146 | }, | ||
| 147 | ], | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | assert.deepEqual( | ||
| 151 | query.mock.calls.map(([input]) => input.options?.cwd), | ||
| 152 | workspaces, | ||
| 153 | ); | ||
| 154 | assert.equal(usageCalls, 0); | ||
| 155 | }).pipe(Effect.scoped), | ||
| 156 | ); | ||
| 157 | |||
| 158 | it.effect("keeps readable skills during failed command discovery and recovers on retry", () => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const fs = yield* FileSystem.FileSystem; | ||
| 161 | const path = yield* Path.Path; | ||
| 162 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); | ||
| 163 | const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); | ||
| 164 | yield* fs.makeDirectory(skillDir, { recursive: true }); | ||
| 165 | yield* fs.writeFileString( | ||
| 166 | path.join(skillDir, "SKILL.md"), | ||
| 167 | "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", | ||
| 168 | ); | ||
| 169 | const machineSnapshot = { | ||
| 170 | instanceId: ProviderInstanceId.make("claude"), | ||
| 171 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 172 | enabled: true, | ||
| 173 | installed: true, | ||
| 174 | status: "ready", | ||
| 175 | auth: { status: "authenticated" }, | ||
| 176 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 177 | version: "2.1.288", | ||
| 178 | models: [], | ||
| 179 | slashCommands: [{ name: "server-cwd-only" }], | ||
| 180 | skills: [], | ||
| 181 | } satisfies ServerProvider; | ||
| 182 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( | ||
| 183 | () => | ||
| 184 | ({ | ||
| 185 | initializationResult: () => | ||
| 186 | Promise.reject<ClaudeSdk.SDKControlInitializeResponse>( | ||
| 187 | new Error("Initialization failed"), | ||
| 188 | ), | ||
| 189 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ | ||
| 190 | rate_limits_available: false, | ||
| 191 | rate_limits: null, | ||
| 192 | }), | ||
| 193 | }) as ReturnType<typeof ClaudeSdk.query>, | ||
| 194 | ); | ||
| 195 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 196 | const settings = decodeClaudeSettings({ homePath: cwd }); | ||
| 197 | const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); | ||
| 198 | assert.deepEqual(failed, { | ||
| 199 | ...machineSnapshot, | ||
| 200 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 201 | slashCommandsPending: true, | ||
| 202 | skills: [ | ||
| 203 | { | ||
| 204 | name: "existing-skill", | ||
| 205 | path: path.join(skillDir, "SKILL.md"), | ||
| 206 | enabled: true, | ||
| 207 | scope: "project", | ||
| 208 | description: "Existing project skill", | ||
| 209 | }, | ||
| 210 | ], | ||
| 211 | }); | ||
| 212 | query.mockImplementation( | ||
| 213 | () => | ||
| 214 | ({ | ||
| 215 | initializationResult: async () => ({ | ||
| 216 | commands: [{ name: "recovered", description: "", argumentHint: "" }], | ||
| 217 | }), | ||
| 218 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ | ||
| 219 | rate_limits_available: false, | ||
| 220 | rate_limits: null, | ||
| 221 | }), | ||
| 222 | }) as ReturnType<typeof ClaudeSdk.query>, | ||
| 223 | ); | ||
| 224 | const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); | ||
| 225 | assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); | ||
| 226 | assert.equal(recovered.status, "ready"); | ||
| 227 | assert.equal(recovered.slashCommandsPending, false); | ||
| 228 | assert.deepEqual(recovered.skills, failed.skills); | ||
| 229 | const disabled = yield* probeClaudeWorkspaceSnapshot( | ||
| 230 | { ...settings, enabled: false }, | ||
| 231 | machineSnapshot, | ||
| 232 | cwd, | ||
| 233 | ); | ||
| 234 | assert.equal(disabled, machineSnapshot); | ||
| 235 | assert.equal(query.mock.calls.length, 2); | ||
| 236 | }).pipe(Effect.scoped), | ||
| 237 | ); | ||
| 238 | |||
| 239 | it.effect("serializes strict no-MCP options and still resolves account capabilities", () => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const fs = yield* FileSystem.FileSystem; | ||
| 242 | const path = yield* Path.Path; | ||
| 243 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); | ||
| 244 | const executablePath = yield* path.fromFileUrl( | ||
| 245 | new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), | ||
| 246 | ); | ||
| 247 | const invocationPath = path.join(tempDir, "invocation.json"); | ||
| 248 | // The probe aborts the SDK without awaiting the child's exit, and on | ||
| 249 | // Windows a directory that is still some process's cwd cannot be | ||
| 250 | // removed. Keep the workspace outside the scoped directory and let it | ||
| 251 | // go with a retrying removal once the child has gone. | ||
| 252 | const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); | ||
| 253 | // Node's own retry rather than an Effect schedule: it.effect runs on a | ||
| 254 | // TestClock, so a scheduled retry would wait for time nobody advances. | ||
| 255 | // If the child still holds the directory after that, an empty temp | ||
| 256 | // directory is left behind rather than failing the test for it. | ||
| 257 | yield* Effect.addFinalizer(() => | ||
| 258 | Effect.promise(() => | ||
| 259 | NodeFSP.rm(workspaceCwd, { | ||
| 260 | recursive: true, | ||
| 261 | force: true, | ||
| 262 | maxRetries: 20, | ||
| 263 | retryDelay: 250, | ||
| 264 | }).catch(() => undefined), | ||
| 265 | ), | ||
| 266 | ); | ||
| 267 | |||
| 268 | const capabilities = yield* probeClaudeCapabilities( | ||
| 269 | decodeClaudeSettings({ binaryPath: executablePath }), | ||
| 270 | { | ||
| 271 | ...process.env, | ||
| 272 | T3_PROBE_INVOCATION_PATH: invocationPath, | ||
| 273 | ENABLE_CLAUDEAI_MCP_SERVERS: "true", | ||
| 274 | }, | ||
| 275 | workspaceCwd, | ||
| 276 | ); | ||
| 277 | |||
| 278 | assert.deepEqual(capabilities, { | ||
| 279 | email: "dev@example.com", | ||
| 280 | subscriptionType: "pro", | ||
| 281 | tokenSource: "oauth", | ||
| 282 | apiProvider: undefined, | ||
| 283 | slashCommands: [ | ||
| 284 | { | ||
| 285 | name: "review", | ||
| 286 | description: "Review changes", | ||
| 287 | input: { hint: "[path]" }, | ||
| 288 | }, | ||
| 289 | ], | ||
| 290 | usage: { | ||
| 291 | rate_limits_available: true, | ||
| 292 | rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, | ||
| 293 | }, | ||
| 294 | }); | ||
| 295 | |||
| 296 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 297 | const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { | ||
| 298 | readonly args: ReadonlyArray<string>; | ||
| 299 | readonly cwd: string; | ||
| 300 | readonly connectorEnv: string; | ||
| 301 | readonly mcpConfig: unknown; | ||
| 302 | }; | ||
| 303 | assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); | ||
| 304 | assert.equal(invocation.connectorEnv, "false"); | ||
| 305 | assert.equal(invocation.args.includes("--strict-mcp-config"), true); | ||
| 306 | assert.equal(invocation.args.includes("--mcp-config"), false); | ||
| 307 | assert.equal(invocation.mcpConfig, undefined); | ||
| 308 | |||
| 309 | assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); | ||
| 310 | |||
| 311 | const settingsFlagIndex = invocation.args.indexOf("--settings"); | ||
| 312 | assert.notEqual(settingsFlagIndex, -1); | ||
| 313 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 314 | const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { | ||
| 315 | readonly disableAllHooks?: boolean; | ||
| 316 | }; | ||
| 317 | assert.equal(flagSettings.disableAllHooks, true); | ||
| 318 | }).pipe(Effect.scoped), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | |||
| 322 | it.effect("preserves initialized capabilities when optional usage times out", () => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const usageStarted = yield* Deferred.make<void>(); | ||
| 325 | let abortSignal: AbortSignal | undefined; | ||
| 326 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { | ||
| 327 | abortSignal = options?.abortController?.signal; | ||
| 328 | return { | ||
| 329 | initializationResult: async () => ({ | ||
| 330 | account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 331 | commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], | ||
| 332 | }), | ||
| 333 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { | ||
| 334 | Deferred.doneUnsafe(usageStarted, Effect.void); | ||
| 335 | return new Promise(() => {}); | ||
| 336 | }, | ||
| 337 | } as ReturnType<typeof ClaudeSdk.query>; | ||
| 338 | }); | ||
| 339 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 340 | const probe = yield* probeClaudeCapabilities( | ||
| 341 | decodeClaudeSettings({ binaryPath: "claude" }), | ||
| 342 | ).pipe(Effect.forkChild); | ||
| 343 | yield* Deferred.await(usageStarted); | ||
| 344 | yield* TestClock.adjust("4 seconds"); | ||
| 345 | const capabilities = yield* Fiber.join(probe); | ||
| 346 | assert.equal(capabilities?.email, "dev@example.com"); | ||
| 347 | assert.equal(capabilities?.subscriptionType, "pro"); | ||
| 348 | assert.equal(capabilities?.tokenSource, "oauth"); | ||
| 349 | assert.deepEqual(capabilities?.slashCommands, [ | ||
| 350 | { name: "review", description: "Review changes", input: { hint: "[path]" } }, | ||
| 351 | ]); | ||
| 352 | assert.equal(capabilities?.usage, undefined); | ||
| 353 | assert.equal(abortSignal?.aborted, true); | ||
| 354 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 355 | ); | ||
apps/server/src/provider/ClaudeProvider.ts created+675| ... | @@ -0,0 +1,675 @@ | ||
| 1 | import { | ||
| 2 | type ClaudeSettings, | ||
| 3 | type ModelCapabilities, | ||
| 4 | type ServerProvider, | ||
| 5 | type ServerProviderSlashCommand, | ||
| 6 | type ServerProviderResetCredits, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Result from "effect/Result"; | ||
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 16 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 17 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 18 | import { | ||
| 19 | query as claudeQuery, | ||
| 20 | type Options as ClaudeQueryOptions, | ||
| 21 | type SlashCommand as ClaudeSlashCommand, | ||
| 22 | type SDKControlGetUsageResponse, | ||
| 23 | type SDKUserMessage, | ||
| 24 | type SettingSource, | ||
| 25 | } from "@anthropic-ai/claude-agent-sdk"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | buildServerProvider, | ||
| 29 | COMPACT_SLASH_COMMAND, | ||
| 30 | DEFAULT_TIMEOUT_MS, | ||
| 31 | isCommandMissingCause, | ||
| 32 | parseGenericCliVersion, | ||
| 33 | providerModelsFromSettings, | ||
| 34 | spawnAndCollect, | ||
| 35 | type ServerProviderDraft, | ||
| 36 | } from "./providerSnapshot.ts"; | ||
| 37 | import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts"; | ||
| 38 | import { makeClaudeEnvironment } from "./Drivers/ClaudeHome.ts"; | ||
| 39 | import { discoverClaudeSkills } from "./Drivers/ClaudeSkills.ts"; | ||
| 40 | import type { ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; | ||
| 41 | import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; | ||
| 42 | import { | ||
| 43 | type ClaudeScopedLimitNames, | ||
| 44 | claudeUsageResponseToLimits, | ||
| 45 | recordClaudeUsageResponse, | ||
| 46 | } from "./claudeUsageLimits.ts"; | ||
| 47 | import { | ||
| 48 | BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 49 | type ClaudeModelCatalog, | ||
| 50 | formatClaudeVersionUpgradeMessage, | ||
| 51 | resolveClaudeModelsForVersion, | ||
| 52 | } from "./ClaudeModelCatalog.ts"; | ||
| 53 | |||
| 54 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const CLAUDE_PRESENTATION = { | ||
| 59 | displayName: "Claude", | ||
| 60 | showInteractionModeToggle: true, | ||
| 61 | reportsContextWindow: true, | ||
| 62 | } as const; | ||
| 63 | function toTitleCaseWords(value: string): string { | ||
| 64 | const parts: Array<string> = []; | ||
| 65 | for (const part of value.split(/[\s_-]+/g)) { | ||
| 66 | if (part.length > 0) { | ||
| 67 | parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase()); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | return parts.join(" "); | ||
| 71 | } | ||
| 72 | |||
| 73 | function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined { | ||
| 74 | const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 75 | if (!normalized) return undefined; | ||
| 76 | |||
| 77 | switch (normalized) { | ||
| 78 | case "claudemaxsubscription": | ||
| 79 | return "Max"; | ||
| 80 | case "claudemax5xsubscription": | ||
| 81 | return "Max 5x"; | ||
| 82 | case "claudemax20xsubscription": | ||
| 83 | return "Max 20x"; | ||
| 84 | case "claudeenterprisesubscription": | ||
| 85 | return "Enterprise"; | ||
| 86 | case "claudeteamsubscription": | ||
| 87 | return "Team"; | ||
| 88 | case "claudeprosubscription": | ||
| 89 | return "Pro"; | ||
| 90 | case "claudefreesubscription": | ||
| 91 | return "Free"; | ||
| 92 | case "max": | ||
| 93 | case "maxplan": | ||
| 94 | return "Max"; | ||
| 95 | case "max5": | ||
| 96 | return "Max 5x"; | ||
| 97 | case "max20": | ||
| 98 | return "Max 20x"; | ||
| 99 | case "enterprise": | ||
| 100 | return "Enterprise"; | ||
| 101 | case "team": | ||
| 102 | return "Team"; | ||
| 103 | case "pro": | ||
| 104 | return "Pro"; | ||
| 105 | case "free": | ||
| 106 | return "Free"; | ||
| 107 | default: | ||
| 108 | return toTitleCaseWords(subscriptionType!); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { | ||
| 113 | const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 114 | if (!normalized) return undefined; | ||
| 115 | if ( | ||
| 116 | normalized === "apikey" || | ||
| 117 | normalized === "anthropicapikey" || | ||
| 118 | normalized === "anthropicauthtoken" | ||
| 119 | ) { | ||
| 120 | return "apiKey"; | ||
| 121 | } | ||
| 122 | return undefined; | ||
| 123 | } | ||
| 124 | |||
| 125 | function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { | ||
| 126 | const subscriptionLabel = | ||
| 127 | claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); | ||
| 128 | const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 129 | |||
| 130 | if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { | ||
| 131 | return subscriptionLabel; | ||
| 132 | } | ||
| 133 | if (normalized.startsWith("claude")) { | ||
| 134 | return `${subscriptionLabel} Subscription`; | ||
| 135 | } | ||
| 136 | if (normalized.endsWith("subscription")) { | ||
| 137 | return `Claude ${subscriptionLabel}`; | ||
| 138 | } | ||
| 139 | return `Claude ${subscriptionLabel} Subscription`; | ||
| 140 | } | ||
| 141 | |||
| 142 | function claudeAuthMetadata(input: { | ||
| 143 | readonly subscriptionType: string | undefined; | ||
| 144 | readonly authMethod: string | undefined; | ||
| 145 | }): { readonly type: string; readonly label: string } | undefined { | ||
| 146 | if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { | ||
| 147 | return { | ||
| 148 | type: "apiKey", | ||
| 149 | label: "Claude API Key", | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | if (input.subscriptionType) { | ||
| 154 | return { | ||
| 155 | type: input.subscriptionType, | ||
| 156 | label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | return undefined; | ||
| 161 | } | ||
| 162 | |||
| 163 | function apiProviderAuthMetadata( | ||
| 164 | apiProvider: string | undefined, | ||
| 165 | ): { readonly type: string; readonly label: string } | undefined { | ||
| 166 | return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; | ||
| 167 | } | ||
| 168 | |||
| 169 | // ── SDK capability probe ──────────────────────────────────────────── | ||
| 170 | |||
| 171 | // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the | ||
| 172 | // Bedrock backend and runs the `awsAuthRefresh` credential hook before returning | ||
| 173 | // account info. The previous 8s budget expired mid-init, so the probe returned | ||
| 174 | // `undefined` and left the provider unverified and unselectable in the picker. | ||
| 175 | const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; | ||
| 176 | |||
| 177 | /** | ||
| 178 | * Keep workspace-scoped command discovery intact while isolating the periodic | ||
| 179 | * health check from configured MCP servers. | ||
| 180 | */ | ||
| 181 | export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ | ||
| 182 | "user", | ||
| 183 | "project", | ||
| 184 | "local", | ||
| 185 | ] as const satisfies ReadonlyArray<SettingSource>; | ||
| 186 | |||
| 187 | /** Build the exact SDK options used by the periodic Claude capability probe. */ | ||
| 188 | export function buildClaudeCapabilitiesProbeQueryOptions(input: { | ||
| 189 | readonly executablePath: string; | ||
| 190 | readonly abortController: AbortController; | ||
| 191 | readonly environment: NodeJS.ProcessEnv; | ||
| 192 | readonly cwd: string | undefined; | ||
| 193 | }): ClaudeQueryOptions { | ||
| 194 | return { | ||
| 195 | persistSession: false, | ||
| 196 | pathToClaudeCodeExecutable: input.executablePath, | ||
| 197 | abortController: input.abortController, | ||
| 198 | settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], | ||
| 199 | // The probe keeps filesystem setting sources for slash-command discovery, | ||
| 200 | // but must not run the user's hooks: it fires every few minutes, so | ||
| 201 | // SessionStart hooks would run on every health check. | ||
| 202 | settings: { disableAllHooks: true }, | ||
| 203 | allowedTools: [], | ||
| 204 | // Ignore MCP definitions from every filesystem setting source above. The | ||
| 205 | // SDK combines this empty explicit map with --strict-mcp-config. | ||
| 206 | mcpServers: {}, | ||
| 207 | strictMcpConfig: true, | ||
| 208 | env: { | ||
| 209 | ...input.environment, | ||
| 210 | // Connected claude.ai MCP servers are discovered outside filesystem | ||
| 211 | // config; disable them independently for this health check. | ||
| 212 | ENABLE_CLAUDEAI_MCP_SERVERS: "false", | ||
| 213 | // This is a noninteractive health check, so IDE discovery cannot add any | ||
| 214 | // useful capability data. Skipping it also avoids Claude spawning a | ||
| 215 | // Windows `tasklist | findstr` process tree on every periodic refresh. | ||
| 216 | FORCE_CODE_TERMINAL: undefined, | ||
| 217 | CLAUDE_CODE_AUTO_CONNECT_IDE: "0", | ||
| 218 | CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", | ||
| 219 | }, | ||
| 220 | ...(input.cwd ? { cwd: input.cwd } : {}), | ||
| 221 | stderr: () => {}, | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | |||
| 225 | function nonEmptyProbeString(value: string): string | undefined { | ||
| 226 | const candidate = value.trim(); | ||
| 227 | return candidate ? candidate : undefined; | ||
| 228 | } | ||
| 229 | |||
| 230 | type ClaudeCapabilitiesProbe = { | ||
| 231 | readonly email: string | undefined; | ||
| 232 | readonly subscriptionType: string | undefined; | ||
| 233 | readonly tokenSource: string | undefined; | ||
| 234 | /** | ||
| 235 | * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth | ||
| 236 | * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) | ||
| 237 | * the subscription/token fields are absent and auth is external AWS creds. | ||
| 238 | */ | ||
| 239 | readonly apiProvider: string | undefined; | ||
| 240 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 241 | /** | ||
| 242 | * Subscription windows from the SDK's `get_usage` control request, or | ||
| 243 | * `undefined` when the request itself failed. Absent windows on an | ||
| 244 | * otherwise successful response mean the account has none (API key). | ||
| 245 | */ | ||
| 246 | readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | ||
| 247 | }; | ||
| 248 | |||
| 249 | function parseClaudeInitializationCommands( | ||
| 250 | commands: ReadonlyArray<ClaudeSlashCommand> | undefined, | ||
| 251 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 252 | return dedupeSlashCommands( | ||
| 253 | (commands ?? []).flatMap((command) => { | ||
| 254 | const name = nonEmptyProbeString(command.name); | ||
| 255 | if (!name) { | ||
| 256 | return []; | ||
| 257 | } | ||
| 258 | |||
| 259 | const description = nonEmptyProbeString(command.description); | ||
| 260 | const argumentHint = nonEmptyProbeString(command.argumentHint); | ||
| 261 | |||
| 262 | return [ | ||
| 263 | { | ||
| 264 | name, | ||
| 265 | ...(description ? { description } : {}), | ||
| 266 | ...(argumentHint ? { input: { hint: argumentHint } } : {}), | ||
| 267 | } satisfies ServerProviderSlashCommand, | ||
| 268 | ]; | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | } | ||
| 272 | |||
| 273 | function dedupeSlashCommands( | ||
| 274 | commands: ReadonlyArray<ServerProviderSlashCommand>, | ||
| 275 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 276 | const commandsByName = new Map<string, ServerProviderSlashCommand>(); | ||
| 277 | |||
| 278 | for (const command of commands) { | ||
| 279 | const name = nonEmptyProbeString(command.name); | ||
| 280 | if (!name) { | ||
| 281 | continue; | ||
| 282 | } | ||
| 283 | |||
| 284 | const key = name.toLowerCase(); | ||
| 285 | const existing = commandsByName.get(key); | ||
| 286 | if (!existing) { | ||
| 287 | commandsByName.set(key, { | ||
| 288 | ...command, | ||
| 289 | name, | ||
| 290 | }); | ||
| 291 | continue; | ||
| 292 | } | ||
| 293 | |||
| 294 | commandsByName.set(key, { | ||
| 295 | ...existing, | ||
| 296 | ...(existing.description | ||
| 297 | ? {} | ||
| 298 | : command.description | ||
| 299 | ? { description: command.description } | ||
| 300 | : {}), | ||
| 301 | ...(existing.input?.hint | ||
| 302 | ? {} | ||
| 303 | : command.input?.hint | ||
| 304 | ? { input: { hint: command.input.hint } } | ||
| 305 | : {}), | ||
| 306 | }); | ||
| 307 | } | ||
| 308 | |||
| 309 | return [...commandsByName.values()]; | ||
| 310 | } | ||
| 311 | |||
| 312 | function waitForAbortSignal(signal: AbortSignal): Promise<void> { | ||
| 313 | if (signal.aborted) { | ||
| 314 | return Promise.resolve(); | ||
| 315 | } | ||
| 316 | return new Promise((resolve) => { | ||
| 317 | signal.addEventListener("abort", () => resolve(), { once: true }); | ||
| 318 | }); | ||
| 319 | } | ||
| 320 | |||
| 321 | /** | ||
| 322 | * Probe account information by spawning a lightweight Claude Agent SDK | ||
| 323 | * session and reading the initialization result. | ||
| 324 | * | ||
| 325 | * We pass a never-yielding AsyncIterable as the prompt so that no user | ||
| 326 | * message is ever written to the subprocess stdin. This means the Claude | ||
| 327 | * Code subprocess completes its local initialization IPC (returning | ||
| 328 | * account info and slash commands) but never starts an API request to | ||
| 329 | * Anthropic. We read the init data and then abort the subprocess. | ||
| 330 | * | ||
| 331 | * This is used as a fallback when `claude auth status` does not include | ||
| 332 | * subscription type information. | ||
| 333 | */ | ||
| 334 | const probeClaudeCapabilities = ( | ||
| 335 | claudeSettings: ClaudeSettings, | ||
| 336 | environment?: NodeJS.ProcessEnv, | ||
| 337 | cwd?: string, | ||
| 338 | includeUsage = true, | ||
| 339 | ) => { | ||
| 340 | const abort = new AbortController(); | ||
| 341 | return Effect.gen(function* () { | ||
| 342 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 343 | const executablePath = yield* resolveClaudeSdkExecutablePath( | ||
| 344 | claudeSettings.binaryPath, | ||
| 345 | claudeEnvironment, | ||
| 346 | ); | ||
| 347 | return yield* Effect.tryPromise(async () => { | ||
| 348 | const q = claudeQuery({ | ||
| 349 | // Never yield — we only need initialization data, not a conversation. | ||
| 350 | // This prevents any prompt from reaching the Anthropic API. | ||
| 351 | // oxlint-disable-next-line require-yield | ||
| 352 | prompt: (async function* (): AsyncGenerator<SDKUserMessage> { | ||
| 353 | await waitForAbortSignal(abort.signal); | ||
| 354 | })(), | ||
| 355 | options: buildClaudeCapabilitiesProbeQueryOptions({ | ||
| 356 | executablePath, | ||
| 357 | abortController: abort, | ||
| 358 | environment: claudeEnvironment, | ||
| 359 | cwd, | ||
| 360 | }), | ||
| 361 | }); | ||
| 362 | const init = await q.initializationResult(); | ||
| 363 | return { q, init }; | ||
| 364 | }); | ||
| 365 | }).pipe( | ||
| 366 | Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), | ||
| 367 | Effect.flatMap(({ q, init }) => | ||
| 368 | Effect.gen(function* () { | ||
| 369 | // Usage has its own deadline so a slow optional request cannot discard initialization. | ||
| 370 | const usageResult = includeUsage | ||
| 371 | ? yield* Effect.tryPromise(() => | ||
| 372 | q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), | ||
| 373 | ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) | ||
| 374 | : undefined; | ||
| 375 | const usage = | ||
| 376 | usageResult && Result.isSuccess(usageResult) | ||
| 377 | ? { | ||
| 378 | rate_limits_available: usageResult.success.rate_limits_available, | ||
| 379 | rate_limits: usageResult.success.rate_limits, | ||
| 380 | } | ||
| 381 | : undefined; | ||
| 382 | const account = init.account as | ||
| 383 | | { | ||
| 384 | readonly email?: string; | ||
| 385 | readonly subscriptionType?: string; | ||
| 386 | readonly tokenSource?: string; | ||
| 387 | readonly apiProvider?: string; | ||
| 388 | } | ||
| 389 | | undefined; | ||
| 390 | return { | ||
| 391 | email: account?.email, | ||
| 392 | subscriptionType: account?.subscriptionType, | ||
| 393 | tokenSource: account?.tokenSource, | ||
| 394 | apiProvider: account?.apiProvider, | ||
| 395 | slashCommands: parseClaudeInitializationCommands(init.commands), | ||
| 396 | ...(usage ? { usage } : {}), | ||
| 397 | } satisfies ClaudeCapabilitiesProbe; | ||
| 398 | }), | ||
| 399 | ), | ||
| 400 | Effect.ensuring( | ||
| 401 | Effect.sync(() => { | ||
| 402 | if (!abort.signal.aborted) abort.abort(); | ||
| 403 | }), | ||
| 404 | ), | ||
| 405 | Effect.result, | ||
| 406 | Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), | ||
| 407 | ); | ||
| 408 | }; | ||
| 409 | |||
| 410 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( | ||
| 411 | claudeSettings: ClaudeSettings, | ||
| 412 | args: ReadonlyArray<string>, | ||
| 413 | environment?: NodeJS.ProcessEnv, | ||
| 414 | ) { | ||
| 415 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 416 | const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { | ||
| 417 | env: claudeEnvironment, | ||
| 418 | }); | ||
| 419 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 420 | env: claudeEnvironment, | ||
| 421 | shell: spawnCommand.shell, | ||
| 422 | }); | ||
| 423 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); | ||
| 424 | }); | ||
| 425 | |||
| 426 | /** Read commands from the same cwd Claude uses for a workspace session. */ | ||
| 427 | export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( | ||
| 428 | claudeSettings: ClaudeSettings, | ||
| 429 | machineSnapshot: ServerProvider, | ||
| 430 | cwd: string, | ||
| 431 | environment?: NodeJS.ProcessEnv, | ||
| 432 | ): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> { | ||
| 433 | if (!claudeSettings.enabled) return machineSnapshot; | ||
| 434 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); | ||
| 435 | const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); | ||
| 436 | return { | ||
| 437 | ...machineSnapshot, | ||
| 438 | skills, | ||
| 439 | slashCommands: dedupeSlashCommands([ | ||
| 440 | COMPACT_SLASH_COMMAND, | ||
| 441 | ...(capabilities?.slashCommands ?? []), | ||
| 442 | ]), | ||
| 443 | slashCommandsPending: !capabilities, | ||
| 444 | }; | ||
| 445 | }); | ||
| 446 | |||
| 447 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( | ||
| 448 | claudeSettings: ClaudeSettings, | ||
| 449 | resolveCapabilities?: ( | ||
| 450 | claudeSettings: ClaudeSettings, | ||
| 451 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, | ||
| 452 | environment?: NodeJS.ProcessEnv, | ||
| 453 | cwd?: string, | ||
| 454 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 455 | /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ | ||
| 456 | scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>, | ||
| 457 | /** Banked resets for a subscription login, given the CLI version for the user agent. */ | ||
| 458 | resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>, | ||
| 459 | ): Effect.fn.Return< | ||
| 460 | ServerProviderDraft, | ||
| 461 | never, | ||
| 462 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 463 | > { | ||
| 464 | const resolvedEnvironment = environment ?? process.env; | ||
| 465 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 466 | const allModels = providerModelsFromSettings( | ||
| 467 | modelCatalog.models.map((entry) => entry.model), | ||
| 468 | claudeSettings.customModels, | ||
| 469 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 470 | ); | ||
| 471 | |||
| 472 | if (!claudeSettings.enabled) { | ||
| 473 | return buildServerProvider({ | ||
| 474 | presentation: CLAUDE_PRESENTATION, | ||
| 475 | enabled: false, | ||
| 476 | checkedAt, | ||
| 477 | models: allModels, | ||
| 478 | probe: { | ||
| 479 | installed: false, | ||
| 480 | version: null, | ||
| 481 | status: "warning", | ||
| 482 | auth: { status: "unknown" }, | ||
| 483 | message: "Claude is disabled in T3 Code settings.", | ||
| 484 | }, | ||
| 485 | }); | ||
| 486 | } | ||
| 487 | |||
| 488 | const versionProbe = yield* runClaudeCommand( | ||
| 489 | claudeSettings, | ||
| 490 | ["--version"], | ||
| 491 | resolvedEnvironment, | ||
| 492 | ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); | ||
| 493 | |||
| 494 | if (Result.isFailure(versionProbe)) { | ||
| 495 | const error = versionProbe.failure; | ||
| 496 | yield* Effect.logWarning("Claude Agent CLI health check failed.", { | ||
| 497 | errorTag: error._tag, | ||
| 498 | }); | ||
| 499 | return buildServerProvider({ | ||
| 500 | presentation: CLAUDE_PRESENTATION, | ||
| 501 | enabled: claudeSettings.enabled, | ||
| 502 | checkedAt, | ||
| 503 | models: allModels, | ||
| 504 | probe: { | ||
| 505 | installed: !isCommandMissingCause(error), | ||
| 506 | version: null, | ||
| 507 | status: "error", | ||
| 508 | auth: { status: "unknown" }, | ||
| 509 | message: isCommandMissingCause(error) | ||
| 510 | ? "Claude Agent CLI (`claude`) was not found on PATH." | ||
| 511 | : "Failed to execute Claude Agent CLI health check.", | ||
| 512 | }, | ||
| 513 | }); | ||
| 514 | } | ||
| 515 | |||
| 516 | if (Option.isNone(versionProbe.success)) { | ||
| 517 | return buildServerProvider({ | ||
| 518 | presentation: CLAUDE_PRESENTATION, | ||
| 519 | enabled: claudeSettings.enabled, | ||
| 520 | checkedAt, | ||
| 521 | models: allModels, | ||
| 522 | probe: { | ||
| 523 | installed: true, | ||
| 524 | version: null, | ||
| 525 | status: "error", | ||
| 526 | auth: { status: "unknown" }, | ||
| 527 | message: | ||
| 528 | "Claude Agent CLI is installed but failed to run. Timed out while running command.", | ||
| 529 | }, | ||
| 530 | }); | ||
| 531 | } | ||
| 532 | |||
| 533 | const version = versionProbe.success.value; | ||
| 534 | const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); | ||
| 535 | if (version.code !== 0) { | ||
| 536 | yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { | ||
| 537 | exitCode: version.code, | ||
| 538 | stdoutLength: version.stdout.length, | ||
| 539 | stderrLength: version.stderr.length, | ||
| 540 | }); | ||
| 541 | return buildServerProvider({ | ||
| 542 | presentation: CLAUDE_PRESENTATION, | ||
| 543 | enabled: claudeSettings.enabled, | ||
| 544 | checkedAt, | ||
| 545 | models: allModels, | ||
| 546 | probe: { | ||
| 547 | installed: true, | ||
| 548 | version: parsedVersion, | ||
| 549 | status: "error", | ||
| 550 | auth: { status: "unknown" }, | ||
| 551 | message: "Claude Agent CLI is installed but failed to run.", | ||
| 552 | }, | ||
| 553 | }); | ||
| 554 | } | ||
| 555 | |||
| 556 | const models = providerModelsFromSettings( | ||
| 557 | resolveClaudeModelsForVersion(modelCatalog, parsedVersion), | ||
| 558 | claudeSettings.customModels, | ||
| 559 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 560 | ); | ||
| 561 | const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); | ||
| 562 | |||
| 563 | const capabilities = resolveCapabilities | ||
| 564 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) | ||
| 565 | : undefined; | ||
| 566 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); | ||
| 567 | const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; | ||
| 568 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); | ||
| 569 | |||
| 570 | if (!capabilities) { | ||
| 571 | return buildServerProvider({ | ||
| 572 | presentation: CLAUDE_PRESENTATION, | ||
| 573 | enabled: claudeSettings.enabled, | ||
| 574 | checkedAt, | ||
| 575 | models, | ||
| 576 | slashCommands: dedupedSlashCommands, | ||
| 577 | skills, | ||
| 578 | probe: { | ||
| 579 | installed: true, | ||
| 580 | version: parsedVersion, | ||
| 581 | status: "warning", | ||
| 582 | auth: { status: "unknown" }, | ||
| 583 | message: "Could not verify Claude authentication status from initialization result.", | ||
| 584 | }, | ||
| 585 | }); | ||
| 586 | } | ||
| 587 | |||
| 588 | const authMetadata = | ||
| 589 | claudeAuthMetadata({ | ||
| 590 | subscriptionType: capabilities.subscriptionType, | ||
| 591 | authMethod: capabilities.tokenSource, | ||
| 592 | }) ?? apiProviderAuthMetadata(capabilities.apiProvider); | ||
| 593 | const usageLimits = !capabilities.usage | ||
| 594 | ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) | ||
| 595 | : scopedLimitNames | ||
| 596 | ? yield* recordClaudeUsageResponse(scopedLimitNames, { | ||
| 597 | response: capabilities.usage, | ||
| 598 | checkedAt, | ||
| 599 | }) | ||
| 600 | : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; | ||
| 601 | const resetCredits = | ||
| 602 | resolveResetCredits && | ||
| 603 | capabilities.subscriptionType && | ||
| 604 | !usageLimits.unavailable && | ||
| 605 | parsedVersion | ||
| 606 | ? yield* resolveResetCredits(parsedVersion) | ||
| 607 | : undefined; | ||
| 608 | return buildServerProvider({ | ||
| 609 | presentation: CLAUDE_PRESENTATION, | ||
| 610 | enabled: claudeSettings.enabled, | ||
| 611 | checkedAt, | ||
| 612 | models, | ||
| 613 | slashCommands: dedupedSlashCommands, | ||
| 614 | skills, | ||
| 615 | probe: { | ||
| 616 | installed: true, | ||
| 617 | version: parsedVersion, | ||
| 618 | status: "ready", | ||
| 619 | auth: { | ||
| 620 | status: "authenticated", | ||
| 621 | ...(capabilities.email ? { email: capabilities.email } : {}), | ||
| 622 | ...(authMetadata ? authMetadata : {}), | ||
| 623 | }, | ||
| 624 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), | ||
| 625 | usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, | ||
| 626 | }, | ||
| 627 | }); | ||
| 628 | }); | ||
| 629 | |||
| 630 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 631 | |||
| 632 | export const makePendingClaudeProvider = ( | ||
| 633 | claudeSettings: ClaudeSettings, | ||
| 634 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 635 | ): Effect.Effect<ServerProviderDraft> => | ||
| 636 | Effect.gen(function* () { | ||
| 637 | const checkedAt = yield* nowIso; | ||
| 638 | const models = providerModelsFromSettings( | ||
| 639 | modelCatalog.models.map((entry) => entry.model), | ||
| 640 | claudeSettings.customModels, | ||
| 641 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 642 | ); | ||
| 643 | |||
| 644 | if (!claudeSettings.enabled) { | ||
| 645 | return buildServerProvider({ | ||
| 646 | presentation: CLAUDE_PRESENTATION, | ||
| 647 | enabled: false, | ||
| 648 | checkedAt, | ||
| 649 | models, | ||
| 650 | probe: { | ||
| 651 | installed: false, | ||
| 652 | version: null, | ||
| 653 | status: "warning", | ||
| 654 | auth: { status: "unknown" }, | ||
| 655 | message: "Claude is disabled in T3 Code settings.", | ||
| 656 | }, | ||
| 657 | }); | ||
| 658 | } | ||
| 659 | |||
| 660 | return buildServerProvider({ | ||
| 661 | presentation: CLAUDE_PRESENTATION, | ||
| 662 | enabled: true, | ||
| 663 | checkedAt, | ||
| 664 | models, | ||
| 665 | probe: { | ||
| 666 | installed: false, | ||
| 667 | version: null, | ||
| 668 | status: "warning", | ||
| 669 | auth: { status: "unknown" }, | ||
| 670 | message: "Claude provider status has not been checked in this session yet.", | ||
| 671 | }, | ||
| 672 | }); | ||
| 673 | }); | ||
| 674 | |||
| 675 | export { probeClaudeCapabilities }; | ||
apps/server/src/provider/CodexManagedRuntime.test.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ... | @@ -17,7 +17,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 17 | import * as CodexInstallation from "./CodexInstallation.ts"; | 17 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 18 | import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; | 18 | import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; |
| 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 20 | import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts"; | 20 | import { codexAppServerArgs } from "./codexLaunchArgs.ts"; |
| 21 | import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; | 21 | import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; |
| 22 | 22 | ||
| 23 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 23 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
apps/server/src/provider/CodexProvider.test.ts created+173| ... | @@ -0,0 +1,173 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; | ||
| 4 | |||
| 5 | it("maps current Codex model capability fields", () => { | ||
| 6 | const capabilities = mapCodexModelCapabilities({ | ||
| 7 | additionalSpeedTiers: [], | ||
| 8 | defaultReasoningEffort: "super-high", | ||
| 9 | description: "Test model", | ||
| 10 | displayName: "GPT Test", | ||
| 11 | hidden: false, | ||
| 12 | id: "gpt-test", | ||
| 13 | isDefault: true, | ||
| 14 | model: "gpt-test", | ||
| 15 | defaultServiceTier: "flex", | ||
| 16 | serviceTiers: [ | ||
| 17 | { | ||
| 18 | id: "priority", | ||
| 19 | name: "Fast", | ||
| 20 | description: "Lower latency responses.", | ||
| 21 | }, | ||
| 22 | { | ||
| 23 | id: "flex", | ||
| 24 | name: "Flex", | ||
| 25 | description: "Lower-cost asynchronous routing.", | ||
| 26 | }, | ||
| 27 | ], | ||
| 28 | supportedReasoningEfforts: [ | ||
| 29 | { | ||
| 30 | description: "Maximum reasoning", | ||
| 31 | reasoningEffort: "super-high", | ||
| 32 | }, | ||
| 33 | ], | ||
| 34 | }); | ||
| 35 | |||
| 36 | assert.deepStrictEqual(capabilities.optionDescriptors, [ | ||
| 37 | { | ||
| 38 | id: "reasoningEffort", | ||
| 39 | label: "Reasoning", | ||
| 40 | type: "select", | ||
| 41 | options: [{ id: "super-high", label: "super-high", isDefault: true }], | ||
| 42 | currentValue: "super-high", | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | id: "serviceTier", | ||
| 46 | label: "Service Tier", | ||
| 47 | type: "select", | ||
| 48 | options: [ | ||
| 49 | { id: "default", label: "Standard" }, | ||
| 50 | { | ||
| 51 | id: "priority", | ||
| 52 | label: "Fast", | ||
| 53 | description: "Lower latency responses.", | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | id: "flex", | ||
| 57 | label: "Flex", | ||
| 58 | description: "Lower-cost asynchronous routing.", | ||
| 59 | isDefault: true, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | currentValue: "flex", | ||
| 63 | }, | ||
| 64 | ]); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("uses standard routing when the catalog has no default service tier", () => { | ||
| 68 | const capabilities = mapCodexModelCapabilities({ | ||
| 69 | additionalSpeedTiers: ["fast"], | ||
| 70 | defaultReasoningEffort: "medium", | ||
| 71 | defaultServiceTier: null, | ||
| 72 | description: "Test model", | ||
| 73 | displayName: "GPT Test", | ||
| 74 | hidden: false, | ||
| 75 | id: "gpt-test", | ||
| 76 | isDefault: true, | ||
| 77 | model: "gpt-test", | ||
| 78 | serviceTiers: [ | ||
| 79 | { | ||
| 80 | id: "priority", | ||
| 81 | name: "Fast", | ||
| 82 | description: "1.5x speed, increased usage", | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | id: "ultrafast", | ||
| 86 | name: "Ultrafast", | ||
| 87 | description: "The fastest available responses for latency-sensitive work.", | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | supportedReasoningEfforts: [], | ||
| 91 | }); | ||
| 92 | |||
| 93 | assert.deepStrictEqual(capabilities.optionDescriptors, [ | ||
| 94 | { | ||
| 95 | id: "serviceTier", | ||
| 96 | label: "Service Tier", | ||
| 97 | type: "select", | ||
| 98 | options: [ | ||
| 99 | { id: "default", label: "Standard", isDefault: true }, | ||
| 100 | { | ||
| 101 | id: "priority", | ||
| 102 | label: "Fast", | ||
| 103 | description: "1.5x speed, increased usage", | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | id: "ultrafast", | ||
| 107 | label: "Ultrafast", | ||
| 108 | description: "Even faster, more expensive", | ||
| 109 | }, | ||
| 110 | ], | ||
| 111 | currentValue: "default", | ||
| 112 | }, | ||
| 113 | ]); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("marks the most preferred available model as default", () => { | ||
| 117 | const models = applyPreferredCodexDefaultModel([ | ||
| 118 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 119 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 120 | ]); | ||
| 121 | |||
| 122 | assert.deepStrictEqual( | ||
| 123 | models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), | ||
| 124 | [ | ||
| 125 | { slug: "gpt-5.6-terra", isDefault: true }, | ||
| 126 | { slug: "gpt-5.4", isDefault: undefined }, | ||
| 127 | ], | ||
| 128 | ); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("prefers sol over terra when both are available", () => { | ||
| 132 | const models = applyPreferredCodexDefaultModel([ | ||
| 133 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 134 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 135 | ]); | ||
| 136 | |||
| 137 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("ranks qualified Codex models while preserving their wire ids", () => { | ||
| 141 | const models = applyPreferredCodexDefaultModel([ | ||
| 142 | { | ||
| 143 | slug: "openai.gpt-5.6-luna", | ||
| 144 | name: "Luna", | ||
| 145 | isCustom: false, | ||
| 146 | isDefault: true, | ||
| 147 | capabilities: null, | ||
| 148 | }, | ||
| 149 | { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, | ||
| 150 | ]); | ||
| 151 | assert.deepStrictEqual( | ||
| 152 | models.filter((model) => model.isDefault).map((model) => model.slug), | ||
| 153 | ["openai.gpt-5.6-sol"], | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("keeps Codex's own default when no preferred model is available", () => { | ||
| 158 | const models = applyPreferredCodexDefaultModel([ | ||
| 159 | { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, | ||
| 160 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 161 | ]); | ||
| 162 | |||
| 163 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("ignores custom models that shadow a preferred slug", () => { | ||
| 167 | const models = applyPreferredCodexDefaultModel([ | ||
| 168 | { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, | ||
| 169 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 170 | ]); | ||
| 171 | |||
| 172 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 173 | }); | ||
apps/server/src/provider/CodexProvider.ts created+723| ... | @@ -0,0 +1,723 @@ | ||
| 1 | import * as DateTime from "effect/DateTime"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Scope from "effect/Scope"; | ||
| 9 | import * as Types from "effect/Types"; | ||
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 12 | import * as CodexClient from "effect-codex-app-server/client"; | ||
| 13 | import * as CodexSchema from "effect-codex-app-server/schema"; | ||
| 14 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 15 | |||
| 16 | import type { | ||
| 17 | CodexSettings, | ||
| 18 | CustomModelSetting, | ||
| 19 | ServerProvider, | ||
| 20 | ServerProviderState, | ||
| 21 | ModelCapabilities, | ||
| 22 | ProviderOptionDescriptor, | ||
| 23 | ServerProviderModel, | ||
| 24 | ServerProviderSkill, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; | ||
| 27 | |||
| 28 | import { | ||
| 29 | codexModelFamily, | ||
| 30 | createModelCapabilities, | ||
| 31 | formatCodexModelName, | ||
| 32 | readCustomModelEntries, | ||
| 33 | } from "@t3tools/shared/model"; | ||
| 34 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 35 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; | ||
| 36 | import { | ||
| 37 | AUTH_PROBE_TIMEOUT_MS, | ||
| 38 | buildServerProvider, | ||
| 39 | COMPACT_SLASH_COMMAND, | ||
| 40 | type ServerProviderDraft, | ||
| 41 | } from "./providerSnapshot.ts"; | ||
| 42 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 43 | import { makeUnavailableUsageLimits } from "./providerUsageLimits.ts"; | ||
| 44 | import { | ||
| 45 | codexRateLimitsFailureMessage, | ||
| 46 | codexRateLimitsToLimits, | ||
| 47 | type CodexRateLimitSnapshot, | ||
| 48 | type CodexResetCreditsSummary, | ||
| 49 | } from "./codexUsageLimits.ts"; | ||
| 50 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 51 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); | ||
| 52 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; | ||
| 53 | |||
| 54 | type CodexRateLimitsProbe = | ||
| 55 | | { | ||
| 56 | readonly snapshot: CodexRateLimitSnapshot; | ||
| 57 | readonly rateLimitsByLimitId?: | ||
| 58 | | Readonly<Record<string, CodexRateLimitSnapshot>> | ||
| 59 | | null | ||
| 60 | | undefined; | ||
| 61 | readonly resetCredits: CodexResetCreditsSummary | null | undefined; | ||
| 62 | } | ||
| 63 | | { readonly failure: string }; | ||
| 64 | |||
| 65 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | ||
| 66 | |||
| 67 | const CODEX_PRESENTATION = { | ||
| 68 | displayName: "Codex", | ||
| 69 | showInteractionModeToggle: true, | ||
| 70 | reportsContextWindow: true, | ||
| 71 | } as const; | ||
| 72 | |||
| 73 | export interface CodexAppServerProviderSnapshot { | ||
| 74 | readonly account: CodexSchema.V2GetAccountResponse; | ||
| 75 | readonly rateLimits?: CodexRateLimitsProbe; | ||
| 76 | readonly version: string | undefined; | ||
| 77 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 78 | readonly skills: ReadonlyArray<ServerProviderSkill>; | ||
| 79 | } | ||
| 80 | |||
| 81 | const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = { | ||
| 82 | none: "None", | ||
| 83 | minimal: "Minimal", | ||
| 84 | low: "Low", | ||
| 85 | medium: "Medium", | ||
| 86 | high: "High", | ||
| 87 | xhigh: "Extra High", | ||
| 88 | max: "Max", | ||
| 89 | ultra: "Ultra", | ||
| 90 | }; | ||
| 91 | |||
| 92 | const DEFAULT_SERVICE_TIER_ID = "default"; | ||
| 93 | |||
| 94 | /** Shorter copy for tiers whose catalog description wraps in the traits menu. */ | ||
| 95 | const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = { | ||
| 96 | ultrafast: "Even faster, more expensive", | ||
| 97 | }; | ||
| 98 | |||
| 99 | function reasoningEffortLabel(reasoningEffort: string): string { | ||
| 100 | return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; | ||
| 101 | } | ||
| 102 | |||
| 103 | function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { | ||
| 104 | if (!account) return undefined; | ||
| 105 | if (account.type === "apiKey") return "OpenAI API Key"; | ||
| 106 | if (account.type === "amazonBedrock") return "Amazon Bedrock"; | ||
| 107 | if (account.type !== "chatgpt") return undefined; | ||
| 108 | return codexPlanLabel(account.planType); | ||
| 109 | } | ||
| 110 | |||
| 111 | /** Shared with usage-limit sources, which report the same `planType` slugs. */ | ||
| 112 | export function codexPlanLabel(planType: string | null | undefined): string | undefined { | ||
| 113 | switch (planType) { | ||
| 114 | case "free": | ||
| 115 | return "ChatGPT Free Subscription"; | ||
| 116 | case "go": | ||
| 117 | return "ChatGPT Go Subscription"; | ||
| 118 | case "plus": | ||
| 119 | return "ChatGPT Plus Subscription"; | ||
| 120 | case "pro": | ||
| 121 | return "ChatGPT Pro 20x Subscription"; | ||
| 122 | case "prolite": | ||
| 123 | return "ChatGPT Pro 5x Subscription"; | ||
| 124 | case "promax": | ||
| 125 | return "ChatGPT Pro Max Subscription"; | ||
| 126 | case "team": | ||
| 127 | return "ChatGPT Team Subscription"; | ||
| 128 | case "self_serve_business_prolite": | ||
| 129 | case "self_serve_business_usage_based": | ||
| 130 | case "business": | ||
| 131 | return "ChatGPT Business Subscription"; | ||
| 132 | case "ent26": | ||
| 133 | case "enterprise_cbp_automation": | ||
| 134 | case "enterprise_cbp_usage_based": | ||
| 135 | case "enterprise": | ||
| 136 | return "ChatGPT Enterprise Subscription"; | ||
| 137 | case "edu": | ||
| 138 | case "edu_plus": | ||
| 139 | case "edu_pro": | ||
| 140 | return "ChatGPT Edu Subscription"; | ||
| 141 | case "unknown": | ||
| 142 | return "ChatGPT Subscription"; | ||
| 143 | default: | ||
| 144 | return undefined; | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { | ||
| 149 | if (!account || account.type !== "chatgpt") return undefined; | ||
| 150 | return account.email; | ||
| 151 | } | ||
| 152 | |||
| 153 | export function mapCodexModelCapabilities( | ||
| 154 | model: CodexSchema.V2ModelListResponse__Model, | ||
| 155 | ): ModelCapabilities { | ||
| 156 | const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => | ||
| 157 | reasoningEffort === | ||
| 158 | (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) | ||
| 159 | ? { | ||
| 160 | id: reasoningEffort, | ||
| 161 | label: reasoningEffortLabel(reasoningEffort), | ||
| 162 | isDefault: true, | ||
| 163 | } | ||
| 164 | : { | ||
| 165 | id: reasoningEffort, | ||
| 166 | label: reasoningEffortLabel(reasoningEffort), | ||
| 167 | }, | ||
| 168 | ); | ||
| 169 | const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; | ||
| 170 | const serviceTiers = | ||
| 171 | model.serviceTiers && model.serviceTiers.length > 0 | ||
| 172 | ? model.serviceTiers | ||
| 173 | : (model.additionalSpeedTiers ?? []).map((id) => ({ | ||
| 174 | id, | ||
| 175 | name: id === "fast" ? "Fast" : id, | ||
| 176 | description: "", | ||
| 177 | })); | ||
| 178 | const catalogDefaultServiceTier = serviceTiers.some( | ||
| 179 | (tier) => tier.id === model.defaultServiceTier, | ||
| 180 | ) | ||
| 181 | ? model.defaultServiceTier | ||
| 182 | : null; | ||
| 183 | const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; | ||
| 184 | const optionDescriptors: ProviderOptionDescriptor[] = []; | ||
| 185 | |||
| 186 | if (reasoningOptions.length > 0) { | ||
| 187 | optionDescriptors.push({ | ||
| 188 | id: "reasoningEffort", | ||
| 189 | label: "Reasoning", | ||
| 190 | type: "select", | ||
| 191 | options: reasoningOptions, | ||
| 192 | ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), | ||
| 193 | }); | ||
| 194 | } | ||
| 195 | if (serviceTiers.length > 0) { | ||
| 196 | optionDescriptors.push({ | ||
| 197 | id: "serviceTier", | ||
| 198 | label: "Service Tier", | ||
| 199 | type: "select", | ||
| 200 | options: [ | ||
| 201 | { | ||
| 202 | id: DEFAULT_SERVICE_TIER_ID, | ||
| 203 | label: "Standard", | ||
| 204 | ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), | ||
| 205 | }, | ||
| 206 | ...serviceTiers.map((tier) => { | ||
| 207 | const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; | ||
| 208 | return { | ||
| 209 | id: tier.id, | ||
| 210 | label: tier.name, | ||
| 211 | ...(description ? { description } : {}), | ||
| 212 | ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), | ||
| 213 | }; | ||
| 214 | }), | ||
| 215 | ], | ||
| 216 | currentValue: defaultServiceTier, | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | return createModelCapabilities({ | ||
| 221 | optionDescriptors, | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | function parseCodexModelListResponse( | ||
| 226 | response: CodexSchema.V2ModelListResponse, | ||
| 227 | ): ReadonlyArray<ServerProviderModel> { | ||
| 228 | return response.data.map((model) => ({ | ||
| 229 | slug: model.model, | ||
| 230 | name: formatCodexModelName(model.displayName), | ||
| 231 | isCustom: false, | ||
| 232 | ...(model.isDefault ? { isDefault: true } : {}), | ||
| 233 | capabilities: mapCodexModelCapabilities(model), | ||
| 234 | })); | ||
| 235 | } | ||
| 236 | |||
| 237 | /** | ||
| 238 | * Prefer our own default-model ranking when one of the preferred slugs is in | ||
| 239 | * the live catalog; otherwise keep whatever Codex itself flagged as default. | ||
| 240 | */ | ||
| 241 | export function applyPreferredCodexDefaultModel( | ||
| 242 | models: ReadonlyArray<ServerProviderModel>, | ||
| 243 | ): ReadonlyArray<ServerProviderModel> { | ||
| 244 | const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => | ||
| 245 | models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), | ||
| 246 | )[0]?.slug; | ||
| 247 | if (!preferredSlug) { | ||
| 248 | return models; | ||
| 249 | } | ||
| 250 | return models.map((model) => { | ||
| 251 | if (model.slug === preferredSlug) { | ||
| 252 | return model.isDefault ? model : { ...model, isDefault: true }; | ||
| 253 | } | ||
| 254 | if (!model.isDefault) { | ||
| 255 | return model; | ||
| 256 | } | ||
| 257 | const { isDefault: _isDefault, ...rest } = model; | ||
| 258 | return rest; | ||
| 259 | }); | ||
| 260 | } | ||
| 261 | |||
| 262 | /** | ||
| 263 | * Codex has no static default capability set, so a bare custom slug borrows | ||
| 264 | * the first built-in's descriptors; an entry with its own capabilities keeps | ||
| 265 | * them. | ||
| 266 | */ | ||
| 267 | function appendCustomCodexModels( | ||
| 268 | models: ReadonlyArray<ServerProviderModel>, | ||
| 269 | customModels: ReadonlyArray<CustomModelSetting>, | ||
| 270 | ): ReadonlyArray<ServerProviderModel> { | ||
| 271 | if (customModels.length === 0) { | ||
| 272 | return models; | ||
| 273 | } | ||
| 274 | |||
| 275 | const seen = new Set(models.map((model) => model.slug)); | ||
| 276 | const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; | ||
| 277 | const customEntries: ServerProviderModel[] = []; | ||
| 278 | for (const entry of readCustomModelEntries(customModels)) { | ||
| 279 | if (seen.has(entry.slug)) { | ||
| 280 | continue; | ||
| 281 | } | ||
| 282 | seen.add(entry.slug); | ||
| 283 | customEntries.push({ | ||
| 284 | slug: entry.slug, | ||
| 285 | name: entry.name, | ||
| 286 | isCustom: true, | ||
| 287 | capabilities: entry.capabilities ?? fallbackCapabilities, | ||
| 288 | }); | ||
| 289 | } | ||
| 290 | return customEntries.length === 0 ? models : [...models, ...customEntries]; | ||
| 291 | } | ||
| 292 | |||
| 293 | function parseCodexSkillsListResponse( | ||
| 294 | response: CodexSchema.V2SkillsListResponse, | ||
| 295 | cwd: string, | ||
| 296 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 297 | const matchingEntry = response.data.find((entry) => entry.cwd === cwd); | ||
| 298 | const skills = matchingEntry | ||
| 299 | ? matchingEntry.skills | ||
| 300 | : response.data.flatMap((entry) => entry.skills); | ||
| 301 | |||
| 302 | return skills.map((skill) => { | ||
| 303 | const shortDescription = | ||
| 304 | skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; | ||
| 305 | |||
| 306 | const parsedSkill: Types.Mutable<ServerProviderSkill> = { | ||
| 307 | name: skill.name, | ||
| 308 | path: skill.path, | ||
| 309 | enabled: skill.enabled, | ||
| 310 | }; | ||
| 311 | |||
| 312 | if (skill.description) { | ||
| 313 | parsedSkill.description = skill.description; | ||
| 314 | } | ||
| 315 | if (skill.scope) { | ||
| 316 | parsedSkill.scope = skill.scope; | ||
| 317 | } | ||
| 318 | if (skill.interface?.displayName) { | ||
| 319 | parsedSkill.displayName = skill.interface.displayName; | ||
| 320 | } | ||
| 321 | if (shortDescription) { | ||
| 322 | parsedSkill.shortDescription = shortDescription; | ||
| 323 | } | ||
| 324 | |||
| 325 | return parsedSkill; | ||
| 326 | }); | ||
| 327 | } | ||
| 328 | |||
| 329 | const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( | ||
| 330 | client: CodexClient.CodexAppServerClient["Service"], | ||
| 331 | ) { | ||
| 332 | const models: ServerProviderModel[] = []; | ||
| 333 | let cursor: string | null | undefined = undefined; | ||
| 334 | |||
| 335 | do { | ||
| 336 | const response: CodexSchema.V2ModelListResponse = yield* client.request( | ||
| 337 | "model/list", | ||
| 338 | cursor ? { cursor } : {}, | ||
| 339 | ); | ||
| 340 | models.push(...parseCodexModelListResponse(response)); | ||
| 341 | cursor = response.nextCursor; | ||
| 342 | } while (cursor); | ||
| 343 | |||
| 344 | return models; | ||
| 345 | }); | ||
| 346 | |||
| 347 | export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { | ||
| 348 | return { | ||
| 349 | clientInfo: { | ||
| 350 | name: "T3 Code", | ||
| 351 | title: "T3 Code", | ||
| 352 | version: packageJson.version, | ||
| 353 | }, | ||
| 354 | capabilities: { | ||
| 355 | experimentalApi: true, | ||
| 356 | }, | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | /** | ||
| 361 | * Spawns a short-lived `codex app-server`, runs the initialize handshake, and | ||
| 362 | * hands the caller a connected client. Scoped: the process is killed when | ||
| 363 | * the caller's scope closes. Shared by the status probe, the skills probe, | ||
| 364 | * and account-level requests such as reset-credit redemption. | ||
| 365 | */ | ||
| 366 | export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { | ||
| 367 | readonly binaryPath: string; | ||
| 368 | readonly homePath?: string | undefined; | ||
| 369 | readonly launchArgs?: string | undefined; | ||
| 370 | readonly cwd: string; | ||
| 371 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 372 | }) { | ||
| 373 | // `~` is not shell-expanded when env vars are set via `child_process.spawn`, | ||
| 374 | // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip | ||
| 375 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". | ||
| 376 | // Expand here for parity with `CodexTextGeneration`. | ||
| 377 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; | ||
| 378 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 379 | const environment = { | ||
| 380 | ...input.environment, | ||
| 381 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | ||
| 382 | }; | ||
| 383 | const spawnCommand = yield* resolveSpawnCommand( | ||
| 384 | input.binaryPath, | ||
| 385 | codexAppServerArgs(input.launchArgs), | ||
| 386 | { env: environment, extendEnv: true }, | ||
| 387 | ); | ||
| 388 | const child = yield* spawner | ||
| 389 | .spawn( | ||
| 390 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 391 | cwd: input.cwd, | ||
| 392 | env: environment, | ||
| 393 | extendEnv: true, | ||
| 394 | forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, | ||
| 395 | shell: spawnCommand.shell, | ||
| 396 | }), | ||
| 397 | ) | ||
| 398 | .pipe( | ||
| 399 | Effect.mapError( | ||
| 400 | (cause) => | ||
| 401 | new CodexErrors.CodexAppServerSpawnError({ | ||
| 402 | command: `${input.binaryPath} app-server`, | ||
| 403 | cause, | ||
| 404 | }), | ||
| 405 | ), | ||
| 406 | ); | ||
| 407 | const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); | ||
| 408 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( | ||
| 409 | Effect.provide(clientContext), | ||
| 410 | ); | ||
| 411 | const initialize = yield* client.request("initialize", buildCodexInitializeParams()); | ||
| 412 | yield* client.notify("initialized", undefined); | ||
| 413 | return { client, initialize }; | ||
| 414 | }); | ||
| 415 | |||
| 416 | const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { | ||
| 417 | readonly binaryPath: string; | ||
| 418 | readonly homePath?: string; | ||
| 419 | readonly launchArgs?: string; | ||
| 420 | readonly cwd: string; | ||
| 421 | readonly customModels?: ReadonlyArray<CustomModelSetting>; | ||
| 422 | readonly environment?: NodeJS.ProcessEnv; | ||
| 423 | readonly skipNativeUsage?: boolean; | ||
| 424 | }) { | ||
| 425 | const { client, initialize } = yield* withCodexAppServerClient(input); | ||
| 426 | |||
| 427 | // Extract the version string after the first '/' in userAgent, up to the next space or the end | ||
| 428 | const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); | ||
| 429 | const version = versionMatch ? versionMatch[1] : undefined; | ||
| 430 | |||
| 431 | const accountResponse = yield* client.request("account/read", {}); | ||
| 432 | if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { | ||
| 433 | return { | ||
| 434 | account: accountResponse, | ||
| 435 | version, | ||
| 436 | models: appendCustomCodexModels([], input.customModels ?? []), | ||
| 437 | skills: [], | ||
| 438 | } satisfies CodexAppServerProviderSnapshot; | ||
| 439 | } | ||
| 440 | |||
| 441 | const [skillsResponse, models, rateLimits] = yield* Effect.all( | ||
| 442 | [ | ||
| 443 | client.request("skills/list", { | ||
| 444 | cwds: [input.cwd], | ||
| 445 | }), | ||
| 446 | requestAllCodexModels(client), | ||
| 447 | // Usage is an enrichment: a failure or a slow answer degrades to "no | ||
| 448 | // usage this probe" rather than costing the account and models. | ||
| 449 | input.skipNativeUsage | ||
| 450 | ? Effect.succeed(undefined) | ||
| 451 | : client.request("account/rateLimits/read", null).pipe( | ||
| 452 | Effect.map((response): CodexRateLimitsProbe => ({ | ||
| 453 | snapshot: response.rateLimits, | ||
| 454 | rateLimitsByLimitId: response.rateLimitsByLimitId, | ||
| 455 | resetCredits: response.rateLimitResetCredits, | ||
| 456 | })), | ||
| 457 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), | ||
| 458 | Effect.map( | ||
| 459 | Option.getOrElse((): CodexRateLimitsProbe => ({ | ||
| 460 | failure: "Codex did not answer the usage request.", | ||
| 461 | })), | ||
| 462 | ), | ||
| 463 | Effect.catch((error) => | ||
| 464 | Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( | ||
| 465 | Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }), | ||
| 466 | ), | ||
| 467 | ), | ||
| 468 | ), | ||
| 469 | ], | ||
| 470 | { concurrency: "unbounded" }, | ||
| 471 | ); | ||
| 472 | |||
| 473 | return { | ||
| 474 | account: accountResponse, | ||
| 475 | ...(rateLimits ? { rateLimits } : {}), | ||
| 476 | version, | ||
| 477 | models: applyPreferredCodexDefaultModel( | ||
| 478 | appendCustomCodexModels(models, input.customModels ?? []), | ||
| 479 | ), | ||
| 480 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), | ||
| 481 | } satisfies CodexAppServerProviderSnapshot; | ||
| 482 | }); | ||
| 483 | |||
| 484 | export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { | ||
| 485 | readonly binaryPath: string; | ||
| 486 | readonly homePath?: string; | ||
| 487 | readonly launchArgs?: string; | ||
| 488 | readonly cwd: string; | ||
| 489 | readonly environment?: NodeJS.ProcessEnv; | ||
| 490 | }) { | ||
| 491 | const { client } = yield* withCodexAppServerClient(input); | ||
| 492 | const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); | ||
| 493 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); | ||
| 494 | }); | ||
| 495 | |||
| 496 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => | ||
| 497 | appendCustomCodexModels([], codexSettings.customModels); | ||
| 498 | |||
| 499 | const makePendingCodexProvider = ( | ||
| 500 | codexSettings: CodexSettings, | ||
| 501 | ): Effect.Effect<ServerProviderDraft> => | ||
| 502 | Effect.gen(function* () { | ||
| 503 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 504 | const models = emptyCodexModelsFromSettings(codexSettings); | ||
| 505 | |||
| 506 | if (!codexSettings.enabled) { | ||
| 507 | return buildServerProvider({ | ||
| 508 | presentation: CODEX_PRESENTATION, | ||
| 509 | enabled: false, | ||
| 510 | checkedAt, | ||
| 511 | models, | ||
| 512 | skills: [], | ||
| 513 | probe: { | ||
| 514 | installed: false, | ||
| 515 | version: null, | ||
| 516 | status: "warning", | ||
| 517 | auth: { status: "unknown" }, | ||
| 518 | message: "Codex is disabled in T3 Code settings.", | ||
| 519 | }, | ||
| 520 | }); | ||
| 521 | } | ||
| 522 | |||
| 523 | return buildServerProvider({ | ||
| 524 | presentation: CODEX_PRESENTATION, | ||
| 525 | enabled: true, | ||
| 526 | checkedAt, | ||
| 527 | models, | ||
| 528 | skills: [], | ||
| 529 | probe: { | ||
| 530 | installed: false, | ||
| 531 | version: null, | ||
| 532 | status: "warning", | ||
| 533 | auth: { status: "unknown" }, | ||
| 534 | message: "Codex provider status has not been checked in this session yet.", | ||
| 535 | }, | ||
| 536 | }); | ||
| 537 | }); | ||
| 538 | |||
| 539 | function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { | ||
| 540 | readonly status: Exclude<ServerProviderState, "disabled">; | ||
| 541 | readonly auth: ServerProvider["auth"]; | ||
| 542 | readonly message?: string; | ||
| 543 | } { | ||
| 544 | const authLabel = codexAccountAuthLabel(account.account); | ||
| 545 | const authEmail = codexAccountEmail(account.account); | ||
| 546 | const auth = { | ||
| 547 | status: account.account ? ("authenticated" as const) : ("unknown" as const), | ||
| 548 | ...(account.account?.type ? { type: account.account?.type } : {}), | ||
| 549 | ...(authLabel ? { label: authLabel } : {}), | ||
| 550 | ...(authEmail ? { email: authEmail } : {}), | ||
| 551 | } satisfies ServerProvider["auth"]; | ||
| 552 | |||
| 553 | if (account.account) { | ||
| 554 | return { status: "ready", auth }; | ||
| 555 | } | ||
| 556 | |||
| 557 | if (account.requiresOpenaiAuth) { | ||
| 558 | return { | ||
| 559 | status: "error", | ||
| 560 | auth: { status: "unauthenticated" }, | ||
| 561 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | ||
| 562 | }; | ||
| 563 | } | ||
| 564 | |||
| 565 | return { status: "ready", auth }; | ||
| 566 | } | ||
| 567 | |||
| 568 | export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( | ||
| 569 | codexSettings: CodexSettings, | ||
| 570 | probe: (input: { | ||
| 571 | readonly binaryPath: string; | ||
| 572 | readonly homePath?: string; | ||
| 573 | readonly launchArgs?: string; | ||
| 574 | readonly cwd: string; | ||
| 575 | readonly customModels: ReadonlyArray<CustomModelSetting>; | ||
| 576 | readonly environment?: NodeJS.ProcessEnv; | ||
| 577 | readonly skipNativeUsage?: boolean; | ||
| 578 | }) => Effect.Effect< | ||
| 579 | CodexAppServerProviderSnapshot, | ||
| 580 | CodexErrors.CodexAppServerError, | ||
| 581 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | ||
| 582 | > = probeCodexAppServerProvider, | ||
| 583 | environment?: NodeJS.ProcessEnv, | ||
| 584 | managedAuth?: ServerProvider["auth"], | ||
| 585 | ): Effect.fn.Return< | ||
| 586 | ServerProviderDraft, | ||
| 587 | ServerSettingsError, | ||
| 588 | ChildProcessSpawner.ChildProcessSpawner | ||
| 589 | > { | ||
| 590 | const resolvedEnvironment = environment ?? process.env; | ||
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 592 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); | ||
| 593 | |||
| 594 | if (!codexSettings.enabled) { | ||
| 595 | return buildServerProvider({ | ||
| 596 | presentation: CODEX_PRESENTATION, | ||
| 597 | enabled: false, | ||
| 598 | checkedAt, | ||
| 599 | models: emptyModels, | ||
| 600 | skills: [], | ||
| 601 | probe: { | ||
| 602 | installed: false, | ||
| 603 | version: null, | ||
| 604 | status: "warning", | ||
| 605 | auth: { status: "unknown" }, | ||
| 606 | message: "Codex is disabled in T3 Code settings.", | ||
| 607 | }, | ||
| 608 | }); | ||
| 609 | } | ||
| 610 | |||
| 611 | const probeResult = yield* probe({ | ||
| 612 | binaryPath: codexSettings.binaryPath, | ||
| 613 | homePath: codexSettings.homePath, | ||
| 614 | launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), | ||
| 615 | cwd: process.cwd(), | ||
| 616 | customModels: codexSettings.customModels, | ||
| 617 | environment: resolvedEnvironment, | ||
| 618 | ...(managedAuth ? { skipNativeUsage: true } : {}), | ||
| 619 | }).pipe( | ||
| 620 | Effect.scoped, | ||
| 621 | Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), | ||
| 622 | Effect.result, | ||
| 623 | ); | ||
| 624 | |||
| 625 | if (Result.isFailure(probeResult)) { | ||
| 626 | const error = probeResult.failure; | ||
| 627 | const installed = !isCodexAppServerSpawnError(error); | ||
| 628 | return buildServerProvider({ | ||
| 629 | presentation: CODEX_PRESENTATION, | ||
| 630 | enabled: codexSettings.enabled, | ||
| 631 | checkedAt, | ||
| 632 | models: emptyModels, | ||
| 633 | skills: [], | ||
| 634 | probe: { | ||
| 635 | installed, | ||
| 636 | version: null, | ||
| 637 | status: "error", | ||
| 638 | auth: { status: "unknown" }, | ||
| 639 | message: installed | ||
| 640 | ? `Codex app-server provider probe failed: ${error.message}.` | ||
| 641 | : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` + | ||
| 642 | (codexSettings.binaryPath === "codex" | ||
| 643 | ? " Installing ChatGPT or Codex desktop may not add codex to PATH." | ||
| 644 | : " Make sure the configured executable exists and can be run."), | ||
| 645 | }, | ||
| 646 | }); | ||
| 647 | } | ||
| 648 | |||
| 649 | if (Option.isNone(probeResult.success)) { | ||
| 650 | return buildServerProvider({ | ||
| 651 | presentation: CODEX_PRESENTATION, | ||
| 652 | enabled: codexSettings.enabled, | ||
| 653 | checkedAt, | ||
| 654 | models: emptyModels, | ||
| 655 | skills: [], | ||
| 656 | probe: { | ||
| 657 | installed: true, | ||
| 658 | version: null, | ||
| 659 | status: "error", | ||
| 660 | auth: { status: "unknown" }, | ||
| 661 | message: "Timed out while checking Codex app-server provider status.", | ||
| 662 | }, | ||
| 663 | }); | ||
| 664 | } | ||
| 665 | |||
| 666 | const snapshot = probeResult.success.value; | ||
| 667 | const accountStatus = managedAuth | ||
| 668 | ? { status: "ready" as const, auth: managedAuth, message: undefined } | ||
| 669 | : accountProbeStatus(snapshot.account); | ||
| 670 | const usageLimits = | ||
| 671 | snapshot.account.account?.type === "apiKey" | ||
| 672 | ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) | ||
| 673 | : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits | ||
| 674 | ? makeUnavailableUsageLimits({ | ||
| 675 | checkedAt, | ||
| 676 | reason: "probeFailed", | ||
| 677 | ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), | ||
| 678 | }) | ||
| 679 | : codexRateLimitsToLimits({ | ||
| 680 | snapshot: snapshot.rateLimits.snapshot, | ||
| 681 | rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId, | ||
| 682 | resetCredits: snapshot.rateLimits.resetCredits, | ||
| 683 | checkedAt, | ||
| 684 | }); | ||
| 685 | |||
| 686 | return buildServerProvider({ | ||
| 687 | presentation: CODEX_PRESENTATION, | ||
| 688 | enabled: codexSettings.enabled, | ||
| 689 | checkedAt, | ||
| 690 | models: snapshot.models, | ||
| 691 | skills: snapshot.skills, | ||
| 692 | slashCommands: [ | ||
| 693 | COMPACT_SLASH_COMMAND, | ||
| 694 | { | ||
| 695 | name: "goal", | ||
| 696 | description: "Set a goal Codex keeps working toward until it is done", | ||
| 697 | input: { hint: "Objective, or pause, resume, clear" }, | ||
| 698 | }, | ||
| 699 | { | ||
| 700 | name: "feedback", | ||
| 701 | description: "Send this thread and Codex logs to OpenAI", | ||
| 702 | input: { hint: "Describe the issue (optional)" }, | ||
| 703 | }, | ||
| 704 | ], | ||
| 705 | probe: { | ||
| 706 | installed: true, | ||
| 707 | version: snapshot.version ?? null, | ||
| 708 | status: accountStatus.status, | ||
| 709 | auth: accountStatus.auth, | ||
| 710 | ...(accountStatus.message ? { message: accountStatus.message } : {}), | ||
| 711 | ...(managedAuth ? {} : { usageLimits }), | ||
| 712 | }, | ||
| 713 | }); | ||
| 714 | }); | ||
| 715 | |||
| 716 | // NOTE: the singleton `CodexProviderLive` Layer has been removed as part of | ||
| 717 | // the per-instance-driver refactor. `CodexDriver.create()` builds a managed | ||
| 718 | // snapshot per instance (each with its own `CodexSettings`) and hands the | ||
| 719 | // resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. | ||
| 720 | // | ||
| 721 | // The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are | ||
| 722 | // re-exported for use by `CodexDriver`. | ||
| 723 | export { makePendingCodexProvider }; | ||
apps/server/src/provider/CursorAuth.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as Stream from "effect/Stream"; | ... | @@ -17,7 +17,7 @@ import * as Stream from "effect/Stream"; |
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | 18 | ||
| 19 | import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; | 19 | import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; |
| 20 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; | 20 | import type { ProviderAuthController } from "./ProviderAuthService.ts"; |
| 21 | 21 | ||
| 22 | const AUTH_TIMEOUT_MS = 300_000; | 22 | const AUTH_TIMEOUT_MS = 300_000; |
| 23 | 23 |
apps/server/src/provider/CursorProvider.test.ts created+252| ... | @@ -0,0 +1,252 @@ | ||
| 1 | import type { SDKModel } from "@cursor/sdk"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import type { CursorSettings } from "@t3tools/contracts"; | ||
| 6 | import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; | ||
| 7 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | buildCursorCapabilitiesFromSdkModel, | ||
| 11 | buildCursorDiscoveredModelsFromSdk, | ||
| 12 | buildCursorProviderSnapshot, | ||
| 13 | buildInitialCursorProviderSnapshot, | ||
| 14 | checkCursorProviderStatus, | ||
| 15 | } from "./CursorProvider.ts"; | ||
| 16 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 17 | |||
| 18 | const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); | ||
| 19 | |||
| 20 | function selectDescriptor( | ||
| 21 | id: string, | ||
| 22 | label: string, | ||
| 23 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 24 | ) { | ||
| 25 | return { | ||
| 26 | id, | ||
| 27 | label, | ||
| 28 | type: "select" as const, | ||
| 29 | options: [...options], | ||
| 30 | ...(options.find((option) => option.isDefault)?.id | ||
| 31 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 32 | : {}), | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | ||
| 37 | return { | ||
| 38 | id, | ||
| 39 | label, | ||
| 40 | type: "boolean" as const, | ||
| 41 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const baseCursorSettings: CursorSettings = decodeCursorSettings({ | ||
| 46 | enabled: true, | ||
| 47 | customModels: [], | ||
| 48 | }); | ||
| 49 | |||
| 50 | const sdkParameterizedModel = { | ||
| 51 | id: "claude-opus-4-8", | ||
| 52 | displayName: "Opus 4.8", | ||
| 53 | parameters: [ | ||
| 54 | { | ||
| 55 | id: "thinking", | ||
| 56 | displayName: "Thinking", | ||
| 57 | values: [{ value: "false" }, { value: "true" }], | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | id: "context", | ||
| 61 | displayName: "Context", | ||
| 62 | values: [ | ||
| 63 | { value: "300k", displayName: "300K" }, | ||
| 64 | { value: "1m", displayName: "1M" }, | ||
| 65 | ], | ||
| 66 | }, | ||
| 67 | { | ||
| 68 | id: "effort", | ||
| 69 | displayName: "Effort", | ||
| 70 | values: [ | ||
| 71 | { value: "low", displayName: "Low" }, | ||
| 72 | { value: "high", displayName: "High" }, | ||
| 73 | ], | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | id: "fast", | ||
| 77 | displayName: "Fast", | ||
| 78 | values: [{ value: "false" }, { value: "true", displayName: "Fast" }], | ||
| 79 | }, | ||
| 80 | ], | ||
| 81 | variants: [ | ||
| 82 | { | ||
| 83 | displayName: "Opus 4.8", | ||
| 84 | isDefault: true, | ||
| 85 | params: [ | ||
| 86 | { id: "thinking", value: "true" }, | ||
| 87 | { id: "context", value: "1m" }, | ||
| 88 | { id: "effort", value: "high" }, | ||
| 89 | { id: "fast", value: "false" }, | ||
| 90 | ], | ||
| 91 | }, | ||
| 92 | ], | ||
| 93 | } satisfies SDKModel; | ||
| 94 | |||
| 95 | describe("buildInitialCursorProviderSnapshot", () => { | ||
| 96 | it.effect("uses SDK-specific pending status copy", () => | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); | ||
| 99 | |||
| 100 | expect(provider).toMatchObject({ | ||
| 101 | status: "warning", | ||
| 102 | message: "Checking Cursor SDK availability...", | ||
| 103 | }); | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | }); | ||
| 107 | |||
| 108 | describe("buildCursorProviderSnapshot", () => { | ||
| 109 | it("downgrades ready status to warning when SDK model discovery returns no models", () => { | ||
| 110 | expect( | ||
| 111 | buildCursorProviderSnapshot({ | ||
| 112 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 113 | cursorSettings: baseCursorSettings, | ||
| 114 | parsed: { | ||
| 115 | version: null, | ||
| 116 | status: "ready", | ||
| 117 | auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, | ||
| 118 | }, | ||
| 119 | discoveryWarning: "Cursor SDK model discovery returned no built-in models.", | ||
| 120 | }), | ||
| 121 | ).toMatchObject({ | ||
| 122 | status: "warning", | ||
| 123 | message: "Cursor SDK model discovery returned no built-in models.", | ||
| 124 | models: [], | ||
| 125 | supportsConversationRollback: false, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | }); | ||
| 129 | |||
| 130 | describe("Cursor SDK model discovery", () => { | ||
| 131 | it("maps native SDK parameter ids and default variant values to model capabilities", () => { | ||
| 132 | expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( | ||
| 133 | createModelCapabilities({ | ||
| 134 | optionDescriptors: [ | ||
| 135 | selectDescriptor("effort", "Effort", [ | ||
| 136 | { id: "low", label: "Low" }, | ||
| 137 | { id: "high", label: "High", isDefault: true }, | ||
| 138 | ]), | ||
| 139 | selectDescriptor("contextWindow", "Context", [ | ||
| 140 | { id: "300k", label: "300K" }, | ||
| 141 | { id: "1m", label: "1M", isDefault: true }, | ||
| 142 | ]), | ||
| 143 | booleanDescriptor("fastMode", "Fast", false), | ||
| 144 | booleanDescriptor("thinking", "Thinking", true), | ||
| 145 | ], | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("filters invalid and duplicate SDK model entries", () => { | ||
| 151 | expect( | ||
| 152 | buildCursorDiscoveredModelsFromSdk([ | ||
| 153 | sdkParameterizedModel, | ||
| 154 | { ...sdkParameterizedModel, displayName: "Duplicate" }, | ||
| 155 | { id: "", displayName: "Invalid" }, | ||
| 156 | ]), | ||
| 157 | ).toEqual([ | ||
| 158 | { | ||
| 159 | slug: "claude-opus-4-8", | ||
| 160 | name: "Opus 4.8", | ||
| 161 | isCustom: false, | ||
| 162 | capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), | ||
| 163 | }, | ||
| 164 | ]); | ||
| 165 | }); | ||
| 166 | }); | ||
| 167 | |||
| 168 | describe("checkCursorProviderStatus", () => { | ||
| 169 | it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const provider = yield* checkCursorProviderStatus( | ||
| 172 | { | ||
| 173 | ...baseCursorSettings, | ||
| 174 | customModels: ["internal/cursor-model"], | ||
| 175 | }, | ||
| 176 | { CURSOR_API_KEY: "test-cursor-key" }, | ||
| 177 | ).pipe( | ||
| 178 | Effect.provide( | ||
| 179 | CursorSdkCatalog.layerTest((apiKey) => { | ||
| 180 | expect(apiKey).toBe("test-cursor-key"); | ||
| 181 | return Effect.succeed({ | ||
| 182 | user: { | ||
| 183 | apiKeyName: "test-key", | ||
| 184 | userEmail: "cursor@example.com", | ||
| 185 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 186 | }, | ||
| 187 | models: [sdkParameterizedModel], | ||
| 188 | }); | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | |||
| 193 | expect(provider).toMatchObject({ | ||
| 194 | status: "ready", | ||
| 195 | auth: { | ||
| 196 | status: "authenticated", | ||
| 197 | type: "api-key", | ||
| 198 | label: "Cursor API key (test-key)", | ||
| 199 | email: "cursor@example.com", | ||
| 200 | }, | ||
| 201 | models: [ | ||
| 202 | { slug: "claude-opus-4-8", isCustom: false }, | ||
| 203 | { slug: "internal/cursor-model", isCustom: true }, | ||
| 204 | ], | ||
| 205 | }); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("surfaces SDK authentication failures", () => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const provider = yield* checkCursorProviderStatus(baseCursorSettings, { | ||
| 212 | CURSOR_API_KEY: "invalid-test-key", | ||
| 213 | }).pipe( | ||
| 214 | Effect.provide( | ||
| 215 | CursorSdkCatalog.layerTest(() => | ||
| 216 | Effect.fail( | ||
| 217 | new CursorSdkCatalog.CursorSdkCatalogError({ | ||
| 218 | authenticationFailure: true, | ||
| 219 | cause: new Error("unauthorized"), | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | ), | ||
| 223 | ), | ||
| 224 | ); | ||
| 225 | |||
| 226 | expect(provider).toMatchObject({ | ||
| 227 | status: "error", | ||
| 228 | auth: { status: "unauthenticated" }, | ||
| 229 | message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", | ||
| 230 | }); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("requires a Cursor API key without probing any external Cursor binary", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( | ||
| 237 | Effect.provide( | ||
| 238 | CursorSdkCatalog.layerTest(() => | ||
| 239 | Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), | ||
| 240 | ), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | expect(provider).toMatchObject({ | ||
| 245 | installed: true, | ||
| 246 | status: "error", | ||
| 247 | auth: { status: "unauthenticated" }, | ||
| 248 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | ||
| 249 | }); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | }); | ||
apps/server/src/provider/CursorProvider.ts created+350| ... | @@ -0,0 +1,350 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import type { | ||
| 3 | CursorSettings, | ||
| 4 | ModelCapabilities, | ||
| 5 | ProviderOptionDescriptor, | ||
| 6 | ServerProviderAuth, | ||
| 7 | ServerProviderModel, | ||
| 8 | ServerProviderState, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Result from "effect/Result"; | ||
| 15 | |||
| 16 | import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts"; | ||
| 17 | import { | ||
| 18 | buildBooleanOptionDescriptor, | ||
| 19 | buildSelectOptionDescriptor, | ||
| 20 | buildServerProvider, | ||
| 21 | providerModelsFromSettings, | ||
| 22 | type ServerProviderDraft, | ||
| 23 | } from "./providerSnapshot.ts"; | ||
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 25 | |||
| 26 | const CURSOR_PRESENTATION = { | ||
| 27 | displayName: "Cursor", | ||
| 28 | supportsConversationRollback: false, | ||
| 29 | showInteractionModeToggle: true, | ||
| 30 | } as const; | ||
| 31 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 32 | optionDescriptors: [], | ||
| 33 | }); | ||
| 34 | |||
| 35 | const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; | ||
| 36 | |||
| 37 | export function buildInitialCursorProviderSnapshot( | ||
| 38 | cursorSettings: CursorSettings, | ||
| 39 | ): Effect.Effect<ServerProviderDraft> { | ||
| 40 | return Effect.gen(function* () { | ||
| 41 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 42 | const models = getCursorFallbackModels(cursorSettings); | ||
| 43 | |||
| 44 | if (!cursorSettings.enabled) { | ||
| 45 | return buildServerProvider({ | ||
| 46 | presentation: CURSOR_PRESENTATION, | ||
| 47 | enabled: false, | ||
| 48 | checkedAt, | ||
| 49 | models, | ||
| 50 | probe: { | ||
| 51 | installed: false, | ||
| 52 | version: null, | ||
| 53 | status: "warning", | ||
| 54 | auth: { status: "unknown" }, | ||
| 55 | message: "Cursor is disabled in T3 Code settings.", | ||
| 56 | }, | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | |||
| 60 | return buildServerProvider({ | ||
| 61 | presentation: CURSOR_PRESENTATION, | ||
| 62 | enabled: true, | ||
| 63 | checkedAt, | ||
| 64 | models, | ||
| 65 | probe: { | ||
| 66 | installed: true, | ||
| 67 | version: null, | ||
| 68 | status: "warning", | ||
| 69 | auth: { status: "unknown" }, | ||
| 70 | message: "Checking Cursor SDK availability...", | ||
| 71 | }, | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | } | ||
| 75 | |||
| 76 | function getCursorFallbackModels( | ||
| 77 | cursorSettings: Pick<CursorSettings, "customModels">, | ||
| 78 | ): ReadonlyArray<ServerProviderModel> { | ||
| 79 | return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); | ||
| 80 | } | ||
| 81 | |||
| 82 | function toTitleCaseWords(value: string): string { | ||
| 83 | const parts: Array<string> = []; | ||
| 84 | for (const part of value.split(/[\s_-]+/g)) { | ||
| 85 | if (part.length > 0) { | ||
| 86 | parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | return parts.join(" "); | ||
| 90 | } | ||
| 91 | |||
| 92 | function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { | ||
| 93 | return model.variants | ||
| 94 | ?.find((variant) => variant.isDefault) | ||
| 95 | ?.params.find((parameter) => parameter.id === parameterId)?.value; | ||
| 96 | } | ||
| 97 | |||
| 98 | export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { | ||
| 99 | const seen = new Set<string>(); | ||
| 100 | const optionDescriptors: Array<ProviderOptionDescriptor> = []; | ||
| 101 | const parameters = (model.parameters ?? []) | ||
| 102 | .map((parameter, index) => ({ parameter, index })) | ||
| 103 | .toSorted( | ||
| 104 | (left, right) => | ||
| 105 | cursorSdkParameterPriority(left.parameter.id) - | ||
| 106 | cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, | ||
| 107 | ); | ||
| 108 | for (const { parameter } of parameters) { | ||
| 109 | const nativeId = parameter.id.trim(); | ||
| 110 | const id = cursorSdkProviderOptionId(nativeId); | ||
| 111 | if (!nativeId || !id || seen.has(id)) { | ||
| 112 | continue; | ||
| 113 | } | ||
| 114 | seen.add(id); | ||
| 115 | |||
| 116 | const values = parameter.values.flatMap((entry) => { | ||
| 117 | const value = entry.value.trim(); | ||
| 118 | if (!value) { | ||
| 119 | return []; | ||
| 120 | } | ||
| 121 | return [ | ||
| 122 | { | ||
| 123 | value, | ||
| 124 | label: entry.displayName?.trim() || value, | ||
| 125 | }, | ||
| 126 | ]; | ||
| 127 | }); | ||
| 128 | if (values.length === 0) { | ||
| 129 | continue; | ||
| 130 | } | ||
| 131 | |||
| 132 | const label = parameter.displayName?.trim() || toTitleCaseWords(id); | ||
| 133 | const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); | ||
| 134 | const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); | ||
| 135 | if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { | ||
| 136 | if (defaultValue === "true" || defaultValue === "false") { | ||
| 137 | optionDescriptors.push( | ||
| 138 | buildBooleanOptionDescriptor({ | ||
| 139 | id, | ||
| 140 | label, | ||
| 141 | currentValue: defaultValue === "true", | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | } else { | ||
| 145 | optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); | ||
| 146 | } | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | |||
| 150 | optionDescriptors.push( | ||
| 151 | buildSelectOptionDescriptor({ | ||
| 152 | id, | ||
| 153 | label, | ||
| 154 | options: values.map((entry) => ({ | ||
| 155 | ...entry, | ||
| 156 | ...(entry.value === defaultValue ? { isDefault: true } : {}), | ||
| 157 | })), | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | return createModelCapabilities({ optionDescriptors }); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function buildCursorDiscoveredModelsFromSdk( | ||
| 166 | models: ReadonlyArray<SDKModel>, | ||
| 167 | ): ReadonlyArray<ServerProviderModel> { | ||
| 168 | const seen = new Set<string>(); | ||
| 169 | return models.flatMap((model) => { | ||
| 170 | const slug = model.id.trim(); | ||
| 171 | const name = model.displayName.trim(); | ||
| 172 | if (!slug || !name || seen.has(slug)) { | ||
| 173 | return []; | ||
| 174 | } | ||
| 175 | seen.add(slug); | ||
| 176 | return [ | ||
| 177 | { | ||
| 178 | slug, | ||
| 179 | name, | ||
| 180 | isCustom: false, | ||
| 181 | capabilities: buildCursorCapabilitiesFromSdkModel(model), | ||
| 182 | } satisfies ServerProviderModel, | ||
| 183 | ]; | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | |||
| 187 | function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { | ||
| 188 | const email = user.userEmail?.trim(); | ||
| 189 | const apiKeyName = user.apiKeyName.trim(); | ||
| 190 | return { | ||
| 191 | status: "authenticated", | ||
| 192 | type, | ||
| 193 | label: | ||
| 194 | type === "browser" | ||
| 195 | ? "Cursor account" | ||
| 196 | : apiKeyName | ||
| 197 | ? `Cursor API key (${apiKeyName})` | ||
| 198 | : "Cursor API key", | ||
| 199 | ...(email ? { email } : {}), | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | interface CursorProviderProbeResult { | ||
| 204 | readonly version: string | null; | ||
| 205 | readonly status: Exclude<ServerProviderState, "disabled">; | ||
| 206 | readonly auth: ServerProviderAuth; | ||
| 207 | readonly message?: string; | ||
| 208 | } | ||
| 209 | |||
| 210 | function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined { | ||
| 211 | const parts: Array<string> = []; | ||
| 212 | for (const message of messages) { | ||
| 213 | const trimmed = message?.trim(); | ||
| 214 | if (trimmed) { | ||
| 215 | parts.push(trimmed); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return parts.length > 0 ? parts.join(" ") : undefined; | ||
| 219 | } | ||
| 220 | |||
| 221 | export function buildCursorProviderSnapshot(input: { | ||
| 222 | readonly checkedAt: string; | ||
| 223 | readonly cursorSettings: CursorSettings; | ||
| 224 | readonly parsed: CursorProviderProbeResult; | ||
| 225 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; | ||
| 226 | readonly discoveryWarning?: string; | ||
| 227 | }): ServerProviderDraft { | ||
| 228 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); | ||
| 229 | return buildServerProvider({ | ||
| 230 | presentation: CURSOR_PRESENTATION, | ||
| 231 | enabled: input.cursorSettings.enabled, | ||
| 232 | checkedAt: input.checkedAt, | ||
| 233 | models: providerModelsFromSettings( | ||
| 234 | input.discoveredModels ?? [], | ||
| 235 | input.cursorSettings.customModels, | ||
| 236 | EMPTY_CAPABILITIES, | ||
| 237 | ), | ||
| 238 | probe: { | ||
| 239 | installed: true, | ||
| 240 | version: input.parsed.version, | ||
| 241 | status: | ||
| 242 | input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, | ||
| 243 | auth: input.parsed.auth, | ||
| 244 | ...(message ? { message } : {}), | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | } | ||
| 248 | |||
| 249 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( | ||
| 250 | cursorSettings: CursorSettings, | ||
| 251 | environment?: NodeJS.ProcessEnv, | ||
| 252 | authenticationType: "api-key" | "browser" = "api-key", | ||
| 253 | ): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> { | ||
| 254 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 255 | const fallbackModels = getCursorFallbackModels(cursorSettings); | ||
| 256 | |||
| 257 | if (!cursorSettings.enabled) { | ||
| 258 | return buildServerProvider({ | ||
| 259 | presentation: CURSOR_PRESENTATION, | ||
| 260 | enabled: false, | ||
| 261 | checkedAt, | ||
| 262 | models: fallbackModels, | ||
| 263 | probe: { | ||
| 264 | installed: false, | ||
| 265 | version: null, | ||
| 266 | status: "warning", | ||
| 267 | auth: { status: "unknown" }, | ||
| 268 | message: "Cursor is disabled in T3 Code settings.", | ||
| 269 | }, | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | |||
| 273 | const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); | ||
| 274 | if (!sdkApiKey) { | ||
| 275 | return buildServerProvider({ | ||
| 276 | presentation: CURSOR_PRESENTATION, | ||
| 277 | enabled: cursorSettings.enabled, | ||
| 278 | checkedAt, | ||
| 279 | models: fallbackModels, | ||
| 280 | probe: { | ||
| 281 | installed: true, | ||
| 282 | version: null, | ||
| 283 | status: "error", | ||
| 284 | auth: { status: "unauthenticated" }, | ||
| 285 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | ||
| 286 | }, | ||
| 287 | }); | ||
| 288 | } | ||
| 289 | |||
| 290 | const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; | ||
| 291 | const catalogResult = yield* sdkCatalog | ||
| 292 | .read(sdkApiKey) | ||
| 293 | .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); | ||
| 294 | |||
| 295 | if (Result.isFailure(catalogResult)) { | ||
| 296 | yield* Effect.logWarning("Cursor SDK catalog probe failed", { | ||
| 297 | cause: catalogResult.failure.cause, | ||
| 298 | }); | ||
| 299 | const authenticationFailure = catalogResult.failure.authenticationFailure; | ||
| 300 | return buildServerProvider({ | ||
| 301 | presentation: CURSOR_PRESENTATION, | ||
| 302 | enabled: cursorSettings.enabled, | ||
| 303 | checkedAt, | ||
| 304 | models: fallbackModels, | ||
| 305 | probe: { | ||
| 306 | installed: true, | ||
| 307 | version: null, | ||
| 308 | status: "error", | ||
| 309 | auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, | ||
| 310 | message: authenticationFailure | ||
| 311 | ? authenticationType === "browser" | ||
| 312 | ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." | ||
| 313 | : "Cursor SDK authentication failed. Check CURSOR_API_KEY." | ||
| 314 | : "Cursor SDK catalog request failed. Check server logs for details.", | ||
| 315 | }, | ||
| 316 | }); | ||
| 317 | } | ||
| 318 | |||
| 319 | if (Option.isNone(catalogResult.success)) { | ||
| 320 | return buildServerProvider({ | ||
| 321 | presentation: CURSOR_PRESENTATION, | ||
| 322 | enabled: cursorSettings.enabled, | ||
| 323 | checkedAt, | ||
| 324 | models: fallbackModels, | ||
| 325 | probe: { | ||
| 326 | installed: true, | ||
| 327 | version: null, | ||
| 328 | status: "error", | ||
| 329 | auth: { status: "unknown" }, | ||
| 330 | message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, | ||
| 331 | }, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | |||
| 335 | const snapshot = catalogResult.success.value; | ||
| 336 | const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); | ||
| 337 | return buildCursorProviderSnapshot({ | ||
| 338 | checkedAt, | ||
| 339 | cursorSettings, | ||
| 340 | parsed: { | ||
| 341 | version: null, | ||
| 342 | status: "ready", | ||
| 343 | auth: cursorSdkAuth(snapshot.user, authenticationType), | ||
| 344 | }, | ||
| 345 | discoveredModels, | ||
| 346 | ...(discoveredModels.length === 0 | ||
| 347 | ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } | ||
| 348 | : {}), | ||
| 349 | }); | ||
| 350 | }); | ||
apps/server/src/provider/CursorSdkCatalog.test.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | |||
| 6 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 7 | |||
| 8 | const user = { | ||
| 9 | apiKeyName: "test-key", | ||
| 10 | userEmail: "cursor@example.com", | ||
| 11 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 12 | } satisfies SDKUser; | ||
| 13 | |||
| 14 | const model = { | ||
| 15 | id: "claude-opus-4-8", | ||
| 16 | displayName: "Opus 4.8", | ||
| 17 | parameters: [], | ||
| 18 | } satisfies SDKModel; | ||
| 19 | |||
| 20 | const catalogError = () => | ||
| 21 | new CursorSdkCatalog.CursorSdkCatalogError({ | ||
| 22 | authenticationFailure: false, | ||
| 23 | cause: new Error("catalog unavailable"), | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("CursorSdkCatalog", () => { | ||
| 27 | it.effect("keeps the user probe fresh while caching models by API key", () => | ||
| 28 | Effect.gen(function* () { | ||
| 29 | let userCalls = 0; | ||
| 30 | let modelCalls = 0; | ||
| 31 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | ||
| 32 | readUser: () => Effect.sync(() => ((userCalls += 1), user)), | ||
| 33 | readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), | ||
| 34 | }); | ||
| 35 | |||
| 36 | yield* catalog.read("first-key"); | ||
| 37 | yield* catalog.read("first-key"); | ||
| 38 | yield* catalog.read("second-key"); | ||
| 39 | |||
| 40 | expect(userCalls).toBe(3); | ||
| 41 | expect(modelCalls).toBe(2); | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | it.effect("does not cache failed or empty model discovery", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | let modelCalls = 0; | ||
| 48 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | ||
| 49 | readUser: () => Effect.succeed(user), | ||
| 50 | readModels: () => | ||
| 51 | Effect.suspend(() => { | ||
| 52 | modelCalls += 1; | ||
| 53 | if (modelCalls === 1) return Effect.fail(catalogError()); | ||
| 54 | if (modelCalls === 2) return Effect.succeed([]); | ||
| 55 | return Effect.succeed([model]); | ||
| 56 | }), | ||
| 57 | }); | ||
| 58 | |||
| 59 | expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); | ||
| 60 | expect((yield* catalog.read("test-key")).models).toEqual([]); | ||
| 61 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | ||
| 62 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | ||
| 63 | expect(modelCalls).toBe(3); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | }); | ||
apps/server/src/provider/CursorSdkCatalog.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import * as Cache from "effect/Cache"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts"; | ||
| 11 | |||
| 12 | export interface CursorSdkCatalogSnapshot { | ||
| 13 | readonly user: SDKUser; | ||
| 14 | readonly models: ReadonlyArray<SDKModel>; | ||
| 15 | } | ||
| 16 | |||
| 17 | export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()( | ||
| 18 | "CursorSdkCatalogError", | ||
| 19 | { | ||
| 20 | authenticationFailure: Schema.Boolean, | ||
| 21 | cause: Schema.Defect(), | ||
| 22 | }, | ||
| 23 | ) { | ||
| 24 | override get message(): string { | ||
| 25 | return this.authenticationFailure | ||
| 26 | ? "Cursor SDK authentication failed." | ||
| 27 | : "Cursor SDK catalog request failed."; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface CursorSdkCatalogShape { | ||
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | ||
| 33 | } | ||
| 34 | |||
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | ||
| 36 | "t3/provider/CursorSdkCatalog", | ||
| 37 | ) {} | ||
| 38 | |||
| 39 | function isAuthenticationFailure(cause: unknown): boolean { | ||
| 40 | return ( | ||
| 41 | cause instanceof AuthenticationError || | ||
| 42 | (cause instanceof CursorSdkError && cause.status === 401) | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface CursorSdkCatalogProbes { | ||
| 47 | readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>; | ||
| 48 | readonly readModels: ( | ||
| 49 | apiKey: string, | ||
| 50 | ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>; | ||
| 51 | } | ||
| 52 | |||
| 53 | const sdkRequest = <A>(tryRequest: () => Promise<A>) => | ||
| 54 | Effect.tryPromise({ | ||
| 55 | try: tryRequest, | ||
| 56 | catch: (cause) => | ||
| 57 | new CursorSdkCatalogError({ | ||
| 58 | authenticationFailure: isAuthenticationFailure(cause), | ||
| 59 | cause, | ||
| 60 | }), | ||
| 61 | }); | ||
| 62 | |||
| 63 | const liveProbes: CursorSdkCatalogProbes = { | ||
| 64 | readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), | ||
| 65 | readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), | ||
| 66 | }; | ||
| 67 | |||
| 68 | export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( | ||
| 69 | probes: CursorSdkCatalogProbes = liveProbes, | ||
| 70 | ) { | ||
| 71 | const modelCache = yield* Cache.makeWith(probes.readModels, { | ||
| 72 | capacity: 32, | ||
| 73 | timeToLive: (exit) => | ||
| 74 | Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, | ||
| 75 | }); | ||
| 76 | return CursorSdkCatalog.of({ | ||
| 77 | read: (apiKey) => | ||
| 78 | Effect.all( | ||
| 79 | { | ||
| 80 | user: probes.readUser(apiKey), | ||
| 81 | models: Cache.get(modelCache, apiKey), | ||
| 82 | }, | ||
| 83 | { concurrency: "unbounded" }, | ||
| 84 | ), | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); | ||
| 89 | |||
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | ||
| 91 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); | ||
| 92 | } | ||
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+1-1| ... | @@ -29,7 +29,7 @@ import { | ... | @@ -29,7 +29,7 @@ import { |
| 29 | ANTIGRAVITY_AUTH_STDOUT_PREFIX, | 29 | ANTIGRAVITY_AUTH_STDOUT_PREFIX, |
| 30 | resolveAntigravityInstanceDirectories, | 30 | resolveAntigravityInstanceDirectories, |
| 31 | } from "../antigravityAuthSupport.ts"; | 31 | } from "../antigravityAuthSupport.ts"; |
| 32 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 32 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 33 | import * as ModelManifest from "../ModelManifest.ts"; | 33 | import * as ModelManifest from "../ModelManifest.ts"; |
| 34 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; | 34 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; |
| 35 | import { AntigravityDriver } from "./AntigravityDriver.ts"; | 35 | import { AntigravityDriver } from "./AntigravityDriver.ts"; |
apps/server/src/provider/Drivers/AntigravityDriver.ts+2-2| ... | @@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo | ... | @@ -52,8 +52,8 @@ import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderCo |
| 52 | import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; | 52 | import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; |
| 53 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; | 53 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; |
| 54 | import { ProviderDriverError } from "../Errors.ts"; | 54 | import { ProviderDriverError } from "../Errors.ts"; |
| 55 | import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; | 55 | import { makeAntigravityProvider } from "../AntigravityProvider.ts"; |
| 56 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 56 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 57 | import * as ModelManifest from "../ModelManifest.ts"; | 57 | import * as ModelManifest from "../ModelManifest.ts"; |
| 58 | import { | 58 | import { |
| 59 | defaultProviderContinuationIdentity, | 59 | defaultProviderContinuationIdentity, |
apps/server/src/provider/Drivers/ClaudeDriver.ts+5-5| ... | @@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat | ... | @@ -27,22 +27,22 @@ import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGenerat |
| 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 27 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 28 | import * as ServerConfig from "../../config.ts"; | 28 | import * as ServerConfig from "../../config.ts"; |
| 29 | import { expandHomePath } from "../../pathExpansion.ts"; | 29 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 30 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 30 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 31 | import { | 31 | import { |
| 32 | createClaudeAdapterV2, | 32 | createClaudeAdapterV2, |
| 33 | type ClaudeAdapterV2DriverEnv, | 33 | type ClaudeAdapterV2DriverEnv, |
| 34 | } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | 34 | } from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 35 | import * as ServerSettings from "../../serverSettings.ts"; | 35 | import * as ServerSettings from "../../serverSettings.ts"; |
| 36 | import { ProviderDriverError } from "../Errors.ts"; | 36 | import { ProviderDriverError } from "../Errors.ts"; |
| 37 | import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; | 37 | import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts"; |
| 38 | import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; | 38 | import * as ClaudeResetCredits from "../claudeResetCredits.ts"; |
| 39 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; | 39 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| 40 | import { | 40 | import { |
| 41 | checkClaudeProviderStatus, | 41 | checkClaudeProviderStatus, |
| 42 | makePendingClaudeProvider, | 42 | makePendingClaudeProvider, |
| 43 | probeClaudeCapabilities, | 43 | probeClaudeCapabilities, |
| 44 | probeClaudeWorkspaceSnapshot, | 44 | probeClaudeWorkspaceSnapshot, |
| 45 | } from "../Layers/ClaudeProvider.ts"; | 45 | } from "../ClaudeProvider.ts"; |
| 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 46 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 47 | import * as ModelManifest from "../ModelManifest.ts"; | 47 | import * as ModelManifest from "../ModelManifest.ts"; |
| 48 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; | 48 | import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; |
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-2| ... | @@ -25,8 +25,8 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -25,8 +25,8 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 25 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 25 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 26 | import * as ServerConfig from "../../config.ts"; | 26 | import * as ServerConfig from "../../config.ts"; |
| 27 | import * as ServerSettings from "../../serverSettings.ts"; | 27 | import * as ServerSettings from "../../serverSettings.ts"; |
| 28 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; | 28 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| 29 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 29 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 30 | import * as ModelManifest from "../ModelManifest.ts"; | 30 | import * as ModelManifest from "../ModelManifest.ts"; |
| 31 | import { | 31 | import { |
| 32 | createProviderVersionAdvisory, | 32 | createProviderVersionAdvisory, |
apps/server/src/provider/Drivers/CodexDriver.ts+4-4| ... | @@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio | ... | @@ -34,21 +34,21 @@ import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneratio |
| 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 34 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 35 | import * as ServerConfig from "../../config.ts"; | 35 | import * as ServerConfig from "../../config.ts"; |
| 36 | import { expandHomePath } from "../../pathExpansion.ts"; | 36 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 37 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 37 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 38 | import { | 38 | import { |
| 39 | createCodexAdapterV2, | 39 | createCodexAdapterV2, |
| 40 | type CodexAdapterV2DriverEnv, | 40 | type CodexAdapterV2DriverEnv, |
| 41 | } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; | 41 | } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 42 | import * as ServerSettings from "../../serverSettings.ts"; | 42 | import * as ServerSettings from "../../serverSettings.ts"; |
| 43 | import { ProviderDriverError } from "../Errors.ts"; | 43 | import { ProviderDriverError } from "../Errors.ts"; |
| 44 | import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; | 44 | import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts"; |
| 45 | import { | 45 | import { |
| 46 | checkCodexProviderStatus, | 46 | checkCodexProviderStatus, |
| 47 | makePendingCodexProvider, | 47 | makePendingCodexProvider, |
| 48 | probeCodexSkillsForCwd, | 48 | probeCodexSkillsForCwd, |
| 49 | withCodexAppServerClient, | 49 | withCodexAppServerClient, |
| 50 | } from "../Layers/CodexProvider.ts"; | 50 | } from "../CodexProvider.ts"; |
| 51 | import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; | 51 | import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; |
| 52 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 52 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 53 | import * as ModelManifest from "../ModelManifest.ts"; | 53 | import * as ModelManifest from "../ModelManifest.ts"; |
| 54 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | 54 | import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; |
apps/server/src/provider/Drivers/CodexManagedProvider.ts+1-1| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | checkCodexProviderStatus, | 13 | checkCodexProviderStatus, |
| 14 | makePendingCodexProvider, | 14 | makePendingCodexProvider, |
| 15 | probeCodexSkillsForCwd, | 15 | probeCodexSkillsForCwd, |
| 16 | } from "../Layers/CodexProvider.ts"; | 16 | } from "../CodexProvider.ts"; |
| 17 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 17 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 18 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 18 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 19 | import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; | 19 | import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; |
apps/server/src/provider/Drivers/CursorDriver.test.ts+1-1| ... | @@ -14,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -14,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 14 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 14 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 15 | import * as ServerConfig from "../../config.ts"; | 15 | import * as ServerConfig from "../../config.ts"; |
| 16 | import * as ServerSettings from "../../serverSettings.ts"; | 16 | import * as ServerSettings from "../../serverSettings.ts"; |
| 17 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 17 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 18 | import { CursorDriver } from "./CursorDriver.ts"; | 18 | import { CursorDriver } from "./CursorDriver.ts"; |
| 19 | import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; | 19 | import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; |
| 20 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; | 20 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; |
apps/server/src/provider/Drivers/CursorDriver.ts+3-3| ... | @@ -13,7 +13,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -13,7 +13,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 14 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 15 | import { HttpClient } from "effect/http"; | 15 | import { HttpClient } from "effect/http"; |
| 16 | import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts"; | 16 | import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; |
| 17 | 17 | ||
| 18 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 18 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 19 | import * as ServerConfig from "../../config.ts"; | 19 | import * as ServerConfig from "../../config.ts"; |
| ... | @@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts"; | ... | @@ -27,8 +27,8 @@ import { ProviderDriverError } from "../Errors.ts"; |
| 27 | import { | 27 | import { |
| 28 | buildInitialCursorProviderSnapshot, | 28 | buildInitialCursorProviderSnapshot, |
| 29 | checkCursorProviderStatus, | 29 | checkCursorProviderStatus, |
| 30 | } from "../Layers/CursorProvider.ts"; | 30 | } from "../CursorProvider.ts"; |
| 31 | import * as CursorSdkCatalog from "../Layers/CursorSdkCatalog.ts"; | 31 | import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; |
| 32 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 32 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 33 | import { | 33 | import { |
| 34 | defaultProviderContinuationIdentity, | 34 | defaultProviderContinuationIdentity, |
apps/server/src/provider/Drivers/GrokDriver.test.ts+1-1| ... | @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 12 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 13 | import * as ServerConfig from "../../config.ts"; | 13 | import * as ServerConfig from "../../config.ts"; |
| 14 | import * as ServerSettings from "../../serverSettings.ts"; | 14 | import * as ServerSettings from "../../serverSettings.ts"; |
| 15 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 15 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 16 | import { GrokDriver } from "./GrokDriver.ts"; | 16 | import { GrokDriver } from "./GrokDriver.ts"; |
| 17 | 17 | ||
| 18 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; | 18 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; |
apps/server/src/provider/Drivers/GrokDriver.ts+3-3| ... | @@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/process"; | ... | @@ -9,7 +9,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 9 | 9 | ||
| 10 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 10 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 11 | import * as ServerConfig from "../../config.ts"; | 11 | import * as ServerConfig from "../../config.ts"; |
| 12 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 12 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 13 | import * as ServerSettings from "../../serverSettings.ts"; | 13 | import * as ServerSettings from "../../serverSettings.ts"; |
| 14 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; | 14 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; |
| 15 | import { | 15 | import { |
| ... | @@ -21,8 +21,8 @@ import { | ... | @@ -21,8 +21,8 @@ import { |
| 21 | buildInitialGrokProviderSnapshot, | 21 | buildInitialGrokProviderSnapshot, |
| 22 | checkGrokProviderStatus, | 22 | checkGrokProviderStatus, |
| 23 | enrichGrokSnapshot, | 23 | enrichGrokSnapshot, |
| 24 | } from "../Layers/GrokProvider.ts"; | 24 | } from "../GrokProvider.ts"; |
| 25 | import { readGrokAccount } from "../Layers/grokUsageLimits.ts"; | 25 | import { readGrokAccount } from "../grokUsageLimits.ts"; |
| 26 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 26 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 27 | import { | 27 | import { |
| 28 | defaultProviderContinuationIdentity, | 28 | defaultProviderContinuationIdentity, |
apps/server/src/provider/Drivers/OpenCodeDriver.test.ts+1-1| ... | @@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ... | @@ -18,7 +18,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 18 | import * as ServerConfig from "../../config.ts"; | 18 | import * as ServerConfig from "../../config.ts"; |
| 19 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; | 19 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; |
| 20 | import * as ServerSettings from "../../serverSettings.ts"; | 20 | import * as ServerSettings from "../../serverSettings.ts"; |
| 21 | import * as ProviderEventLoggers from "../Layers/ProviderEventLoggers.ts"; | 21 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; |
| 22 | import * as ProviderMaintenance from "../providerMaintenance.ts"; | 22 | import * as ProviderMaintenance from "../providerMaintenance.ts"; |
| 23 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 23 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 24 | import { | 24 | import { |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-2| ... | @@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap | ... | @@ -34,7 +34,7 @@ import type { ProviderAdapterV2Shape } from "../../orchestration-v2/ProviderAdap |
| 34 | import * as ServerSettings from "../../serverSettings.ts"; | 34 | import * as ServerSettings from "../../serverSettings.ts"; |
| 35 | import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | 35 | import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 36 | import { ProviderDriverError } from "../Errors.ts"; | 36 | import { ProviderDriverError } from "../Errors.ts"; |
| 37 | import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; | 37 | import { readOpenCodeGoUsageLimits } from "../openCodeUsageLimits.ts"; |
| 38 | import { | 38 | import { |
| 39 | checkOpenCodeProviderStatus, | 39 | checkOpenCodeProviderStatus, |
| 40 | loadOpenCode2Workspace, | 40 | loadOpenCode2Workspace, |
| ... | @@ -44,7 +44,7 @@ import { | ... | @@ -44,7 +44,7 @@ import { |
| 44 | openCode2SkillsToServerProviderSkills, | 44 | openCode2SkillsToServerProviderSkills, |
| 45 | openCodeSkillsToServerProviderSkills, | 45 | openCodeSkillsToServerProviderSkills, |
| 46 | openCodeCommandsToServerProviderSlashCommands, | 46 | openCodeCommandsToServerProviderSlashCommands, |
| 47 | } from "../Layers/OpenCodeProvider.ts"; | 47 | } from "../OpenCodeProvider.ts"; |
| 48 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 48 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 49 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 49 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 50 | import { | 50 | import { |
apps/server/src/provider/Drivers/PiDriver.ts+1-1| ... | @@ -27,7 +27,7 @@ import { | ... | @@ -27,7 +27,7 @@ import { |
| 27 | buildInitialPiProviderSnapshot, | 27 | buildInitialPiProviderSnapshot, |
| 28 | checkPiProviderStatus, | 28 | checkPiProviderStatus, |
| 29 | enrichPiSnapshot, | 29 | enrichPiSnapshot, |
| 30 | } from "../Layers/PiProvider.ts"; | 30 | } from "../PiProvider.ts"; |
| 31 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 31 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 32 | import { | 32 | import { |
| 33 | defaultProviderContinuationIdentity, | 33 | defaultProviderContinuationIdentity, |
apps/server/src/provider/EventNdjsonLogger.test.ts created+856| ... | @@ -0,0 +1,856 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | |||
| 6 | import { ThreadId } from "@t3tools/contracts"; | ||
| 7 | import { assert, describe, it } from "@effect/vitest"; | ||
| 8 | import * as Clock from "effect/Clock"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Logger from "effect/Logger"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as TestClock from "effect/testing/TestClock"; | ||
| 14 | |||
| 15 | import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; | ||
| 16 | import { | ||
| 17 | makeEventNdjsonLogger, | ||
| 18 | makeEventNdjsonLogStore, | ||
| 19 | type PendingRecord, | ||
| 20 | writeBatchedMessages, | ||
| 21 | } from "./EventNdjsonLogger.ts"; | ||
| 22 | |||
| 23 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 24 | const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 25 | |||
| 26 | function ownedLogPath(basePath: string, segment: string): string { | ||
| 27 | const basename = NodePath.basename(basePath); | ||
| 28 | const extension = NodePath.extname(basename); | ||
| 29 | const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; | ||
| 30 | return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); | ||
| 31 | } | ||
| 32 | |||
| 33 | function parseLogLine(line: string) { | ||
| 34 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); | ||
| 35 | assert.notEqual(match, null); | ||
| 36 | if (!match) { | ||
| 37 | throw new Error(`invalid log line: ${line}`); | ||
| 38 | } | ||
| 39 | const observedAt = match[1]; | ||
| 40 | const stream = match[2]; | ||
| 41 | const payload = match[3]; | ||
| 42 | if (!observedAt || !stream || payload === undefined) { | ||
| 43 | throw new Error(`invalid log line: ${line}`); | ||
| 44 | } | ||
| 45 | return { | ||
| 46 | observedAt, | ||
| 47 | stream, | ||
| 48 | payload, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("EventNdjsonLogger", () => { | ||
| 53 | it.effect("summarizes circular events without exposing their contents in diagnostics", () => { | ||
| 54 | const messages: Array<unknown> = []; | ||
| 55 | const logCapture = Logger.make<unknown, void>(({ message }) => { | ||
| 56 | if (Array.isArray(message)) { | ||
| 57 | messages.push(...message); | ||
| 58 | } else { | ||
| 59 | messages.push(message); | ||
| 60 | } | ||
| 61 | }); | ||
| 62 | const secret = "secret-circular-event-value"; | ||
| 63 | |||
| 64 | return Effect.gen(function* () { | ||
| 65 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 66 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 67 | const circular: Record<string, unknown> = { secret }; | ||
| 68 | circular.self = circular; | ||
| 69 | |||
| 70 | try { | ||
| 71 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); | ||
| 72 | assert.exists(logger); | ||
| 73 | if (!logger) return; | ||
| 74 | yield* logger.write(circular, ThreadId.make("thread-1")); | ||
| 75 | yield* logger.close(); | ||
| 76 | |||
| 77 | const serialized = encodeUnknownJson(messages); | ||
| 78 | assert.notInclude(serialized, secret); | ||
| 79 | const line = parseLogLine( | ||
| 80 | NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(), | ||
| 81 | ); | ||
| 82 | assert.equal(line.payload, '{"truncated":true}'); | ||
| 83 | } finally { | ||
| 84 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 85 | } | ||
| 86 | }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it.effect("writes effect-style lines to thread-scoped files", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 92 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 93 | |||
| 94 | try { | ||
| 95 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); | ||
| 96 | assert.notEqual(logger, undefined); | ||
| 97 | if (!logger) { | ||
| 98 | return; | ||
| 99 | } | ||
| 100 | |||
| 101 | yield* logger.write( | ||
| 102 | { threadId: "provider-thread-1", id: "evt-1" }, | ||
| 103 | ThreadId.make("thread-1"), | ||
| 104 | ); | ||
| 105 | yield* logger.write( | ||
| 106 | { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, | ||
| 107 | ThreadId.make("thread-2"), | ||
| 108 | ); | ||
| 109 | yield* logger.close(); | ||
| 110 | |||
| 111 | const threadOnePath = ownedLogPath(basePath, "thread-1"); | ||
| 112 | const threadTwoPath = ownedLogPath(basePath, "thread-2"); | ||
| 113 | assert.equal(NodeFS.existsSync(threadOnePath), true); | ||
| 114 | assert.equal(NodeFS.existsSync(threadTwoPath), true); | ||
| 115 | |||
| 116 | const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); | ||
| 117 | const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); | ||
| 118 | |||
| 119 | assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); | ||
| 120 | assert.equal(first.stream, "NTIVE"); | ||
| 121 | assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); | ||
| 122 | |||
| 123 | assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); | ||
| 124 | assert.equal(second.stream, "NTIVE"); | ||
| 125 | assert.equal( | ||
| 126 | second.payload, | ||
| 127 | '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', | ||
| 128 | ); | ||
| 129 | } finally { | ||
| 130 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 131 | } | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect( | ||
| 136 | "falls back to a global segment when orchestration thread id is missing or invalid", | ||
| 137 | () => | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 140 | const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); | ||
| 141 | |||
| 142 | try { | ||
| 143 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); | ||
| 144 | assert.notEqual(logger, undefined); | ||
| 145 | if (!logger) { | ||
| 146 | return; | ||
| 147 | } | ||
| 148 | |||
| 149 | yield* logger.write({ id: "evt-no-thread" }, null); | ||
| 150 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); | ||
| 151 | yield* logger.close(); | ||
| 152 | |||
| 153 | const globalPath = ownedLogPath(basePath, "_global"); | ||
| 154 | assert.equal(NodeFS.existsSync(globalPath), true); | ||
| 155 | const lines = NodeFS.readFileSync(globalPath, "utf8") | ||
| 156 | .trim() | ||
| 157 | .split("\n") | ||
| 158 | .map((line) => parseLogLine(line)); | ||
| 159 | assert.equal(lines.length, 2); | ||
| 160 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); | ||
| 161 | assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); | ||
| 162 | assert.equal(lines[0]?.stream, "ORCH"); | ||
| 163 | assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); | ||
| 164 | assert.equal(lines[1]?.stream, "ORCH"); | ||
| 165 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); | ||
| 166 | } finally { | ||
| 167 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 168 | } | ||
| 169 | }), | ||
| 170 | ); | ||
| 171 | |||
| 172 | it.effect("shares one thread writer across native and canonical streams", () => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 175 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 176 | |||
| 177 | try { | ||
| 178 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 179 | const native = store.logger("native"); | ||
| 180 | const canonical = store.logger("canonical"); | ||
| 181 | const threadId = ThreadId.make("thread-shared"); | ||
| 182 | |||
| 183 | yield* native.write({ id: "native-event" }, threadId); | ||
| 184 | yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); | ||
| 185 | yield* store.close(); | ||
| 186 | |||
| 187 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") | ||
| 188 | .trim() | ||
| 189 | .split("\n") | ||
| 190 | .map(parseLogLine); | ||
| 191 | |||
| 192 | assert.deepEqual( | ||
| 193 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 194 | [ | ||
| 195 | { stream: "NTIVE", payload: '{"id":"native-event"}' }, | ||
| 196 | { | ||
| 197 | stream: "CANON", | ||
| 198 | payload: '{"type":"item.completed","id":"canonical-event"}', | ||
| 199 | }, | ||
| 200 | ], | ||
| 201 | ); | ||
| 202 | } finally { | ||
| 203 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 204 | } | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("keeps shared store views non-owning when one adapter closes", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 211 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 212 | |||
| 213 | try { | ||
| 214 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 215 | const native = store.logger("native"); | ||
| 216 | const canonical = store.logger("canonical"); | ||
| 217 | const threadId = ThreadId.make("thread-shared-close"); | ||
| 218 | |||
| 219 | yield* native.write({ id: "before-close" }, threadId); | ||
| 220 | yield* native.close(); | ||
| 221 | yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); | ||
| 222 | yield* store.close(); | ||
| 223 | |||
| 224 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") | ||
| 225 | .trim() | ||
| 226 | .split("\n") | ||
| 227 | .map(parseLogLine); | ||
| 228 | |||
| 229 | assert.deepEqual( | ||
| 230 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 231 | [ | ||
| 232 | { stream: "NTIVE", payload: '{"id":"before-close"}' }, | ||
| 233 | { | ||
| 234 | stream: "CANON", | ||
| 235 | payload: '{"type":"item.completed","id":"after-close"}', | ||
| 236 | }, | ||
| 237 | ], | ||
| 238 | ); | ||
| 239 | } finally { | ||
| 240 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 241 | } | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | |||
| 245 | it.effect("flushes an active batch without a permanent polling loop", () => | ||
| 246 | Effect.gen(function* () { | ||
| 247 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 248 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 249 | const threadPath = ownedLogPath(basePath, "thread-batched"); | ||
| 250 | |||
| 251 | try { | ||
| 252 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | ||
| 253 | yield* store | ||
| 254 | .logger("native") | ||
| 255 | .write({ id: "batched-event" }, ThreadId.make("thread-batched")); | ||
| 256 | |||
| 257 | assert.equal(NodeFS.existsSync(threadPath), false); | ||
| 258 | yield* TestClock.adjust(1_000); | ||
| 259 | assert.equal(NodeFS.existsSync(threadPath), true); | ||
| 260 | yield* store.close(); | ||
| 261 | } finally { | ||
| 262 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 263 | } | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | |||
| 267 | it.effect("does not strand a later batch after an interrupted write", () => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 270 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 271 | const threadPath = ownedLogPath(basePath, "thread-interrupted"); | ||
| 272 | |||
| 273 | try { | ||
| 274 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | ||
| 275 | const logger = store.logger("native"); | ||
| 276 | const interruptedWrite = yield* logger | ||
| 277 | .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) | ||
| 278 | .pipe(Effect.forkChild); | ||
| 279 | yield* Effect.yieldNow; | ||
| 280 | yield* Fiber.interrupt(interruptedWrite); | ||
| 281 | yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); | ||
| 282 | |||
| 283 | yield* TestClock.adjust(1_000); | ||
| 284 | |||
| 285 | assert.equal(NodeFS.existsSync(threadPath), true); | ||
| 286 | assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); | ||
| 287 | yield* store.close(); | ||
| 288 | } finally { | ||
| 289 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 290 | } | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | |||
| 294 | it.effect("drops transient provider events before serialization", () => | ||
| 295 | Effect.gen(function* () { | ||
| 296 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 297 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 298 | |||
| 299 | try { | ||
| 300 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 301 | const canonical = store.logger("canonical"); | ||
| 302 | const native = store.logger("native"); | ||
| 303 | const threadId = ThreadId.make("thread-filtered"); | ||
| 304 | const circularDelta: Record<string, unknown> = { type: "content.delta" }; | ||
| 305 | circularDelta["self"] = circularDelta; | ||
| 306 | |||
| 307 | yield* canonical.write(circularDelta, threadId); | ||
| 308 | yield* canonical.write({ type: "item.completed", id: "final" }, threadId); | ||
| 309 | yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); | ||
| 310 | yield* native.write( | ||
| 311 | { method: "item/agentMessage/delta", payload: circularDelta }, | ||
| 312 | threadId, | ||
| 313 | ); | ||
| 314 | yield* native.write( | ||
| 315 | { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, | ||
| 316 | threadId, | ||
| 317 | ); | ||
| 318 | yield* native.write( | ||
| 319 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, | ||
| 320 | threadId, | ||
| 321 | ); | ||
| 322 | yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); | ||
| 323 | yield* native.write( | ||
| 324 | { | ||
| 325 | event: { | ||
| 326 | direction: "incoming", | ||
| 327 | stage: "decoded", | ||
| 328 | payload: { | ||
| 329 | method: "turn/diff/updated", | ||
| 330 | get params() { | ||
| 331 | throw new Error("unused diff snapshots must not be traversed"); | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | }, | ||
| 335 | }, | ||
| 336 | threadId, | ||
| 337 | ); | ||
| 338 | yield* native.write( | ||
| 339 | { | ||
| 340 | event: { | ||
| 341 | method: "claude/stream_event/content_block_delta/text_delta", | ||
| 342 | payload: circularDelta, | ||
| 343 | }, | ||
| 344 | }, | ||
| 345 | threadId, | ||
| 346 | ); | ||
| 347 | yield* native.write( | ||
| 348 | { | ||
| 349 | event: { | ||
| 350 | method: "session/update", | ||
| 351 | payload: { update: { sessionUpdate: "agent_message_chunk" } }, | ||
| 352 | }, | ||
| 353 | }, | ||
| 354 | threadId, | ||
| 355 | ); | ||
| 356 | yield* native.write( | ||
| 357 | { | ||
| 358 | event: { | ||
| 359 | direction: "incoming", | ||
| 360 | stage: "decoded", | ||
| 361 | payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, | ||
| 362 | }, | ||
| 363 | }, | ||
| 364 | threadId, | ||
| 365 | ); | ||
| 366 | yield* native.write( | ||
| 367 | { | ||
| 368 | event: { | ||
| 369 | direction: "incoming", | ||
| 370 | stage: "decoded", | ||
| 371 | payload: { | ||
| 372 | type: "stream_event", | ||
| 373 | event: { type: "content_block_delta", delta: circularDelta }, | ||
| 374 | }, | ||
| 375 | }, | ||
| 376 | }, | ||
| 377 | threadId, | ||
| 378 | ); | ||
| 379 | yield* native.write( | ||
| 380 | { | ||
| 381 | event: { | ||
| 382 | direction: "incoming", | ||
| 383 | stage: "raw", | ||
| 384 | get payload() { | ||
| 385 | throw new Error("raw frames must not be inspected"); | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | }, | ||
| 389 | threadId, | ||
| 390 | ); | ||
| 391 | yield* native.write( | ||
| 392 | { | ||
| 393 | event: { | ||
| 394 | type: "message.part.updated", | ||
| 395 | payload: { properties: { part: { type: "text" } } }, | ||
| 396 | }, | ||
| 397 | }, | ||
| 398 | threadId, | ||
| 399 | ); | ||
| 400 | yield* native.write( | ||
| 401 | { | ||
| 402 | event: { | ||
| 403 | type: "message.part.updated", | ||
| 404 | payload: { | ||
| 405 | properties: { | ||
| 406 | part: { type: "tool", state: { status: "running", output: "progress" } }, | ||
| 407 | }, | ||
| 408 | }, | ||
| 409 | }, | ||
| 410 | }, | ||
| 411 | threadId, | ||
| 412 | ); | ||
| 413 | yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); | ||
| 414 | yield* store.close(); | ||
| 415 | |||
| 416 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") | ||
| 417 | .trim() | ||
| 418 | .split("\n") | ||
| 419 | .map(parseLogLine); | ||
| 420 | |||
| 421 | assert.deepEqual( | ||
| 422 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 423 | [ | ||
| 424 | { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, | ||
| 425 | { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, | ||
| 426 | ], | ||
| 427 | ); | ||
| 428 | } finally { | ||
| 429 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 430 | } | ||
| 431 | }), | ||
| 432 | ); | ||
| 433 | |||
| 434 | it.effect("summarizes large histories without reading their items", () => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 437 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 438 | const turns = Array.from({ length: 10_000 }); | ||
| 439 | Object.defineProperty(turns, 0, { | ||
| 440 | get: () => { | ||
| 441 | throw new Error("history must not be serialized"); | ||
| 442 | }, | ||
| 443 | }); | ||
| 444 | |||
| 445 | try { | ||
| 446 | const store = yield* makeEventNdjsonLogStore(basePath); | ||
| 447 | yield* store.logger("native").write( | ||
| 448 | { | ||
| 449 | provider: "codex", | ||
| 450 | event: { | ||
| 451 | direction: "incoming", | ||
| 452 | stage: "decoded", | ||
| 453 | payload: { id: 42, result: { thread: { id: "native-thread", turns } } }, | ||
| 454 | }, | ||
| 455 | }, | ||
| 456 | ThreadId.make("large-history"), | ||
| 457 | ); | ||
| 458 | yield* store.close(); | ||
| 459 | |||
| 460 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8"); | ||
| 461 | assert.isBelow(Buffer.byteLength(contents), 2_048); | ||
| 462 | const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); | ||
| 463 | assert.nestedPropertyVal(record, "event.payload.id", 42); | ||
| 464 | assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread"); | ||
| 465 | assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000); | ||
| 466 | } finally { | ||
| 467 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 468 | } | ||
| 469 | }), | ||
| 470 | ); | ||
| 471 | |||
| 472 | it.effect("bounds oversized records while retaining failure details", () => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 475 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 476 | |||
| 477 | try { | ||
| 478 | const store = yield* makeEventNdjsonLogStore(basePath); | ||
| 479 | const logger = store.logger("native"); | ||
| 480 | const threadId = ThreadId.make("large-error"); | ||
| 481 | const failure = { | ||
| 482 | method: "error", | ||
| 483 | params: { | ||
| 484 | threadId: "native-thread", | ||
| 485 | turnId: "native-turn", | ||
| 486 | error: { message: "The provider is unavailable.", code: "overloaded" }, | ||
| 487 | output: "x".repeat(128 * 1_024), | ||
| 488 | }, | ||
| 489 | }; | ||
| 490 | yield* logger.write(failure, threadId); | ||
| 491 | yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId); | ||
| 492 | yield* store.close(); | ||
| 493 | |||
| 494 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8"); | ||
| 495 | const records = contents | ||
| 496 | .trim() | ||
| 497 | .split("\n") | ||
| 498 | .map((line) => decodeUnknownJson(parseLogLine(line).payload)); | ||
| 499 | assert.isBelow(Buffer.byteLength(contents), 64 * 1_024); | ||
| 500 | assert.equal(records.length, 2); | ||
| 501 | assert.nestedPropertyVal(records[0], "params.threadId", "native-thread"); | ||
| 502 | assert.nestedPropertyVal(records[0], "params.turnId", "native-turn"); | ||
| 503 | assert.nestedPropertyVal( | ||
| 504 | records[0], | ||
| 505 | "params.error.message", | ||
| 506 | "The provider is unavailable.", | ||
| 507 | ); | ||
| 508 | assert.nestedPropertyVal(records[0], "params.error.code", "overloaded"); | ||
| 509 | assert.propertyVal(records[1], "id", "escaped"); | ||
| 510 | } finally { | ||
| 511 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 512 | } | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | |||
| 516 | it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () => | ||
| 517 | Effect.gen(function* () { | ||
| 518 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 519 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 520 | const threadId = ThreadId.make("large-diff"); | ||
| 521 | const diff = "diff-payload".repeat(128 * 1_024); | ||
| 522 | try { | ||
| 523 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 524 | yield* store.logger("canonical").write( | ||
| 525 | { | ||
| 526 | type: "turn.diff.updated", | ||
| 527 | threadId, | ||
| 528 | turnId: "native-turn", | ||
| 529 | raw: { method: "turn/diff/updated", payload: { diff } }, | ||
| 530 | payload: { unifiedDiff: diff }, | ||
| 531 | }, | ||
| 532 | threadId, | ||
| 533 | ); | ||
| 534 | yield* store.close(); | ||
| 535 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8"); | ||
| 536 | assert.isBelow(Buffer.byteLength(contents), 2_048); | ||
| 537 | const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); | ||
| 538 | assert.propertyVal(record, "type", "turn.diff.updated"); | ||
| 539 | assert.propertyVal(record, "threadId", threadId); | ||
| 540 | assert.propertyVal(record, "turnId", "native-turn"); | ||
| 541 | } finally { | ||
| 542 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 543 | } | ||
| 544 | }), | ||
| 545 | ); | ||
| 546 | |||
| 547 | it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => | ||
| 548 | Effect.gen(function* () { | ||
| 549 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 550 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 551 | const threadId = ThreadId.make("thread-tool-lifecycle"); | ||
| 552 | |||
| 553 | try { | ||
| 554 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 555 | const native = store.logger("native"); | ||
| 556 | const input = { command: "run-build" }; | ||
| 557 | const states = [ | ||
| 558 | { status: "pending", input }, | ||
| 559 | { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, | ||
| 560 | { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, | ||
| 561 | { status: "unknown", input }, | ||
| 562 | null, | ||
| 563 | ]; | ||
| 564 | const events = states.map((state) => ({ | ||
| 565 | event: { | ||
| 566 | type: "message.part.updated", | ||
| 567 | payload: { properties: { part: { type: "tool", state } } }, | ||
| 568 | }, | ||
| 569 | })); | ||
| 570 | for (const event of events) { | ||
| 571 | yield* native.write(event, threadId); | ||
| 572 | } | ||
| 573 | yield* store.close(); | ||
| 574 | |||
| 575 | const payloads = NodeFS.readFileSync( | ||
| 576 | ownedLogPath(basePath, "thread-tool-lifecycle"), | ||
| 577 | "utf8", | ||
| 578 | ) | ||
| 579 | .trim() | ||
| 580 | .split("\n") | ||
| 581 | .map((line) => parseLogLine(line).payload); | ||
| 582 | assert.deepEqual( | ||
| 583 | payloads, | ||
| 584 | events.map((event) => encodeUnknownJson(event)), | ||
| 585 | ); | ||
| 586 | } finally { | ||
| 587 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 588 | } | ||
| 589 | }), | ||
| 590 | ); | ||
| 591 | |||
| 592 | it.effect("contains hostile event accessors inside guarded serialization", () => | ||
| 593 | Effect.gen(function* () { | ||
| 594 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 595 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 596 | |||
| 597 | try { | ||
| 598 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 599 | stream: "canonical", | ||
| 600 | batchWindowMs: 0, | ||
| 601 | }); | ||
| 602 | assert.exists(logger); | ||
| 603 | if (!logger) return; | ||
| 604 | const hostile = new Proxy( | ||
| 605 | { id: "hostile" }, | ||
| 606 | { | ||
| 607 | get(_target, property) { | ||
| 608 | if (property === "type") throw new Error("blocked"); | ||
| 609 | return undefined; | ||
| 610 | }, | ||
| 611 | }, | ||
| 612 | ); | ||
| 613 | |||
| 614 | yield* logger.write(hostile, ThreadId.make("thread-hostile")); | ||
| 615 | yield* logger.close(); | ||
| 616 | |||
| 617 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); | ||
| 618 | assert.notInclude(contents, "blocked"); | ||
| 619 | } finally { | ||
| 620 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 621 | } | ||
| 622 | }), | ||
| 623 | ); | ||
| 624 | |||
| 625 | it.effect("serializes concurrent first writes for the same segment", () => | ||
| 626 | Effect.gen(function* () { | ||
| 627 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 628 | const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); | ||
| 629 | |||
| 630 | try { | ||
| 631 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 632 | stream: "canonical", | ||
| 633 | batchWindowMs: 0, | ||
| 634 | }); | ||
| 635 | assert.notEqual(logger, undefined); | ||
| 636 | if (!logger) { | ||
| 637 | return; | ||
| 638 | } | ||
| 639 | |||
| 640 | yield* Effect.all( | ||
| 641 | [ | ||
| 642 | logger.write({ id: "evt-concurrent-1" }, null), | ||
| 643 | logger.write({ id: "evt-concurrent-2" }, null), | ||
| 644 | ], | ||
| 645 | { concurrency: "unbounded" }, | ||
| 646 | ); | ||
| 647 | yield* logger.close(); | ||
| 648 | |||
| 649 | const globalPath = ownedLogPath(basePath, "_global"); | ||
| 650 | assert.equal(NodeFS.existsSync(globalPath), true); | ||
| 651 | const lines = NodeFS.readFileSync(globalPath, "utf8") | ||
| 652 | .trim() | ||
| 653 | .split("\n") | ||
| 654 | .map((line) => parseLogLine(line)); | ||
| 655 | |||
| 656 | assert.equal(lines.length, 2); | ||
| 657 | assert.deepEqual(lines.map((line) => line.payload).toSorted(), [ | ||
| 658 | '{"id":"evt-concurrent-1"}', | ||
| 659 | '{"id":"evt-concurrent-2"}', | ||
| 660 | ]); | ||
| 661 | } finally { | ||
| 662 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 663 | } | ||
| 664 | }), | ||
| 665 | ); | ||
| 666 | |||
| 667 | it.effect("rotates per-thread files when max size is exceeded", () => | ||
| 668 | Effect.gen(function* () { | ||
| 669 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 670 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 671 | |||
| 672 | try { | ||
| 673 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 674 | maxBytes: 120, | ||
| 675 | maxFiles: 2, | ||
| 676 | batchWindowMs: 0, | ||
| 677 | }); | ||
| 678 | const native = store.logger("native"); | ||
| 679 | const canonical = store.logger("canonical"); | ||
| 680 | |||
| 681 | for (let index = 0; index < 10; index += 1) { | ||
| 682 | const logger = index % 2 === 0 ? native : canonical; | ||
| 683 | yield* logger.write( | ||
| 684 | { | ||
| 685 | type: "session.started", | ||
| 686 | threadId: "provider-thread-rotate", | ||
| 687 | id: `evt-${index}`, | ||
| 688 | payload: "x".repeat(40), | ||
| 689 | }, | ||
| 690 | ThreadId.make("thread-rotate"), | ||
| 691 | ); | ||
| 692 | } | ||
| 693 | yield* store.close(); | ||
| 694 | |||
| 695 | const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); | ||
| 696 | const matchingFiles = NodeFS.readdirSync(tempDir) | ||
| 697 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) | ||
| 698 | .toSorted(); | ||
| 699 | |||
| 700 | assert.equal( | ||
| 701 | matchingFiles.some((entry) => entry === `${fileStem}.1`), | ||
| 702 | true, | ||
| 703 | ); | ||
| 704 | assert.equal( | ||
| 705 | matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`), | ||
| 706 | true, | ||
| 707 | ); | ||
| 708 | assert.equal( | ||
| 709 | matchingFiles.some((entry) => entry === `${fileStem}.3`), | ||
| 710 | false, | ||
| 711 | ); | ||
| 712 | } finally { | ||
| 713 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 714 | } | ||
| 715 | }), | ||
| 716 | ); | ||
| 717 | |||
| 718 | it.effect("enforces aggregate age and byte retention on startup", () => | ||
| 719 | Effect.gen(function* () { | ||
| 720 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 721 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 722 | const expiredPath = ownedLogPath(basePath, "expired"); | ||
| 723 | const oldPath = ownedLogPath(basePath, "old"); | ||
| 724 | const newPath = ownedLogPath(basePath, "new"); | ||
| 725 | const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); | ||
| 726 | const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); | ||
| 727 | const ignoredPath = NodePath.join(tempDir, "ignored.txt"); | ||
| 728 | |||
| 729 | try { | ||
| 730 | yield* TestClock.setTime(1_800_000_000_000); | ||
| 731 | const now = yield* Clock.currentTimeMillis; | ||
| 732 | for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { | ||
| 733 | NodeFS.writeFileSync(filePath, "x".repeat(40)); | ||
| 734 | } | ||
| 735 | NodeFS.writeFileSync( | ||
| 736 | legacyLogPath, | ||
| 737 | "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", | ||
| 738 | ); | ||
| 739 | NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | ||
| 740 | NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | ||
| 741 | NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); | ||
| 742 | NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); | ||
| 743 | |||
| 744 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 745 | maxAgeMs: 10_000, | ||
| 746 | maxTotalBytes: 60, | ||
| 747 | }); | ||
| 748 | yield* store.close(); | ||
| 749 | |||
| 750 | assert.equal(NodeFS.existsSync(expiredPath), false); | ||
| 751 | assert.equal(NodeFS.existsSync(legacyLogPath), false); | ||
| 752 | assert.equal(NodeFS.existsSync(oldPath), false); | ||
| 753 | assert.equal(NodeFS.existsSync(newPath), true); | ||
| 754 | assert.equal(NodeFS.existsSync(unrelatedLogPath), true); | ||
| 755 | assert.equal(NodeFS.existsSync(ignoredPath), true); | ||
| 756 | } finally { | ||
| 757 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 758 | } | ||
| 759 | }), | ||
| 760 | ); | ||
| 761 | |||
| 762 | it.effect("does not prune an active thread sink during an unrelated flush", () => | ||
| 763 | Effect.gen(function* () { | ||
| 764 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 765 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 766 | const activePath = ownedLogPath(basePath, "active"); | ||
| 767 | |||
| 768 | try { | ||
| 769 | yield* TestClock.setTime(1_800_000_000_000); | ||
| 770 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 771 | batchWindowMs: 0, | ||
| 772 | maxAgeMs: 1, | ||
| 773 | retentionCheckIntervalMs: 1, | ||
| 774 | }); | ||
| 775 | const logger = store.logger("native"); | ||
| 776 | |||
| 777 | yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); | ||
| 778 | assert.equal(NodeFS.existsSync(activePath), true); | ||
| 779 | |||
| 780 | yield* TestClock.adjust("2 millis"); | ||
| 781 | yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); | ||
| 782 | |||
| 783 | assert.equal(NodeFS.existsSync(activePath), true); | ||
| 784 | yield* store.close(); | ||
| 785 | } finally { | ||
| 786 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 787 | } | ||
| 788 | }), | ||
| 789 | ); | ||
| 790 | |||
| 791 | it("attributes batches that were written before a later chunk fails", () => { | ||
| 792 | const records: ReadonlyArray<PendingRecord> = [ | ||
| 793 | { | ||
| 794 | stream: "native", | ||
| 795 | threadSegment: "thread", | ||
| 796 | line: "first", | ||
| 797 | bytes: 5, | ||
| 798 | }, | ||
| 799 | { | ||
| 800 | stream: "canonical", | ||
| 801 | threadSegment: "thread", | ||
| 802 | line: "second", | ||
| 803 | bytes: 6, | ||
| 804 | }, | ||
| 805 | ]; | ||
| 806 | const attributed: Array<PendingRecord> = []; | ||
| 807 | let writes = 0; | ||
| 808 | |||
| 809 | assert.throws(() => | ||
| 810 | writeBatchedMessages( | ||
| 811 | { | ||
| 812 | write: () => { | ||
| 813 | writes += 1; | ||
| 814 | if (writes === 2) throw new Error("simulated disk exhaustion"); | ||
| 815 | }, | ||
| 816 | }, | ||
| 817 | records, | ||
| 818 | 5, | ||
| 819 | (written) => attributed.push(...written), | ||
| 820 | ), | ||
| 821 | ); | ||
| 822 | assert.deepEqual(attributed, [records[0]]); | ||
| 823 | }); | ||
| 824 | |||
| 825 | it.effect("reports logical provider log writes to resource attribution", () => | ||
| 826 | Effect.gen(function* () { | ||
| 827 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 828 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 829 | |||
| 830 | try { | ||
| 831 | const attribution = yield* ResourceAttribution.make(); | ||
| 832 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 833 | stream: "native", | ||
| 834 | batchWindowMs: 0, | ||
| 835 | attribution, | ||
| 836 | }); | ||
| 837 | assert.notEqual(logger, undefined); | ||
| 838 | if (!logger) { | ||
| 839 | return; | ||
| 840 | } | ||
| 841 | |||
| 842 | yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); | ||
| 843 | yield* logger.close(); | ||
| 844 | |||
| 845 | const snapshot = yield* attribution.snapshot; | ||
| 846 | assert.equal(snapshot.entries.length, 1); | ||
| 847 | assert.equal(snapshot.entries[0]?.component, "provider-event-log"); | ||
| 848 | assert.equal(snapshot.entries[0]?.operation, "native.append"); | ||
| 849 | assert.equal(snapshot.entries[0]?.count, 1); | ||
| 850 | assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); | ||
| 851 | } finally { | ||
| 852 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 853 | } | ||
| 854 | }), | ||
| 855 | ); | ||
| 856 | }); | ||
apps/server/src/provider/EventNdjsonLogger.ts created+805| ... | @@ -0,0 +1,805 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | ||
| 3 | * Best-effort provider event logging with one shared writer per thread. | ||
| 4 | * | ||
| 5 | * Native and canonical views share batching, rotation, and retention state so | ||
| 6 | * they cannot race while appending to the same thread-scoped file. | ||
| 7 | */ | ||
| 8 | import * as NodeFS from "node:fs"; | ||
| 9 | import * as NodePath from "node:path"; | ||
| 10 | |||
| 11 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; | ||
| 13 | import { errorTag } from "@t3tools/shared/observability"; | ||
| 14 | import * as Clock from "effect/Clock"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Exit from "effect/Exit"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Scope from "effect/Scope"; | ||
| 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 21 | |||
| 22 | import { toSafeThreadAttachmentSegment } from "../attachmentStore.ts"; | ||
| 23 | import type { ResourceAttribution } from "../resourceTelemetry/ResourceAttribution.ts"; | ||
| 24 | |||
| 25 | const MEBIBYTE = 1024 * 1024; | ||
| 26 | const DAY_MS = 24 * 60 * 60 * 1_000; | ||
| 27 | const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; | ||
| 28 | const DEFAULT_MAX_FILES = 10; | ||
| 29 | const DEFAULT_BATCH_WINDOW_MS = 1_000; | ||
| 30 | const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; | ||
| 31 | const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; | ||
| 32 | const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; | ||
| 33 | const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; | ||
| 34 | const DEFAULT_MAX_BUFFERED_RECORDS = 512; | ||
| 35 | const MAX_RECORD_CHARACTERS = 64 * 1024; | ||
| 36 | const MAX_RECORD_FIELDS = 1_024; | ||
| 37 | const MAX_RECORD_DEPTH = 16; | ||
| 38 | const GLOBAL_THREAD_SEGMENT = "_global"; | ||
| 39 | const LOG_SCOPE = "provider-observability"; | ||
| 40 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 41 | |||
| 42 | const transientCanonicalEventTypes = new Set([ | ||
| 43 | "content.delta", | ||
| 44 | "hook.progress", | ||
| 45 | "item.updated", | ||
| 46 | "task.progress", | ||
| 47 | "thread.realtime.audio.delta", | ||
| 48 | "tool.progress", | ||
| 49 | "turn.proposed.delta", | ||
| 50 | ]); | ||
| 51 | const transientNativeMethods = new Set([ | ||
| 52 | "item/agentMessage/delta", | ||
| 53 | "item/commandExecution/outputDelta", | ||
| 54 | "item/fileChange/outputDelta", | ||
| 55 | "item/plan/delta", | ||
| 56 | "item/reasoning/summaryTextDelta", | ||
| 57 | "item/reasoning/textDelta", | ||
| 58 | "thread/realtime/outputAudio/delta", | ||
| 59 | "thread/realtime/transcript/delta", | ||
| 60 | "turn/diff/updated", | ||
| 61 | ]); | ||
| 62 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | ||
| 63 | |||
| 64 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; | ||
| 65 | |||
| 66 | export interface EventNdjsonLogger { | ||
| 67 | readonly filePath: string; | ||
| 68 | readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>; | ||
| 69 | readonly close: () => Effect.Effect<void>; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface EventNdjsonLogStore { | ||
| 73 | readonly filePath: string; | ||
| 74 | readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; | ||
| 75 | readonly close: () => Effect.Effect<void>; | ||
| 76 | } | ||
| 77 | |||
| 78 | export interface EventNdjsonLogStoreOptions { | ||
| 79 | readonly maxBytes?: number; | ||
| 80 | readonly maxFiles?: number; | ||
| 81 | readonly batchWindowMs?: number; | ||
| 82 | readonly maxTotalBytes?: number; | ||
| 83 | readonly maxAgeMs?: number; | ||
| 84 | readonly retentionCheckIntervalMs?: number; | ||
| 85 | readonly maxBufferedBytes?: number; | ||
| 86 | readonly maxBufferedRecords?: number; | ||
| 87 | readonly attribution?: ResourceAttribution["Service"]; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { | ||
| 91 | readonly stream: EventNdjsonStream; | ||
| 92 | } | ||
| 93 | |||
| 94 | export class EventNdjsonLogConfigurationError extends Schema.TaggedError<EventNdjsonLogConfigurationError>()( | ||
| 95 | "EventNdjsonLogConfigurationError", | ||
| 96 | { | ||
| 97 | filePath: Schema.String, | ||
| 98 | option: Schema.String, | ||
| 99 | value: Schema.Number, | ||
| 100 | minimum: Schema.Number, | ||
| 101 | }, | ||
| 102 | ) { | ||
| 103 | override get message(): string { | ||
| 104 | return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | export class EventNdjsonLogDirectoryError extends Schema.TaggedError<EventNdjsonLogDirectoryError>()( | ||
| 109 | "EventNdjsonLogDirectoryError", | ||
| 110 | { | ||
| 111 | directory: Schema.String, | ||
| 112 | cause: Schema.Defect(), | ||
| 113 | }, | ||
| 114 | ) { | ||
| 115 | override get message(): string { | ||
| 116 | return `Failed to create provider event log directory '${this.directory}'`; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | export type EventNdjsonLogStoreError = | ||
| 121 | | EventNdjsonLogConfigurationError | ||
| 122 | | EventNdjsonLogDirectoryError; | ||
| 123 | |||
| 124 | interface ResolvedOptions { | ||
| 125 | readonly maxBytes: number; | ||
| 126 | readonly maxFiles: number; | ||
| 127 | readonly batchWindowMs: number; | ||
| 128 | readonly maxTotalBytes: number; | ||
| 129 | readonly maxAgeMs: number; | ||
| 130 | readonly retentionCheckIntervalMs: number; | ||
| 131 | readonly maxBufferedBytes: number; | ||
| 132 | readonly maxBufferedRecords: number; | ||
| 133 | readonly attribution: ResourceAttribution["Service"] | undefined; | ||
| 134 | } | ||
| 135 | |||
| 136 | export interface PendingRecord { | ||
| 137 | readonly stream: EventNdjsonStream; | ||
| 138 | readonly threadSegment: string; | ||
| 139 | readonly line: string; | ||
| 140 | readonly bytes: number; | ||
| 141 | } | ||
| 142 | |||
| 143 | interface StoreState { | ||
| 144 | readonly pending: Array<PendingRecord>; | ||
| 145 | readonly pendingBytes: number; | ||
| 146 | readonly sinks: ReadonlyMap<string, RotatingFileSink>; | ||
| 147 | readonly flushScheduled: boolean; | ||
| 148 | readonly closed: boolean; | ||
| 149 | readonly lastRetentionAt: number; | ||
| 150 | } | ||
| 151 | |||
| 152 | interface AttributionSummary { | ||
| 153 | readonly stream: EventNdjsonStream; | ||
| 154 | readonly count: number; | ||
| 155 | readonly logicalWriteBytes: number; | ||
| 156 | } | ||
| 157 | |||
| 158 | interface FileOperationFailure { | ||
| 159 | readonly filePath: string; | ||
| 160 | readonly cause: unknown; | ||
| 161 | } | ||
| 162 | |||
| 163 | interface RetentionResult { | ||
| 164 | readonly failures: ReadonlyArray<FileOperationFailure>; | ||
| 165 | } | ||
| 166 | |||
| 167 | interface DrainResult { | ||
| 168 | readonly attributions: ReadonlyArray<AttributionSummary>; | ||
| 169 | readonly failures: ReadonlyArray<FileOperationFailure>; | ||
| 170 | } | ||
| 171 | |||
| 172 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { | ||
| 173 | return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); | ||
| 174 | } | ||
| 175 | |||
| 176 | function resolveThreadSegment(raw: string | null | undefined): string { | ||
| 177 | const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; | ||
| 178 | return normalized ?? GLOBAL_THREAD_SEGMENT; | ||
| 179 | } | ||
| 180 | |||
| 181 | function resolveStreamLabel(stream: EventNdjsonStream): string { | ||
| 182 | return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; | ||
| 183 | } | ||
| 184 | |||
| 185 | function providerLogPrefix(filePath: string): string { | ||
| 186 | const basename = NodePath.basename(filePath); | ||
| 187 | const extension = NodePath.extname(basename); | ||
| 188 | return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; | ||
| 189 | } | ||
| 190 | |||
| 191 | function providerLogPath(directory: string, prefix: string, threadSegment: string): string { | ||
| 192 | return NodePath.join(directory, `${prefix}${threadSegment}.log`); | ||
| 193 | } | ||
| 194 | |||
| 195 | export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean { | ||
| 196 | if (stream === "orchestration" || typeof event !== "object" || event === null) { | ||
| 197 | return true; | ||
| 198 | } | ||
| 199 | try { | ||
| 200 | const type = Reflect.get(event, "type"); | ||
| 201 | if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { | ||
| 202 | return false; | ||
| 203 | } | ||
| 204 | if (stream !== "native") return true; | ||
| 205 | |||
| 206 | const nested = Reflect.get(event, "event"); | ||
| 207 | const envelope = typeof nested === "object" && nested !== null ? nested : event; | ||
| 208 | // Decoded frames carry the same information as raw frames without another | ||
| 209 | // copy of every token delta. Decode failures have their own diagnostic frame. | ||
| 210 | if (Reflect.get(envelope, "stage") === "raw") return false; | ||
| 211 | const decodedPayload = Reflect.get(envelope, "payload"); | ||
| 212 | const nativeEvent = | ||
| 213 | Reflect.get(envelope, "stage") === "decoded" && | ||
| 214 | typeof decodedPayload === "object" && | ||
| 215 | decodedPayload !== null | ||
| 216 | ? decodedPayload | ||
| 217 | : envelope; | ||
| 218 | const method = Reflect.get(nativeEvent, "method"); | ||
| 219 | if ( | ||
| 220 | typeof method === "string" && | ||
| 221 | (transientNativeMethods.has(method) || | ||
| 222 | method.startsWith("claude/stream_event/content_block_delta/")) | ||
| 223 | ) { | ||
| 224 | return false; | ||
| 225 | } | ||
| 226 | |||
| 227 | const nativeType = Reflect.get(nativeEvent, "type"); | ||
| 228 | if (nativeType === "message.part.delta") return false; | ||
| 229 | if (nativeType === "stream_event") { | ||
| 230 | const streamEvent = Reflect.get(nativeEvent, "event"); | ||
| 231 | if ( | ||
| 232 | typeof streamEvent === "object" && | ||
| 233 | streamEvent !== null && | ||
| 234 | Reflect.get(streamEvent, "type") === "content_block_delta" | ||
| 235 | ) { | ||
| 236 | return false; | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | const payload = Reflect.get(nativeEvent, "payload"); | ||
| 241 | if (typeof payload !== "object" || payload === null) return true; | ||
| 242 | |||
| 243 | if (method === "session/update") { | ||
| 244 | const update = Reflect.get(payload, "update"); | ||
| 245 | if (typeof update !== "object" || update === null) return true; | ||
| 246 | const updateType = Reflect.get(update, "sessionUpdate"); | ||
| 247 | return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); | ||
| 248 | } | ||
| 249 | |||
| 250 | if (nativeType === "message.part.updated") { | ||
| 251 | const properties = Reflect.get(payload, "properties"); | ||
| 252 | if (typeof properties !== "object" || properties === null) return true; | ||
| 253 | const part = Reflect.get(properties, "part"); | ||
| 254 | if (typeof part !== "object" || part === null) return true; | ||
| 255 | const partType = Reflect.get(part, "type"); | ||
| 256 | if (partType === "text" || partType === "reasoning") return false; | ||
| 257 | if (partType === "tool") { | ||
| 258 | // Running snapshots repeat growing output. Pending and terminal states stay in the log. | ||
| 259 | const state = Reflect.get(part, "state"); | ||
| 260 | if (typeof state === "object" && state !== null) { | ||
| 261 | return Reflect.get(state, "status") !== "running"; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | return true; | ||
| 265 | } | ||
| 266 | |||
| 267 | return true; | ||
| 268 | } catch { | ||
| 269 | return true; | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | const summaryFields = [ | ||
| 274 | "provider", | ||
| 275 | "protocol", | ||
| 276 | "kind", | ||
| 277 | "providerSessionId", | ||
| 278 | "direction", | ||
| 279 | "stage", | ||
| 280 | "type", | ||
| 281 | "subtype", | ||
| 282 | "method", | ||
| 283 | "id", | ||
| 284 | "threadId", | ||
| 285 | "turnId", | ||
| 286 | "requestId", | ||
| 287 | "session_id", | ||
| 288 | "status", | ||
| 289 | "is_error", | ||
| 290 | "api_error_status", | ||
| 291 | "terminal_reason", | ||
| 292 | "stop_reason", | ||
| 293 | "operation", | ||
| 294 | "code", | ||
| 295 | "willRetry", | ||
| 296 | "message", | ||
| 297 | "event", | ||
| 298 | "payload", | ||
| 299 | "params", | ||
| 300 | "result", | ||
| 301 | "thread", | ||
| 302 | "turn", | ||
| 303 | "error", | ||
| 304 | "turns", | ||
| 305 | "items", | ||
| 306 | "content", | ||
| 307 | ] as const; | ||
| 308 | |||
| 309 | function summarizeProviderEvent(event: unknown): unknown { | ||
| 310 | let remainingFields = 128; | ||
| 311 | let remainingCharacters = 8 * 1024; | ||
| 312 | const summarize = (value: unknown, depth: number): unknown => { | ||
| 313 | if (typeof value === "string") { | ||
| 314 | if (value.length > Math.min(1_024, remainingCharacters)) { | ||
| 315 | return { omittedCharacters: value.length }; | ||
| 316 | } | ||
| 317 | remainingCharacters -= value.length; | ||
| 318 | return value; | ||
| 319 | } | ||
| 320 | if (value === null || typeof value === "number" || typeof value === "boolean") return value; | ||
| 321 | if (typeof value !== "object") return undefined; | ||
| 322 | if (Array.isArray(value)) return { itemCount: value.length }; | ||
| 323 | const summary: Record<string, unknown> = { truncated: true }; | ||
| 324 | if (depth >= 6) return summary; | ||
| 325 | for (const key of summaryFields) { | ||
| 326 | if (remainingFields <= 0) break; | ||
| 327 | const nested = Reflect.get(value, key); | ||
| 328 | if (nested === undefined) continue; | ||
| 329 | remainingFields -= 1; | ||
| 330 | summary[key] = summarize(nested, depth + 1); | ||
| 331 | } | ||
| 332 | return summary; | ||
| 333 | }; | ||
| 334 | try { | ||
| 335 | return summarize(event, 0); | ||
| 336 | } catch { | ||
| 337 | return { truncated: true }; | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | /** Bounds traversal before adapters copy payloads or the logger encodes them. */ | ||
| 342 | export function boundProviderEventForLogging(event: unknown): unknown { | ||
| 343 | let remainingCharacters = MAX_RECORD_CHARACTERS; | ||
| 344 | let remainingFields = MAX_RECORD_FIELDS; | ||
| 345 | const ancestors = new WeakSet<object>(); | ||
| 346 | const fits = (value: unknown, depth: number): boolean => { | ||
| 347 | if (typeof value === "string") { | ||
| 348 | remainingCharacters -= value.length; | ||
| 349 | return remainingCharacters >= 0; | ||
| 350 | } | ||
| 351 | if (typeof value !== "object" || value === null) return true; | ||
| 352 | if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false; | ||
| 353 | if (Array.isArray(value) && value.length > remainingFields) return false; | ||
| 354 | ancestors.add(value); | ||
| 355 | for (const key in value) { | ||
| 356 | if (!Object.hasOwn(value, key)) continue; | ||
| 357 | remainingFields -= 1; | ||
| 358 | remainingCharacters -= key.length; | ||
| 359 | if ( | ||
| 360 | remainingFields < 0 || | ||
| 361 | remainingCharacters < 0 || | ||
| 362 | !fits(Reflect.get(value, key), depth + 1) | ||
| 363 | ) | ||
| 364 | return false; | ||
| 365 | } | ||
| 366 | ancestors.delete(value); | ||
| 367 | return true; | ||
| 368 | }; | ||
| 369 | try { | ||
| 370 | if (fits(event, 0)) return event; | ||
| 371 | } catch { | ||
| 372 | // A failing accessor must not escape into provider processing. | ||
| 373 | } | ||
| 374 | return summarizeProviderEvent(event); | ||
| 375 | } | ||
| 376 | |||
| 377 | export function writeBatchedMessages( | ||
| 378 | sink: Pick<RotatingFileSink, "write">, | ||
| 379 | records: ReadonlyArray<PendingRecord>, | ||
| 380 | maxBytes: number, | ||
| 381 | onWritten: (records: ReadonlyArray<PendingRecord>) => void, | ||
| 382 | ): void { | ||
| 383 | let pendingRecords: Array<PendingRecord> = []; | ||
| 384 | let pendingBytes = 0; | ||
| 385 | |||
| 386 | const flush = () => { | ||
| 387 | if (pendingRecords.length === 0) return; | ||
| 388 | const writtenRecords = pendingRecords; | ||
| 389 | sink.write(writtenRecords.map((record) => record.line).join("")); | ||
| 390 | onWritten(writtenRecords); | ||
| 391 | pendingRecords = []; | ||
| 392 | pendingBytes = 0; | ||
| 393 | }; | ||
| 394 | |||
| 395 | for (const record of records) { | ||
| 396 | if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { | ||
| 397 | flush(); | ||
| 398 | } | ||
| 399 | pendingRecords.push(record); | ||
| 400 | pendingBytes += record.bytes; | ||
| 401 | if (pendingBytes >= maxBytes) { | ||
| 402 | flush(); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | flush(); | ||
| 406 | } | ||
| 407 | |||
| 408 | function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { | ||
| 409 | if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; | ||
| 410 | if (fileName.startsWith(filePrefix)) return true; | ||
| 411 | |||
| 412 | const descriptor = NodeFS.openSync(filePath, "r"); | ||
| 413 | try { | ||
| 414 | const header = Buffer.alloc(256); | ||
| 415 | const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); | ||
| 416 | return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead)); | ||
| 417 | } finally { | ||
| 418 | NodeFS.closeSync(descriptor); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | function enforceRetention(input: { | ||
| 423 | readonly directory: string; | ||
| 424 | readonly maxTotalBytes: number; | ||
| 425 | readonly maxAgeMs: number; | ||
| 426 | readonly activeFilePaths: ReadonlySet<string>; | ||
| 427 | readonly filePrefix: string; | ||
| 428 | readonly now: number; | ||
| 429 | }): RetentionResult { | ||
| 430 | const failures: Array<FileOperationFailure> = []; | ||
| 431 | const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; | ||
| 432 | |||
| 433 | let entries: ReadonlyArray<NodeFS.Dirent>; | ||
| 434 | try { | ||
| 435 | entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); | ||
| 436 | } catch (cause) { | ||
| 437 | return { failures: [{ filePath: input.directory, cause }] }; | ||
| 438 | } | ||
| 439 | |||
| 440 | for (const entry of entries) { | ||
| 441 | if (!entry.isFile()) continue; | ||
| 442 | const filePath = NodePath.join(input.directory, entry.name); | ||
| 443 | try { | ||
| 444 | if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; | ||
| 445 | const stat = NodeFS.statSync(filePath); | ||
| 446 | files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); | ||
| 447 | } catch (cause) { | ||
| 448 | failures.push({ filePath, cause }); | ||
| 449 | } | ||
| 450 | } | ||
| 451 | |||
| 452 | let totalBytes = files.reduce((total, file) => total + file.size, 0); | ||
| 453 | const remove = (file: (typeof files)[number]) => { | ||
| 454 | if (input.activeFilePaths.has(file.filePath)) return false; | ||
| 455 | try { | ||
| 456 | NodeFS.rmSync(file.filePath, { force: true }); | ||
| 457 | totalBytes -= file.size; | ||
| 458 | return true; | ||
| 459 | } catch (cause) { | ||
| 460 | failures.push({ filePath: file.filePath, cause }); | ||
| 461 | return false; | ||
| 462 | } | ||
| 463 | }; | ||
| 464 | |||
| 465 | const retained = files.filter((file) => { | ||
| 466 | if (input.now - file.mtimeMs <= input.maxAgeMs) return true; | ||
| 467 | return !remove(file); | ||
| 468 | }); | ||
| 469 | |||
| 470 | for (const file of retained.toSorted( | ||
| 471 | (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), | ||
| 472 | )) { | ||
| 473 | if (totalBytes <= input.maxTotalBytes) break; | ||
| 474 | remove(file); | ||
| 475 | } | ||
| 476 | |||
| 477 | return { failures }; | ||
| 478 | } | ||
| 479 | |||
| 480 | function validateOption(input: { | ||
| 481 | readonly filePath: string; | ||
| 482 | readonly option: string; | ||
| 483 | readonly value: number; | ||
| 484 | readonly minimum: number; | ||
| 485 | }): EventNdjsonLogConfigurationError | undefined { | ||
| 486 | if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; | ||
| 487 | return new EventNdjsonLogConfigurationError(input); | ||
| 488 | } | ||
| 489 | |||
| 490 | function resolveOptions( | ||
| 491 | filePath: string, | ||
| 492 | options: EventNdjsonLogStoreOptions, | ||
| 493 | ): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> { | ||
| 494 | const resolved = { | ||
| 495 | maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, | ||
| 496 | maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, | ||
| 497 | batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, | ||
| 498 | maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, | ||
| 499 | maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, | ||
| 500 | retentionCheckIntervalMs: | ||
| 501 | options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, | ||
| 502 | maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, | ||
| 503 | maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, | ||
| 504 | attribution: options.attribution, | ||
| 505 | } satisfies ResolvedOptions; | ||
| 506 | |||
| 507 | const validations = [ | ||
| 508 | ["maxBytes", resolved.maxBytes, 1], | ||
| 509 | ["maxFiles", resolved.maxFiles, 1], | ||
| 510 | ["batchWindowMs", resolved.batchWindowMs, 0], | ||
| 511 | ["maxTotalBytes", resolved.maxTotalBytes, 1], | ||
| 512 | ["maxAgeMs", resolved.maxAgeMs, 1], | ||
| 513 | ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], | ||
| 514 | ["maxBufferedBytes", resolved.maxBufferedBytes, 1], | ||
| 515 | ["maxBufferedRecords", resolved.maxBufferedRecords, 1], | ||
| 516 | ] as const; | ||
| 517 | |||
| 518 | for (const [option, value, minimum] of validations) { | ||
| 519 | const error = validateOption({ filePath, option, value, minimum }); | ||
| 520 | if (error) return Effect.fail(error); | ||
| 521 | } | ||
| 522 | return Effect.succeed(resolved); | ||
| 523 | } | ||
| 524 | |||
| 525 | function drainPending(input: { | ||
| 526 | readonly directory: string; | ||
| 527 | readonly options: ResolvedOptions; | ||
| 528 | readonly state: StoreState; | ||
| 529 | readonly filePrefix: string; | ||
| 530 | readonly now: number; | ||
| 531 | readonly timerFired: boolean; | ||
| 532 | readonly close: boolean; | ||
| 533 | }): readonly [DrainResult, StoreState] { | ||
| 534 | if (input.state.closed) { | ||
| 535 | return [{ attributions: [], failures: [] }, input.state]; | ||
| 536 | } | ||
| 537 | |||
| 538 | const sinks = new Map(input.state.sinks); | ||
| 539 | const failures: Array<FileOperationFailure> = []; | ||
| 540 | const attributionByStream = new Map< | ||
| 541 | EventNdjsonStream, | ||
| 542 | { count: number; logicalWriteBytes: number } | ||
| 543 | >(); | ||
| 544 | const recordsBySegment = new Map<string, Array<PendingRecord>>(); | ||
| 545 | |||
| 546 | for (const record of input.state.pending) { | ||
| 547 | const records = recordsBySegment.get(record.threadSegment) ?? []; | ||
| 548 | records.push(record); | ||
| 549 | recordsBySegment.set(record.threadSegment, records); | ||
| 550 | } | ||
| 551 | |||
| 552 | for (const [threadSegment, records] of recordsBySegment) { | ||
| 553 | const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); | ||
| 554 | let sink = sinks.get(threadSegment); | ||
| 555 | if (!sink) { | ||
| 556 | try { | ||
| 557 | sink = new RotatingFileSink({ | ||
| 558 | filePath, | ||
| 559 | maxBytes: input.options.maxBytes, | ||
| 560 | maxFiles: input.options.maxFiles, | ||
| 561 | throwOnError: true, | ||
| 562 | }); | ||
| 563 | sinks.set(threadSegment, sink); | ||
| 564 | } catch (cause) { | ||
| 565 | failures.push({ filePath, cause }); | ||
| 566 | continue; | ||
| 567 | } | ||
| 568 | } | ||
| 569 | |||
| 570 | try { | ||
| 571 | writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { | ||
| 572 | for (const record of writtenRecords) { | ||
| 573 | const current = attributionByStream.get(record.stream) ?? { | ||
| 574 | count: 0, | ||
| 575 | logicalWriteBytes: 0, | ||
| 576 | }; | ||
| 577 | attributionByStream.set(record.stream, { | ||
| 578 | count: current.count + 1, | ||
| 579 | logicalWriteBytes: current.logicalWriteBytes + record.bytes, | ||
| 580 | }); | ||
| 581 | } | ||
| 582 | }); | ||
| 583 | } catch (cause) { | ||
| 584 | sinks.delete(threadSegment); | ||
| 585 | failures.push({ filePath, cause }); | ||
| 586 | } | ||
| 587 | } | ||
| 588 | |||
| 589 | const retentionDue = | ||
| 590 | input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; | ||
| 591 | const retention = retentionDue | ||
| 592 | ? enforceRetention({ | ||
| 593 | directory: input.directory, | ||
| 594 | maxTotalBytes: input.options.maxTotalBytes, | ||
| 595 | maxAgeMs: input.options.maxAgeMs, | ||
| 596 | activeFilePaths: new Set( | ||
| 597 | Array.from(sinks.keys(), (threadSegment) => | ||
| 598 | providerLogPath(input.directory, input.filePrefix, threadSegment), | ||
| 599 | ), | ||
| 600 | ), | ||
| 601 | filePrefix: input.filePrefix, | ||
| 602 | now: input.now, | ||
| 603 | }) | ||
| 604 | : { failures: [] }; | ||
| 605 | |||
| 606 | return [ | ||
| 607 | { | ||
| 608 | attributions: Array.from(attributionByStream, ([stream, value]) => ({ | ||
| 609 | stream, | ||
| 610 | ...value, | ||
| 611 | })), | ||
| 612 | failures: [...failures, ...retention.failures], | ||
| 613 | }, | ||
| 614 | { | ||
| 615 | pending: [], | ||
| 616 | pendingBytes: 0, | ||
| 617 | sinks, | ||
| 618 | flushScheduled: input.timerFired ? false : input.state.flushScheduled, | ||
| 619 | closed: input.close, | ||
| 620 | lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, | ||
| 621 | }, | ||
| 622 | ]; | ||
| 623 | } | ||
| 624 | |||
| 625 | const serializeEvent = Effect.fnUntraced(function* (event: unknown) { | ||
| 626 | return yield* encodeUnknownJsonString(event).pipe( | ||
| 627 | Effect.catch((error) => | ||
| 628 | logWarning("failed to serialize provider event log record", { | ||
| 629 | errorTag: errorTag(error), | ||
| 630 | }).pipe(Effect.as(undefined)), | ||
| 631 | ), | ||
| 632 | ); | ||
| 633 | }); | ||
| 634 | |||
| 635 | export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( | ||
| 636 | filePath: string, | ||
| 637 | options: EventNdjsonLogStoreOptions = {}, | ||
| 638 | ): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> { | ||
| 639 | const resolved = yield* resolveOptions(filePath, options); | ||
| 640 | const directory = NodePath.dirname(filePath); | ||
| 641 | const filePrefix = providerLogPrefix(filePath); | ||
| 642 | |||
| 643 | yield* Effect.try({ | ||
| 644 | try: () => NodeFS.mkdirSync(directory, { recursive: true }), | ||
| 645 | catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), | ||
| 646 | }); | ||
| 647 | |||
| 648 | const initializedAt = yield* Clock.currentTimeMillis; | ||
| 649 | const initialRetention = yield* Effect.sync(() => | ||
| 650 | enforceRetention({ | ||
| 651 | directory, | ||
| 652 | maxTotalBytes: resolved.maxTotalBytes, | ||
| 653 | maxAgeMs: resolved.maxAgeMs, | ||
| 654 | activeFilePaths: new Set(), | ||
| 655 | filePrefix, | ||
| 656 | now: initializedAt, | ||
| 657 | }), | ||
| 658 | ); | ||
| 659 | for (const failure of initialRetention.failures) { | ||
| 660 | yield* logWarning("provider event log retention failed", { | ||
| 661 | filePath: failure.filePath, | ||
| 662 | errorTag: errorTag(failure.cause), | ||
| 663 | }); | ||
| 664 | } | ||
| 665 | |||
| 666 | const stateRef = yield* SynchronizedRef.make<StoreState>({ | ||
| 667 | pending: [], | ||
| 668 | pendingBytes: 0, | ||
| 669 | sinks: new Map(), | ||
| 670 | flushScheduled: false, | ||
| 671 | closed: false, | ||
| 672 | lastRetentionAt: initializedAt, | ||
| 673 | }); | ||
| 674 | const timerScope = yield* Scope.make(); | ||
| 675 | |||
| 676 | const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { | ||
| 677 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 678 | const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => | ||
| 679 | Effect.sync(() => | ||
| 680 | drainPending({ | ||
| 681 | directory, | ||
| 682 | options: resolved, | ||
| 683 | state, | ||
| 684 | filePrefix, | ||
| 685 | now: startedAt, | ||
| 686 | timerFired, | ||
| 687 | close, | ||
| 688 | }), | ||
| 689 | ), | ||
| 690 | ); | ||
| 691 | |||
| 692 | for (const failure of result.failures) { | ||
| 693 | yield* logWarning("provider event log write or retention failed", { | ||
| 694 | filePath: failure.filePath, | ||
| 695 | errorTag: errorTag(failure.cause), | ||
| 696 | }); | ||
| 697 | } | ||
| 698 | |||
| 699 | if (resolved.attribution && result.attributions.length > 0) { | ||
| 700 | const completedAt = yield* Clock.currentTimeMillis; | ||
| 701 | const durationMs = Math.max(0, completedAt - startedAt); | ||
| 702 | const totalBytes = result.attributions.reduce( | ||
| 703 | (total, entry) => total + entry.logicalWriteBytes, | ||
| 704 | 0, | ||
| 705 | ); | ||
| 706 | yield* Effect.forEach( | ||
| 707 | result.attributions, | ||
| 708 | (entry) => | ||
| 709 | resolved.attribution?.record({ | ||
| 710 | component: "provider-event-log", | ||
| 711 | operation: `${entry.stream}.append`, | ||
| 712 | logicalWriteBytes: entry.logicalWriteBytes, | ||
| 713 | count: entry.count, | ||
| 714 | durationMs: | ||
| 715 | totalBytes === 0 | ||
| 716 | ? 0 | ||
| 717 | : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), | ||
| 718 | }) ?? Effect.void, | ||
| 719 | { discard: true }, | ||
| 720 | ); | ||
| 721 | } | ||
| 722 | }); | ||
| 723 | |||
| 724 | const scheduleFlush = Effect.fnUntraced(function* () { | ||
| 725 | yield* Effect.forkIn( | ||
| 726 | Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), | ||
| 727 | timerScope, | ||
| 728 | { startImmediately: true }, | ||
| 729 | ); | ||
| 730 | }); | ||
| 731 | |||
| 732 | const close = Effect.fnUntraced(function* () { | ||
| 733 | yield* flush(false, true); | ||
| 734 | yield* Scope.close(timerScope, Exit.void); | ||
| 735 | }); | ||
| 736 | |||
| 737 | const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>(); | ||
| 738 | const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { | ||
| 739 | const existing = loggerViews.get(stream); | ||
| 740 | if (existing) return existing; | ||
| 741 | |||
| 742 | const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { | ||
| 743 | if (!shouldPersistProviderEvent(stream, event)) return; | ||
| 744 | let payload = yield* serializeEvent(boundProviderEventForLogging(event)); | ||
| 745 | if (payload === undefined) return; | ||
| 746 | // Escaping can expand strings beyond their input size. Keep that bounded | ||
| 747 | // serialization out of the file too, while retaining routing/error fields. | ||
| 748 | if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) { | ||
| 749 | payload = yield* serializeEvent(summarizeProviderEvent(event)); | ||
| 750 | if (payload === undefined) return; | ||
| 751 | } | ||
| 752 | |||
| 753 | const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); | ||
| 754 | const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; | ||
| 755 | const bytes = Buffer.byteLength(line); | ||
| 756 | const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | ||
| 757 | if (state.closed) { | ||
| 758 | return Effect.succeed([{ flush: false }, state] as const); | ||
| 759 | } | ||
| 760 | const pending = state.pending; | ||
| 761 | pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); | ||
| 762 | const pendingBytes = state.pendingBytes + bytes; | ||
| 763 | const flush = | ||
| 764 | resolved.batchWindowMs === 0 || | ||
| 765 | pending.length >= resolved.maxBufferedRecords || | ||
| 766 | pendingBytes >= resolved.maxBufferedBytes; | ||
| 767 | const schedule = !flush && !state.flushScheduled; | ||
| 768 | const nextState = { | ||
| 769 | ...state, | ||
| 770 | pending, | ||
| 771 | pendingBytes, | ||
| 772 | flushScheduled: state.flushScheduled || schedule, | ||
| 773 | }; | ||
| 774 | return (schedule ? scheduleFlush() : Effect.void).pipe( | ||
| 775 | Effect.as([{ flush }, nextState] as const), | ||
| 776 | ); | ||
| 777 | }).pipe(Effect.uninterruptible); | ||
| 778 | |||
| 779 | if (action.flush) { | ||
| 780 | yield* flush(false, false); | ||
| 781 | } | ||
| 782 | }); | ||
| 783 | |||
| 784 | const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; | ||
| 785 | loggerViews.set(stream, view); | ||
| 786 | return view; | ||
| 787 | }; | ||
| 788 | |||
| 789 | return { filePath, logger, close } satisfies EventNdjsonLogStore; | ||
| 790 | }); | ||
| 791 | |||
| 792 | export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( | ||
| 793 | filePath: string, | ||
| 794 | options: EventNdjsonLoggerOptions, | ||
| 795 | ): Effect.fn.Return<EventNdjsonLogger | undefined> { | ||
| 796 | const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( | ||
| 797 | Effect.catch((error) => | ||
| 798 | logWarning(error.message, { error }).pipe( | ||
| 799 | Effect.as<EventNdjsonLogStore | undefined>(undefined), | ||
| 800 | ), | ||
| 801 | ), | ||
| 802 | ); | ||
| 803 | if (!store) return undefined; | ||
| 804 | return { ...store.logger(options.stream), close: store.close }; | ||
| 805 | }); | ||
apps/server/src/provider/GrokProvider.test.ts created+845| ... | @@ -0,0 +1,845 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import * as NodeURL from "node:url"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 11 | import { GrokSettings } from "@t3tools/contracts"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | buildGrokModelCapabilities, | ||
| 15 | buildGrokModelsFromSessionModelState, | ||
| 16 | buildInitialGrokProviderSnapshot, | ||
| 17 | checkGrokProviderStatus, | ||
| 18 | grokSlashCommandsFromInitialize, | ||
| 19 | parseGrokModelsCliOutput, | ||
| 20 | } from "./GrokProvider.ts"; | ||
| 21 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | ||
| 22 | import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; | ||
| 23 | |||
| 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 26 | |||
| 27 | const LOGGED_IN_MODELS_OUTPUT = [ | ||
| 28 | "You are logged in with grok.com.", | ||
| 29 | "", | ||
| 30 | "Default model: grok-4.6", | ||
| 31 | "", | ||
| 32 | "Available models:", | ||
| 33 | " * grok-4.6 (default)", | ||
| 34 | " - grok-4.5", | ||
| 35 | "", | ||
| 36 | ].join("\n"); | ||
| 37 | |||
| 38 | const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( | ||
| 39 | "You are logged in with grok.com.", | ||
| 40 | "You are not authenticated.", | ||
| 41 | ); | ||
| 42 | |||
| 43 | describe("parseGrokModelsCliOutput", () => { | ||
| 44 | it("reads login state and model slugs, marking the default", () => { | ||
| 45 | const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); | ||
| 46 | expect(parsed.authenticated).toBe(true); | ||
| 47 | expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 48 | ["grok-4.6", true], | ||
| 49 | ["grok-4.5", false], | ||
| 50 | ]); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("detects a logged-out CLI even though it exits 0", () => { | ||
| 54 | expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("returns unknown auth for unrecognized output", () => { | ||
| 58 | expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | |||
| 62 | describe("grokSlashCommandsFromInitialize", () => { | ||
| 63 | it("publishes native ACP commands and input hints without permission overrides", () => { | ||
| 64 | const commands = grokSlashCommandsFromInitialize({ | ||
| 65 | protocolVersion: 1, | ||
| 66 | _meta: { | ||
| 67 | availableCommands: [ | ||
| 68 | { name: "compact", description: "Compress history", input: { hint: "what to preserve" } }, | ||
| 69 | { | ||
| 70 | name: "always-approve", | ||
| 71 | description: "Skip permission prompts", | ||
| 72 | input: { hint: "on|off" }, | ||
| 73 | }, | ||
| 74 | { name: "context", description: "Show context usage", input: null }, | ||
| 75 | { name: "session-info", description: "Show session details" }, | ||
| 76 | { name: "deep-research", description: "Research a topic", input: { hint: "<query>" } }, | ||
| 77 | { name: "workflow", description: "Manage workflows", input: { hint: "runs" } }, | ||
| 78 | { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } }, | ||
| 79 | ], | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | expect(commands.map((command) => command.name)).toEqual([ | ||
| 83 | "compact", | ||
| 84 | "session-info", | ||
| 85 | "deep-research", | ||
| 86 | "workflow", | ||
| 87 | "goal", | ||
| 88 | ]); | ||
| 89 | expect(commands[0]?.input).toEqual({ hint: "what to preserve" }); | ||
| 90 | expect(commands[2]).toEqual({ | ||
| 91 | name: "deep-research", | ||
| 92 | description: "Research a topic", | ||
| 93 | input: { hint: "<query>" }, | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps valid commands when other metadata entries are malformed", () => { | ||
| 98 | const commands = grokSlashCommandsFromInitialize({ | ||
| 99 | protocolVersion: 1, | ||
| 100 | _meta: { | ||
| 101 | availableCommands: [ | ||
| 102 | null, | ||
| 103 | { name: "broken", description: 42 }, | ||
| 104 | { name: " ", description: "Empty name" }, | ||
| 105 | { name: " session-info ", description: " Session details " }, | ||
| 106 | { name: "session-info", description: "Updated session details" }, | ||
| 107 | ], | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); | ||
| 111 | expect(commands[1]?.description).toBe("Updated session details"); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps compact available for older agents without command metadata", () => { | ||
| 115 | for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { | ||
| 116 | expect( | ||
| 117 | grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( | ||
| 118 | (command) => command.name, | ||
| 119 | ), | ||
| 120 | ).toEqual(["compact"]); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | describe("buildGrokModelsFromSessionModelState", () => { | ||
| 126 | it("marks the agent's current model as default and keeps reasoning options", () => { | ||
| 127 | const models = buildGrokModelsFromSessionModelState({ | ||
| 128 | currentModelId: "grok-4.6", | ||
| 129 | availableModels: [ | ||
| 130 | { | ||
| 131 | modelId: "grok-4.6", | ||
| 132 | name: "Grok 4.6", | ||
| 133 | _meta: { | ||
| 134 | supportsReasoningEffort: true, | ||
| 135 | reasoningEffort: "high", | ||
| 136 | reasoningEfforts: [{ value: "high", label: "High", default: true }], | ||
| 137 | }, | ||
| 138 | }, | ||
| 139 | { modelId: "grok-4.5", name: "Grok 4.5" }, | ||
| 140 | ], | ||
| 141 | }); | ||
| 142 | expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 143 | ["grok-4.6", true], | ||
| 144 | ["grok-4.5", false], | ||
| 145 | ]); | ||
| 146 | expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | describe("buildGrokModelCapabilities", () => { | ||
| 151 | it("preserves ACP-provided reasoning labels and the active default", () => { | ||
| 152 | const capabilities = buildGrokModelCapabilities({ | ||
| 153 | modelId: "grok-4.6", | ||
| 154 | name: "Grok 4.6", | ||
| 155 | _meta: { | ||
| 156 | supportsReasoningEffort: true, | ||
| 157 | reasoningEffort: "xhigh", | ||
| 158 | reasoningEfforts: [ | ||
| 159 | { value: "xhigh", label: "Extra High Effort", default: true }, | ||
| 160 | { value: "high", label: "High Effort", default: true }, | ||
| 161 | { value: "medium", label: "Medium Effort" }, | ||
| 162 | { value: "low", label: "Low Effort" }, | ||
| 163 | ], | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | |||
| 167 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 168 | { | ||
| 169 | id: "reasoningEffort", | ||
| 170 | label: "Reasoning", | ||
| 171 | type: "select", | ||
| 172 | currentValue: "xhigh", | ||
| 173 | options: [ | ||
| 174 | { id: "xhigh", label: "Extra High Effort", isDefault: true }, | ||
| 175 | { id: "high", label: "High Effort" }, | ||
| 176 | { id: "medium", label: "Medium Effort" }, | ||
| 177 | { id: "low", label: "Low Effort" }, | ||
| 178 | ], | ||
| 179 | }, | ||
| 180 | ]); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("uses raw ACP values when option labels are omitted", () => { | ||
| 184 | const capabilities = buildGrokModelCapabilities({ | ||
| 185 | modelId: "grok-4.6", | ||
| 186 | name: "Grok 4.6", | ||
| 187 | _meta: { | ||
| 188 | supportsReasoningEffort: true, | ||
| 189 | reasoningEffort: "xhigh", | ||
| 190 | reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], | ||
| 191 | }, | ||
| 192 | }); | ||
| 193 | |||
| 194 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 195 | { | ||
| 196 | id: "reasoningEffort", | ||
| 197 | label: "Reasoning", | ||
| 198 | type: "select", | ||
| 199 | currentValue: "xhigh", | ||
| 200 | options: [ | ||
| 201 | { id: "xhigh", label: "xhigh" }, | ||
| 202 | { id: "medium", label: "medium" }, | ||
| 203 | ], | ||
| 204 | }, | ||
| 205 | ]); | ||
| 206 | }); | ||
| 207 | |||
| 208 | it("keeps ACP current effort separate from its collapsed advertised default", () => { | ||
| 209 | const capabilities = buildGrokModelCapabilities({ | ||
| 210 | modelId: "grok-4.6", | ||
| 211 | name: "Grok 4.6", | ||
| 212 | _meta: { | ||
| 213 | supportsReasoningEffort: true, | ||
| 214 | reasoningEffort: "medium", | ||
| 215 | reasoningEfforts: [ | ||
| 216 | { value: "xhigh", label: "Extra High Effort", default: true }, | ||
| 217 | { value: "high", label: "High Effort", default: true }, | ||
| 218 | { value: "medium", label: "Medium Effort" }, | ||
| 219 | ], | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | |||
| 223 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 224 | { | ||
| 225 | id: "reasoningEffort", | ||
| 226 | label: "Reasoning", | ||
| 227 | type: "select", | ||
| 228 | currentValue: "medium", | ||
| 229 | options: [ | ||
| 230 | { id: "xhigh", label: "Extra High Effort", isDefault: true }, | ||
| 231 | { id: "high", label: "High Effort" }, | ||
| 232 | { id: "medium", label: "Medium Effort" }, | ||
| 233 | ], | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { | ||
| 239 | const capabilities = buildGrokModelCapabilities({ | ||
| 240 | modelId: "grok-4.6", | ||
| 241 | name: "Grok 4.6", | ||
| 242 | _meta: { | ||
| 243 | supportsReasoningEffort: true, | ||
| 244 | reasoningEffort: "high", | ||
| 245 | reasoningEfforts: [ | ||
| 246 | { | ||
| 247 | id: "high", | ||
| 248 | value: "not a token", | ||
| 249 | label: "High Effort", | ||
| 250 | description: "Higher implementation quality", | ||
| 251 | default: true, | ||
| 252 | }, | ||
| 253 | { id: "bad id", value: "also invalid", label: "Invalid" }, | ||
| 254 | ], | ||
| 255 | }, | ||
| 256 | }); | ||
| 257 | |||
| 258 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 259 | { | ||
| 260 | id: "reasoningEffort", | ||
| 261 | label: "Reasoning", | ||
| 262 | type: "select", | ||
| 263 | currentValue: "high", | ||
| 264 | options: [ | ||
| 265 | { | ||
| 266 | id: "high", | ||
| 267 | label: "High Effort", | ||
| 268 | description: "Higher implementation quality", | ||
| 269 | isDefault: true, | ||
| 270 | }, | ||
| 271 | ], | ||
| 272 | }, | ||
| 273 | ]); | ||
| 274 | }); | ||
| 275 | |||
| 276 | it("accepts an advertised ACP menu when the support flag is omitted", () => { | ||
| 277 | const capabilities = buildGrokModelCapabilities({ | ||
| 278 | modelId: "grok-4.6", | ||
| 279 | name: "Grok 4.6", | ||
| 280 | _meta: { | ||
| 281 | reasoningEffort: "high", | ||
| 282 | reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], | ||
| 283 | }, | ||
| 284 | }); | ||
| 285 | |||
| 286 | expect(capabilities.optionDescriptors).toHaveLength(1); | ||
| 287 | }); | ||
| 288 | |||
| 289 | it("honors an explicit ACP opt-out even when a menu is present", () => { | ||
| 290 | const capabilities = buildGrokModelCapabilities({ | ||
| 291 | modelId: "grok-4.6", | ||
| 292 | name: "Grok 4.6", | ||
| 293 | _meta: { | ||
| 294 | supportsReasoningEffort: false, | ||
| 295 | reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], | ||
| 296 | }, | ||
| 297 | }); | ||
| 298 | |||
| 299 | expect(capabilities.optionDescriptors).toEqual([]); | ||
| 300 | }); | ||
| 301 | |||
| 302 | it("does not synthesize a reasoning menu when ACP omits it", () => { | ||
| 303 | expect( | ||
| 304 | buildGrokModelCapabilities({ | ||
| 305 | modelId: "grok-4.6", | ||
| 306 | name: "Grok 4.6", | ||
| 307 | _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, | ||
| 308 | }).optionDescriptors, | ||
| 309 | ).toEqual([]); | ||
| 310 | }); | ||
| 311 | |||
| 312 | it("keeps non-reasoning Grok models free of reasoning controls", () => { | ||
| 313 | expect( | ||
| 314 | buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, | ||
| 315 | ).toEqual([]); | ||
| 316 | }); | ||
| 317 | }); | ||
| 318 | |||
| 319 | describe("buildInitialGrokProviderSnapshot", () => { | ||
| 320 | it.effect("returns a disabled snapshot when settings.enabled is false", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const snapshot = yield* buildInitialGrokProviderSnapshot( | ||
| 323 | decodeGrokSettings({ enabled: false }), | ||
| 324 | ); | ||
| 325 | expect(snapshot.enabled).toBe(false); | ||
| 326 | expect(snapshot.status).toBe("disabled"); | ||
| 327 | expect(snapshot.installed).toBe(false); | ||
| 328 | expect(snapshot.message).toContain("disabled"); | ||
| 329 | }), | ||
| 330 | ); | ||
| 331 | |||
| 332 | it.effect("returns a disabled snapshot by default — Grok is opt-in", () => | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); | ||
| 335 | expect(snapshot.enabled).toBe(false); | ||
| 336 | expect(snapshot.status).toBe("disabled"); | ||
| 337 | }), | ||
| 338 | ); | ||
| 339 | |||
| 340 | it.effect("returns a pending snapshot when enabled", () => | ||
| 341 | Effect.gen(function* () { | ||
| 342 | const snapshot = yield* buildInitialGrokProviderSnapshot( | ||
| 343 | decodeGrokSettings({ enabled: true }), | ||
| 344 | ); | ||
| 345 | expect(snapshot.enabled).toBe(true); | ||
| 346 | expect(snapshot.installed).toBe(true); | ||
| 347 | expect(snapshot.status).toBe("warning"); | ||
| 348 | expect(snapshot.version).toBeNull(); | ||
| 349 | expect(snapshot.message).toContain("Checking Grok"); | ||
| 350 | expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); | ||
| 351 | expect(snapshot.supportsConversationRollback).toBe(false); | ||
| 352 | }), | ||
| 353 | ); | ||
| 354 | }); | ||
| 355 | |||
| 356 | it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ||
| 357 | it.effect("reports the binary as missing when the binary path does not resolve", () => | ||
| 358 | Effect.gen(function* () { | ||
| 359 | const snapshot = yield* checkGrokProviderStatus( | ||
| 360 | decodeGrokSettings({ | ||
| 361 | enabled: true, | ||
| 362 | binaryPath: "/definitely/not/installed/grok-binary", | ||
| 363 | }), | ||
| 364 | ); | ||
| 365 | expect(snapshot.enabled).toBe(true); | ||
| 366 | expect(snapshot.installed).toBe(false); | ||
| 367 | expect(snapshot.status).toBe("error"); | ||
| 368 | expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | |||
| 372 | it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => | ||
| 373 | Effect.gen(function* () { | ||
| 374 | const secretStderr = "broken grok install: secret-token-value"; | ||
| 375 | const snapshot = yield* Effect.scoped( | ||
| 376 | Effect.gen(function* () { | ||
| 377 | const fs = yield* FileSystem.FileSystem; | ||
| 378 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); | ||
| 379 | const grokPath = writeFakeCli({ | ||
| 380 | directory: dir, | ||
| 381 | name: "grok", | ||
| 382 | source: [ | ||
| 383 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 384 | `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, | ||
| 385 | "process.exit(2);", | ||
| 386 | "", | ||
| 387 | ].join("\n"), | ||
| 388 | }); | ||
| 389 | |||
| 390 | return yield* checkGrokProviderStatus( | ||
| 391 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 392 | ); | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | |||
| 396 | expect(snapshot.enabled).toBe(true); | ||
| 397 | expect(snapshot.installed).toBe(true); | ||
| 398 | expect(snapshot.status).toBe("error"); | ||
| 399 | expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); | ||
| 400 | expect(snapshot.message).not.toContain(secretStderr); | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | |||
| 404 | // A stand-in for the Grok CLI: `--version` and `models` print canned text, | ||
| 405 | // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. | ||
| 406 | const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => | ||
| 407 | Effect.gen(function* () { | ||
| 408 | const fs = yield* FileSystem.FileSystem; | ||
| 409 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); | ||
| 410 | const mockAgentPath = NodePath.resolve(__dirname, "../../scripts/acp-mock-agent.ts"); | ||
| 411 | return writeFakeCli({ | ||
| 412 | directory: dir, | ||
| 413 | name: "grok", | ||
| 414 | source: [ | ||
| 415 | 'if (process.argv[2] === "--version") {', | ||
| 416 | ' process.stdout.write("grok 1.0.13\\n");', | ||
| 417 | " process.exit(0);", | ||
| 418 | "}", | ||
| 419 | 'if (process.argv[2] === "models") {', | ||
| 420 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 421 | ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, | ||
| 422 | " process.exit(0);", | ||
| 423 | "}", | ||
| 424 | 'if (process.argv[2] !== "agent") process.exit(1);', | ||
| 425 | ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), | ||
| 426 | "", | ||
| 427 | ].join("\n"), | ||
| 428 | }); | ||
| 429 | }); | ||
| 430 | |||
| 431 | it.effect("reports ready with ACP-discovered models when logged in", () => | ||
| 432 | Effect.gen(function* () { | ||
| 433 | const snapshot = yield* Effect.scoped( | ||
| 434 | Effect.gen(function* () { | ||
| 435 | const grokPath = yield* writeFakeGrokCli({ | ||
| 436 | modelsOutput: LOGGED_IN_MODELS_OUTPUT, | ||
| 437 | acp: true, | ||
| 438 | }); | ||
| 439 | return yield* checkGrokProviderStatus( | ||
| 440 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 441 | { ...process.env, XAI_API_KEY: "" }, | ||
| 442 | ); | ||
| 443 | }), | ||
| 444 | ); | ||
| 445 | |||
| 446 | expect(snapshot.status).toBe("ready"); | ||
| 447 | expect(snapshot.version).toBe("1.0.13"); | ||
| 448 | expect(snapshot.auth).toEqual({ | ||
| 449 | status: "authenticated", | ||
| 450 | type: "cached_token", | ||
| 451 | label: "Grok account", | ||
| 452 | }); | ||
| 453 | // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. | ||
| 454 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); | ||
| 455 | expect(snapshot.models[0]?.isDefault).toBe(true); | ||
| 456 | expect( | ||
| 457 | snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], | ||
| 458 | ).toEqual(["reasoningEffort"]); | ||
| 459 | }), | ||
| 460 | ); | ||
| 461 | |||
| 462 | it.effect("reports unauthenticated from `grok models` without starting a session", () => | ||
| 463 | Effect.gen(function* () { | ||
| 464 | const snapshot = yield* Effect.scoped( | ||
| 465 | Effect.gen(function* () { | ||
| 466 | const grokPath = yield* writeFakeGrokCli({ | ||
| 467 | modelsOutput: LOGGED_OUT_MODELS_OUTPUT, | ||
| 468 | acp: true, | ||
| 469 | }); | ||
| 470 | return yield* checkGrokProviderStatus( | ||
| 471 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 472 | { ...process.env, XAI_API_KEY: "" }, | ||
| 473 | ); | ||
| 474 | }), | ||
| 475 | ); | ||
| 476 | |||
| 477 | expect(snapshot.status).toBe("error"); | ||
| 478 | expect(snapshot.auth.status).toBe("unauthenticated"); | ||
| 479 | expect(snapshot.message).toContain("grok login"); | ||
| 480 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); | ||
| 481 | }), | ||
| 482 | ); | ||
| 483 | |||
| 484 | it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => | ||
| 485 | Effect.gen(function* () { | ||
| 486 | const snapshot = yield* Effect.scoped( | ||
| 487 | Effect.gen(function* () { | ||
| 488 | const grokPath = yield* writeFakeGrokCli({ | ||
| 489 | modelsOutput: LOGGED_IN_MODELS_OUTPUT, | ||
| 490 | acp: false, | ||
| 491 | }); | ||
| 492 | return yield* checkGrokProviderStatus( | ||
| 493 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 494 | { ...process.env, XAI_API_KEY: "" }, | ||
| 495 | ); | ||
| 496 | }), | ||
| 497 | ); | ||
| 498 | |||
| 499 | expect(snapshot.status).toBe("warning"); | ||
| 500 | expect(snapshot.installed).toBe(true); | ||
| 501 | expect(snapshot.auth.status).toBe("authenticated"); | ||
| 502 | expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 503 | ["grok-4.6", true], | ||
| 504 | ["grok-4.5", false], | ||
| 505 | ]); | ||
| 506 | expect(snapshot.message).toContain("ACP initialize failed"); | ||
| 507 | expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); | ||
| 508 | }), | ||
| 509 | ); | ||
| 510 | |||
| 511 | it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => | ||
| 512 | Effect.gen(function* () { | ||
| 513 | const snapshot = yield* Effect.scoped( | ||
| 514 | Effect.gen(function* () { | ||
| 515 | const grokPath = yield* writeFakeGrokCli({ | ||
| 516 | modelsOutput: LOGGED_OUT_MODELS_OUTPUT, | ||
| 517 | acp: false, | ||
| 518 | }); | ||
| 519 | return yield* checkGrokProviderStatus( | ||
| 520 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 521 | { ...process.env, XAI_API_KEY: "xai-test-key" }, | ||
| 522 | ); | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | |||
| 526 | expect(snapshot.auth).toEqual({ | ||
| 527 | status: "authenticated", | ||
| 528 | type: "api_key", | ||
| 529 | label: "xAI API key", | ||
| 530 | }); | ||
| 531 | expect(snapshot.status).toBe("warning"); | ||
| 532 | }), | ||
| 533 | ); | ||
| 534 | }); | ||
| 535 | |||
| 536 | describe("Grok usage limits", () => { | ||
| 537 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 538 | |||
| 539 | it("uses the reported subscription percentage and weekly reset", () => { | ||
| 540 | const limits = grokUsageResponseToLimits( | ||
| 541 | { | ||
| 542 | config: { | ||
| 543 | creditUsagePercent: 100, | ||
| 544 | currentPeriod: { | ||
| 545 | type: "USAGE_PERIOD_TYPE_WEEKLY", | ||
| 546 | end: "2026-09-18T03:10:30.159171+00:00", | ||
| 547 | }, | ||
| 548 | }, | ||
| 549 | }, | ||
| 550 | checkedAt, | ||
| 551 | ); | ||
| 552 | expect(limits.windows).toEqual([ | ||
| 553 | { | ||
| 554 | id: "subscription", | ||
| 555 | kind: "weekly", | ||
| 556 | label: "Weekly", | ||
| 557 | usedPercent: 100, | ||
| 558 | resetsAt: "2026-09-18T03:10:30.159Z", | ||
| 559 | }, | ||
| 560 | ]); | ||
| 561 | }); | ||
| 562 | |||
| 563 | it("does not invent allowance or reset dates when billing omits them", () => { | ||
| 564 | for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { | ||
| 565 | const limits = grokUsageResponseToLimits(response, checkedAt); | ||
| 566 | expect(limits.windows).toEqual([]); | ||
| 567 | // Nothing metered yet, which xAI reports by omitting the field until | ||
| 568 | // usage registers. Marking it `unsupported` would drop the account from | ||
| 569 | // the Limits view for good; leaving the marker off keeps it listed. | ||
| 570 | expect(limits.unavailable).toBeUndefined(); | ||
| 571 | } | ||
| 572 | expect( | ||
| 573 | grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, | ||
| 574 | ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); | ||
| 575 | expect( | ||
| 576 | grokUsageResponseToLimits( | ||
| 577 | { | ||
| 578 | config: { | ||
| 579 | creditUsagePercent: 120, | ||
| 580 | currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, | ||
| 581 | }, | ||
| 582 | }, | ||
| 583 | checkedAt, | ||
| 584 | ).windows, | ||
| 585 | ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); | ||
| 586 | }); | ||
| 587 | }); | ||
| 588 | |||
| 589 | it.layer(NodeServices.layer)("readGrokAccount", (it) => { | ||
| 590 | it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => | ||
| 591 | Effect.gen(function* () { | ||
| 592 | const fs = yield* FileSystem.FileSystem; | ||
| 593 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 594 | yield* fs.writeFileString( | ||
| 595 | NodePath.join(directory, "auth.json"), | ||
| 596 | '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 597 | ); | ||
| 598 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 599 | GROK_HOME: directory, | ||
| 600 | HOME: "/unrelated-home", | ||
| 601 | }).pipe( | ||
| 602 | Effect.provideService( | ||
| 603 | HttpClient.HttpClient, | ||
| 604 | HttpClient.make((request) => { | ||
| 605 | expect(request.method).toBe("GET"); | ||
| 606 | expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); | ||
| 607 | expect(request.headers.authorization).toBe("Bearer session-token"); | ||
| 608 | return Effect.succeed( | ||
| 609 | HttpClientResponse.fromWeb( | ||
| 610 | request, | ||
| 611 | Response.json({ config: { creditUsagePercent: 37 } }), | ||
| 612 | ), | ||
| 613 | ); | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | ); | ||
| 617 | expect(limits.windows[0]?.usedPercent).toBe(37); | ||
| 618 | }).pipe(Effect.scoped), | ||
| 619 | ); | ||
| 620 | |||
| 621 | it.effect( | ||
| 622 | "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", | ||
| 623 | () => | ||
| 624 | readGrokAccount({ | ||
| 625 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 626 | }).pipe( | ||
| 627 | Effect.provideService( | ||
| 628 | FileSystem.FileSystem, | ||
| 629 | FileSystem.makeNoop({ | ||
| 630 | readFileString: (path) => | ||
| 631 | path.endsWith("auth.json") | ||
| 632 | ? Effect.die("must not read stored credentials") | ||
| 633 | : Effect.succeed(""), | ||
| 634 | }), | ||
| 635 | ), | ||
| 636 | Effect.provideService( | ||
| 637 | HttpClient.HttpClient, | ||
| 638 | HttpClient.make((request) => { | ||
| 639 | expect(request.headers.authorization).toBe("Bearer legacy-token"); | ||
| 640 | return Effect.succeed( | ||
| 641 | HttpClientResponse.fromWeb( | ||
| 642 | request, | ||
| 643 | Response.json({ config: { creditUsagePercent: 12 } }), | ||
| 644 | ), | ||
| 645 | ); | ||
| 646 | }), | ||
| 647 | ), | ||
| 648 | Effect.tap(({ usageLimits }) => | ||
| 649 | Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), | ||
| 650 | ), | ||
| 651 | ), | ||
| 652 | ); | ||
| 653 | |||
| 654 | it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => | ||
| 655 | Effect.gen(function* () { | ||
| 656 | for (const environment of [ | ||
| 657 | { | ||
| 658 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 659 | XAI_API_KEY: "different-api-account", | ||
| 660 | }, | ||
| 661 | { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, | ||
| 662 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, | ||
| 663 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, | ||
| 664 | { | ||
| 665 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 666 | GROK_OIDC_ISSUER: "https://custom.example", | ||
| 667 | }, | ||
| 668 | { | ||
| 669 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 670 | GROK_MODELS_BASE_URL: "https://custom.example/v1", | ||
| 671 | }, | ||
| 672 | { | ||
| 673 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 674 | GROK_OAUTH2_PRINCIPAL_TYPE: "Team", | ||
| 675 | }, | ||
| 676 | { | ||
| 677 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 678 | GROK_OAUTH2_PRINCIPAL_ID: "team-id", | ||
| 679 | }, | ||
| 680 | { | ||
| 681 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 682 | GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', | ||
| 683 | }, | ||
| 684 | { | ||
| 685 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 686 | GROK_CONFIG_PATH: "/custom-config.toml", | ||
| 687 | }, | ||
| 688 | ]) { | ||
| 689 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 690 | HOME: "/definitely/not/a/grok-home", | ||
| 691 | ...environment, | ||
| 692 | }).pipe( | ||
| 693 | Effect.provideService( | ||
| 694 | HttpClient.HttpClient, | ||
| 695 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 696 | ), | ||
| 697 | ); | ||
| 698 | expect(limits.windows).toEqual([]); | ||
| 699 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 700 | } | ||
| 701 | }), | ||
| 702 | ); | ||
| 703 | |||
| 704 | it.effect( | ||
| 705 | "reports a missing login as unsupported and malformed credentials as a sanitized failure", | ||
| 706 | () => | ||
| 707 | Effect.gen(function* () { | ||
| 708 | const fs = yield* FileSystem.FileSystem; | ||
| 709 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 710 | const client = HttpClient.make(() => Effect.die("must not request without credentials")); | ||
| 711 | const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( | ||
| 712 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 713 | ); | ||
| 714 | expect(missing.unavailable?.reason).toBe("unsupported"); | ||
| 715 | for (const contents of [ | ||
| 716 | "private-token-invalid-json", | ||
| 717 | '{"https://accounts.x.ai/sign-in":{"key":42}}', | ||
| 718 | ]) { | ||
| 719 | const { usageLimits: malformed } = yield* readGrokAccount({ | ||
| 720 | GROK_HOME: directory, | ||
| 721 | GROK_AUTH: contents, | ||
| 722 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); | ||
| 723 | expect(malformed.unavailable).toEqual({ | ||
| 724 | reason: "probeFailed", | ||
| 725 | message: "Grok could not read usage limits.", | ||
| 726 | }); | ||
| 727 | } | ||
| 728 | }).pipe(Effect.scoped), | ||
| 729 | ); | ||
| 730 | |||
| 731 | it.effect( | ||
| 732 | "does not request subscription limits for custom account or endpoint configuration", | ||
| 733 | () => | ||
| 734 | Effect.gen(function* () { | ||
| 735 | for (const config of [ | ||
| 736 | '[auth]\nprovider_command = "custom-auth"', | ||
| 737 | '[grok_com_config]\nissuer = "https://custom.example"', | ||
| 738 | 'endpoints.proxy = "https://custom.example"', | ||
| 739 | ]) { | ||
| 740 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 741 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', | ||
| 742 | }).pipe( | ||
| 743 | Effect.provideService( | ||
| 744 | FileSystem.FileSystem, | ||
| 745 | FileSystem.makeNoop({ | ||
| 746 | readFileString: (path) => { | ||
| 747 | expect(path.endsWith("config.toml")).toBe(true); | ||
| 748 | return Effect.succeed(config); | ||
| 749 | }, | ||
| 750 | }), | ||
| 751 | ), | ||
| 752 | Effect.provideService( | ||
| 753 | HttpClient.HttpClient, | ||
| 754 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 755 | ), | ||
| 756 | ); | ||
| 757 | expect(limits.windows).toEqual([]); | ||
| 758 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 759 | } | ||
| 760 | }), | ||
| 761 | ); | ||
| 762 | |||
| 763 | it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => | ||
| 764 | Effect.gen(function* () { | ||
| 765 | for (const response of [ | ||
| 766 | new Response("private response", { status: 401 }), | ||
| 767 | Response.json({ config: { creditUsagePercent: "private-value" } }), | ||
| 768 | ]) { | ||
| 769 | const { email, usageLimits: limits } = yield* readGrokAccount({ | ||
| 770 | HOME: "/definitely/not/a/grok-home", | ||
| 771 | GROK_AUTH: | ||
| 772 | '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', | ||
| 773 | }).pipe( | ||
| 774 | Effect.provideService( | ||
| 775 | HttpClient.HttpClient, | ||
| 776 | HttpClient.make((request) => | ||
| 777 | Effect.succeed(HttpClientResponse.fromWeb(request, response)), | ||
| 778 | ), | ||
| 779 | ), | ||
| 780 | ); | ||
| 781 | expect(email).toBe("someone@example.com"); | ||
| 782 | expect(limits.windows).toEqual([]); | ||
| 783 | expect(limits.unavailable).toEqual({ | ||
| 784 | reason: "probeFailed", | ||
| 785 | message: "Grok could not read usage limits.", | ||
| 786 | }); | ||
| 787 | } | ||
| 788 | }), | ||
| 789 | ); | ||
| 790 | }); | ||
| 791 | |||
| 792 | it.layer(NodeServices.layer)("readGrokAccount email", (it) => { | ||
| 793 | it.effect("names the account whose limits were read, and nothing for other auth", () => | ||
| 794 | Effect.gen(function* () { | ||
| 795 | const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; | ||
| 796 | const cases = [ | ||
| 797 | [ | ||
| 798 | { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, | ||
| 799 | "Someone@Example.com", | ||
| 800 | ], | ||
| 801 | [ | ||
| 802 | { | ||
| 803 | GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, | ||
| 804 | }, | ||
| 805 | "current@example.com", | ||
| 806 | ], | ||
| 807 | [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], | ||
| 808 | [ | ||
| 809 | { | ||
| 810 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, | ||
| 811 | XAI_API_KEY: "api-key", | ||
| 812 | }, | ||
| 813 | undefined, | ||
| 814 | ], | ||
| 815 | [ | ||
| 816 | { | ||
| 817 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, | ||
| 818 | }, | ||
| 819 | undefined, | ||
| 820 | ], | ||
| 821 | [{ GROK_AUTH: "not-json" }, undefined], | ||
| 822 | ] as const; | ||
| 823 | for (const [environment, expected] of cases) { | ||
| 824 | const { email, usageLimits } = yield* readGrokAccount({ | ||
| 825 | HOME: "/definitely/not/a/grok-home", | ||
| 826 | ...environment, | ||
| 827 | }).pipe( | ||
| 828 | Effect.provideService( | ||
| 829 | HttpClient.HttpClient, | ||
| 830 | HttpClient.make((request) => | ||
| 831 | Effect.succeed( | ||
| 832 | HttpClientResponse.fromWeb( | ||
| 833 | request, | ||
| 834 | Response.json({ config: { creditUsagePercent: 25 } }), | ||
| 835 | ), | ||
| 836 | ), | ||
| 837 | ), | ||
| 838 | ), | ||
| 839 | ); | ||
| 840 | expect(email).toBe(expected); | ||
| 841 | if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); | ||
| 842 | } | ||
| 843 | }), | ||
| 844 | ); | ||
| 845 | }); | ||
apps/server/src/provider/GrokProvider.ts created+586| ... | @@ -0,0 +1,586 @@ | ||
| 1 | import { | ||
| 2 | type CustomModelSetting, | ||
| 3 | type GrokSettings, | ||
| 4 | type ModelCapabilities, | ||
| 5 | type ServerProvider, | ||
| 6 | type ServerProviderAuth, | ||
| 7 | type ServerProviderModel, | ||
| 8 | type ServerProviderSlashCommand, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 11 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Exit from "effect/Exit"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Result from "effect/Result"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { HttpClient } from "effect/http"; | ||
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 21 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 23 | |||
| 24 | import { | ||
| 25 | AUTH_PROBE_TIMEOUT_MS, | ||
| 26 | buildServerProvider, | ||
| 27 | COMPACT_SLASH_COMMAND, | ||
| 28 | isCommandMissingCause, | ||
| 29 | parseGenericCliVersion, | ||
| 30 | providerModelsFromSettings, | ||
| 31 | spawnAndCollect, | ||
| 32 | type ServerProviderDraft, | ||
| 33 | } from "./providerSnapshot.ts"; | ||
| 34 | import { | ||
| 35 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 36 | type ProviderMaintenanceCapabilities, | ||
| 37 | } from "./providerMaintenance.ts"; | ||
| 38 | import { | ||
| 39 | GROK_DEFAULT_MODEL_SLUG, | ||
| 40 | GROK_SUPPORTED_RUNTIME_MODES, | ||
| 41 | isValidGrokReasoningEffortToken, | ||
| 42 | makeGrokAcpRuntime, | ||
| 43 | resolveGrokAcpBaseModelId, | ||
| 44 | } from "./acp/GrokAcpSupport.ts"; | ||
| 45 | import { sessionModelStateFromInitialize } from "./acp/AcpRuntimeModel.ts"; | ||
| 46 | import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; | ||
| 47 | |||
| 48 | const GROK_PRESENTATION = { | ||
| 49 | displayName: "Grok", | ||
| 50 | supportsConversationRollback: false, | ||
| 51 | showInteractionModeToggle: false, | ||
| 52 | supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, | ||
| 53 | } as const; | ||
| 54 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 59 | // `initialize` is a single local round trip, so this is generous even on slow machines. | ||
| 60 | const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; | ||
| 61 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 62 | |||
| 63 | const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ||
| 64 | { | ||
| 65 | slug: GROK_DEFAULT_MODEL_SLUG, | ||
| 66 | name: "Grok Build", | ||
| 67 | isCustom: false, | ||
| 68 | capabilities: EMPTY_CAPABILITIES, | ||
| 69 | }, | ||
| 70 | ]; | ||
| 71 | |||
| 72 | export function buildInitialGrokProviderSnapshot( | ||
| 73 | grokSettings: GrokSettings, | ||
| 74 | ): Effect.Effect<ServerProviderDraft> { | ||
| 75 | return Effect.gen(function* () { | ||
| 76 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 77 | const models = grokModelsFromSettings(grokSettings.customModels); | ||
| 78 | |||
| 79 | if (!grokSettings.enabled) { | ||
| 80 | return buildServerProvider({ | ||
| 81 | presentation: GROK_PRESENTATION, | ||
| 82 | enabled: false, | ||
| 83 | checkedAt, | ||
| 84 | models, | ||
| 85 | probe: { | ||
| 86 | installed: false, | ||
| 87 | version: null, | ||
| 88 | status: "warning", | ||
| 89 | auth: { status: "unknown" }, | ||
| 90 | message: "Grok is disabled in T3 Code settings.", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | |||
| 95 | return buildServerProvider({ | ||
| 96 | presentation: GROK_PRESENTATION, | ||
| 97 | enabled: true, | ||
| 98 | checkedAt, | ||
| 99 | models, | ||
| 100 | probe: { | ||
| 101 | installed: true, | ||
| 102 | version: null, | ||
| 103 | status: "warning", | ||
| 104 | auth: { status: "unknown" }, | ||
| 105 | message: "Checking Grok CLI availability...", | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | } | ||
| 110 | |||
| 111 | function grokModelsFromSettings( | ||
| 112 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | ||
| 113 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, | ||
| 114 | ): ReadonlyArray<ServerProviderModel> { | ||
| 115 | return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); | ||
| 116 | } | ||
| 117 | |||
| 118 | function nonEmptyString(value: unknown): string | undefined { | ||
| 119 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 123 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 124 | } | ||
| 125 | |||
| 126 | function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { | ||
| 127 | readonly options: ReadonlyArray<{ | ||
| 128 | value: string; | ||
| 129 | label: string; | ||
| 130 | description?: string; | ||
| 131 | isDefault?: boolean; | ||
| 132 | }>; | ||
| 133 | readonly currentValue: string | undefined; | ||
| 134 | } { | ||
| 135 | const meta = model._meta; | ||
| 136 | if (!meta || meta.supportsReasoningEffort === false) { | ||
| 137 | return { options: [], currentValue: undefined }; | ||
| 138 | } | ||
| 139 | |||
| 140 | const currentEffort = nonEmptyString(meta.reasoningEffort); | ||
| 141 | const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; | ||
| 142 | const seen = new Set<string>(); | ||
| 143 | const options: Array<{ | ||
| 144 | value: string; | ||
| 145 | label: string; | ||
| 146 | description?: string; | ||
| 147 | advertisedDefault: boolean; | ||
| 148 | }> = []; | ||
| 149 | |||
| 150 | for (const entry of advertisedOptions) { | ||
| 151 | if (!isRecord(entry)) { | ||
| 152 | continue; | ||
| 153 | } | ||
| 154 | const rawValue = nonEmptyString(entry.value); | ||
| 155 | const rawId = nonEmptyString(entry.id); | ||
| 156 | const value = | ||
| 157 | rawValue && isValidGrokReasoningEffortToken(rawValue) | ||
| 158 | ? rawValue | ||
| 159 | : rawId && isValidGrokReasoningEffortToken(rawId) | ||
| 160 | ? rawId | ||
| 161 | : undefined; | ||
| 162 | if (value === undefined || seen.has(value)) { | ||
| 163 | continue; | ||
| 164 | } | ||
| 165 | seen.add(value); | ||
| 166 | const description = nonEmptyString(entry.description); | ||
| 167 | options.push({ | ||
| 168 | value, | ||
| 169 | label: nonEmptyString(entry.label) ?? value, | ||
| 170 | ...(description ? { description } : {}), | ||
| 171 | advertisedDefault: entry.default === true || entry.isDefault === true, | ||
| 172 | }); | ||
| 173 | } | ||
| 174 | |||
| 175 | const currentValue = | ||
| 176 | currentEffort && options.some((option) => option.value === currentEffort) | ||
| 177 | ? currentEffort | ||
| 178 | : undefined; | ||
| 179 | const advertisedDefaults = options.filter((option) => option.advertisedDefault); | ||
| 180 | const selectedDefault = | ||
| 181 | advertisedDefaults.find((option) => option.value === currentValue)?.value ?? | ||
| 182 | advertisedDefaults[0]?.value; | ||
| 183 | return { | ||
| 184 | options: options.map(({ value, label, description }) => ({ | ||
| 185 | value, | ||
| 186 | label, | ||
| 187 | ...(description ? { description } : {}), | ||
| 188 | ...(value === selectedDefault ? { isDefault: true } : {}), | ||
| 189 | })), | ||
| 190 | currentValue: currentValue ?? selectedDefault, | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { | ||
| 195 | const reasoning = grokReasoningOptionsFromModel(model); | ||
| 196 | return reasoning.options.length > 0 | ||
| 197 | ? createModelCapabilities({ | ||
| 198 | optionDescriptors: [ | ||
| 199 | { | ||
| 200 | id: "reasoningEffort", | ||
| 201 | label: "Reasoning", | ||
| 202 | type: "select", | ||
| 203 | options: reasoning.options.map((option) => ({ | ||
| 204 | id: option.value, | ||
| 205 | label: option.label, | ||
| 206 | ...(option.description ? { description: option.description } : {}), | ||
| 207 | ...(option.isDefault ? { isDefault: true } : {}), | ||
| 208 | })), | ||
| 209 | ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), | ||
| 210 | }, | ||
| 211 | ], | ||
| 212 | }) | ||
| 213 | : EMPTY_CAPABILITIES; | ||
| 214 | } | ||
| 215 | |||
| 216 | /** Models advertised by the ACP agent, with the session's current model marked as default. */ | ||
| 217 | export function buildGrokModelsFromSessionModelState( | ||
| 218 | modelState: EffectAcpSchema.SessionModelState | null | undefined, | ||
| 219 | ): ReadonlyArray<ServerProviderModel> { | ||
| 220 | if (!modelState || modelState.availableModels.length === 0) { | ||
| 221 | return []; | ||
| 222 | } | ||
| 223 | const currentModelId = modelState.currentModelId.trim(); | ||
| 224 | const seen = new Set<string>(); | ||
| 225 | return modelState.availableModels.flatMap((model): ServerProviderModel[] => { | ||
| 226 | const slug = resolveGrokAcpBaseModelId(model.modelId); | ||
| 227 | if (!slug || seen.has(slug)) { | ||
| 228 | return []; | ||
| 229 | } | ||
| 230 | seen.add(slug); | ||
| 231 | return [ | ||
| 232 | { | ||
| 233 | slug, | ||
| 234 | name: model.name.trim() || slug, | ||
| 235 | isCustom: false, | ||
| 236 | ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), | ||
| 237 | capabilities: buildGrokModelCapabilities(model), | ||
| 238 | }, | ||
| 239 | ]; | ||
| 240 | }); | ||
| 241 | } | ||
| 242 | |||
| 243 | export interface GrokModelsCliOutput { | ||
| 244 | /** True or false when the CLI printed a login line, null when it printed neither. */ | ||
| 245 | readonly authenticated: boolean | null; | ||
| 246 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 247 | } | ||
| 248 | |||
| 249 | /** | ||
| 250 | * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the | ||
| 251 | * text is the only signal. Current output looks like: | ||
| 252 | * | ||
| 253 | * You are logged in with grok.com. | ||
| 254 | * Default model: grok-4.6 | ||
| 255 | * Available models: | ||
| 256 | * * grok-4.6 (default) | ||
| 257 | * - grok-4.5 | ||
| 258 | */ | ||
| 259 | export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { | ||
| 260 | const authenticated = /you are logged in/i.test(output) | ||
| 261 | ? true | ||
| 262 | : /not authenticated|not logged in/i.test(output) | ||
| 263 | ? false | ||
| 264 | : null; | ||
| 265 | |||
| 266 | const seen = new Set<string>(); | ||
| 267 | const models: ServerProviderModel[] = []; | ||
| 268 | for (const line of output.split(/\r?\n/)) { | ||
| 269 | const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); | ||
| 270 | if (!bullet?.[1]) { | ||
| 271 | continue; | ||
| 272 | } | ||
| 273 | const slug = resolveGrokAcpBaseModelId(bullet[1]); | ||
| 274 | if (seen.has(slug)) { | ||
| 275 | continue; | ||
| 276 | } | ||
| 277 | seen.add(slug); | ||
| 278 | models.push({ | ||
| 279 | slug, | ||
| 280 | name: displayNameFromGrokModelSlug(slug), | ||
| 281 | isCustom: false, | ||
| 282 | ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), | ||
| 283 | capabilities: EMPTY_CAPABILITIES, | ||
| 284 | }); | ||
| 285 | } | ||
| 286 | return { authenticated, models }; | ||
| 287 | } | ||
| 288 | |||
| 289 | function displayNameFromGrokModelSlug(slug: string): string { | ||
| 290 | return slug | ||
| 291 | .split(/[-_]/g) | ||
| 292 | .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) | ||
| 293 | .join(" "); | ||
| 294 | } | ||
| 295 | |||
| 296 | const runGrokCliCommand = ( | ||
| 297 | grokSettings: GrokSettings, | ||
| 298 | args: ReadonlyArray<string>, | ||
| 299 | environment: NodeJS.ProcessEnv, | ||
| 300 | ) => | ||
| 301 | Effect.gen(function* () { | ||
| 302 | const command = grokSettings.binaryPath || "grok"; | ||
| 303 | const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); | ||
| 304 | return yield* spawnAndCollect( | ||
| 305 | command, | ||
| 306 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 307 | env: environment, | ||
| 308 | shell: spawnCommand.shell, | ||
| 309 | }), | ||
| 310 | ); | ||
| 311 | }); | ||
| 312 | |||
| 313 | const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); | ||
| 314 | const decodeAvailableCommand = Schema.decodeUnknownOption( | ||
| 315 | Schema.Struct({ | ||
| 316 | name: Schema.String, | ||
| 317 | description: Schema.String, | ||
| 318 | input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))), | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | |||
| 322 | export function grokSlashCommandsFromInitialize( | ||
| 323 | initialized: EffectAcpSchema.InitializeResponse, | ||
| 324 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 325 | const commands = decodeAvailableCommands(initialized._meta?.availableCommands); | ||
| 326 | const byName = new Map<string, ServerProviderSlashCommand>([ | ||
| 327 | [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND], | ||
| 328 | ]); | ||
| 329 | for (const entry of Option.getOrElse(commands, () => [])) { | ||
| 330 | const decoded = decodeAvailableCommand(entry); | ||
| 331 | if (Option.isNone(decoded)) continue; | ||
| 332 | const command = decoded.value; | ||
| 333 | const name = command.name.trim(); | ||
| 334 | // Permission changes must go through T3 so the client and provider agree. | ||
| 335 | if (!name || name.toLowerCase() === "always-approve") continue; | ||
| 336 | // Grok advertises /context, but its ACP handler completes without emitting output. | ||
| 337 | if (name.toLowerCase() === "context") continue; | ||
| 338 | const description = command.description.trim(); | ||
| 339 | const hint = command.input?.hint.trim(); | ||
| 340 | byName.set(name, { | ||
| 341 | name, | ||
| 342 | ...(description ? { description } : {}), | ||
| 343 | ...(hint ? { input: { hint } } : {}), | ||
| 344 | }); | ||
| 345 | } | ||
| 346 | return [...byName.values()]; | ||
| 347 | } | ||
| 348 | |||
| 349 | /** | ||
| 350 | * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` | ||
| 351 | * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. | ||
| 352 | */ | ||
| 353 | const discoverGrokMetadataViaAcpInitialize = ( | ||
| 354 | grokSettings: GrokSettings, | ||
| 355 | environment: NodeJS.ProcessEnv, | ||
| 356 | ) => | ||
| 357 | Effect.gen(function* () { | ||
| 358 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 359 | const acp = yield* makeGrokAcpRuntime({ | ||
| 360 | grokSettings, | ||
| 361 | environment, | ||
| 362 | childProcessSpawner, | ||
| 363 | cwd: process.cwd(), | ||
| 364 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | ||
| 365 | }); | ||
| 366 | const initialized = yield* acp.initialize(); | ||
| 367 | return { | ||
| 368 | models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), | ||
| 369 | slashCommands: grokSlashCommandsFromInitialize(initialized), | ||
| 370 | }; | ||
| 371 | }).pipe(Effect.scoped); | ||
| 372 | |||
| 373 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | ||
| 374 | grokSettings: GrokSettings, | ||
| 375 | environment: NodeJS.ProcessEnv = process.env, | ||
| 376 | cwd?: string, | ||
| 377 | ): Effect.fn.Return< | ||
| 378 | ServerProviderDraft, | ||
| 379 | never, | ||
| 380 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 381 | > { | ||
| 382 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 383 | const fallbackModels = grokModelsFromSettings(grokSettings.customModels); | ||
| 384 | |||
| 385 | if (!grokSettings.enabled) { | ||
| 386 | return buildServerProvider({ | ||
| 387 | presentation: GROK_PRESENTATION, | ||
| 388 | enabled: false, | ||
| 389 | checkedAt, | ||
| 390 | models: fallbackModels, | ||
| 391 | probe: { | ||
| 392 | installed: false, | ||
| 393 | version: null, | ||
| 394 | status: "warning", | ||
| 395 | auth: { status: "unknown" }, | ||
| 396 | message: "Grok is disabled in T3 Code settings.", | ||
| 397 | }, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | |||
| 401 | const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( | ||
| 402 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | ||
| 403 | Effect.result, | ||
| 404 | ); | ||
| 405 | |||
| 406 | if (Result.isFailure(versionResult)) { | ||
| 407 | const error = versionResult.failure; | ||
| 408 | yield* Effect.logWarning("Grok CLI health check failed.", { | ||
| 409 | errorTag: error._tag, | ||
| 410 | }); | ||
| 411 | return buildServerProvider({ | ||
| 412 | presentation: GROK_PRESENTATION, | ||
| 413 | enabled: grokSettings.enabled, | ||
| 414 | checkedAt, | ||
| 415 | models: fallbackModels, | ||
| 416 | probe: { | ||
| 417 | installed: !isCommandMissingCause(error), | ||
| 418 | version: null, | ||
| 419 | status: "error", | ||
| 420 | auth: { status: "unknown" }, | ||
| 421 | message: isCommandMissingCause(error) | ||
| 422 | ? "Grok CLI (`grok`) is not installed or not on PATH." | ||
| 423 | : "Failed to execute Grok CLI health check.", | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | } | ||
| 427 | |||
| 428 | if (Option.isNone(versionResult.success)) { | ||
| 429 | return buildServerProvider({ | ||
| 430 | presentation: GROK_PRESENTATION, | ||
| 431 | enabled: grokSettings.enabled, | ||
| 432 | checkedAt, | ||
| 433 | models: fallbackModels, | ||
| 434 | probe: { | ||
| 435 | installed: true, | ||
| 436 | version: null, | ||
| 437 | status: "error", | ||
| 438 | auth: { status: "unknown" }, | ||
| 439 | message: "Grok CLI is installed but timed out while running `grok --version`.", | ||
| 440 | }, | ||
| 441 | }); | ||
| 442 | } | ||
| 443 | |||
| 444 | const versionOutput = versionResult.success.value; | ||
| 445 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | ||
| 446 | if (versionOutput.code !== 0) { | ||
| 447 | yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { | ||
| 448 | exitCode: versionOutput.code, | ||
| 449 | stdoutLength: versionOutput.stdout.length, | ||
| 450 | stderrLength: versionOutput.stderr.length, | ||
| 451 | }); | ||
| 452 | return buildServerProvider({ | ||
| 453 | presentation: GROK_PRESENTATION, | ||
| 454 | enabled: grokSettings.enabled, | ||
| 455 | checkedAt, | ||
| 456 | models: fallbackModels, | ||
| 457 | probe: { | ||
| 458 | installed: true, | ||
| 459 | version, | ||
| 460 | status: "error", | ||
| 461 | auth: { status: "unknown" }, | ||
| 462 | message: "Grok CLI is installed but failed to run.", | ||
| 463 | }, | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | |||
| 467 | // `grok models` reports login state and model slugs without starting the agent. | ||
| 468 | const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( | ||
| 469 | Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), | ||
| 470 | Effect.result, | ||
| 471 | ); | ||
| 472 | // Only a clean exit is parsed. Failed invocations print help or error text that | ||
| 473 | // must not be read as model slugs or as a login verdict. | ||
| 474 | const modelsOutput = | ||
| 475 | Result.isSuccess(modelsResult) && | ||
| 476 | Option.isSome(modelsResult.success) && | ||
| 477 | modelsResult.success.value.code === 0 | ||
| 478 | ? modelsResult.success.value | ||
| 479 | : undefined; | ||
| 480 | const cliModels: GrokModelsCliOutput = modelsOutput | ||
| 481 | ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) | ||
| 482 | : { authenticated: null, models: [] }; | ||
| 483 | if (!modelsOutput) { | ||
| 484 | yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { | ||
| 485 | errorTag: Result.isFailure(modelsResult) | ||
| 486 | ? modelsResult.failure._tag | ||
| 487 | : Option.isNone(modelsResult.success) | ||
| 488 | ? "Timeout" | ||
| 489 | : `ExitCode${modelsResult.success.value.code}`, | ||
| 490 | }); | ||
| 491 | } | ||
| 492 | |||
| 493 | const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() | ||
| 494 | ? { status: "authenticated", type: "api_key", label: "xAI API key" } | ||
| 495 | : cliModels.authenticated === true | ||
| 496 | ? { status: "authenticated", type: "cached_token", label: "Grok account" } | ||
| 497 | : cliModels.authenticated === false | ||
| 498 | ? { status: "unauthenticated" } | ||
| 499 | : { status: "unknown" }; | ||
| 500 | |||
| 501 | const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( | ||
| 502 | Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), | ||
| 503 | Effect.orElseSucceed(() => []), | ||
| 504 | ); | ||
| 505 | |||
| 506 | const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe( | ||
| 507 | Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), | ||
| 508 | Effect.exit, | ||
| 509 | ); | ||
| 510 | const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; | ||
| 511 | const acpModels = acpMetadata?.models ?? []; | ||
| 512 | const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); | ||
| 513 | if (acpFailed) { | ||
| 514 | yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { | ||
| 515 | errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | |||
| 519 | const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; | ||
| 520 | const models = | ||
| 521 | discoveredModels.length > 0 | ||
| 522 | ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) | ||
| 523 | : fallbackModels; | ||
| 524 | |||
| 525 | if (auth.status === "unauthenticated") { | ||
| 526 | return buildServerProvider({ | ||
| 527 | presentation: GROK_PRESENTATION, | ||
| 528 | enabled: grokSettings.enabled, | ||
| 529 | checkedAt, | ||
| 530 | models, | ||
| 531 | skills, | ||
| 532 | probe: { | ||
| 533 | installed: true, | ||
| 534 | version, | ||
| 535 | status: "error", | ||
| 536 | auth, | ||
| 537 | message: "Grok CLI is installed but not logged in. Run `grok login`.", | ||
| 538 | }, | ||
| 539 | }); | ||
| 540 | } | ||
| 541 | |||
| 542 | return buildServerProvider({ | ||
| 543 | presentation: GROK_PRESENTATION, | ||
| 544 | enabled: grokSettings.enabled, | ||
| 545 | checkedAt, | ||
| 546 | models, | ||
| 547 | skills, | ||
| 548 | slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND], | ||
| 549 | probe: { | ||
| 550 | installed: true, | ||
| 551 | version, | ||
| 552 | // A failed metadata probe degrades the model picker, it does not make chats fail. | ||
| 553 | status: acpFailed ? "warning" : "ready", | ||
| 554 | auth, | ||
| 555 | ...(acpFailed | ||
| 556 | ? { | ||
| 557 | message: | ||
| 558 | "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", | ||
| 559 | } | ||
| 560 | : {}), | ||
| 561 | }, | ||
| 562 | }); | ||
| 563 | }); | ||
| 564 | |||
| 565 | export const enrichGrokSnapshot = (input: { | ||
| 566 | readonly snapshot: ServerProvider; | ||
| 567 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 568 | readonly enableProviderUpdateChecks?: boolean; | ||
| 569 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 570 | readonly httpClient: HttpClient.HttpClient; | ||
| 571 | }): Effect.Effect<void> => { | ||
| 572 | const { snapshot, publishSnapshot } = input; | ||
| 573 | |||
| 574 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | ||
| 575 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | ||
| 576 | }).pipe( | ||
| 577 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | ||
| 578 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | ||
| 579 | Effect.catchCause((cause) => | ||
| 580 | Effect.logWarning("Grok version advisory enrichment failed", { | ||
| 581 | errorTag: causeErrorTag(cause), | ||
| 582 | }), | ||
| 583 | ), | ||
| 584 | Effect.asVoid, | ||
| 585 | ); | ||
| 586 | }; | ||
apps/server/src/provider/Layers/AcpRegistryCatalog.ts deleted-22| ... | @@ -1,22 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | |||
| 5 | import * as ServerConfig from "../../config.ts"; | ||
| 6 | import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; | ||
| 7 | import * as AcpRegistryRuntimeCoordinator from "../acp/AcpRegistryRuntimeCoordinator.ts"; | ||
| 8 | |||
| 9 | /** Server-lifetime ACP Registry catalog shared by setup, snapshots, and turn launch. */ | ||
| 10 | export const layer = Layer.merge( | ||
| 11 | Layer.unwrap( | ||
| 12 | Effect.gen(function* () { | ||
| 13 | const config = yield* ServerConfig.ServerConfig; | ||
| 14 | const path = yield* Path.Path; | ||
| 15 | return AcpRegistrySupport.AcpRegistryCatalog.layer({ | ||
| 16 | cacheDir: config.providerStatusCacheDir, | ||
| 17 | toolsDir: path.join(config.baseDir, "tools"), | ||
| 18 | }); | ||
| 19 | }), | ||
| 20 | ), | ||
| 21 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator.layer, | ||
| 22 | ); | ||
apps/server/src/provider/Layers/AntigravityProvider.test.ts deleted-661| ... | @@ -1,661 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 4 | AntigravitySettings, | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ProviderSetupError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 17 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 18 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 19 | |||
| 20 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 21 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 22 | import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | ||
| 23 | import { | ||
| 24 | buildAntigravityModelsFromSession, | ||
| 25 | makeAntigravityProvider, | ||
| 26 | } from "./AntigravityProvider.ts"; | ||
| 27 | |||
| 28 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | ||
| 29 | const instanceId = ProviderInstanceId.make("antigravity-test"); | ||
| 30 | const driver = ProviderDriverKind.make("antigravity"); | ||
| 31 | |||
| 32 | const initializeResult = { | ||
| 33 | protocolVersion: 1, | ||
| 34 | agentCapabilities: { | ||
| 35 | loadSession: true, | ||
| 36 | promptCapabilities: { image: true, audio: true, embeddedContext: true }, | ||
| 37 | sessionCapabilities: { list: {}, resume: {} }, | ||
| 38 | }, | ||
| 39 | authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], | ||
| 40 | agentInfo: { | ||
| 41 | name: "antigravity-acp", | ||
| 42 | title: "Google Antigravity", | ||
| 43 | version: "agy_acp_server_20260818_01_RC01", | ||
| 44 | }, | ||
| 45 | } satisfies EffectAcpSchema.InitializeResponse; | ||
| 46 | |||
| 47 | const modelOptions = [ | ||
| 48 | { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, | ||
| 49 | { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" }, | ||
| 50 | { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, | ||
| 51 | { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, | ||
| 52 | { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, | ||
| 53 | { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" }, | ||
| 54 | { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, | ||
| 55 | { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, | ||
| 56 | { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, | ||
| 57 | { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, | ||
| 58 | { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, | ||
| 59 | ]; | ||
| 60 | |||
| 61 | const modelConfig = { | ||
| 62 | id: "model", | ||
| 63 | name: "Model", | ||
| 64 | category: "model", | ||
| 65 | type: "select", | ||
| 66 | currentValue: "gemini-3.7-flash-high", | ||
| 67 | options: modelOptions, | ||
| 68 | } satisfies EffectAcpSchema.SessionConfigOption; | ||
| 69 | |||
| 70 | const sessionSetupResult = { | ||
| 71 | sessionId: "session-1", | ||
| 72 | models: { | ||
| 73 | currentModelId: "gemini-3.7-flash-high", | ||
| 74 | availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })), | ||
| 75 | }, | ||
| 76 | configOptions: [ | ||
| 77 | modelConfig, | ||
| 78 | { | ||
| 79 | id: "mode", | ||
| 80 | name: "Session Mode", | ||
| 81 | category: "mode", | ||
| 82 | type: "select", | ||
| 83 | currentValue: "default", | ||
| 84 | options: [ | ||
| 85 | { value: "default", name: "Default" }, | ||
| 86 | { value: "yolo", name: "YOLO" }, | ||
| 87 | ], | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | } satisfies EffectAcpSchema.NewSessionResponse; | ||
| 91 | |||
| 92 | const started = { | ||
| 93 | sessionId: "session-1", | ||
| 94 | initializeResult, | ||
| 95 | sessionSetupResult, | ||
| 96 | modelConfigId: "model", | ||
| 97 | } satisfies AcpSessionRuntimeStartResult; | ||
| 98 | |||
| 99 | const commands = [ | ||
| 100 | { name: "plan", description: "Create a plan", input: { type: "text", hint: "What to plan" } }, | ||
| 101 | { name: "logout", description: "Sign out of Google" }, | ||
| 102 | ] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>; | ||
| 103 | |||
| 104 | const expectedCommands = [ | ||
| 105 | { name: "plan", description: "Create a plan", input: { hint: "What to plan" } }, | ||
| 106 | { name: "logout", description: "Sign out of Google" }, | ||
| 107 | ]; | ||
| 108 | |||
| 109 | const layerTest = Layer.merge( | ||
| 110 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 111 | shouldRunScopeWork: () => Effect.succeed(false), | ||
| 112 | }), | ||
| 113 | ServerSettings.layerTest(), | ||
| 114 | ); | ||
| 115 | |||
| 116 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; | ||
| 117 | |||
| 118 | const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( | ||
| 119 | options: { readonly enabled?: boolean; readonly safe?: boolean } = {}, | ||
| 120 | ) { | ||
| 121 | const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>(); | ||
| 122 | const probeCalls = yield* Ref.make(0); | ||
| 123 | const safetyCalls = yield* Ref.make(0); | ||
| 124 | const probe = yield* Ref.make<Effect.Effect<EffectAcpSchema.InitializeResponse, ProbeError>>( | ||
| 125 | Deferred.await(initialProbe), | ||
| 126 | ); | ||
| 127 | const safety = yield* Ref.make<Effect.Effect<boolean>>(Effect.succeed(options.safe ?? true)); | ||
| 128 | const provider = yield* makeAntigravityProvider( | ||
| 129 | decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }), | ||
| 130 | { | ||
| 131 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | ||
| 132 | probe: Ref.update(probeCalls, (count) => count + 1).pipe( | ||
| 133 | Effect.andThen(Ref.get(probe)), | ||
| 134 | Effect.flatten, | ||
| 135 | ), | ||
| 136 | supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe( | ||
| 137 | Effect.andThen(Ref.get(safety)), | ||
| 138 | Effect.flatten, | ||
| 139 | ), | ||
| 140 | }, | ||
| 141 | ); | ||
| 142 | const initialUpdate = yield* Stream.toPull( | ||
| 143 | provider.snapshot.streamChanges.pipe( | ||
| 144 | Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"), | ||
| 145 | ), | ||
| 146 | ); | ||
| 147 | const initialize = Deferred.succeed(initialProbe, initializeResult).pipe( | ||
| 148 | Effect.andThen(initialUpdate), | ||
| 149 | Effect.asVoid, | ||
| 150 | ); | ||
| 151 | return { | ||
| 152 | provider, | ||
| 153 | probe, | ||
| 154 | probeCalls, | ||
| 155 | safety, | ||
| 156 | safetyCalls, | ||
| 157 | initialProbe, | ||
| 158 | initialUpdate, | ||
| 159 | initialize, | ||
| 160 | }; | ||
| 161 | }); | ||
| 162 | |||
| 163 | describe("Antigravity model catalog", () => { | ||
| 164 | it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { | ||
| 165 | const models = buildAntigravityModelsFromSession(sessionSetupResult); | ||
| 166 | expect(models.map((model) => [model.slug, model.name])).toEqual( | ||
| 167 | modelOptions.map((option) => [option.value, option.name]), | ||
| 168 | ); | ||
| 169 | expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ | ||
| 170 | "gemini-3.7-flash-high", | ||
| 171 | ]); | ||
| 172 | expect( | ||
| 173 | models | ||
| 174 | .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) | ||
| 175 | .map((model) => model.slug), | ||
| 176 | ).toEqual(["gemini-3.7-flash-high"]); | ||
| 177 | expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); | ||
| 178 | expect(models.every((model) => !model.isCustom)).toBe(true); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("uses legacy session models only when model config is absent", () => { | ||
| 182 | const fromLegacy = buildAntigravityModelsFromSession({ | ||
| 183 | models: sessionSetupResult.models, | ||
| 184 | }); | ||
| 185 | expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 186 | expect( | ||
| 187 | buildAntigravityModelsFromSession({ | ||
| 188 | ...sessionSetupResult, | ||
| 189 | configOptions: [{ ...modelConfig, options: [] }], | ||
| 190 | }), | ||
| 191 | ).toEqual([]); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("flattens native option groups without combining distinct model IDs", () => { | ||
| 195 | const models = buildAntigravityModelsFromSession({ | ||
| 196 | configOptions: [ | ||
| 197 | { | ||
| 198 | ...modelConfig, | ||
| 199 | currentValue: "gemini-pro-agent", | ||
| 200 | options: [ | ||
| 201 | { groupId: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] }, | ||
| 202 | { groupId: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] }, | ||
| 203 | ], | ||
| 204 | }, | ||
| 205 | ], | ||
| 206 | }); | ||
| 207 | expect(models.map((model) => model.slug)).toEqual([ | ||
| 208 | "gemini-3.7-flash-high", | ||
| 209 | "gemini-3.7-flash-medium", | ||
| 210 | "gemini-pro-agent", | ||
| 211 | ]); | ||
| 212 | expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent"); | ||
| 213 | expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe( | ||
| 214 | "gemini-pro-agent", | ||
| 215 | ); | ||
| 216 | }); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it.layer(layerTest)("Antigravity provider snapshots", (it) => { | ||
| 220 | it.effect("does not probe or run helper safety checks while disabled", () => | ||
| 221 | Effect.scoped( | ||
| 222 | Effect.gen(function* () { | ||
| 223 | const harness = yield* makeHarness({ enabled: false }); | ||
| 224 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 225 | expect(snapshot).toMatchObject({ | ||
| 226 | enabled: false, | ||
| 227 | installed: false, | ||
| 228 | status: "disabled", | ||
| 229 | auth: { status: "unknown" }, | ||
| 230 | models: [], | ||
| 231 | setup: { canAuthenticate: true, canInstall: true }, | ||
| 232 | showInteractionModeToggle: false, | ||
| 233 | supportsConversationRollback: false, | ||
| 234 | supportsTextGeneration: false, | ||
| 235 | }); | ||
| 236 | expect(yield* Ref.get(harness.probeCalls)).toBe(0); | ||
| 237 | expect(yield* Ref.get(harness.safetyCalls)).toBe(0); | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | |||
| 242 | it.effect("records explicit sign-in while disabled without starting a health probe", () => | ||
| 243 | Effect.scoped( | ||
| 244 | Effect.gen(function* () { | ||
| 245 | const harness = yield* makeHarness({ enabled: false }); | ||
| 246 | const signedIn = yield* Stream.toPull( | ||
| 247 | harness.provider.snapshot.streamChanges.pipe( | ||
| 248 | Stream.filter( | ||
| 249 | (snapshot) => | ||
| 250 | snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, | ||
| 251 | ), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | yield* harness.provider.onSessionStarted(started); | ||
| 255 | yield* harness.provider.onAvailableCommands(commands); | ||
| 256 | const [snapshot] = yield* signedIn; | ||
| 257 | expect(snapshot).toMatchObject({ | ||
| 258 | enabled: false, | ||
| 259 | installed: true, | ||
| 260 | status: "disabled", | ||
| 261 | auth: { status: "authenticated", type: "oauth-personal" }, | ||
| 262 | workspaceSnapshots: [], | ||
| 263 | }); | ||
| 264 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 265 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 266 | expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models); | ||
| 267 | expect(yield* Ref.get(harness.probeCalls)).toBe(0); | ||
| 268 | }), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("publishes the configured sign-in method before any account is checked", () => | ||
| 273 | Effect.scoped( | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { | ||
| 276 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | ||
| 277 | probe: Effect.succeed(initializeResult), | ||
| 278 | supportsTextGeneration: Effect.succeed(true), | ||
| 279 | auth: { type: "gemini-api-key", label: "Gemini API key" }, | ||
| 280 | }); | ||
| 281 | expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ | ||
| 282 | status: "unknown", | ||
| 283 | type: "gemini-api-key", | ||
| 284 | }); | ||
| 285 | }), | ||
| 286 | ), | ||
| 287 | ); | ||
| 288 | |||
| 289 | it.effect("treats initialize as installation proof, not account or model discovery", () => | ||
| 290 | Effect.scoped( | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const harness = yield* makeHarness(); | ||
| 293 | expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ | ||
| 294 | installed: false, | ||
| 295 | status: "warning", | ||
| 296 | auth: { status: "unknown" }, | ||
| 297 | models: [], | ||
| 298 | }); | ||
| 299 | yield* harness.initialize; | ||
| 300 | const snapshot = yield* harness.provider.snapshot.getSnapshot; | ||
| 301 | expect(snapshot).toMatchObject({ | ||
| 302 | installed: true, | ||
| 303 | status: "warning", | ||
| 304 | version: "agy_acp_server_20260818_01_RC01", | ||
| 305 | auth: { status: "unknown" }, | ||
| 306 | models: [], | ||
| 307 | }); | ||
| 308 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 309 | }), | ||
| 310 | ), | ||
| 311 | ); | ||
| 312 | |||
| 313 | it.effect("publishes session metadata and native commands without another health probe", () => | ||
| 314 | Effect.scoped( | ||
| 315 | Effect.gen(function* () { | ||
| 316 | const harness = yield* makeHarness(); | ||
| 317 | yield* harness.initialize; | ||
| 318 | const nextReady = yield* Stream.toPull( | ||
| 319 | harness.provider.snapshot.streamChanges.pipe( | ||
| 320 | Stream.filter( | ||
| 321 | (snapshot) => | ||
| 322 | snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0, | ||
| 323 | ), | ||
| 324 | ), | ||
| 325 | ); | ||
| 326 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 327 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 328 | const [snapshot] = yield* nextReady; | ||
| 329 | expect(snapshot).toMatchObject({ | ||
| 330 | status: "ready", | ||
| 331 | auth: { status: "authenticated", type: "oauth-personal" }, | ||
| 332 | supportsTextGeneration: true, | ||
| 333 | }); | ||
| 334 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 335 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 336 | expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual( | ||
| 337 | expectedCommands, | ||
| 338 | ); | ||
| 339 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ); | ||
| 343 | |||
| 344 | it.effect("does not retain a disposable sign-in workspace", () => | ||
| 345 | Effect.scoped( | ||
| 346 | Effect.gen(function* () { | ||
| 347 | const harness = yield* makeHarness(); | ||
| 348 | yield* harness.initialize; | ||
| 349 | yield* harness.provider.onSessionStarted(started); | ||
| 350 | yield* harness.provider.onAvailableCommands(commands); | ||
| 351 | const snapshot = yield* harness.provider.snapshot.getSnapshot; | ||
| 352 | expect(snapshot.models).toHaveLength(11); | ||
| 353 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 354 | expect(snapshot.workspaceSnapshots).toEqual([]); | ||
| 355 | }), | ||
| 356 | ), | ||
| 357 | ); | ||
| 358 | |||
| 359 | it.effect("clears all account metadata on sign-out and authentication failure", () => | ||
| 360 | Effect.scoped( | ||
| 361 | Effect.gen(function* () { | ||
| 362 | const harness = yield* makeHarness(); | ||
| 363 | yield* harness.initialize; | ||
| 364 | for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) { | ||
| 365 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 366 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 367 | yield* clear; | ||
| 368 | expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({ | ||
| 369 | installed: true, | ||
| 370 | status: "warning", | ||
| 371 | auth: { status: "unauthenticated" }, | ||
| 372 | models: [], | ||
| 373 | slashCommands: [], | ||
| 374 | skills: [], | ||
| 375 | workspaceSnapshots: [], | ||
| 376 | supportsTextGeneration: false, | ||
| 377 | }); | ||
| 378 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 379 | yield* harness.provider.onConfigOptionsUpdated([modelConfig]); | ||
| 380 | expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); | ||
| 381 | expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); | ||
| 382 | } | ||
| 383 | const refreshed = yield* harness.provider.snapshot.refresh; | ||
| 384 | expect(refreshed.auth.status).toBe("unauthenticated"); | ||
| 385 | expect(refreshed.supportsTextGeneration).toBe(false); | ||
| 386 | }), | ||
| 387 | ), | ||
| 388 | ); | ||
| 389 | |||
| 390 | it.effect("replaces live model choices and accepts an empty catalog", () => | ||
| 391 | Effect.scoped( | ||
| 392 | Effect.gen(function* () { | ||
| 393 | const harness = yield* makeHarness(); | ||
| 394 | yield* harness.initialize; | ||
| 395 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 396 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 397 | const before = yield* harness.provider.snapshot.getSnapshot; | ||
| 398 | const configOptions = [ | ||
| 399 | { | ||
| 400 | ...modelConfig, | ||
| 401 | currentValue: "gemini-3.8-flash-high", | ||
| 402 | options: modelOptions.slice(0, 3), | ||
| 403 | }, | ||
| 404 | ]; | ||
| 405 | const nextSnapshot = yield* Stream.toPull( | ||
| 406 | harness.provider.snapshot.streamChanges.pipe( | ||
| 407 | Stream.filter((snapshot) => snapshot.models.length === 3), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | yield* harness.provider.onConfigOptionsUpdated(configOptions); | ||
| 411 | expect((yield* nextSnapshot)[0]).toMatchObject({ | ||
| 412 | models: buildAntigravityModelsFromSession({ configOptions }), | ||
| 413 | auth: before.auth, | ||
| 414 | workspaceSnapshots: before.workspaceSnapshots, | ||
| 415 | slashCommands: expectedCommands, | ||
| 416 | }); | ||
| 417 | yield* harness.provider.onConfigOptionsUpdated([]); | ||
| 418 | expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); | ||
| 419 | }), | ||
| 420 | ), | ||
| 421 | ); | ||
| 422 | |||
| 423 | it.effect("replaces one account's catalog instead of combining accounts", () => | ||
| 424 | Effect.scoped( | ||
| 425 | Effect.gen(function* () { | ||
| 426 | const harness = yield* makeHarness(); | ||
| 427 | yield* harness.initialize; | ||
| 428 | yield* harness.provider.onSessionStarted(started); | ||
| 429 | yield* harness.provider.onSignedOut; | ||
| 430 | yield* harness.provider.onSessionStarted({ | ||
| 431 | ...started, | ||
| 432 | sessionSetupResult: { | ||
| 433 | sessionId: "new-account-session", | ||
| 434 | configOptions: [ | ||
| 435 | { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] }, | ||
| 436 | ], | ||
| 437 | }, | ||
| 438 | }); | ||
| 439 | expect( | ||
| 440 | (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug), | ||
| 441 | ).toEqual(["gemini-pro-agent"]); | ||
| 442 | }), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | |||
| 446 | it.effect("retains known account metadata when a local health check fails", () => | ||
| 447 | Effect.scoped( | ||
| 448 | Effect.gen(function* () { | ||
| 449 | const harness = yield* makeHarness(); | ||
| 450 | yield* harness.initialize; | ||
| 451 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 452 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 453 | yield* Ref.set( | ||
| 454 | harness.probe, | ||
| 455 | Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")), | ||
| 456 | ); | ||
| 457 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 458 | expect(snapshot).toMatchObject({ | ||
| 459 | installed: true, | ||
| 460 | status: "error", | ||
| 461 | auth: { status: "authenticated" }, | ||
| 462 | }); | ||
| 463 | expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); | ||
| 464 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 465 | expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace"); | ||
| 466 | }), | ||
| 467 | ), | ||
| 468 | ); | ||
| 469 | |||
| 470 | it.effect("allows a slow packaged runtime health check to finish", () => | ||
| 471 | Effect.scoped( | ||
| 472 | Effect.gen(function* () { | ||
| 473 | const harness = yield* makeHarness(); | ||
| 474 | yield* harness.initialize; | ||
| 475 | const entered = yield* Deferred.make<void>(); | ||
| 476 | const initialized = yield* Deferred.make<EffectAcpSchema.InitializeResponse>(); | ||
| 477 | yield* Ref.set( | ||
| 478 | harness.probe, | ||
| 479 | Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), | ||
| 480 | ); | ||
| 481 | |||
| 482 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 483 | yield* Deferred.await(entered); | ||
| 484 | yield* TestClock.adjust("47 seconds"); | ||
| 485 | yield* Deferred.succeed(initialized, initializeResult); | ||
| 486 | const snapshot = yield* Fiber.join(refresh); | ||
| 487 | |||
| 488 | expect(snapshot).toMatchObject({ | ||
| 489 | installed: true, | ||
| 490 | status: "warning", | ||
| 491 | auth: { status: "unknown" }, | ||
| 492 | }); | ||
| 493 | }), | ||
| 494 | ), | ||
| 495 | ); | ||
| 496 | |||
| 497 | it.effect("closes a stalled health probe at its deadline", () => | ||
| 498 | Effect.scoped( | ||
| 499 | Effect.gen(function* () { | ||
| 500 | const harness = yield* makeHarness(); | ||
| 501 | yield* harness.initialize; | ||
| 502 | const entered = yield* Deferred.make<void>(); | ||
| 503 | const closed = yield* Deferred.make<void>(); | ||
| 504 | yield* Ref.set( | ||
| 505 | harness.probe, | ||
| 506 | Deferred.succeed(entered, undefined).pipe( | ||
| 507 | Effect.andThen(Effect.never), | ||
| 508 | Effect.ensuring(Deferred.succeed(closed, undefined)), | ||
| 509 | ), | ||
| 510 | ); | ||
| 511 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 512 | yield* Deferred.await(entered); | ||
| 513 | yield* TestClock.adjust("90 seconds"); | ||
| 514 | const snapshot = yield* Fiber.join(refresh); | ||
| 515 | yield* Deferred.await(closed); | ||
| 516 | expect(snapshot.status).toBe("error"); | ||
| 517 | expect(snapshot.message).toContain("90 seconds"); | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ); | ||
| 521 | |||
| 522 | it.effect("distinguishes missing executables from a failed installed executable", () => | ||
| 523 | Effect.scoped( | ||
| 524 | Effect.gen(function* () { | ||
| 525 | const harness = yield* makeHarness(); | ||
| 526 | yield* harness.initialize; | ||
| 527 | const failures = [ | ||
| 528 | { | ||
| 529 | error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }), | ||
| 530 | installed: false, | ||
| 531 | }, | ||
| 532 | { | ||
| 533 | error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }), | ||
| 534 | installed: true, | ||
| 535 | }, | ||
| 536 | { | ||
| 537 | error: new ProviderSetupError({ | ||
| 538 | instanceId, | ||
| 539 | operation: "resolve", | ||
| 540 | detail: "Antigravity is not installed.", | ||
| 541 | }), | ||
| 542 | installed: false, | ||
| 543 | }, | ||
| 544 | ]; | ||
| 545 | for (const { error, installed } of failures) { | ||
| 546 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 547 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 548 | yield* Ref.set(harness.probe, Effect.fail(error)); | ||
| 549 | const snapshot = yield* harness.provider.snapshot.refresh; | ||
| 550 | expect(snapshot).toMatchObject({ | ||
| 551 | installed, | ||
| 552 | status: "error", | ||
| 553 | auth: { status: "authenticated" }, | ||
| 554 | }); | ||
| 555 | expect(snapshot.models).toHaveLength(installed ? 11 : 0); | ||
| 556 | expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0); | ||
| 557 | expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0); | ||
| 558 | expect(snapshot.supportsTextGeneration).toBe(installed); | ||
| 559 | } | ||
| 560 | }), | ||
| 561 | ), | ||
| 562 | ); | ||
| 563 | |||
| 564 | it.effect("does not let an old health result restore a signed-out account", () => | ||
| 565 | Effect.scoped( | ||
| 566 | Effect.gen(function* () { | ||
| 567 | const harness = yield* makeHarness(); | ||
| 568 | yield* harness.initialize; | ||
| 569 | yield* harness.provider.onSessionStarted(started); | ||
| 570 | const releaseProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse>(); | ||
| 571 | const probeEntered = yield* Deferred.make<void>(); | ||
| 572 | yield* Ref.set( | ||
| 573 | harness.probe, | ||
| 574 | Deferred.succeed(probeEntered, undefined).pipe( | ||
| 575 | Effect.andThen(Deferred.await(releaseProbe)), | ||
| 576 | ), | ||
| 577 | ); | ||
| 578 | const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); | ||
| 579 | yield* Deferred.await(probeEntered); | ||
| 580 | yield* harness.provider.onSignedOut; | ||
| 581 | yield* Deferred.succeed(releaseProbe, initializeResult); | ||
| 582 | const snapshot = yield* Fiber.join(refresh); | ||
| 583 | expect(snapshot).toMatchObject({ | ||
| 584 | auth: { status: "unauthenticated" }, | ||
| 585 | models: [], | ||
| 586 | supportsTextGeneration: false, | ||
| 587 | }); | ||
| 588 | }), | ||
| 589 | ), | ||
| 590 | ); | ||
| 591 | |||
| 592 | it.effect("exposes helper support only when the supplied safety check allows it", () => | ||
| 593 | Effect.scoped( | ||
| 594 | Effect.gen(function* () { | ||
| 595 | const harness = yield* makeHarness({ safe: false }); | ||
| 596 | yield* harness.initialize; | ||
| 597 | yield* harness.provider.onSessionStarted(started); | ||
| 598 | expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false); | ||
| 599 | yield* Ref.set(harness.safety, Effect.succeed(true)); | ||
| 600 | yield* harness.provider.snapshot.refresh; | ||
| 601 | expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true); | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("keeps discovered workspace skills through session and command updates", () => | ||
| 607 | Effect.scoped( | ||
| 608 | Effect.gen(function* () { | ||
| 609 | const harness = yield* makeHarness(); | ||
| 610 | yield* harness.initialize; | ||
| 611 | const skills = [ | ||
| 612 | { | ||
| 613 | name: "deploy", | ||
| 614 | description: "Ship it", | ||
| 615 | path: "/workspace/.agent/skills/deploy", | ||
| 616 | enabled: true, | ||
| 617 | }, | ||
| 618 | ]; | ||
| 619 | const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); | ||
| 620 | expect(discovered.skills).toEqual(skills); | ||
| 621 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 622 | yield* harness.provider.onAvailableCommands(commands, "/workspace"); | ||
| 623 | const after = yield* harness.provider.snapshot.getSnapshot; | ||
| 624 | expect( | ||
| 625 | after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, | ||
| 626 | ).toEqual(skills); | ||
| 627 | expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); | ||
| 628 | |||
| 629 | const rescanned = [ | ||
| 630 | ...skills, | ||
| 631 | { name: "review", path: "/workspace/.agent/skills/review", enabled: true }, | ||
| 632 | ]; | ||
| 633 | yield* harness.provider.snapshotForCwd("/workspace", rescanned); | ||
| 634 | yield* harness.provider.onSessionStarted(started, "/workspace"); | ||
| 635 | expect( | ||
| 636 | (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find( | ||
| 637 | (entry) => entry.cwd === "/workspace", | ||
| 638 | )?.skills, | ||
| 639 | ).toEqual(rescanned); | ||
| 640 | }), | ||
| 641 | ), | ||
| 642 | ); | ||
| 643 | |||
| 644 | it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => | ||
| 645 | Effect.scoped( | ||
| 646 | Effect.gen(function* () { | ||
| 647 | const harness = yield* makeHarness(); | ||
| 648 | yield* harness.initialize; | ||
| 649 | yield* harness.provider.onSessionStarted(started); | ||
| 650 | for (let index = 0; index < 35; index++) { | ||
| 651 | yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`); | ||
| 652 | } | ||
| 653 | const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34"); | ||
| 654 | expect(snapshot.workspaceSnapshots).toHaveLength(32); | ||
| 655 | expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3"); | ||
| 656 | expect(snapshot.slashCommands).toEqual(expectedCommands); | ||
| 657 | expect(yield* Ref.get(harness.probeCalls)).toBe(1); | ||
| 658 | }), | ||
| 659 | ), | ||
| 660 | ); | ||
| 661 | }); | ||
apps/server/src/provider/Layers/AntigravityProvider.ts deleted-415| ... | @@ -1,415 +0,0 @@ | ||
| 1 | import { | ||
| 2 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 3 | ProviderDriverKind, | ||
| 4 | type AntigravitySettings, | ||
| 5 | type ProviderSetupError, | ||
| 6 | type ServerProvider, | ||
| 7 | type ServerProviderModel, | ||
| 8 | type ServerProviderSlashCommand, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Predicate from "effect/Predicate"; | ||
| 15 | import * as Result from "effect/Result"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 19 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 20 | |||
| 21 | import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | ||
| 22 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 23 | import { | ||
| 24 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 25 | type ProviderMaintenanceCapabilities, | ||
| 26 | } from "../providerMaintenance.ts"; | ||
| 27 | import { | ||
| 28 | buildServerProvider, | ||
| 29 | isCommandMissingCause, | ||
| 30 | type ServerProviderDraft, | ||
| 31 | } from "../providerSnapshot.ts"; | ||
| 32 | |||
| 33 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | ||
| 34 | const MAX_WORKSPACE_SNAPSHOTS = 32; | ||
| 35 | const HEALTH_CHECK_TIMEOUT = "90 seconds"; | ||
| 36 | const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; | ||
| 37 | const AUTH_UNCHECKED_MESSAGE = | ||
| 38 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 39 | |||
| 40 | type SessionSetupResult = Pick< | ||
| 41 | AcpSessionRuntimeStartResult["sessionSetupResult"], | ||
| 42 | "configOptions" | "models" | ||
| 43 | >; | ||
| 44 | |||
| 45 | /** Keep the native model IDs, including model-specific thinking levels. */ | ||
| 46 | export function buildAntigravityModelsFromSession( | ||
| 47 | setup: SessionSetupResult, | ||
| 48 | ): ReadonlyArray<ServerProviderModel> { | ||
| 49 | const config = setup.configOptions?.find( | ||
| 50 | (option) => option.id === "model" || option.category === "model", | ||
| 51 | ); | ||
| 52 | const currentValue = | ||
| 53 | config?.type === "select" ? config.currentValue : setup.models?.currentModelId; | ||
| 54 | const entries = | ||
| 55 | config?.type === "select" | ||
| 56 | ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) | ||
| 57 | : config === undefined | ||
| 58 | ? (setup.models?.availableModels.map((model) => ({ | ||
| 59 | value: model.modelId, | ||
| 60 | name: model.name, | ||
| 61 | })) ?? []) | ||
| 62 | : []; | ||
| 63 | const seen = new Set<string>(); | ||
| 64 | return entries.flatMap((entry): ServerProviderModel[] => { | ||
| 65 | if (!entry.value.trim() || seen.has(entry.value)) return []; | ||
| 66 | seen.add(entry.value); | ||
| 67 | return [ | ||
| 68 | { | ||
| 69 | slug: entry.value, | ||
| 70 | name: entry.name.trim() ? entry.name : entry.value, | ||
| 71 | isCustom: false, | ||
| 72 | ...(entry.value === currentValue | ||
| 73 | ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } | ||
| 74 | : {}), | ||
| 75 | capabilities: EMPTY_MODEL_CAPABILITIES, | ||
| 76 | }, | ||
| 77 | ]; | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | |||
| 81 | function nativeCommands( | ||
| 82 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 83 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 84 | const seen = new Set<string>(); | ||
| 85 | return commands.flatMap((command): ServerProviderSlashCommand[] => { | ||
| 86 | if (!command.name.trim() || seen.has(command.name)) return []; | ||
| 87 | seen.add(command.name); | ||
| 88 | const description = command.description.trim(); | ||
| 89 | const hint = command.input?.hint.trim(); | ||
| 90 | return [ | ||
| 91 | { | ||
| 92 | name: command.name, | ||
| 93 | ...(description ? { description } : {}), | ||
| 94 | ...(hint ? { input: { hint } } : {}), | ||
| 95 | }, | ||
| 96 | ]; | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean { | ||
| 101 | if (error._tag === "AcpSpawnError") { | ||
| 102 | return ( | ||
| 103 | isCommandMissingCause(error.cause) || | ||
| 104 | (Predicate.isObject(error.cause) && error.cause.code === "ENOENT") | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | return ( | ||
| 108 | error._tag === "ProviderSetupError" && | ||
| 109 | error.operation === "resolve" && | ||
| 110 | /not installed|missing|incomplete|does not publish/i.test(error.detail) | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | interface AntigravityProviderState { | ||
| 115 | readonly draft: ServerProviderDraft; | ||
| 116 | readonly authRevision: number; | ||
| 117 | } | ||
| 118 | |||
| 119 | interface AntigravityProviderOptions { | ||
| 120 | readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>; | ||
| 121 | readonly probe: Effect.Effect< | ||
| 122 | EffectAcpSchema.InitializeResponse, | ||
| 123 | EffectAcpErrors.AcpError | ProviderSetupError | ||
| 124 | >; | ||
| 125 | readonly supportsTextGeneration: Effect.Effect<boolean>; | ||
| 126 | readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; | ||
| 127 | /** Auth type and label published once a session authenticates. */ | ||
| 128 | readonly auth?: { readonly type: string; readonly label: string }; | ||
| 129 | } | ||
| 130 | |||
| 131 | /** Health uses initialize only. Session callbacks supply account-specific metadata. */ | ||
| 132 | export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* ( | ||
| 133 | settings: AntigravitySettings, | ||
| 134 | options: AntigravityProviderOptions, | ||
| 135 | ) { | ||
| 136 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 137 | const initialDraft = { | ||
| 138 | ...buildServerProvider({ | ||
| 139 | presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, | ||
| 140 | enabled: settings.enabled, | ||
| 141 | checkedAt, | ||
| 142 | models: [], | ||
| 143 | probe: { | ||
| 144 | installed: false, | ||
| 145 | version: null, | ||
| 146 | status: "warning", | ||
| 147 | // The configured method rides along so the registry can tell a saved | ||
| 148 | // account for this method from one left by a previous configuration. | ||
| 149 | auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, | ||
| 150 | message: settings.enabled | ||
| 151 | ? "Checking Antigravity availability." | ||
| 152 | : "Antigravity is disabled in T3 Code settings.", | ||
| 153 | }, | ||
| 154 | }), | ||
| 155 | setup: { canAuthenticate: true, canInstall: true }, | ||
| 156 | supportsConversationRollback: false, | ||
| 157 | supportsTextGeneration: false, | ||
| 158 | workspaceSnapshots: [], | ||
| 159 | } satisfies ServerProviderDraft; | ||
| 160 | const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({ | ||
| 161 | draft: initialDraft, | ||
| 162 | authRevision: 0, | ||
| 163 | }); | ||
| 164 | // Skills the driver discovered on disk per workspace. Session callbacks | ||
| 165 | // rewrite the workspace entry with native commands and must keep these, or | ||
| 166 | // the registry drops the suggestions and never re-reads the workspace. | ||
| 167 | const discoveredSkills = new Map<string, ServerProvider["skills"]>(); | ||
| 168 | const getSnapshot = SubscriptionRef.get(metadata).pipe( | ||
| 169 | Effect.flatMap((state) => options.stampIdentity(state.draft)), | ||
| 170 | ); | ||
| 171 | |||
| 172 | const checkProvider = Effect.fn("checkAntigravityProvider")(function* () { | ||
| 173 | if (!settings.enabled) return yield* getSnapshot; | ||
| 174 | const before = yield* SubscriptionRef.get(metadata); | ||
| 175 | const result = yield* options.probe.pipe( | ||
| 176 | Effect.timeoutOption(HEALTH_CHECK_TIMEOUT), | ||
| 177 | Effect.result, | ||
| 178 | ); | ||
| 179 | const initialized = | ||
| 180 | Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined; | ||
| 181 | const failure = Result.isFailure(result) ? result.failure : undefined; | ||
| 182 | const missingInstallation = failure !== undefined && isMissingInstallation(failure); | ||
| 183 | const errorMessage = | ||
| 184 | initialized !== undefined | ||
| 185 | ? undefined | ||
| 186 | : failure?._tag === "ProviderSetupError" | ||
| 187 | ? failure.detail.trim() || "Antigravity could not complete its local health check." | ||
| 188 | : missingInstallation | ||
| 189 | ? "Antigravity is not installed or its executable could not be found." | ||
| 190 | : failure | ||
| 191 | ? "Antigravity could not complete its local health check." | ||
| 192 | : `Antigravity did not respond to its local health check within ${HEALTH_CHECK_TIMEOUT}.`; | ||
| 193 | const supportsTextGeneration = | ||
| 194 | initialized !== undefined ? yield* options.supportsTextGeneration : false; | ||
| 195 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 196 | const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => { | ||
| 197 | if (state.authRevision !== before.authRevision) return state; | ||
| 198 | const { message: _previousMessage, ...draft } = state.draft; | ||
| 199 | const authenticated = draft.auth.status === "authenticated"; | ||
| 200 | const message = | ||
| 201 | errorMessage ?? | ||
| 202 | (authenticated | ||
| 203 | ? undefined | ||
| 204 | : draft.auth.status === "unauthenticated" | ||
| 205 | ? SIGN_IN_MESSAGE | ||
| 206 | : AUTH_UNCHECKED_MESSAGE); | ||
| 207 | return { | ||
| 208 | ...state, | ||
| 209 | draft: { | ||
| 210 | ...draft, | ||
| 211 | installed: !missingInstallation, | ||
| 212 | version: initialized?.agentInfo?.version || draft.version, | ||
| 213 | status: errorMessage ? "error" : authenticated ? "ready" : "warning", | ||
| 214 | checkedAt: updatedAt, | ||
| 215 | ...(missingInstallation | ||
| 216 | ? { | ||
| 217 | models: [], | ||
| 218 | slashCommands: [], | ||
| 219 | skills: [], | ||
| 220 | workspaceSnapshots: [], | ||
| 221 | supportsTextGeneration: false, | ||
| 222 | } | ||
| 223 | : {}), | ||
| 224 | ...(initialized !== undefined | ||
| 225 | ? { | ||
| 226 | supportsTextGeneration: | ||
| 227 | supportsTextGeneration && draft.auth.status !== "unauthenticated", | ||
| 228 | } | ||
| 229 | : {}), | ||
| 230 | ...(message ? { message } : {}), | ||
| 231 | }, | ||
| 232 | } satisfies AntigravityProviderState; | ||
| 233 | }); | ||
| 234 | return yield* options.stampIdentity(next.draft); | ||
| 235 | }); | ||
| 236 | |||
| 237 | const maintenanceCapabilities = | ||
| 238 | options.maintenanceCapabilities ?? | ||
| 239 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 240 | provider: ProviderDriverKind.make("antigravity"), | ||
| 241 | packageName: null, | ||
| 242 | }); | ||
| 243 | const managed = yield* makeManagedServerProvider({ | ||
| 244 | resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), | ||
| 245 | getSettings: Effect.succeed(settings), | ||
| 246 | streamSettings: Stream.empty, | ||
| 247 | haveSettingsChanged: () => false, | ||
| 248 | initialSnapshot: () => getSnapshot, | ||
| 249 | checkProvider: checkProvider(), | ||
| 250 | enrichSnapshot: ({ publishSnapshot }) => | ||
| 251 | SubscriptionRef.changes(metadata).pipe( | ||
| 252 | Stream.runForEach((state) => | ||
| 253 | options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)), | ||
| 254 | ), | ||
| 255 | ), | ||
| 256 | }); | ||
| 257 | |||
| 258 | const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* ( | ||
| 259 | started: AcpSessionRuntimeStartResult, | ||
| 260 | cwd?: string, | ||
| 261 | ) { | ||
| 262 | const before = yield* SubscriptionRef.get(metadata); | ||
| 263 | const supportsTextGeneration = yield* options.supportsTextGeneration; | ||
| 264 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 265 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 266 | if ( | ||
| 267 | state.authRevision !== before.authRevision && | ||
| 268 | state.draft.auth.status === "unauthenticated" | ||
| 269 | ) { | ||
| 270 | return state; | ||
| 271 | } | ||
| 272 | const { message: _previousMessage, ...draft } = state.draft; | ||
| 273 | const workspaces = draft.workspaceSnapshots ?? []; | ||
| 274 | const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; | ||
| 275 | return { | ||
| 276 | authRevision: state.authRevision + 1, | ||
| 277 | draft: { | ||
| 278 | ...draft, | ||
| 279 | installed: true, | ||
| 280 | status: settings.enabled ? "ready" : "disabled", | ||
| 281 | version: started.initializeResult.agentInfo?.version || draft.version, | ||
| 282 | auth: { | ||
| 283 | status: "authenticated", | ||
| 284 | type: options.auth?.type ?? "oauth-personal", | ||
| 285 | label: options.auth?.label ?? "Google account", | ||
| 286 | }, | ||
| 287 | checkedAt: updatedAt, | ||
| 288 | models: buildAntigravityModelsFromSession(started.sessionSetupResult), | ||
| 289 | supportsTextGeneration, | ||
| 290 | ...(cwd | ||
| 291 | ? { | ||
| 292 | workspaceSnapshots: [ | ||
| 293 | ...workspaces.filter((entry) => entry.cwd !== cwd), | ||
| 294 | { | ||
| 295 | cwd, | ||
| 296 | checkedAt: updatedAt, | ||
| 297 | slashCommands: workspace?.slashCommands ?? draft.slashCommands, | ||
| 298 | skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], | ||
| 299 | }, | ||
| 300 | ].slice(-MAX_WORKSPACE_SNAPSHOTS), | ||
| 301 | } | ||
| 302 | : {}), | ||
| 303 | }, | ||
| 304 | } satisfies AntigravityProviderState; | ||
| 305 | }); | ||
| 306 | }); | ||
| 307 | |||
| 308 | const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( | ||
| 309 | configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>, | ||
| 310 | ) { | ||
| 311 | const models = buildAntigravityModelsFromSession({ configOptions }); | ||
| 312 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 313 | if (state.draft.auth.status !== "authenticated") return state; | ||
| 314 | return { ...state, draft: { ...state.draft, models } }; | ||
| 315 | }); | ||
| 316 | }); | ||
| 317 | |||
| 318 | const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( | ||
| 319 | commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>, | ||
| 320 | cwd?: string, | ||
| 321 | ) { | ||
| 322 | const slashCommands = nativeCommands(commands); | ||
| 323 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 324 | yield* SubscriptionRef.update(metadata, (state) => { | ||
| 325 | if (state.draft.auth.status === "unauthenticated") return state; | ||
| 326 | return { | ||
| 327 | ...state, | ||
| 328 | draft: { | ||
| 329 | ...state.draft, | ||
| 330 | slashCommands, | ||
| 331 | ...(cwd | ||
| 332 | ? { | ||
| 333 | workspaceSnapshots: [ | ||
| 334 | ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), | ||
| 335 | { | ||
| 336 | cwd, | ||
| 337 | checkedAt: updatedAt, | ||
| 338 | slashCommands, | ||
| 339 | skills: | ||
| 340 | state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? | ||
| 341 | discoveredSkills.get(cwd) ?? | ||
| 342 | [], | ||
| 343 | }, | ||
| 344 | ].slice(-MAX_WORKSPACE_SNAPSHOTS), | ||
| 345 | } | ||
| 346 | : {}), | ||
| 347 | }, | ||
| 348 | }; | ||
| 349 | }); | ||
| 350 | }); | ||
| 351 | |||
| 352 | const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () { | ||
| 353 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 354 | yield* SubscriptionRef.update( | ||
| 355 | metadata, | ||
| 356 | (state) => | ||
| 357 | ({ | ||
| 358 | authRevision: state.authRevision + 1, | ||
| 359 | draft: { | ||
| 360 | ...state.draft, | ||
| 361 | auth: { status: "unauthenticated" }, | ||
| 362 | status: settings.enabled ? "warning" : "disabled", | ||
| 363 | message: SIGN_IN_MESSAGE, | ||
| 364 | checkedAt: updatedAt, | ||
| 365 | models: [], | ||
| 366 | slashCommands: [], | ||
| 367 | skills: [], | ||
| 368 | workspaceSnapshots: [], | ||
| 369 | supportsTextGeneration: false, | ||
| 370 | }, | ||
| 371 | }) satisfies AntigravityProviderState, | ||
| 372 | ); | ||
| 373 | discoveredSkills.clear(); | ||
| 374 | }); | ||
| 375 | |||
| 376 | const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( | ||
| 377 | cwd: string, | ||
| 378 | skills?: ServerProvider["skills"], | ||
| 379 | ) { | ||
| 380 | if (skills) { | ||
| 381 | discoveredSkills.set(cwd, skills); | ||
| 382 | // A rescan replaces the stored entry's skills. Session callbacks and | ||
| 383 | // health checks republish that entry, so it must not keep old ones. | ||
| 384 | yield* SubscriptionRef.update(metadata, (state) => | ||
| 385 | state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd) | ||
| 386 | ? { | ||
| 387 | ...state, | ||
| 388 | draft: { | ||
| 389 | ...state.draft, | ||
| 390 | workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) => | ||
| 391 | entry.cwd === cwd ? { ...entry, skills } : entry, | ||
| 392 | ), | ||
| 393 | }, | ||
| 394 | } | ||
| 395 | : state, | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | const snapshot = yield* getSnapshot; | ||
| 399 | const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); | ||
| 400 | const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; | ||
| 401 | return workspace | ||
| 402 | ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } | ||
| 403 | : { ...snapshot, skills: resolvedSkills }; | ||
| 404 | }); | ||
| 405 | |||
| 406 | return { | ||
| 407 | snapshot: { ...managed, getSnapshot }, | ||
| 408 | onSessionStarted, | ||
| 409 | onConfigOptionsUpdated, | ||
| 410 | onAvailableCommands, | ||
| 411 | onSignedOut: clearAccountMetadata(), | ||
| 412 | onAuthRequired: clearAccountMetadata(), | ||
| 413 | snapshotForCwd, | ||
| 414 | }; | ||
| 415 | }); | ||
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts deleted-355| ... | @@ -1,355 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. | ||
| 2 | import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; | ||
| 3 | import { vi } from "vite-plus/test"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as TestClock from "effect/testing/TestClock"; | ||
| 7 | import { | ||
| 8 | ClaudeSettings, | ||
| 9 | ProviderDriverKind, | ||
| 10 | ProviderInstanceId, | ||
| 11 | type ServerProvider, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import * as NodeFSP from "node:fs/promises"; | ||
| 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 15 | import { assert, it } from "@effect/vitest"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as FileSystem from "effect/FileSystem"; | ||
| 18 | import * as Path from "effect/Path"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | |||
| 21 | import { | ||
| 22 | buildClaudeCapabilitiesProbeQueryOptions, | ||
| 23 | CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, | ||
| 24 | probeClaudeCapabilities, | ||
| 25 | probeClaudeWorkspaceSnapshot, | ||
| 26 | } from "./ClaudeProvider.ts"; | ||
| 27 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | ||
| 28 | |||
| 29 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); | ||
| 30 | |||
| 31 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); | ||
| 32 | |||
| 33 | it("isolates Claude capability probes without dropping workspace setting sources", () => { | ||
| 34 | const abortController = new AbortController(); | ||
| 35 | const options = buildClaudeCapabilitiesProbeQueryOptions({ | ||
| 36 | executablePath: "/usr/bin/claude", | ||
| 37 | abortController, | ||
| 38 | environment: { | ||
| 39 | HOME: "/home/user", | ||
| 40 | ENABLE_CLAUDEAI_MCP_SERVERS: "true", | ||
| 41 | FORCE_CODE_TERMINAL: "1", | ||
| 42 | }, | ||
| 43 | cwd: "/workspace/project", | ||
| 44 | }); | ||
| 45 | |||
| 46 | assert.deepEqual(options.mcpServers, {}); | ||
| 47 | assert.equal(options.strictMcpConfig, true); | ||
| 48 | assert.equal(options.cwd, "/workspace/project"); | ||
| 49 | assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); | ||
| 50 | assert.deepEqual(options.settings, { disableAllHooks: true }); | ||
| 51 | assert.deepEqual(options.allowedTools, []); | ||
| 52 | assert.equal(options.persistSession, false); | ||
| 53 | assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); | ||
| 54 | assert.equal(options.abortController, abortController); | ||
| 55 | assert.equal(options.env?.HOME, "/home/user"); | ||
| 56 | assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); | ||
| 57 | assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); | ||
| 58 | assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); | ||
| 59 | assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { | ||
| 63 | it.effect( | ||
| 64 | "discovers commands and skills separately for each cwd without replacing machine metadata", | ||
| 65 | () => | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const fs = yield* FileSystem.FileSystem; | ||
| 68 | const path = yield* Path.Path; | ||
| 69 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); | ||
| 70 | const configDir = path.join(tempDir, "claude-home"); | ||
| 71 | const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; | ||
| 72 | for (const cwd of workspaces) { | ||
| 73 | const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); | ||
| 74 | yield* fs.makeDirectory(skillDir, { recursive: true }); | ||
| 75 | yield* fs.writeFileString( | ||
| 76 | path.join(skillDir, "SKILL.md"), | ||
| 77 | "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | const machineSnapshot = { | ||
| 81 | instanceId: ProviderInstanceId.make("claude"), | ||
| 82 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 83 | enabled: true, | ||
| 84 | installed: true, | ||
| 85 | status: "ready", | ||
| 86 | auth: { status: "authenticated", email: "machine@example.com" }, | ||
| 87 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 88 | version: "2.1.288", | ||
| 89 | models: [], | ||
| 90 | slashCommands: [{ name: "server-cwd-only" }], | ||
| 91 | skills: [], | ||
| 92 | } satisfies ServerProvider; | ||
| 93 | let usageCalls = 0; | ||
| 94 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { | ||
| 95 | assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); | ||
| 96 | assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); | ||
| 97 | return { | ||
| 98 | initializationResult: async () => ({ | ||
| 99 | account: { email: "workspace@example.com" }, | ||
| 100 | commands: [ | ||
| 101 | { | ||
| 102 | name: options?.cwd === workspaces[0] ? "start-session" : "other-project", | ||
| 103 | description: "Project command", | ||
| 104 | argumentHint: "[topic]", | ||
| 105 | }, | ||
| 106 | { name: "nested:review", description: "Review changes", argumentHint: "" }, | ||
| 107 | { name: "NESTED:review", description: "", argumentHint: "[path]" }, | ||
| 108 | { name: "compact", description: "Provider compact", argumentHint: "" }, | ||
| 109 | { name: "user-command", description: "Existing user command", argumentHint: "" }, | ||
| 110 | ], | ||
| 111 | }), | ||
| 112 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { | ||
| 113 | usageCalls++; | ||
| 114 | return { rate_limits_available: false, rate_limits: null }; | ||
| 115 | }, | ||
| 116 | } as ReturnType<typeof ClaudeSdk.query>; | ||
| 117 | }); | ||
| 118 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 119 | for (const [index, cwd] of workspaces.entries()) { | ||
| 120 | const scoped = yield* probeClaudeWorkspaceSnapshot( | ||
| 121 | decodeClaudeSettings({ homePath: configDir }), | ||
| 122 | machineSnapshot, | ||
| 123 | cwd, | ||
| 124 | { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, | ||
| 125 | ); | ||
| 126 | assert.deepEqual(scoped, { | ||
| 127 | ...machineSnapshot, | ||
| 128 | slashCommandsPending: false, | ||
| 129 | slashCommands: [ | ||
| 130 | COMPACT_SLASH_COMMAND, | ||
| 131 | { | ||
| 132 | name: index === 0 ? "start-session" : "other-project", | ||
| 133 | description: "Project command", | ||
| 134 | input: { hint: "[topic]" }, | ||
| 135 | }, | ||
| 136 | { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, | ||
| 137 | { name: "user-command", description: "Existing user command" }, | ||
| 138 | ], | ||
| 139 | skills: [ | ||
| 140 | { | ||
| 141 | name: "existing-skill", | ||
| 142 | path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), | ||
| 143 | enabled: true, | ||
| 144 | scope: "project", | ||
| 145 | description: "Existing project skill", | ||
| 146 | }, | ||
| 147 | ], | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | assert.deepEqual( | ||
| 151 | query.mock.calls.map(([input]) => input.options?.cwd), | ||
| 152 | workspaces, | ||
| 153 | ); | ||
| 154 | assert.equal(usageCalls, 0); | ||
| 155 | }).pipe(Effect.scoped), | ||
| 156 | ); | ||
| 157 | |||
| 158 | it.effect("keeps readable skills during failed command discovery and recovers on retry", () => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const fs = yield* FileSystem.FileSystem; | ||
| 161 | const path = yield* Path.Path; | ||
| 162 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); | ||
| 163 | const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); | ||
| 164 | yield* fs.makeDirectory(skillDir, { recursive: true }); | ||
| 165 | yield* fs.writeFileString( | ||
| 166 | path.join(skillDir, "SKILL.md"), | ||
| 167 | "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", | ||
| 168 | ); | ||
| 169 | const machineSnapshot = { | ||
| 170 | instanceId: ProviderInstanceId.make("claude"), | ||
| 171 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 172 | enabled: true, | ||
| 173 | installed: true, | ||
| 174 | status: "ready", | ||
| 175 | auth: { status: "authenticated" }, | ||
| 176 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 177 | version: "2.1.288", | ||
| 178 | models: [], | ||
| 179 | slashCommands: [{ name: "server-cwd-only" }], | ||
| 180 | skills: [], | ||
| 181 | } satisfies ServerProvider; | ||
| 182 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( | ||
| 183 | () => | ||
| 184 | ({ | ||
| 185 | initializationResult: () => | ||
| 186 | Promise.reject<ClaudeSdk.SDKControlInitializeResponse>( | ||
| 187 | new Error("Initialization failed"), | ||
| 188 | ), | ||
| 189 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ | ||
| 190 | rate_limits_available: false, | ||
| 191 | rate_limits: null, | ||
| 192 | }), | ||
| 193 | }) as ReturnType<typeof ClaudeSdk.query>, | ||
| 194 | ); | ||
| 195 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 196 | const settings = decodeClaudeSettings({ homePath: cwd }); | ||
| 197 | const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); | ||
| 198 | assert.deepEqual(failed, { | ||
| 199 | ...machineSnapshot, | ||
| 200 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 201 | slashCommandsPending: true, | ||
| 202 | skills: [ | ||
| 203 | { | ||
| 204 | name: "existing-skill", | ||
| 205 | path: path.join(skillDir, "SKILL.md"), | ||
| 206 | enabled: true, | ||
| 207 | scope: "project", | ||
| 208 | description: "Existing project skill", | ||
| 209 | }, | ||
| 210 | ], | ||
| 211 | }); | ||
| 212 | query.mockImplementation( | ||
| 213 | () => | ||
| 214 | ({ | ||
| 215 | initializationResult: async () => ({ | ||
| 216 | commands: [{ name: "recovered", description: "", argumentHint: "" }], | ||
| 217 | }), | ||
| 218 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ | ||
| 219 | rate_limits_available: false, | ||
| 220 | rate_limits: null, | ||
| 221 | }), | ||
| 222 | }) as ReturnType<typeof ClaudeSdk.query>, | ||
| 223 | ); | ||
| 224 | const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); | ||
| 225 | assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); | ||
| 226 | assert.equal(recovered.status, "ready"); | ||
| 227 | assert.equal(recovered.slashCommandsPending, false); | ||
| 228 | assert.deepEqual(recovered.skills, failed.skills); | ||
| 229 | const disabled = yield* probeClaudeWorkspaceSnapshot( | ||
| 230 | { ...settings, enabled: false }, | ||
| 231 | machineSnapshot, | ||
| 232 | cwd, | ||
| 233 | ); | ||
| 234 | assert.equal(disabled, machineSnapshot); | ||
| 235 | assert.equal(query.mock.calls.length, 2); | ||
| 236 | }).pipe(Effect.scoped), | ||
| 237 | ); | ||
| 238 | |||
| 239 | it.effect("serializes strict no-MCP options and still resolves account capabilities", () => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const fs = yield* FileSystem.FileSystem; | ||
| 242 | const path = yield* Path.Path; | ||
| 243 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); | ||
| 244 | const executablePath = yield* path.fromFileUrl( | ||
| 245 | new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), | ||
| 246 | ); | ||
| 247 | const invocationPath = path.join(tempDir, "invocation.json"); | ||
| 248 | // The probe aborts the SDK without awaiting the child's exit, and on | ||
| 249 | // Windows a directory that is still some process's cwd cannot be | ||
| 250 | // removed. Keep the workspace outside the scoped directory and let it | ||
| 251 | // go with a retrying removal once the child has gone. | ||
| 252 | const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); | ||
| 253 | // Node's own retry rather than an Effect schedule: it.effect runs on a | ||
| 254 | // TestClock, so a scheduled retry would wait for time nobody advances. | ||
| 255 | // If the child still holds the directory after that, an empty temp | ||
| 256 | // directory is left behind rather than failing the test for it. | ||
| 257 | yield* Effect.addFinalizer(() => | ||
| 258 | Effect.promise(() => | ||
| 259 | NodeFSP.rm(workspaceCwd, { | ||
| 260 | recursive: true, | ||
| 261 | force: true, | ||
| 262 | maxRetries: 20, | ||
| 263 | retryDelay: 250, | ||
| 264 | }).catch(() => undefined), | ||
| 265 | ), | ||
| 266 | ); | ||
| 267 | |||
| 268 | const capabilities = yield* probeClaudeCapabilities( | ||
| 269 | decodeClaudeSettings({ binaryPath: executablePath }), | ||
| 270 | { | ||
| 271 | ...process.env, | ||
| 272 | T3_PROBE_INVOCATION_PATH: invocationPath, | ||
| 273 | ENABLE_CLAUDEAI_MCP_SERVERS: "true", | ||
| 274 | }, | ||
| 275 | workspaceCwd, | ||
| 276 | ); | ||
| 277 | |||
| 278 | assert.deepEqual(capabilities, { | ||
| 279 | email: "dev@example.com", | ||
| 280 | subscriptionType: "pro", | ||
| 281 | tokenSource: "oauth", | ||
| 282 | apiProvider: undefined, | ||
| 283 | slashCommands: [ | ||
| 284 | { | ||
| 285 | name: "review", | ||
| 286 | description: "Review changes", | ||
| 287 | input: { hint: "[path]" }, | ||
| 288 | }, | ||
| 289 | ], | ||
| 290 | usage: { | ||
| 291 | rate_limits_available: true, | ||
| 292 | rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, | ||
| 293 | }, | ||
| 294 | }); | ||
| 295 | |||
| 296 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 297 | const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { | ||
| 298 | readonly args: ReadonlyArray<string>; | ||
| 299 | readonly cwd: string; | ||
| 300 | readonly connectorEnv: string; | ||
| 301 | readonly mcpConfig: unknown; | ||
| 302 | }; | ||
| 303 | assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); | ||
| 304 | assert.equal(invocation.connectorEnv, "false"); | ||
| 305 | assert.equal(invocation.args.includes("--strict-mcp-config"), true); | ||
| 306 | assert.equal(invocation.args.includes("--mcp-config"), false); | ||
| 307 | assert.equal(invocation.mcpConfig, undefined); | ||
| 308 | |||
| 309 | assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); | ||
| 310 | |||
| 311 | const settingsFlagIndex = invocation.args.indexOf("--settings"); | ||
| 312 | assert.notEqual(settingsFlagIndex, -1); | ||
| 313 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 314 | const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { | ||
| 315 | readonly disableAllHooks?: boolean; | ||
| 316 | }; | ||
| 317 | assert.equal(flagSettings.disableAllHooks, true); | ||
| 318 | }).pipe(Effect.scoped), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | |||
| 322 | it.effect("preserves initialized capabilities when optional usage times out", () => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const usageStarted = yield* Deferred.make<void>(); | ||
| 325 | let abortSignal: AbortSignal | undefined; | ||
| 326 | const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { | ||
| 327 | abortSignal = options?.abortController?.signal; | ||
| 328 | return { | ||
| 329 | initializationResult: async () => ({ | ||
| 330 | account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 331 | commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], | ||
| 332 | }), | ||
| 333 | usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { | ||
| 334 | Deferred.doneUnsafe(usageStarted, Effect.void); | ||
| 335 | return new Promise(() => {}); | ||
| 336 | }, | ||
| 337 | } as ReturnType<typeof ClaudeSdk.query>; | ||
| 338 | }); | ||
| 339 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | ||
| 340 | const probe = yield* probeClaudeCapabilities( | ||
| 341 | decodeClaudeSettings({ binaryPath: "claude" }), | ||
| 342 | ).pipe(Effect.forkChild); | ||
| 343 | yield* Deferred.await(usageStarted); | ||
| 344 | yield* TestClock.adjust("4 seconds"); | ||
| 345 | const capabilities = yield* Fiber.join(probe); | ||
| 346 | assert.equal(capabilities?.email, "dev@example.com"); | ||
| 347 | assert.equal(capabilities?.subscriptionType, "pro"); | ||
| 348 | assert.equal(capabilities?.tokenSource, "oauth"); | ||
| 349 | assert.deepEqual(capabilities?.slashCommands, [ | ||
| 350 | { name: "review", description: "Review changes", input: { hint: "[path]" } }, | ||
| 351 | ]); | ||
| 352 | assert.equal(capabilities?.usage, undefined); | ||
| 353 | assert.equal(abortSignal?.aborted, true); | ||
| 354 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 355 | ); | ||
apps/server/src/provider/Layers/ClaudeProvider.ts deleted-675| ... | @@ -1,675 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type ClaudeSettings, | ||
| 3 | type ModelCapabilities, | ||
| 4 | type ServerProvider, | ||
| 5 | type ServerProviderSlashCommand, | ||
| 6 | type ServerProviderResetCredits, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Result from "effect/Result"; | ||
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 16 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 17 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 18 | import { | ||
| 19 | query as claudeQuery, | ||
| 20 | type Options as ClaudeQueryOptions, | ||
| 21 | type SlashCommand as ClaudeSlashCommand, | ||
| 22 | type SDKControlGetUsageResponse, | ||
| 23 | type SDKUserMessage, | ||
| 24 | type SettingSource, | ||
| 25 | } from "@anthropic-ai/claude-agent-sdk"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | buildServerProvider, | ||
| 29 | COMPACT_SLASH_COMMAND, | ||
| 30 | DEFAULT_TIMEOUT_MS, | ||
| 31 | isCommandMissingCause, | ||
| 32 | parseGenericCliVersion, | ||
| 33 | providerModelsFromSettings, | ||
| 34 | spawnAndCollect, | ||
| 35 | type ServerProviderDraft, | ||
| 36 | } from "../providerSnapshot.ts"; | ||
| 37 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | ||
| 38 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | ||
| 39 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; | ||
| 40 | import type { ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | ||
| 41 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; | ||
| 42 | import { | ||
| 43 | type ClaudeScopedLimitNames, | ||
| 44 | claudeUsageResponseToLimits, | ||
| 45 | recordClaudeUsageResponse, | ||
| 46 | } from "./claudeUsageLimits.ts"; | ||
| 47 | import { | ||
| 48 | BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 49 | type ClaudeModelCatalog, | ||
| 50 | formatClaudeVersionUpgradeMessage, | ||
| 51 | resolveClaudeModelsForVersion, | ||
| 52 | } from "../ClaudeModelCatalog.ts"; | ||
| 53 | |||
| 54 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const CLAUDE_PRESENTATION = { | ||
| 59 | displayName: "Claude", | ||
| 60 | showInteractionModeToggle: true, | ||
| 61 | reportsContextWindow: true, | ||
| 62 | } as const; | ||
| 63 | function toTitleCaseWords(value: string): string { | ||
| 64 | const parts: Array<string> = []; | ||
| 65 | for (const part of value.split(/[\s_-]+/g)) { | ||
| 66 | if (part.length > 0) { | ||
| 67 | parts.push(part[0]!.toUpperCase() + part.slice(1).toLowerCase()); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | return parts.join(" "); | ||
| 71 | } | ||
| 72 | |||
| 73 | function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined { | ||
| 74 | const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 75 | if (!normalized) return undefined; | ||
| 76 | |||
| 77 | switch (normalized) { | ||
| 78 | case "claudemaxsubscription": | ||
| 79 | return "Max"; | ||
| 80 | case "claudemax5xsubscription": | ||
| 81 | return "Max 5x"; | ||
| 82 | case "claudemax20xsubscription": | ||
| 83 | return "Max 20x"; | ||
| 84 | case "claudeenterprisesubscription": | ||
| 85 | return "Enterprise"; | ||
| 86 | case "claudeteamsubscription": | ||
| 87 | return "Team"; | ||
| 88 | case "claudeprosubscription": | ||
| 89 | return "Pro"; | ||
| 90 | case "claudefreesubscription": | ||
| 91 | return "Free"; | ||
| 92 | case "max": | ||
| 93 | case "maxplan": | ||
| 94 | return "Max"; | ||
| 95 | case "max5": | ||
| 96 | return "Max 5x"; | ||
| 97 | case "max20": | ||
| 98 | return "Max 20x"; | ||
| 99 | case "enterprise": | ||
| 100 | return "Enterprise"; | ||
| 101 | case "team": | ||
| 102 | return "Team"; | ||
| 103 | case "pro": | ||
| 104 | return "Pro"; | ||
| 105 | case "free": | ||
| 106 | return "Free"; | ||
| 107 | default: | ||
| 108 | return toTitleCaseWords(subscriptionType!); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { | ||
| 113 | const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 114 | if (!normalized) return undefined; | ||
| 115 | if ( | ||
| 116 | normalized === "apikey" || | ||
| 117 | normalized === "anthropicapikey" || | ||
| 118 | normalized === "anthropicauthtoken" | ||
| 119 | ) { | ||
| 120 | return "apiKey"; | ||
| 121 | } | ||
| 122 | return undefined; | ||
| 123 | } | ||
| 124 | |||
| 125 | function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { | ||
| 126 | const subscriptionLabel = | ||
| 127 | claudeSubscriptionLabel(subscriptionType) ?? toTitleCaseWords(subscriptionType); | ||
| 128 | const normalized = subscriptionLabel.toLowerCase().replace(/[\s_-]+/g, ""); | ||
| 129 | |||
| 130 | if (normalized.startsWith("claude") && normalized.endsWith("subscription")) { | ||
| 131 | return subscriptionLabel; | ||
| 132 | } | ||
| 133 | if (normalized.startsWith("claude")) { | ||
| 134 | return `${subscriptionLabel} Subscription`; | ||
| 135 | } | ||
| 136 | if (normalized.endsWith("subscription")) { | ||
| 137 | return `Claude ${subscriptionLabel}`; | ||
| 138 | } | ||
| 139 | return `Claude ${subscriptionLabel} Subscription`; | ||
| 140 | } | ||
| 141 | |||
| 142 | function claudeAuthMetadata(input: { | ||
| 143 | readonly subscriptionType: string | undefined; | ||
| 144 | readonly authMethod: string | undefined; | ||
| 145 | }): { readonly type: string; readonly label: string } | undefined { | ||
| 146 | if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { | ||
| 147 | return { | ||
| 148 | type: "apiKey", | ||
| 149 | label: "Claude API Key", | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | if (input.subscriptionType) { | ||
| 154 | return { | ||
| 155 | type: input.subscriptionType, | ||
| 156 | label: formatClaudeSubscriptionAuthLabel(input.subscriptionType), | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | return undefined; | ||
| 161 | } | ||
| 162 | |||
| 163 | function apiProviderAuthMetadata( | ||
| 164 | apiProvider: string | undefined, | ||
| 165 | ): { readonly type: string; readonly label: string } | undefined { | ||
| 166 | return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; | ||
| 167 | } | ||
| 168 | |||
| 169 | // ── SDK capability probe ──────────────────────────────────────────── | ||
| 170 | |||
| 171 | // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the | ||
| 172 | // Bedrock backend and runs the `awsAuthRefresh` credential hook before returning | ||
| 173 | // account info. The previous 8s budget expired mid-init, so the probe returned | ||
| 174 | // `undefined` and left the provider unverified and unselectable in the picker. | ||
| 175 | const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; | ||
| 176 | |||
| 177 | /** | ||
| 178 | * Keep workspace-scoped command discovery intact while isolating the periodic | ||
| 179 | * health check from configured MCP servers. | ||
| 180 | */ | ||
| 181 | export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ | ||
| 182 | "user", | ||
| 183 | "project", | ||
| 184 | "local", | ||
| 185 | ] as const satisfies ReadonlyArray<SettingSource>; | ||
| 186 | |||
| 187 | /** Build the exact SDK options used by the periodic Claude capability probe. */ | ||
| 188 | export function buildClaudeCapabilitiesProbeQueryOptions(input: { | ||
| 189 | readonly executablePath: string; | ||
| 190 | readonly abortController: AbortController; | ||
| 191 | readonly environment: NodeJS.ProcessEnv; | ||
| 192 | readonly cwd: string | undefined; | ||
| 193 | }): ClaudeQueryOptions { | ||
| 194 | return { | ||
| 195 | persistSession: false, | ||
| 196 | pathToClaudeCodeExecutable: input.executablePath, | ||
| 197 | abortController: input.abortController, | ||
| 198 | settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], | ||
| 199 | // The probe keeps filesystem setting sources for slash-command discovery, | ||
| 200 | // but must not run the user's hooks: it fires every few minutes, so | ||
| 201 | // SessionStart hooks would run on every health check. | ||
| 202 | settings: { disableAllHooks: true }, | ||
| 203 | allowedTools: [], | ||
| 204 | // Ignore MCP definitions from every filesystem setting source above. The | ||
| 205 | // SDK combines this empty explicit map with --strict-mcp-config. | ||
| 206 | mcpServers: {}, | ||
| 207 | strictMcpConfig: true, | ||
| 208 | env: { | ||
| 209 | ...input.environment, | ||
| 210 | // Connected claude.ai MCP servers are discovered outside filesystem | ||
| 211 | // config; disable them independently for this health check. | ||
| 212 | ENABLE_CLAUDEAI_MCP_SERVERS: "false", | ||
| 213 | // This is a noninteractive health check, so IDE discovery cannot add any | ||
| 214 | // useful capability data. Skipping it also avoids Claude spawning a | ||
| 215 | // Windows `tasklist | findstr` process tree on every periodic refresh. | ||
| 216 | FORCE_CODE_TERMINAL: undefined, | ||
| 217 | CLAUDE_CODE_AUTO_CONNECT_IDE: "0", | ||
| 218 | CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", | ||
| 219 | }, | ||
| 220 | ...(input.cwd ? { cwd: input.cwd } : {}), | ||
| 221 | stderr: () => {}, | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | |||
| 225 | function nonEmptyProbeString(value: string): string | undefined { | ||
| 226 | const candidate = value.trim(); | ||
| 227 | return candidate ? candidate : undefined; | ||
| 228 | } | ||
| 229 | |||
| 230 | type ClaudeCapabilitiesProbe = { | ||
| 231 | readonly email: string | undefined; | ||
| 232 | readonly subscriptionType: string | undefined; | ||
| 233 | readonly tokenSource: string | undefined; | ||
| 234 | /** | ||
| 235 | * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth | ||
| 236 | * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) | ||
| 237 | * the subscription/token fields are absent and auth is external AWS creds. | ||
| 238 | */ | ||
| 239 | readonly apiProvider: string | undefined; | ||
| 240 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 241 | /** | ||
| 242 | * Subscription windows from the SDK's `get_usage` control request, or | ||
| 243 | * `undefined` when the request itself failed. Absent windows on an | ||
| 244 | * otherwise successful response mean the account has none (API key). | ||
| 245 | */ | ||
| 246 | readonly usage?: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | ||
| 247 | }; | ||
| 248 | |||
| 249 | function parseClaudeInitializationCommands( | ||
| 250 | commands: ReadonlyArray<ClaudeSlashCommand> | undefined, | ||
| 251 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 252 | return dedupeSlashCommands( | ||
| 253 | (commands ?? []).flatMap((command) => { | ||
| 254 | const name = nonEmptyProbeString(command.name); | ||
| 255 | if (!name) { | ||
| 256 | return []; | ||
| 257 | } | ||
| 258 | |||
| 259 | const description = nonEmptyProbeString(command.description); | ||
| 260 | const argumentHint = nonEmptyProbeString(command.argumentHint); | ||
| 261 | |||
| 262 | return [ | ||
| 263 | { | ||
| 264 | name, | ||
| 265 | ...(description ? { description } : {}), | ||
| 266 | ...(argumentHint ? { input: { hint: argumentHint } } : {}), | ||
| 267 | } satisfies ServerProviderSlashCommand, | ||
| 268 | ]; | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | } | ||
| 272 | |||
| 273 | function dedupeSlashCommands( | ||
| 274 | commands: ReadonlyArray<ServerProviderSlashCommand>, | ||
| 275 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 276 | const commandsByName = new Map<string, ServerProviderSlashCommand>(); | ||
| 277 | |||
| 278 | for (const command of commands) { | ||
| 279 | const name = nonEmptyProbeString(command.name); | ||
| 280 | if (!name) { | ||
| 281 | continue; | ||
| 282 | } | ||
| 283 | |||
| 284 | const key = name.toLowerCase(); | ||
| 285 | const existing = commandsByName.get(key); | ||
| 286 | if (!existing) { | ||
| 287 | commandsByName.set(key, { | ||
| 288 | ...command, | ||
| 289 | name, | ||
| 290 | }); | ||
| 291 | continue; | ||
| 292 | } | ||
| 293 | |||
| 294 | commandsByName.set(key, { | ||
| 295 | ...existing, | ||
| 296 | ...(existing.description | ||
| 297 | ? {} | ||
| 298 | : command.description | ||
| 299 | ? { description: command.description } | ||
| 300 | : {}), | ||
| 301 | ...(existing.input?.hint | ||
| 302 | ? {} | ||
| 303 | : command.input?.hint | ||
| 304 | ? { input: { hint: command.input.hint } } | ||
| 305 | : {}), | ||
| 306 | }); | ||
| 307 | } | ||
| 308 | |||
| 309 | return [...commandsByName.values()]; | ||
| 310 | } | ||
| 311 | |||
| 312 | function waitForAbortSignal(signal: AbortSignal): Promise<void> { | ||
| 313 | if (signal.aborted) { | ||
| 314 | return Promise.resolve(); | ||
| 315 | } | ||
| 316 | return new Promise((resolve) => { | ||
| 317 | signal.addEventListener("abort", () => resolve(), { once: true }); | ||
| 318 | }); | ||
| 319 | } | ||
| 320 | |||
| 321 | /** | ||
| 322 | * Probe account information by spawning a lightweight Claude Agent SDK | ||
| 323 | * session and reading the initialization result. | ||
| 324 | * | ||
| 325 | * We pass a never-yielding AsyncIterable as the prompt so that no user | ||
| 326 | * message is ever written to the subprocess stdin. This means the Claude | ||
| 327 | * Code subprocess completes its local initialization IPC (returning | ||
| 328 | * account info and slash commands) but never starts an API request to | ||
| 329 | * Anthropic. We read the init data and then abort the subprocess. | ||
| 330 | * | ||
| 331 | * This is used as a fallback when `claude auth status` does not include | ||
| 332 | * subscription type information. | ||
| 333 | */ | ||
| 334 | const probeClaudeCapabilities = ( | ||
| 335 | claudeSettings: ClaudeSettings, | ||
| 336 | environment?: NodeJS.ProcessEnv, | ||
| 337 | cwd?: string, | ||
| 338 | includeUsage = true, | ||
| 339 | ) => { | ||
| 340 | const abort = new AbortController(); | ||
| 341 | return Effect.gen(function* () { | ||
| 342 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 343 | const executablePath = yield* resolveClaudeSdkExecutablePath( | ||
| 344 | claudeSettings.binaryPath, | ||
| 345 | claudeEnvironment, | ||
| 346 | ); | ||
| 347 | return yield* Effect.tryPromise(async () => { | ||
| 348 | const q = claudeQuery({ | ||
| 349 | // Never yield — we only need initialization data, not a conversation. | ||
| 350 | // This prevents any prompt from reaching the Anthropic API. | ||
| 351 | // oxlint-disable-next-line require-yield | ||
| 352 | prompt: (async function* (): AsyncGenerator<SDKUserMessage> { | ||
| 353 | await waitForAbortSignal(abort.signal); | ||
| 354 | })(), | ||
| 355 | options: buildClaudeCapabilitiesProbeQueryOptions({ | ||
| 356 | executablePath, | ||
| 357 | abortController: abort, | ||
| 358 | environment: claudeEnvironment, | ||
| 359 | cwd, | ||
| 360 | }), | ||
| 361 | }); | ||
| 362 | const init = await q.initializationResult(); | ||
| 363 | return { q, init }; | ||
| 364 | }); | ||
| 365 | }).pipe( | ||
| 366 | Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), | ||
| 367 | Effect.flatMap(({ q, init }) => | ||
| 368 | Effect.gen(function* () { | ||
| 369 | // Usage has its own deadline so a slow optional request cannot discard initialization. | ||
| 370 | const usageResult = includeUsage | ||
| 371 | ? yield* Effect.tryPromise(() => | ||
| 372 | q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), | ||
| 373 | ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result) | ||
| 374 | : undefined; | ||
| 375 | const usage = | ||
| 376 | usageResult && Result.isSuccess(usageResult) | ||
| 377 | ? { | ||
| 378 | rate_limits_available: usageResult.success.rate_limits_available, | ||
| 379 | rate_limits: usageResult.success.rate_limits, | ||
| 380 | } | ||
| 381 | : undefined; | ||
| 382 | const account = init.account as | ||
| 383 | | { | ||
| 384 | readonly email?: string; | ||
| 385 | readonly subscriptionType?: string; | ||
| 386 | readonly tokenSource?: string; | ||
| 387 | readonly apiProvider?: string; | ||
| 388 | } | ||
| 389 | | undefined; | ||
| 390 | return { | ||
| 391 | email: account?.email, | ||
| 392 | subscriptionType: account?.subscriptionType, | ||
| 393 | tokenSource: account?.tokenSource, | ||
| 394 | apiProvider: account?.apiProvider, | ||
| 395 | slashCommands: parseClaudeInitializationCommands(init.commands), | ||
| 396 | ...(usage ? { usage } : {}), | ||
| 397 | } satisfies ClaudeCapabilitiesProbe; | ||
| 398 | }), | ||
| 399 | ), | ||
| 400 | Effect.ensuring( | ||
| 401 | Effect.sync(() => { | ||
| 402 | if (!abort.signal.aborted) abort.abort(); | ||
| 403 | }), | ||
| 404 | ), | ||
| 405 | Effect.result, | ||
| 406 | Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), | ||
| 407 | ); | ||
| 408 | }; | ||
| 409 | |||
| 410 | const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( | ||
| 411 | claudeSettings: ClaudeSettings, | ||
| 412 | args: ReadonlyArray<string>, | ||
| 413 | environment?: NodeJS.ProcessEnv, | ||
| 414 | ) { | ||
| 415 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 416 | const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { | ||
| 417 | env: claudeEnvironment, | ||
| 418 | }); | ||
| 419 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 420 | env: claudeEnvironment, | ||
| 421 | shell: spawnCommand.shell, | ||
| 422 | }); | ||
| 423 | return yield* spawnAndCollect(claudeSettings.binaryPath, command); | ||
| 424 | }); | ||
| 425 | |||
| 426 | /** Read commands from the same cwd Claude uses for a workspace session. */ | ||
| 427 | export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnapshot")(function* ( | ||
| 428 | claudeSettings: ClaudeSettings, | ||
| 429 | machineSnapshot: ServerProvider, | ||
| 430 | cwd: string, | ||
| 431 | environment?: NodeJS.ProcessEnv, | ||
| 432 | ): Effect.fn.Return<ProviderWorkspaceSnapshot, never, FileSystem.FileSystem | Path.Path> { | ||
| 433 | if (!claudeSettings.enabled) return machineSnapshot; | ||
| 434 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); | ||
| 435 | const capabilities = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false); | ||
| 436 | return { | ||
| 437 | ...machineSnapshot, | ||
| 438 | skills, | ||
| 439 | slashCommands: dedupeSlashCommands([ | ||
| 440 | COMPACT_SLASH_COMMAND, | ||
| 441 | ...(capabilities?.slashCommands ?? []), | ||
| 442 | ]), | ||
| 443 | slashCommandsPending: !capabilities, | ||
| 444 | }; | ||
| 445 | }); | ||
| 446 | |||
| 447 | export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( | ||
| 448 | claudeSettings: ClaudeSettings, | ||
| 449 | resolveCapabilities?: ( | ||
| 450 | claudeSettings: ClaudeSettings, | ||
| 451 | ) => Effect.Effect<ClaudeCapabilitiesProbe | undefined>, | ||
| 452 | environment?: NodeJS.ProcessEnv, | ||
| 453 | cwd?: string, | ||
| 454 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 455 | /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ | ||
| 456 | scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>, | ||
| 457 | /** Banked resets for a subscription login, given the CLI version for the user agent. */ | ||
| 458 | resolveResetCredits?: (version: string) => Effect.Effect<ServerProviderResetCredits | undefined>, | ||
| 459 | ): Effect.fn.Return< | ||
| 460 | ServerProviderDraft, | ||
| 461 | never, | ||
| 462 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | ||
| 463 | > { | ||
| 464 | const resolvedEnvironment = environment ?? process.env; | ||
| 465 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 466 | const allModels = providerModelsFromSettings( | ||
| 467 | modelCatalog.models.map((entry) => entry.model), | ||
| 468 | claudeSettings.customModels, | ||
| 469 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 470 | ); | ||
| 471 | |||
| 472 | if (!claudeSettings.enabled) { | ||
| 473 | return buildServerProvider({ | ||
| 474 | presentation: CLAUDE_PRESENTATION, | ||
| 475 | enabled: false, | ||
| 476 | checkedAt, | ||
| 477 | models: allModels, | ||
| 478 | probe: { | ||
| 479 | installed: false, | ||
| 480 | version: null, | ||
| 481 | status: "warning", | ||
| 482 | auth: { status: "unknown" }, | ||
| 483 | message: "Claude is disabled in T3 Code settings.", | ||
| 484 | }, | ||
| 485 | }); | ||
| 486 | } | ||
| 487 | |||
| 488 | const versionProbe = yield* runClaudeCommand( | ||
| 489 | claudeSettings, | ||
| 490 | ["--version"], | ||
| 491 | resolvedEnvironment, | ||
| 492 | ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); | ||
| 493 | |||
| 494 | if (Result.isFailure(versionProbe)) { | ||
| 495 | const error = versionProbe.failure; | ||
| 496 | yield* Effect.logWarning("Claude Agent CLI health check failed.", { | ||
| 497 | errorTag: error._tag, | ||
| 498 | }); | ||
| 499 | return buildServerProvider({ | ||
| 500 | presentation: CLAUDE_PRESENTATION, | ||
| 501 | enabled: claudeSettings.enabled, | ||
| 502 | checkedAt, | ||
| 503 | models: allModels, | ||
| 504 | probe: { | ||
| 505 | installed: !isCommandMissingCause(error), | ||
| 506 | version: null, | ||
| 507 | status: "error", | ||
| 508 | auth: { status: "unknown" }, | ||
| 509 | message: isCommandMissingCause(error) | ||
| 510 | ? "Claude Agent CLI (`claude`) was not found on PATH." | ||
| 511 | : "Failed to execute Claude Agent CLI health check.", | ||
| 512 | }, | ||
| 513 | }); | ||
| 514 | } | ||
| 515 | |||
| 516 | if (Option.isNone(versionProbe.success)) { | ||
| 517 | return buildServerProvider({ | ||
| 518 | presentation: CLAUDE_PRESENTATION, | ||
| 519 | enabled: claudeSettings.enabled, | ||
| 520 | checkedAt, | ||
| 521 | models: allModels, | ||
| 522 | probe: { | ||
| 523 | installed: true, | ||
| 524 | version: null, | ||
| 525 | status: "error", | ||
| 526 | auth: { status: "unknown" }, | ||
| 527 | message: | ||
| 528 | "Claude Agent CLI is installed but failed to run. Timed out while running command.", | ||
| 529 | }, | ||
| 530 | }); | ||
| 531 | } | ||
| 532 | |||
| 533 | const version = versionProbe.success.value; | ||
| 534 | const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); | ||
| 535 | if (version.code !== 0) { | ||
| 536 | yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { | ||
| 537 | exitCode: version.code, | ||
| 538 | stdoutLength: version.stdout.length, | ||
| 539 | stderrLength: version.stderr.length, | ||
| 540 | }); | ||
| 541 | return buildServerProvider({ | ||
| 542 | presentation: CLAUDE_PRESENTATION, | ||
| 543 | enabled: claudeSettings.enabled, | ||
| 544 | checkedAt, | ||
| 545 | models: allModels, | ||
| 546 | probe: { | ||
| 547 | installed: true, | ||
| 548 | version: parsedVersion, | ||
| 549 | status: "error", | ||
| 550 | auth: { status: "unknown" }, | ||
| 551 | message: "Claude Agent CLI is installed but failed to run.", | ||
| 552 | }, | ||
| 553 | }); | ||
| 554 | } | ||
| 555 | |||
| 556 | const models = providerModelsFromSettings( | ||
| 557 | resolveClaudeModelsForVersion(modelCatalog, parsedVersion), | ||
| 558 | claudeSettings.customModels, | ||
| 559 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 560 | ); | ||
| 561 | const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); | ||
| 562 | |||
| 563 | const capabilities = resolveCapabilities | ||
| 564 | ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) | ||
| 565 | : undefined; | ||
| 566 | const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); | ||
| 567 | const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; | ||
| 568 | const dedupedSlashCommands = dedupeSlashCommands(slashCommands); | ||
| 569 | |||
| 570 | if (!capabilities) { | ||
| 571 | return buildServerProvider({ | ||
| 572 | presentation: CLAUDE_PRESENTATION, | ||
| 573 | enabled: claudeSettings.enabled, | ||
| 574 | checkedAt, | ||
| 575 | models, | ||
| 576 | slashCommands: dedupedSlashCommands, | ||
| 577 | skills, | ||
| 578 | probe: { | ||
| 579 | installed: true, | ||
| 580 | version: parsedVersion, | ||
| 581 | status: "warning", | ||
| 582 | auth: { status: "unknown" }, | ||
| 583 | message: "Could not verify Claude authentication status from initialization result.", | ||
| 584 | }, | ||
| 585 | }); | ||
| 586 | } | ||
| 587 | |||
| 588 | const authMetadata = | ||
| 589 | claudeAuthMetadata({ | ||
| 590 | subscriptionType: capabilities.subscriptionType, | ||
| 591 | authMethod: capabilities.tokenSource, | ||
| 592 | }) ?? apiProviderAuthMetadata(capabilities.apiProvider); | ||
| 593 | const usageLimits = !capabilities.usage | ||
| 594 | ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed" }) | ||
| 595 | : scopedLimitNames | ||
| 596 | ? yield* recordClaudeUsageResponse(scopedLimitNames, { | ||
| 597 | response: capabilities.usage, | ||
| 598 | checkedAt, | ||
| 599 | }) | ||
| 600 | : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; | ||
| 601 | const resetCredits = | ||
| 602 | resolveResetCredits && | ||
| 603 | capabilities.subscriptionType && | ||
| 604 | !usageLimits.unavailable && | ||
| 605 | parsedVersion | ||
| 606 | ? yield* resolveResetCredits(parsedVersion) | ||
| 607 | : undefined; | ||
| 608 | return buildServerProvider({ | ||
| 609 | presentation: CLAUDE_PRESENTATION, | ||
| 610 | enabled: claudeSettings.enabled, | ||
| 611 | checkedAt, | ||
| 612 | models, | ||
| 613 | slashCommands: dedupedSlashCommands, | ||
| 614 | skills, | ||
| 615 | probe: { | ||
| 616 | installed: true, | ||
| 617 | version: parsedVersion, | ||
| 618 | status: "ready", | ||
| 619 | auth: { | ||
| 620 | status: "authenticated", | ||
| 621 | ...(capabilities.email ? { email: capabilities.email } : {}), | ||
| 622 | ...(authMetadata ? authMetadata : {}), | ||
| 623 | }, | ||
| 624 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), | ||
| 625 | usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, | ||
| 626 | }, | ||
| 627 | }); | ||
| 628 | }); | ||
| 629 | |||
| 630 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | ||
| 631 | |||
| 632 | export const makePendingClaudeProvider = ( | ||
| 633 | claudeSettings: ClaudeSettings, | ||
| 634 | modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 635 | ): Effect.Effect<ServerProviderDraft> => | ||
| 636 | Effect.gen(function* () { | ||
| 637 | const checkedAt = yield* nowIso; | ||
| 638 | const models = providerModelsFromSettings( | ||
| 639 | modelCatalog.models.map((entry) => entry.model), | ||
| 640 | claudeSettings.customModels, | ||
| 641 | DEFAULT_CLAUDE_MODEL_CAPABILITIES, | ||
| 642 | ); | ||
| 643 | |||
| 644 | if (!claudeSettings.enabled) { | ||
| 645 | return buildServerProvider({ | ||
| 646 | presentation: CLAUDE_PRESENTATION, | ||
| 647 | enabled: false, | ||
| 648 | checkedAt, | ||
| 649 | models, | ||
| 650 | probe: { | ||
| 651 | installed: false, | ||
| 652 | version: null, | ||
| 653 | status: "warning", | ||
| 654 | auth: { status: "unknown" }, | ||
| 655 | message: "Claude is disabled in T3 Code settings.", | ||
| 656 | }, | ||
| 657 | }); | ||
| 658 | } | ||
| 659 | |||
| 660 | return buildServerProvider({ | ||
| 661 | presentation: CLAUDE_PRESENTATION, | ||
| 662 | enabled: true, | ||
| 663 | checkedAt, | ||
| 664 | models, | ||
| 665 | probe: { | ||
| 666 | installed: false, | ||
| 667 | version: null, | ||
| 668 | status: "warning", | ||
| 669 | auth: { status: "unknown" }, | ||
| 670 | message: "Claude provider status has not been checked in this session yet.", | ||
| 671 | }, | ||
| 672 | }); | ||
| 673 | }); | ||
| 674 | |||
| 675 | export { probeClaudeCapabilities }; | ||
apps/server/src/provider/Layers/CodexProvider.test.ts deleted-173| ... | @@ -1,173 +0,0 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; | ||
| 4 | |||
| 5 | it("maps current Codex model capability fields", () => { | ||
| 6 | const capabilities = mapCodexModelCapabilities({ | ||
| 7 | additionalSpeedTiers: [], | ||
| 8 | defaultReasoningEffort: "super-high", | ||
| 9 | description: "Test model", | ||
| 10 | displayName: "GPT Test", | ||
| 11 | hidden: false, | ||
| 12 | id: "gpt-test", | ||
| 13 | isDefault: true, | ||
| 14 | model: "gpt-test", | ||
| 15 | defaultServiceTier: "flex", | ||
| 16 | serviceTiers: [ | ||
| 17 | { | ||
| 18 | id: "priority", | ||
| 19 | name: "Fast", | ||
| 20 | description: "Lower latency responses.", | ||
| 21 | }, | ||
| 22 | { | ||
| 23 | id: "flex", | ||
| 24 | name: "Flex", | ||
| 25 | description: "Lower-cost asynchronous routing.", | ||
| 26 | }, | ||
| 27 | ], | ||
| 28 | supportedReasoningEfforts: [ | ||
| 29 | { | ||
| 30 | description: "Maximum reasoning", | ||
| 31 | reasoningEffort: "super-high", | ||
| 32 | }, | ||
| 33 | ], | ||
| 34 | }); | ||
| 35 | |||
| 36 | assert.deepStrictEqual(capabilities.optionDescriptors, [ | ||
| 37 | { | ||
| 38 | id: "reasoningEffort", | ||
| 39 | label: "Reasoning", | ||
| 40 | type: "select", | ||
| 41 | options: [{ id: "super-high", label: "super-high", isDefault: true }], | ||
| 42 | currentValue: "super-high", | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | id: "serviceTier", | ||
| 46 | label: "Service Tier", | ||
| 47 | type: "select", | ||
| 48 | options: [ | ||
| 49 | { id: "default", label: "Standard" }, | ||
| 50 | { | ||
| 51 | id: "priority", | ||
| 52 | label: "Fast", | ||
| 53 | description: "Lower latency responses.", | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | id: "flex", | ||
| 57 | label: "Flex", | ||
| 58 | description: "Lower-cost asynchronous routing.", | ||
| 59 | isDefault: true, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | currentValue: "flex", | ||
| 63 | }, | ||
| 64 | ]); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("uses standard routing when the catalog has no default service tier", () => { | ||
| 68 | const capabilities = mapCodexModelCapabilities({ | ||
| 69 | additionalSpeedTiers: ["fast"], | ||
| 70 | defaultReasoningEffort: "medium", | ||
| 71 | defaultServiceTier: null, | ||
| 72 | description: "Test model", | ||
| 73 | displayName: "GPT Test", | ||
| 74 | hidden: false, | ||
| 75 | id: "gpt-test", | ||
| 76 | isDefault: true, | ||
| 77 | model: "gpt-test", | ||
| 78 | serviceTiers: [ | ||
| 79 | { | ||
| 80 | id: "priority", | ||
| 81 | name: "Fast", | ||
| 82 | description: "1.5x speed, increased usage", | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | id: "ultrafast", | ||
| 86 | name: "Ultrafast", | ||
| 87 | description: "The fastest available responses for latency-sensitive work.", | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | supportedReasoningEfforts: [], | ||
| 91 | }); | ||
| 92 | |||
| 93 | assert.deepStrictEqual(capabilities.optionDescriptors, [ | ||
| 94 | { | ||
| 95 | id: "serviceTier", | ||
| 96 | label: "Service Tier", | ||
| 97 | type: "select", | ||
| 98 | options: [ | ||
| 99 | { id: "default", label: "Standard", isDefault: true }, | ||
| 100 | { | ||
| 101 | id: "priority", | ||
| 102 | label: "Fast", | ||
| 103 | description: "1.5x speed, increased usage", | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | id: "ultrafast", | ||
| 107 | label: "Ultrafast", | ||
| 108 | description: "Even faster, more expensive", | ||
| 109 | }, | ||
| 110 | ], | ||
| 111 | currentValue: "default", | ||
| 112 | }, | ||
| 113 | ]); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("marks the most preferred available model as default", () => { | ||
| 117 | const models = applyPreferredCodexDefaultModel([ | ||
| 118 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 119 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 120 | ]); | ||
| 121 | |||
| 122 | assert.deepStrictEqual( | ||
| 123 | models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), | ||
| 124 | [ | ||
| 125 | { slug: "gpt-5.6-terra", isDefault: true }, | ||
| 126 | { slug: "gpt-5.4", isDefault: undefined }, | ||
| 127 | ], | ||
| 128 | ); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("prefers sol over terra when both are available", () => { | ||
| 132 | const models = applyPreferredCodexDefaultModel([ | ||
| 133 | { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, | ||
| 134 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 135 | ]); | ||
| 136 | |||
| 137 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("ranks qualified Codex models while preserving their wire ids", () => { | ||
| 141 | const models = applyPreferredCodexDefaultModel([ | ||
| 142 | { | ||
| 143 | slug: "openai.gpt-5.6-luna", | ||
| 144 | name: "Luna", | ||
| 145 | isCustom: false, | ||
| 146 | isDefault: true, | ||
| 147 | capabilities: null, | ||
| 148 | }, | ||
| 149 | { slug: "openai.gpt-5.6-sol", name: "Sol", isCustom: false, capabilities: null }, | ||
| 150 | ]); | ||
| 151 | assert.deepStrictEqual( | ||
| 152 | models.filter((model) => model.isDefault).map((model) => model.slug), | ||
| 153 | ["openai.gpt-5.6-sol"], | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("keeps Codex's own default when no preferred model is available", () => { | ||
| 158 | const models = applyPreferredCodexDefaultModel([ | ||
| 159 | { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, | ||
| 160 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 161 | ]); | ||
| 162 | |||
| 163 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("ignores custom models that shadow a preferred slug", () => { | ||
| 167 | const models = applyPreferredCodexDefaultModel([ | ||
| 168 | { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, | ||
| 169 | { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, | ||
| 170 | ]); | ||
| 171 | |||
| 172 | assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); | ||
| 173 | }); | ||
apps/server/src/provider/Layers/CodexProvider.ts deleted-723| ... | @@ -1,723 +0,0 @@ | ||
| 1 | import * as DateTime from "effect/DateTime"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as Scope from "effect/Scope"; | ||
| 9 | import * as Types from "effect/Types"; | ||
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; | ||
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 12 | import * as CodexClient from "effect-codex-app-server/client"; | ||
| 13 | import * as CodexSchema from "effect-codex-app-server/schema"; | ||
| 14 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 15 | |||
| 16 | import type { | ||
| 17 | CodexSettings, | ||
| 18 | CustomModelSetting, | ||
| 19 | ServerProvider, | ||
| 20 | ServerProviderState, | ||
| 21 | ModelCapabilities, | ||
| 22 | ProviderOptionDescriptor, | ||
| 23 | ServerProviderModel, | ||
| 24 | ServerProviderSkill, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; | ||
| 27 | |||
| 28 | import { | ||
| 29 | codexModelFamily, | ||
| 30 | createModelCapabilities, | ||
| 31 | formatCodexModelName, | ||
| 32 | readCustomModelEntries, | ||
| 33 | } from "@t3tools/shared/model"; | ||
| 34 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 35 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; | ||
| 36 | import { | ||
| 37 | AUTH_PROBE_TIMEOUT_MS, | ||
| 38 | buildServerProvider, | ||
| 39 | COMPACT_SLASH_COMMAND, | ||
| 40 | type ServerProviderDraft, | ||
| 41 | } from "../providerSnapshot.ts"; | ||
| 42 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 43 | import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; | ||
| 44 | import { | ||
| 45 | codexRateLimitsFailureMessage, | ||
| 46 | codexRateLimitsToLimits, | ||
| 47 | type CodexRateLimitSnapshot, | ||
| 48 | type CodexResetCreditsSummary, | ||
| 49 | } from "./codexUsageLimits.ts"; | ||
| 50 | import packageJson from "../../../package.json" with { type: "json" }; | ||
| 51 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); | ||
| 52 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; | ||
| 53 | |||
| 54 | type CodexRateLimitsProbe = | ||
| 55 | | { | ||
| 56 | readonly snapshot: CodexRateLimitSnapshot; | ||
| 57 | readonly rateLimitsByLimitId?: | ||
| 58 | | Readonly<Record<string, CodexRateLimitSnapshot>> | ||
| 59 | | null | ||
| 60 | | undefined; | ||
| 61 | readonly resetCredits: CodexResetCreditsSummary | null | undefined; | ||
| 62 | } | ||
| 63 | | { readonly failure: string }; | ||
| 64 | |||
| 65 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; | ||
| 66 | |||
| 67 | const CODEX_PRESENTATION = { | ||
| 68 | displayName: "Codex", | ||
| 69 | showInteractionModeToggle: true, | ||
| 70 | reportsContextWindow: true, | ||
| 71 | } as const; | ||
| 72 | |||
| 73 | export interface CodexAppServerProviderSnapshot { | ||
| 74 | readonly account: CodexSchema.V2GetAccountResponse; | ||
| 75 | readonly rateLimits?: CodexRateLimitsProbe; | ||
| 76 | readonly version: string | undefined; | ||
| 77 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 78 | readonly skills: ReadonlyArray<ServerProviderSkill>; | ||
| 79 | } | ||
| 80 | |||
| 81 | const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = { | ||
| 82 | none: "None", | ||
| 83 | minimal: "Minimal", | ||
| 84 | low: "Low", | ||
| 85 | medium: "Medium", | ||
| 86 | high: "High", | ||
| 87 | xhigh: "Extra High", | ||
| 88 | max: "Max", | ||
| 89 | ultra: "Ultra", | ||
| 90 | }; | ||
| 91 | |||
| 92 | const DEFAULT_SERVICE_TIER_ID = "default"; | ||
| 93 | |||
| 94 | /** Shorter copy for tiers whose catalog description wraps in the traits menu. */ | ||
| 95 | const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = { | ||
| 96 | ultrafast: "Even faster, more expensive", | ||
| 97 | }; | ||
| 98 | |||
| 99 | function reasoningEffortLabel(reasoningEffort: string): string { | ||
| 100 | return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; | ||
| 101 | } | ||
| 102 | |||
| 103 | function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { | ||
| 104 | if (!account) return undefined; | ||
| 105 | if (account.type === "apiKey") return "OpenAI API Key"; | ||
| 106 | if (account.type === "amazonBedrock") return "Amazon Bedrock"; | ||
| 107 | if (account.type !== "chatgpt") return undefined; | ||
| 108 | return codexPlanLabel(account.planType); | ||
| 109 | } | ||
| 110 | |||
| 111 | /** Shared with usage-limit sources, which report the same `planType` slugs. */ | ||
| 112 | export function codexPlanLabel(planType: string | null | undefined): string | undefined { | ||
| 113 | switch (planType) { | ||
| 114 | case "free": | ||
| 115 | return "ChatGPT Free Subscription"; | ||
| 116 | case "go": | ||
| 117 | return "ChatGPT Go Subscription"; | ||
| 118 | case "plus": | ||
| 119 | return "ChatGPT Plus Subscription"; | ||
| 120 | case "pro": | ||
| 121 | return "ChatGPT Pro 20x Subscription"; | ||
| 122 | case "prolite": | ||
| 123 | return "ChatGPT Pro 5x Subscription"; | ||
| 124 | case "promax": | ||
| 125 | return "ChatGPT Pro Max Subscription"; | ||
| 126 | case "team": | ||
| 127 | return "ChatGPT Team Subscription"; | ||
| 128 | case "self_serve_business_prolite": | ||
| 129 | case "self_serve_business_usage_based": | ||
| 130 | case "business": | ||
| 131 | return "ChatGPT Business Subscription"; | ||
| 132 | case "ent26": | ||
| 133 | case "enterprise_cbp_automation": | ||
| 134 | case "enterprise_cbp_usage_based": | ||
| 135 | case "enterprise": | ||
| 136 | return "ChatGPT Enterprise Subscription"; | ||
| 137 | case "edu": | ||
| 138 | case "edu_plus": | ||
| 139 | case "edu_pro": | ||
| 140 | return "ChatGPT Edu Subscription"; | ||
| 141 | case "unknown": | ||
| 142 | return "ChatGPT Subscription"; | ||
| 143 | default: | ||
| 144 | return undefined; | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { | ||
| 149 | if (!account || account.type !== "chatgpt") return undefined; | ||
| 150 | return account.email; | ||
| 151 | } | ||
| 152 | |||
| 153 | export function mapCodexModelCapabilities( | ||
| 154 | model: CodexSchema.V2ModelListResponse__Model, | ||
| 155 | ): ModelCapabilities { | ||
| 156 | const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => | ||
| 157 | reasoningEffort === | ||
| 158 | (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) | ||
| 159 | ? { | ||
| 160 | id: reasoningEffort, | ||
| 161 | label: reasoningEffortLabel(reasoningEffort), | ||
| 162 | isDefault: true, | ||
| 163 | } | ||
| 164 | : { | ||
| 165 | id: reasoningEffort, | ||
| 166 | label: reasoningEffortLabel(reasoningEffort), | ||
| 167 | }, | ||
| 168 | ); | ||
| 169 | const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; | ||
| 170 | const serviceTiers = | ||
| 171 | model.serviceTiers && model.serviceTiers.length > 0 | ||
| 172 | ? model.serviceTiers | ||
| 173 | : (model.additionalSpeedTiers ?? []).map((id) => ({ | ||
| 174 | id, | ||
| 175 | name: id === "fast" ? "Fast" : id, | ||
| 176 | description: "", | ||
| 177 | })); | ||
| 178 | const catalogDefaultServiceTier = serviceTiers.some( | ||
| 179 | (tier) => tier.id === model.defaultServiceTier, | ||
| 180 | ) | ||
| 181 | ? model.defaultServiceTier | ||
| 182 | : null; | ||
| 183 | const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; | ||
| 184 | const optionDescriptors: ProviderOptionDescriptor[] = []; | ||
| 185 | |||
| 186 | if (reasoningOptions.length > 0) { | ||
| 187 | optionDescriptors.push({ | ||
| 188 | id: "reasoningEffort", | ||
| 189 | label: "Reasoning", | ||
| 190 | type: "select", | ||
| 191 | options: reasoningOptions, | ||
| 192 | ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), | ||
| 193 | }); | ||
| 194 | } | ||
| 195 | if (serviceTiers.length > 0) { | ||
| 196 | optionDescriptors.push({ | ||
| 197 | id: "serviceTier", | ||
| 198 | label: "Service Tier", | ||
| 199 | type: "select", | ||
| 200 | options: [ | ||
| 201 | { | ||
| 202 | id: DEFAULT_SERVICE_TIER_ID, | ||
| 203 | label: "Standard", | ||
| 204 | ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), | ||
| 205 | }, | ||
| 206 | ...serviceTiers.map((tier) => { | ||
| 207 | const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; | ||
| 208 | return { | ||
| 209 | id: tier.id, | ||
| 210 | label: tier.name, | ||
| 211 | ...(description ? { description } : {}), | ||
| 212 | ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), | ||
| 213 | }; | ||
| 214 | }), | ||
| 215 | ], | ||
| 216 | currentValue: defaultServiceTier, | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | return createModelCapabilities({ | ||
| 221 | optionDescriptors, | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | |||
| 225 | function parseCodexModelListResponse( | ||
| 226 | response: CodexSchema.V2ModelListResponse, | ||
| 227 | ): ReadonlyArray<ServerProviderModel> { | ||
| 228 | return response.data.map((model) => ({ | ||
| 229 | slug: model.model, | ||
| 230 | name: formatCodexModelName(model.displayName), | ||
| 231 | isCustom: false, | ||
| 232 | ...(model.isDefault ? { isDefault: true } : {}), | ||
| 233 | capabilities: mapCodexModelCapabilities(model), | ||
| 234 | })); | ||
| 235 | } | ||
| 236 | |||
| 237 | /** | ||
| 238 | * Prefer our own default-model ranking when one of the preferred slugs is in | ||
| 239 | * the live catalog; otherwise keep whatever Codex itself flagged as default. | ||
| 240 | */ | ||
| 241 | export function applyPreferredCodexDefaultModel( | ||
| 242 | models: ReadonlyArray<ServerProviderModel>, | ||
| 243 | ): ReadonlyArray<ServerProviderModel> { | ||
| 244 | const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) => | ||
| 245 | models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug), | ||
| 246 | )[0]?.slug; | ||
| 247 | if (!preferredSlug) { | ||
| 248 | return models; | ||
| 249 | } | ||
| 250 | return models.map((model) => { | ||
| 251 | if (model.slug === preferredSlug) { | ||
| 252 | return model.isDefault ? model : { ...model, isDefault: true }; | ||
| 253 | } | ||
| 254 | if (!model.isDefault) { | ||
| 255 | return model; | ||
| 256 | } | ||
| 257 | const { isDefault: _isDefault, ...rest } = model; | ||
| 258 | return rest; | ||
| 259 | }); | ||
| 260 | } | ||
| 261 | |||
| 262 | /** | ||
| 263 | * Codex has no static default capability set, so a bare custom slug borrows | ||
| 264 | * the first built-in's descriptors; an entry with its own capabilities keeps | ||
| 265 | * them. | ||
| 266 | */ | ||
| 267 | function appendCustomCodexModels( | ||
| 268 | models: ReadonlyArray<ServerProviderModel>, | ||
| 269 | customModels: ReadonlyArray<CustomModelSetting>, | ||
| 270 | ): ReadonlyArray<ServerProviderModel> { | ||
| 271 | if (customModels.length === 0) { | ||
| 272 | return models; | ||
| 273 | } | ||
| 274 | |||
| 275 | const seen = new Set(models.map((model) => model.slug)); | ||
| 276 | const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; | ||
| 277 | const customEntries: ServerProviderModel[] = []; | ||
| 278 | for (const entry of readCustomModelEntries(customModels)) { | ||
| 279 | if (seen.has(entry.slug)) { | ||
| 280 | continue; | ||
| 281 | } | ||
| 282 | seen.add(entry.slug); | ||
| 283 | customEntries.push({ | ||
| 284 | slug: entry.slug, | ||
| 285 | name: entry.name, | ||
| 286 | isCustom: true, | ||
| 287 | capabilities: entry.capabilities ?? fallbackCapabilities, | ||
| 288 | }); | ||
| 289 | } | ||
| 290 | return customEntries.length === 0 ? models : [...models, ...customEntries]; | ||
| 291 | } | ||
| 292 | |||
| 293 | function parseCodexSkillsListResponse( | ||
| 294 | response: CodexSchema.V2SkillsListResponse, | ||
| 295 | cwd: string, | ||
| 296 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 297 | const matchingEntry = response.data.find((entry) => entry.cwd === cwd); | ||
| 298 | const skills = matchingEntry | ||
| 299 | ? matchingEntry.skills | ||
| 300 | : response.data.flatMap((entry) => entry.skills); | ||
| 301 | |||
| 302 | return skills.map((skill) => { | ||
| 303 | const shortDescription = | ||
| 304 | skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; | ||
| 305 | |||
| 306 | const parsedSkill: Types.Mutable<ServerProviderSkill> = { | ||
| 307 | name: skill.name, | ||
| 308 | path: skill.path, | ||
| 309 | enabled: skill.enabled, | ||
| 310 | }; | ||
| 311 | |||
| 312 | if (skill.description) { | ||
| 313 | parsedSkill.description = skill.description; | ||
| 314 | } | ||
| 315 | if (skill.scope) { | ||
| 316 | parsedSkill.scope = skill.scope; | ||
| 317 | } | ||
| 318 | if (skill.interface?.displayName) { | ||
| 319 | parsedSkill.displayName = skill.interface.displayName; | ||
| 320 | } | ||
| 321 | if (shortDescription) { | ||
| 322 | parsedSkill.shortDescription = shortDescription; | ||
| 323 | } | ||
| 324 | |||
| 325 | return parsedSkill; | ||
| 326 | }); | ||
| 327 | } | ||
| 328 | |||
| 329 | const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( | ||
| 330 | client: CodexClient.CodexAppServerClient["Service"], | ||
| 331 | ) { | ||
| 332 | const models: ServerProviderModel[] = []; | ||
| 333 | let cursor: string | null | undefined = undefined; | ||
| 334 | |||
| 335 | do { | ||
| 336 | const response: CodexSchema.V2ModelListResponse = yield* client.request( | ||
| 337 | "model/list", | ||
| 338 | cursor ? { cursor } : {}, | ||
| 339 | ); | ||
| 340 | models.push(...parseCodexModelListResponse(response)); | ||
| 341 | cursor = response.nextCursor; | ||
| 342 | } while (cursor); | ||
| 343 | |||
| 344 | return models; | ||
| 345 | }); | ||
| 346 | |||
| 347 | export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { | ||
| 348 | return { | ||
| 349 | clientInfo: { | ||
| 350 | name: "T3 Code", | ||
| 351 | title: "T3 Code", | ||
| 352 | version: packageJson.version, | ||
| 353 | }, | ||
| 354 | capabilities: { | ||
| 355 | experimentalApi: true, | ||
| 356 | }, | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | /** | ||
| 361 | * Spawns a short-lived `codex app-server`, runs the initialize handshake, and | ||
| 362 | * hands the caller a connected client. Scoped: the process is killed when | ||
| 363 | * the caller's scope closes. Shared by the status probe, the skills probe, | ||
| 364 | * and account-level requests such as reset-credit redemption. | ||
| 365 | */ | ||
| 366 | export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { | ||
| 367 | readonly binaryPath: string; | ||
| 368 | readonly homePath?: string | undefined; | ||
| 369 | readonly launchArgs?: string | undefined; | ||
| 370 | readonly cwd: string; | ||
| 371 | readonly environment?: NodeJS.ProcessEnv | undefined; | ||
| 372 | }) { | ||
| 373 | // `~` is not shell-expanded when env vars are set via `child_process.spawn`, | ||
| 374 | // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip | ||
| 375 | // "CODEX_HOME points to '~/.codex_work', but that path does not exist". | ||
| 376 | // Expand here for parity with `CodexTextGeneration`. | ||
| 377 | const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; | ||
| 378 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 379 | const environment = { | ||
| 380 | ...input.environment, | ||
| 381 | ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), | ||
| 382 | }; | ||
| 383 | const spawnCommand = yield* resolveSpawnCommand( | ||
| 384 | input.binaryPath, | ||
| 385 | codexAppServerArgs(input.launchArgs), | ||
| 386 | { env: environment, extendEnv: true }, | ||
| 387 | ); | ||
| 388 | const child = yield* spawner | ||
| 389 | .spawn( | ||
| 390 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 391 | cwd: input.cwd, | ||
| 392 | env: environment, | ||
| 393 | extendEnv: true, | ||
| 394 | forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, | ||
| 395 | shell: spawnCommand.shell, | ||
| 396 | }), | ||
| 397 | ) | ||
| 398 | .pipe( | ||
| 399 | Effect.mapError( | ||
| 400 | (cause) => | ||
| 401 | new CodexErrors.CodexAppServerSpawnError({ | ||
| 402 | command: `${input.binaryPath} app-server`, | ||
| 403 | cause, | ||
| 404 | }), | ||
| 405 | ), | ||
| 406 | ); | ||
| 407 | const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); | ||
| 408 | const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( | ||
| 409 | Effect.provide(clientContext), | ||
| 410 | ); | ||
| 411 | const initialize = yield* client.request("initialize", buildCodexInitializeParams()); | ||
| 412 | yield* client.notify("initialized", undefined); | ||
| 413 | return { client, initialize }; | ||
| 414 | }); | ||
| 415 | |||
| 416 | const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { | ||
| 417 | readonly binaryPath: string; | ||
| 418 | readonly homePath?: string; | ||
| 419 | readonly launchArgs?: string; | ||
| 420 | readonly cwd: string; | ||
| 421 | readonly customModels?: ReadonlyArray<CustomModelSetting>; | ||
| 422 | readonly environment?: NodeJS.ProcessEnv; | ||
| 423 | readonly skipNativeUsage?: boolean; | ||
| 424 | }) { | ||
| 425 | const { client, initialize } = yield* withCodexAppServerClient(input); | ||
| 426 | |||
| 427 | // Extract the version string after the first '/' in userAgent, up to the next space or the end | ||
| 428 | const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); | ||
| 429 | const version = versionMatch ? versionMatch[1] : undefined; | ||
| 430 | |||
| 431 | const accountResponse = yield* client.request("account/read", {}); | ||
| 432 | if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { | ||
| 433 | return { | ||
| 434 | account: accountResponse, | ||
| 435 | version, | ||
| 436 | models: appendCustomCodexModels([], input.customModels ?? []), | ||
| 437 | skills: [], | ||
| 438 | } satisfies CodexAppServerProviderSnapshot; | ||
| 439 | } | ||
| 440 | |||
| 441 | const [skillsResponse, models, rateLimits] = yield* Effect.all( | ||
| 442 | [ | ||
| 443 | client.request("skills/list", { | ||
| 444 | cwds: [input.cwd], | ||
| 445 | }), | ||
| 446 | requestAllCodexModels(client), | ||
| 447 | // Usage is an enrichment: a failure or a slow answer degrades to "no | ||
| 448 | // usage this probe" rather than costing the account and models. | ||
| 449 | input.skipNativeUsage | ||
| 450 | ? Effect.succeed(undefined) | ||
| 451 | : client.request("account/rateLimits/read", null).pipe( | ||
| 452 | Effect.map((response): CodexRateLimitsProbe => ({ | ||
| 453 | snapshot: response.rateLimits, | ||
| 454 | rateLimitsByLimitId: response.rateLimitsByLimitId, | ||
| 455 | resetCredits: response.rateLimitResetCredits, | ||
| 456 | })), | ||
| 457 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), | ||
| 458 | Effect.map( | ||
| 459 | Option.getOrElse((): CodexRateLimitsProbe => ({ | ||
| 460 | failure: "Codex did not answer the usage request.", | ||
| 461 | })), | ||
| 462 | ), | ||
| 463 | Effect.catch((error) => | ||
| 464 | Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( | ||
| 465 | Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }), | ||
| 466 | ), | ||
| 467 | ), | ||
| 468 | ), | ||
| 469 | ], | ||
| 470 | { concurrency: "unbounded" }, | ||
| 471 | ); | ||
| 472 | |||
| 473 | return { | ||
| 474 | account: accountResponse, | ||
| 475 | ...(rateLimits ? { rateLimits } : {}), | ||
| 476 | version, | ||
| 477 | models: applyPreferredCodexDefaultModel( | ||
| 478 | appendCustomCodexModels(models, input.customModels ?? []), | ||
| 479 | ), | ||
| 480 | skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), | ||
| 481 | } satisfies CodexAppServerProviderSnapshot; | ||
| 482 | }); | ||
| 483 | |||
| 484 | export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { | ||
| 485 | readonly binaryPath: string; | ||
| 486 | readonly homePath?: string; | ||
| 487 | readonly launchArgs?: string; | ||
| 488 | readonly cwd: string; | ||
| 489 | readonly environment?: NodeJS.ProcessEnv; | ||
| 490 | }) { | ||
| 491 | const { client } = yield* withCodexAppServerClient(input); | ||
| 492 | const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); | ||
| 493 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); | ||
| 494 | }); | ||
| 495 | |||
| 496 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => | ||
| 497 | appendCustomCodexModels([], codexSettings.customModels); | ||
| 498 | |||
| 499 | const makePendingCodexProvider = ( | ||
| 500 | codexSettings: CodexSettings, | ||
| 501 | ): Effect.Effect<ServerProviderDraft> => | ||
| 502 | Effect.gen(function* () { | ||
| 503 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 504 | const models = emptyCodexModelsFromSettings(codexSettings); | ||
| 505 | |||
| 506 | if (!codexSettings.enabled) { | ||
| 507 | return buildServerProvider({ | ||
| 508 | presentation: CODEX_PRESENTATION, | ||
| 509 | enabled: false, | ||
| 510 | checkedAt, | ||
| 511 | models, | ||
| 512 | skills: [], | ||
| 513 | probe: { | ||
| 514 | installed: false, | ||
| 515 | version: null, | ||
| 516 | status: "warning", | ||
| 517 | auth: { status: "unknown" }, | ||
| 518 | message: "Codex is disabled in T3 Code settings.", | ||
| 519 | }, | ||
| 520 | }); | ||
| 521 | } | ||
| 522 | |||
| 523 | return buildServerProvider({ | ||
| 524 | presentation: CODEX_PRESENTATION, | ||
| 525 | enabled: true, | ||
| 526 | checkedAt, | ||
| 527 | models, | ||
| 528 | skills: [], | ||
| 529 | probe: { | ||
| 530 | installed: false, | ||
| 531 | version: null, | ||
| 532 | status: "warning", | ||
| 533 | auth: { status: "unknown" }, | ||
| 534 | message: "Codex provider status has not been checked in this session yet.", | ||
| 535 | }, | ||
| 536 | }); | ||
| 537 | }); | ||
| 538 | |||
| 539 | function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { | ||
| 540 | readonly status: Exclude<ServerProviderState, "disabled">; | ||
| 541 | readonly auth: ServerProvider["auth"]; | ||
| 542 | readonly message?: string; | ||
| 543 | } { | ||
| 544 | const authLabel = codexAccountAuthLabel(account.account); | ||
| 545 | const authEmail = codexAccountEmail(account.account); | ||
| 546 | const auth = { | ||
| 547 | status: account.account ? ("authenticated" as const) : ("unknown" as const), | ||
| 548 | ...(account.account?.type ? { type: account.account?.type } : {}), | ||
| 549 | ...(authLabel ? { label: authLabel } : {}), | ||
| 550 | ...(authEmail ? { email: authEmail } : {}), | ||
| 551 | } satisfies ServerProvider["auth"]; | ||
| 552 | |||
| 553 | if (account.account) { | ||
| 554 | return { status: "ready", auth }; | ||
| 555 | } | ||
| 556 | |||
| 557 | if (account.requiresOpenaiAuth) { | ||
| 558 | return { | ||
| 559 | status: "error", | ||
| 560 | auth: { status: "unauthenticated" }, | ||
| 561 | message: "Codex CLI is not authenticated. Run `codex login` and try again.", | ||
| 562 | }; | ||
| 563 | } | ||
| 564 | |||
| 565 | return { status: "ready", auth }; | ||
| 566 | } | ||
| 567 | |||
| 568 | export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( | ||
| 569 | codexSettings: CodexSettings, | ||
| 570 | probe: (input: { | ||
| 571 | readonly binaryPath: string; | ||
| 572 | readonly homePath?: string; | ||
| 573 | readonly launchArgs?: string; | ||
| 574 | readonly cwd: string; | ||
| 575 | readonly customModels: ReadonlyArray<CustomModelSetting>; | ||
| 576 | readonly environment?: NodeJS.ProcessEnv; | ||
| 577 | readonly skipNativeUsage?: boolean; | ||
| 578 | }) => Effect.Effect< | ||
| 579 | CodexAppServerProviderSnapshot, | ||
| 580 | CodexErrors.CodexAppServerError, | ||
| 581 | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | ||
| 582 | > = probeCodexAppServerProvider, | ||
| 583 | environment?: NodeJS.ProcessEnv, | ||
| 584 | managedAuth?: ServerProvider["auth"], | ||
| 585 | ): Effect.fn.Return< | ||
| 586 | ServerProviderDraft, | ||
| 587 | ServerSettingsError, | ||
| 588 | ChildProcessSpawner.ChildProcessSpawner | ||
| 589 | > { | ||
| 590 | const resolvedEnvironment = environment ?? process.env; | ||
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 592 | const emptyModels = emptyCodexModelsFromSettings(codexSettings); | ||
| 593 | |||
| 594 | if (!codexSettings.enabled) { | ||
| 595 | return buildServerProvider({ | ||
| 596 | presentation: CODEX_PRESENTATION, | ||
| 597 | enabled: false, | ||
| 598 | checkedAt, | ||
| 599 | models: emptyModels, | ||
| 600 | skills: [], | ||
| 601 | probe: { | ||
| 602 | installed: false, | ||
| 603 | version: null, | ||
| 604 | status: "warning", | ||
| 605 | auth: { status: "unknown" }, | ||
| 606 | message: "Codex is disabled in T3 Code settings.", | ||
| 607 | }, | ||
| 608 | }); | ||
| 609 | } | ||
| 610 | |||
| 611 | const probeResult = yield* probe({ | ||
| 612 | binaryPath: codexSettings.binaryPath, | ||
| 613 | homePath: codexSettings.homePath, | ||
| 614 | launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), | ||
| 615 | cwd: process.cwd(), | ||
| 616 | customModels: codexSettings.customModels, | ||
| 617 | environment: resolvedEnvironment, | ||
| 618 | ...(managedAuth ? { skipNativeUsage: true } : {}), | ||
| 619 | }).pipe( | ||
| 620 | Effect.scoped, | ||
| 621 | Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), | ||
| 622 | Effect.result, | ||
| 623 | ); | ||
| 624 | |||
| 625 | if (Result.isFailure(probeResult)) { | ||
| 626 | const error = probeResult.failure; | ||
| 627 | const installed = !isCodexAppServerSpawnError(error); | ||
| 628 | return buildServerProvider({ | ||
| 629 | presentation: CODEX_PRESENTATION, | ||
| 630 | enabled: codexSettings.enabled, | ||
| 631 | checkedAt, | ||
| 632 | models: emptyModels, | ||
| 633 | skills: [], | ||
| 634 | probe: { | ||
| 635 | installed, | ||
| 636 | version: null, | ||
| 637 | status: "error", | ||
| 638 | auth: { status: "unknown" }, | ||
| 639 | message: installed | ||
| 640 | ? `Codex app-server provider probe failed: ${error.message}.` | ||
| 641 | : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` + | ||
| 642 | (codexSettings.binaryPath === "codex" | ||
| 643 | ? " Installing ChatGPT or Codex desktop may not add codex to PATH." | ||
| 644 | : " Make sure the configured executable exists and can be run."), | ||
| 645 | }, | ||
| 646 | }); | ||
| 647 | } | ||
| 648 | |||
| 649 | if (Option.isNone(probeResult.success)) { | ||
| 650 | return buildServerProvider({ | ||
| 651 | presentation: CODEX_PRESENTATION, | ||
| 652 | enabled: codexSettings.enabled, | ||
| 653 | checkedAt, | ||
| 654 | models: emptyModels, | ||
| 655 | skills: [], | ||
| 656 | probe: { | ||
| 657 | installed: true, | ||
| 658 | version: null, | ||
| 659 | status: "error", | ||
| 660 | auth: { status: "unknown" }, | ||
| 661 | message: "Timed out while checking Codex app-server provider status.", | ||
| 662 | }, | ||
| 663 | }); | ||
| 664 | } | ||
| 665 | |||
| 666 | const snapshot = probeResult.success.value; | ||
| 667 | const accountStatus = managedAuth | ||
| 668 | ? { status: "ready" as const, auth: managedAuth, message: undefined } | ||
| 669 | : accountProbeStatus(snapshot.account); | ||
| 670 | const usageLimits = | ||
| 671 | snapshot.account.account?.type === "apiKey" | ||
| 672 | ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) | ||
| 673 | : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits | ||
| 674 | ? makeUnavailableUsageLimits({ | ||
| 675 | checkedAt, | ||
| 676 | reason: "probeFailed", | ||
| 677 | ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), | ||
| 678 | }) | ||
| 679 | : codexRateLimitsToLimits({ | ||
| 680 | snapshot: snapshot.rateLimits.snapshot, | ||
| 681 | rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId, | ||
| 682 | resetCredits: snapshot.rateLimits.resetCredits, | ||
| 683 | checkedAt, | ||
| 684 | }); | ||
| 685 | |||
| 686 | return buildServerProvider({ | ||
| 687 | presentation: CODEX_PRESENTATION, | ||
| 688 | enabled: codexSettings.enabled, | ||
| 689 | checkedAt, | ||
| 690 | models: snapshot.models, | ||
| 691 | skills: snapshot.skills, | ||
| 692 | slashCommands: [ | ||
| 693 | COMPACT_SLASH_COMMAND, | ||
| 694 | { | ||
| 695 | name: "goal", | ||
| 696 | description: "Set a goal Codex keeps working toward until it is done", | ||
| 697 | input: { hint: "Objective, or pause, resume, clear" }, | ||
| 698 | }, | ||
| 699 | { | ||
| 700 | name: "feedback", | ||
| 701 | description: "Send this thread and Codex logs to OpenAI", | ||
| 702 | input: { hint: "Describe the issue (optional)" }, | ||
| 703 | }, | ||
| 704 | ], | ||
| 705 | probe: { | ||
| 706 | installed: true, | ||
| 707 | version: snapshot.version ?? null, | ||
| 708 | status: accountStatus.status, | ||
| 709 | auth: accountStatus.auth, | ||
| 710 | ...(accountStatus.message ? { message: accountStatus.message } : {}), | ||
| 711 | ...(managedAuth ? {} : { usageLimits }), | ||
| 712 | }, | ||
| 713 | }); | ||
| 714 | }); | ||
| 715 | |||
| 716 | // NOTE: the singleton `CodexProviderLive` Layer has been removed as part of | ||
| 717 | // the per-instance-driver refactor. `CodexDriver.create()` builds a managed | ||
| 718 | // snapshot per instance (each with its own `CodexSettings`) and hands the | ||
| 719 | // resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. | ||
| 720 | // | ||
| 721 | // The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are | ||
| 722 | // re-exported for use by `CodexDriver`. | ||
| 723 | export { makePendingCodexProvider }; | ||
apps/server/src/provider/Layers/CursorProvider.test.ts deleted-252| ... | @@ -1,252 +0,0 @@ | ||
| 1 | import type { SDKModel } from "@cursor/sdk"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import type { CursorSettings } from "@t3tools/contracts"; | ||
| 6 | import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; | ||
| 7 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | buildCursorCapabilitiesFromSdkModel, | ||
| 11 | buildCursorDiscoveredModelsFromSdk, | ||
| 12 | buildCursorProviderSnapshot, | ||
| 13 | buildInitialCursorProviderSnapshot, | ||
| 14 | checkCursorProviderStatus, | ||
| 15 | } from "./CursorProvider.ts"; | ||
| 16 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 17 | |||
| 18 | const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); | ||
| 19 | |||
| 20 | function selectDescriptor( | ||
| 21 | id: string, | ||
| 22 | label: string, | ||
| 23 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 24 | ) { | ||
| 25 | return { | ||
| 26 | id, | ||
| 27 | label, | ||
| 28 | type: "select" as const, | ||
| 29 | options: [...options], | ||
| 30 | ...(options.find((option) => option.isDefault)?.id | ||
| 31 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 32 | : {}), | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | ||
| 37 | return { | ||
| 38 | id, | ||
| 39 | label, | ||
| 40 | type: "boolean" as const, | ||
| 41 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const baseCursorSettings: CursorSettings = decodeCursorSettings({ | ||
| 46 | enabled: true, | ||
| 47 | customModels: [], | ||
| 48 | }); | ||
| 49 | |||
| 50 | const sdkParameterizedModel = { | ||
| 51 | id: "claude-opus-4-8", | ||
| 52 | displayName: "Opus 4.8", | ||
| 53 | parameters: [ | ||
| 54 | { | ||
| 55 | id: "thinking", | ||
| 56 | displayName: "Thinking", | ||
| 57 | values: [{ value: "false" }, { value: "true" }], | ||
| 58 | }, | ||
| 59 | { | ||
| 60 | id: "context", | ||
| 61 | displayName: "Context", | ||
| 62 | values: [ | ||
| 63 | { value: "300k", displayName: "300K" }, | ||
| 64 | { value: "1m", displayName: "1M" }, | ||
| 65 | ], | ||
| 66 | }, | ||
| 67 | { | ||
| 68 | id: "effort", | ||
| 69 | displayName: "Effort", | ||
| 70 | values: [ | ||
| 71 | { value: "low", displayName: "Low" }, | ||
| 72 | { value: "high", displayName: "High" }, | ||
| 73 | ], | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | id: "fast", | ||
| 77 | displayName: "Fast", | ||
| 78 | values: [{ value: "false" }, { value: "true", displayName: "Fast" }], | ||
| 79 | }, | ||
| 80 | ], | ||
| 81 | variants: [ | ||
| 82 | { | ||
| 83 | displayName: "Opus 4.8", | ||
| 84 | isDefault: true, | ||
| 85 | params: [ | ||
| 86 | { id: "thinking", value: "true" }, | ||
| 87 | { id: "context", value: "1m" }, | ||
| 88 | { id: "effort", value: "high" }, | ||
| 89 | { id: "fast", value: "false" }, | ||
| 90 | ], | ||
| 91 | }, | ||
| 92 | ], | ||
| 93 | } satisfies SDKModel; | ||
| 94 | |||
| 95 | describe("buildInitialCursorProviderSnapshot", () => { | ||
| 96 | it.effect("uses SDK-specific pending status copy", () => | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); | ||
| 99 | |||
| 100 | expect(provider).toMatchObject({ | ||
| 101 | status: "warning", | ||
| 102 | message: "Checking Cursor SDK availability...", | ||
| 103 | }); | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | }); | ||
| 107 | |||
| 108 | describe("buildCursorProviderSnapshot", () => { | ||
| 109 | it("downgrades ready status to warning when SDK model discovery returns no models", () => { | ||
| 110 | expect( | ||
| 111 | buildCursorProviderSnapshot({ | ||
| 112 | checkedAt: "2026-01-01T00:00:00.000Z", | ||
| 113 | cursorSettings: baseCursorSettings, | ||
| 114 | parsed: { | ||
| 115 | version: null, | ||
| 116 | status: "ready", | ||
| 117 | auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, | ||
| 118 | }, | ||
| 119 | discoveryWarning: "Cursor SDK model discovery returned no built-in models.", | ||
| 120 | }), | ||
| 121 | ).toMatchObject({ | ||
| 122 | status: "warning", | ||
| 123 | message: "Cursor SDK model discovery returned no built-in models.", | ||
| 124 | models: [], | ||
| 125 | supportsConversationRollback: false, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | }); | ||
| 129 | |||
| 130 | describe("Cursor SDK model discovery", () => { | ||
| 131 | it("maps native SDK parameter ids and default variant values to model capabilities", () => { | ||
| 132 | expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( | ||
| 133 | createModelCapabilities({ | ||
| 134 | optionDescriptors: [ | ||
| 135 | selectDescriptor("effort", "Effort", [ | ||
| 136 | { id: "low", label: "Low" }, | ||
| 137 | { id: "high", label: "High", isDefault: true }, | ||
| 138 | ]), | ||
| 139 | selectDescriptor("contextWindow", "Context", [ | ||
| 140 | { id: "300k", label: "300K" }, | ||
| 141 | { id: "1m", label: "1M", isDefault: true }, | ||
| 142 | ]), | ||
| 143 | booleanDescriptor("fastMode", "Fast", false), | ||
| 144 | booleanDescriptor("thinking", "Thinking", true), | ||
| 145 | ], | ||
| 146 | }), | ||
| 147 | ); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("filters invalid and duplicate SDK model entries", () => { | ||
| 151 | expect( | ||
| 152 | buildCursorDiscoveredModelsFromSdk([ | ||
| 153 | sdkParameterizedModel, | ||
| 154 | { ...sdkParameterizedModel, displayName: "Duplicate" }, | ||
| 155 | { id: "", displayName: "Invalid" }, | ||
| 156 | ]), | ||
| 157 | ).toEqual([ | ||
| 158 | { | ||
| 159 | slug: "claude-opus-4-8", | ||
| 160 | name: "Opus 4.8", | ||
| 161 | isCustom: false, | ||
| 162 | capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), | ||
| 163 | }, | ||
| 164 | ]); | ||
| 165 | }); | ||
| 166 | }); | ||
| 167 | |||
| 168 | describe("checkCursorProviderStatus", () => { | ||
| 169 | it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const provider = yield* checkCursorProviderStatus( | ||
| 172 | { | ||
| 173 | ...baseCursorSettings, | ||
| 174 | customModels: ["internal/cursor-model"], | ||
| 175 | }, | ||
| 176 | { CURSOR_API_KEY: "test-cursor-key" }, | ||
| 177 | ).pipe( | ||
| 178 | Effect.provide( | ||
| 179 | CursorSdkCatalog.layerTest((apiKey) => { | ||
| 180 | expect(apiKey).toBe("test-cursor-key"); | ||
| 181 | return Effect.succeed({ | ||
| 182 | user: { | ||
| 183 | apiKeyName: "test-key", | ||
| 184 | userEmail: "cursor@example.com", | ||
| 185 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 186 | }, | ||
| 187 | models: [sdkParameterizedModel], | ||
| 188 | }); | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | |||
| 193 | expect(provider).toMatchObject({ | ||
| 194 | status: "ready", | ||
| 195 | auth: { | ||
| 196 | status: "authenticated", | ||
| 197 | type: "api-key", | ||
| 198 | label: "Cursor API key (test-key)", | ||
| 199 | email: "cursor@example.com", | ||
| 200 | }, | ||
| 201 | models: [ | ||
| 202 | { slug: "claude-opus-4-8", isCustom: false }, | ||
| 203 | { slug: "internal/cursor-model", isCustom: true }, | ||
| 204 | ], | ||
| 205 | }); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("surfaces SDK authentication failures", () => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const provider = yield* checkCursorProviderStatus(baseCursorSettings, { | ||
| 212 | CURSOR_API_KEY: "invalid-test-key", | ||
| 213 | }).pipe( | ||
| 214 | Effect.provide( | ||
| 215 | CursorSdkCatalog.layerTest(() => | ||
| 216 | Effect.fail( | ||
| 217 | new CursorSdkCatalog.CursorSdkCatalogError({ | ||
| 218 | authenticationFailure: true, | ||
| 219 | cause: new Error("unauthorized"), | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | ), | ||
| 223 | ), | ||
| 224 | ); | ||
| 225 | |||
| 226 | expect(provider).toMatchObject({ | ||
| 227 | status: "error", | ||
| 228 | auth: { status: "unauthenticated" }, | ||
| 229 | message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", | ||
| 230 | }); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("requires a Cursor API key without probing any external Cursor binary", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( | ||
| 237 | Effect.provide( | ||
| 238 | CursorSdkCatalog.layerTest(() => | ||
| 239 | Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), | ||
| 240 | ), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | expect(provider).toMatchObject({ | ||
| 245 | installed: true, | ||
| 246 | status: "error", | ||
| 247 | auth: { status: "unauthenticated" }, | ||
| 248 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | ||
| 249 | }); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | }); | ||
apps/server/src/provider/Layers/CursorProvider.ts deleted-350| ... | @@ -1,350 +0,0 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import type { | ||
| 3 | CursorSettings, | ||
| 4 | ModelCapabilities, | ||
| 5 | ProviderOptionDescriptor, | ||
| 6 | ServerProviderAuth, | ||
| 7 | ServerProviderModel, | ||
| 8 | ServerProviderState, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Result from "effect/Result"; | ||
| 15 | |||
| 16 | import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "../cursorSdkModel.ts"; | ||
| 17 | import { | ||
| 18 | buildBooleanOptionDescriptor, | ||
| 19 | buildSelectOptionDescriptor, | ||
| 20 | buildServerProvider, | ||
| 21 | providerModelsFromSettings, | ||
| 22 | type ServerProviderDraft, | ||
| 23 | } from "../providerSnapshot.ts"; | ||
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 25 | |||
| 26 | const CURSOR_PRESENTATION = { | ||
| 27 | displayName: "Cursor", | ||
| 28 | supportsConversationRollback: false, | ||
| 29 | showInteractionModeToggle: true, | ||
| 30 | } as const; | ||
| 31 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 32 | optionDescriptors: [], | ||
| 33 | }); | ||
| 34 | |||
| 35 | const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; | ||
| 36 | |||
| 37 | export function buildInitialCursorProviderSnapshot( | ||
| 38 | cursorSettings: CursorSettings, | ||
| 39 | ): Effect.Effect<ServerProviderDraft> { | ||
| 40 | return Effect.gen(function* () { | ||
| 41 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 42 | const models = getCursorFallbackModels(cursorSettings); | ||
| 43 | |||
| 44 | if (!cursorSettings.enabled) { | ||
| 45 | return buildServerProvider({ | ||
| 46 | presentation: CURSOR_PRESENTATION, | ||
| 47 | enabled: false, | ||
| 48 | checkedAt, | ||
| 49 | models, | ||
| 50 | probe: { | ||
| 51 | installed: false, | ||
| 52 | version: null, | ||
| 53 | status: "warning", | ||
| 54 | auth: { status: "unknown" }, | ||
| 55 | message: "Cursor is disabled in T3 Code settings.", | ||
| 56 | }, | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | |||
| 60 | return buildServerProvider({ | ||
| 61 | presentation: CURSOR_PRESENTATION, | ||
| 62 | enabled: true, | ||
| 63 | checkedAt, | ||
| 64 | models, | ||
| 65 | probe: { | ||
| 66 | installed: true, | ||
| 67 | version: null, | ||
| 68 | status: "warning", | ||
| 69 | auth: { status: "unknown" }, | ||
| 70 | message: "Checking Cursor SDK availability...", | ||
| 71 | }, | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | } | ||
| 75 | |||
| 76 | function getCursorFallbackModels( | ||
| 77 | cursorSettings: Pick<CursorSettings, "customModels">, | ||
| 78 | ): ReadonlyArray<ServerProviderModel> { | ||
| 79 | return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); | ||
| 80 | } | ||
| 81 | |||
| 82 | function toTitleCaseWords(value: string): string { | ||
| 83 | const parts: Array<string> = []; | ||
| 84 | for (const part of value.split(/[\s_-]+/g)) { | ||
| 85 | if (part.length > 0) { | ||
| 86 | parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | return parts.join(" "); | ||
| 90 | } | ||
| 91 | |||
| 92 | function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { | ||
| 93 | return model.variants | ||
| 94 | ?.find((variant) => variant.isDefault) | ||
| 95 | ?.params.find((parameter) => parameter.id === parameterId)?.value; | ||
| 96 | } | ||
| 97 | |||
| 98 | export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { | ||
| 99 | const seen = new Set<string>(); | ||
| 100 | const optionDescriptors: Array<ProviderOptionDescriptor> = []; | ||
| 101 | const parameters = (model.parameters ?? []) | ||
| 102 | .map((parameter, index) => ({ parameter, index })) | ||
| 103 | .toSorted( | ||
| 104 | (left, right) => | ||
| 105 | cursorSdkParameterPriority(left.parameter.id) - | ||
| 106 | cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, | ||
| 107 | ); | ||
| 108 | for (const { parameter } of parameters) { | ||
| 109 | const nativeId = parameter.id.trim(); | ||
| 110 | const id = cursorSdkProviderOptionId(nativeId); | ||
| 111 | if (!nativeId || !id || seen.has(id)) { | ||
| 112 | continue; | ||
| 113 | } | ||
| 114 | seen.add(id); | ||
| 115 | |||
| 116 | const values = parameter.values.flatMap((entry) => { | ||
| 117 | const value = entry.value.trim(); | ||
| 118 | if (!value) { | ||
| 119 | return []; | ||
| 120 | } | ||
| 121 | return [ | ||
| 122 | { | ||
| 123 | value, | ||
| 124 | label: entry.displayName?.trim() || value, | ||
| 125 | }, | ||
| 126 | ]; | ||
| 127 | }); | ||
| 128 | if (values.length === 0) { | ||
| 129 | continue; | ||
| 130 | } | ||
| 131 | |||
| 132 | const label = parameter.displayName?.trim() || toTitleCaseWords(id); | ||
| 133 | const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); | ||
| 134 | const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); | ||
| 135 | if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { | ||
| 136 | if (defaultValue === "true" || defaultValue === "false") { | ||
| 137 | optionDescriptors.push( | ||
| 138 | buildBooleanOptionDescriptor({ | ||
| 139 | id, | ||
| 140 | label, | ||
| 141 | currentValue: defaultValue === "true", | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | } else { | ||
| 145 | optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); | ||
| 146 | } | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | |||
| 150 | optionDescriptors.push( | ||
| 151 | buildSelectOptionDescriptor({ | ||
| 152 | id, | ||
| 153 | label, | ||
| 154 | options: values.map((entry) => ({ | ||
| 155 | ...entry, | ||
| 156 | ...(entry.value === defaultValue ? { isDefault: true } : {}), | ||
| 157 | })), | ||
| 158 | }), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | return createModelCapabilities({ optionDescriptors }); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function buildCursorDiscoveredModelsFromSdk( | ||
| 166 | models: ReadonlyArray<SDKModel>, | ||
| 167 | ): ReadonlyArray<ServerProviderModel> { | ||
| 168 | const seen = new Set<string>(); | ||
| 169 | return models.flatMap((model) => { | ||
| 170 | const slug = model.id.trim(); | ||
| 171 | const name = model.displayName.trim(); | ||
| 172 | if (!slug || !name || seen.has(slug)) { | ||
| 173 | return []; | ||
| 174 | } | ||
| 175 | seen.add(slug); | ||
| 176 | return [ | ||
| 177 | { | ||
| 178 | slug, | ||
| 179 | name, | ||
| 180 | isCustom: false, | ||
| 181 | capabilities: buildCursorCapabilitiesFromSdkModel(model), | ||
| 182 | } satisfies ServerProviderModel, | ||
| 183 | ]; | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | |||
| 187 | function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { | ||
| 188 | const email = user.userEmail?.trim(); | ||
| 189 | const apiKeyName = user.apiKeyName.trim(); | ||
| 190 | return { | ||
| 191 | status: "authenticated", | ||
| 192 | type, | ||
| 193 | label: | ||
| 194 | type === "browser" | ||
| 195 | ? "Cursor account" | ||
| 196 | : apiKeyName | ||
| 197 | ? `Cursor API key (${apiKeyName})` | ||
| 198 | : "Cursor API key", | ||
| 199 | ...(email ? { email } : {}), | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | interface CursorProviderProbeResult { | ||
| 204 | readonly version: string | null; | ||
| 205 | readonly status: Exclude<ServerProviderState, "disabled">; | ||
| 206 | readonly auth: ServerProviderAuth; | ||
| 207 | readonly message?: string; | ||
| 208 | } | ||
| 209 | |||
| 210 | function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined { | ||
| 211 | const parts: Array<string> = []; | ||
| 212 | for (const message of messages) { | ||
| 213 | const trimmed = message?.trim(); | ||
| 214 | if (trimmed) { | ||
| 215 | parts.push(trimmed); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return parts.length > 0 ? parts.join(" ") : undefined; | ||
| 219 | } | ||
| 220 | |||
| 221 | export function buildCursorProviderSnapshot(input: { | ||
| 222 | readonly checkedAt: string; | ||
| 223 | readonly cursorSettings: CursorSettings; | ||
| 224 | readonly parsed: CursorProviderProbeResult; | ||
| 225 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; | ||
| 226 | readonly discoveryWarning?: string; | ||
| 227 | }): ServerProviderDraft { | ||
| 228 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); | ||
| 229 | return buildServerProvider({ | ||
| 230 | presentation: CURSOR_PRESENTATION, | ||
| 231 | enabled: input.cursorSettings.enabled, | ||
| 232 | checkedAt: input.checkedAt, | ||
| 233 | models: providerModelsFromSettings( | ||
| 234 | input.discoveredModels ?? [], | ||
| 235 | input.cursorSettings.customModels, | ||
| 236 | EMPTY_CAPABILITIES, | ||
| 237 | ), | ||
| 238 | probe: { | ||
| 239 | installed: true, | ||
| 240 | version: input.parsed.version, | ||
| 241 | status: | ||
| 242 | input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, | ||
| 243 | auth: input.parsed.auth, | ||
| 244 | ...(message ? { message } : {}), | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | } | ||
| 248 | |||
| 249 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( | ||
| 250 | cursorSettings: CursorSettings, | ||
| 251 | environment?: NodeJS.ProcessEnv, | ||
| 252 | authenticationType: "api-key" | "browser" = "api-key", | ||
| 253 | ): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> { | ||
| 254 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 255 | const fallbackModels = getCursorFallbackModels(cursorSettings); | ||
| 256 | |||
| 257 | if (!cursorSettings.enabled) { | ||
| 258 | return buildServerProvider({ | ||
| 259 | presentation: CURSOR_PRESENTATION, | ||
| 260 | enabled: false, | ||
| 261 | checkedAt, | ||
| 262 | models: fallbackModels, | ||
| 263 | probe: { | ||
| 264 | installed: false, | ||
| 265 | version: null, | ||
| 266 | status: "warning", | ||
| 267 | auth: { status: "unknown" }, | ||
| 268 | message: "Cursor is disabled in T3 Code settings.", | ||
| 269 | }, | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | |||
| 273 | const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); | ||
| 274 | if (!sdkApiKey) { | ||
| 275 | return buildServerProvider({ | ||
| 276 | presentation: CURSOR_PRESENTATION, | ||
| 277 | enabled: cursorSettings.enabled, | ||
| 278 | checkedAt, | ||
| 279 | models: fallbackModels, | ||
| 280 | probe: { | ||
| 281 | installed: true, | ||
| 282 | version: null, | ||
| 283 | status: "error", | ||
| 284 | auth: { status: "unauthenticated" }, | ||
| 285 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | ||
| 286 | }, | ||
| 287 | }); | ||
| 288 | } | ||
| 289 | |||
| 290 | const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; | ||
| 291 | const catalogResult = yield* sdkCatalog | ||
| 292 | .read(sdkApiKey) | ||
| 293 | .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); | ||
| 294 | |||
| 295 | if (Result.isFailure(catalogResult)) { | ||
| 296 | yield* Effect.logWarning("Cursor SDK catalog probe failed", { | ||
| 297 | cause: catalogResult.failure.cause, | ||
| 298 | }); | ||
| 299 | const authenticationFailure = catalogResult.failure.authenticationFailure; | ||
| 300 | return buildServerProvider({ | ||
| 301 | presentation: CURSOR_PRESENTATION, | ||
| 302 | enabled: cursorSettings.enabled, | ||
| 303 | checkedAt, | ||
| 304 | models: fallbackModels, | ||
| 305 | probe: { | ||
| 306 | installed: true, | ||
| 307 | version: null, | ||
| 308 | status: "error", | ||
| 309 | auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, | ||
| 310 | message: authenticationFailure | ||
| 311 | ? authenticationType === "browser" | ||
| 312 | ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." | ||
| 313 | : "Cursor SDK authentication failed. Check CURSOR_API_KEY." | ||
| 314 | : "Cursor SDK catalog request failed. Check server logs for details.", | ||
| 315 | }, | ||
| 316 | }); | ||
| 317 | } | ||
| 318 | |||
| 319 | if (Option.isNone(catalogResult.success)) { | ||
| 320 | return buildServerProvider({ | ||
| 321 | presentation: CURSOR_PRESENTATION, | ||
| 322 | enabled: cursorSettings.enabled, | ||
| 323 | checkedAt, | ||
| 324 | models: fallbackModels, | ||
| 325 | probe: { | ||
| 326 | installed: true, | ||
| 327 | version: null, | ||
| 328 | status: "error", | ||
| 329 | auth: { status: "unknown" }, | ||
| 330 | message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, | ||
| 331 | }, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | |||
| 335 | const snapshot = catalogResult.success.value; | ||
| 336 | const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); | ||
| 337 | return buildCursorProviderSnapshot({ | ||
| 338 | checkedAt, | ||
| 339 | cursorSettings, | ||
| 340 | parsed: { | ||
| 341 | version: null, | ||
| 342 | status: "ready", | ||
| 343 | auth: cursorSdkAuth(snapshot.user, authenticationType), | ||
| 344 | }, | ||
| 345 | discoveredModels, | ||
| 346 | ...(discoveredModels.length === 0 | ||
| 347 | ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } | ||
| 348 | : {}), | ||
| 349 | }); | ||
| 350 | }); | ||
apps/server/src/provider/Layers/CursorSdkCatalog.test.ts deleted-66| ... | @@ -1,66 +0,0 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | |||
| 6 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | ||
| 7 | |||
| 8 | const user = { | ||
| 9 | apiKeyName: "test-key", | ||
| 10 | userEmail: "cursor@example.com", | ||
| 11 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 12 | } satisfies SDKUser; | ||
| 13 | |||
| 14 | const model = { | ||
| 15 | id: "claude-opus-4-8", | ||
| 16 | displayName: "Opus 4.8", | ||
| 17 | parameters: [], | ||
| 18 | } satisfies SDKModel; | ||
| 19 | |||
| 20 | const catalogError = () => | ||
| 21 | new CursorSdkCatalog.CursorSdkCatalogError({ | ||
| 22 | authenticationFailure: false, | ||
| 23 | cause: new Error("catalog unavailable"), | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("CursorSdkCatalog", () => { | ||
| 27 | it.effect("keeps the user probe fresh while caching models by API key", () => | ||
| 28 | Effect.gen(function* () { | ||
| 29 | let userCalls = 0; | ||
| 30 | let modelCalls = 0; | ||
| 31 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | ||
| 32 | readUser: () => Effect.sync(() => ((userCalls += 1), user)), | ||
| 33 | readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), | ||
| 34 | }); | ||
| 35 | |||
| 36 | yield* catalog.read("first-key"); | ||
| 37 | yield* catalog.read("first-key"); | ||
| 38 | yield* catalog.read("second-key"); | ||
| 39 | |||
| 40 | expect(userCalls).toBe(3); | ||
| 41 | expect(modelCalls).toBe(2); | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | it.effect("does not cache failed or empty model discovery", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | let modelCalls = 0; | ||
| 48 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | ||
| 49 | readUser: () => Effect.succeed(user), | ||
| 50 | readModels: () => | ||
| 51 | Effect.suspend(() => { | ||
| 52 | modelCalls += 1; | ||
| 53 | if (modelCalls === 1) return Effect.fail(catalogError()); | ||
| 54 | if (modelCalls === 2) return Effect.succeed([]); | ||
| 55 | return Effect.succeed([model]); | ||
| 56 | }), | ||
| 57 | }); | ||
| 58 | |||
| 59 | expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); | ||
| 60 | expect((yield* catalog.read("test-key")).models).toEqual([]); | ||
| 61 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | ||
| 62 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | ||
| 63 | expect(modelCalls).toBe(3); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | }); | ||
apps/server/src/provider/Layers/CursorSdkCatalog.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | ||
| 2 | import * as Cache from "effect/Cache"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { AuthenticationError, Cursor, CursorSdkError } from "../cursorSdk.ts"; | ||
| 11 | |||
| 12 | export interface CursorSdkCatalogSnapshot { | ||
| 13 | readonly user: SDKUser; | ||
| 14 | readonly models: ReadonlyArray<SDKModel>; | ||
| 15 | } | ||
| 16 | |||
| 17 | export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()( | ||
| 18 | "CursorSdkCatalogError", | ||
| 19 | { | ||
| 20 | authenticationFailure: Schema.Boolean, | ||
| 21 | cause: Schema.Defect(), | ||
| 22 | }, | ||
| 23 | ) { | ||
| 24 | override get message(): string { | ||
| 25 | return this.authenticationFailure | ||
| 26 | ? "Cursor SDK authentication failed." | ||
| 27 | : "Cursor SDK catalog request failed."; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface CursorSdkCatalogShape { | ||
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | ||
| 33 | } | ||
| 34 | |||
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | ||
| 36 | "t3/provider/Layers/CursorSdkCatalog", | ||
| 37 | ) {} | ||
| 38 | |||
| 39 | function isAuthenticationFailure(cause: unknown): boolean { | ||
| 40 | return ( | ||
| 41 | cause instanceof AuthenticationError || | ||
| 42 | (cause instanceof CursorSdkError && cause.status === 401) | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface CursorSdkCatalogProbes { | ||
| 47 | readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>; | ||
| 48 | readonly readModels: ( | ||
| 49 | apiKey: string, | ||
| 50 | ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>; | ||
| 51 | } | ||
| 52 | |||
| 53 | const sdkRequest = <A>(tryRequest: () => Promise<A>) => | ||
| 54 | Effect.tryPromise({ | ||
| 55 | try: tryRequest, | ||
| 56 | catch: (cause) => | ||
| 57 | new CursorSdkCatalogError({ | ||
| 58 | authenticationFailure: isAuthenticationFailure(cause), | ||
| 59 | cause, | ||
| 60 | }), | ||
| 61 | }); | ||
| 62 | |||
| 63 | const liveProbes: CursorSdkCatalogProbes = { | ||
| 64 | readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), | ||
| 65 | readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), | ||
| 66 | }; | ||
| 67 | |||
| 68 | export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( | ||
| 69 | probes: CursorSdkCatalogProbes = liveProbes, | ||
| 70 | ) { | ||
| 71 | const modelCache = yield* Cache.makeWith(probes.readModels, { | ||
| 72 | capacity: 32, | ||
| 73 | timeToLive: (exit) => | ||
| 74 | Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, | ||
| 75 | }); | ||
| 76 | return CursorSdkCatalog.of({ | ||
| 77 | read: (apiKey) => | ||
| 78 | Effect.all( | ||
| 79 | { | ||
| 80 | user: probes.readUser(apiKey), | ||
| 81 | models: Cache.get(modelCache, apiKey), | ||
| 82 | }, | ||
| 83 | { concurrency: "unbounded" }, | ||
| 84 | ), | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); | ||
| 89 | |||
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | ||
| 91 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); | ||
| 92 | } | ||
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts deleted-856| ... | @@ -1,856 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeOS from "node:os"; | ||
| 4 | import * as NodePath from "node:path"; | ||
| 5 | |||
| 6 | import { ThreadId } from "@t3tools/contracts"; | ||
| 7 | import { assert, describe, it } from "@effect/vitest"; | ||
| 8 | import * as Clock from "effect/Clock"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Logger from "effect/Logger"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as TestClock from "effect/testing/TestClock"; | ||
| 14 | |||
| 15 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 16 | import { | ||
| 17 | makeEventNdjsonLogger, | ||
| 18 | makeEventNdjsonLogStore, | ||
| 19 | type PendingRecord, | ||
| 20 | writeBatchedMessages, | ||
| 21 | } from "./EventNdjsonLogger.ts"; | ||
| 22 | |||
| 23 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 24 | const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 25 | |||
| 26 | function ownedLogPath(basePath: string, segment: string): string { | ||
| 27 | const basename = NodePath.basename(basePath); | ||
| 28 | const extension = NodePath.extname(basename); | ||
| 29 | const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename; | ||
| 30 | return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`); | ||
| 31 | } | ||
| 32 | |||
| 33 | function parseLogLine(line: string) { | ||
| 34 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); | ||
| 35 | assert.notEqual(match, null); | ||
| 36 | if (!match) { | ||
| 37 | throw new Error(`invalid log line: ${line}`); | ||
| 38 | } | ||
| 39 | const observedAt = match[1]; | ||
| 40 | const stream = match[2]; | ||
| 41 | const payload = match[3]; | ||
| 42 | if (!observedAt || !stream || payload === undefined) { | ||
| 43 | throw new Error(`invalid log line: ${line}`); | ||
| 44 | } | ||
| 45 | return { | ||
| 46 | observedAt, | ||
| 47 | stream, | ||
| 48 | payload, | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | describe("EventNdjsonLogger", () => { | ||
| 53 | it.effect("summarizes circular events without exposing their contents in diagnostics", () => { | ||
| 54 | const messages: Array<unknown> = []; | ||
| 55 | const logCapture = Logger.make<unknown, void>(({ message }) => { | ||
| 56 | if (Array.isArray(message)) { | ||
| 57 | messages.push(...message); | ||
| 58 | } else { | ||
| 59 | messages.push(message); | ||
| 60 | } | ||
| 61 | }); | ||
| 62 | const secret = "secret-circular-event-value"; | ||
| 63 | |||
| 64 | return Effect.gen(function* () { | ||
| 65 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 66 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 67 | const circular: Record<string, unknown> = { secret }; | ||
| 68 | circular.self = circular; | ||
| 69 | |||
| 70 | try { | ||
| 71 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); | ||
| 72 | assert.exists(logger); | ||
| 73 | if (!logger) return; | ||
| 74 | yield* logger.write(circular, ThreadId.make("thread-1")); | ||
| 75 | yield* logger.close(); | ||
| 76 | |||
| 77 | const serialized = encodeUnknownJson(messages); | ||
| 78 | assert.notInclude(serialized, secret); | ||
| 79 | const line = parseLogLine( | ||
| 80 | NodeFS.readFileSync(ownedLogPath(basePath, "thread-1"), "utf8").trim(), | ||
| 81 | ); | ||
| 82 | assert.equal(line.payload, '{"truncated":true}'); | ||
| 83 | } finally { | ||
| 84 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 85 | } | ||
| 86 | }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it.effect("writes effect-style lines to thread-scoped files", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 92 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 93 | |||
| 94 | try { | ||
| 95 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); | ||
| 96 | assert.notEqual(logger, undefined); | ||
| 97 | if (!logger) { | ||
| 98 | return; | ||
| 99 | } | ||
| 100 | |||
| 101 | yield* logger.write( | ||
| 102 | { threadId: "provider-thread-1", id: "evt-1" }, | ||
| 103 | ThreadId.make("thread-1"), | ||
| 104 | ); | ||
| 105 | yield* logger.write( | ||
| 106 | { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, | ||
| 107 | ThreadId.make("thread-2"), | ||
| 108 | ); | ||
| 109 | yield* logger.close(); | ||
| 110 | |||
| 111 | const threadOnePath = ownedLogPath(basePath, "thread-1"); | ||
| 112 | const threadTwoPath = ownedLogPath(basePath, "thread-2"); | ||
| 113 | assert.equal(NodeFS.existsSync(threadOnePath), true); | ||
| 114 | assert.equal(NodeFS.existsSync(threadTwoPath), true); | ||
| 115 | |||
| 116 | const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); | ||
| 117 | const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); | ||
| 118 | |||
| 119 | assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); | ||
| 120 | assert.equal(first.stream, "NTIVE"); | ||
| 121 | assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); | ||
| 122 | |||
| 123 | assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); | ||
| 124 | assert.equal(second.stream, "NTIVE"); | ||
| 125 | assert.equal( | ||
| 126 | second.payload, | ||
| 127 | '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', | ||
| 128 | ); | ||
| 129 | } finally { | ||
| 130 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 131 | } | ||
| 132 | }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | it.effect( | ||
| 136 | "falls back to a global segment when orchestration thread id is missing or invalid", | ||
| 137 | () => | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 140 | const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); | ||
| 141 | |||
| 142 | try { | ||
| 143 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); | ||
| 144 | assert.notEqual(logger, undefined); | ||
| 145 | if (!logger) { | ||
| 146 | return; | ||
| 147 | } | ||
| 148 | |||
| 149 | yield* logger.write({ id: "evt-no-thread" }, null); | ||
| 150 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); | ||
| 151 | yield* logger.close(); | ||
| 152 | |||
| 153 | const globalPath = ownedLogPath(basePath, "_global"); | ||
| 154 | assert.equal(NodeFS.existsSync(globalPath), true); | ||
| 155 | const lines = NodeFS.readFileSync(globalPath, "utf8") | ||
| 156 | .trim() | ||
| 157 | .split("\n") | ||
| 158 | .map((line) => parseLogLine(line)); | ||
| 159 | assert.equal(lines.length, 2); | ||
| 160 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); | ||
| 161 | assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); | ||
| 162 | assert.equal(lines[0]?.stream, "ORCH"); | ||
| 163 | assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); | ||
| 164 | assert.equal(lines[1]?.stream, "ORCH"); | ||
| 165 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); | ||
| 166 | } finally { | ||
| 167 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 168 | } | ||
| 169 | }), | ||
| 170 | ); | ||
| 171 | |||
| 172 | it.effect("shares one thread writer across native and canonical streams", () => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 175 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 176 | |||
| 177 | try { | ||
| 178 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 179 | const native = store.logger("native"); | ||
| 180 | const canonical = store.logger("canonical"); | ||
| 181 | const threadId = ThreadId.make("thread-shared"); | ||
| 182 | |||
| 183 | yield* native.write({ id: "native-event" }, threadId); | ||
| 184 | yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); | ||
| 185 | yield* store.close(); | ||
| 186 | |||
| 187 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") | ||
| 188 | .trim() | ||
| 189 | .split("\n") | ||
| 190 | .map(parseLogLine); | ||
| 191 | |||
| 192 | assert.deepEqual( | ||
| 193 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 194 | [ | ||
| 195 | { stream: "NTIVE", payload: '{"id":"native-event"}' }, | ||
| 196 | { | ||
| 197 | stream: "CANON", | ||
| 198 | payload: '{"type":"item.completed","id":"canonical-event"}', | ||
| 199 | }, | ||
| 200 | ], | ||
| 201 | ); | ||
| 202 | } finally { | ||
| 203 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 204 | } | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("keeps shared store views non-owning when one adapter closes", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 211 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 212 | |||
| 213 | try { | ||
| 214 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 215 | const native = store.logger("native"); | ||
| 216 | const canonical = store.logger("canonical"); | ||
| 217 | const threadId = ThreadId.make("thread-shared-close"); | ||
| 218 | |||
| 219 | yield* native.write({ id: "before-close" }, threadId); | ||
| 220 | yield* native.close(); | ||
| 221 | yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId); | ||
| 222 | yield* store.close(); | ||
| 223 | |||
| 224 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8") | ||
| 225 | .trim() | ||
| 226 | .split("\n") | ||
| 227 | .map(parseLogLine); | ||
| 228 | |||
| 229 | assert.deepEqual( | ||
| 230 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 231 | [ | ||
| 232 | { stream: "NTIVE", payload: '{"id":"before-close"}' }, | ||
| 233 | { | ||
| 234 | stream: "CANON", | ||
| 235 | payload: '{"type":"item.completed","id":"after-close"}', | ||
| 236 | }, | ||
| 237 | ], | ||
| 238 | ); | ||
| 239 | } finally { | ||
| 240 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 241 | } | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | |||
| 245 | it.effect("flushes an active batch without a permanent polling loop", () => | ||
| 246 | Effect.gen(function* () { | ||
| 247 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 248 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 249 | const threadPath = ownedLogPath(basePath, "thread-batched"); | ||
| 250 | |||
| 251 | try { | ||
| 252 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | ||
| 253 | yield* store | ||
| 254 | .logger("native") | ||
| 255 | .write({ id: "batched-event" }, ThreadId.make("thread-batched")); | ||
| 256 | |||
| 257 | assert.equal(NodeFS.existsSync(threadPath), false); | ||
| 258 | yield* TestClock.adjust(1_000); | ||
| 259 | assert.equal(NodeFS.existsSync(threadPath), true); | ||
| 260 | yield* store.close(); | ||
| 261 | } finally { | ||
| 262 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 263 | } | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | |||
| 267 | it.effect("does not strand a later batch after an interrupted write", () => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 270 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 271 | const threadPath = ownedLogPath(basePath, "thread-interrupted"); | ||
| 272 | |||
| 273 | try { | ||
| 274 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); | ||
| 275 | const logger = store.logger("native"); | ||
| 276 | const interruptedWrite = yield* logger | ||
| 277 | .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted")) | ||
| 278 | .pipe(Effect.forkChild); | ||
| 279 | yield* Effect.yieldNow; | ||
| 280 | yield* Fiber.interrupt(interruptedWrite); | ||
| 281 | yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted")); | ||
| 282 | |||
| 283 | yield* TestClock.adjust(1_000); | ||
| 284 | |||
| 285 | assert.equal(NodeFS.existsSync(threadPath), true); | ||
| 286 | assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}'); | ||
| 287 | yield* store.close(); | ||
| 288 | } finally { | ||
| 289 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 290 | } | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | |||
| 294 | it.effect("drops transient provider events before serialization", () => | ||
| 295 | Effect.gen(function* () { | ||
| 296 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 297 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 298 | |||
| 299 | try { | ||
| 300 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 301 | const canonical = store.logger("canonical"); | ||
| 302 | const native = store.logger("native"); | ||
| 303 | const threadId = ThreadId.make("thread-filtered"); | ||
| 304 | const circularDelta: Record<string, unknown> = { type: "content.delta" }; | ||
| 305 | circularDelta["self"] = circularDelta; | ||
| 306 | |||
| 307 | yield* canonical.write(circularDelta, threadId); | ||
| 308 | yield* canonical.write({ type: "item.completed", id: "final" }, threadId); | ||
| 309 | yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); | ||
| 310 | yield* native.write( | ||
| 311 | { method: "item/agentMessage/delta", payload: circularDelta }, | ||
| 312 | threadId, | ||
| 313 | ); | ||
| 314 | yield* native.write( | ||
| 315 | { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, | ||
| 316 | threadId, | ||
| 317 | ); | ||
| 318 | yield* native.write( | ||
| 319 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, | ||
| 320 | threadId, | ||
| 321 | ); | ||
| 322 | yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); | ||
| 323 | yield* native.write( | ||
| 324 | { | ||
| 325 | event: { | ||
| 326 | direction: "incoming", | ||
| 327 | stage: "decoded", | ||
| 328 | payload: { | ||
| 329 | method: "turn/diff/updated", | ||
| 330 | get params() { | ||
| 331 | throw new Error("unused diff snapshots must not be traversed"); | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | }, | ||
| 335 | }, | ||
| 336 | threadId, | ||
| 337 | ); | ||
| 338 | yield* native.write( | ||
| 339 | { | ||
| 340 | event: { | ||
| 341 | method: "claude/stream_event/content_block_delta/text_delta", | ||
| 342 | payload: circularDelta, | ||
| 343 | }, | ||
| 344 | }, | ||
| 345 | threadId, | ||
| 346 | ); | ||
| 347 | yield* native.write( | ||
| 348 | { | ||
| 349 | event: { | ||
| 350 | method: "session/update", | ||
| 351 | payload: { update: { sessionUpdate: "agent_message_chunk" } }, | ||
| 352 | }, | ||
| 353 | }, | ||
| 354 | threadId, | ||
| 355 | ); | ||
| 356 | yield* native.write( | ||
| 357 | { | ||
| 358 | event: { | ||
| 359 | direction: "incoming", | ||
| 360 | stage: "decoded", | ||
| 361 | payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, | ||
| 362 | }, | ||
| 363 | }, | ||
| 364 | threadId, | ||
| 365 | ); | ||
| 366 | yield* native.write( | ||
| 367 | { | ||
| 368 | event: { | ||
| 369 | direction: "incoming", | ||
| 370 | stage: "decoded", | ||
| 371 | payload: { | ||
| 372 | type: "stream_event", | ||
| 373 | event: { type: "content_block_delta", delta: circularDelta }, | ||
| 374 | }, | ||
| 375 | }, | ||
| 376 | }, | ||
| 377 | threadId, | ||
| 378 | ); | ||
| 379 | yield* native.write( | ||
| 380 | { | ||
| 381 | event: { | ||
| 382 | direction: "incoming", | ||
| 383 | stage: "raw", | ||
| 384 | get payload() { | ||
| 385 | throw new Error("raw frames must not be inspected"); | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | }, | ||
| 389 | threadId, | ||
| 390 | ); | ||
| 391 | yield* native.write( | ||
| 392 | { | ||
| 393 | event: { | ||
| 394 | type: "message.part.updated", | ||
| 395 | payload: { properties: { part: { type: "text" } } }, | ||
| 396 | }, | ||
| 397 | }, | ||
| 398 | threadId, | ||
| 399 | ); | ||
| 400 | yield* native.write( | ||
| 401 | { | ||
| 402 | event: { | ||
| 403 | type: "message.part.updated", | ||
| 404 | payload: { | ||
| 405 | properties: { | ||
| 406 | part: { type: "tool", state: { status: "running", output: "progress" } }, | ||
| 407 | }, | ||
| 408 | }, | ||
| 409 | }, | ||
| 410 | }, | ||
| 411 | threadId, | ||
| 412 | ); | ||
| 413 | yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); | ||
| 414 | yield* store.close(); | ||
| 415 | |||
| 416 | const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") | ||
| 417 | .trim() | ||
| 418 | .split("\n") | ||
| 419 | .map(parseLogLine); | ||
| 420 | |||
| 421 | assert.deepEqual( | ||
| 422 | lines.map(({ stream, payload }) => ({ stream, payload })), | ||
| 423 | [ | ||
| 424 | { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, | ||
| 425 | { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, | ||
| 426 | ], | ||
| 427 | ); | ||
| 428 | } finally { | ||
| 429 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 430 | } | ||
| 431 | }), | ||
| 432 | ); | ||
| 433 | |||
| 434 | it.effect("summarizes large histories without reading their items", () => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 437 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 438 | const turns = Array.from({ length: 10_000 }); | ||
| 439 | Object.defineProperty(turns, 0, { | ||
| 440 | get: () => { | ||
| 441 | throw new Error("history must not be serialized"); | ||
| 442 | }, | ||
| 443 | }); | ||
| 444 | |||
| 445 | try { | ||
| 446 | const store = yield* makeEventNdjsonLogStore(basePath); | ||
| 447 | yield* store.logger("native").write( | ||
| 448 | { | ||
| 449 | provider: "codex", | ||
| 450 | event: { | ||
| 451 | direction: "incoming", | ||
| 452 | stage: "decoded", | ||
| 453 | payload: { id: 42, result: { thread: { id: "native-thread", turns } } }, | ||
| 454 | }, | ||
| 455 | }, | ||
| 456 | ThreadId.make("large-history"), | ||
| 457 | ); | ||
| 458 | yield* store.close(); | ||
| 459 | |||
| 460 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-history"), "utf8"); | ||
| 461 | assert.isBelow(Buffer.byteLength(contents), 2_048); | ||
| 462 | const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); | ||
| 463 | assert.nestedPropertyVal(record, "event.payload.id", 42); | ||
| 464 | assert.nestedPropertyVal(record, "event.payload.result.thread.id", "native-thread"); | ||
| 465 | assert.nestedPropertyVal(record, "event.payload.result.thread.turns.itemCount", 10_000); | ||
| 466 | } finally { | ||
| 467 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 468 | } | ||
| 469 | }), | ||
| 470 | ); | ||
| 471 | |||
| 472 | it.effect("bounds oversized records while retaining failure details", () => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 475 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 476 | |||
| 477 | try { | ||
| 478 | const store = yield* makeEventNdjsonLogStore(basePath); | ||
| 479 | const logger = store.logger("native"); | ||
| 480 | const threadId = ThreadId.make("large-error"); | ||
| 481 | const failure = { | ||
| 482 | method: "error", | ||
| 483 | params: { | ||
| 484 | threadId: "native-thread", | ||
| 485 | turnId: "native-turn", | ||
| 486 | error: { message: "The provider is unavailable.", code: "overloaded" }, | ||
| 487 | output: "x".repeat(128 * 1_024), | ||
| 488 | }, | ||
| 489 | }; | ||
| 490 | yield* logger.write(failure, threadId); | ||
| 491 | yield* logger.write({ id: "escaped", output: "\u0000".repeat(20_000) }, threadId); | ||
| 492 | yield* store.close(); | ||
| 493 | |||
| 494 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-error"), "utf8"); | ||
| 495 | const records = contents | ||
| 496 | .trim() | ||
| 497 | .split("\n") | ||
| 498 | .map((line) => decodeUnknownJson(parseLogLine(line).payload)); | ||
| 499 | assert.isBelow(Buffer.byteLength(contents), 64 * 1_024); | ||
| 500 | assert.equal(records.length, 2); | ||
| 501 | assert.nestedPropertyVal(records[0], "params.threadId", "native-thread"); | ||
| 502 | assert.nestedPropertyVal(records[0], "params.turnId", "native-turn"); | ||
| 503 | assert.nestedPropertyVal( | ||
| 504 | records[0], | ||
| 505 | "params.error.message", | ||
| 506 | "The provider is unavailable.", | ||
| 507 | ); | ||
| 508 | assert.nestedPropertyVal(records[0], "params.error.code", "overloaded"); | ||
| 509 | assert.propertyVal(records[1], "id", "escaped"); | ||
| 510 | } finally { | ||
| 511 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 512 | } | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | |||
| 516 | it.effect("bounds canonical diff snapshots before serializing their duplicate payloads", () => | ||
| 517 | Effect.gen(function* () { | ||
| 518 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 519 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 520 | const threadId = ThreadId.make("large-diff"); | ||
| 521 | const diff = "diff-payload".repeat(128 * 1_024); | ||
| 522 | try { | ||
| 523 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 524 | yield* store.logger("canonical").write( | ||
| 525 | { | ||
| 526 | type: "turn.diff.updated", | ||
| 527 | threadId, | ||
| 528 | turnId: "native-turn", | ||
| 529 | raw: { method: "turn/diff/updated", payload: { diff } }, | ||
| 530 | payload: { unifiedDiff: diff }, | ||
| 531 | }, | ||
| 532 | threadId, | ||
| 533 | ); | ||
| 534 | yield* store.close(); | ||
| 535 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "large-diff"), "utf8"); | ||
| 536 | assert.isBelow(Buffer.byteLength(contents), 2_048); | ||
| 537 | const record = decodeUnknownJson(parseLogLine(contents.trim()).payload); | ||
| 538 | assert.propertyVal(record, "type", "turn.diff.updated"); | ||
| 539 | assert.propertyVal(record, "threadId", threadId); | ||
| 540 | assert.propertyVal(record, "turnId", "native-turn"); | ||
| 541 | } finally { | ||
| 542 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 543 | } | ||
| 544 | }), | ||
| 545 | ); | ||
| 546 | |||
| 547 | it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => | ||
| 548 | Effect.gen(function* () { | ||
| 549 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 550 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 551 | const threadId = ThreadId.make("thread-tool-lifecycle"); | ||
| 552 | |||
| 553 | try { | ||
| 554 | const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); | ||
| 555 | const native = store.logger("native"); | ||
| 556 | const input = { command: "run-build" }; | ||
| 557 | const states = [ | ||
| 558 | { status: "pending", input }, | ||
| 559 | { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, | ||
| 560 | { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, | ||
| 561 | { status: "unknown", input }, | ||
| 562 | null, | ||
| 563 | ]; | ||
| 564 | const events = states.map((state) => ({ | ||
| 565 | event: { | ||
| 566 | type: "message.part.updated", | ||
| 567 | payload: { properties: { part: { type: "tool", state } } }, | ||
| 568 | }, | ||
| 569 | })); | ||
| 570 | for (const event of events) { | ||
| 571 | yield* native.write(event, threadId); | ||
| 572 | } | ||
| 573 | yield* store.close(); | ||
| 574 | |||
| 575 | const payloads = NodeFS.readFileSync( | ||
| 576 | ownedLogPath(basePath, "thread-tool-lifecycle"), | ||
| 577 | "utf8", | ||
| 578 | ) | ||
| 579 | .trim() | ||
| 580 | .split("\n") | ||
| 581 | .map((line) => parseLogLine(line).payload); | ||
| 582 | assert.deepEqual( | ||
| 583 | payloads, | ||
| 584 | events.map((event) => encodeUnknownJson(event)), | ||
| 585 | ); | ||
| 586 | } finally { | ||
| 587 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 588 | } | ||
| 589 | }), | ||
| 590 | ); | ||
| 591 | |||
| 592 | it.effect("contains hostile event accessors inside guarded serialization", () => | ||
| 593 | Effect.gen(function* () { | ||
| 594 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 595 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 596 | |||
| 597 | try { | ||
| 598 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 599 | stream: "canonical", | ||
| 600 | batchWindowMs: 0, | ||
| 601 | }); | ||
| 602 | assert.exists(logger); | ||
| 603 | if (!logger) return; | ||
| 604 | const hostile = new Proxy( | ||
| 605 | { id: "hostile" }, | ||
| 606 | { | ||
| 607 | get(_target, property) { | ||
| 608 | if (property === "type") throw new Error("blocked"); | ||
| 609 | return undefined; | ||
| 610 | }, | ||
| 611 | }, | ||
| 612 | ); | ||
| 613 | |||
| 614 | yield* logger.write(hostile, ThreadId.make("thread-hostile")); | ||
| 615 | yield* logger.close(); | ||
| 616 | |||
| 617 | const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); | ||
| 618 | assert.notInclude(contents, "blocked"); | ||
| 619 | } finally { | ||
| 620 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 621 | } | ||
| 622 | }), | ||
| 623 | ); | ||
| 624 | |||
| 625 | it.effect("serializes concurrent first writes for the same segment", () => | ||
| 626 | Effect.gen(function* () { | ||
| 627 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 628 | const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); | ||
| 629 | |||
| 630 | try { | ||
| 631 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 632 | stream: "canonical", | ||
| 633 | batchWindowMs: 0, | ||
| 634 | }); | ||
| 635 | assert.notEqual(logger, undefined); | ||
| 636 | if (!logger) { | ||
| 637 | return; | ||
| 638 | } | ||
| 639 | |||
| 640 | yield* Effect.all( | ||
| 641 | [ | ||
| 642 | logger.write({ id: "evt-concurrent-1" }, null), | ||
| 643 | logger.write({ id: "evt-concurrent-2" }, null), | ||
| 644 | ], | ||
| 645 | { concurrency: "unbounded" }, | ||
| 646 | ); | ||
| 647 | yield* logger.close(); | ||
| 648 | |||
| 649 | const globalPath = ownedLogPath(basePath, "_global"); | ||
| 650 | assert.equal(NodeFS.existsSync(globalPath), true); | ||
| 651 | const lines = NodeFS.readFileSync(globalPath, "utf8") | ||
| 652 | .trim() | ||
| 653 | .split("\n") | ||
| 654 | .map((line) => parseLogLine(line)); | ||
| 655 | |||
| 656 | assert.equal(lines.length, 2); | ||
| 657 | assert.deepEqual(lines.map((line) => line.payload).toSorted(), [ | ||
| 658 | '{"id":"evt-concurrent-1"}', | ||
| 659 | '{"id":"evt-concurrent-2"}', | ||
| 660 | ]); | ||
| 661 | } finally { | ||
| 662 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 663 | } | ||
| 664 | }), | ||
| 665 | ); | ||
| 666 | |||
| 667 | it.effect("rotates per-thread files when max size is exceeded", () => | ||
| 668 | Effect.gen(function* () { | ||
| 669 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 670 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 671 | |||
| 672 | try { | ||
| 673 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 674 | maxBytes: 120, | ||
| 675 | maxFiles: 2, | ||
| 676 | batchWindowMs: 0, | ||
| 677 | }); | ||
| 678 | const native = store.logger("native"); | ||
| 679 | const canonical = store.logger("canonical"); | ||
| 680 | |||
| 681 | for (let index = 0; index < 10; index += 1) { | ||
| 682 | const logger = index % 2 === 0 ? native : canonical; | ||
| 683 | yield* logger.write( | ||
| 684 | { | ||
| 685 | type: "session.started", | ||
| 686 | threadId: "provider-thread-rotate", | ||
| 687 | id: `evt-${index}`, | ||
| 688 | payload: "x".repeat(40), | ||
| 689 | }, | ||
| 690 | ThreadId.make("thread-rotate"), | ||
| 691 | ); | ||
| 692 | } | ||
| 693 | yield* store.close(); | ||
| 694 | |||
| 695 | const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate")); | ||
| 696 | const matchingFiles = NodeFS.readdirSync(tempDir) | ||
| 697 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) | ||
| 698 | .toSorted(); | ||
| 699 | |||
| 700 | assert.equal( | ||
| 701 | matchingFiles.some((entry) => entry === `${fileStem}.1`), | ||
| 702 | true, | ||
| 703 | ); | ||
| 704 | assert.equal( | ||
| 705 | matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`), | ||
| 706 | true, | ||
| 707 | ); | ||
| 708 | assert.equal( | ||
| 709 | matchingFiles.some((entry) => entry === `${fileStem}.3`), | ||
| 710 | false, | ||
| 711 | ); | ||
| 712 | } finally { | ||
| 713 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 714 | } | ||
| 715 | }), | ||
| 716 | ); | ||
| 717 | |||
| 718 | it.effect("enforces aggregate age and byte retention on startup", () => | ||
| 719 | Effect.gen(function* () { | ||
| 720 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 721 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 722 | const expiredPath = ownedLogPath(basePath, "expired"); | ||
| 723 | const oldPath = ownedLogPath(basePath, "old"); | ||
| 724 | const newPath = ownedLogPath(basePath, "new"); | ||
| 725 | const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); | ||
| 726 | const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); | ||
| 727 | const ignoredPath = NodePath.join(tempDir, "ignored.txt"); | ||
| 728 | |||
| 729 | try { | ||
| 730 | yield* TestClock.setTime(1_800_000_000_000); | ||
| 731 | const now = yield* Clock.currentTimeMillis; | ||
| 732 | for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { | ||
| 733 | NodeFS.writeFileSync(filePath, "x".repeat(40)); | ||
| 734 | } | ||
| 735 | NodeFS.writeFileSync( | ||
| 736 | legacyLogPath, | ||
| 737 | "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", | ||
| 738 | ); | ||
| 739 | NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | ||
| 740 | NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); | ||
| 741 | NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); | ||
| 742 | NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); | ||
| 743 | |||
| 744 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 745 | maxAgeMs: 10_000, | ||
| 746 | maxTotalBytes: 60, | ||
| 747 | }); | ||
| 748 | yield* store.close(); | ||
| 749 | |||
| 750 | assert.equal(NodeFS.existsSync(expiredPath), false); | ||
| 751 | assert.equal(NodeFS.existsSync(legacyLogPath), false); | ||
| 752 | assert.equal(NodeFS.existsSync(oldPath), false); | ||
| 753 | assert.equal(NodeFS.existsSync(newPath), true); | ||
| 754 | assert.equal(NodeFS.existsSync(unrelatedLogPath), true); | ||
| 755 | assert.equal(NodeFS.existsSync(ignoredPath), true); | ||
| 756 | } finally { | ||
| 757 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 758 | } | ||
| 759 | }), | ||
| 760 | ); | ||
| 761 | |||
| 762 | it.effect("does not prune an active thread sink during an unrelated flush", () => | ||
| 763 | Effect.gen(function* () { | ||
| 764 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 765 | const basePath = NodePath.join(tempDir, "events.log"); | ||
| 766 | const activePath = ownedLogPath(basePath, "active"); | ||
| 767 | |||
| 768 | try { | ||
| 769 | yield* TestClock.setTime(1_800_000_000_000); | ||
| 770 | const store = yield* makeEventNdjsonLogStore(basePath, { | ||
| 771 | batchWindowMs: 0, | ||
| 772 | maxAgeMs: 1, | ||
| 773 | retentionCheckIntervalMs: 1, | ||
| 774 | }); | ||
| 775 | const logger = store.logger("native"); | ||
| 776 | |||
| 777 | yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); | ||
| 778 | assert.equal(NodeFS.existsSync(activePath), true); | ||
| 779 | |||
| 780 | yield* TestClock.adjust("2 millis"); | ||
| 781 | yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); | ||
| 782 | |||
| 783 | assert.equal(NodeFS.existsSync(activePath), true); | ||
| 784 | yield* store.close(); | ||
| 785 | } finally { | ||
| 786 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 787 | } | ||
| 788 | }), | ||
| 789 | ); | ||
| 790 | |||
| 791 | it("attributes batches that were written before a later chunk fails", () => { | ||
| 792 | const records: ReadonlyArray<PendingRecord> = [ | ||
| 793 | { | ||
| 794 | stream: "native", | ||
| 795 | threadSegment: "thread", | ||
| 796 | line: "first", | ||
| 797 | bytes: 5, | ||
| 798 | }, | ||
| 799 | { | ||
| 800 | stream: "canonical", | ||
| 801 | threadSegment: "thread", | ||
| 802 | line: "second", | ||
| 803 | bytes: 6, | ||
| 804 | }, | ||
| 805 | ]; | ||
| 806 | const attributed: Array<PendingRecord> = []; | ||
| 807 | let writes = 0; | ||
| 808 | |||
| 809 | assert.throws(() => | ||
| 810 | writeBatchedMessages( | ||
| 811 | { | ||
| 812 | write: () => { | ||
| 813 | writes += 1; | ||
| 814 | if (writes === 2) throw new Error("simulated disk exhaustion"); | ||
| 815 | }, | ||
| 816 | }, | ||
| 817 | records, | ||
| 818 | 5, | ||
| 819 | (written) => attributed.push(...written), | ||
| 820 | ), | ||
| 821 | ); | ||
| 822 | assert.deepEqual(attributed, [records[0]]); | ||
| 823 | }); | ||
| 824 | |||
| 825 | it.effect("reports logical provider log writes to resource attribution", () => | ||
| 826 | Effect.gen(function* () { | ||
| 827 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); | ||
| 828 | const basePath = NodePath.join(tempDir, "provider-native.ndjson"); | ||
| 829 | |||
| 830 | try { | ||
| 831 | const attribution = yield* ResourceAttribution.make(); | ||
| 832 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 833 | stream: "native", | ||
| 834 | batchWindowMs: 0, | ||
| 835 | attribution, | ||
| 836 | }); | ||
| 837 | assert.notEqual(logger, undefined); | ||
| 838 | if (!logger) { | ||
| 839 | return; | ||
| 840 | } | ||
| 841 | |||
| 842 | yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); | ||
| 843 | yield* logger.close(); | ||
| 844 | |||
| 845 | const snapshot = yield* attribution.snapshot; | ||
| 846 | assert.equal(snapshot.entries.length, 1); | ||
| 847 | assert.equal(snapshot.entries[0]?.component, "provider-event-log"); | ||
| 848 | assert.equal(snapshot.entries[0]?.operation, "native.append"); | ||
| 849 | assert.equal(snapshot.entries[0]?.count, 1); | ||
| 850 | assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); | ||
| 851 | } finally { | ||
| 852 | NodeFS.rmSync(tempDir, { recursive: true, force: true }); | ||
| 853 | } | ||
| 854 | }), | ||
| 855 | ); | ||
| 856 | }); | ||
apps/server/src/provider/Layers/EventNdjsonLogger.ts deleted-805| ... | @@ -1,805 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | ||
| 3 | * Best-effort provider event logging with one shared writer per thread. | ||
| 4 | * | ||
| 5 | * Native and canonical views share batching, rotation, and retention state so | ||
| 6 | * they cannot race while appending to the same thread-scoped file. | ||
| 7 | */ | ||
| 8 | import * as NodeFS from "node:fs"; | ||
| 9 | import * as NodePath from "node:path"; | ||
| 10 | |||
| 11 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; | ||
| 13 | import { errorTag } from "@t3tools/shared/observability"; | ||
| 14 | import * as Clock from "effect/Clock"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Exit from "effect/Exit"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import * as Scope from "effect/Scope"; | ||
| 20 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 21 | |||
| 22 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | ||
| 23 | import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 24 | |||
| 25 | const MEBIBYTE = 1024 * 1024; | ||
| 26 | const DAY_MS = 24 * 60 * 60 * 1_000; | ||
| 27 | const DEFAULT_MAX_BYTES = 10 * MEBIBYTE; | ||
| 28 | const DEFAULT_MAX_FILES = 10; | ||
| 29 | const DEFAULT_BATCH_WINDOW_MS = 1_000; | ||
| 30 | const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE; | ||
| 31 | const DEFAULT_MAX_AGE_MS = 14 * DAY_MS; | ||
| 32 | const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000; | ||
| 33 | const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE; | ||
| 34 | const DEFAULT_MAX_BUFFERED_RECORDS = 512; | ||
| 35 | const MAX_RECORD_CHARACTERS = 64 * 1024; | ||
| 36 | const MAX_RECORD_FIELDS = 1_024; | ||
| 37 | const MAX_RECORD_DEPTH = 16; | ||
| 38 | const GLOBAL_THREAD_SEGMENT = "_global"; | ||
| 39 | const LOG_SCOPE = "provider-observability"; | ||
| 40 | const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 41 | |||
| 42 | const transientCanonicalEventTypes = new Set([ | ||
| 43 | "content.delta", | ||
| 44 | "hook.progress", | ||
| 45 | "item.updated", | ||
| 46 | "task.progress", | ||
| 47 | "thread.realtime.audio.delta", | ||
| 48 | "tool.progress", | ||
| 49 | "turn.proposed.delta", | ||
| 50 | ]); | ||
| 51 | const transientNativeMethods = new Set([ | ||
| 52 | "item/agentMessage/delta", | ||
| 53 | "item/commandExecution/outputDelta", | ||
| 54 | "item/fileChange/outputDelta", | ||
| 55 | "item/plan/delta", | ||
| 56 | "item/reasoning/summaryTextDelta", | ||
| 57 | "item/reasoning/textDelta", | ||
| 58 | "thread/realtime/outputAudio/delta", | ||
| 59 | "thread/realtime/transcript/delta", | ||
| 60 | "turn/diff/updated", | ||
| 61 | ]); | ||
| 62 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); | ||
| 63 | |||
| 64 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; | ||
| 65 | |||
| 66 | export interface EventNdjsonLogger { | ||
| 67 | readonly filePath: string; | ||
| 68 | readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>; | ||
| 69 | readonly close: () => Effect.Effect<void>; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface EventNdjsonLogStore { | ||
| 73 | readonly filePath: string; | ||
| 74 | readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger; | ||
| 75 | readonly close: () => Effect.Effect<void>; | ||
| 76 | } | ||
| 77 | |||
| 78 | export interface EventNdjsonLogStoreOptions { | ||
| 79 | readonly maxBytes?: number; | ||
| 80 | readonly maxFiles?: number; | ||
| 81 | readonly batchWindowMs?: number; | ||
| 82 | readonly maxTotalBytes?: number; | ||
| 83 | readonly maxAgeMs?: number; | ||
| 84 | readonly retentionCheckIntervalMs?: number; | ||
| 85 | readonly maxBufferedBytes?: number; | ||
| 86 | readonly maxBufferedRecords?: number; | ||
| 87 | readonly attribution?: ResourceAttribution["Service"]; | ||
| 88 | } | ||
| 89 | |||
| 90 | export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { | ||
| 91 | readonly stream: EventNdjsonStream; | ||
| 92 | } | ||
| 93 | |||
| 94 | export class EventNdjsonLogConfigurationError extends Schema.TaggedError<EventNdjsonLogConfigurationError>()( | ||
| 95 | "EventNdjsonLogConfigurationError", | ||
| 96 | { | ||
| 97 | filePath: Schema.String, | ||
| 98 | option: Schema.String, | ||
| 99 | value: Schema.Number, | ||
| 100 | minimum: Schema.Number, | ||
| 101 | }, | ||
| 102 | ) { | ||
| 103 | override get message(): string { | ||
| 104 | return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | export class EventNdjsonLogDirectoryError extends Schema.TaggedError<EventNdjsonLogDirectoryError>()( | ||
| 109 | "EventNdjsonLogDirectoryError", | ||
| 110 | { | ||
| 111 | directory: Schema.String, | ||
| 112 | cause: Schema.Defect(), | ||
| 113 | }, | ||
| 114 | ) { | ||
| 115 | override get message(): string { | ||
| 116 | return `Failed to create provider event log directory '${this.directory}'`; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | export type EventNdjsonLogStoreError = | ||
| 121 | | EventNdjsonLogConfigurationError | ||
| 122 | | EventNdjsonLogDirectoryError; | ||
| 123 | |||
| 124 | interface ResolvedOptions { | ||
| 125 | readonly maxBytes: number; | ||
| 126 | readonly maxFiles: number; | ||
| 127 | readonly batchWindowMs: number; | ||
| 128 | readonly maxTotalBytes: number; | ||
| 129 | readonly maxAgeMs: number; | ||
| 130 | readonly retentionCheckIntervalMs: number; | ||
| 131 | readonly maxBufferedBytes: number; | ||
| 132 | readonly maxBufferedRecords: number; | ||
| 133 | readonly attribution: ResourceAttribution["Service"] | undefined; | ||
| 134 | } | ||
| 135 | |||
| 136 | export interface PendingRecord { | ||
| 137 | readonly stream: EventNdjsonStream; | ||
| 138 | readonly threadSegment: string; | ||
| 139 | readonly line: string; | ||
| 140 | readonly bytes: number; | ||
| 141 | } | ||
| 142 | |||
| 143 | interface StoreState { | ||
| 144 | readonly pending: Array<PendingRecord>; | ||
| 145 | readonly pendingBytes: number; | ||
| 146 | readonly sinks: ReadonlyMap<string, RotatingFileSink>; | ||
| 147 | readonly flushScheduled: boolean; | ||
| 148 | readonly closed: boolean; | ||
| 149 | readonly lastRetentionAt: number; | ||
| 150 | } | ||
| 151 | |||
| 152 | interface AttributionSummary { | ||
| 153 | readonly stream: EventNdjsonStream; | ||
| 154 | readonly count: number; | ||
| 155 | readonly logicalWriteBytes: number; | ||
| 156 | } | ||
| 157 | |||
| 158 | interface FileOperationFailure { | ||
| 159 | readonly filePath: string; | ||
| 160 | readonly cause: unknown; | ||
| 161 | } | ||
| 162 | |||
| 163 | interface RetentionResult { | ||
| 164 | readonly failures: ReadonlyArray<FileOperationFailure>; | ||
| 165 | } | ||
| 166 | |||
| 167 | interface DrainResult { | ||
| 168 | readonly attributions: ReadonlyArray<AttributionSummary>; | ||
| 169 | readonly failures: ReadonlyArray<FileOperationFailure>; | ||
| 170 | } | ||
| 171 | |||
| 172 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { | ||
| 173 | return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); | ||
| 174 | } | ||
| 175 | |||
| 176 | function resolveThreadSegment(raw: string | null | undefined): string { | ||
| 177 | const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; | ||
| 178 | return normalized ?? GLOBAL_THREAD_SEGMENT; | ||
| 179 | } | ||
| 180 | |||
| 181 | function resolveStreamLabel(stream: EventNdjsonStream): string { | ||
| 182 | return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON"; | ||
| 183 | } | ||
| 184 | |||
| 185 | function providerLogPrefix(filePath: string): string { | ||
| 186 | const basename = NodePath.basename(filePath); | ||
| 187 | const extension = NodePath.extname(basename); | ||
| 188 | return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`; | ||
| 189 | } | ||
| 190 | |||
| 191 | function providerLogPath(directory: string, prefix: string, threadSegment: string): string { | ||
| 192 | return NodePath.join(directory, `${prefix}${threadSegment}.log`); | ||
| 193 | } | ||
| 194 | |||
| 195 | export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unknown): boolean { | ||
| 196 | if (stream === "orchestration" || typeof event !== "object" || event === null) { | ||
| 197 | return true; | ||
| 198 | } | ||
| 199 | try { | ||
| 200 | const type = Reflect.get(event, "type"); | ||
| 201 | if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { | ||
| 202 | return false; | ||
| 203 | } | ||
| 204 | if (stream !== "native") return true; | ||
| 205 | |||
| 206 | const nested = Reflect.get(event, "event"); | ||
| 207 | const envelope = typeof nested === "object" && nested !== null ? nested : event; | ||
| 208 | // Decoded frames carry the same information as raw frames without another | ||
| 209 | // copy of every token delta. Decode failures have their own diagnostic frame. | ||
| 210 | if (Reflect.get(envelope, "stage") === "raw") return false; | ||
| 211 | const decodedPayload = Reflect.get(envelope, "payload"); | ||
| 212 | const nativeEvent = | ||
| 213 | Reflect.get(envelope, "stage") === "decoded" && | ||
| 214 | typeof decodedPayload === "object" && | ||
| 215 | decodedPayload !== null | ||
| 216 | ? decodedPayload | ||
| 217 | : envelope; | ||
| 218 | const method = Reflect.get(nativeEvent, "method"); | ||
| 219 | if ( | ||
| 220 | typeof method === "string" && | ||
| 221 | (transientNativeMethods.has(method) || | ||
| 222 | method.startsWith("claude/stream_event/content_block_delta/")) | ||
| 223 | ) { | ||
| 224 | return false; | ||
| 225 | } | ||
| 226 | |||
| 227 | const nativeType = Reflect.get(nativeEvent, "type"); | ||
| 228 | if (nativeType === "message.part.delta") return false; | ||
| 229 | if (nativeType === "stream_event") { | ||
| 230 | const streamEvent = Reflect.get(nativeEvent, "event"); | ||
| 231 | if ( | ||
| 232 | typeof streamEvent === "object" && | ||
| 233 | streamEvent !== null && | ||
| 234 | Reflect.get(streamEvent, "type") === "content_block_delta" | ||
| 235 | ) { | ||
| 236 | return false; | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | const payload = Reflect.get(nativeEvent, "payload"); | ||
| 241 | if (typeof payload !== "object" || payload === null) return true; | ||
| 242 | |||
| 243 | if (method === "session/update") { | ||
| 244 | const update = Reflect.get(payload, "update"); | ||
| 245 | if (typeof update !== "object" || update === null) return true; | ||
| 246 | const updateType = Reflect.get(update, "sessionUpdate"); | ||
| 247 | return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); | ||
| 248 | } | ||
| 249 | |||
| 250 | if (nativeType === "message.part.updated") { | ||
| 251 | const properties = Reflect.get(payload, "properties"); | ||
| 252 | if (typeof properties !== "object" || properties === null) return true; | ||
| 253 | const part = Reflect.get(properties, "part"); | ||
| 254 | if (typeof part !== "object" || part === null) return true; | ||
| 255 | const partType = Reflect.get(part, "type"); | ||
| 256 | if (partType === "text" || partType === "reasoning") return false; | ||
| 257 | if (partType === "tool") { | ||
| 258 | // Running snapshots repeat growing output. Pending and terminal states stay in the log. | ||
| 259 | const state = Reflect.get(part, "state"); | ||
| 260 | if (typeof state === "object" && state !== null) { | ||
| 261 | return Reflect.get(state, "status") !== "running"; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | return true; | ||
| 265 | } | ||
| 266 | |||
| 267 | return true; | ||
| 268 | } catch { | ||
| 269 | return true; | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | const summaryFields = [ | ||
| 274 | "provider", | ||
| 275 | "protocol", | ||
| 276 | "kind", | ||
| 277 | "providerSessionId", | ||
| 278 | "direction", | ||
| 279 | "stage", | ||
| 280 | "type", | ||
| 281 | "subtype", | ||
| 282 | "method", | ||
| 283 | "id", | ||
| 284 | "threadId", | ||
| 285 | "turnId", | ||
| 286 | "requestId", | ||
| 287 | "session_id", | ||
| 288 | "status", | ||
| 289 | "is_error", | ||
| 290 | "api_error_status", | ||
| 291 | "terminal_reason", | ||
| 292 | "stop_reason", | ||
| 293 | "operation", | ||
| 294 | "code", | ||
| 295 | "willRetry", | ||
| 296 | "message", | ||
| 297 | "event", | ||
| 298 | "payload", | ||
| 299 | "params", | ||
| 300 | "result", | ||
| 301 | "thread", | ||
| 302 | "turn", | ||
| 303 | "error", | ||
| 304 | "turns", | ||
| 305 | "items", | ||
| 306 | "content", | ||
| 307 | ] as const; | ||
| 308 | |||
| 309 | function summarizeProviderEvent(event: unknown): unknown { | ||
| 310 | let remainingFields = 128; | ||
| 311 | let remainingCharacters = 8 * 1024; | ||
| 312 | const summarize = (value: unknown, depth: number): unknown => { | ||
| 313 | if (typeof value === "string") { | ||
| 314 | if (value.length > Math.min(1_024, remainingCharacters)) { | ||
| 315 | return { omittedCharacters: value.length }; | ||
| 316 | } | ||
| 317 | remainingCharacters -= value.length; | ||
| 318 | return value; | ||
| 319 | } | ||
| 320 | if (value === null || typeof value === "number" || typeof value === "boolean") return value; | ||
| 321 | if (typeof value !== "object") return undefined; | ||
| 322 | if (Array.isArray(value)) return { itemCount: value.length }; | ||
| 323 | const summary: Record<string, unknown> = { truncated: true }; | ||
| 324 | if (depth >= 6) return summary; | ||
| 325 | for (const key of summaryFields) { | ||
| 326 | if (remainingFields <= 0) break; | ||
| 327 | const nested = Reflect.get(value, key); | ||
| 328 | if (nested === undefined) continue; | ||
| 329 | remainingFields -= 1; | ||
| 330 | summary[key] = summarize(nested, depth + 1); | ||
| 331 | } | ||
| 332 | return summary; | ||
| 333 | }; | ||
| 334 | try { | ||
| 335 | return summarize(event, 0); | ||
| 336 | } catch { | ||
| 337 | return { truncated: true }; | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | /** Bounds traversal before adapters copy payloads or the logger encodes them. */ | ||
| 342 | export function boundProviderEventForLogging(event: unknown): unknown { | ||
| 343 | let remainingCharacters = MAX_RECORD_CHARACTERS; | ||
| 344 | let remainingFields = MAX_RECORD_FIELDS; | ||
| 345 | const ancestors = new WeakSet<object>(); | ||
| 346 | const fits = (value: unknown, depth: number): boolean => { | ||
| 347 | if (typeof value === "string") { | ||
| 348 | remainingCharacters -= value.length; | ||
| 349 | return remainingCharacters >= 0; | ||
| 350 | } | ||
| 351 | if (typeof value !== "object" || value === null) return true; | ||
| 352 | if (depth > MAX_RECORD_DEPTH || ancestors.has(value)) return false; | ||
| 353 | if (Array.isArray(value) && value.length > remainingFields) return false; | ||
| 354 | ancestors.add(value); | ||
| 355 | for (const key in value) { | ||
| 356 | if (!Object.hasOwn(value, key)) continue; | ||
| 357 | remainingFields -= 1; | ||
| 358 | remainingCharacters -= key.length; | ||
| 359 | if ( | ||
| 360 | remainingFields < 0 || | ||
| 361 | remainingCharacters < 0 || | ||
| 362 | !fits(Reflect.get(value, key), depth + 1) | ||
| 363 | ) | ||
| 364 | return false; | ||
| 365 | } | ||
| 366 | ancestors.delete(value); | ||
| 367 | return true; | ||
| 368 | }; | ||
| 369 | try { | ||
| 370 | if (fits(event, 0)) return event; | ||
| 371 | } catch { | ||
| 372 | // A failing accessor must not escape into provider processing. | ||
| 373 | } | ||
| 374 | return summarizeProviderEvent(event); | ||
| 375 | } | ||
| 376 | |||
| 377 | export function writeBatchedMessages( | ||
| 378 | sink: Pick<RotatingFileSink, "write">, | ||
| 379 | records: ReadonlyArray<PendingRecord>, | ||
| 380 | maxBytes: number, | ||
| 381 | onWritten: (records: ReadonlyArray<PendingRecord>) => void, | ||
| 382 | ): void { | ||
| 383 | let pendingRecords: Array<PendingRecord> = []; | ||
| 384 | let pendingBytes = 0; | ||
| 385 | |||
| 386 | const flush = () => { | ||
| 387 | if (pendingRecords.length === 0) return; | ||
| 388 | const writtenRecords = pendingRecords; | ||
| 389 | sink.write(writtenRecords.map((record) => record.line).join("")); | ||
| 390 | onWritten(writtenRecords); | ||
| 391 | pendingRecords = []; | ||
| 392 | pendingBytes = 0; | ||
| 393 | }; | ||
| 394 | |||
| 395 | for (const record of records) { | ||
| 396 | if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) { | ||
| 397 | flush(); | ||
| 398 | } | ||
| 399 | pendingRecords.push(record); | ||
| 400 | pendingBytes += record.bytes; | ||
| 401 | if (pendingBytes >= maxBytes) { | ||
| 402 | flush(); | ||
| 403 | } | ||
| 404 | } | ||
| 405 | flush(); | ||
| 406 | } | ||
| 407 | |||
| 408 | function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean { | ||
| 409 | if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false; | ||
| 410 | if (fileName.startsWith(filePrefix)) return true; | ||
| 411 | |||
| 412 | const descriptor = NodeFS.openSync(filePath, "r"); | ||
| 413 | try { | ||
| 414 | const header = Buffer.alloc(256); | ||
| 415 | const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0); | ||
| 416 | return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead)); | ||
| 417 | } finally { | ||
| 418 | NodeFS.closeSync(descriptor); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | function enforceRetention(input: { | ||
| 423 | readonly directory: string; | ||
| 424 | readonly maxTotalBytes: number; | ||
| 425 | readonly maxAgeMs: number; | ||
| 426 | readonly activeFilePaths: ReadonlySet<string>; | ||
| 427 | readonly filePrefix: string; | ||
| 428 | readonly now: number; | ||
| 429 | }): RetentionResult { | ||
| 430 | const failures: Array<FileOperationFailure> = []; | ||
| 431 | const files: Array<{ filePath: string; mtimeMs: number; size: number }> = []; | ||
| 432 | |||
| 433 | let entries: ReadonlyArray<NodeFS.Dirent>; | ||
| 434 | try { | ||
| 435 | entries = NodeFS.readdirSync(input.directory, { withFileTypes: true }); | ||
| 436 | } catch (cause) { | ||
| 437 | return { failures: [{ filePath: input.directory, cause }] }; | ||
| 438 | } | ||
| 439 | |||
| 440 | for (const entry of entries) { | ||
| 441 | if (!entry.isFile()) continue; | ||
| 442 | const filePath = NodePath.join(input.directory, entry.name); | ||
| 443 | try { | ||
| 444 | if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue; | ||
| 445 | const stat = NodeFS.statSync(filePath); | ||
| 446 | files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size }); | ||
| 447 | } catch (cause) { | ||
| 448 | failures.push({ filePath, cause }); | ||
| 449 | } | ||
| 450 | } | ||
| 451 | |||
| 452 | let totalBytes = files.reduce((total, file) => total + file.size, 0); | ||
| 453 | const remove = (file: (typeof files)[number]) => { | ||
| 454 | if (input.activeFilePaths.has(file.filePath)) return false; | ||
| 455 | try { | ||
| 456 | NodeFS.rmSync(file.filePath, { force: true }); | ||
| 457 | totalBytes -= file.size; | ||
| 458 | return true; | ||
| 459 | } catch (cause) { | ||
| 460 | failures.push({ filePath: file.filePath, cause }); | ||
| 461 | return false; | ||
| 462 | } | ||
| 463 | }; | ||
| 464 | |||
| 465 | const retained = files.filter((file) => { | ||
| 466 | if (input.now - file.mtimeMs <= input.maxAgeMs) return true; | ||
| 467 | return !remove(file); | ||
| 468 | }); | ||
| 469 | |||
| 470 | for (const file of retained.toSorted( | ||
| 471 | (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath), | ||
| 472 | )) { | ||
| 473 | if (totalBytes <= input.maxTotalBytes) break; | ||
| 474 | remove(file); | ||
| 475 | } | ||
| 476 | |||
| 477 | return { failures }; | ||
| 478 | } | ||
| 479 | |||
| 480 | function validateOption(input: { | ||
| 481 | readonly filePath: string; | ||
| 482 | readonly option: string; | ||
| 483 | readonly value: number; | ||
| 484 | readonly minimum: number; | ||
| 485 | }): EventNdjsonLogConfigurationError | undefined { | ||
| 486 | if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined; | ||
| 487 | return new EventNdjsonLogConfigurationError(input); | ||
| 488 | } | ||
| 489 | |||
| 490 | function resolveOptions( | ||
| 491 | filePath: string, | ||
| 492 | options: EventNdjsonLogStoreOptions, | ||
| 493 | ): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> { | ||
| 494 | const resolved = { | ||
| 495 | maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, | ||
| 496 | maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, | ||
| 497 | batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, | ||
| 498 | maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, | ||
| 499 | maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, | ||
| 500 | retentionCheckIntervalMs: | ||
| 501 | options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, | ||
| 502 | maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, | ||
| 503 | maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, | ||
| 504 | attribution: options.attribution, | ||
| 505 | } satisfies ResolvedOptions; | ||
| 506 | |||
| 507 | const validations = [ | ||
| 508 | ["maxBytes", resolved.maxBytes, 1], | ||
| 509 | ["maxFiles", resolved.maxFiles, 1], | ||
| 510 | ["batchWindowMs", resolved.batchWindowMs, 0], | ||
| 511 | ["maxTotalBytes", resolved.maxTotalBytes, 1], | ||
| 512 | ["maxAgeMs", resolved.maxAgeMs, 1], | ||
| 513 | ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], | ||
| 514 | ["maxBufferedBytes", resolved.maxBufferedBytes, 1], | ||
| 515 | ["maxBufferedRecords", resolved.maxBufferedRecords, 1], | ||
| 516 | ] as const; | ||
| 517 | |||
| 518 | for (const [option, value, minimum] of validations) { | ||
| 519 | const error = validateOption({ filePath, option, value, minimum }); | ||
| 520 | if (error) return Effect.fail(error); | ||
| 521 | } | ||
| 522 | return Effect.succeed(resolved); | ||
| 523 | } | ||
| 524 | |||
| 525 | function drainPending(input: { | ||
| 526 | readonly directory: string; | ||
| 527 | readonly options: ResolvedOptions; | ||
| 528 | readonly state: StoreState; | ||
| 529 | readonly filePrefix: string; | ||
| 530 | readonly now: number; | ||
| 531 | readonly timerFired: boolean; | ||
| 532 | readonly close: boolean; | ||
| 533 | }): readonly [DrainResult, StoreState] { | ||
| 534 | if (input.state.closed) { | ||
| 535 | return [{ attributions: [], failures: [] }, input.state]; | ||
| 536 | } | ||
| 537 | |||
| 538 | const sinks = new Map(input.state.sinks); | ||
| 539 | const failures: Array<FileOperationFailure> = []; | ||
| 540 | const attributionByStream = new Map< | ||
| 541 | EventNdjsonStream, | ||
| 542 | { count: number; logicalWriteBytes: number } | ||
| 543 | >(); | ||
| 544 | const recordsBySegment = new Map<string, Array<PendingRecord>>(); | ||
| 545 | |||
| 546 | for (const record of input.state.pending) { | ||
| 547 | const records = recordsBySegment.get(record.threadSegment) ?? []; | ||
| 548 | records.push(record); | ||
| 549 | recordsBySegment.set(record.threadSegment, records); | ||
| 550 | } | ||
| 551 | |||
| 552 | for (const [threadSegment, records] of recordsBySegment) { | ||
| 553 | const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment); | ||
| 554 | let sink = sinks.get(threadSegment); | ||
| 555 | if (!sink) { | ||
| 556 | try { | ||
| 557 | sink = new RotatingFileSink({ | ||
| 558 | filePath, | ||
| 559 | maxBytes: input.options.maxBytes, | ||
| 560 | maxFiles: input.options.maxFiles, | ||
| 561 | throwOnError: true, | ||
| 562 | }); | ||
| 563 | sinks.set(threadSegment, sink); | ||
| 564 | } catch (cause) { | ||
| 565 | failures.push({ filePath, cause }); | ||
| 566 | continue; | ||
| 567 | } | ||
| 568 | } | ||
| 569 | |||
| 570 | try { | ||
| 571 | writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => { | ||
| 572 | for (const record of writtenRecords) { | ||
| 573 | const current = attributionByStream.get(record.stream) ?? { | ||
| 574 | count: 0, | ||
| 575 | logicalWriteBytes: 0, | ||
| 576 | }; | ||
| 577 | attributionByStream.set(record.stream, { | ||
| 578 | count: current.count + 1, | ||
| 579 | logicalWriteBytes: current.logicalWriteBytes + record.bytes, | ||
| 580 | }); | ||
| 581 | } | ||
| 582 | }); | ||
| 583 | } catch (cause) { | ||
| 584 | sinks.delete(threadSegment); | ||
| 585 | failures.push({ filePath, cause }); | ||
| 586 | } | ||
| 587 | } | ||
| 588 | |||
| 589 | const retentionDue = | ||
| 590 | input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs; | ||
| 591 | const retention = retentionDue | ||
| 592 | ? enforceRetention({ | ||
| 593 | directory: input.directory, | ||
| 594 | maxTotalBytes: input.options.maxTotalBytes, | ||
| 595 | maxAgeMs: input.options.maxAgeMs, | ||
| 596 | activeFilePaths: new Set( | ||
| 597 | Array.from(sinks.keys(), (threadSegment) => | ||
| 598 | providerLogPath(input.directory, input.filePrefix, threadSegment), | ||
| 599 | ), | ||
| 600 | ), | ||
| 601 | filePrefix: input.filePrefix, | ||
| 602 | now: input.now, | ||
| 603 | }) | ||
| 604 | : { failures: [] }; | ||
| 605 | |||
| 606 | return [ | ||
| 607 | { | ||
| 608 | attributions: Array.from(attributionByStream, ([stream, value]) => ({ | ||
| 609 | stream, | ||
| 610 | ...value, | ||
| 611 | })), | ||
| 612 | failures: [...failures, ...retention.failures], | ||
| 613 | }, | ||
| 614 | { | ||
| 615 | pending: [], | ||
| 616 | pendingBytes: 0, | ||
| 617 | sinks, | ||
| 618 | flushScheduled: input.timerFired ? false : input.state.flushScheduled, | ||
| 619 | closed: input.close, | ||
| 620 | lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt, | ||
| 621 | }, | ||
| 622 | ]; | ||
| 623 | } | ||
| 624 | |||
| 625 | const serializeEvent = Effect.fnUntraced(function* (event: unknown) { | ||
| 626 | return yield* encodeUnknownJsonString(event).pipe( | ||
| 627 | Effect.catch((error) => | ||
| 628 | logWarning("failed to serialize provider event log record", { | ||
| 629 | errorTag: errorTag(error), | ||
| 630 | }).pipe(Effect.as(undefined)), | ||
| 631 | ), | ||
| 632 | ); | ||
| 633 | }); | ||
| 634 | |||
| 635 | export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( | ||
| 636 | filePath: string, | ||
| 637 | options: EventNdjsonLogStoreOptions = {}, | ||
| 638 | ): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> { | ||
| 639 | const resolved = yield* resolveOptions(filePath, options); | ||
| 640 | const directory = NodePath.dirname(filePath); | ||
| 641 | const filePrefix = providerLogPrefix(filePath); | ||
| 642 | |||
| 643 | yield* Effect.try({ | ||
| 644 | try: () => NodeFS.mkdirSync(directory, { recursive: true }), | ||
| 645 | catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }), | ||
| 646 | }); | ||
| 647 | |||
| 648 | const initializedAt = yield* Clock.currentTimeMillis; | ||
| 649 | const initialRetention = yield* Effect.sync(() => | ||
| 650 | enforceRetention({ | ||
| 651 | directory, | ||
| 652 | maxTotalBytes: resolved.maxTotalBytes, | ||
| 653 | maxAgeMs: resolved.maxAgeMs, | ||
| 654 | activeFilePaths: new Set(), | ||
| 655 | filePrefix, | ||
| 656 | now: initializedAt, | ||
| 657 | }), | ||
| 658 | ); | ||
| 659 | for (const failure of initialRetention.failures) { | ||
| 660 | yield* logWarning("provider event log retention failed", { | ||
| 661 | filePath: failure.filePath, | ||
| 662 | errorTag: errorTag(failure.cause), | ||
| 663 | }); | ||
| 664 | } | ||
| 665 | |||
| 666 | const stateRef = yield* SynchronizedRef.make<StoreState>({ | ||
| 667 | pending: [], | ||
| 668 | pendingBytes: 0, | ||
| 669 | sinks: new Map(), | ||
| 670 | flushScheduled: false, | ||
| 671 | closed: false, | ||
| 672 | lastRetentionAt: initializedAt, | ||
| 673 | }); | ||
| 674 | const timerScope = yield* Scope.make(); | ||
| 675 | |||
| 676 | const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) { | ||
| 677 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 678 | const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) => | ||
| 679 | Effect.sync(() => | ||
| 680 | drainPending({ | ||
| 681 | directory, | ||
| 682 | options: resolved, | ||
| 683 | state, | ||
| 684 | filePrefix, | ||
| 685 | now: startedAt, | ||
| 686 | timerFired, | ||
| 687 | close, | ||
| 688 | }), | ||
| 689 | ), | ||
| 690 | ); | ||
| 691 | |||
| 692 | for (const failure of result.failures) { | ||
| 693 | yield* logWarning("provider event log write or retention failed", { | ||
| 694 | filePath: failure.filePath, | ||
| 695 | errorTag: errorTag(failure.cause), | ||
| 696 | }); | ||
| 697 | } | ||
| 698 | |||
| 699 | if (resolved.attribution && result.attributions.length > 0) { | ||
| 700 | const completedAt = yield* Clock.currentTimeMillis; | ||
| 701 | const durationMs = Math.max(0, completedAt - startedAt); | ||
| 702 | const totalBytes = result.attributions.reduce( | ||
| 703 | (total, entry) => total + entry.logicalWriteBytes, | ||
| 704 | 0, | ||
| 705 | ); | ||
| 706 | yield* Effect.forEach( | ||
| 707 | result.attributions, | ||
| 708 | (entry) => | ||
| 709 | resolved.attribution?.record({ | ||
| 710 | component: "provider-event-log", | ||
| 711 | operation: `${entry.stream}.append`, | ||
| 712 | logicalWriteBytes: entry.logicalWriteBytes, | ||
| 713 | count: entry.count, | ||
| 714 | durationMs: | ||
| 715 | totalBytes === 0 | ||
| 716 | ? 0 | ||
| 717 | : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)), | ||
| 718 | }) ?? Effect.void, | ||
| 719 | { discard: true }, | ||
| 720 | ); | ||
| 721 | } | ||
| 722 | }); | ||
| 723 | |||
| 724 | const scheduleFlush = Effect.fnUntraced(function* () { | ||
| 725 | yield* Effect.forkIn( | ||
| 726 | Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))), | ||
| 727 | timerScope, | ||
| 728 | { startImmediately: true }, | ||
| 729 | ); | ||
| 730 | }); | ||
| 731 | |||
| 732 | const close = Effect.fnUntraced(function* () { | ||
| 733 | yield* flush(false, true); | ||
| 734 | yield* Scope.close(timerScope, Exit.void); | ||
| 735 | }); | ||
| 736 | |||
| 737 | const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>(); | ||
| 738 | const logger = (stream: EventNdjsonStream): EventNdjsonLogger => { | ||
| 739 | const existing = loggerViews.get(stream); | ||
| 740 | if (existing) return existing; | ||
| 741 | |||
| 742 | const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) { | ||
| 743 | if (!shouldPersistProviderEvent(stream, event)) return; | ||
| 744 | let payload = yield* serializeEvent(boundProviderEventForLogging(event)); | ||
| 745 | if (payload === undefined) return; | ||
| 746 | // Escaping can expand strings beyond their input size. Keep that bounded | ||
| 747 | // serialization out of the file too, while retaining routing/error fields. | ||
| 748 | if (Buffer.byteLength(payload) > MAX_RECORD_CHARACTERS) { | ||
| 749 | payload = yield* serializeEvent(summarizeProviderEvent(event)); | ||
| 750 | if (payload === undefined) return; | ||
| 751 | } | ||
| 752 | |||
| 753 | const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); | ||
| 754 | const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`; | ||
| 755 | const bytes = Buffer.byteLength(line); | ||
| 756 | const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => { | ||
| 757 | if (state.closed) { | ||
| 758 | return Effect.succeed([{ flush: false }, state] as const); | ||
| 759 | } | ||
| 760 | const pending = state.pending; | ||
| 761 | pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); | ||
| 762 | const pendingBytes = state.pendingBytes + bytes; | ||
| 763 | const flush = | ||
| 764 | resolved.batchWindowMs === 0 || | ||
| 765 | pending.length >= resolved.maxBufferedRecords || | ||
| 766 | pendingBytes >= resolved.maxBufferedBytes; | ||
| 767 | const schedule = !flush && !state.flushScheduled; | ||
| 768 | const nextState = { | ||
| 769 | ...state, | ||
| 770 | pending, | ||
| 771 | pendingBytes, | ||
| 772 | flushScheduled: state.flushScheduled || schedule, | ||
| 773 | }; | ||
| 774 | return (schedule ? scheduleFlush() : Effect.void).pipe( | ||
| 775 | Effect.as([{ flush }, nextState] as const), | ||
| 776 | ); | ||
| 777 | }).pipe(Effect.uninterruptible); | ||
| 778 | |||
| 779 | if (action.flush) { | ||
| 780 | yield* flush(false, false); | ||
| 781 | } | ||
| 782 | }); | ||
| 783 | |||
| 784 | const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger; | ||
| 785 | loggerViews.set(stream, view); | ||
| 786 | return view; | ||
| 787 | }; | ||
| 788 | |||
| 789 | return { filePath, logger, close } satisfies EventNdjsonLogStore; | ||
| 790 | }); | ||
| 791 | |||
| 792 | export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( | ||
| 793 | filePath: string, | ||
| 794 | options: EventNdjsonLoggerOptions, | ||
| 795 | ): Effect.fn.Return<EventNdjsonLogger | undefined> { | ||
| 796 | const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( | ||
| 797 | Effect.catch((error) => | ||
| 798 | logWarning(error.message, { error }).pipe( | ||
| 799 | Effect.as<EventNdjsonLogStore | undefined>(undefined), | ||
| 800 | ), | ||
| 801 | ), | ||
| 802 | ); | ||
| 803 | if (!store) return undefined; | ||
| 804 | return { ...store.logger(options.stream), close: store.close }; | ||
| 805 | }); | ||
apps/server/src/provider/Layers/GrokProvider.test.ts deleted-845| ... | @@ -1,845 +0,0 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. | ||
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import * as NodeURL from "node:url"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 11 | import { GrokSettings } from "@t3tools/contracts"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | buildGrokModelCapabilities, | ||
| 15 | buildGrokModelsFromSessionModelState, | ||
| 16 | buildInitialGrokProviderSnapshot, | ||
| 17 | checkGrokProviderStatus, | ||
| 18 | grokSlashCommandsFromInitialize, | ||
| 19 | parseGrokModelsCliOutput, | ||
| 20 | } from "./GrokProvider.ts"; | ||
| 21 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 22 | import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; | ||
| 23 | |||
| 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 26 | |||
| 27 | const LOGGED_IN_MODELS_OUTPUT = [ | ||
| 28 | "You are logged in with grok.com.", | ||
| 29 | "", | ||
| 30 | "Default model: grok-4.6", | ||
| 31 | "", | ||
| 32 | "Available models:", | ||
| 33 | " * grok-4.6 (default)", | ||
| 34 | " - grok-4.5", | ||
| 35 | "", | ||
| 36 | ].join("\n"); | ||
| 37 | |||
| 38 | const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( | ||
| 39 | "You are logged in with grok.com.", | ||
| 40 | "You are not authenticated.", | ||
| 41 | ); | ||
| 42 | |||
| 43 | describe("parseGrokModelsCliOutput", () => { | ||
| 44 | it("reads login state and model slugs, marking the default", () => { | ||
| 45 | const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); | ||
| 46 | expect(parsed.authenticated).toBe(true); | ||
| 47 | expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 48 | ["grok-4.6", true], | ||
| 49 | ["grok-4.5", false], | ||
| 50 | ]); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("detects a logged-out CLI even though it exits 0", () => { | ||
| 54 | expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("returns unknown auth for unrecognized output", () => { | ||
| 58 | expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | |||
| 62 | describe("grokSlashCommandsFromInitialize", () => { | ||
| 63 | it("publishes native ACP commands and input hints without permission overrides", () => { | ||
| 64 | const commands = grokSlashCommandsFromInitialize({ | ||
| 65 | protocolVersion: 1, | ||
| 66 | _meta: { | ||
| 67 | availableCommands: [ | ||
| 68 | { name: "compact", description: "Compress history", input: { hint: "what to preserve" } }, | ||
| 69 | { | ||
| 70 | name: "always-approve", | ||
| 71 | description: "Skip permission prompts", | ||
| 72 | input: { hint: "on|off" }, | ||
| 73 | }, | ||
| 74 | { name: "context", description: "Show context usage", input: null }, | ||
| 75 | { name: "session-info", description: "Show session details" }, | ||
| 76 | { name: "deep-research", description: "Research a topic", input: { hint: "<query>" } }, | ||
| 77 | { name: "workflow", description: "Manage workflows", input: { hint: "runs" } }, | ||
| 78 | { name: "goal", description: "Manage an autonomous goal", input: { hint: "status" } }, | ||
| 79 | ], | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | expect(commands.map((command) => command.name)).toEqual([ | ||
| 83 | "compact", | ||
| 84 | "session-info", | ||
| 85 | "deep-research", | ||
| 86 | "workflow", | ||
| 87 | "goal", | ||
| 88 | ]); | ||
| 89 | expect(commands[0]?.input).toEqual({ hint: "what to preserve" }); | ||
| 90 | expect(commands[2]).toEqual({ | ||
| 91 | name: "deep-research", | ||
| 92 | description: "Research a topic", | ||
| 93 | input: { hint: "<query>" }, | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps valid commands when other metadata entries are malformed", () => { | ||
| 98 | const commands = grokSlashCommandsFromInitialize({ | ||
| 99 | protocolVersion: 1, | ||
| 100 | _meta: { | ||
| 101 | availableCommands: [ | ||
| 102 | null, | ||
| 103 | { name: "broken", description: 42 }, | ||
| 104 | { name: " ", description: "Empty name" }, | ||
| 105 | { name: " session-info ", description: " Session details " }, | ||
| 106 | { name: "session-info", description: "Updated session details" }, | ||
| 107 | ], | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); | ||
| 111 | expect(commands[1]?.description).toBe("Updated session details"); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps compact available for older agents without command metadata", () => { | ||
| 115 | for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { | ||
| 116 | expect( | ||
| 117 | grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( | ||
| 118 | (command) => command.name, | ||
| 119 | ), | ||
| 120 | ).toEqual(["compact"]); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | describe("buildGrokModelsFromSessionModelState", () => { | ||
| 126 | it("marks the agent's current model as default and keeps reasoning options", () => { | ||
| 127 | const models = buildGrokModelsFromSessionModelState({ | ||
| 128 | currentModelId: "grok-4.6", | ||
| 129 | availableModels: [ | ||
| 130 | { | ||
| 131 | modelId: "grok-4.6", | ||
| 132 | name: "Grok 4.6", | ||
| 133 | _meta: { | ||
| 134 | supportsReasoningEffort: true, | ||
| 135 | reasoningEffort: "high", | ||
| 136 | reasoningEfforts: [{ value: "high", label: "High", default: true }], | ||
| 137 | }, | ||
| 138 | }, | ||
| 139 | { modelId: "grok-4.5", name: "Grok 4.5" }, | ||
| 140 | ], | ||
| 141 | }); | ||
| 142 | expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 143 | ["grok-4.6", true], | ||
| 144 | ["grok-4.5", false], | ||
| 145 | ]); | ||
| 146 | expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | describe("buildGrokModelCapabilities", () => { | ||
| 151 | it("preserves ACP-provided reasoning labels and the active default", () => { | ||
| 152 | const capabilities = buildGrokModelCapabilities({ | ||
| 153 | modelId: "grok-4.6", | ||
| 154 | name: "Grok 4.6", | ||
| 155 | _meta: { | ||
| 156 | supportsReasoningEffort: true, | ||
| 157 | reasoningEffort: "xhigh", | ||
| 158 | reasoningEfforts: [ | ||
| 159 | { value: "xhigh", label: "Extra High Effort", default: true }, | ||
| 160 | { value: "high", label: "High Effort", default: true }, | ||
| 161 | { value: "medium", label: "Medium Effort" }, | ||
| 162 | { value: "low", label: "Low Effort" }, | ||
| 163 | ], | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | |||
| 167 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 168 | { | ||
| 169 | id: "reasoningEffort", | ||
| 170 | label: "Reasoning", | ||
| 171 | type: "select", | ||
| 172 | currentValue: "xhigh", | ||
| 173 | options: [ | ||
| 174 | { id: "xhigh", label: "Extra High Effort", isDefault: true }, | ||
| 175 | { id: "high", label: "High Effort" }, | ||
| 176 | { id: "medium", label: "Medium Effort" }, | ||
| 177 | { id: "low", label: "Low Effort" }, | ||
| 178 | ], | ||
| 179 | }, | ||
| 180 | ]); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("uses raw ACP values when option labels are omitted", () => { | ||
| 184 | const capabilities = buildGrokModelCapabilities({ | ||
| 185 | modelId: "grok-4.6", | ||
| 186 | name: "Grok 4.6", | ||
| 187 | _meta: { | ||
| 188 | supportsReasoningEffort: true, | ||
| 189 | reasoningEffort: "xhigh", | ||
| 190 | reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], | ||
| 191 | }, | ||
| 192 | }); | ||
| 193 | |||
| 194 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 195 | { | ||
| 196 | id: "reasoningEffort", | ||
| 197 | label: "Reasoning", | ||
| 198 | type: "select", | ||
| 199 | currentValue: "xhigh", | ||
| 200 | options: [ | ||
| 201 | { id: "xhigh", label: "xhigh" }, | ||
| 202 | { id: "medium", label: "medium" }, | ||
| 203 | ], | ||
| 204 | }, | ||
| 205 | ]); | ||
| 206 | }); | ||
| 207 | |||
| 208 | it("keeps ACP current effort separate from its collapsed advertised default", () => { | ||
| 209 | const capabilities = buildGrokModelCapabilities({ | ||
| 210 | modelId: "grok-4.6", | ||
| 211 | name: "Grok 4.6", | ||
| 212 | _meta: { | ||
| 213 | supportsReasoningEffort: true, | ||
| 214 | reasoningEffort: "medium", | ||
| 215 | reasoningEfforts: [ | ||
| 216 | { value: "xhigh", label: "Extra High Effort", default: true }, | ||
| 217 | { value: "high", label: "High Effort", default: true }, | ||
| 218 | { value: "medium", label: "Medium Effort" }, | ||
| 219 | ], | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | |||
| 223 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 224 | { | ||
| 225 | id: "reasoningEffort", | ||
| 226 | label: "Reasoning", | ||
| 227 | type: "select", | ||
| 228 | currentValue: "medium", | ||
| 229 | options: [ | ||
| 230 | { id: "xhigh", label: "Extra High Effort", isDefault: true }, | ||
| 231 | { id: "high", label: "High Effort" }, | ||
| 232 | { id: "medium", label: "Medium Effort" }, | ||
| 233 | ], | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { | ||
| 239 | const capabilities = buildGrokModelCapabilities({ | ||
| 240 | modelId: "grok-4.6", | ||
| 241 | name: "Grok 4.6", | ||
| 242 | _meta: { | ||
| 243 | supportsReasoningEffort: true, | ||
| 244 | reasoningEffort: "high", | ||
| 245 | reasoningEfforts: [ | ||
| 246 | { | ||
| 247 | id: "high", | ||
| 248 | value: "not a token", | ||
| 249 | label: "High Effort", | ||
| 250 | description: "Higher implementation quality", | ||
| 251 | default: true, | ||
| 252 | }, | ||
| 253 | { id: "bad id", value: "also invalid", label: "Invalid" }, | ||
| 254 | ], | ||
| 255 | }, | ||
| 256 | }); | ||
| 257 | |||
| 258 | expect(capabilities.optionDescriptors).toEqual([ | ||
| 259 | { | ||
| 260 | id: "reasoningEffort", | ||
| 261 | label: "Reasoning", | ||
| 262 | type: "select", | ||
| 263 | currentValue: "high", | ||
| 264 | options: [ | ||
| 265 | { | ||
| 266 | id: "high", | ||
| 267 | label: "High Effort", | ||
| 268 | description: "Higher implementation quality", | ||
| 269 | isDefault: true, | ||
| 270 | }, | ||
| 271 | ], | ||
| 272 | }, | ||
| 273 | ]); | ||
| 274 | }); | ||
| 275 | |||
| 276 | it("accepts an advertised ACP menu when the support flag is omitted", () => { | ||
| 277 | const capabilities = buildGrokModelCapabilities({ | ||
| 278 | modelId: "grok-4.6", | ||
| 279 | name: "Grok 4.6", | ||
| 280 | _meta: { | ||
| 281 | reasoningEffort: "high", | ||
| 282 | reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], | ||
| 283 | }, | ||
| 284 | }); | ||
| 285 | |||
| 286 | expect(capabilities.optionDescriptors).toHaveLength(1); | ||
| 287 | }); | ||
| 288 | |||
| 289 | it("honors an explicit ACP opt-out even when a menu is present", () => { | ||
| 290 | const capabilities = buildGrokModelCapabilities({ | ||
| 291 | modelId: "grok-4.6", | ||
| 292 | name: "Grok 4.6", | ||
| 293 | _meta: { | ||
| 294 | supportsReasoningEffort: false, | ||
| 295 | reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], | ||
| 296 | }, | ||
| 297 | }); | ||
| 298 | |||
| 299 | expect(capabilities.optionDescriptors).toEqual([]); | ||
| 300 | }); | ||
| 301 | |||
| 302 | it("does not synthesize a reasoning menu when ACP omits it", () => { | ||
| 303 | expect( | ||
| 304 | buildGrokModelCapabilities({ | ||
| 305 | modelId: "grok-4.6", | ||
| 306 | name: "Grok 4.6", | ||
| 307 | _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, | ||
| 308 | }).optionDescriptors, | ||
| 309 | ).toEqual([]); | ||
| 310 | }); | ||
| 311 | |||
| 312 | it("keeps non-reasoning Grok models free of reasoning controls", () => { | ||
| 313 | expect( | ||
| 314 | buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, | ||
| 315 | ).toEqual([]); | ||
| 316 | }); | ||
| 317 | }); | ||
| 318 | |||
| 319 | describe("buildInitialGrokProviderSnapshot", () => { | ||
| 320 | it.effect("returns a disabled snapshot when settings.enabled is false", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const snapshot = yield* buildInitialGrokProviderSnapshot( | ||
| 323 | decodeGrokSettings({ enabled: false }), | ||
| 324 | ); | ||
| 325 | expect(snapshot.enabled).toBe(false); | ||
| 326 | expect(snapshot.status).toBe("disabled"); | ||
| 327 | expect(snapshot.installed).toBe(false); | ||
| 328 | expect(snapshot.message).toContain("disabled"); | ||
| 329 | }), | ||
| 330 | ); | ||
| 331 | |||
| 332 | it.effect("returns a disabled snapshot by default — Grok is opt-in", () => | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); | ||
| 335 | expect(snapshot.enabled).toBe(false); | ||
| 336 | expect(snapshot.status).toBe("disabled"); | ||
| 337 | }), | ||
| 338 | ); | ||
| 339 | |||
| 340 | it.effect("returns a pending snapshot when enabled", () => | ||
| 341 | Effect.gen(function* () { | ||
| 342 | const snapshot = yield* buildInitialGrokProviderSnapshot( | ||
| 343 | decodeGrokSettings({ enabled: true }), | ||
| 344 | ); | ||
| 345 | expect(snapshot.enabled).toBe(true); | ||
| 346 | expect(snapshot.installed).toBe(true); | ||
| 347 | expect(snapshot.status).toBe("warning"); | ||
| 348 | expect(snapshot.version).toBeNull(); | ||
| 349 | expect(snapshot.message).toContain("Checking Grok"); | ||
| 350 | expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); | ||
| 351 | expect(snapshot.supportsConversationRollback).toBe(false); | ||
| 352 | }), | ||
| 353 | ); | ||
| 354 | }); | ||
| 355 | |||
| 356 | it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ||
| 357 | it.effect("reports the binary as missing when the binary path does not resolve", () => | ||
| 358 | Effect.gen(function* () { | ||
| 359 | const snapshot = yield* checkGrokProviderStatus( | ||
| 360 | decodeGrokSettings({ | ||
| 361 | enabled: true, | ||
| 362 | binaryPath: "/definitely/not/installed/grok-binary", | ||
| 363 | }), | ||
| 364 | ); | ||
| 365 | expect(snapshot.enabled).toBe(true); | ||
| 366 | expect(snapshot.installed).toBe(false); | ||
| 367 | expect(snapshot.status).toBe("error"); | ||
| 368 | expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | |||
| 372 | it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => | ||
| 373 | Effect.gen(function* () { | ||
| 374 | const secretStderr = "broken grok install: secret-token-value"; | ||
| 375 | const snapshot = yield* Effect.scoped( | ||
| 376 | Effect.gen(function* () { | ||
| 377 | const fs = yield* FileSystem.FileSystem; | ||
| 378 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); | ||
| 379 | const grokPath = writeFakeCli({ | ||
| 380 | directory: dir, | ||
| 381 | name: "grok", | ||
| 382 | source: [ | ||
| 383 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 384 | `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, | ||
| 385 | "process.exit(2);", | ||
| 386 | "", | ||
| 387 | ].join("\n"), | ||
| 388 | }); | ||
| 389 | |||
| 390 | return yield* checkGrokProviderStatus( | ||
| 391 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 392 | ); | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | |||
| 396 | expect(snapshot.enabled).toBe(true); | ||
| 397 | expect(snapshot.installed).toBe(true); | ||
| 398 | expect(snapshot.status).toBe("error"); | ||
| 399 | expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); | ||
| 400 | expect(snapshot.message).not.toContain(secretStderr); | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | |||
| 404 | // A stand-in for the Grok CLI: `--version` and `models` print canned text, | ||
| 405 | // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. | ||
| 406 | const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => | ||
| 407 | Effect.gen(function* () { | ||
| 408 | const fs = yield* FileSystem.FileSystem; | ||
| 409 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); | ||
| 410 | const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); | ||
| 411 | return writeFakeCli({ | ||
| 412 | directory: dir, | ||
| 413 | name: "grok", | ||
| 414 | source: [ | ||
| 415 | 'if (process.argv[2] === "--version") {', | ||
| 416 | ' process.stdout.write("grok 1.0.13\\n");', | ||
| 417 | " process.exit(0);", | ||
| 418 | "}", | ||
| 419 | 'if (process.argv[2] === "models") {', | ||
| 420 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 421 | ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, | ||
| 422 | " process.exit(0);", | ||
| 423 | "}", | ||
| 424 | 'if (process.argv[2] !== "agent") process.exit(1);', | ||
| 425 | ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), | ||
| 426 | "", | ||
| 427 | ].join("\n"), | ||
| 428 | }); | ||
| 429 | }); | ||
| 430 | |||
| 431 | it.effect("reports ready with ACP-discovered models when logged in", () => | ||
| 432 | Effect.gen(function* () { | ||
| 433 | const snapshot = yield* Effect.scoped( | ||
| 434 | Effect.gen(function* () { | ||
| 435 | const grokPath = yield* writeFakeGrokCli({ | ||
| 436 | modelsOutput: LOGGED_IN_MODELS_OUTPUT, | ||
| 437 | acp: true, | ||
| 438 | }); | ||
| 439 | return yield* checkGrokProviderStatus( | ||
| 440 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 441 | { ...process.env, XAI_API_KEY: "" }, | ||
| 442 | ); | ||
| 443 | }), | ||
| 444 | ); | ||
| 445 | |||
| 446 | expect(snapshot.status).toBe("ready"); | ||
| 447 | expect(snapshot.version).toBe("1.0.13"); | ||
| 448 | expect(snapshot.auth).toEqual({ | ||
| 449 | status: "authenticated", | ||
| 450 | type: "cached_token", | ||
| 451 | label: "Grok account", | ||
| 452 | }); | ||
| 453 | // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. | ||
| 454 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); | ||
| 455 | expect(snapshot.models[0]?.isDefault).toBe(true); | ||
| 456 | expect( | ||
| 457 | snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], | ||
| 458 | ).toEqual(["reasoningEffort"]); | ||
| 459 | }), | ||
| 460 | ); | ||
| 461 | |||
| 462 | it.effect("reports unauthenticated from `grok models` without starting a session", () => | ||
| 463 | Effect.gen(function* () { | ||
| 464 | const snapshot = yield* Effect.scoped( | ||
| 465 | Effect.gen(function* () { | ||
| 466 | const grokPath = yield* writeFakeGrokCli({ | ||
| 467 | modelsOutput: LOGGED_OUT_MODELS_OUTPUT, | ||
| 468 | acp: true, | ||
| 469 | }); | ||
| 470 | return yield* checkGrokProviderStatus( | ||
| 471 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 472 | { ...process.env, XAI_API_KEY: "" }, | ||
| 473 | ); | ||
| 474 | }), | ||
| 475 | ); | ||
| 476 | |||
| 477 | expect(snapshot.status).toBe("error"); | ||
| 478 | expect(snapshot.auth.status).toBe("unauthenticated"); | ||
| 479 | expect(snapshot.message).toContain("grok login"); | ||
| 480 | expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); | ||
| 481 | }), | ||
| 482 | ); | ||
| 483 | |||
| 484 | it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => | ||
| 485 | Effect.gen(function* () { | ||
| 486 | const snapshot = yield* Effect.scoped( | ||
| 487 | Effect.gen(function* () { | ||
| 488 | const grokPath = yield* writeFakeGrokCli({ | ||
| 489 | modelsOutput: LOGGED_IN_MODELS_OUTPUT, | ||
| 490 | acp: false, | ||
| 491 | }); | ||
| 492 | return yield* checkGrokProviderStatus( | ||
| 493 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 494 | { ...process.env, XAI_API_KEY: "" }, | ||
| 495 | ); | ||
| 496 | }), | ||
| 497 | ); | ||
| 498 | |||
| 499 | expect(snapshot.status).toBe("warning"); | ||
| 500 | expect(snapshot.installed).toBe(true); | ||
| 501 | expect(snapshot.auth.status).toBe("authenticated"); | ||
| 502 | expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ | ||
| 503 | ["grok-4.6", true], | ||
| 504 | ["grok-4.5", false], | ||
| 505 | ]); | ||
| 506 | expect(snapshot.message).toContain("ACP initialize failed"); | ||
| 507 | expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["compact"]); | ||
| 508 | }), | ||
| 509 | ); | ||
| 510 | |||
| 511 | it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => | ||
| 512 | Effect.gen(function* () { | ||
| 513 | const snapshot = yield* Effect.scoped( | ||
| 514 | Effect.gen(function* () { | ||
| 515 | const grokPath = yield* writeFakeGrokCli({ | ||
| 516 | modelsOutput: LOGGED_OUT_MODELS_OUTPUT, | ||
| 517 | acp: false, | ||
| 518 | }); | ||
| 519 | return yield* checkGrokProviderStatus( | ||
| 520 | decodeGrokSettings({ enabled: true, binaryPath: grokPath }), | ||
| 521 | { ...process.env, XAI_API_KEY: "xai-test-key" }, | ||
| 522 | ); | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | |||
| 526 | expect(snapshot.auth).toEqual({ | ||
| 527 | status: "authenticated", | ||
| 528 | type: "api_key", | ||
| 529 | label: "xAI API key", | ||
| 530 | }); | ||
| 531 | expect(snapshot.status).toBe("warning"); | ||
| 532 | }), | ||
| 533 | ); | ||
| 534 | }); | ||
| 535 | |||
| 536 | describe("Grok usage limits", () => { | ||
| 537 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 538 | |||
| 539 | it("uses the reported subscription percentage and weekly reset", () => { | ||
| 540 | const limits = grokUsageResponseToLimits( | ||
| 541 | { | ||
| 542 | config: { | ||
| 543 | creditUsagePercent: 100, | ||
| 544 | currentPeriod: { | ||
| 545 | type: "USAGE_PERIOD_TYPE_WEEKLY", | ||
| 546 | end: "2026-09-18T03:10:30.159171+00:00", | ||
| 547 | }, | ||
| 548 | }, | ||
| 549 | }, | ||
| 550 | checkedAt, | ||
| 551 | ); | ||
| 552 | expect(limits.windows).toEqual([ | ||
| 553 | { | ||
| 554 | id: "subscription", | ||
| 555 | kind: "weekly", | ||
| 556 | label: "Weekly", | ||
| 557 | usedPercent: 100, | ||
| 558 | resetsAt: "2026-09-18T03:10:30.159Z", | ||
| 559 | }, | ||
| 560 | ]); | ||
| 561 | }); | ||
| 562 | |||
| 563 | it("does not invent allowance or reset dates when billing omits them", () => { | ||
| 564 | for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { | ||
| 565 | const limits = grokUsageResponseToLimits(response, checkedAt); | ||
| 566 | expect(limits.windows).toEqual([]); | ||
| 567 | // Nothing metered yet, which xAI reports by omitting the field until | ||
| 568 | // usage registers. Marking it `unsupported` would drop the account from | ||
| 569 | // the Limits view for good; leaving the marker off keeps it listed. | ||
| 570 | expect(limits.unavailable).toBeUndefined(); | ||
| 571 | } | ||
| 572 | expect( | ||
| 573 | grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, | ||
| 574 | ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); | ||
| 575 | expect( | ||
| 576 | grokUsageResponseToLimits( | ||
| 577 | { | ||
| 578 | config: { | ||
| 579 | creditUsagePercent: 120, | ||
| 580 | currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, | ||
| 581 | }, | ||
| 582 | }, | ||
| 583 | checkedAt, | ||
| 584 | ).windows, | ||
| 585 | ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); | ||
| 586 | }); | ||
| 587 | }); | ||
| 588 | |||
| 589 | it.layer(NodeServices.layer)("readGrokAccount", (it) => { | ||
| 590 | it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => | ||
| 591 | Effect.gen(function* () { | ||
| 592 | const fs = yield* FileSystem.FileSystem; | ||
| 593 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 594 | yield* fs.writeFileString( | ||
| 595 | NodePath.join(directory, "auth.json"), | ||
| 596 | '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 597 | ); | ||
| 598 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 599 | GROK_HOME: directory, | ||
| 600 | HOME: "/unrelated-home", | ||
| 601 | }).pipe( | ||
| 602 | Effect.provideService( | ||
| 603 | HttpClient.HttpClient, | ||
| 604 | HttpClient.make((request) => { | ||
| 605 | expect(request.method).toBe("GET"); | ||
| 606 | expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); | ||
| 607 | expect(request.headers.authorization).toBe("Bearer session-token"); | ||
| 608 | return Effect.succeed( | ||
| 609 | HttpClientResponse.fromWeb( | ||
| 610 | request, | ||
| 611 | Response.json({ config: { creditUsagePercent: 37 } }), | ||
| 612 | ), | ||
| 613 | ); | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | ); | ||
| 617 | expect(limits.windows[0]?.usedPercent).toBe(37); | ||
| 618 | }).pipe(Effect.scoped), | ||
| 619 | ); | ||
| 620 | |||
| 621 | it.effect( | ||
| 622 | "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", | ||
| 623 | () => | ||
| 624 | readGrokAccount({ | ||
| 625 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 626 | }).pipe( | ||
| 627 | Effect.provideService( | ||
| 628 | FileSystem.FileSystem, | ||
| 629 | FileSystem.makeNoop({ | ||
| 630 | readFileString: (path) => | ||
| 631 | path.endsWith("auth.json") | ||
| 632 | ? Effect.die("must not read stored credentials") | ||
| 633 | : Effect.succeed(""), | ||
| 634 | }), | ||
| 635 | ), | ||
| 636 | Effect.provideService( | ||
| 637 | HttpClient.HttpClient, | ||
| 638 | HttpClient.make((request) => { | ||
| 639 | expect(request.headers.authorization).toBe("Bearer legacy-token"); | ||
| 640 | return Effect.succeed( | ||
| 641 | HttpClientResponse.fromWeb( | ||
| 642 | request, | ||
| 643 | Response.json({ config: { creditUsagePercent: 12 } }), | ||
| 644 | ), | ||
| 645 | ); | ||
| 646 | }), | ||
| 647 | ), | ||
| 648 | Effect.tap(({ usageLimits }) => | ||
| 649 | Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), | ||
| 650 | ), | ||
| 651 | ), | ||
| 652 | ); | ||
| 653 | |||
| 654 | it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => | ||
| 655 | Effect.gen(function* () { | ||
| 656 | for (const environment of [ | ||
| 657 | { | ||
| 658 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 659 | XAI_API_KEY: "different-api-account", | ||
| 660 | }, | ||
| 661 | { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, | ||
| 662 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, | ||
| 663 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, | ||
| 664 | { | ||
| 665 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 666 | GROK_OIDC_ISSUER: "https://custom.example", | ||
| 667 | }, | ||
| 668 | { | ||
| 669 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 670 | GROK_MODELS_BASE_URL: "https://custom.example/v1", | ||
| 671 | }, | ||
| 672 | { | ||
| 673 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 674 | GROK_OAUTH2_PRINCIPAL_TYPE: "Team", | ||
| 675 | }, | ||
| 676 | { | ||
| 677 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 678 | GROK_OAUTH2_PRINCIPAL_ID: "team-id", | ||
| 679 | }, | ||
| 680 | { | ||
| 681 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 682 | GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', | ||
| 683 | }, | ||
| 684 | { | ||
| 685 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 686 | GROK_CONFIG_PATH: "/custom-config.toml", | ||
| 687 | }, | ||
| 688 | ]) { | ||
| 689 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 690 | HOME: "/definitely/not/a/grok-home", | ||
| 691 | ...environment, | ||
| 692 | }).pipe( | ||
| 693 | Effect.provideService( | ||
| 694 | HttpClient.HttpClient, | ||
| 695 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 696 | ), | ||
| 697 | ); | ||
| 698 | expect(limits.windows).toEqual([]); | ||
| 699 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 700 | } | ||
| 701 | }), | ||
| 702 | ); | ||
| 703 | |||
| 704 | it.effect( | ||
| 705 | "reports a missing login as unsupported and malformed credentials as a sanitized failure", | ||
| 706 | () => | ||
| 707 | Effect.gen(function* () { | ||
| 708 | const fs = yield* FileSystem.FileSystem; | ||
| 709 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 710 | const client = HttpClient.make(() => Effect.die("must not request without credentials")); | ||
| 711 | const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( | ||
| 712 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 713 | ); | ||
| 714 | expect(missing.unavailable?.reason).toBe("unsupported"); | ||
| 715 | for (const contents of [ | ||
| 716 | "private-token-invalid-json", | ||
| 717 | '{"https://accounts.x.ai/sign-in":{"key":42}}', | ||
| 718 | ]) { | ||
| 719 | const { usageLimits: malformed } = yield* readGrokAccount({ | ||
| 720 | GROK_HOME: directory, | ||
| 721 | GROK_AUTH: contents, | ||
| 722 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); | ||
| 723 | expect(malformed.unavailable).toEqual({ | ||
| 724 | reason: "probeFailed", | ||
| 725 | message: "Grok could not read usage limits.", | ||
| 726 | }); | ||
| 727 | } | ||
| 728 | }).pipe(Effect.scoped), | ||
| 729 | ); | ||
| 730 | |||
| 731 | it.effect( | ||
| 732 | "does not request subscription limits for custom account or endpoint configuration", | ||
| 733 | () => | ||
| 734 | Effect.gen(function* () { | ||
| 735 | for (const config of [ | ||
| 736 | '[auth]\nprovider_command = "custom-auth"', | ||
| 737 | '[grok_com_config]\nissuer = "https://custom.example"', | ||
| 738 | 'endpoints.proxy = "https://custom.example"', | ||
| 739 | ]) { | ||
| 740 | const { usageLimits: limits } = yield* readGrokAccount({ | ||
| 741 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', | ||
| 742 | }).pipe( | ||
| 743 | Effect.provideService( | ||
| 744 | FileSystem.FileSystem, | ||
| 745 | FileSystem.makeNoop({ | ||
| 746 | readFileString: (path) => { | ||
| 747 | expect(path.endsWith("config.toml")).toBe(true); | ||
| 748 | return Effect.succeed(config); | ||
| 749 | }, | ||
| 750 | }), | ||
| 751 | ), | ||
| 752 | Effect.provideService( | ||
| 753 | HttpClient.HttpClient, | ||
| 754 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 755 | ), | ||
| 756 | ); | ||
| 757 | expect(limits.windows).toEqual([]); | ||
| 758 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 759 | } | ||
| 760 | }), | ||
| 761 | ); | ||
| 762 | |||
| 763 | it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => | ||
| 764 | Effect.gen(function* () { | ||
| 765 | for (const response of [ | ||
| 766 | new Response("private response", { status: 401 }), | ||
| 767 | Response.json({ config: { creditUsagePercent: "private-value" } }), | ||
| 768 | ]) { | ||
| 769 | const { email, usageLimits: limits } = yield* readGrokAccount({ | ||
| 770 | HOME: "/definitely/not/a/grok-home", | ||
| 771 | GROK_AUTH: | ||
| 772 | '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', | ||
| 773 | }).pipe( | ||
| 774 | Effect.provideService( | ||
| 775 | HttpClient.HttpClient, | ||
| 776 | HttpClient.make((request) => | ||
| 777 | Effect.succeed(HttpClientResponse.fromWeb(request, response)), | ||
| 778 | ), | ||
| 779 | ), | ||
| 780 | ); | ||
| 781 | expect(email).toBe("someone@example.com"); | ||
| 782 | expect(limits.windows).toEqual([]); | ||
| 783 | expect(limits.unavailable).toEqual({ | ||
| 784 | reason: "probeFailed", | ||
| 785 | message: "Grok could not read usage limits.", | ||
| 786 | }); | ||
| 787 | } | ||
| 788 | }), | ||
| 789 | ); | ||
| 790 | }); | ||
| 791 | |||
| 792 | it.layer(NodeServices.layer)("readGrokAccount email", (it) => { | ||
| 793 | it.effect("names the account whose limits were read, and nothing for other auth", () => | ||
| 794 | Effect.gen(function* () { | ||
| 795 | const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; | ||
| 796 | const cases = [ | ||
| 797 | [ | ||
| 798 | { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, | ||
| 799 | "Someone@Example.com", | ||
| 800 | ], | ||
| 801 | [ | ||
| 802 | { | ||
| 803 | GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, | ||
| 804 | }, | ||
| 805 | "current@example.com", | ||
| 806 | ], | ||
| 807 | [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], | ||
| 808 | [ | ||
| 809 | { | ||
| 810 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, | ||
| 811 | XAI_API_KEY: "api-key", | ||
| 812 | }, | ||
| 813 | undefined, | ||
| 814 | ], | ||
| 815 | [ | ||
| 816 | { | ||
| 817 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, | ||
| 818 | }, | ||
| 819 | undefined, | ||
| 820 | ], | ||
| 821 | [{ GROK_AUTH: "not-json" }, undefined], | ||
| 822 | ] as const; | ||
| 823 | for (const [environment, expected] of cases) { | ||
| 824 | const { email, usageLimits } = yield* readGrokAccount({ | ||
| 825 | HOME: "/definitely/not/a/grok-home", | ||
| 826 | ...environment, | ||
| 827 | }).pipe( | ||
| 828 | Effect.provideService( | ||
| 829 | HttpClient.HttpClient, | ||
| 830 | HttpClient.make((request) => | ||
| 831 | Effect.succeed( | ||
| 832 | HttpClientResponse.fromWeb( | ||
| 833 | request, | ||
| 834 | Response.json({ config: { creditUsagePercent: 25 } }), | ||
| 835 | ), | ||
| 836 | ), | ||
| 837 | ), | ||
| 838 | ), | ||
| 839 | ); | ||
| 840 | expect(email).toBe(expected); | ||
| 841 | if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); | ||
| 842 | } | ||
| 843 | }), | ||
| 844 | ); | ||
| 845 | }); | ||
apps/server/src/provider/Layers/GrokProvider.ts deleted-586| ... | @@ -1,586 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type CustomModelSetting, | ||
| 3 | type GrokSettings, | ||
| 4 | type ModelCapabilities, | ||
| 5 | type ServerProvider, | ||
| 6 | type ServerProviderAuth, | ||
| 7 | type ServerProviderModel, | ||
| 8 | type ServerProviderSlashCommand, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 11 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Exit from "effect/Exit"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Result from "effect/Result"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { HttpClient } from "effect/http"; | ||
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 21 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 23 | |||
| 24 | import { | ||
| 25 | AUTH_PROBE_TIMEOUT_MS, | ||
| 26 | buildServerProvider, | ||
| 27 | COMPACT_SLASH_COMMAND, | ||
| 28 | isCommandMissingCause, | ||
| 29 | parseGenericCliVersion, | ||
| 30 | providerModelsFromSettings, | ||
| 31 | spawnAndCollect, | ||
| 32 | type ServerProviderDraft, | ||
| 33 | } from "../providerSnapshot.ts"; | ||
| 34 | import { | ||
| 35 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 36 | type ProviderMaintenanceCapabilities, | ||
| 37 | } from "../providerMaintenance.ts"; | ||
| 38 | import { | ||
| 39 | GROK_DEFAULT_MODEL_SLUG, | ||
| 40 | GROK_SUPPORTED_RUNTIME_MODES, | ||
| 41 | isValidGrokReasoningEffortToken, | ||
| 42 | makeGrokAcpRuntime, | ||
| 43 | resolveGrokAcpBaseModelId, | ||
| 44 | } from "../acp/GrokAcpSupport.ts"; | ||
| 45 | import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; | ||
| 46 | import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; | ||
| 47 | |||
| 48 | const GROK_PRESENTATION = { | ||
| 49 | displayName: "Grok", | ||
| 50 | supportsConversationRollback: false, | ||
| 51 | showInteractionModeToggle: false, | ||
| 52 | supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, | ||
| 53 | } as const; | ||
| 54 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 59 | // `initialize` is a single local round trip, so this is generous even on slow machines. | ||
| 60 | const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; | ||
| 61 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 62 | |||
| 63 | const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ | ||
| 64 | { | ||
| 65 | slug: GROK_DEFAULT_MODEL_SLUG, | ||
| 66 | name: "Grok Build", | ||
| 67 | isCustom: false, | ||
| 68 | capabilities: EMPTY_CAPABILITIES, | ||
| 69 | }, | ||
| 70 | ]; | ||
| 71 | |||
| 72 | export function buildInitialGrokProviderSnapshot( | ||
| 73 | grokSettings: GrokSettings, | ||
| 74 | ): Effect.Effect<ServerProviderDraft> { | ||
| 75 | return Effect.gen(function* () { | ||
| 76 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 77 | const models = grokModelsFromSettings(grokSettings.customModels); | ||
| 78 | |||
| 79 | if (!grokSettings.enabled) { | ||
| 80 | return buildServerProvider({ | ||
| 81 | presentation: GROK_PRESENTATION, | ||
| 82 | enabled: false, | ||
| 83 | checkedAt, | ||
| 84 | models, | ||
| 85 | probe: { | ||
| 86 | installed: false, | ||
| 87 | version: null, | ||
| 88 | status: "warning", | ||
| 89 | auth: { status: "unknown" }, | ||
| 90 | message: "Grok is disabled in T3 Code settings.", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | } | ||
| 94 | |||
| 95 | return buildServerProvider({ | ||
| 96 | presentation: GROK_PRESENTATION, | ||
| 97 | enabled: true, | ||
| 98 | checkedAt, | ||
| 99 | models, | ||
| 100 | probe: { | ||
| 101 | installed: true, | ||
| 102 | version: null, | ||
| 103 | status: "warning", | ||
| 104 | auth: { status: "unknown" }, | ||
| 105 | message: "Checking Grok CLI availability...", | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | } | ||
| 110 | |||
| 111 | function grokModelsFromSettings( | ||
| 112 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | ||
| 113 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, | ||
| 114 | ): ReadonlyArray<ServerProviderModel> { | ||
| 115 | return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); | ||
| 116 | } | ||
| 117 | |||
| 118 | function nonEmptyString(value: unknown): string | undefined { | ||
| 119 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 123 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 124 | } | ||
| 125 | |||
| 126 | function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { | ||
| 127 | readonly options: ReadonlyArray<{ | ||
| 128 | value: string; | ||
| 129 | label: string; | ||
| 130 | description?: string; | ||
| 131 | isDefault?: boolean; | ||
| 132 | }>; | ||
| 133 | readonly currentValue: string | undefined; | ||
| 134 | } { | ||
| 135 | const meta = model._meta; | ||
| 136 | if (!meta || meta.supportsReasoningEffort === false) { | ||
| 137 | return { options: [], currentValue: undefined }; | ||
| 138 | } | ||
| 139 | |||
| 140 | const currentEffort = nonEmptyString(meta.reasoningEffort); | ||
| 141 | const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; | ||
| 142 | const seen = new Set<string>(); | ||
| 143 | const options: Array<{ | ||
| 144 | value: string; | ||
| 145 | label: string; | ||
| 146 | description?: string; | ||
| 147 | advertisedDefault: boolean; | ||
| 148 | }> = []; | ||
| 149 | |||
| 150 | for (const entry of advertisedOptions) { | ||
| 151 | if (!isRecord(entry)) { | ||
| 152 | continue; | ||
| 153 | } | ||
| 154 | const rawValue = nonEmptyString(entry.value); | ||
| 155 | const rawId = nonEmptyString(entry.id); | ||
| 156 | const value = | ||
| 157 | rawValue && isValidGrokReasoningEffortToken(rawValue) | ||
| 158 | ? rawValue | ||
| 159 | : rawId && isValidGrokReasoningEffortToken(rawId) | ||
| 160 | ? rawId | ||
| 161 | : undefined; | ||
| 162 | if (value === undefined || seen.has(value)) { | ||
| 163 | continue; | ||
| 164 | } | ||
| 165 | seen.add(value); | ||
| 166 | const description = nonEmptyString(entry.description); | ||
| 167 | options.push({ | ||
| 168 | value, | ||
| 169 | label: nonEmptyString(entry.label) ?? value, | ||
| 170 | ...(description ? { description } : {}), | ||
| 171 | advertisedDefault: entry.default === true || entry.isDefault === true, | ||
| 172 | }); | ||
| 173 | } | ||
| 174 | |||
| 175 | const currentValue = | ||
| 176 | currentEffort && options.some((option) => option.value === currentEffort) | ||
| 177 | ? currentEffort | ||
| 178 | : undefined; | ||
| 179 | const advertisedDefaults = options.filter((option) => option.advertisedDefault); | ||
| 180 | const selectedDefault = | ||
| 181 | advertisedDefaults.find((option) => option.value === currentValue)?.value ?? | ||
| 182 | advertisedDefaults[0]?.value; | ||
| 183 | return { | ||
| 184 | options: options.map(({ value, label, description }) => ({ | ||
| 185 | value, | ||
| 186 | label, | ||
| 187 | ...(description ? { description } : {}), | ||
| 188 | ...(value === selectedDefault ? { isDefault: true } : {}), | ||
| 189 | })), | ||
| 190 | currentValue: currentValue ?? selectedDefault, | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { | ||
| 195 | const reasoning = grokReasoningOptionsFromModel(model); | ||
| 196 | return reasoning.options.length > 0 | ||
| 197 | ? createModelCapabilities({ | ||
| 198 | optionDescriptors: [ | ||
| 199 | { | ||
| 200 | id: "reasoningEffort", | ||
| 201 | label: "Reasoning", | ||
| 202 | type: "select", | ||
| 203 | options: reasoning.options.map((option) => ({ | ||
| 204 | id: option.value, | ||
| 205 | label: option.label, | ||
| 206 | ...(option.description ? { description: option.description } : {}), | ||
| 207 | ...(option.isDefault ? { isDefault: true } : {}), | ||
| 208 | })), | ||
| 209 | ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), | ||
| 210 | }, | ||
| 211 | ], | ||
| 212 | }) | ||
| 213 | : EMPTY_CAPABILITIES; | ||
| 214 | } | ||
| 215 | |||
| 216 | /** Models advertised by the ACP agent, with the session's current model marked as default. */ | ||
| 217 | export function buildGrokModelsFromSessionModelState( | ||
| 218 | modelState: EffectAcpSchema.SessionModelState | null | undefined, | ||
| 219 | ): ReadonlyArray<ServerProviderModel> { | ||
| 220 | if (!modelState || modelState.availableModels.length === 0) { | ||
| 221 | return []; | ||
| 222 | } | ||
| 223 | const currentModelId = modelState.currentModelId.trim(); | ||
| 224 | const seen = new Set<string>(); | ||
| 225 | return modelState.availableModels.flatMap((model): ServerProviderModel[] => { | ||
| 226 | const slug = resolveGrokAcpBaseModelId(model.modelId); | ||
| 227 | if (!slug || seen.has(slug)) { | ||
| 228 | return []; | ||
| 229 | } | ||
| 230 | seen.add(slug); | ||
| 231 | return [ | ||
| 232 | { | ||
| 233 | slug, | ||
| 234 | name: model.name.trim() || slug, | ||
| 235 | isCustom: false, | ||
| 236 | ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), | ||
| 237 | capabilities: buildGrokModelCapabilities(model), | ||
| 238 | }, | ||
| 239 | ]; | ||
| 240 | }); | ||
| 241 | } | ||
| 242 | |||
| 243 | export interface GrokModelsCliOutput { | ||
| 244 | /** True or false when the CLI printed a login line, null when it printed neither. */ | ||
| 245 | readonly authenticated: boolean | null; | ||
| 246 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 247 | } | ||
| 248 | |||
| 249 | /** | ||
| 250 | * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the | ||
| 251 | * text is the only signal. Current output looks like: | ||
| 252 | * | ||
| 253 | * You are logged in with grok.com. | ||
| 254 | * Default model: grok-4.6 | ||
| 255 | * Available models: | ||
| 256 | * * grok-4.6 (default) | ||
| 257 | * - grok-4.5 | ||
| 258 | */ | ||
| 259 | export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { | ||
| 260 | const authenticated = /you are logged in/i.test(output) | ||
| 261 | ? true | ||
| 262 | : /not authenticated|not logged in/i.test(output) | ||
| 263 | ? false | ||
| 264 | : null; | ||
| 265 | |||
| 266 | const seen = new Set<string>(); | ||
| 267 | const models: ServerProviderModel[] = []; | ||
| 268 | for (const line of output.split(/\r?\n/)) { | ||
| 269 | const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); | ||
| 270 | if (!bullet?.[1]) { | ||
| 271 | continue; | ||
| 272 | } | ||
| 273 | const slug = resolveGrokAcpBaseModelId(bullet[1]); | ||
| 274 | if (seen.has(slug)) { | ||
| 275 | continue; | ||
| 276 | } | ||
| 277 | seen.add(slug); | ||
| 278 | models.push({ | ||
| 279 | slug, | ||
| 280 | name: displayNameFromGrokModelSlug(slug), | ||
| 281 | isCustom: false, | ||
| 282 | ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), | ||
| 283 | capabilities: EMPTY_CAPABILITIES, | ||
| 284 | }); | ||
| 285 | } | ||
| 286 | return { authenticated, models }; | ||
| 287 | } | ||
| 288 | |||
| 289 | function displayNameFromGrokModelSlug(slug: string): string { | ||
| 290 | return slug | ||
| 291 | .split(/[-_]/g) | ||
| 292 | .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) | ||
| 293 | .join(" "); | ||
| 294 | } | ||
| 295 | |||
| 296 | const runGrokCliCommand = ( | ||
| 297 | grokSettings: GrokSettings, | ||
| 298 | args: ReadonlyArray<string>, | ||
| 299 | environment: NodeJS.ProcessEnv, | ||
| 300 | ) => | ||
| 301 | Effect.gen(function* () { | ||
| 302 | const command = grokSettings.binaryPath || "grok"; | ||
| 303 | const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); | ||
| 304 | return yield* spawnAndCollect( | ||
| 305 | command, | ||
| 306 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 307 | env: environment, | ||
| 308 | shell: spawnCommand.shell, | ||
| 309 | }), | ||
| 310 | ); | ||
| 311 | }); | ||
| 312 | |||
| 313 | const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); | ||
| 314 | const decodeAvailableCommand = Schema.decodeUnknownOption( | ||
| 315 | Schema.Struct({ | ||
| 316 | name: Schema.String, | ||
| 317 | description: Schema.String, | ||
| 318 | input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))), | ||
| 319 | }), | ||
| 320 | ); | ||
| 321 | |||
| 322 | export function grokSlashCommandsFromInitialize( | ||
| 323 | initialized: EffectAcpSchema.InitializeResponse, | ||
| 324 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 325 | const commands = decodeAvailableCommands(initialized._meta?.availableCommands); | ||
| 326 | const byName = new Map<string, ServerProviderSlashCommand>([ | ||
| 327 | [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND], | ||
| 328 | ]); | ||
| 329 | for (const entry of Option.getOrElse(commands, () => [])) { | ||
| 330 | const decoded = decodeAvailableCommand(entry); | ||
| 331 | if (Option.isNone(decoded)) continue; | ||
| 332 | const command = decoded.value; | ||
| 333 | const name = command.name.trim(); | ||
| 334 | // Permission changes must go through T3 so the client and provider agree. | ||
| 335 | if (!name || name.toLowerCase() === "always-approve") continue; | ||
| 336 | // Grok advertises /context, but its ACP handler completes without emitting output. | ||
| 337 | if (name.toLowerCase() === "context") continue; | ||
| 338 | const description = command.description.trim(); | ||
| 339 | const hint = command.input?.hint.trim(); | ||
| 340 | byName.set(name, { | ||
| 341 | name, | ||
| 342 | ...(description ? { description } : {}), | ||
| 343 | ...(hint ? { input: { hint } } : {}), | ||
| 344 | }); | ||
| 345 | } | ||
| 346 | return [...byName.values()]; | ||
| 347 | } | ||
| 348 | |||
| 349 | /** | ||
| 350 | * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` | ||
| 351 | * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. | ||
| 352 | */ | ||
| 353 | const discoverGrokMetadataViaAcpInitialize = ( | ||
| 354 | grokSettings: GrokSettings, | ||
| 355 | environment: NodeJS.ProcessEnv, | ||
| 356 | ) => | ||
| 357 | Effect.gen(function* () { | ||
| 358 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 359 | const acp = yield* makeGrokAcpRuntime({ | ||
| 360 | grokSettings, | ||
| 361 | environment, | ||
| 362 | childProcessSpawner, | ||
| 363 | cwd: process.cwd(), | ||
| 364 | clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, | ||
| 365 | }); | ||
| 366 | const initialized = yield* acp.initialize(); | ||
| 367 | return { | ||
| 368 | models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)), | ||
| 369 | slashCommands: grokSlashCommandsFromInitialize(initialized), | ||
| 370 | }; | ||
| 371 | }).pipe(Effect.scoped); | ||
| 372 | |||
| 373 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | ||
| 374 | grokSettings: GrokSettings, | ||
| 375 | environment: NodeJS.ProcessEnv = process.env, | ||
| 376 | cwd?: string, | ||
| 377 | ): Effect.fn.Return< | ||
| 378 | ServerProviderDraft, | ||
| 379 | never, | ||
| 380 | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | ||
| 381 | > { | ||
| 382 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 383 | const fallbackModels = grokModelsFromSettings(grokSettings.customModels); | ||
| 384 | |||
| 385 | if (!grokSettings.enabled) { | ||
| 386 | return buildServerProvider({ | ||
| 387 | presentation: GROK_PRESENTATION, | ||
| 388 | enabled: false, | ||
| 389 | checkedAt, | ||
| 390 | models: fallbackModels, | ||
| 391 | probe: { | ||
| 392 | installed: false, | ||
| 393 | version: null, | ||
| 394 | status: "warning", | ||
| 395 | auth: { status: "unknown" }, | ||
| 396 | message: "Grok is disabled in T3 Code settings.", | ||
| 397 | }, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | |||
| 401 | const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( | ||
| 402 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | ||
| 403 | Effect.result, | ||
| 404 | ); | ||
| 405 | |||
| 406 | if (Result.isFailure(versionResult)) { | ||
| 407 | const error = versionResult.failure; | ||
| 408 | yield* Effect.logWarning("Grok CLI health check failed.", { | ||
| 409 | errorTag: error._tag, | ||
| 410 | }); | ||
| 411 | return buildServerProvider({ | ||
| 412 | presentation: GROK_PRESENTATION, | ||
| 413 | enabled: grokSettings.enabled, | ||
| 414 | checkedAt, | ||
| 415 | models: fallbackModels, | ||
| 416 | probe: { | ||
| 417 | installed: !isCommandMissingCause(error), | ||
| 418 | version: null, | ||
| 419 | status: "error", | ||
| 420 | auth: { status: "unknown" }, | ||
| 421 | message: isCommandMissingCause(error) | ||
| 422 | ? "Grok CLI (`grok`) is not installed or not on PATH." | ||
| 423 | : "Failed to execute Grok CLI health check.", | ||
| 424 | }, | ||
| 425 | }); | ||
| 426 | } | ||
| 427 | |||
| 428 | if (Option.isNone(versionResult.success)) { | ||
| 429 | return buildServerProvider({ | ||
| 430 | presentation: GROK_PRESENTATION, | ||
| 431 | enabled: grokSettings.enabled, | ||
| 432 | checkedAt, | ||
| 433 | models: fallbackModels, | ||
| 434 | probe: { | ||
| 435 | installed: true, | ||
| 436 | version: null, | ||
| 437 | status: "error", | ||
| 438 | auth: { status: "unknown" }, | ||
| 439 | message: "Grok CLI is installed but timed out while running `grok --version`.", | ||
| 440 | }, | ||
| 441 | }); | ||
| 442 | } | ||
| 443 | |||
| 444 | const versionOutput = versionResult.success.value; | ||
| 445 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | ||
| 446 | if (versionOutput.code !== 0) { | ||
| 447 | yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { | ||
| 448 | exitCode: versionOutput.code, | ||
| 449 | stdoutLength: versionOutput.stdout.length, | ||
| 450 | stderrLength: versionOutput.stderr.length, | ||
| 451 | }); | ||
| 452 | return buildServerProvider({ | ||
| 453 | presentation: GROK_PRESENTATION, | ||
| 454 | enabled: grokSettings.enabled, | ||
| 455 | checkedAt, | ||
| 456 | models: fallbackModels, | ||
| 457 | probe: { | ||
| 458 | installed: true, | ||
| 459 | version, | ||
| 460 | status: "error", | ||
| 461 | auth: { status: "unknown" }, | ||
| 462 | message: "Grok CLI is installed but failed to run.", | ||
| 463 | }, | ||
| 464 | }); | ||
| 465 | } | ||
| 466 | |||
| 467 | // `grok models` reports login state and model slugs without starting the agent. | ||
| 468 | const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( | ||
| 469 | Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), | ||
| 470 | Effect.result, | ||
| 471 | ); | ||
| 472 | // Only a clean exit is parsed. Failed invocations print help or error text that | ||
| 473 | // must not be read as model slugs or as a login verdict. | ||
| 474 | const modelsOutput = | ||
| 475 | Result.isSuccess(modelsResult) && | ||
| 476 | Option.isSome(modelsResult.success) && | ||
| 477 | modelsResult.success.value.code === 0 | ||
| 478 | ? modelsResult.success.value | ||
| 479 | : undefined; | ||
| 480 | const cliModels: GrokModelsCliOutput = modelsOutput | ||
| 481 | ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) | ||
| 482 | : { authenticated: null, models: [] }; | ||
| 483 | if (!modelsOutput) { | ||
| 484 | yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { | ||
| 485 | errorTag: Result.isFailure(modelsResult) | ||
| 486 | ? modelsResult.failure._tag | ||
| 487 | : Option.isNone(modelsResult.success) | ||
| 488 | ? "Timeout" | ||
| 489 | : `ExitCode${modelsResult.success.value.code}`, | ||
| 490 | }); | ||
| 491 | } | ||
| 492 | |||
| 493 | const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() | ||
| 494 | ? { status: "authenticated", type: "api_key", label: "xAI API key" } | ||
| 495 | : cliModels.authenticated === true | ||
| 496 | ? { status: "authenticated", type: "cached_token", label: "Grok account" } | ||
| 497 | : cliModels.authenticated === false | ||
| 498 | ? { status: "unauthenticated" } | ||
| 499 | : { status: "unknown" }; | ||
| 500 | |||
| 501 | const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( | ||
| 502 | Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), | ||
| 503 | Effect.orElseSucceed(() => []), | ||
| 504 | ); | ||
| 505 | |||
| 506 | const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe( | ||
| 507 | Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), | ||
| 508 | Effect.exit, | ||
| 509 | ); | ||
| 510 | const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; | ||
| 511 | const acpModels = acpMetadata?.models ?? []; | ||
| 512 | const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); | ||
| 513 | if (acpFailed) { | ||
| 514 | yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { | ||
| 515 | errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | |||
| 519 | const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; | ||
| 520 | const models = | ||
| 521 | discoveredModels.length > 0 | ||
| 522 | ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) | ||
| 523 | : fallbackModels; | ||
| 524 | |||
| 525 | if (auth.status === "unauthenticated") { | ||
| 526 | return buildServerProvider({ | ||
| 527 | presentation: GROK_PRESENTATION, | ||
| 528 | enabled: grokSettings.enabled, | ||
| 529 | checkedAt, | ||
| 530 | models, | ||
| 531 | skills, | ||
| 532 | probe: { | ||
| 533 | installed: true, | ||
| 534 | version, | ||
| 535 | status: "error", | ||
| 536 | auth, | ||
| 537 | message: "Grok CLI is installed but not logged in. Run `grok login`.", | ||
| 538 | }, | ||
| 539 | }); | ||
| 540 | } | ||
| 541 | |||
| 542 | return buildServerProvider({ | ||
| 543 | presentation: GROK_PRESENTATION, | ||
| 544 | enabled: grokSettings.enabled, | ||
| 545 | checkedAt, | ||
| 546 | models, | ||
| 547 | skills, | ||
| 548 | slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND], | ||
| 549 | probe: { | ||
| 550 | installed: true, | ||
| 551 | version, | ||
| 552 | // A failed metadata probe degrades the model picker, it does not make chats fail. | ||
| 553 | status: acpFailed ? "warning" : "ready", | ||
| 554 | auth, | ||
| 555 | ...(acpFailed | ||
| 556 | ? { | ||
| 557 | message: | ||
| 558 | "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", | ||
| 559 | } | ||
| 560 | : {}), | ||
| 561 | }, | ||
| 562 | }); | ||
| 563 | }); | ||
| 564 | |||
| 565 | export const enrichGrokSnapshot = (input: { | ||
| 566 | readonly snapshot: ServerProvider; | ||
| 567 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 568 | readonly enableProviderUpdateChecks?: boolean; | ||
| 569 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 570 | readonly httpClient: HttpClient.HttpClient; | ||
| 571 | }): Effect.Effect<void> => { | ||
| 572 | const { snapshot, publishSnapshot } = input; | ||
| 573 | |||
| 574 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | ||
| 575 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | ||
| 576 | }).pipe( | ||
| 577 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | ||
| 578 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | ||
| 579 | Effect.catchCause((cause) => | ||
| 580 | Effect.logWarning("Grok version advisory enrichment failed", { | ||
| 581 | errorTag: causeErrorTag(cause), | ||
| 582 | }), | ||
| 583 | ), | ||
| 584 | Effect.asVoid, | ||
| 585 | ); | ||
| 586 | }; | ||
apps/server/src/provider/Layers/OpenCodeProvider.test.ts deleted-873| ... | @@ -1,873 +0,0 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { it } from "@effect/vitest"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 14 | import { beforeEach } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { OpenCodeSettings } from "@t3tools/contracts"; | ||
| 17 | import * as ServerConfig from "../../config.ts"; | ||
| 18 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | ||
| 19 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | ||
| 20 | import { | ||
| 21 | checkOpenCodeProviderStatus, | ||
| 22 | loadOpenCode2Workspace, | ||
| 23 | makeOpenCode2ModelLoader, | ||
| 24 | type OpenCode2Model, | ||
| 25 | type OpenCode2Workspace, | ||
| 26 | openCode2CommandsToServerProviderSlashCommands, | ||
| 27 | openCode2SkillsToServerProviderSkills, | ||
| 28 | openCodeCommandsToServerProviderSlashCommands, | ||
| 29 | } from "./OpenCodeProvider.ts"; | ||
| 30 | import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; | ||
| 31 | import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts"; | ||
| 32 | import { | ||
| 33 | OPENCODE_1_RESPONSES, | ||
| 34 | OPENCODE_2_RESPONSES, | ||
| 35 | replayOpenCodeServer, | ||
| 36 | } from "../testFixtures/opencodeProbeResponses.ts"; | ||
| 37 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | ||
| 38 | |||
| 39 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | ||
| 40 | |||
| 41 | it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const resetsAt = "2026-09-17T12:00:00.000Z"; | ||
| 44 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 45 | enabled: true, | ||
| 46 | serverUrl: "", | ||
| 47 | environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, | ||
| 48 | }).pipe( | ||
| 49 | Effect.provideService( | ||
| 50 | FileSystem.FileSystem, | ||
| 51 | FileSystem.makeNoop({ | ||
| 52 | readFileString: (path) => { | ||
| 53 | NodeAssert.equal(path, "/instance/data/opencode/auth.json"); | ||
| 54 | return Effect.succeed( | ||
| 55 | JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), | ||
| 56 | ); | ||
| 57 | }, | ||
| 58 | }), | ||
| 59 | ), | ||
| 60 | Effect.provideService( | ||
| 61 | HttpClient.HttpClient, | ||
| 62 | HttpClient.make((request) => { | ||
| 63 | NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); | ||
| 64 | NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); | ||
| 65 | return Effect.succeed( | ||
| 66 | HttpClientResponse.fromWeb( | ||
| 67 | request, | ||
| 68 | Response.json({ | ||
| 69 | usage: { | ||
| 70 | rolling: { percent: 0, resetsAt }, | ||
| 71 | weekly: { percent: 10, resetsAt }, | ||
| 72 | monthly: { percent: 125, resetsAt }, | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | }), | ||
| 78 | ), | ||
| 79 | Effect.provide(NodeServices.layer), | ||
| 80 | ); | ||
| 81 | NodeAssert.equal(limits.unavailable, undefined); | ||
| 82 | NodeAssert.equal( | ||
| 83 | limits.credentialFingerprint, | ||
| 84 | NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), | ||
| 85 | ); | ||
| 86 | NodeAssert.deepEqual( | ||
| 87 | limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ | ||
| 88 | kind, | ||
| 89 | usedPercent, | ||
| 90 | reset, | ||
| 91 | })), | ||
| 92 | [ | ||
| 93 | { kind: "session", usedPercent: 0, reset: resetsAt }, | ||
| 94 | { kind: "weekly", usedPercent: 10, reset: resetsAt }, | ||
| 95 | { kind: "monthly", usedPercent: 100, reset: resetsAt }, | ||
| 96 | ], | ||
| 97 | ); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("does not read local credentials for external or disabled OpenCode instances", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | for (const settings of [ | ||
| 104 | { enabled: true, serverUrl: "https://remote.example" }, | ||
| 105 | { enabled: false, serverUrl: "" }, | ||
| 106 | ]) { | ||
| 107 | const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( | ||
| 108 | Effect.provideService( | ||
| 109 | FileSystem.FileSystem, | ||
| 110 | FileSystem.makeNoop({ | ||
| 111 | readFileString: () => Effect.die("unexpected credential read"), | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | Effect.provideService( | ||
| 115 | HttpClient.HttpClient, | ||
| 116 | HttpClient.make(() => Effect.die("unexpected usage request")), | ||
| 117 | ), | ||
| 118 | Effect.provide(NodeServices.layer), | ||
| 119 | ); | ||
| 120 | NodeAssert.equal(limits.unavailable?.reason, "unsupported"); | ||
| 121 | } | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | for (const [status, reason] of [ | ||
| 128 | [403, "unsupported"], | ||
| 129 | [401, "probeFailed"], | ||
| 130 | [200, "probeFailed"], | ||
| 131 | ] as const) { | ||
| 132 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 133 | enabled: true, | ||
| 134 | serverUrl: "", | ||
| 135 | environment: { | ||
| 136 | OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', | ||
| 137 | }, | ||
| 138 | }).pipe( | ||
| 139 | Effect.provideService( | ||
| 140 | FileSystem.FileSystem, | ||
| 141 | FileSystem.makeNoop({ | ||
| 142 | readFileString: () => Effect.die("inline credentials must bypass disk"), | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | Effect.provideService( | ||
| 146 | HttpClient.HttpClient, | ||
| 147 | HttpClient.make((request) => | ||
| 148 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | Effect.provide(NodeServices.layer), | ||
| 152 | ); | ||
| 153 | NodeAssert.equal(limits.unavailable?.reason, reason); | ||
| 154 | NodeAssert.equal(limits.credentialFingerprint, undefined); | ||
| 155 | NodeAssert.deepEqual(limits.windows, []); | ||
| 156 | } | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | /** | ||
| 161 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | ||
| 162 | * are deleted. The snapshot-producing logic they wrapped now lives in the | ||
| 163 | * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which | ||
| 164 | * drivers call directly when building their per-instance snapshot | ||
| 165 | * `ServerProviderShape`. Tests mirror that shape: build a settings payload, | ||
| 166 | * invoke the check, assert on the returned snapshot. | ||
| 167 | */ | ||
| 168 | |||
| 169 | const runtimeMock = { | ||
| 170 | state: { | ||
| 171 | runVersionError: null as Error | null, | ||
| 172 | runVersionPending: false, | ||
| 173 | versionStdout: DEFAULT_VERSION_STDOUT, | ||
| 174 | inventoryError: null as Error | null, | ||
| 175 | connectionError: null as Error | null, | ||
| 176 | inventoryCwd: null as string | null, | ||
| 177 | closeCalls: 0, | ||
| 178 | sdkClientInputs: [] as Array<{ | ||
| 179 | baseUrl: string; | ||
| 180 | directory: string; | ||
| 181 | serverPassword?: string; | ||
| 182 | }>, | ||
| 183 | inventory: { | ||
| 184 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | ||
| 185 | agents: [] as unknown[], | ||
| 186 | skills: [] as unknown[], | ||
| 187 | } as unknown, | ||
| 188 | }, | ||
| 189 | reset() { | ||
| 190 | this.state.runVersionError = null; | ||
| 191 | this.state.runVersionPending = false; | ||
| 192 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | ||
| 193 | this.state.inventoryError = null; | ||
| 194 | this.state.connectionError = null; | ||
| 195 | this.state.inventoryCwd = null; | ||
| 196 | this.state.closeCalls = 0; | ||
| 197 | this.state.sdkClientInputs.length = 0; | ||
| 198 | this.state.inventory = { | ||
| 199 | providerList: { connected: [], all: [] as unknown[], default: {} }, | ||
| 200 | agents: [] as unknown[], | ||
| 201 | skills: [] as unknown[], | ||
| 202 | }; | ||
| 203 | }, | ||
| 204 | }; | ||
| 205 | |||
| 206 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | ||
| 207 | startOpenCodeServerProcess: ({ serverPassword, environment }) => | ||
| 208 | Effect.gen(function* () { | ||
| 209 | yield* Effect.addFinalizer(() => | ||
| 210 | Effect.sync(() => { | ||
| 211 | runtimeMock.state.closeCalls += 1; | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | ||
| 215 | external: false, | ||
| 216 | ...(serverPassword !== undefined ? { serverPassword } : {}), | ||
| 217 | ...(environment !== undefined ? { environment } : {}), | ||
| 218 | }); | ||
| 219 | return { | ||
| 220 | url: "http://127.0.0.1:4301", | ||
| 221 | ...(effectiveServerPassword !== undefined | ||
| 222 | ? { serverPassword: effectiveServerPassword } | ||
| 223 | : {}), | ||
| 224 | version: "1.14.19", | ||
| 225 | isRunning: Effect.succeed(true), | ||
| 226 | exitCode: Effect.never, | ||
| 227 | }; | ||
| 228 | }), | ||
| 229 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | if (runtimeMock.state.connectionError) { | ||
| 232 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 233 | operation: "global.health", | ||
| 234 | detail: runtimeMock.state.connectionError.message, | ||
| 235 | cause: runtimeMock.state.connectionError, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | if (!serverUrl) { | ||
| 239 | yield* Effect.addFinalizer(() => | ||
| 240 | Effect.sync(() => { | ||
| 241 | runtimeMock.state.closeCalls += 1; | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | return { | ||
| 246 | url: serverUrl ?? "http://127.0.0.1:4301", | ||
| 247 | ...(serverPassword ? { serverPassword } : {}), | ||
| 248 | version: "1.14.19", | ||
| 249 | exitCode: null, | ||
| 250 | external: Boolean(serverUrl), | ||
| 251 | }; | ||
| 252 | }), | ||
| 253 | runOpenCodeCommand: () => | ||
| 254 | runtimeMock.state.runVersionPending | ||
| 255 | ? Effect.never | ||
| 256 | : runtimeMock.state.runVersionError | ||
| 257 | ? Effect.fail( | ||
| 258 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 259 | operation: "runOpenCodeCommand", | ||
| 260 | detail: runtimeMock.state.runVersionError.message, | ||
| 261 | cause: runtimeMock.state.runVersionError, | ||
| 262 | }), | ||
| 263 | ) | ||
| 264 | : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), | ||
| 265 | createOpenCodeSdkClient: (input) => { | ||
| 266 | runtimeMock.state.sdkClientInputs.push(input); | ||
| 267 | return {} as unknown as ReturnType< | ||
| 268 | OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] | ||
| 269 | >; | ||
| 270 | }, | ||
| 271 | loadOpenCodeInventory: () => | ||
| 272 | runtimeMock.state.inventoryError | ||
| 273 | ? Effect.fail( | ||
| 274 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 275 | operation: "loadOpenCodeInventory", | ||
| 276 | detail: runtimeMock.state.inventoryError.message, | ||
| 277 | cause: runtimeMock.state.inventoryError, | ||
| 278 | }), | ||
| 279 | ) | ||
| 280 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), | ||
| 281 | loadInventoryFromCli: ({ cwd }) => { | ||
| 282 | runtimeMock.state.inventoryCwd = cwd; | ||
| 283 | return runtimeMock.state.inventoryError | ||
| 284 | ? Effect.fail( | ||
| 285 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 286 | operation: "loadInventoryFromCli", | ||
| 287 | detail: runtimeMock.state.inventoryError.message, | ||
| 288 | cause: runtimeMock.state.inventoryError, | ||
| 289 | }), | ||
| 290 | ) | ||
| 291 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); | ||
| 292 | }, | ||
| 293 | loadOpenCodeSkills: () => Effect.succeed([]), | ||
| 294 | loadSkillsFromCli: () => Effect.succeed([]), | ||
| 295 | }; | ||
| 296 | |||
| 297 | beforeEach(() => { | ||
| 298 | runtimeMock.reset(); | ||
| 299 | }); | ||
| 300 | |||
| 301 | it("keeps native and MCP commands while preserving compaction and separate skills", () => { | ||
| 302 | NodeAssert.deepEqual( | ||
| 303 | openCodeCommandsToServerProviderSlashCommands([ | ||
| 304 | { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, | ||
| 305 | { name: "review", source: "command", hints: [] }, | ||
| 306 | { name: "compact", source: "command", hints: [] }, | ||
| 307 | { name: "skill", source: "skill", hints: [] }, | ||
| 308 | { name: "mcp:search", source: "mcp", hints: ["query"] }, | ||
| 309 | ]).slice(1), | ||
| 310 | [ | ||
| 311 | { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, | ||
| 312 | { name: "mcp:search", input: { hint: "query" } }, | ||
| 313 | ], | ||
| 314 | ); | ||
| 315 | }); | ||
| 316 | |||
| 317 | const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( | ||
| 318 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 319 | Layer.provideMerge(NodeServices.layer), | ||
| 320 | ); | ||
| 321 | |||
| 322 | const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings => | ||
| 323 | decodeOpenCodeSettings({ | ||
| 324 | enabled: true, | ||
| 325 | binaryPath: "opencode", | ||
| 326 | serverUrl: "", | ||
| 327 | serverPassword: "", | ||
| 328 | customModels: [], | ||
| 329 | ...overrides, | ||
| 330 | }); | ||
| 331 | |||
| 332 | const checkProvider = Effect.fn("checkProvider")(function* ( | ||
| 333 | settings: OpenCodeSettings, | ||
| 334 | cwd = process.cwd(), | ||
| 335 | environment?: NodeJS.ProcessEnv, | ||
| 336 | server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), | ||
| 337 | openCode2Models: Effect.Effect< | ||
| 338 | ReadonlyArray<OpenCode2Model>, | ||
| 339 | OpenCodeRuntime.OpenCodeRuntimeError | ||
| 340 | > = Effect.succeed([]), | ||
| 341 | ) { | ||
| 342 | return yield* Effect.scoped( | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const serverOwner = yield* OpenCodeServerOwner.make({ | ||
| 345 | binaryPath: settings.binaryPath, | ||
| 346 | directory: cwd, | ||
| 347 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | ||
| 348 | ...(environment ? { environment } : {}), | ||
| 349 | }); | ||
| 350 | const probe = probeOpenCodeRuntime(settings, environment).pipe( | ||
| 351 | Effect.provideService(HttpClient.HttpClient, server), | ||
| 352 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), | ||
| 353 | ); | ||
| 354 | return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( | ||
| 355 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | ||
| 356 | ); | ||
| 357 | }), | ||
| 358 | ); | ||
| 359 | }); | ||
| 360 | |||
| 361 | it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { | ||
| 362 | it.effect("shows a codex-style missing binary message", () => | ||
| 363 | Effect.gen(function* () { | ||
| 364 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | ||
| 365 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 366 | |||
| 367 | NodeAssert.equal(snapshot.status, "error"); | ||
| 368 | NodeAssert.equal(snapshot.installed, false); | ||
| 369 | NodeAssert.equal( | ||
| 370 | snapshot.message, | ||
| 371 | "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 372 | ); | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | |||
| 376 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | ||
| 377 | Effect.gen(function* () { | ||
| 378 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | ||
| 379 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 380 | |||
| 381 | NodeAssert.equal(snapshot.status, "error"); | ||
| 382 | NodeAssert.equal(snapshot.installed, true); | ||
| 383 | NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); | ||
| 384 | }), | ||
| 385 | ); | ||
| 386 | |||
| 387 | it.effect("times out a hanging local CLI version probe", () => | ||
| 388 | Effect.gen(function* () { | ||
| 389 | runtimeMock.state.runVersionPending = true; | ||
| 390 | const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); | ||
| 391 | |||
| 392 | yield* Effect.yieldNow; | ||
| 393 | yield* TestClock.adjust("4 seconds"); | ||
| 394 | const snapshot = yield* Fiber.join(probeFiber); | ||
| 395 | |||
| 396 | NodeAssert.equal(snapshot.status, "error"); | ||
| 397 | NodeAssert.equal(snapshot.installed, true); | ||
| 398 | NodeAssert.equal( | ||
| 399 | snapshot.message, | ||
| 400 | "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", | ||
| 401 | ); | ||
| 402 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | runtimeMock.state.inventory = { | ||
| 408 | providerList: { | ||
| 409 | connected: ["openai"], | ||
| 410 | all: [ | ||
| 411 | { | ||
| 412 | id: "openai", | ||
| 413 | name: "OpenAI", | ||
| 414 | models: { | ||
| 415 | "gpt-5.4": { | ||
| 416 | id: "gpt-5.4", | ||
| 417 | name: "GPT-5.4", | ||
| 418 | variants: { | ||
| 419 | none: {}, | ||
| 420 | low: {}, | ||
| 421 | medium: {}, | ||
| 422 | high: {}, | ||
| 423 | xhigh: {}, | ||
| 424 | }, | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | }, | ||
| 428 | ], | ||
| 429 | default: {}, | ||
| 430 | }, | ||
| 431 | agents: [ | ||
| 432 | { name: "build", hidden: false, mode: "primary" }, | ||
| 433 | { name: "plan", hidden: false, mode: "primary" }, | ||
| 434 | ], | ||
| 435 | }; | ||
| 436 | |||
| 437 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 438 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); | ||
| 439 | |||
| 440 | NodeAssert.ok(model); | ||
| 441 | const variantDescriptor = model.capabilities?.optionDescriptors?.find( | ||
| 442 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | ||
| 443 | ); | ||
| 444 | NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); | ||
| 445 | NodeAssert.equal( | ||
| 446 | variantDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 447 | "medium", | ||
| 448 | ); | ||
| 449 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | ||
| 450 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | ||
| 451 | ); | ||
| 452 | NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); | ||
| 453 | NodeAssert.equal( | ||
| 454 | agentDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 455 | "build", | ||
| 456 | ); | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | |||
| 460 | it.effect("includes OpenCode skills in the provider snapshot", () => | ||
| 461 | Effect.gen(function* () { | ||
| 462 | runtimeMock.state.inventory = { | ||
| 463 | providerList: { | ||
| 464 | connected: ["openai"], | ||
| 465 | all: [ | ||
| 466 | { | ||
| 467 | id: "openai", | ||
| 468 | name: "OpenAI", | ||
| 469 | models: { | ||
| 470 | "gpt-5.4": { | ||
| 471 | id: "gpt-5.4", | ||
| 472 | name: "GPT-5.4", | ||
| 473 | variants: {}, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | ], | ||
| 478 | default: {}, | ||
| 479 | }, | ||
| 480 | agents: [], | ||
| 481 | skills: [ | ||
| 482 | { | ||
| 483 | name: "openclaw-review", | ||
| 484 | description: "Review OpenClaw workflow changes.", | ||
| 485 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 486 | }, | ||
| 487 | { | ||
| 488 | name: "openclaw-triage", | ||
| 489 | description: "Triage OpenClaw routing issues.", | ||
| 490 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 491 | }, | ||
| 492 | { | ||
| 493 | name: "missing-location", | ||
| 494 | description: "This incomplete SDK row should be skipped.", | ||
| 495 | location: "", | ||
| 496 | }, | ||
| 497 | ], | ||
| 498 | }; | ||
| 499 | |||
| 500 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 501 | |||
| 502 | NodeAssert.deepEqual( | ||
| 503 | snapshot.skills.map((skill) => ({ | ||
| 504 | name: skill.name, | ||
| 505 | path: skill.path, | ||
| 506 | enabled: skill.enabled, | ||
| 507 | shortDescription: skill.shortDescription, | ||
| 508 | })), | ||
| 509 | [ | ||
| 510 | { | ||
| 511 | name: "openclaw-review", | ||
| 512 | path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 513 | enabled: true, | ||
| 514 | shortDescription: "Review OpenClaw workflow changes.", | ||
| 515 | }, | ||
| 516 | { | ||
| 517 | name: "openclaw-triage", | ||
| 518 | path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 519 | enabled: true, | ||
| 520 | shortDescription: "Triage OpenClaw routing issues.", | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | ); | ||
| 524 | }), | ||
| 525 | ); | ||
| 526 | |||
| 527 | it.effect("loads local inventory from a scoped OpenCode server", () => | ||
| 528 | Effect.gen(function* () { | ||
| 529 | yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); | ||
| 530 | |||
| 531 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 532 | { | ||
| 533 | baseUrl: "http://127.0.0.1:4301", | ||
| 534 | directory: process.cwd(), | ||
| 535 | serverPassword: "secret-password", | ||
| 536 | }, | ||
| 537 | ]); | ||
| 538 | NodeAssert.equal(runtimeMock.state.closeCalls, 1); | ||
| 539 | NodeAssert.equal(runtimeMock.state.inventoryCwd, null); | ||
| 540 | }), | ||
| 541 | ); | ||
| 542 | |||
| 543 | it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => | ||
| 544 | Effect.gen(function* () { | ||
| 545 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | ||
| 546 | const snapshot = yield* checkProvider( | ||
| 547 | makeOpenCodeSettings(), | ||
| 548 | process.cwd(), | ||
| 549 | undefined, | ||
| 550 | undefined, | ||
| 551 | Effect.succeed([ | ||
| 552 | { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, | ||
| 553 | { | ||
| 554 | providerID: "opencode", | ||
| 555 | id: "space-bunny-free", | ||
| 556 | name: "Space Bunny Free", | ||
| 557 | variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], | ||
| 558 | }, | ||
| 559 | ]), | ||
| 560 | ); | ||
| 561 | |||
| 562 | NodeAssert.equal(snapshot.status, "ready"); | ||
| 563 | NodeAssert.equal(snapshot.version, "2.0.18"); | ||
| 564 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | ||
| 565 | "approval-required", | ||
| 566 | "auto-accept-edits", | ||
| 567 | "auto", | ||
| 568 | "full-access", | ||
| 569 | ]); | ||
| 570 | // Plan mode runs as OpenCode's plan agent, and every session can compact. | ||
| 571 | NodeAssert.equal(snapshot.showInteractionModeToggle, true); | ||
| 572 | NodeAssert.deepEqual( | ||
| 573 | snapshot.slashCommands.map((command) => command.name), | ||
| 574 | ["compact"], | ||
| 575 | ); | ||
| 576 | NodeAssert.deepEqual( | ||
| 577 | snapshot.models.map((model) => model.slug), | ||
| 578 | ["opencode/big-pickle", "opencode/space-bunny-free"], | ||
| 579 | ); | ||
| 580 | const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") | ||
| 581 | ?.capabilities?.optionDescriptors?.[0]; | ||
| 582 | NodeAssert.deepEqual( | ||
| 583 | variant?.type === "select" ? variant.options.map((option) => option.id) : [], | ||
| 584 | ["low", "medium", "high"], | ||
| 585 | ); | ||
| 586 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | |||
| 590 | it.effect("reports a failed OpenCode 2 model list without the server's response", () => | ||
| 591 | Effect.gen(function* () { | ||
| 592 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | ||
| 593 | const snapshot = yield* checkProvider( | ||
| 594 | makeOpenCodeSettings(), | ||
| 595 | process.cwd(), | ||
| 596 | undefined, | ||
| 597 | undefined, | ||
| 598 | Effect.fail( | ||
| 599 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 600 | operation: "model.list", | ||
| 601 | detail: 'status=500 body={"token":"leaked-response-body"}', | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ); | ||
| 605 | |||
| 606 | NodeAssert.equal(snapshot.status, "error"); | ||
| 607 | NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); | ||
| 608 | // A failed catalog read still leaves the session able to compact. | ||
| 609 | NodeAssert.deepEqual( | ||
| 610 | snapshot.slashCommands.map((command) => command.name), | ||
| 611 | ["compact"], | ||
| 612 | ); | ||
| 613 | }), | ||
| 614 | ); | ||
| 615 | |||
| 616 | it.effect("uses an environment-only password for local inventory", () => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { | ||
| 619 | OPENCODE_SERVER_PASSWORD: "environment-password", | ||
| 620 | }); | ||
| 621 | |||
| 622 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 623 | { | ||
| 624 | baseUrl: "http://127.0.0.1:4301", | ||
| 625 | directory: process.cwd(), | ||
| 626 | serverPassword: "environment-password", | ||
| 627 | }, | ||
| 628 | ]); | ||
| 629 | }), | ||
| 630 | ); | ||
| 631 | |||
| 632 | it.effect("uses the settings password when local environment auth differs", () => | ||
| 633 | Effect.gen(function* () { | ||
| 634 | yield* checkProvider( | ||
| 635 | makeOpenCodeSettings({ serverPassword: "settings-password" }), | ||
| 636 | process.cwd(), | ||
| 637 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | ||
| 638 | ); | ||
| 639 | |||
| 640 | NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); | ||
| 641 | }), | ||
| 642 | ); | ||
| 643 | |||
| 644 | it.effect("reports local model inventory failures without treating them as empty", () => | ||
| 645 | Effect.gen(function* () { | ||
| 646 | runtimeMock.state.inventoryError = new Error("opencode models failed"); | ||
| 647 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 648 | |||
| 649 | NodeAssert.equal(snapshot.status, "error"); | ||
| 650 | NodeAssert.equal(snapshot.installed, true); | ||
| 651 | NodeAssert.equal(snapshot.models.length, 0); | ||
| 652 | NodeAssert.equal( | ||
| 653 | snapshot.message, | ||
| 654 | "Failed to load OpenCode provider inventory: opencode models failed", | ||
| 655 | ); | ||
| 656 | }), | ||
| 657 | ); | ||
| 658 | }); | ||
| 659 | |||
| 660 | it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { | ||
| 661 | it.effect("does not send a local environment password to a configured server", () => | ||
| 662 | Effect.gen(function* () { | ||
| 663 | const snapshot = yield* checkProvider( | ||
| 664 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | ||
| 665 | process.cwd(), | ||
| 666 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | ||
| 667 | ); | ||
| 668 | |||
| 669 | NodeAssert.equal(snapshot.version, "1.14.19"); | ||
| 670 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 671 | { | ||
| 672 | baseUrl: "http://127.0.0.1:9999", | ||
| 673 | directory: process.cwd(), | ||
| 674 | }, | ||
| 675 | ]); | ||
| 676 | }), | ||
| 677 | ); | ||
| 678 | |||
| 679 | it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => | ||
| 680 | Effect.gen(function* () { | ||
| 681 | const settings = makeOpenCodeSettings({ | ||
| 682 | serverUrl: "http://127.0.0.1:9999", | ||
| 683 | serverPassword: "secret-password", | ||
| 684 | }); | ||
| 685 | const snapshot = yield* checkProvider( | ||
| 686 | settings, | ||
| 687 | process.cwd(), | ||
| 688 | undefined, | ||
| 689 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | ||
| 690 | ); | ||
| 691 | |||
| 692 | NodeAssert.equal(snapshot.status, "warning"); | ||
| 693 | NodeAssert.equal(snapshot.version, "2.0.18"); | ||
| 694 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | ||
| 695 | "approval-required", | ||
| 696 | "auto-accept-edits", | ||
| 697 | "auto", | ||
| 698 | "full-access", | ||
| 699 | ]); | ||
| 700 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 701 | }), | ||
| 702 | ); | ||
| 703 | |||
| 704 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => | ||
| 705 | Effect.gen(function* () { | ||
| 706 | const snapshot = yield* checkProvider( | ||
| 707 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), | ||
| 708 | process.cwd(), | ||
| 709 | undefined, | ||
| 710 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | ||
| 711 | ); | ||
| 712 | |||
| 713 | NodeAssert.equal(snapshot.status, "error"); | ||
| 714 | NodeAssert.equal( | ||
| 715 | snapshot.message, | ||
| 716 | "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 717 | ); | ||
| 718 | }), | ||
| 719 | ); | ||
| 720 | |||
| 721 | it.effect("rejects an unsupported server before loading inventory", () => | ||
| 722 | Effect.gen(function* () { | ||
| 723 | runtimeMock.state.connectionError = new Error( | ||
| 724 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | ||
| 725 | ); | ||
| 726 | const snapshot = yield* checkProvider( | ||
| 727 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | ||
| 728 | ); | ||
| 729 | |||
| 730 | NodeAssert.equal(snapshot.status, "error"); | ||
| 731 | NodeAssert.equal(snapshot.models.length, 0); | ||
| 732 | NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); | ||
| 733 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 734 | }), | ||
| 735 | ); | ||
| 736 | |||
| 737 | it.effect("surfaces a friendly auth error for configured servers", () => | ||
| 738 | Effect.gen(function* () { | ||
| 739 | runtimeMock.state.connectionError = new Error("401 Unauthorized"); | ||
| 740 | const snapshot = yield* checkProvider( | ||
| 741 | makeOpenCodeSettings({ | ||
| 742 | serverUrl: "http://127.0.0.1:9999", | ||
| 743 | serverPassword: "secret-password", | ||
| 744 | }), | ||
| 745 | ); | ||
| 746 | |||
| 747 | NodeAssert.equal(snapshot.status, "error"); | ||
| 748 | NodeAssert.equal(snapshot.installed, true); | ||
| 749 | NodeAssert.equal( | ||
| 750 | snapshot.message, | ||
| 751 | "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 752 | ); | ||
| 753 | }), | ||
| 754 | ); | ||
| 755 | |||
| 756 | it.effect("surfaces a friendly connection error for configured servers", () => | ||
| 757 | Effect.gen(function* () { | ||
| 758 | runtimeMock.state.connectionError = new Error( | ||
| 759 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | ||
| 760 | ); | ||
| 761 | const snapshot = yield* checkProvider( | ||
| 762 | makeOpenCodeSettings({ | ||
| 763 | serverUrl: "http://127.0.0.1:9999", | ||
| 764 | serverPassword: "secret-password", | ||
| 765 | }), | ||
| 766 | ); | ||
| 767 | |||
| 768 | NodeAssert.equal(snapshot.status, "error"); | ||
| 769 | NodeAssert.equal(snapshot.installed, true); | ||
| 770 | NodeAssert.equal( | ||
| 771 | snapshot.message, | ||
| 772 | "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.", | ||
| 773 | ); | ||
| 774 | }), | ||
| 775 | ); | ||
| 776 | }); | ||
| 777 | |||
| 778 | const bigPickle: OpenCode2Model = { | ||
| 779 | providerID: "opencode", | ||
| 780 | id: "big-pickle", | ||
| 781 | name: "Big Pickle", | ||
| 782 | variants: [], | ||
| 783 | }; | ||
| 784 | |||
| 785 | it.effect("waits for a fresh OpenCode 2 server to list its models", () => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | // A fresh server lists nothing until its catalog loads. | ||
| 788 | const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]]; | ||
| 789 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); | ||
| 790 | const fiber = yield* load.pipe(Effect.forkChild); | ||
| 791 | yield* TestClock.adjust("1 second"); | ||
| 792 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | ||
| 793 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 794 | ); | ||
| 795 | |||
| 796 | it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => | ||
| 797 | Effect.gen(function* () { | ||
| 798 | let listed: ReadonlyArray<OpenCode2Model> = [bigPickle]; | ||
| 799 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); | ||
| 800 | NodeAssert.deepEqual(yield* load, [bigPickle]); | ||
| 801 | listed = []; | ||
| 802 | const fiber = yield* load.pipe(Effect.forkChild); | ||
| 803 | yield* TestClock.adjust("6 seconds"); | ||
| 804 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | ||
| 805 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 806 | ); | ||
| 807 | |||
| 808 | // What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). | ||
| 809 | const builtinCommands = [ | ||
| 810 | { name: "init", description: "guided AGENTS.md setup" }, | ||
| 811 | { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, | ||
| 812 | ]; | ||
| 813 | const builtinSkills = [ | ||
| 814 | { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, | ||
| 815 | { id: "report", name: "Report", path: "/builtin/report.md" }, | ||
| 816 | ]; | ||
| 817 | const scanned: OpenCode2Workspace = { | ||
| 818 | commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], | ||
| 819 | skills: [ | ||
| 820 | ...builtinSkills, | ||
| 821 | { | ||
| 822 | id: "greet", | ||
| 823 | name: "greet", | ||
| 824 | path: "/work/.opencode/skills/greet/SKILL.md", | ||
| 825 | description: "Greets the user with the secret word MANGO.", | ||
| 826 | }, | ||
| 827 | ], | ||
| 828 | }; | ||
| 829 | |||
| 830 | it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => | ||
| 831 | Effect.gen(function* () { | ||
| 832 | // A directory the server has not served yet lists no commands until its scan ends. | ||
| 833 | const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned]; | ||
| 834 | const scanEnded = yield* Deferred.make<void>(); | ||
| 835 | const load = yield* loadOpenCode2Workspace( | ||
| 836 | Effect.sync(() => reads.shift()!), | ||
| 837 | Deferred.await(scanEnded), | ||
| 838 | ).pipe(Effect.forkChild); | ||
| 839 | yield* Effect.yieldNow; | ||
| 840 | NodeAssert.equal(reads.length, 1); | ||
| 841 | yield* Deferred.succeed(scanEnded, undefined); | ||
| 842 | NodeAssert.deepEqual(yield* Fiber.join(load), scanned); | ||
| 843 | }), | ||
| 844 | ); | ||
| 845 | |||
| 846 | it.effect("takes a served OpenCode 2 directory's first listing as is", () => | ||
| 847 | Effect.gen(function* () { | ||
| 848 | const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); | ||
| 849 | NodeAssert.deepEqual(workspace, scanned); | ||
| 850 | }), | ||
| 851 | ); | ||
| 852 | |||
| 853 | it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { | ||
| 854 | NodeAssert.deepEqual( | ||
| 855 | openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ | ||
| 856 | skill.name, | ||
| 857 | skill.displayName, | ||
| 858 | skill.path, | ||
| 859 | ]), | ||
| 860 | [ | ||
| 861 | ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], | ||
| 862 | ["opencode", "OpenCode", "/builtin/opencode.md"], | ||
| 863 | ["report", "Report", "/builtin/report.md"], | ||
| 864 | ], | ||
| 865 | ); | ||
| 866 | NodeAssert.deepEqual( | ||
| 867 | openCode2CommandsToServerProviderSlashCommands([ | ||
| 868 | ...scanned.commands, | ||
| 869 | { name: "compact", description: "a workspace command named like T3's own" }, | ||
| 870 | ]).map((command) => command.name), | ||
| 871 | ["compact", "init", "review", "hello"], | ||
| 872 | ); | ||
| 873 | }); | ||
apps/server/src/provider/Layers/OpenCodeProvider.ts deleted-747| ... | @@ -1,747 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type ModelCapabilities, | ||
| 3 | type OpenCodeSettings, | ||
| 4 | type ServerProviderModel, | ||
| 5 | type ServerProviderSkill, | ||
| 6 | type ServerProviderSlashCommand, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Data from "effect/Data"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Schedule from "effect/Schedule"; | ||
| 13 | |||
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 16 | import { | ||
| 17 | buildServerProvider, | ||
| 18 | COMPACT_SLASH_COMMAND, | ||
| 19 | nonEmptyTrimmed, | ||
| 20 | providerModelsFromSettings, | ||
| 21 | type ServerProviderDraft, | ||
| 22 | } from "../providerSnapshot.ts"; | ||
| 23 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | ||
| 24 | import type { ProbedOpenCode } from "../opencodeVersionProbe.ts"; | ||
| 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | ||
| 26 | import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; | ||
| 27 | |||
| 28 | const OPENCODE_PRESENTATION = { | ||
| 29 | displayName: "OpenCode", | ||
| 30 | showInteractionModeToggle: false, | ||
| 31 | } as const; | ||
| 32 | |||
| 33 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ | ||
| 34 | readonly cause?: unknown; | ||
| 35 | readonly detail: string; | ||
| 36 | }> {} | ||
| 37 | |||
| 38 | function normalizeProbeMessage(message: string): string | undefined { | ||
| 39 | const trimmed = message.trim(); | ||
| 40 | if (trimmed.length === 0) { | ||
| 41 | return undefined; | ||
| 42 | } | ||
| 43 | if ( | ||
| 44 | trimmed === "An error occurred in Effect.tryPromise" || | ||
| 45 | trimmed === "An error occurred in Effect.try" | ||
| 46 | ) { | ||
| 47 | return undefined; | ||
| 48 | } | ||
| 49 | return trimmed; | ||
| 50 | } | ||
| 51 | |||
| 52 | function normalizedErrorMessage(cause: unknown): string | undefined { | ||
| 53 | if (cause instanceof OpenCodeProbeError) { | ||
| 54 | return normalizeProbeMessage(cause.detail); | ||
| 55 | } | ||
| 56 | |||
| 57 | if (!(cause instanceof Error)) { | ||
| 58 | return undefined; | ||
| 59 | } | ||
| 60 | |||
| 61 | return normalizeProbeMessage(cause.message); | ||
| 62 | } | ||
| 63 | |||
| 64 | function formatOpenCodeProbeError(input: { | ||
| 65 | readonly cause: unknown; | ||
| 66 | readonly isExternalServer: boolean; | ||
| 67 | readonly phase: "version" | "inventory"; | ||
| 68 | readonly serverUrl: string; | ||
| 69 | }): { readonly installed: boolean; readonly message: string } { | ||
| 70 | const detail = normalizedErrorMessage(input.cause); | ||
| 71 | const lower = detail?.toLowerCase() ?? ""; | ||
| 72 | |||
| 73 | if (input.isExternalServer) { | ||
| 74 | if ( | ||
| 75 | lower.includes("401") || | ||
| 76 | lower.includes("403") || | ||
| 77 | lower.includes("unauthorized") || | ||
| 78 | lower.includes("forbidden") | ||
| 79 | ) { | ||
| 80 | return { | ||
| 81 | installed: true, | ||
| 82 | message: "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | if ( | ||
| 87 | lower.includes("econnrefused") || | ||
| 88 | lower.includes("enotfound") || | ||
| 89 | lower.includes("fetch failed") || | ||
| 90 | lower.includes("networkerror") || | ||
| 91 | lower.includes("timed out") || | ||
| 92 | lower.includes("timeout") || | ||
| 93 | lower.includes("socket hang up") | ||
| 94 | ) { | ||
| 95 | return { | ||
| 96 | installed: true, | ||
| 97 | message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | |||
| 101 | return { | ||
| 102 | installed: true, | ||
| 103 | message: detail ?? "Failed to connect to the configured OpenCode server.", | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | |||
| 107 | if (lower.includes("enoent") || lower.includes("notfound")) { | ||
| 108 | return { | ||
| 109 | installed: false, | ||
| 110 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 111 | }; | ||
| 112 | } | ||
| 113 | |||
| 114 | if (lower.includes("quarantine")) { | ||
| 115 | return { | ||
| 116 | installed: true, | ||
| 117 | message: | ||
| 118 | "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | if (lower.includes("invalid code signature") || lower.includes("corrupted")) { | ||
| 123 | return { | ||
| 124 | installed: true, | ||
| 125 | message: | ||
| 126 | "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", | ||
| 127 | }; | ||
| 128 | } | ||
| 129 | |||
| 130 | const failureLabel = | ||
| 131 | input.phase === "inventory" | ||
| 132 | ? "Failed to load OpenCode provider inventory" | ||
| 133 | : "Failed to execute OpenCode CLI health check"; | ||
| 134 | return { | ||
| 135 | installed: true, | ||
| 136 | message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | function titleCaseSlug(value: string): string { | ||
| 141 | const segments: Array<string> = []; | ||
| 142 | for (const segment of value.split(/[-_/]+/)) { | ||
| 143 | if (segment.length > 0) { | ||
| 144 | segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); | ||
| 145 | } | ||
| 146 | } | ||
| 147 | return segments.join(" "); | ||
| 148 | } | ||
| 149 | |||
| 150 | function inferDefaultVariant( | ||
| 151 | providerID: string, | ||
| 152 | variants: ReadonlyArray<string>, | ||
| 153 | ): string | undefined { | ||
| 154 | if (variants.length === 1) { | ||
| 155 | return variants[0]; | ||
| 156 | } | ||
| 157 | if (providerID === "anthropic" || providerID.startsWith("google")) { | ||
| 158 | return variants.includes("high") ? "high" : undefined; | ||
| 159 | } | ||
| 160 | if (providerID === "openai" || providerID === "opencode") { | ||
| 161 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | ||
| 162 | } | ||
| 163 | return undefined; | ||
| 164 | } | ||
| 165 | |||
| 166 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { | ||
| 167 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; | ||
| 168 | } | ||
| 169 | |||
| 170 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 171 | optionDescriptors: [ | ||
| 172 | { | ||
| 173 | id: "variant", | ||
| 174 | label: "Reasoning", | ||
| 175 | type: "select", | ||
| 176 | options: [ | ||
| 177 | { id: "low", label: "Low" }, | ||
| 178 | { id: "medium", label: "Medium", isDefault: true }, | ||
| 179 | { id: "high", label: "High" }, | ||
| 180 | { id: "xhigh", label: "Extra High" }, | ||
| 181 | ], | ||
| 182 | currentValue: "medium", | ||
| 183 | }, | ||
| 184 | { | ||
| 185 | id: "agent", | ||
| 186 | label: "Agent", | ||
| 187 | type: "select", | ||
| 188 | options: [ | ||
| 189 | { id: "build", label: "Build", isDefault: true }, | ||
| 190 | { id: "plan", label: "Plan" }, | ||
| 191 | ], | ||
| 192 | currentValue: "build", | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | }); | ||
| 196 | |||
| 197 | function openCodeCapabilitiesForModel(input: { | ||
| 198 | readonly providerID: string; | ||
| 199 | readonly model: ProviderListResponse["all"][number]["models"][string]; | ||
| 200 | readonly agents: ReadonlyArray<Agent>; | ||
| 201 | }): ModelCapabilities { | ||
| 202 | const rawVariantValues = Object.keys(input.model.variants ?? {}); | ||
| 203 | // When a model advertises no variants, synthesize the standard reasoning | ||
| 204 | // levels so the composer still offers a Reasoning selector (mirrors the | ||
| 205 | // Codex/Grok experience where reasoning is always configurable). The set | ||
| 206 | // covers the common OpenCode variant spectrum; `inferDefaultVariant` | ||
| 207 | // picks the provider-appropriate default (e.g. medium for openai/opencode). | ||
| 208 | const variantValues = | ||
| 209 | rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; | ||
| 210 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); | ||
| 211 | const variantOptions = variantValues.map((value) => | ||
| 212 | defaultVariant === value | ||
| 213 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } | ||
| 214 | : { id: value, label: titleCaseSlug(value) }, | ||
| 215 | ); | ||
| 216 | const primaryAgents = input.agents.filter( | ||
| 217 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), | ||
| 218 | ); | ||
| 219 | const defaultAgent = inferDefaultAgent(primaryAgents); | ||
| 220 | const agentOptions = primaryAgents.map((agent) => | ||
| 221 | defaultAgent === agent.name | ||
| 222 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } | ||
| 223 | : { id: agent.name, label: titleCaseSlug(agent.name) }, | ||
| 224 | ); | ||
| 225 | return createModelCapabilities({ | ||
| 226 | optionDescriptors: [ | ||
| 227 | ...(variantOptions.length > 0 | ||
| 228 | ? [ | ||
| 229 | { | ||
| 230 | id: "variant", | ||
| 231 | label: "Reasoning", | ||
| 232 | type: "select" as const, | ||
| 233 | options: variantOptions, | ||
| 234 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), | ||
| 235 | }, | ||
| 236 | ] | ||
| 237 | : []), | ||
| 238 | ...(agentOptions.length > 0 | ||
| 239 | ? [ | ||
| 240 | { | ||
| 241 | id: "agent", | ||
| 242 | label: "Agent", | ||
| 243 | type: "select" as const, | ||
| 244 | options: agentOptions, | ||
| 245 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), | ||
| 246 | }, | ||
| 247 | ] | ||
| 248 | : []), | ||
| 249 | ], | ||
| 250 | }); | ||
| 251 | } | ||
| 252 | |||
| 253 | function flattenOpenCodeModels( | ||
| 254 | input: OpenCodeRuntime.OpenCodeInventory, | ||
| 255 | ): ReadonlyArray<ServerProviderModel> { | ||
| 256 | const connected = new Set(input.providerList.connected); | ||
| 257 | const models: Array<ServerProviderModel> = []; | ||
| 258 | |||
| 259 | for (const provider of input.providerList.all) { | ||
| 260 | if (!connected.has(provider.id)) { | ||
| 261 | continue; | ||
| 262 | } | ||
| 263 | |||
| 264 | for (const model of Object.values(provider.models)) { | ||
| 265 | const name = nonEmptyTrimmed(model.name); | ||
| 266 | if (!name) { | ||
| 267 | continue; | ||
| 268 | } | ||
| 269 | |||
| 270 | const subProvider = nonEmptyTrimmed(provider.name); | ||
| 271 | models.push({ | ||
| 272 | slug: `${provider.id}/${model.id}`, | ||
| 273 | name, | ||
| 274 | ...(subProvider ? { subProvider } : {}), | ||
| 275 | isCustom: false, | ||
| 276 | capabilities: openCodeCapabilitiesForModel({ | ||
| 277 | providerID: provider.id, | ||
| 278 | model, | ||
| 279 | agents: input.agents, | ||
| 280 | }), | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 286 | } | ||
| 287 | |||
| 288 | function trimOptional(value: string | null | undefined): string | undefined { | ||
| 289 | const trimmed = value?.trim(); | ||
| 290 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | ||
| 291 | } | ||
| 292 | |||
| 293 | export function openCodeSkillsToServerProviderSkills( | ||
| 294 | input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, | ||
| 295 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 296 | const skills: ServerProviderSkill[] = []; | ||
| 297 | for (const skill of input ?? []) { | ||
| 298 | const name = trimOptional(skill.name); | ||
| 299 | const path = trimOptional(skill.location); | ||
| 300 | if (!name || !path) { | ||
| 301 | continue; | ||
| 302 | } | ||
| 303 | |||
| 304 | const description = trimOptional(skill.description); | ||
| 305 | skills.push({ | ||
| 306 | name, | ||
| 307 | path, | ||
| 308 | enabled: true, | ||
| 309 | ...(description ? { description, shortDescription: description } : {}), | ||
| 310 | }); | ||
| 311 | } | ||
| 312 | |||
| 313 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 314 | } | ||
| 315 | |||
| 316 | export function openCodeCommandsToServerProviderSlashCommands( | ||
| 317 | input: OpenCodeRuntime.OpenCodeInventory["commands"], | ||
| 318 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 319 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | ||
| 320 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 321 | for (const command of input ?? []) { | ||
| 322 | const name = trimOptional(command.name); | ||
| 323 | if (!name || names.has(name) || command.source === "skill") continue; | ||
| 324 | names.add(name); | ||
| 325 | const description = trimOptional(command.description); | ||
| 326 | const hint = trimOptional(command.hints.join(" ")); | ||
| 327 | commands.push({ | ||
| 328 | name, | ||
| 329 | ...(description ? { description } : {}), | ||
| 330 | ...(hint ? { input: { hint } } : {}), | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | return commands; | ||
| 334 | } | ||
| 335 | |||
| 336 | export const makePendingOpenCodeProvider = ( | ||
| 337 | openCodeSettings: OpenCodeSettings, | ||
| 338 | ): Effect.Effect<ServerProviderDraft> => | ||
| 339 | Effect.gen(function* () { | ||
| 340 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 341 | const models = providerModelsFromSettings( | ||
| 342 | [], | ||
| 343 | openCodeSettings.customModels, | ||
| 344 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 345 | ); | ||
| 346 | |||
| 347 | if (!openCodeSettings.enabled) { | ||
| 348 | return buildServerProvider({ | ||
| 349 | presentation: OPENCODE_PRESENTATION, | ||
| 350 | enabled: false, | ||
| 351 | checkedAt, | ||
| 352 | models, | ||
| 353 | probe: { | ||
| 354 | installed: false, | ||
| 355 | version: null, | ||
| 356 | status: "warning", | ||
| 357 | auth: { status: "unknown" }, | ||
| 358 | message: | ||
| 359 | openCodeSettings.serverUrl.trim().length > 0 | ||
| 360 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 361 | : "OpenCode is disabled in T3 Code settings.", | ||
| 362 | }, | ||
| 363 | }); | ||
| 364 | } | ||
| 365 | |||
| 366 | return buildServerProvider({ | ||
| 367 | presentation: OPENCODE_PRESENTATION, | ||
| 368 | enabled: true, | ||
| 369 | checkedAt, | ||
| 370 | models, | ||
| 371 | probe: { | ||
| 372 | installed: false, | ||
| 373 | version: null, | ||
| 374 | status: "warning", | ||
| 375 | auth: { status: "unknown" }, | ||
| 376 | message: "OpenCode provider status has not been checked in this session yet.", | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | }); | ||
| 380 | |||
| 381 | /** One model an OpenCode 2 server lists, as the status check reads it. */ | ||
| 382 | export interface OpenCode2Model { | ||
| 383 | readonly providerID: string; | ||
| 384 | readonly id: string; | ||
| 385 | readonly name: string; | ||
| 386 | readonly variants: ReadonlyArray<{ readonly id: string }>; | ||
| 387 | } | ||
| 388 | |||
| 389 | /** | ||
| 390 | * A server loads its catalog lazily and lists nothing for its first few | ||
| 391 | * hundred milliseconds, and a local instance starts a fresh server for each | ||
| 392 | * status check. So an empty list is read again for a while, and the last | ||
| 393 | * non-empty list stands in if the catalog still has not loaded. | ||
| 394 | */ | ||
| 395 | export const makeOpenCode2ModelLoader = <E>( | ||
| 396 | list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>, | ||
| 397 | ) => | ||
| 398 | Effect.sync(() => { | ||
| 399 | let lastLoaded: ReadonlyArray<OpenCode2Model> = []; | ||
| 400 | return list.pipe( | ||
| 401 | Effect.repeat({ | ||
| 402 | until: (models) => models.length > 0, | ||
| 403 | schedule: Schedule.spaced("250 millis"), | ||
| 404 | }), | ||
| 405 | Effect.timeoutOption("5 seconds"), | ||
| 406 | Effect.map((loaded) => { | ||
| 407 | if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; | ||
| 408 | return lastLoaded; | ||
| 409 | }), | ||
| 410 | ); | ||
| 411 | }); | ||
| 412 | |||
| 413 | /** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ | ||
| 414 | export interface OpenCode2Workspace { | ||
| 415 | readonly skills: ReadonlyArray<{ | ||
| 416 | readonly id: string; | ||
| 417 | readonly name: string; | ||
| 418 | readonly path: string; | ||
| 419 | readonly description?: string | undefined; | ||
| 420 | }>; | ||
| 421 | readonly commands: ReadonlyArray<{ | ||
| 422 | readonly name: string; | ||
| 423 | readonly description?: string | undefined; | ||
| 424 | }>; | ||
| 425 | } | ||
| 426 | |||
| 427 | /** | ||
| 428 | * A server scans a directory it has not served yet in the background: its | ||
| 429 | * first read lists no commands at all (not even the built-ins), and | ||
| 430 | * `command.updated` plus `skill.updated` for the directory end the scan. A | ||
| 431 | * directory it already serves lists everything at once. `scanned` must be | ||
| 432 | * listening before `read` runs, since the read is what starts the scan. | ||
| 433 | */ | ||
| 434 | export const loadOpenCode2Workspace = <E>( | ||
| 435 | read: Effect.Effect<OpenCode2Workspace, E>, | ||
| 436 | scanned: Effect.Effect<void>, | ||
| 437 | ) => | ||
| 438 | Effect.gen(function* () { | ||
| 439 | const first = yield* read; | ||
| 440 | if (first.commands.length > 0) return first; | ||
| 441 | yield* scanned.pipe(Effect.timeoutOption("10 seconds")); | ||
| 442 | return yield* read; | ||
| 443 | }); | ||
| 444 | |||
| 445 | export function openCode2SkillsToServerProviderSkills( | ||
| 446 | skills: OpenCode2Workspace["skills"], | ||
| 447 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 448 | return skills | ||
| 449 | .map((skill) => { | ||
| 450 | const description = trimOptional(skill.description); | ||
| 451 | return { | ||
| 452 | name: skill.id, | ||
| 453 | path: skill.path, | ||
| 454 | enabled: true, | ||
| 455 | ...(skill.name === skill.id ? {} : { displayName: skill.name }), | ||
| 456 | ...(description ? { description, shortDescription: description } : {}), | ||
| 457 | }; | ||
| 458 | }) | ||
| 459 | .toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 460 | } | ||
| 461 | |||
| 462 | export function openCode2CommandsToServerProviderSlashCommands( | ||
| 463 | commands: OpenCode2Workspace["commands"], | ||
| 464 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 465 | const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | ||
| 466 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 467 | for (const command of commands) { | ||
| 468 | const name = trimOptional(command.name); | ||
| 469 | if (!name || names.has(name)) continue; | ||
| 470 | names.add(name); | ||
| 471 | const description = trimOptional(command.description); | ||
| 472 | slashCommands.push({ name, ...(description ? { description } : {}) }); | ||
| 473 | } | ||
| 474 | return slashCommands; | ||
| 475 | } | ||
| 476 | |||
| 477 | /** | ||
| 478 | * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: | ||
| 479 | * OpenCode has no reviewer that approves routine actions. Plan mode is its | ||
| 480 | * `plan` agent, so the composer's mode toggle drives it. | ||
| 481 | */ | ||
| 482 | const OPENCODE_2_PRESENTATION = { | ||
| 483 | ...OPENCODE_PRESENTATION, | ||
| 484 | showInteractionModeToggle: true, | ||
| 485 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], | ||
| 486 | } as const; | ||
| 487 | |||
| 488 | function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { | ||
| 489 | const variants = model.variants.map((variant) => variant.id); | ||
| 490 | const defaultVariant = inferDefaultVariant(model.providerID, variants); | ||
| 491 | return createModelCapabilities({ | ||
| 492 | optionDescriptors: | ||
| 493 | variants.length === 0 | ||
| 494 | ? [] | ||
| 495 | : [ | ||
| 496 | { | ||
| 497 | id: "variant", | ||
| 498 | label: "Reasoning", | ||
| 499 | type: "select", | ||
| 500 | options: variants.map((id) => ({ | ||
| 501 | id, | ||
| 502 | label: titleCaseSlug(id), | ||
| 503 | ...(id === defaultVariant ? { isDefault: true } : {}), | ||
| 504 | })), | ||
| 505 | ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), | ||
| 506 | }, | ||
| 507 | ], | ||
| 508 | }); | ||
| 509 | } | ||
| 510 | |||
| 511 | const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( | ||
| 512 | settings: OpenCodeSettings, | ||
| 513 | version: string, | ||
| 514 | checkedAt: string, | ||
| 515 | loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>, | ||
| 516 | ) { | ||
| 517 | const result = yield* Effect.exit(loadModels); | ||
| 518 | const probe = (status: "ready" | "warning" | "error", message: string) => ({ | ||
| 519 | installed: true, | ||
| 520 | version, | ||
| 521 | status, | ||
| 522 | auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, | ||
| 523 | message, | ||
| 524 | }); | ||
| 525 | if (result._tag === "Failure") { | ||
| 526 | // The detail can carry a response body; it stays in the log, not the status. | ||
| 527 | yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); | ||
| 528 | return buildServerProvider({ | ||
| 529 | presentation: OPENCODE_2_PRESENTATION, | ||
| 530 | enabled: true, | ||
| 531 | checkedAt, | ||
| 532 | models: providerModelsFromSettings( | ||
| 533 | [], | ||
| 534 | settings.customModels, | ||
| 535 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 536 | ), | ||
| 537 | // Compaction does not depend on the model catalog. | ||
| 538 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 539 | probe: probe("error", "OpenCode could not load its model list."), | ||
| 540 | }); | ||
| 541 | } | ||
| 542 | const models = providerModelsFromSettings( | ||
| 543 | result.value | ||
| 544 | .map((model) => ({ | ||
| 545 | slug: `${model.providerID}/${model.id}`, | ||
| 546 | name: model.name, | ||
| 547 | isCustom: false, | ||
| 548 | capabilities: openCode2ModelCapabilities(model), | ||
| 549 | })) | ||
| 550 | .toSorted((left, right) => left.name.localeCompare(right.name)), | ||
| 551 | settings.customModels, | ||
| 552 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 553 | ); | ||
| 554 | return buildServerProvider({ | ||
| 555 | presentation: OPENCODE_2_PRESENTATION, | ||
| 556 | enabled: true, | ||
| 557 | checkedAt, | ||
| 558 | models, | ||
| 559 | // Every session can compact; a workspace snapshot adds that directory's commands. | ||
| 560 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 561 | probe: | ||
| 562 | result.value.length > 0 | ||
| 563 | ? probe( | ||
| 564 | "ready", | ||
| 565 | `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, | ||
| 566 | ) | ||
| 567 | : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), | ||
| 568 | }); | ||
| 569 | }); | ||
| 570 | |||
| 571 | /** | ||
| 572 | * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, | ||
| 573 | * the version endpoints for a configured server. The status check refreshes it, so an in-place | ||
| 574 | * upgrade re-routes the instance. | ||
| 575 | */ | ||
| 576 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( | ||
| 577 | openCodeSettings: OpenCodeSettings, | ||
| 578 | cwd: string, | ||
| 579 | probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>, | ||
| 580 | loadOpenCode2Models: Effect.Effect< | ||
| 581 | ReadonlyArray<OpenCode2Model>, | ||
| 582 | OpenCodeRuntime.OpenCodeRuntimeError | ||
| 583 | >, | ||
| 584 | ): Effect.fn.Return< | ||
| 585 | ServerProviderDraft, | ||
| 586 | never, | ||
| 587 | OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner | ||
| 588 | > { | ||
| 589 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | ||
| 590 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | ||
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 592 | const customModels = openCodeSettings.customModels; | ||
| 593 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; | ||
| 594 | |||
| 595 | const fallback = ( | ||
| 596 | cause: unknown, | ||
| 597 | version: string | null = null, | ||
| 598 | phase: "version" | "inventory" = "version", | ||
| 599 | ) => { | ||
| 600 | const failure = formatOpenCodeProbeError({ | ||
| 601 | cause, | ||
| 602 | isExternalServer, | ||
| 603 | phase, | ||
| 604 | serverUrl: openCodeSettings.serverUrl, | ||
| 605 | }); | ||
| 606 | return buildServerProvider({ | ||
| 607 | presentation: OPENCODE_PRESENTATION, | ||
| 608 | enabled: openCodeSettings.enabled, | ||
| 609 | checkedAt, | ||
| 610 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 611 | probe: { | ||
| 612 | installed: failure.installed, | ||
| 613 | version, | ||
| 614 | status: "error", | ||
| 615 | auth: { status: "unknown" }, | ||
| 616 | message: failure.message, | ||
| 617 | }, | ||
| 618 | }); | ||
| 619 | }; | ||
| 620 | |||
| 621 | if (!openCodeSettings.enabled) { | ||
| 622 | return buildServerProvider({ | ||
| 623 | presentation: OPENCODE_PRESENTATION, | ||
| 624 | enabled: false, | ||
| 625 | checkedAt, | ||
| 626 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 627 | probe: { | ||
| 628 | installed: false, | ||
| 629 | version: null, | ||
| 630 | status: "warning", | ||
| 631 | auth: { status: "unknown" }, | ||
| 632 | message: isExternalServer | ||
| 633 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 634 | : "OpenCode is disabled in T3 Code settings.", | ||
| 635 | }, | ||
| 636 | }); | ||
| 637 | } | ||
| 638 | |||
| 639 | const probedExit = yield* Effect.exit( | ||
| 640 | probeRuntime.pipe( | ||
| 641 | Effect.mapError( | ||
| 642 | (cause) => | ||
| 643 | new OpenCodeProbeError({ | ||
| 644 | cause, | ||
| 645 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | ||
| 646 | }), | ||
| 647 | ), | ||
| 648 | ), | ||
| 649 | ); | ||
| 650 | if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); | ||
| 651 | const probed = probedExit.value; | ||
| 652 | if (probed.generation === "v2") { | ||
| 653 | return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); | ||
| 654 | } | ||
| 655 | let version: string | null = probed.version; | ||
| 656 | if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { | ||
| 657 | return buildServerProvider({ | ||
| 658 | presentation: OPENCODE_PRESENTATION, | ||
| 659 | enabled: openCodeSettings.enabled, | ||
| 660 | checkedAt, | ||
| 661 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 662 | probe: { | ||
| 663 | installed: true, | ||
| 664 | version: probed.version, | ||
| 665 | status: "error", | ||
| 666 | auth: { status: "unknown" }, | ||
| 667 | message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 668 | }, | ||
| 669 | }); | ||
| 670 | } | ||
| 671 | |||
| 672 | const loadInventory = (server: { | ||
| 673 | readonly url: string; | ||
| 674 | readonly serverPassword?: string; | ||
| 675 | readonly version: string; | ||
| 676 | }) => | ||
| 677 | openCodeRuntime | ||
| 678 | .loadOpenCodeInventory( | ||
| 679 | openCodeRuntime.createOpenCodeSdkClient({ | ||
| 680 | baseUrl: server.url, | ||
| 681 | directory: cwd, | ||
| 682 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | ||
| 683 | }), | ||
| 684 | ) | ||
| 685 | .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); | ||
| 686 | const inventoryEffect = isExternalServer | ||
| 687 | ? openCodeRuntime | ||
| 688 | .connectToOpenCodeServer({ | ||
| 689 | binaryPath: openCodeSettings.binaryPath, | ||
| 690 | directory: cwd, | ||
| 691 | serverUrl: openCodeSettings.serverUrl, | ||
| 692 | ...(openCodeSettings.serverPassword | ||
| 693 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 694 | : {}), | ||
| 695 | }) | ||
| 696 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) | ||
| 697 | : serverOwner.withServer(loadInventory); | ||
| 698 | const inventoryExit = yield* Effect.exit( | ||
| 699 | inventoryEffect.pipe( | ||
| 700 | Effect.mapError( | ||
| 701 | (cause) => | ||
| 702 | new OpenCodeProbeError({ | ||
| 703 | cause, | ||
| 704 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | ||
| 705 | }), | ||
| 706 | ), | ||
| 707 | ), | ||
| 708 | ); | ||
| 709 | if (inventoryExit._tag === "Failure") { | ||
| 710 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); | ||
| 711 | } | ||
| 712 | |||
| 713 | version = inventoryExit.value.version; | ||
| 714 | |||
| 715 | const models = providerModelsFromSettings( | ||
| 716 | flattenOpenCodeModels(inventoryExit.value.inventory), | ||
| 717 | customModels, | ||
| 718 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 719 | ); | ||
| 720 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); | ||
| 721 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; | ||
| 722 | return buildServerProvider({ | ||
| 723 | presentation: OPENCODE_PRESENTATION, | ||
| 724 | enabled: true, | ||
| 725 | checkedAt, | ||
| 726 | models, | ||
| 727 | skills, | ||
| 728 | slashCommands: openCodeCommandsToServerProviderSlashCommands( | ||
| 729 | inventoryExit.value.inventory.commands, | ||
| 730 | ), | ||
| 731 | probe: { | ||
| 732 | installed: true, | ||
| 733 | version, | ||
| 734 | status: connectedCount > 0 ? "ready" : "warning", | ||
| 735 | auth: { | ||
| 736 | status: connectedCount > 0 ? "authenticated" : "unknown", | ||
| 737 | type: "opencode", | ||
| 738 | }, | ||
| 739 | message: | ||
| 740 | connectedCount > 0 | ||
| 741 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | ||
| 742 | : isExternalServer | ||
| 743 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | ||
| 744 | : "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 745 | }, | ||
| 746 | }); | ||
| 747 | }); | ||
apps/server/src/provider/Layers/PiProvider.test.ts deleted-80| ... | @@ -1,80 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Sink from "effect/Sink"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 7 | |||
| 8 | import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; | ||
| 9 | |||
| 10 | const encoder = new TextEncoder(); | ||
| 11 | |||
| 12 | function processHandle(input: { | ||
| 13 | readonly stdout?: string; | ||
| 14 | readonly stderr?: string; | ||
| 15 | readonly exitCode?: number; | ||
| 16 | }) { | ||
| 17 | const bytes = (value: string | undefined) => | ||
| 18 | value === undefined || value.length === 0 | ||
| 19 | ? Stream.empty | ||
| 20 | : Stream.succeed(encoder.encode(value)); | ||
| 21 | return ChildProcessSpawner.makeHandle({ | ||
| 22 | pid: ChildProcessSpawner.ProcessId(900_000_001), | ||
| 23 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | ||
| 24 | isRunning: Effect.succeed(false), | ||
| 25 | kill: () => Effect.void, | ||
| 26 | unref: Effect.succeed(Effect.void), | ||
| 27 | stdin: Sink.drain, | ||
| 28 | stdout: bytes(input.stdout), | ||
| 29 | stderr: bytes(input.stderr), | ||
| 30 | all: Stream.empty, | ||
| 31 | getInputFd: () => Sink.drain, | ||
| 32 | getOutputFd: () => Stream.empty, | ||
| 33 | }); | ||
| 34 | } | ||
| 35 | |||
| 36 | function piProbeSpawner(version: string) { | ||
| 37 | return ChildProcessSpawner.make((command) => { | ||
| 38 | const args = ChildProcess.isStandardCommand(command) ? command.args : []; | ||
| 39 | return Effect.succeed( | ||
| 40 | args.includes("--version") | ||
| 41 | ? processHandle({ stdout: `pi ${version}\n` }) | ||
| 42 | : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | |||
| 47 | const settings = { | ||
| 48 | enabled: true, | ||
| 49 | binaryPath: "pi", | ||
| 50 | launchArgs: "", | ||
| 51 | customModels: [], | ||
| 52 | } as const; | ||
| 53 | |||
| 54 | describe("PiProvider", () => { | ||
| 55 | it.effect("requires the first published Pi version with entries and settlement hooks", () => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | ||
| 58 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), | ||
| 59 | ); | ||
| 60 | assert.equal(snapshot.status, "error"); | ||
| 61 | assert.equal(snapshot.version, "0.80.3"); | ||
| 62 | assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); | ||
| 63 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 64 | ); | ||
| 65 | |||
| 66 | it.effect("keeps compatible Pi selectable when optional discovery fails", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | ||
| 69 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), | ||
| 70 | ); | ||
| 71 | assert.equal(snapshot.status, "ready"); | ||
| 72 | assert.equal(snapshot.auth.status, "unknown"); | ||
| 73 | assert.deepEqual( | ||
| 74 | snapshot.models.map((model) => model.slug), | ||
| 75 | ["default"], | ||
| 76 | ); | ||
| 77 | assert.include(snapshot.message ?? "", "could not refresh its models and commands"); | ||
| 78 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 79 | ); | ||
| 80 | }); | ||
apps/server/src/provider/Layers/PiProvider.ts deleted-441| ... | @@ -1,441 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * PiProvider — snapshot/probe layer for the Pi coding agent. | ||
| 3 | * | ||
| 4 | * Health is probed with `pi --version`. Models, the user's default model, and | ||
| 5 | * the user's commands (extension slash commands, prompt templates, skills) | ||
| 6 | * are discovered through a short-lived ephemeral RPC session | ||
| 7 | * (`pi --mode rpc --no-session`), so everything the user configured in | ||
| 8 | * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — | ||
| 9 | * shows up in T3 without any hardcoded catalog. | ||
| 10 | */ | ||
| 11 | import { | ||
| 12 | type CustomModelSetting, | ||
| 13 | type PiSettings, | ||
| 14 | type ServerProvider, | ||
| 15 | type ServerProviderModel, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 19 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 20 | import * as DateTime from "effect/DateTime"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as Exit from "effect/Exit"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as Result from "effect/Result"; | ||
| 26 | import { HttpClient } from "effect/http"; | ||
| 27 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 28 | |||
| 29 | import { | ||
| 30 | buildPiRpcLaunch, | ||
| 31 | resolvePiLaunchArgs, | ||
| 32 | } from "../../orchestration-v2/Adapters/piT3McpInjection.ts"; | ||
| 33 | import { | ||
| 34 | makePiRpcConnection, | ||
| 35 | piRecordField as recordField, | ||
| 36 | piRecordString as recordString, | ||
| 37 | } from "../../orchestration-v2/Adapters/PiRpc.ts"; | ||
| 38 | import { | ||
| 39 | buildServerProvider, | ||
| 40 | isCommandMissingCause, | ||
| 41 | parseGenericCliVersion, | ||
| 42 | providerModelsFromSettings, | ||
| 43 | spawnAndCollect, | ||
| 44 | type ServerProviderDraft, | ||
| 45 | } from "../providerSnapshot.ts"; | ||
| 46 | import { | ||
| 47 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 48 | type ProviderMaintenanceCapabilities, | ||
| 49 | } from "../providerMaintenance.ts"; | ||
| 50 | import { | ||
| 51 | EMPTY_PI_MODEL_CAPABILITIES, | ||
| 52 | thinkingCapabilitiesForPiModel, | ||
| 53 | } from "./piThinkingCapabilities.ts"; | ||
| 54 | import { | ||
| 55 | parsePiDiscoveredCommands, | ||
| 56 | withPiBuiltinSlashCommands, | ||
| 57 | type PiDiscoveredCommands, | ||
| 58 | } from "../PiCommands.ts"; | ||
| 59 | |||
| 60 | const PI_PRESENTATION = { | ||
| 61 | displayName: "Pi", | ||
| 62 | showInteractionModeToggle: false, | ||
| 63 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], | ||
| 64 | // The adapter reports context usage from Pi's streaming usage while a | ||
| 65 | // turn runs, so clients can reserve the meter before the first settle. | ||
| 66 | reportsContextWindow: true, | ||
| 67 | requiresNewThreadForModelChange: false, | ||
| 68 | } as const; | ||
| 69 | |||
| 70 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 71 | const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; | ||
| 72 | /** | ||
| 73 | * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. | ||
| 74 | * Version 0.80.5 was the first published package containing both hooks. T3 | ||
| 75 | * needs them for rollback boundaries and reliable turn terminalization. | ||
| 76 | */ | ||
| 77 | export const MINIMUM_PI_VERSION = "0.80.5"; | ||
| 78 | |||
| 79 | /** Deferring to the user's own settings.json default model. */ | ||
| 80 | const PI_DEFAULT_MODEL: ServerProviderModel = { | ||
| 81 | slug: "default", | ||
| 82 | name: "Pi default", | ||
| 83 | isCustom: false, | ||
| 84 | capabilities: EMPTY_PI_MODEL_CAPABILITIES, | ||
| 85 | }; | ||
| 86 | |||
| 87 | interface PiDiscovery extends PiDiscoveredCommands { | ||
| 88 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 89 | readonly authenticated: boolean; | ||
| 90 | } | ||
| 91 | |||
| 92 | function piModelsFromSettings( | ||
| 93 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | ||
| 94 | discovered: ReadonlyArray<ServerProviderModel> = [], | ||
| 95 | ): ReadonlyArray<ServerProviderModel> { | ||
| 96 | return providerModelsFromSettings( | ||
| 97 | [PI_DEFAULT_MODEL, ...discovered], | ||
| 98 | customModels ?? [], | ||
| 99 | EMPTY_PI_MODEL_CAPABILITIES, | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | function parseDiscoveredModels( | ||
| 104 | data: unknown, | ||
| 105 | defaultThinkingLevel: unknown, | ||
| 106 | ): ReadonlyArray<ServerProviderModel> { | ||
| 107 | const models = recordField(data, "models"); | ||
| 108 | if (!Array.isArray(models)) return []; | ||
| 109 | const seen = new Set<string>(); | ||
| 110 | const parsed: Array<ServerProviderModel> = []; | ||
| 111 | for (const model of models) { | ||
| 112 | const provider = recordString(model, "provider"); | ||
| 113 | const id = recordString(model, "id"); | ||
| 114 | if (provider === undefined || id === undefined) continue; | ||
| 115 | const slug = `${provider}/${id}`; | ||
| 116 | if (seen.has(slug)) continue; | ||
| 117 | seen.add(slug); | ||
| 118 | parsed.push({ | ||
| 119 | slug, | ||
| 120 | name: recordString(model, "name") ?? slug, | ||
| 121 | isCustom: false, | ||
| 122 | capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | return parsed; | ||
| 126 | } | ||
| 127 | |||
| 128 | const discoverPiViaRpc = ( | ||
| 129 | piSettings: PiSettings, | ||
| 130 | environment: NodeJS.ProcessEnv, | ||
| 131 | launchArgs: ReadonlyArray<string>, | ||
| 132 | cwd?: string, | ||
| 133 | ) => | ||
| 134 | Effect.gen(function* () { | ||
| 135 | const launch = buildPiRpcLaunch({ | ||
| 136 | launchArgs, | ||
| 137 | environment, | ||
| 138 | mcpSession: undefined, | ||
| 139 | extensionPath: undefined, | ||
| 140 | ephemeral: true, | ||
| 141 | }); | ||
| 142 | const connection = yield* makePiRpcConnection({ | ||
| 143 | command: piSettings.binaryPath || "pi", | ||
| 144 | args: launch.args, | ||
| 145 | cwd, | ||
| 146 | env: launch.env, | ||
| 147 | }); | ||
| 148 | yield* Stream.fromQueue(connection.events).pipe( | ||
| 149 | Stream.runDrain, | ||
| 150 | Effect.ignore, | ||
| 151 | Effect.forkScoped, | ||
| 152 | ); | ||
| 153 | const stateData = yield* connection.request({ type: "get_state" }); | ||
| 154 | const modelsData = yield* connection.request({ type: "get_available_models" }); | ||
| 155 | const commandsData = yield* connection | ||
| 156 | .request({ type: "get_commands" }) | ||
| 157 | .pipe(Effect.orElseSucceed(() => undefined)); | ||
| 158 | const discoveredModels = parseDiscoveredModels( | ||
| 159 | modelsData, | ||
| 160 | recordString(stateData, "thinkingLevel"), | ||
| 161 | ); | ||
| 162 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | ||
| 163 | return { | ||
| 164 | models: discoveredModels, | ||
| 165 | slashCommands: withPiBuiltinSlashCommands(slashCommands), | ||
| 166 | skills, | ||
| 167 | authenticated: discoveredModels.length > 0, | ||
| 168 | } satisfies PiDiscovery; | ||
| 169 | }).pipe(Effect.scoped); | ||
| 170 | |||
| 171 | const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const command = piSettings.binaryPath || "pi"; | ||
| 174 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { | ||
| 175 | env: environment, | ||
| 176 | }); | ||
| 177 | return yield* spawnAndCollect( | ||
| 178 | command, | ||
| 179 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 180 | env: environment, | ||
| 181 | shell: spawnCommand.shell, | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | }); | ||
| 185 | |||
| 186 | export function buildInitialPiProviderSnapshot( | ||
| 187 | piSettings: PiSettings, | ||
| 188 | ): Effect.Effect<ServerProviderDraft> { | ||
| 189 | return Effect.gen(function* () { | ||
| 190 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 191 | const models = piModelsFromSettings(piSettings.customModels); | ||
| 192 | if (!piSettings.enabled) { | ||
| 193 | return buildServerProvider({ | ||
| 194 | presentation: PI_PRESENTATION, | ||
| 195 | enabled: false, | ||
| 196 | checkedAt, | ||
| 197 | models, | ||
| 198 | probe: { | ||
| 199 | installed: false, | ||
| 200 | version: null, | ||
| 201 | status: "warning", | ||
| 202 | auth: { status: "unknown" }, | ||
| 203 | message: "Pi is disabled in T3 Code settings.", | ||
| 204 | }, | ||
| 205 | }); | ||
| 206 | } | ||
| 207 | return buildServerProvider({ | ||
| 208 | presentation: PI_PRESENTATION, | ||
| 209 | enabled: true, | ||
| 210 | checkedAt, | ||
| 211 | models, | ||
| 212 | probe: { | ||
| 213 | installed: true, | ||
| 214 | version: null, | ||
| 215 | status: "warning", | ||
| 216 | auth: { status: "unknown" }, | ||
| 217 | message: "Checking Pi CLI availability...", | ||
| 218 | }, | ||
| 219 | }); | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | |||
| 223 | export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( | ||
| 224 | piSettings: PiSettings, | ||
| 225 | environment: NodeJS.ProcessEnv = process.env, | ||
| 226 | cwd?: string, | ||
| 227 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 228 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 229 | const fallbackModels = piModelsFromSettings(piSettings.customModels); | ||
| 230 | |||
| 231 | if (!piSettings.enabled) { | ||
| 232 | return buildServerProvider({ | ||
| 233 | presentation: PI_PRESENTATION, | ||
| 234 | enabled: false, | ||
| 235 | checkedAt, | ||
| 236 | models: fallbackModels, | ||
| 237 | probe: { | ||
| 238 | installed: false, | ||
| 239 | version: null, | ||
| 240 | status: "warning", | ||
| 241 | auth: { status: "unknown" }, | ||
| 242 | message: "Pi is disabled in T3 Code settings.", | ||
| 243 | }, | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | |||
| 247 | const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( | ||
| 248 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | ||
| 249 | Effect.result, | ||
| 250 | ); | ||
| 251 | |||
| 252 | if (Result.isFailure(versionResult)) { | ||
| 253 | const error = versionResult.failure; | ||
| 254 | yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); | ||
| 255 | return buildServerProvider({ | ||
| 256 | presentation: PI_PRESENTATION, | ||
| 257 | enabled: piSettings.enabled, | ||
| 258 | checkedAt, | ||
| 259 | models: fallbackModels, | ||
| 260 | probe: { | ||
| 261 | installed: !isCommandMissingCause(error), | ||
| 262 | version: null, | ||
| 263 | status: "error", | ||
| 264 | auth: { status: "unknown" }, | ||
| 265 | message: isCommandMissingCause(error) | ||
| 266 | ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." | ||
| 267 | : "Failed to execute Pi CLI health check.", | ||
| 268 | }, | ||
| 269 | }); | ||
| 270 | } | ||
| 271 | |||
| 272 | if (Option.isNone(versionResult.success)) { | ||
| 273 | return buildServerProvider({ | ||
| 274 | presentation: PI_PRESENTATION, | ||
| 275 | enabled: piSettings.enabled, | ||
| 276 | checkedAt, | ||
| 277 | models: fallbackModels, | ||
| 278 | probe: { | ||
| 279 | installed: true, | ||
| 280 | version: null, | ||
| 281 | status: "error", | ||
| 282 | auth: { status: "unknown" }, | ||
| 283 | message: "Pi CLI is installed but timed out while running `pi --version`.", | ||
| 284 | }, | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | |||
| 288 | const versionOutput = versionResult.success.value; | ||
| 289 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | ||
| 290 | if (versionOutput.code !== 0) { | ||
| 291 | return buildServerProvider({ | ||
| 292 | presentation: PI_PRESENTATION, | ||
| 293 | enabled: piSettings.enabled, | ||
| 294 | checkedAt, | ||
| 295 | models: fallbackModels, | ||
| 296 | probe: { | ||
| 297 | installed: true, | ||
| 298 | version, | ||
| 299 | status: "error", | ||
| 300 | auth: { status: "unknown" }, | ||
| 301 | message: "Pi CLI is installed but failed to run.", | ||
| 302 | }, | ||
| 303 | }); | ||
| 304 | } | ||
| 305 | |||
| 306 | if (version === null) { | ||
| 307 | return buildServerProvider({ | ||
| 308 | presentation: PI_PRESENTATION, | ||
| 309 | enabled: piSettings.enabled, | ||
| 310 | checkedAt, | ||
| 311 | models: fallbackModels, | ||
| 312 | probe: { | ||
| 313 | installed: true, | ||
| 314 | version: null, | ||
| 315 | status: "error", | ||
| 316 | auth: { status: "unknown" }, | ||
| 317 | message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, | ||
| 318 | }, | ||
| 319 | }); | ||
| 320 | } | ||
| 321 | |||
| 322 | if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { | ||
| 323 | return buildServerProvider({ | ||
| 324 | presentation: PI_PRESENTATION, | ||
| 325 | enabled: piSettings.enabled, | ||
| 326 | checkedAt, | ||
| 327 | models: fallbackModels, | ||
| 328 | probe: { | ||
| 329 | installed: true, | ||
| 330 | version, | ||
| 331 | status: "error", | ||
| 332 | auth: { status: "unknown" }, | ||
| 333 | message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, | ||
| 334 | }, | ||
| 335 | }); | ||
| 336 | } | ||
| 337 | |||
| 338 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | ||
| 339 | if (!resolvedLaunchArgs.ok) { | ||
| 340 | return buildServerProvider({ | ||
| 341 | presentation: PI_PRESENTATION, | ||
| 342 | enabled: piSettings.enabled, | ||
| 343 | checkedAt, | ||
| 344 | models: fallbackModels, | ||
| 345 | probe: { | ||
| 346 | installed: true, | ||
| 347 | version, | ||
| 348 | status: "error", | ||
| 349 | auth: { status: "unknown" }, | ||
| 350 | message: resolvedLaunchArgs.message, | ||
| 351 | }, | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | |||
| 355 | const discoveryExit = yield* discoverPiViaRpc( | ||
| 356 | piSettings, | ||
| 357 | environment, | ||
| 358 | resolvedLaunchArgs.args, | ||
| 359 | cwd, | ||
| 360 | ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); | ||
| 361 | if (Exit.isFailure(discoveryExit)) { | ||
| 362 | yield* Effect.logWarning("Pi RPC discovery failed.", { | ||
| 363 | errorTag: causeErrorTag(discoveryExit.cause), | ||
| 364 | }); | ||
| 365 | return buildServerProvider({ | ||
| 366 | presentation: PI_PRESENTATION, | ||
| 367 | enabled: piSettings.enabled, | ||
| 368 | checkedAt, | ||
| 369 | models: fallbackModels, | ||
| 370 | probe: { | ||
| 371 | installed: true, | ||
| 372 | version, | ||
| 373 | status: "ready", | ||
| 374 | auth: { status: "unknown" }, | ||
| 375 | message: | ||
| 376 | "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | } | ||
| 380 | if (Option.isNone(discoveryExit.value)) { | ||
| 381 | return buildServerProvider({ | ||
| 382 | presentation: PI_PRESENTATION, | ||
| 383 | enabled: piSettings.enabled, | ||
| 384 | checkedAt, | ||
| 385 | models: fallbackModels, | ||
| 386 | probe: { | ||
| 387 | installed: true, | ||
| 388 | version, | ||
| 389 | status: "ready", | ||
| 390 | auth: { status: "unknown" }, | ||
| 391 | message: | ||
| 392 | "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", | ||
| 393 | }, | ||
| 394 | }); | ||
| 395 | } | ||
| 396 | |||
| 397 | const discovery = discoveryExit.value.value; | ||
| 398 | const models = piModelsFromSettings(piSettings.customModels, discovery.models); | ||
| 399 | return buildServerProvider({ | ||
| 400 | presentation: PI_PRESENTATION, | ||
| 401 | enabled: piSettings.enabled, | ||
| 402 | checkedAt, | ||
| 403 | models, | ||
| 404 | slashCommands: discovery.slashCommands, | ||
| 405 | skills: discovery.skills, | ||
| 406 | probe: { | ||
| 407 | installed: true, | ||
| 408 | version, | ||
| 409 | status: discovery.authenticated ? "ready" : "warning", | ||
| 410 | auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, | ||
| 411 | ...(discovery.authenticated | ||
| 412 | ? {} | ||
| 413 | : { | ||
| 414 | message: | ||
| 415 | "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", | ||
| 416 | }), | ||
| 417 | }, | ||
| 418 | }); | ||
| 419 | }); | ||
| 420 | |||
| 421 | export const enrichPiSnapshot = (input: { | ||
| 422 | readonly snapshot: ServerProvider; | ||
| 423 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 424 | readonly enableProviderUpdateChecks?: boolean; | ||
| 425 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 426 | readonly httpClient: HttpClient.HttpClient; | ||
| 427 | }): Effect.Effect<void> => { | ||
| 428 | const { snapshot, publishSnapshot } = input; | ||
| 429 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | ||
| 430 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | ||
| 431 | }).pipe( | ||
| 432 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | ||
| 433 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | ||
| 434 | Effect.catchCause((cause) => | ||
| 435 | Effect.logWarning("Pi version advisory enrichment failed", { | ||
| 436 | errorTag: causeErrorTag(cause), | ||
| 437 | }), | ||
| 438 | ), | ||
| 439 | Effect.asVoid, | ||
| 440 | ); | ||
| 441 | }; | ||
apps/server/src/provider/Layers/ProviderAuthService.test.ts deleted-980| ... | @@ -1,980 +0,0 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ProviderSessionId, | ||
| 6 | ProviderSetupError, | ||
| 7 | ProviderThreadId, | ||
| 8 | ThreadId, | ||
| 9 | type OrchestrationV2ProviderSession, | ||
| 10 | type OrchestrationV2ThreadShell, | ||
| 11 | type ProviderAuthState, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Deferred from "effect/Deferred"; | ||
| 16 | import * as Fiber from "effect/Fiber"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as PubSub from "effect/PubSub"; | ||
| 19 | import * as Queue from "effect/Queue"; | ||
| 20 | import * as Stream from "effect/Stream"; | ||
| 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 22 | |||
| 23 | import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; | ||
| 24 | import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts"; | ||
| 25 | import { AcpProviderCapabilitiesV2 } from "../../orchestration-v2/Adapters/AcpAdapterV2.ts"; | ||
| 26 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 27 | import type { ProviderAuthController } from "../Services/ProviderAuthService.ts"; | ||
| 28 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 29 | import { makeProviderAuthService } from "./ProviderAuthService.ts"; | ||
| 30 | |||
| 31 | const instanceId = ProviderInstanceId.make("antigravity-personal"); | ||
| 32 | const otherInstanceId = ProviderInstanceId.make("antigravity-work"); | ||
| 33 | const unsupportedInstanceId = ProviderInstanceId.make("codex"); | ||
| 34 | const driverKind = ProviderDriverKind.make("antigravity"); | ||
| 35 | const owner = "paired-client-owner"; | ||
| 36 | const otherOwner = "paired-client-other"; | ||
| 37 | const flowId = "test-sign-in-flow"; | ||
| 38 | const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; | ||
| 39 | const now = "2026-09-02T00:00:00.000Z"; | ||
| 40 | const idleAuthState: ProviderAuthState = { | ||
| 41 | instanceId, | ||
| 42 | phase: "idle", | ||
| 43 | flowId: null, | ||
| 44 | authorizationUrl: null, | ||
| 45 | expiresAt: null, | ||
| 46 | message: null, | ||
| 47 | }; | ||
| 48 | const waitingAuthState: ProviderAuthState = { | ||
| 49 | ...idleAuthState, | ||
| 50 | phase: "waiting", | ||
| 51 | flowId, | ||
| 52 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", | ||
| 53 | expiresAt: "2026-09-02T00:05:00.000Z", | ||
| 54 | }; | ||
| 55 | |||
| 56 | function makeInstance(input: { | ||
| 57 | instanceId: ProviderInstanceId; | ||
| 58 | enabled: boolean; | ||
| 59 | auth?: ProviderAuthController; | ||
| 60 | }): ProviderInstance { | ||
| 61 | return { | ||
| 62 | ...input, | ||
| 63 | driverKind, | ||
| 64 | displayName: undefined, | ||
| 65 | continuationIdentity: { driverKind, continuationKey: input.instanceId }, | ||
| 66 | get snapshot(): never { | ||
| 67 | throw new Error("Auth routing must not refresh the provider snapshot."); | ||
| 68 | }, | ||
| 69 | get orchestrationAdapter(): never { | ||
| 70 | throw new Error("Auth routing must not start an adapter session."); | ||
| 71 | }, | ||
| 72 | get textGeneration(): never { | ||
| 73 | throw new Error("Auth routing must not generate text."); | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | |||
| 78 | const nowUtc = DateTime.makeUnsafe(now); | ||
| 79 | |||
| 80 | /** | ||
| 81 | * A thread bound to a provider instance with (or without) a live native | ||
| 82 | * thread. The auth service reads only the routing fields, so the rest of the | ||
| 83 | * shell row is left out. | ||
| 84 | */ | ||
| 85 | function makeThread( | ||
| 86 | thread: string, | ||
| 87 | input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {}, | ||
| 88 | ): OrchestrationV2ThreadShell { | ||
| 89 | const shell: Pick< | ||
| 90 | OrchestrationV2ThreadShell, | ||
| 91 | "id" | "providerInstanceId" | "activeProviderThreadId" | ||
| 92 | > = { | ||
| 93 | id: ThreadId.make(thread), | ||
| 94 | providerInstanceId: input.providerInstanceId ?? instanceId, | ||
| 95 | activeProviderThreadId: | ||
| 96 | input.active === false ? null : ProviderThreadId.make(`${thread}:native`), | ||
| 97 | }; | ||
| 98 | return shell as unknown as OrchestrationV2ThreadShell; | ||
| 99 | } | ||
| 100 | |||
| 101 | function makeSession( | ||
| 102 | session: string, | ||
| 103 | status: OrchestrationV2ProviderSession["status"] = "ready", | ||
| 104 | providerInstanceId = instanceId, | ||
| 105 | ): OrchestrationV2ProviderSession { | ||
| 106 | return { | ||
| 107 | id: ProviderSessionId.make(session), | ||
| 108 | driver: driverKind, | ||
| 109 | providerInstanceId, | ||
| 110 | status, | ||
| 111 | cwd: "/workspace", | ||
| 112 | model: null, | ||
| 113 | capabilities: AcpProviderCapabilitiesV2, | ||
| 114 | createdAt: nowUtc, | ||
| 115 | updatedAt: nowUtc, | ||
| 116 | lastError: null, | ||
| 117 | }; | ||
| 118 | } | ||
| 119 | |||
| 120 | function sessionThreads(list: ReadonlyArray<OrchestrationV2ProviderSession>) { | ||
| 121 | return { | ||
| 122 | threads: list.map((session) => makeThread(session.id)), | ||
| 123 | sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])), | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* ( | ||
| 128 | input: { | ||
| 129 | enabled?: boolean; | ||
| 130 | threads?: ReadonlyArray<OrchestrationV2ThreadShell>; | ||
| 131 | sessions?: ReadonlyMap<ThreadId, ReadonlyArray<OrchestrationV2ProviderSession>>; | ||
| 132 | shellError?: boolean; | ||
| 133 | stopError?: boolean; | ||
| 134 | logoutError?: ProviderSetupError; | ||
| 135 | sharedCredentials?: boolean; | ||
| 136 | sharedBusy?: boolean; | ||
| 137 | sharedBusyEffect?: Effect.Effect<boolean>; | ||
| 138 | responds?: boolean; | ||
| 139 | beforeLogout?: Effect.Effect<void>; | ||
| 140 | beforeStop?: Effect.Effect<void>; | ||
| 141 | beforeListSessions?: Effect.Effect<void>; | ||
| 142 | onLookup?: Effect.Effect<void>; | ||
| 143 | onListThreads?: (instances: ProviderInstance[]) => void; | ||
| 144 | } = {}, | ||
| 145 | ) { | ||
| 146 | const actions: string[] = []; | ||
| 147 | const registryChanges = yield* PubSub.unbounded<void>(); | ||
| 148 | const threads = input.threads ?? []; | ||
| 149 | const sessions = new Map( | ||
| 150 | [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]), | ||
| 151 | ); | ||
| 152 | const released: string[] = []; | ||
| 153 | const idle = idleAuthState; | ||
| 154 | let state = idle; | ||
| 155 | let flowOwner: string | undefined; | ||
| 156 | let gateClosed = false; | ||
| 157 | |||
| 158 | const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* ( | ||
| 159 | ownerSessionId: string, | ||
| 160 | requestedFlowId: string, | ||
| 161 | operation: string, | ||
| 162 | ) { | ||
| 163 | if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) { | ||
| 164 | return yield* new ProviderSetupError({ | ||
| 165 | instanceId, | ||
| 166 | operation, | ||
| 167 | detail: "This sign-in belongs to another client or has expired.", | ||
| 168 | }); | ||
| 169 | } | ||
| 170 | }); | ||
| 171 | |||
| 172 | const auth: ProviderAuthController = { | ||
| 173 | ...(input.sharedCredentials | ||
| 174 | ? { credentialBinding: { owner: "provider" as const, key: "shared" } } | ||
| 175 | : {}), | ||
| 176 | ...(input.responds | ||
| 177 | ? { | ||
| 178 | respond: Effect.fn(function* (ownerSessionId, request) { | ||
| 179 | yield* checkOwner(ownerSessionId, request.flowId, "respond"); | ||
| 180 | actions.push(`respond:${request.response.type}`); | ||
| 181 | return state; | ||
| 182 | }), | ||
| 183 | } | ||
| 184 | : {}), | ||
| 185 | start: Effect.fn(function* (ownerSessionId, stopSessions) { | ||
| 186 | gateClosed = true; | ||
| 187 | actions.push("close-gate"); | ||
| 188 | yield* stopSessions ?? Effect.void; | ||
| 189 | flowOwner = ownerSessionId; | ||
| 190 | state = waitingAuthState; | ||
| 191 | actions.push("start-sign-in"); | ||
| 192 | return state; | ||
| 193 | }), | ||
| 194 | complete: Effect.fn(function* (ownerSessionId, request) { | ||
| 195 | yield* checkOwner(ownerSessionId, request.flowId, "complete"); | ||
| 196 | if (request.callbackUrl !== callbackUrl) { | ||
| 197 | return yield* new ProviderSetupError({ | ||
| 198 | instanceId, | ||
| 199 | operation: "complete", | ||
| 200 | detail: "The redirect URL does not match this sign-in.", | ||
| 201 | }); | ||
| 202 | } | ||
| 203 | state = { ...idle, flowId, phase: "succeeded" }; | ||
| 204 | return state; | ||
| 205 | }), | ||
| 206 | cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) { | ||
| 207 | yield* checkOwner(ownerSessionId, requestedFlowId, "cancel"); | ||
| 208 | state = { ...idle, flowId, phase: "cancelled" }; | ||
| 209 | return state; | ||
| 210 | }), | ||
| 211 | logout: Effect.fn(function* (stopSessions) { | ||
| 212 | gateClosed = true; | ||
| 213 | actions.push("close-gate"); | ||
| 214 | yield* stopSessions; | ||
| 215 | if (input.logoutError) return yield* input.logoutError; | ||
| 216 | yield* input.beforeLogout ?? Effect.void; | ||
| 217 | actions.push("native-logout"); | ||
| 218 | state = idle; | ||
| 219 | flowOwner = undefined; | ||
| 220 | return state; | ||
| 221 | }), | ||
| 222 | subscribe: (ownerSessionId) => | ||
| 223 | Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))), | ||
| 224 | isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", | ||
| 225 | }; | ||
| 226 | const instances = [ | ||
| 227 | makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), | ||
| 228 | makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), | ||
| 229 | ...(input.sharedCredentials | ||
| 230 | ? [ | ||
| 231 | makeInstance({ | ||
| 232 | instanceId: otherInstanceId, | ||
| 233 | enabled: true, | ||
| 234 | auth: { | ||
| 235 | ...auth, | ||
| 236 | isChangingCredentials: | ||
| 237 | input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), | ||
| 238 | invalidate: Effect.sync(() => { | ||
| 239 | actions.push("invalidate-shared"); | ||
| 240 | }), | ||
| 241 | }, | ||
| 242 | }), | ||
| 243 | ] | ||
| 244 | : []), | ||
| 245 | ]; | ||
| 246 | const service = yield* makeProviderAuthService.pipe( | ||
| 247 | Effect.provide( | ||
| 248 | Layer.mergeAll( | ||
| 249 | Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ | ||
| 250 | getInstance: (id) => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | const found = instances.find((instance) => instance.instanceId === id); | ||
| 253 | yield* input.onLookup ?? Effect.void; | ||
| 254 | return found; | ||
| 255 | }), | ||
| 256 | listInstances: Effect.succeed(instances), | ||
| 257 | subscribeChanges: PubSub.subscribe(registryChanges), | ||
| 258 | }), | ||
| 259 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | ||
| 260 | getRecoveryThreadIds: () => | ||
| 261 | Effect.gen(function* () { | ||
| 262 | assert.isTrue(gateClosed); | ||
| 263 | actions.push("list-threads"); | ||
| 264 | input.onListThreads?.(instances); | ||
| 265 | yield* input.beforeListSessions ?? Effect.void; | ||
| 266 | return yield* input.shellError | ||
| 267 | ? Effect.fail( | ||
| 268 | new ProjectionStore.ProjectionStoreReadError({ | ||
| 269 | threadId: ThreadId.make("shell"), | ||
| 270 | cause: new Error("private database diagnostics"), | ||
| 271 | }), | ||
| 272 | ) | ||
| 273 | : Effect.succeed(threads.map((thread) => thread.id)); | ||
| 274 | }), | ||
| 275 | getThreadRecords: (threadId) => | ||
| 276 | Effect.sync(() => { | ||
| 277 | actions.push(`sessions:${threadId}`); | ||
| 278 | return { | ||
| 279 | providerSessions: sessions.get(threadId) ?? [], | ||
| 280 | } as never; | ||
| 281 | }), | ||
| 282 | }), | ||
| 283 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ | ||
| 284 | release: ({ providerSessionId, reason }) => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | assert.isTrue(gateClosed); | ||
| 287 | actions.push(`stop:${providerSessionId}`); | ||
| 288 | yield* input.beforeStop ?? Effect.void; | ||
| 289 | if (input.stopError) { | ||
| 290 | return yield* Effect.fail( | ||
| 291 | new ProviderSessionManager.ProviderSessionReleaseError({ | ||
| 292 | providerSessionId, | ||
| 293 | reason, | ||
| 294 | cause: new Error("private process diagnostics"), | ||
| 295 | }), | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | released.push(providerSessionId); | ||
| 299 | for (const [threadId, list] of sessions) { | ||
| 300 | const remaining = list.filter((session) => session.id !== providerSessionId); | ||
| 301 | if (remaining.length === 0) sessions.delete(threadId); | ||
| 302 | else sessions.set(threadId, remaining); | ||
| 303 | } | ||
| 304 | }), | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | return { | ||
| 310 | service, | ||
| 311 | actions, | ||
| 312 | released, | ||
| 313 | sessions, | ||
| 314 | auth, | ||
| 315 | addInstance: (instance: ProviderInstance) => instances.push(instance), | ||
| 316 | replaceInstance: (replacement: ProviderInstance) => { | ||
| 317 | const index = instances.findIndex( | ||
| 318 | (instance) => instance.instanceId === replacement.instanceId, | ||
| 319 | ); | ||
| 320 | assert.isAtLeast(index, 0); | ||
| 321 | instances[index] = replacement; | ||
| 322 | }, | ||
| 323 | replaceAuth: (next: ProviderAuthController) => { | ||
| 324 | instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); | ||
| 325 | }, | ||
| 326 | }; | ||
| 327 | }); | ||
| 328 | |||
| 329 | const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( | ||
| 330 | function* (flowOwner: string) { | ||
| 331 | const state = yield* SubscriptionRef.make(idleAuthState); | ||
| 332 | const close = yield* Deferred.make<void>(); | ||
| 333 | const closedSubscriptions = yield* Queue.unbounded<string>(); | ||
| 334 | const unused = () => Effect.die("Unexpected auth operation in a subscription test."); | ||
| 335 | const auth: ProviderAuthController = { | ||
| 336 | start: unused, | ||
| 337 | complete: unused, | ||
| 338 | cancel: unused, | ||
| 339 | logout: unused, | ||
| 340 | subscribe: (ownerSessionId) => | ||
| 341 | SubscriptionRef.changes(state).pipe( | ||
| 342 | Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)), | ||
| 343 | Stream.interruptWhen(Deferred.await(close)), | ||
| 344 | Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)), | ||
| 345 | ), | ||
| 346 | }; | ||
| 347 | return { | ||
| 348 | auth, | ||
| 349 | state, | ||
| 350 | closedSubscriptions, | ||
| 351 | close: Deferred.succeed(close, undefined), | ||
| 352 | }; | ||
| 353 | }, | ||
| 354 | ); | ||
| 355 | |||
| 356 | const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")( | ||
| 357 | function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) { | ||
| 358 | const changes = yield* PubSub.unbounded<void>(); | ||
| 359 | let current: ProviderInstance | undefined = initial; | ||
| 360 | let pendingReplacement = replaceDuringFirstLookup; | ||
| 361 | let subscribed = false; | ||
| 362 | const service = yield* makeProviderAuthService.pipe( | ||
| 363 | Effect.provide( | ||
| 364 | Layer.mergeAll( | ||
| 365 | Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ | ||
| 366 | subscribeChanges: Effect.gen(function* () { | ||
| 367 | const subscription = yield* PubSub.subscribe(changes); | ||
| 368 | subscribed = true; | ||
| 369 | return subscription; | ||
| 370 | }), | ||
| 371 | getInstance: () => | ||
| 372 | Effect.gen(function* () { | ||
| 373 | const instance = current; | ||
| 374 | if (pendingReplacement) { | ||
| 375 | assert.isTrue(subscribed, "Registry changes must be subscribed before lookup."); | ||
| 376 | current = pendingReplacement; | ||
| 377 | pendingReplacement = undefined; | ||
| 378 | yield* PubSub.publish(changes, undefined); | ||
| 379 | } | ||
| 380 | return instance; | ||
| 381 | }), | ||
| 382 | }), | ||
| 383 | Layer.mock(ProjectionStore.ProjectionStoreV2)({}), | ||
| 384 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), | ||
| 385 | ), | ||
| 386 | ), | ||
| 387 | ); | ||
| 388 | return { | ||
| 389 | service, | ||
| 390 | replace: Effect.fn(function* (replacement: ProviderInstance | undefined) { | ||
| 391 | current = replacement; | ||
| 392 | yield* PubSub.publish(changes, undefined); | ||
| 393 | }), | ||
| 394 | }; | ||
| 395 | }, | ||
| 396 | ); | ||
| 397 | |||
| 398 | const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* ( | ||
| 399 | stream: Stream.Stream<ProviderAuthState, ProviderSetupError>, | ||
| 400 | ) { | ||
| 401 | const states = yield* Queue.unbounded<ProviderAuthState>(); | ||
| 402 | const fiber = yield* stream.pipe( | ||
| 403 | Stream.runForEach((state) => Queue.offer(states, state)), | ||
| 404 | Effect.forkScoped, | ||
| 405 | ); | ||
| 406 | return { states, fiber }; | ||
| 407 | }); | ||
| 408 | |||
| 409 | describe("ProviderAuthService", () => { | ||
| 410 | it.effect( | ||
| 411 | "stops sessions sharing credentials and invalidates their processes before logout", | ||
| 412 | () => | ||
| 413 | Effect.gen(function* () { | ||
| 414 | const { service, actions, sessions } = yield* makeHarness({ | ||
| 415 | sharedCredentials: true, | ||
| 416 | ...sessionThreads([ | ||
| 417 | makeSession("target"), | ||
| 418 | makeSession("shared", "ready", otherInstanceId), | ||
| 419 | makeSession("unrelated", "ready", unsupportedInstanceId), | ||
| 420 | ]), | ||
| 421 | }); | ||
| 422 | yield* service.logout({ instanceId }); | ||
| 423 | assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); | ||
| 424 | assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); | ||
| 425 | assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); | ||
| 426 | }), | ||
| 427 | ); | ||
| 428 | it.effect("rejects overlapping changes to a shared sign-in", () => | ||
| 429 | Effect.gen(function* () { | ||
| 430 | const { service, actions } = yield* makeHarness({ | ||
| 431 | sharedCredentials: true, | ||
| 432 | sharedBusy: true, | ||
| 433 | }); | ||
| 434 | for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { | ||
| 435 | const error = yield* task.pipe(Effect.flip); | ||
| 436 | assert.include(error.detail, "shared sign-in"); | ||
| 437 | } | ||
| 438 | assert.deepStrictEqual(actions, []); | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => | ||
| 442 | Effect.gen(function* () { | ||
| 443 | const { service, actions } = yield* makeHarness({ responds: true }); | ||
| 444 | yield* service.start({ instanceId }, owner); | ||
| 445 | const request = { | ||
| 446 | instanceId, | ||
| 447 | flowId, | ||
| 448 | interactionId: "consent", | ||
| 449 | response: { type: "browser" as const, action: "accept" as const }, | ||
| 450 | }; | ||
| 451 | const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); | ||
| 452 | assert.strictEqual(rejected.operation, "respond"); | ||
| 453 | yield* service.respond(request, owner); | ||
| 454 | assert.strictEqual(actions.at(-1), "respond:browser"); | ||
| 455 | const unsupported = yield* makeHarness(); | ||
| 456 | const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); | ||
| 457 | assert.include(error.detail, "does not accept"); | ||
| 458 | }), | ||
| 459 | ); | ||
| 460 | it.effect("stops routed sessions before sign-in, including for a disabled instance", () => | ||
| 461 | Effect.gen(function* () { | ||
| 462 | const { service, actions, released } = yield* makeHarness({ | ||
| 463 | enabled: false, | ||
| 464 | threads: [makeThread("active")], | ||
| 465 | sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), | ||
| 466 | }); | ||
| 467 | const state = yield* service.start({ instanceId }, owner); | ||
| 468 | |||
| 469 | assert.strictEqual(state.instanceId, instanceId); | ||
| 470 | assert.strictEqual(state.phase, "waiting"); | ||
| 471 | assert.deepStrictEqual(released, ["active-session"]); | ||
| 472 | assert.deepStrictEqual(actions, [ | ||
| 473 | "close-gate", | ||
| 474 | "list-threads", | ||
| 475 | "sessions:active", | ||
| 476 | "stop:active-session", | ||
| 477 | "start-sign-in", | ||
| 478 | ]); | ||
| 479 | }), | ||
| 480 | ); | ||
| 481 | |||
| 482 | it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => | ||
| 483 | Effect.gen(function* () { | ||
| 484 | const { service } = yield* makeHarness(); | ||
| 485 | const waiting = yield* service.start({ instanceId }, owner); | ||
| 486 | const ownerStates = yield* service | ||
| 487 | .subscribe({ instanceId }, owner) | ||
| 488 | .pipe(Stream.take(1), Stream.runCollect); | ||
| 489 | const otherStates = yield* service | ||
| 490 | .subscribe({ instanceId }, otherOwner) | ||
| 491 | .pipe(Stream.take(1), Stream.runCollect); | ||
| 492 | |||
| 493 | assert.deepStrictEqual(ownerStates, [waiting]); | ||
| 494 | assert.strictEqual(otherStates[0]?.authorizationUrl, null); | ||
| 495 | assert.strictEqual(otherStates[0]?.flowId, null); | ||
| 496 | |||
| 497 | const otherError = yield* Effect.flip( | ||
| 498 | service.complete({ instanceId, flowId, callbackUrl }, otherOwner), | ||
| 499 | ); | ||
| 500 | assert.strictEqual(otherError.operation, "complete"); | ||
| 501 | const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner); | ||
| 502 | assert.strictEqual(complete.phase, "succeeded"); | ||
| 503 | assert.strictEqual(complete.authorizationUrl, null); | ||
| 504 | }), | ||
| 505 | ); | ||
| 506 | |||
| 507 | it.effect("lets only the flow owner cancel sign-in", () => | ||
| 508 | Effect.gen(function* () { | ||
| 509 | const { service } = yield* makeHarness(); | ||
| 510 | yield* service.start({ instanceId }, owner); | ||
| 511 | const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner)); | ||
| 512 | assert.strictEqual(error.operation, "cancel"); | ||
| 513 | |||
| 514 | const cancelled = yield* service.cancel({ instanceId, flowId }, owner); | ||
| 515 | assert.strictEqual(cancelled.phase, "cancelled"); | ||
| 516 | assert.strictEqual(cancelled.authorizationUrl, null); | ||
| 517 | }), | ||
| 518 | ); | ||
| 519 | |||
| 520 | it.effect.each([ | ||
| 521 | { change: "enable", initialEnabled: false, closeBeforeReplacement: true }, | ||
| 522 | { change: "config", initialEnabled: true, closeBeforeReplacement: false }, | ||
| 523 | ])( | ||
| 524 | "keeps private auth subscriptions current after an instance $change change", | ||
| 525 | ({ initialEnabled, closeBeforeReplacement }) => | ||
| 526 | Effect.gen(function* () { | ||
| 527 | const first = yield* makeStreamingController(owner); | ||
| 528 | const replacement = yield* makeStreamingController(otherOwner); | ||
| 529 | yield* SubscriptionRef.set(first.state, waitingAuthState); | ||
| 530 | const { service, replace } = yield* makeSubscriptionHarness( | ||
| 531 | makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }), | ||
| 532 | ); | ||
| 533 | const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner)); | ||
| 534 | const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner)); | ||
| 535 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState); | ||
| 536 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); | ||
| 537 | |||
| 538 | if (closeBeforeReplacement) { | ||
| 539 | yield* first.close; | ||
| 540 | const closed = yield* Queue.takeN(first.closedSubscriptions, 2); | ||
| 541 | assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); | ||
| 542 | } | ||
| 543 | yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth })); | ||
| 544 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); | ||
| 545 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); | ||
| 546 | if (!closeBeforeReplacement) { | ||
| 547 | const closed = yield* Queue.takeN(first.closedSubscriptions, 2); | ||
| 548 | assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); | ||
| 549 | } | ||
| 550 | |||
| 551 | const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" }; | ||
| 552 | yield* SubscriptionRef.set(first.state, staleState); | ||
| 553 | const newWaiting: ProviderAuthState = { | ||
| 554 | ...waitingAuthState, | ||
| 555 | flowId: "replacement-flow", | ||
| 556 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state", | ||
| 557 | }; | ||
| 558 | yield* SubscriptionRef.set(replacement.state, newWaiting); | ||
| 559 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); | ||
| 560 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting); | ||
| 561 | }), | ||
| 562 | ); | ||
| 563 | |||
| 564 | it.effect("does not miss a replacement during the first instance lookup", () => | ||
| 565 | Effect.gen(function* () { | ||
| 566 | const first = yield* makeStreamingController(owner); | ||
| 567 | const replacement = yield* makeStreamingController(owner); | ||
| 568 | yield* SubscriptionRef.set(replacement.state, waitingAuthState); | ||
| 569 | const { service } = yield* makeSubscriptionHarness( | ||
| 570 | makeInstance({ instanceId, enabled: false, auth: first.auth }), | ||
| 571 | makeInstance({ instanceId, enabled: true, auth: replacement.auth }), | ||
| 572 | ); | ||
| 573 | |||
| 574 | const states = yield* service.subscribe({ instanceId }, owner).pipe( | ||
| 575 | Stream.filter((state) => state.phase === "waiting"), | ||
| 576 | Stream.take(1), | ||
| 577 | Stream.runCollect, | ||
| 578 | ); | ||
| 579 | |||
| 580 | assert.deepStrictEqual(states, [waitingAuthState]); | ||
| 581 | }), | ||
| 582 | ); | ||
| 583 | |||
| 584 | it.effect("ends the subscription with a setup error when the instance is removed", () => | ||
| 585 | Effect.gen(function* () { | ||
| 586 | const controller = yield* makeStreamingController(owner); | ||
| 587 | const { service, replace } = yield* makeSubscriptionHarness( | ||
| 588 | makeInstance({ instanceId, enabled: true, auth: controller.auth }), | ||
| 589 | ); | ||
| 590 | const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner)); | ||
| 591 | assert.deepStrictEqual(yield* Queue.take(states), idleAuthState); | ||
| 592 | |||
| 593 | yield* replace(undefined); | ||
| 594 | const error = yield* Effect.flip(Fiber.join(fiber)); | ||
| 595 | |||
| 596 | assert.instanceOf(error, ProviderSetupError); | ||
| 597 | assert.strictEqual(error.instanceId, instanceId); | ||
| 598 | assert.strictEqual(error.operation, "subscribe"); | ||
| 599 | assert.include(error.detail, "no longer available"); | ||
| 600 | assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner); | ||
| 601 | }), | ||
| 602 | ); | ||
| 603 | |||
| 604 | it.effect.each([ | ||
| 605 | { id: ProviderInstanceId.make("missing"), detail: "no longer available" }, | ||
| 606 | { id: unsupportedInstanceId, detail: "does not support sign-in" }, | ||
| 607 | ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) => | ||
| 608 | Effect.gen(function* () { | ||
| 609 | const { service, actions } = yield* makeHarness(); | ||
| 610 | const operations: Effect.Effect< | ||
| 611 | ProviderAuthState | ReadonlyArray<ProviderAuthState>, | ||
| 612 | ProviderSetupError | ||
| 613 | >[] = [ | ||
| 614 | service.start({ instanceId: id }, owner), | ||
| 615 | service.complete({ instanceId: id, flowId, callbackUrl }, owner), | ||
| 616 | service.cancel({ instanceId: id, flowId }, owner), | ||
| 617 | service.logout({ instanceId: id }), | ||
| 618 | Stream.runCollect(service.subscribe({ instanceId: id }, owner)), | ||
| 619 | ]; | ||
| 620 | for (const operation of operations) { | ||
| 621 | const error = yield* Effect.flip(operation); | ||
| 622 | assert.instanceOf(error, ProviderSetupError); | ||
| 623 | assert.strictEqual(error.instanceId, id); | ||
| 624 | assert.include(error.detail, detail); | ||
| 625 | } | ||
| 626 | assert.deepStrictEqual(actions, []); | ||
| 627 | }), | ||
| 628 | ); | ||
| 629 | |||
| 630 | it.effect("stops every live session on the instance before native logout", () => | ||
| 631 | Effect.gen(function* () { | ||
| 632 | const { service, actions, released } = yield* makeHarness({ | ||
| 633 | threads: [ | ||
| 634 | makeThread("running"), | ||
| 635 | makeThread("starting"), | ||
| 636 | makeThread("idle", { active: false }), | ||
| 637 | makeThread("other-instance", { providerInstanceId: otherInstanceId }), | ||
| 638 | ], | ||
| 639 | sessions: new Map([ | ||
| 640 | [ | ||
| 641 | ThreadId.make("running"), | ||
| 642 | [ | ||
| 643 | makeSession("running-session", "running"), | ||
| 644 | makeSession("stopped-session", "stopped"), | ||
| 645 | makeSession("errored-session", "error"), | ||
| 646 | makeSession("foreign-session", "ready", otherInstanceId), | ||
| 647 | ], | ||
| 648 | ], | ||
| 649 | [ThreadId.make("starting"), [makeSession("starting-session", "starting")]], | ||
| 650 | [ThreadId.make("idle"), [makeSession("idle-session")]], | ||
| 651 | [ | ||
| 652 | ThreadId.make("other-instance"), | ||
| 653 | [makeSession("other-session", "ready", otherInstanceId)], | ||
| 654 | ], | ||
| 655 | ]), | ||
| 656 | }); | ||
| 657 | const state = yield* service.logout({ instanceId }); | ||
| 658 | |||
| 659 | assert.strictEqual(state.phase, "idle"); | ||
| 660 | assert.deepStrictEqual(actions, [ | ||
| 661 | "close-gate", | ||
| 662 | "list-threads", | ||
| 663 | "sessions:running", | ||
| 664 | "stop:running-session", | ||
| 665 | "sessions:starting", | ||
| 666 | "stop:starting-session", | ||
| 667 | "sessions:idle", | ||
| 668 | "stop:idle-session", | ||
| 669 | "sessions:other-instance", | ||
| 670 | "native-logout", | ||
| 671 | ]); | ||
| 672 | assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]); | ||
| 673 | }), | ||
| 674 | ); | ||
| 675 | |||
| 676 | it.effect( | ||
| 677 | "releases a shared native session once after a thread's selected provider changes", | ||
| 678 | () => | ||
| 679 | Effect.gen(function* () { | ||
| 680 | const shared = makeSession("shared-native-session"); | ||
| 681 | const { service, released } = yield* makeHarness({ | ||
| 682 | threads: [ | ||
| 683 | makeThread("changed-selection", { providerInstanceId: otherInstanceId }), | ||
| 684 | makeThread("same-session"), | ||
| 685 | ], | ||
| 686 | sessions: new Map([ | ||
| 687 | [ThreadId.make("changed-selection"), [shared]], | ||
| 688 | [ThreadId.make("same-session"), [shared]], | ||
| 689 | ]), | ||
| 690 | }); | ||
| 691 | |||
| 692 | yield* service.logout({ instanceId }); | ||
| 693 | |||
| 694 | assert.deepStrictEqual(released, [shared.id]); | ||
| 695 | }), | ||
| 696 | ); | ||
| 697 | |||
| 698 | it.effect.each([ | ||
| 699 | { text: "/logout", hasAttachments: false, handled: true }, | ||
| 700 | { text: " \n/logout\t", hasAttachments: false, handled: true }, | ||
| 701 | { text: "/logout", hasAttachments: true, handled: false }, | ||
| 702 | { text: "/logout please", hasAttachments: false, handled: false }, | ||
| 703 | { text: "Explain /logout", hasAttachments: false, handled: false }, | ||
| 704 | { text: "/Logout", hasAttachments: false, handled: false }, | ||
| 705 | ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => | ||
| 706 | Effect.gen(function* () { | ||
| 707 | const { service, actions } = yield* makeHarness(); | ||
| 708 | const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); | ||
| 709 | |||
| 710 | assert.strictEqual(result, handled); | ||
| 711 | assert.deepStrictEqual( | ||
| 712 | actions, | ||
| 713 | handled ? ["close-gate", "list-threads", "native-logout"] : [], | ||
| 714 | ); | ||
| 715 | }), | ||
| 716 | ); | ||
| 717 | |||
| 718 | it.effect("does not intercept commands for providers without an auth controller", () => | ||
| 719 | Effect.gen(function* () { | ||
| 720 | const { service, actions } = yield* makeHarness(); | ||
| 721 | for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { | ||
| 722 | assert.isFalse( | ||
| 723 | yield* service.tryHandlePromptCommand({ | ||
| 724 | instanceId: id, | ||
| 725 | text: "/logout", | ||
| 726 | hasAttachments: false, | ||
| 727 | }), | ||
| 728 | ); | ||
| 729 | } | ||
| 730 | assert.deepStrictEqual(actions, []); | ||
| 731 | }), | ||
| 732 | ); | ||
| 733 | |||
| 734 | it.effect("does not log out when the thread shell cannot be read", () => | ||
| 735 | Effect.gen(function* () { | ||
| 736 | const { service, actions } = yield* makeHarness({ shellError: true }); | ||
| 737 | const error = yield* Effect.flip(service.logout({ instanceId })); | ||
| 738 | |||
| 739 | assert.instanceOf(error, ProviderSetupError); | ||
| 740 | assert.strictEqual(error.instanceId, instanceId); | ||
| 741 | assert.strictEqual(error.operation, "stopSessions"); | ||
| 742 | assert.notInclude(error.detail, "private database diagnostics"); | ||
| 743 | assert.deepStrictEqual(actions, ["close-gate", "list-threads"]); | ||
| 744 | }), | ||
| 745 | ); | ||
| 746 | |||
| 747 | it.effect("does not log out or consume the command when stopping a session fails", () => | ||
| 748 | Effect.gen(function* () { | ||
| 749 | const { service, actions, released } = yield* makeHarness({ | ||
| 750 | threads: [makeThread("active")], | ||
| 751 | sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), | ||
| 752 | stopError: true, | ||
| 753 | }); | ||
| 754 | const error = yield* Effect.flip( | ||
| 755 | service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), | ||
| 756 | ); | ||
| 757 | |||
| 758 | assert.instanceOf(error, ProviderSetupError); | ||
| 759 | assert.strictEqual(error.operation, "stopSessions"); | ||
| 760 | assert.notInclude(error.detail, "private process diagnostics"); | ||
| 761 | assert.deepStrictEqual(released, []); | ||
| 762 | assert.deepStrictEqual(actions, [ | ||
| 763 | "close-gate", | ||
| 764 | "list-threads", | ||
| 765 | "sessions:active", | ||
| 766 | "stop:active-session", | ||
| 767 | ]); | ||
| 768 | }), | ||
| 769 | ); | ||
| 770 | |||
| 771 | it.effect("returns native logout failures to the command caller", () => | ||
| 772 | Effect.gen(function* () { | ||
| 773 | const logoutError = new ProviderSetupError({ | ||
| 774 | instanceId, | ||
| 775 | operation: "logout", | ||
| 776 | detail: "Native sign-out failed. Try again.", | ||
| 777 | }); | ||
| 778 | const { service } = yield* makeHarness({ logoutError }); | ||
| 779 | const error = yield* Effect.flip( | ||
| 780 | service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), | ||
| 781 | ); | ||
| 782 | |||
| 783 | assert.strictEqual(error, logoutError); | ||
| 784 | }), | ||
| 785 | ); | ||
| 786 | }); | ||
| 787 | |||
| 788 | it.effect("queued logout prompts resolve the current controller after provider replacement", () => | ||
| 789 | Effect.gen(function* () { | ||
| 790 | const entered = yield* Deferred.make<void>(); | ||
| 791 | const release = yield* Deferred.make<void>(); | ||
| 792 | const lookup = yield* Deferred.make<void>(); | ||
| 793 | let observeLookup = false; | ||
| 794 | const harness = yield* makeHarness({ | ||
| 795 | beforeLogout: Deferred.succeed(entered, undefined).pipe( | ||
| 796 | Effect.andThen(Deferred.await(release)), | ||
| 797 | ), | ||
| 798 | onLookup: Effect.suspend(() => | ||
| 799 | observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, | ||
| 800 | ), | ||
| 801 | }); | ||
| 802 | const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 803 | yield* Deferred.await(entered); | ||
| 804 | observeLookup = true; | ||
| 805 | const queued = yield* harness.service | ||
| 806 | .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) | ||
| 807 | .pipe(Effect.forkChild); | ||
| 808 | yield* Deferred.await(lookup); | ||
| 809 | let replacementLoggedOut = false; | ||
| 810 | harness.replaceAuth({ | ||
| 811 | ...harness.auth, | ||
| 812 | logout: (stopSessions) => | ||
| 813 | stopSessions.pipe( | ||
| 814 | Effect.andThen( | ||
| 815 | Effect.sync(() => { | ||
| 816 | replacementLoggedOut = true; | ||
| 817 | return idleAuthState; | ||
| 818 | }), | ||
| 819 | ), | ||
| 820 | ), | ||
| 821 | }); | ||
| 822 | yield* Deferred.succeed(release, undefined); | ||
| 823 | yield* Fiber.join(first); | ||
| 824 | assert.isTrue(yield* Fiber.join(queued)); | ||
| 825 | assert.isTrue(replacementLoggedOut); | ||
| 826 | assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); | ||
| 827 | }).pipe(Effect.scoped), | ||
| 828 | ); | ||
| 829 | |||
| 830 | it.effect.each(["start", "logout", "prompt"] as const)( | ||
| 831 | "%s uses the same credential controller for shared exclusion and mutation during replacement", | ||
| 832 | (action) => | ||
| 833 | Effect.gen(function* () { | ||
| 834 | const checked = yield* Deferred.make<void>(); | ||
| 835 | const continueCheck = yield* Deferred.make<void>(); | ||
| 836 | const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); | ||
| 837 | const harness = yield* makeHarness({ | ||
| 838 | sharedCredentials: true, | ||
| 839 | sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( | ||
| 840 | Effect.andThen(Deferred.await(continueCheck)), | ||
| 841 | Effect.as(false), | ||
| 842 | ), | ||
| 843 | ...sessionThreads([ | ||
| 844 | makeSession("old-shared", "ready", otherInstanceId), | ||
| 845 | makeSession("replacement-shared", "ready", replacementPeerId), | ||
| 846 | ]), | ||
| 847 | }); | ||
| 848 | harness.addInstance( | ||
| 849 | makeInstance({ | ||
| 850 | instanceId: replacementPeerId, | ||
| 851 | enabled: true, | ||
| 852 | auth: { | ||
| 853 | ...harness.auth, | ||
| 854 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | ||
| 855 | isChangingCredentials: Effect.succeed(true), | ||
| 856 | invalidate: Effect.die( | ||
| 857 | "The unrelated replacement credential binding must stay intact.", | ||
| 858 | ), | ||
| 859 | }, | ||
| 860 | }), | ||
| 861 | ); | ||
| 862 | const operation = | ||
| 863 | action === "start" | ||
| 864 | ? harness.service.start({ instanceId }, owner) | ||
| 865 | : action === "logout" | ||
| 866 | ? harness.service.logout({ instanceId }) | ||
| 867 | : harness.service.tryHandlePromptCommand({ | ||
| 868 | instanceId, | ||
| 869 | text: "/logout", | ||
| 870 | hasAttachments: false, | ||
| 871 | }); | ||
| 872 | const running = yield* operation.pipe(Effect.forkChild); | ||
| 873 | yield* Deferred.await(checked); | ||
| 874 | let replacementMutations = 0; | ||
| 875 | harness.replaceAuth({ | ||
| 876 | ...harness.auth, | ||
| 877 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | ||
| 878 | start: () => | ||
| 879 | Effect.sync(() => { | ||
| 880 | replacementMutations++; | ||
| 881 | return waitingAuthState; | ||
| 882 | }), | ||
| 883 | logout: () => | ||
| 884 | Effect.sync(() => { | ||
| 885 | replacementMutations++; | ||
| 886 | return idleAuthState; | ||
| 887 | }), | ||
| 888 | }); | ||
| 889 | yield* Deferred.succeed(continueCheck, undefined); | ||
| 890 | yield* Fiber.join(running); | ||
| 891 | assert.equal(replacementMutations, 0); | ||
| 892 | assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); | ||
| 893 | assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); | ||
| 894 | assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); | ||
| 895 | const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); | ||
| 896 | assert.include(blocked.detail, "shared sign-in"); | ||
| 897 | assert.equal(replacementMutations, 0); | ||
| 898 | }).pipe(Effect.scoped), | ||
| 899 | ); | ||
| 900 | |||
| 901 | it.effect.each([ | ||
| 902 | { owner: "provider" as const, key: "different-binding" }, | ||
| 903 | { owner: "t3" as const, key: "shared" }, | ||
| 904 | ])( | ||
| 905 | "does not invalidate a peer that switches credential binding during session draining %#", | ||
| 906 | (binding) => | ||
| 907 | Effect.gen(function* () { | ||
| 908 | const draining = yield* Deferred.make<void>(); | ||
| 909 | const continueDrain = yield* Deferred.make<void>(); | ||
| 910 | const harness = yield* makeHarness({ | ||
| 911 | sharedCredentials: true, | ||
| 912 | ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), | ||
| 913 | beforeStop: Deferred.succeed(draining, undefined).pipe( | ||
| 914 | Effect.andThen(Deferred.await(continueDrain)), | ||
| 915 | ), | ||
| 916 | }); | ||
| 917 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 918 | yield* Deferred.await(draining); | ||
| 919 | let replacementInvalidated = false; | ||
| 920 | harness.replaceInstance( | ||
| 921 | makeInstance({ | ||
| 922 | instanceId: otherInstanceId, | ||
| 923 | enabled: true, | ||
| 924 | auth: { | ||
| 925 | ...harness.auth, | ||
| 926 | credentialBinding: binding, | ||
| 927 | invalidate: Effect.sync(() => { | ||
| 928 | replacementInvalidated = true; | ||
| 929 | }), | ||
| 930 | }, | ||
| 931 | }), | ||
| 932 | ); | ||
| 933 | yield* Deferred.succeed(continueDrain, undefined); | ||
| 934 | yield* Fiber.join(logout); | ||
| 935 | assert.isFalse(replacementInvalidated); | ||
| 936 | assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); | ||
| 937 | assert.include(harness.actions, "native-logout"); | ||
| 938 | }).pipe(Effect.scoped), | ||
| 939 | ); | ||
| 940 | |||
| 941 | it.effect.each(["selection", "drain"] as const)( | ||
| 942 | "preserves replacement peer sessions when credentials change during session %s", | ||
| 943 | (phase) => | ||
| 944 | Effect.gen(function* () { | ||
| 945 | const entered = yield* Deferred.make<void>(); | ||
| 946 | const proceed = yield* Deferred.make<void>(); | ||
| 947 | const block = Deferred.succeed(entered, undefined).pipe( | ||
| 948 | Effect.andThen(Deferred.await(proceed)), | ||
| 949 | ); | ||
| 950 | const harness = yield* makeHarness({ | ||
| 951 | sharedCredentials: true, | ||
| 952 | ...sessionThreads([ | ||
| 953 | makeSession("target-draining"), | ||
| 954 | makeSession("peer-replacement", "ready", otherInstanceId), | ||
| 955 | makeSession("peer-persisted", "running", otherInstanceId), | ||
| 956 | ]), | ||
| 957 | ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), | ||
| 958 | }); | ||
| 959 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 960 | yield* Deferred.await(entered); | ||
| 961 | harness.replaceInstance( | ||
| 962 | makeInstance({ | ||
| 963 | instanceId: otherInstanceId, | ||
| 964 | enabled: true, | ||
| 965 | auth: { | ||
| 966 | ...harness.auth, | ||
| 967 | credentialBinding: { owner: "provider", key: "replacement-credentials" }, | ||
| 968 | invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), | ||
| 969 | }, | ||
| 970 | }), | ||
| 971 | ); | ||
| 972 | yield* Deferred.succeed(proceed, undefined); | ||
| 973 | yield* Fiber.join(logout); | ||
| 974 | assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); | ||
| 975 | assert.notInclude(harness.actions, "stop:peer-replacement"); | ||
| 976 | assert.notInclude(harness.actions, "stop:peer-persisted"); | ||
| 977 | assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); | ||
| 978 | assert.include(harness.actions, "native-logout"); | ||
| 979 | }).pipe(Effect.scoped), | ||
| 980 | ); | ||
apps/server/src/provider/Layers/ProviderAuthService.ts deleted-253| ... | @@ -1,253 +0,0 @@ | ||
| 1 | import { | ||
| 2 | ProviderSetupError, | ||
| 3 | type ProviderInstanceId, | ||
| 4 | type ProviderSessionId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as Semaphore from "effect/Semaphore"; | ||
| 10 | |||
| 11 | import * as ProviderSessionManager from "../../orchestration-v2/ProviderSessionManager.ts"; | ||
| 12 | import * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; | ||
| 13 | import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; | ||
| 14 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 15 | |||
| 16 | export const makeProviderAuthService = Effect.gen(function* () { | ||
| 17 | const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; | ||
| 18 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 19 | const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | ||
| 20 | const credentialChanges = yield* Semaphore.make(1); | ||
| 21 | |||
| 22 | const getController = Effect.fn("ProviderAuthService.getController")(function* ( | ||
| 23 | instanceId: ProviderInstanceId, | ||
| 24 | operation: string, | ||
| 25 | ) { | ||
| 26 | const instance = yield* registry.getInstance(instanceId); | ||
| 27 | if (!instance?.auth) { | ||
| 28 | return yield* new ProviderSetupError({ | ||
| 29 | instanceId, | ||
| 30 | operation, | ||
| 31 | detail: instance | ||
| 32 | ? "This provider does not support sign-in in T3 Code." | ||
| 33 | : "This provider instance is no longer available.", | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | return instance.auth; | ||
| 37 | }); | ||
| 38 | |||
| 39 | // Native sessions may still belong to the previous provider after the | ||
| 40 | // selected model changes. Read session bindings, not the selected model, | ||
| 41 | // when invalidating credentials for sign-in or sign-out. | ||
| 42 | const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( | ||
| 43 | instanceId: ProviderInstanceId, | ||
| 44 | binding: ProviderAuthService.ProviderAuthController["credentialBinding"], | ||
| 45 | ) { | ||
| 46 | const failure = (detail: string) => | ||
| 47 | new ProviderSetupError({ instanceId, operation: "stopSessions", detail }); | ||
| 48 | const affectedIds = new Set([ | ||
| 49 | instanceId, | ||
| 50 | ...(binding === undefined | ||
| 51 | ? [] | ||
| 52 | : (yield* registry.listInstances) | ||
| 53 | .filter( | ||
| 54 | (instance) => | ||
| 55 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 56 | instance.auth.credentialBinding.owner === binding.owner, | ||
| 57 | ) | ||
| 58 | .map((instance) => instance.instanceId)), | ||
| 59 | ]); | ||
| 60 | const threadIds = yield* projections | ||
| 61 | .getRecoveryThreadIds("runtime") | ||
| 62 | .pipe( | ||
| 63 | Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")), | ||
| 64 | ); | ||
| 65 | const released = new Set<ProviderSessionId>(); | ||
| 66 | yield* Effect.forEach( | ||
| 67 | threadIds, | ||
| 68 | (threadId) => | ||
| 69 | projections.getThreadRecords(threadId, ["providerSessions"]).pipe( | ||
| 70 | Effect.flatMap((projection) => | ||
| 71 | Effect.forEach( | ||
| 72 | projection.providerSessions.filter( | ||
| 73 | (session) => | ||
| 74 | affectedIds.has(session.providerInstanceId) && | ||
| 75 | session.status !== "stopped" && | ||
| 76 | session.status !== "error" && | ||
| 77 | !released.has(session.id), | ||
| 78 | ), | ||
| 79 | (session) => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | if (session.providerInstanceId !== instanceId) { | ||
| 82 | const current = yield* registry.getInstance(session.providerInstanceId); | ||
| 83 | if ( | ||
| 84 | !binding || | ||
| 85 | current?.auth?.credentialBinding?.key !== binding.key || | ||
| 86 | current.auth.credentialBinding.owner !== binding.owner | ||
| 87 | ) | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | yield* providerSessions | ||
| 91 | .release({ | ||
| 92 | providerSessionId: session.id, | ||
| 93 | reason: "manual_shutdown", | ||
| 94 | detail: "Provider sign-in changed.", | ||
| 95 | }) | ||
| 96 | .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id)))); | ||
| 97 | }), | ||
| 98 | { discard: true }, | ||
| 99 | ), | ||
| 100 | ), | ||
| 101 | Effect.mapError(() => | ||
| 102 | failure("Could not stop all sessions for this provider. Try again."), | ||
| 103 | ), | ||
| 104 | ), | ||
| 105 | { discard: true }, | ||
| 106 | ); | ||
| 107 | if (binding) { | ||
| 108 | yield* Effect.forEach( | ||
| 109 | (yield* registry.listInstances).filter( | ||
| 110 | (instance) => | ||
| 111 | instance.instanceId !== instanceId && | ||
| 112 | affectedIds.has(instance.instanceId) && | ||
| 113 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 114 | instance.auth.credentialBinding.owner === binding.owner, | ||
| 115 | ), | ||
| 116 | (instance) => instance.auth?.invalidate ?? Effect.void, | ||
| 117 | { discard: true }, | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | }); | ||
| 121 | |||
| 122 | const checkSharedBinding = Effect.fnUntraced(function* ( | ||
| 123 | instanceId: ProviderInstanceId, | ||
| 124 | operation: "start" | "logout", | ||
| 125 | auth: ProviderAuthService.ProviderAuthController, | ||
| 126 | ) { | ||
| 127 | const binding = auth.credentialBinding; | ||
| 128 | if (!binding) return; | ||
| 129 | const instances = yield* registry.listInstances; | ||
| 130 | for (const instance of instances) { | ||
| 131 | if ( | ||
| 132 | instance.instanceId !== instanceId && | ||
| 133 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 134 | instance.auth.credentialBinding.owner === binding.owner && | ||
| 135 | instance.auth.isChangingCredentials && | ||
| 136 | (yield* instance.auth.isChangingCredentials) | ||
| 137 | ) { | ||
| 138 | return yield* new ProviderSetupError({ | ||
| 139 | instanceId, | ||
| 140 | operation, | ||
| 141 | detail: | ||
| 142 | "Another provider instance is changing this shared sign-in. Finish or cancel it first.", | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | } | ||
| 146 | }); | ||
| 147 | |||
| 148 | return ProviderAuthService.ProviderAuthService.of({ | ||
| 149 | reconnectProfile: Effect.fnUntraced(function* (input) { | ||
| 150 | const auth = yield* getController(input.instanceId, "export"); | ||
| 151 | if (!auth.reconnectProfile) | ||
| 152 | return yield* new ProviderSetupError({ | ||
| 153 | instanceId: input.instanceId, | ||
| 154 | operation: "export", | ||
| 155 | detail: "This provider does not support ChatGPT profile transfer.", | ||
| 156 | }); | ||
| 157 | return yield* auth.reconnectProfile(input.methodId); | ||
| 158 | }), | ||
| 159 | importProfile: (input) => | ||
| 160 | credentialChanges.withPermit( | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const auth = yield* getController(input.instanceId, "import"); | ||
| 163 | yield* checkSharedBinding(input.instanceId, "start", auth); | ||
| 164 | if (!auth.importProfile) | ||
| 165 | return yield* new ProviderSetupError({ | ||
| 166 | instanceId: input.instanceId, | ||
| 167 | operation: "import", | ||
| 168 | detail: "This provider does not support ChatGPT profile transfer.", | ||
| 169 | }); | ||
| 170 | return yield* auth.importProfile( | ||
| 171 | input.profile, | ||
| 172 | stopSessions(input.instanceId, auth.credentialBinding), | ||
| 173 | ); | ||
| 174 | }), | ||
| 175 | ), | ||
| 176 | start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { | ||
| 177 | return yield* credentialChanges.withPermit( | ||
| 178 | Effect.gen(function* () { | ||
| 179 | const auth = yield* getController(input.instanceId, "start"); | ||
| 180 | yield* checkSharedBinding(input.instanceId, "start", auth); | ||
| 181 | return yield* auth.start( | ||
| 182 | ownerSessionId, | ||
| 183 | stopSessions(input.instanceId, auth.credentialBinding), | ||
| 184 | input.methodId, | ||
| 185 | input.returnUrl, | ||
| 186 | input.callbackMode, | ||
| 187 | ); | ||
| 188 | }), | ||
| 189 | ); | ||
| 190 | }), | ||
| 191 | respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { | ||
| 192 | const auth = yield* getController(input.instanceId, "respond"); | ||
| 193 | if (!auth.respond) { | ||
| 194 | return yield* new ProviderSetupError({ | ||
| 195 | instanceId: input.instanceId, | ||
| 196 | operation: "respond", | ||
| 197 | detail: "This provider does not accept this sign-in interaction.", | ||
| 198 | }); | ||
| 199 | } | ||
| 200 | return yield* auth.respond(ownerSessionId, input); | ||
| 201 | }), | ||
| 202 | complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { | ||
| 203 | const auth = yield* getController(input.instanceId, "complete"); | ||
| 204 | return yield* auth.complete(ownerSessionId, input); | ||
| 205 | }), | ||
| 206 | cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { | ||
| 207 | const auth = yield* getController(input.instanceId, "cancel"); | ||
| 208 | return yield* auth.cancel(ownerSessionId, input.flowId); | ||
| 209 | }), | ||
| 210 | logout: Effect.fn("ProviderAuthService.logout")(function* (input) { | ||
| 211 | return yield* credentialChanges.withPermit( | ||
| 212 | Effect.gen(function* () { | ||
| 213 | const auth = yield* getController(input.instanceId, "logout"); | ||
| 214 | yield* checkSharedBinding(input.instanceId, "logout", auth); | ||
| 215 | return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | ||
| 216 | }), | ||
| 217 | ); | ||
| 218 | }), | ||
| 219 | subscribe: (input, ownerSessionId) => | ||
| 220 | Effect.gen(function* () { | ||
| 221 | const changes = yield* registry.subscribeChanges; | ||
| 222 | const initial = yield* getController(input.instanceId, "subscribe"); | ||
| 223 | return Stream.concat( | ||
| 224 | Stream.succeed(initial), | ||
| 225 | Stream.fromSubscription(changes).pipe( | ||
| 226 | Stream.mapEffect(() => getController(input.instanceId, "subscribe")), | ||
| 227 | ), | ||
| 228 | ).pipe( | ||
| 229 | Stream.changesWith((previous, next) => previous === next), | ||
| 230 | Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), | ||
| 231 | ); | ||
| 232 | }).pipe(Stream.unwrap), | ||
| 233 | tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( | ||
| 234 | function* (input) { | ||
| 235 | const instance = yield* registry.getInstance(input.instanceId); | ||
| 236 | if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { | ||
| 237 | return false; | ||
| 238 | } | ||
| 239 | return yield* credentialChanges.withPermit( | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const auth = yield* getController(input.instanceId, "logout"); | ||
| 242 | if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; | ||
| 243 | yield* checkSharedBinding(input.instanceId, "logout", auth); | ||
| 244 | yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | ||
| 245 | return true; | ||
| 246 | }), | ||
| 247 | ); | ||
| 248 | }, | ||
| 249 | ), | ||
| 250 | }); | ||
| 251 | }); | ||
| 252 | |||
| 253 | export const layer = Layer.effect(ProviderAuthService.ProviderAuthService, makeProviderAuthService); | ||
apps/server/src/provider/Layers/ProviderEventLoggers.ts deleted-92| ... | @@ -1,92 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderEventLoggers — single observability service that owns the shared | ||
| 3 | * provider event log store and exposes its two runtime views: | ||
| 4 | * | ||
| 5 | * - `native` — provider-protocol events as the SDK emits them, written | ||
| 6 | * from inside each `<X>Adapter` factory. | ||
| 7 | * - `canonical` — runtime events after `ProviderService` has normalized | ||
| 8 | * them onto `ProviderRuntimeEvent`. | ||
| 9 | * | ||
| 10 | * Why a service tag and not constructor options? | ||
| 11 | * | ||
| 12 | * - Adapters are now constructed *inside* drivers (`<X>Driver.create()`), | ||
| 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` | ||
| 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. | ||
| 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) | ||
| 16 | * must share one underlying log store — opening N writers against the | ||
| 17 | * same rotating file would race the rotation logic. Owning the loggers on | ||
| 18 | * a single tag keeps that invariant intact. | ||
| 19 | * - Tests can swap one (or both) loggers with in-memory recorders by | ||
| 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of | ||
| 21 | * juggling per-Layer option threading. | ||
| 22 | * | ||
| 23 | * Both fields are optional because observability must not prevent startup. | ||
| 24 | * | ||
| 25 | * @module provider/Layers/ProviderEventLoggers | ||
| 26 | */ | ||
| 27 | import * as Context from "effect/Context"; | ||
| 28 | import * as Effect from "effect/Effect"; | ||
| 29 | import * as Layer from "effect/Layer"; | ||
| 30 | |||
| 31 | import * as ServerConfig from "../../config.ts"; | ||
| 32 | import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts"; | ||
| 33 | import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; | ||
| 34 | |||
| 35 | /** | ||
| 36 | * Shared logger pair for native + canonical provider event streams. | ||
| 37 | * | ||
| 38 | * Service value is intentionally a struct of two optional loggers rather | ||
| 39 | * than two parallel tags. Construction site is one place | ||
| 40 | * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the | ||
| 41 | * field they need. | ||
| 42 | */ | ||
| 43 | export class ProviderEventLoggers extends Context.Service< | ||
| 44 | ProviderEventLoggers, | ||
| 45 | { | ||
| 46 | readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 47 | readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 48 | } | ||
| 49 | >()("t3/provider/Layers/ProviderEventLoggers") {} | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Constant value used by tests / boot layers that want to opt out of native | ||
| 53 | * + canonical logging entirely. Keeps the tag non-optional in the type | ||
| 54 | * system while letting the runtime treat absence as a no-op. | ||
| 55 | */ | ||
| 56 | export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { | ||
| 57 | native: undefined, | ||
| 58 | canonical: undefined, | ||
| 59 | }; | ||
| 60 | |||
| 61 | /** | ||
| 62 | * Builds both stream views over one shared store. Setup failures are logged | ||
| 63 | * and downgraded to the no-op service so diagnostics never block startup. | ||
| 64 | * | ||
| 65 | * @public Service construction is part of the canonical Effect module API. | ||
| 66 | */ | ||
| 67 | export const make = Effect.gen(function* () { | ||
| 68 | const { providerEventLogPath } = yield* ServerConfig.ServerConfig; | ||
| 69 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 70 | const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { | ||
| 71 | attribution, | ||
| 72 | }).pipe( | ||
| 73 | Effect.catch((error) => | ||
| 74 | Effect.logWarning(error.message, { error }).pipe( | ||
| 75 | Effect.annotateLogs({ scope: "provider-observability" }), | ||
| 76 | Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined), | ||
| 77 | ), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | if (!store) { | ||
| 82 | return ProviderEventLoggers.of(NoOpProviderEventLoggers); | ||
| 83 | } | ||
| 84 | |||
| 85 | yield* Effect.addFinalizer(() => store.close()); | ||
| 86 | return ProviderEventLoggers.of({ | ||
| 87 | native: store.logger("native"), | ||
| 88 | canonical: store.logger("canonical"), | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | |||
| 92 | export const layer = Layer.effect(ProviderEventLoggers, make); | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts deleted-193| ... | @@ -1,193 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` | ||
| 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. | ||
| 4 | * | ||
| 5 | * The server still reads two shapes: | ||
| 6 | * | ||
| 7 | * 1. `settings.providerInstances` — the new driver-agnostic map the | ||
| 8 | * registry expects. Keyed by `ProviderInstanceId`, values are | ||
| 9 | * `ProviderInstanceConfig` envelopes. | ||
| 10 | * 2. `settings.providers.<kind>` — the legacy single-instance-per-driver | ||
| 11 | * fields (`providers.codex`, `providers.claudeAgent`, …). These are | ||
| 12 | * the source of truth for every deployment that hasn't been migrated | ||
| 13 | * yet to an explicit `providerInstances` entry. | ||
| 14 | * | ||
| 15 | * This module bridges (2) into (1) and wires the resulting map into a | ||
| 16 | * mutable registry. For every built-in driver whose id is not already | ||
| 17 | * present in `providerInstances` (keyed on | ||
| 18 | * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a | ||
| 19 | * routing slug), we synthesize an envelope from the legacy field. The | ||
| 20 | * registry decodes both flavours through the same `configSchema` and ends | ||
| 21 | * up with one uniform `ProviderInstance` per entry. | ||
| 22 | * | ||
| 23 | * Explicit `providerInstances` entries always win — users can already | ||
| 24 | * override the legacy `providers.<kind>` blob by authoring a | ||
| 25 | * `providerInstances.codex` entry with a matching driver, and we don't | ||
| 26 | * want the synthesized envelope to silently stomp their config. | ||
| 27 | * | ||
| 28 | * Hot-reload | ||
| 29 | * ---------- | ||
| 30 | * On layer build we: | ||
| 31 | * 1. Read the current `ServerSettings` once and use it to seed the | ||
| 32 | * registry's initial state via `ProviderInstanceRegistryLive.layer`. | ||
| 33 | * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that | ||
| 34 | * acquires `ServerSettingsService.subscribeChanges` and calls | ||
| 35 | * `ProviderInstanceRegistryMutator.reconcile` on every emission. | ||
| 36 | * | ||
| 37 | * Failures inside the watcher are logged and swallowed so a single bad | ||
| 38 | * settings emission cannot kill the registry. Unknown drivers and invalid | ||
| 39 | * configs already round-trip through the registry's own "unavailable" | ||
| 40 | * shadow bucket. | ||
| 41 | * | ||
| 42 | * @module provider/Layers/ProviderInstanceRegistryHydration | ||
| 43 | */ | ||
| 44 | import { | ||
| 45 | defaultInstanceIdForDriver, | ||
| 46 | type ProviderInstanceConfig, | ||
| 47 | type ProviderInstanceConfigMap, | ||
| 48 | ServerSettings, | ||
| 49 | } from "@t3tools/contracts"; | ||
| 50 | import * as Effect from "effect/Effect"; | ||
| 51 | import * as Layer from "effect/Layer"; | ||
| 52 | import * as Stream from "effect/Stream"; | ||
| 53 | |||
| 54 | import * as Settings from "../../serverSettings.ts"; | ||
| 55 | import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; | ||
| 56 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 57 | import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts"; | ||
| 58 | import * as ProviderInstanceRegistryLive from "./ProviderInstanceRegistryLive.ts"; | ||
| 59 | import { type ProviderOrchestrationAdapterInfrastructure } from "./ProviderOrchestrationAdapterInfrastructure.ts"; | ||
| 60 | import * as ProviderOrchestrationAdapterInfrastructureLayer from "./ProviderOrchestrationAdapterInfrastructure.ts"; | ||
| 61 | import * as AcpRegistrySupport from "../acp/AcpRegistrySupport.ts"; | ||
| 62 | import * as AcpRegistryCatalogLayer from "./AcpRegistryCatalog.ts"; | ||
| 63 | |||
| 64 | type ProviderInstanceRegistryHydrationEnv = | ||
| 65 | | Exclude< | ||
| 66 | BuiltInDriversEnv, | ||
| 67 | ProviderOrchestrationAdapterInfrastructure | AcpRegistrySupport.AcpRegistryCatalog | ||
| 68 | > | ||
| 69 | | Settings.ServerSettingsService; | ||
| 70 | |||
| 71 | /** | ||
| 72 | * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. | ||
| 73 | * | ||
| 74 | * Strategy: | ||
| 75 | * 1. Copy all explicit `settings.providerInstances` entries verbatim. | ||
| 76 | * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key | ||
| 77 | * is *not* already in the explicit map, synthesize an entry from the | ||
| 78 | * matching legacy `settings.providers.<kind>` blob. | ||
| 79 | * | ||
| 80 | * The returned map is the input the registry consumes; pure & exported | ||
| 81 | * separately so the hydration logic can be exercised by unit tests | ||
| 82 | * without layering. | ||
| 83 | */ | ||
| 84 | export const deriveProviderInstanceConfigMap = ( | ||
| 85 | settings: ServerSettings, | ||
| 86 | ): ProviderInstanceConfigMap => { | ||
| 87 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; | ||
| 88 | |||
| 89 | for (const driver of BUILT_IN_DRIVERS) { | ||
| 90 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); | ||
| 91 | if (instanceId in merged) { | ||
| 92 | // Explicit `providerInstances` entry for this slot — user-authored | ||
| 93 | // config always wins over the legacy mirror. | ||
| 94 | continue; | ||
| 95 | } | ||
| 96 | |||
| 97 | // Only built-in drivers have a legacy mirror; the registry's | ||
| 98 | // `providers` struct is keyed on the same literal slug as | ||
| 99 | // `driverKind`. Access is dynamic (the driver kind is a branded string), | ||
| 100 | // but it's constrained to `keyof settings.providers` by the union of | ||
| 101 | // built-in driver kinds. | ||
| 102 | const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; | ||
| 103 | const legacyConfig = settings.providers[legacyKey]; | ||
| 104 | if (legacyConfig === undefined) { | ||
| 105 | continue; | ||
| 106 | } | ||
| 107 | |||
| 108 | merged[instanceId] = { | ||
| 109 | driver: driver.driverKind, | ||
| 110 | config: legacyConfig, | ||
| 111 | }; | ||
| 112 | } | ||
| 113 | |||
| 114 | return merged as ProviderInstanceConfigMap; | ||
| 115 | }; | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Layer that consumes `ProviderInstanceRegistryMutator` and forks a | ||
| 119 | * settings-watcher fiber. The fiber's lifetime is tied to the enclosing | ||
| 120 | * layer scope (process lifetime in production), so it is interrupted on | ||
| 121 | * shutdown without leaking. | ||
| 122 | * | ||
| 123 | * Errors inside the watcher are logged and swallowed — the registry's own | ||
| 124 | * "unavailable" bucket already absorbs unknown drivers and invalid | ||
| 125 | * configs, so the only way the watcher could fail is a settings stream | ||
| 126 | * tear-down, which logs and exits cleanly. | ||
| 127 | */ | ||
| 128 | const layerSettingsWatcher = Layer.effectDiscard( | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; | ||
| 131 | const serverSettings = yield* Settings.ServerSettingsService; | ||
| 132 | const settingsChanges = yield* serverSettings.subscribeChanges; | ||
| 133 | yield* settingsChanges.pipe( | ||
| 134 | Stream.runForEach((next) => | ||
| 135 | mutator | ||
| 136 | .reconcile(deriveProviderInstanceConfigMap(next)) | ||
| 137 | .pipe( | ||
| 138 | Effect.catchCause((cause) => | ||
| 139 | Effect.logError("ProviderInstanceRegistry reconcile failed", cause), | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | ), | ||
| 143 | Effect.forkScoped, | ||
| 144 | ); | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | |||
| 148 | /** | ||
| 149 | * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in | ||
| 150 | * sync with subsequent `streamChanges` emissions. | ||
| 151 | * | ||
| 152 | * The Layer's two halves: | ||
| 153 | * - `ProviderInstanceRegistryLive.layer` produces the registry + | ||
| 154 | * mutator from the initial config map. Its scope owns every | ||
| 155 | * per-instance child scope created during reconcile. | ||
| 156 | * - `SettingsWatcherLive` consumes the mutator, acquires its settings | ||
| 157 | * subscription before forking, and runs a daemon fiber in the same scope. | ||
| 158 | * | ||
| 159 | * Composing via `Layer.provideMerge` makes the watcher's deps available | ||
| 160 | * from the mutable layer while still surfacing the registry as an output. | ||
| 161 | * The mutator tag is technically also exposed; only this module imports | ||
| 162 | * it, so the visibility leak is harmless in practice. | ||
| 163 | */ | ||
| 164 | export const layer: Layer.Layer< | ||
| 165 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 166 | never, | ||
| 167 | ProviderInstanceRegistryHydrationEnv | ||
| 168 | > = Layer.unwrap( | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const serverSettings = yield* Settings.ServerSettingsService; | ||
| 171 | const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( | ||
| 172 | Effect.orElseSucceed(() => undefined), | ||
| 173 | ); | ||
| 174 | const initialConfigMap = | ||
| 175 | initialSettings === undefined | ||
| 176 | ? ({} as ProviderInstanceConfigMap) | ||
| 177 | : deriveProviderInstanceConfigMap(initialSettings); | ||
| 178 | |||
| 179 | const layerMutable = ProviderInstanceRegistryLive.layer({ | ||
| 180 | drivers: BUILT_IN_DRIVERS, | ||
| 181 | configMap: initialConfigMap, | ||
| 182 | }).pipe( | ||
| 183 | Layer.provide(ProviderOrchestrationAdapterInfrastructureLayer.layer), | ||
| 184 | Layer.provide(AcpRegistryCatalogLayer.layer), | ||
| 185 | ); | ||
| 186 | |||
| 187 | return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); | ||
| 188 | }), | ||
| 189 | ) as Layer.Layer< | ||
| 190 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 191 | never, | ||
| 192 | ProviderInstanceRegistryHydrationEnv | ||
| 193 | >; | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts deleted-781| ... | @@ -1,781 +0,0 @@ | ||
| 1 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | ||
| 2 | import * as CodexInstallation from "../CodexInstallation.ts"; | ||
| 3 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; | ||
| 4 | /** | ||
| 5 | * Multi-instance validation slices for `ProviderInstanceRegistryLive`. | ||
| 6 | * | ||
| 7 | * Two axes of the driver/registry refactor are exercised here: | ||
| 8 | * | ||
| 9 | * 1. **Same driver, many instances** — the "multi-instance codex slice" | ||
| 10 | * describe block below configures two independent `codex` instances and | ||
| 11 | * asserts each gets its own closures and identity. This is the | ||
| 12 | * multi-codex capability the refactor exists to unlock. | ||
| 13 | * | ||
| 14 | * 2. **Many drivers, one registry** — the "all drivers slice" describe | ||
| 15 | * block below configures one instance of every shipped driver | ||
| 16 | * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single | ||
| 17 | * `ProviderInstanceConfigMap` and asserts the registry boots them all | ||
| 18 | * without cross-contamination. This proves the driver SPI is uniform | ||
| 19 | * across every provider — any driver plugs into the registry through | ||
| 20 | * the same `ProviderDriver` value contract. | ||
| 21 | * | ||
| 22 | * Every instance in these tests is configured with `enabled: false` so the | ||
| 23 | * provider-status checks short-circuit to pending/disabled snapshots | ||
| 24 | * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` | ||
| 25 | * binaries. That keeps the assertions focused on registry routing | ||
| 26 | * behaviour rather than the runtime details of each provider. | ||
| 27 | */ | ||
| 28 | import { describe, expect, it } from "@effect/vitest"; | ||
| 29 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 30 | import { | ||
| 31 | EnvironmentId, | ||
| 32 | type ClaudeSettings, | ||
| 33 | type CodexSettings, | ||
| 34 | type CursorSettings, | ||
| 35 | type GrokSettings, | ||
| 36 | type OpenCodeSettings, | ||
| 37 | ProviderDriverKind, | ||
| 38 | type ProviderInstanceConfigMap, | ||
| 39 | ProviderInstanceId, | ||
| 40 | } from "@t3tools/contracts"; | ||
| 41 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; | ||
| 42 | import * as DateTime from "effect/DateTime"; | ||
| 43 | import * as Effect from "effect/Effect"; | ||
| 44 | import * as FileSystem from "effect/FileSystem"; | ||
| 45 | import * as Layer from "effect/Layer"; | ||
| 46 | import * as Path from "effect/Path"; | ||
| 47 | import * as Stream from "effect/Stream"; | ||
| 48 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 49 | |||
| 50 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 51 | import * as AntigravityInstallation from "../AntigravityInstallation.ts"; | ||
| 52 | import * as ServerConfig from "../../config.ts"; | ||
| 53 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 54 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 55 | import { ClaudeDriver, type ClaudeDriverEnv } from "../Drivers/ClaudeDriver.ts"; | ||
| 56 | import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; | ||
| 57 | import { CursorDriver, type CursorDriverEnv } from "../Drivers/CursorDriver.ts"; | ||
| 58 | import { GrokDriver, type GrokDriverEnv } from "../Drivers/GrokDriver.ts"; | ||
| 59 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "../Drivers/OpenCodeDriver.ts"; | ||
| 60 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 61 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | ||
| 62 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | ||
| 63 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 64 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | ||
| 65 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; | ||
| 66 | import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; | ||
| 67 | |||
| 68 | const layerTestHttpClient = Layer.succeed( | ||
| 69 | HttpClient.HttpClient, | ||
| 70 | HttpClient.make((request) => | ||
| 71 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 76 | |||
| 77 | const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 78 | reportClientActivity: () => Effect.void, | ||
| 79 | removeRpcClient: () => Effect.void, | ||
| 80 | reportHostPowerState: () => Effect.void, | ||
| 81 | snapshot: Effect.succeed({ | ||
| 82 | hostPower: { | ||
| 83 | source: "unknown", | ||
| 84 | idle: "unknown", | ||
| 85 | idleSeconds: null, | ||
| 86 | locked: "unknown", | ||
| 87 | suspended: false, | ||
| 88 | onBattery: "unknown", | ||
| 89 | lowPowerMode: "unknown", | ||
| 90 | thermalState: "unknown", | ||
| 91 | stale: true, | ||
| 92 | updatedAt: TEST_EPOCH, | ||
| 93 | }, | ||
| 94 | leases: [], | ||
| 95 | activeForegroundLeaseCount: 0, | ||
| 96 | activeScopeKeys: [], | ||
| 97 | shouldRunOpportunisticWork: true, | ||
| 98 | updatedAt: TEST_EPOCH, | ||
| 99 | }), | ||
| 100 | streamChanges: Stream.empty, | ||
| 101 | hasDemand: () => Effect.succeed(true), | ||
| 102 | shouldRunScopeWork: () => Effect.succeed(true), | ||
| 103 | shouldRunOpportunisticWork: Effect.succeed(true), | ||
| 104 | }); | ||
| 105 | |||
| 106 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ | ||
| 107 | enabled: false, | ||
| 108 | binaryPath: "codex", | ||
| 109 | homePath: "", | ||
| 110 | shadowHomePath: "", | ||
| 111 | launchArgs: "", | ||
| 112 | customModels: [], | ||
| 113 | ...overrides, | ||
| 114 | }); | ||
| 115 | |||
| 116 | const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({ | ||
| 117 | enabled: false, | ||
| 118 | binaryPath: "claude", | ||
| 119 | homePath: "", | ||
| 120 | customModels: [], | ||
| 121 | launchArgs: "", | ||
| 122 | autoCompactWindow: "", | ||
| 123 | ...overrides, | ||
| 124 | }); | ||
| 125 | |||
| 126 | const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({ | ||
| 127 | enabled: false, | ||
| 128 | customModels: [], | ||
| 129 | ...overrides, | ||
| 130 | }); | ||
| 131 | |||
| 132 | const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({ | ||
| 133 | enabled: false, | ||
| 134 | binaryPath: "grok", | ||
| 135 | customModels: [], | ||
| 136 | ...overrides, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({ | ||
| 140 | enabled: false, | ||
| 141 | binaryPath: "opencode", | ||
| 142 | serverUrl: "", | ||
| 143 | serverPassword: "", | ||
| 144 | customModels: [], | ||
| 145 | ...overrides, | ||
| 146 | }); | ||
| 147 | |||
| 148 | const makeTildeProviderFixtures = Effect.fn( | ||
| 149 | "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", | ||
| 150 | )(function* () { | ||
| 151 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 152 | const path = yield* Path.Path; | ||
| 153 | const homePath = expandHomePath("~"); | ||
| 154 | const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 155 | directory: homePath, | ||
| 156 | prefix: ".t3-provider-path-test-", | ||
| 157 | }); | ||
| 158 | const codexPath = path.join(fixtureDir, "codex"); | ||
| 159 | const claudePath = path.join(fixtureDir, "claude"); | ||
| 160 | const claudeHomePath = path.join(fixtureDir, "claude-home"); | ||
| 161 | const codexScriptPath = path.join(fixtureDir, "codex-script.json"); | ||
| 162 | const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); | ||
| 163 | |||
| 164 | yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); | ||
| 165 | yield* fileSystem.copyFile( | ||
| 166 | path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), | ||
| 167 | path.join(fixtureDir, "codexCollabMockPeer.mjs"), | ||
| 168 | ); | ||
| 169 | yield* fileSystem.copyFile( | ||
| 170 | path.join(codexFixtureDir, "codexMultiAgentWire.json"), | ||
| 171 | path.join(fixtureDir, "codexMultiAgentWire.json"), | ||
| 172 | ); | ||
| 173 | yield* fileSystem.writeFileString( | ||
| 174 | codexScriptPath, | ||
| 175 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. | ||
| 176 | JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), | ||
| 177 | ); | ||
| 178 | yield* fileSystem.chmod(codexPath, 0o755); | ||
| 179 | |||
| 180 | yield* fileSystem.copyFile( | ||
| 181 | yield* path.fromFileUrl( | ||
| 182 | new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url), | ||
| 183 | ), | ||
| 184 | claudePath, | ||
| 185 | ); | ||
| 186 | yield* fileSystem.chmod(claudePath, 0o755); | ||
| 187 | yield* fileSystem.makeDirectory(claudeHomePath); | ||
| 188 | |||
| 189 | const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; | ||
| 190 | return { | ||
| 191 | codexBinaryPath: asTildePath(codexPath), | ||
| 192 | claudeBinaryPath: asTildePath(claudePath), | ||
| 193 | claudeHomePath, | ||
| 194 | codexScriptPath, | ||
| 195 | }; | ||
| 196 | }); | ||
| 197 | |||
| 198 | describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { | ||
| 199 | // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch | ||
| 200 | // directory. `Layer.merge` just unions requirements, so we have to push | ||
| 201 | // `NodeServices.layer` through `Layer.provideMerge` to satisfy that | ||
| 202 | // dependency while still surfacing NodeServices to the test body (the | ||
| 203 | // codex driver's `create` yields `ChildProcessSpawner` directly). | ||
| 204 | const layerBase = ServerConfig.layerTest(process.cwd(), { | ||
| 205 | prefix: "provider-instance-registry-test", | ||
| 206 | }).pipe( | ||
| 207 | Layer.provideMerge(NodeServices.layer), | ||
| 208 | Layer.provideMerge( | ||
| 209 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 210 | managedDirectory: "unused-managed-installation", | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), | ||
| 214 | Layer.provideMerge( | ||
| 215 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 216 | getEnvironmentId: Effect.succeed( | ||
| 217 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | ||
| 218 | ), | ||
| 219 | }), | ||
| 220 | ), | ||
| 221 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 222 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 223 | Layer.provideMerge(layerTestHttpClient), | ||
| 224 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 225 | Layer.provideMerge( | ||
| 226 | Layer.succeed( | ||
| 227 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 228 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 229 | ), | ||
| 230 | ), | ||
| 231 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 232 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 233 | ); | ||
| 234 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( | ||
| 235 | Layer.provideMerge(layerBase), | ||
| 236 | ); | ||
| 237 | |||
| 238 | it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 241 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 242 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 243 | |||
| 244 | const configMap: ProviderInstanceConfigMap = { | ||
| 245 | [personalId]: { | ||
| 246 | driver: codexDriverKind, | ||
| 247 | displayName: "Codex (personal)", | ||
| 248 | enabled: false, | ||
| 249 | config: makeCodexConfig({ | ||
| 250 | binaryPath: "/opt/codex-personal/bin/codex", | ||
| 251 | homePath: "/home/julius/.codex_personal", | ||
| 252 | customModels: ["personal-preview"], | ||
| 253 | }), | ||
| 254 | }, | ||
| 255 | [workId]: { | ||
| 256 | driver: codexDriverKind, | ||
| 257 | displayName: "Codex (work)", | ||
| 258 | enabled: false, | ||
| 259 | config: makeCodexConfig({ | ||
| 260 | binaryPath: "/opt/codex-work/bin/codex", | ||
| 261 | homePath: "/home/julius/.codex", | ||
| 262 | customModels: ["work-preview"], | ||
| 263 | }), | ||
| 264 | }, | ||
| 265 | }; | ||
| 266 | |||
| 267 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({ | ||
| 268 | drivers: [CodexDriver], | ||
| 269 | configMap, | ||
| 270 | }); | ||
| 271 | |||
| 272 | const instances = yield* registry.listInstances; | ||
| 273 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 274 | [personalId, workId].toSorted(), | ||
| 275 | ); | ||
| 276 | expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); | ||
| 277 | expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( | ||
| 278 | ["Codex (personal)", "Codex (work)"].toSorted(), | ||
| 279 | ); | ||
| 280 | |||
| 281 | // Each instance must be retrievable by id and carry its *own* closures. | ||
| 282 | const personal = yield* registry.getInstance(personalId); | ||
| 283 | const work = yield* registry.getInstance(workId); | ||
| 284 | expect(personal).toBeDefined(); | ||
| 285 | expect(work).toBeDefined(); | ||
| 286 | expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); | ||
| 287 | expect(personal!.textGeneration).not.toBe(work!.textGeneration); | ||
| 288 | expect(personal!.snapshot).not.toBe(work!.snapshot); | ||
| 289 | |||
| 290 | // Snapshots identify themselves by instanceId + driver — this is | ||
| 291 | // what makes per-instance routing distinguishable downstream. | ||
| 292 | const personalSnapshot = yield* personal!.snapshot.getSnapshot; | ||
| 293 | expect(personalSnapshot.instanceId).toBe(personalId); | ||
| 294 | expect(personalSnapshot.driver).toBe(codexDriverKind); | ||
| 295 | expect(personalSnapshot.enabled).toBe(false); | ||
| 296 | // The layout resolves the configured home through the host Path. | ||
| 297 | const path = yield* Path.Path; | ||
| 298 | expect(personalSnapshot.continuation?.groupKey).toBe( | ||
| 299 | `codex:home:${path.resolve("/home/julius/.codex_personal")}`, | ||
| 300 | ); | ||
| 301 | |||
| 302 | const workSnapshot = yield* work!.snapshot.getSnapshot; | ||
| 303 | expect(workSnapshot.instanceId).toBe(workId); | ||
| 304 | expect(workSnapshot.driver).toBe(codexDriverKind); | ||
| 305 | expect(workSnapshot.enabled).toBe(false); | ||
| 306 | expect(workSnapshot.continuation?.groupKey).toBe( | ||
| 307 | `codex:home:${path.resolve("/home/julius/.codex")}`, | ||
| 308 | ); | ||
| 309 | |||
| 310 | // Nothing goes to the unavailable bucket — both drivers are registered. | ||
| 311 | const unavailable = yield* registry.listUnavailable; | ||
| 312 | expect(unavailable).toEqual([]); | ||
| 313 | }).pipe(Effect.provide(layerTest)), | ||
| 314 | ); | ||
| 315 | |||
| 316 | it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => | ||
| 317 | Effect.gen(function* () { | ||
| 318 | // Old settings files can carry both flags with conflicting values. | ||
| 319 | // The explicit false must win so a user's disable is never undone. | ||
| 320 | const staleId = ProviderInstanceId.make("codex_stale"); | ||
| 321 | const configMap: ProviderInstanceConfigMap = { | ||
| 322 | [staleId]: { | ||
| 323 | driver: ProviderDriverKind.make("codex"), | ||
| 324 | enabled: true, | ||
| 325 | config: makeCodexConfig({ enabled: false }), | ||
| 326 | }, | ||
| 327 | }; | ||
| 328 | |||
| 329 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 330 | drivers: [CodexDriver], | ||
| 331 | configMap, | ||
| 332 | }); | ||
| 333 | |||
| 334 | const instance = yield* registry.getInstance(staleId); | ||
| 335 | expect(instance).toBeDefined(); | ||
| 336 | expect(instance!.enabled).toBe(false); | ||
| 337 | const snapshot = yield* instance!.snapshot.getSnapshot; | ||
| 338 | expect(snapshot.enabled).toBe(false); | ||
| 339 | }).pipe(Effect.provide(layerTest)), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.live("reports Codex's answer when a redemption changed nothing", () => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | if (yield* isHostWindows) return; | ||
| 345 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 346 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 347 | yield* fileSystem.writeFileString( | ||
| 348 | fixtures.codexScriptPath, | ||
| 349 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. | ||
| 350 | JSON.stringify({ | ||
| 351 | rootThreadId: "probe-thread", | ||
| 352 | notifications: [], | ||
| 353 | account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, | ||
| 354 | failRateLimitsRead: true, | ||
| 355 | resetCreditOutcome: "alreadyRedeemed", | ||
| 356 | }), | ||
| 357 | ); | ||
| 358 | const codexId = ProviderInstanceId.make("codex_reset"); | ||
| 359 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 360 | drivers: [CodexDriver], | ||
| 361 | configMap: { | ||
| 362 | [codexId]: { | ||
| 363 | driver: ProviderDriverKind.make("codex"), | ||
| 364 | enabled: true, | ||
| 365 | environment: [ | ||
| 366 | { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, | ||
| 367 | ], | ||
| 368 | config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | }); | ||
| 372 | const codex = yield* registry.getInstance(codexId); | ||
| 373 | expect(codex).toBeDefined(); | ||
| 374 | // The usage read fails, so the re-probe cannot confirm new limits. | ||
| 375 | yield* codex!.snapshot.refresh; | ||
| 376 | expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); | ||
| 377 | }).pipe(Effect.provide(layerTest)), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.live("runs Codex and Claude readiness probes from configured tilde paths", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | if (yield* isHostWindows) return; | ||
| 383 | |||
| 384 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 385 | |||
| 386 | const codexId = ProviderInstanceId.make("codex_tilde"); | ||
| 387 | const claudeId = ProviderInstanceId.make("claude_tilde"); | ||
| 388 | const configMap: ProviderInstanceConfigMap = { | ||
| 389 | [codexId]: { | ||
| 390 | driver: ProviderDriverKind.make("codex"), | ||
| 391 | enabled: true, | ||
| 392 | environment: [ | ||
| 393 | { | ||
| 394 | name: "T3_CODEX_COLLAB_SCRIPT", | ||
| 395 | value: fixtures.codexScriptPath, | ||
| 396 | sensitive: false, | ||
| 397 | }, | ||
| 398 | ], | ||
| 399 | config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), | ||
| 400 | }, | ||
| 401 | [claudeId]: { | ||
| 402 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 403 | enabled: true, | ||
| 404 | config: makeClaudeConfig({ | ||
| 405 | enabled: true, | ||
| 406 | binaryPath: fixtures.claudeBinaryPath, | ||
| 407 | homePath: fixtures.claudeHomePath, | ||
| 408 | }), | ||
| 409 | }, | ||
| 410 | }; | ||
| 411 | |||
| 412 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv | ClaudeDriverEnv>({ | ||
| 413 | drivers: [CodexDriver, ClaudeDriver], | ||
| 414 | configMap, | ||
| 415 | }); | ||
| 416 | const codex = yield* registry.getInstance(codexId); | ||
| 417 | const claude = yield* registry.getInstance(claudeId); | ||
| 418 | expect(codex).toBeDefined(); | ||
| 419 | expect(claude).toBeDefined(); | ||
| 420 | |||
| 421 | const [codexSnapshot, claudeSnapshot] = yield* Effect.all( | ||
| 422 | [codex!.snapshot.refresh, claude!.snapshot.refresh], | ||
| 423 | { concurrency: "unbounded" }, | ||
| 424 | ); | ||
| 425 | expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); | ||
| 426 | expect(claudeSnapshot).toMatchObject({ | ||
| 427 | status: "ready", | ||
| 428 | installed: true, | ||
| 429 | version: "2.1.219", | ||
| 430 | }); | ||
| 431 | }).pipe(Effect.provide(layerTest)), | ||
| 432 | ); | ||
| 433 | |||
| 434 | const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const fs = yield* FileSystem.FileSystem; | ||
| 437 | const path = yield* Path.Path; | ||
| 438 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 439 | const marker = path.join(fixtures.claudeHomePath, "redeemed"); | ||
| 440 | yield* fs.writeFileString( | ||
| 441 | path.join(fixtures.claudeHomePath, ".credentials.json"), | ||
| 442 | '{"claudeAiOauth":{"accessToken":"fake-token"}}', | ||
| 443 | ); | ||
| 444 | yield* fs.writeFileString( | ||
| 445 | path.join(fixtures.claudeHomePath, ".claude.json"), | ||
| 446 | '{"oauthAccount":{"organizationUuid":"fake-org"}}', | ||
| 447 | ); | ||
| 448 | const client = HttpClient.make((request) => | ||
| 449 | Effect.gen(function* () { | ||
| 450 | if (request.url.endsWith("/api/oauth/usage")) { | ||
| 451 | return HttpClientResponse.fromWeb( | ||
| 452 | request, | ||
| 453 | Response.json({ | ||
| 454 | cedar_ember: { | ||
| 455 | eligible: true, | ||
| 456 | next_grant_id: "grant_a", | ||
| 457 | grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], | ||
| 458 | }, | ||
| 459 | }), | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | if (request.url.endsWith("/reset_rate_limits")) { | ||
| 463 | yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); | ||
| 464 | return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); | ||
| 465 | } | ||
| 466 | return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); | ||
| 467 | }), | ||
| 468 | ); | ||
| 469 | const instanceId = ProviderInstanceId.make("claude_reset"); | ||
| 470 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 471 | drivers: [ClaudeDriver], | ||
| 472 | configMap: { | ||
| 473 | [instanceId]: { | ||
| 474 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 475 | enabled: true, | ||
| 476 | environment: [ | ||
| 477 | { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, | ||
| 478 | ...(claim.usageFailsAfterClaim | ||
| 479 | ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] | ||
| 480 | : []), | ||
| 481 | ], | ||
| 482 | config: makeClaudeConfig({ | ||
| 483 | enabled: true, | ||
| 484 | binaryPath: fixtures.claudeBinaryPath, | ||
| 485 | homePath: fixtures.claudeHomePath, | ||
| 486 | }), | ||
| 487 | }, | ||
| 488 | }, | ||
| 489 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); | ||
| 490 | const instance = yield* registry.getInstance(instanceId); | ||
| 491 | expect(instance).toBeDefined(); | ||
| 492 | const before = yield* instance!.snapshot.refresh; | ||
| 493 | expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); | ||
| 494 | expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); | ||
| 495 | const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); | ||
| 496 | return { outcome, after: yield* instance!.snapshot.getSnapshot }; | ||
| 497 | }).pipe( | ||
| 498 | // macOS logins live in the Keychain, where resets are never read. | ||
| 499 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 500 | Effect.provide(layerTest), | ||
| 501 | ); | ||
| 502 | |||
| 503 | it.live("refreshes Claude usage after redeeming a reset", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const { outcome, after } = yield* redeemClaudeReset({ | ||
| 506 | result: "reset", | ||
| 507 | usageFailsAfterClaim: false, | ||
| 508 | }); | ||
| 509 | expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); | ||
| 510 | expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); | ||
| 511 | }), | ||
| 512 | ); | ||
| 513 | |||
| 514 | it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => | ||
| 515 | Effect.gen(function* () { | ||
| 516 | const { outcome } = yield* redeemClaudeReset({ | ||
| 517 | result: "already_used", | ||
| 518 | usageFailsAfterClaim: true, | ||
| 519 | }); | ||
| 520 | expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); | ||
| 521 | }), | ||
| 522 | ); | ||
| 523 | |||
| 524 | it.live( | ||
| 525 | "shadows instances whose driver is not registered in this build without failing boot", | ||
| 526 | () => | ||
| 527 | Effect.gen(function* () { | ||
| 528 | const codexId = ProviderInstanceId.make("codex_main"); | ||
| 529 | const ghostId = ProviderInstanceId.make("ghost_main"); | ||
| 530 | |||
| 531 | const configMap: ProviderInstanceConfigMap = { | ||
| 532 | [codexId]: { | ||
| 533 | driver: ProviderDriverKind.make("codex"), | ||
| 534 | enabled: false, | ||
| 535 | config: makeCodexConfig({}), | ||
| 536 | }, | ||
| 537 | [ghostId]: { | ||
| 538 | driver: ProviderDriverKind.make("ghostDriver"), | ||
| 539 | displayName: "A fork-only driver we don't ship", | ||
| 540 | enabled: false, | ||
| 541 | config: { arbitrary: "payload", preserved: true }, | ||
| 542 | }, | ||
| 543 | }; | ||
| 544 | |||
| 545 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({ | ||
| 546 | drivers: [CodexDriver], | ||
| 547 | configMap, | ||
| 548 | }); | ||
| 549 | |||
| 550 | const instances = yield* registry.listInstances; | ||
| 551 | expect(instances).toHaveLength(1); | ||
| 552 | expect(instances[0]!.instanceId).toBe(codexId); | ||
| 553 | |||
| 554 | const unavailable = yield* registry.listUnavailable; | ||
| 555 | expect(unavailable).toHaveLength(1); | ||
| 556 | const ghost = unavailable[0]!; | ||
| 557 | expect(ghost.instanceId).toBe(ghostId); | ||
| 558 | expect(ghost.driver).toBe("ghostDriver"); | ||
| 559 | expect(ghost.availability).toBe("unavailable"); | ||
| 560 | expect(ghost.unavailableReason).toMatch(/ghostDriver/); | ||
| 561 | }).pipe(Effect.provide(layerTest)), | ||
| 562 | ); | ||
| 563 | }); | ||
| 564 | |||
| 565 | describe("ProviderInstanceRegistryLive — all drivers slice", () => { | ||
| 566 | // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + | ||
| 567 | // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` | ||
| 568 | // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. | ||
| 569 | // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via | ||
| 570 | // `Layer.provide`, so the only external requirement it still exposes is | ||
| 571 | // `ChildProcessSpawner` — resolved here by piping it through | ||
| 572 | // `provideMerge(NodeServices.layer)`. | ||
| 573 | // | ||
| 574 | // The nested `provideMerge`s read bottom-up: `NodeServices.layer` | ||
| 575 | // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs | ||
| 576 | // surfaced; that merged layer then provides `ServerConfig.layerTest`'s | ||
| 577 | // `FileSystem` dep while keeping everything else surfaced to the test. | ||
| 578 | const layerInfra = OpenCodeRuntime.layer.pipe( | ||
| 579 | Layer.provide(OpenCodeServerLedger.layerTest), | ||
| 580 | Layer.provideMerge(NodeServices.layer), | ||
| 581 | Layer.provideMerge( | ||
| 582 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 583 | managedDirectory: "unused-managed-installation", | ||
| 584 | }), | ||
| 585 | ), | ||
| 586 | Layer.provideMerge( | ||
| 587 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 588 | getEnvironmentId: Effect.succeed( | ||
| 589 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | ||
| 590 | ), | ||
| 591 | }), | ||
| 592 | ), | ||
| 593 | ); | ||
| 594 | const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe( | ||
| 595 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 596 | Layer.provideMerge( | ||
| 597 | ServerConfig.layerTest(process.cwd(), { | ||
| 598 | prefix: "provider-instance-registry-all-drivers-test", | ||
| 599 | }), | ||
| 600 | ), | ||
| 601 | Layer.provideMerge(layerInfra), | ||
| 602 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 603 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 604 | Layer.provideMerge(layerTestHttpClient), | ||
| 605 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 606 | Layer.provideMerge( | ||
| 607 | Layer.succeed( | ||
| 608 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 609 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 610 | ), | ||
| 611 | ), | ||
| 612 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 613 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 614 | ); | ||
| 615 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( | ||
| 616 | Layer.provideMerge(layerBase), | ||
| 617 | ); | ||
| 618 | |||
| 619 | it.live("boots one instance of every shipped driver from a single config map", () => | ||
| 620 | Effect.gen(function* () { | ||
| 621 | const codexId = ProviderInstanceId.make("codex_default"); | ||
| 622 | const claudeId = ProviderInstanceId.make("claude_default"); | ||
| 623 | const cursorId = ProviderInstanceId.make("cursor_default"); | ||
| 624 | const grokId = ProviderInstanceId.make("grok_default"); | ||
| 625 | const openCodeId = ProviderInstanceId.make("opencode_default"); | ||
| 626 | |||
| 627 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 628 | const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); | ||
| 629 | const cursorDriverKind = ProviderDriverKind.make("cursor"); | ||
| 630 | const grokDriverKind = ProviderDriverKind.make("grok"); | ||
| 631 | const openCodeDriverKind = ProviderDriverKind.make("opencode"); | ||
| 632 | |||
| 633 | const configMap: ProviderInstanceConfigMap = { | ||
| 634 | [codexId]: { | ||
| 635 | driver: codexDriverKind, | ||
| 636 | displayName: "Codex", | ||
| 637 | enabled: false, | ||
| 638 | config: makeCodexConfig({ homePath: "/home/julius/.codex" }), | ||
| 639 | }, | ||
| 640 | [claudeId]: { | ||
| 641 | driver: claudeDriverKind, | ||
| 642 | displayName: "Claude", | ||
| 643 | enabled: false, | ||
| 644 | config: makeClaudeConfig({ | ||
| 645 | homePath: "/home/julius/.claude-work", | ||
| 646 | launchArgs: "--verbose", | ||
| 647 | }), | ||
| 648 | }, | ||
| 649 | [cursorId]: { | ||
| 650 | driver: cursorDriverKind, | ||
| 651 | displayName: "Cursor", | ||
| 652 | enabled: false, | ||
| 653 | config: makeCursorConfig({}), | ||
| 654 | }, | ||
| 655 | [grokId]: { | ||
| 656 | driver: grokDriverKind, | ||
| 657 | displayName: "Grok", | ||
| 658 | enabled: false, | ||
| 659 | config: makeGrokConfig({}), | ||
| 660 | }, | ||
| 661 | [openCodeId]: { | ||
| 662 | driver: openCodeDriverKind, | ||
| 663 | displayName: "OpenCode", | ||
| 664 | enabled: false, | ||
| 665 | config: makeOpenCodeConfig({}), | ||
| 666 | }, | ||
| 667 | }; | ||
| 668 | |||
| 669 | const { registry } = yield* makeProviderInstanceRegistry< | ||
| 670 | CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | ||
| 671 | >({ | ||
| 672 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], | ||
| 673 | configMap, | ||
| 674 | }); | ||
| 675 | |||
| 676 | // Every configured instance must materialize — none downgraded to a | ||
| 677 | // shadow snapshot, because every driver in the map is registered. | ||
| 678 | const unavailable = yield* registry.listUnavailable; | ||
| 679 | expect(unavailable).toEqual([]); | ||
| 680 | |||
| 681 | const instances = yield* registry.listInstances; | ||
| 682 | expect(instances).toHaveLength(5); | ||
| 683 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 684 | [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), | ||
| 685 | ); | ||
| 686 | |||
| 687 | // Instance lookup by id resolves each instance to its own bundle — | ||
| 688 | // this is how rest-of-server routes turn/session calls in the new | ||
| 689 | // model. Each driver's bundle carries its advertised `driverKind`. | ||
| 690 | const codex = yield* registry.getInstance(codexId); | ||
| 691 | const claude = yield* registry.getInstance(claudeId); | ||
| 692 | const cursor = yield* registry.getInstance(cursorId); | ||
| 693 | const grok = yield* registry.getInstance(grokId); | ||
| 694 | const openCode = yield* registry.getInstance(openCodeId); | ||
| 695 | expect(codex?.driverKind).toBe(codexDriverKind); | ||
| 696 | expect(claude?.driverKind).toBe(claudeDriverKind); | ||
| 697 | expect(cursor?.driverKind).toBe(cursorDriverKind); | ||
| 698 | expect(grok?.driverKind).toBe(grokDriverKind); | ||
| 699 | expect(openCode?.driverKind).toBe(openCodeDriverKind); | ||
| 700 | expect(codex?.displayName).toBe("Codex"); | ||
| 701 | expect(claude?.displayName).toBe("Claude"); | ||
| 702 | expect(cursor?.displayName).toBe("Cursor"); | ||
| 703 | expect(grok?.displayName).toBe("Grok"); | ||
| 704 | expect(openCode?.displayName).toBe("OpenCode"); | ||
| 705 | |||
| 706 | // Every instance owns its own set of closures — no sharing across | ||
| 707 | // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all | ||
| 708 | // distinct references even when two instances happen to share a | ||
| 709 | // trait (e.g. Cursor + others all use a stub-or-real | ||
| 710 | // `textGeneration`; they must still be different object values). | ||
| 711 | const adapters = [ | ||
| 712 | codex!.orchestrationAdapter, | ||
| 713 | claude!.orchestrationAdapter, | ||
| 714 | cursor!.orchestrationAdapter, | ||
| 715 | grok!.orchestrationAdapter, | ||
| 716 | openCode!.orchestrationAdapter, | ||
| 717 | ]; | ||
| 718 | expect(new Set(adapters).size).toBe(adapters.length); | ||
| 719 | const textGenerations = [ | ||
| 720 | codex!.textGeneration, | ||
| 721 | claude!.textGeneration, | ||
| 722 | cursor!.textGeneration, | ||
| 723 | grok!.textGeneration, | ||
| 724 | openCode!.textGeneration, | ||
| 725 | ]; | ||
| 726 | expect(new Set(textGenerations).size).toBe(textGenerations.length); | ||
| 727 | const snapshots = [ | ||
| 728 | codex!.snapshot, | ||
| 729 | claude!.snapshot, | ||
| 730 | cursor!.snapshot, | ||
| 731 | grok!.snapshot, | ||
| 732 | openCode!.snapshot, | ||
| 733 | ]; | ||
| 734 | expect(new Set(snapshots).size).toBe(snapshots.length); | ||
| 735 | |||
| 736 | // Snapshots identify themselves by `instanceId` + `driver` so | ||
| 737 | // downstream aggregation in `ProviderRegistry` can tell instances | ||
| 738 | // apart even when two share a driver. With `enabled: false`, the | ||
| 739 | // check short-circuits and we get a disabled/pending snapshot back | ||
| 740 | // — that's enough signal to validate the stamping wrapper without | ||
| 741 | // spawning real binaries. | ||
| 742 | const codexSnapshot = yield* codex!.snapshot.getSnapshot; | ||
| 743 | expect(codexSnapshot.instanceId).toBe(codexId); | ||
| 744 | expect(codexSnapshot.driver).toBe(codexDriverKind); | ||
| 745 | expect(codexSnapshot.enabled).toBe(false); | ||
| 746 | expect(codexSnapshot.continuation?.groupKey).toBe( | ||
| 747 | `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, | ||
| 748 | ); | ||
| 749 | |||
| 750 | const claudeSnapshot = yield* claude!.snapshot.getSnapshot; | ||
| 751 | expect(claudeSnapshot.instanceId).toBe(claudeId); | ||
| 752 | expect(claudeSnapshot.driver).toBe(claudeDriverKind); | ||
| 753 | expect(claudeSnapshot.enabled).toBe(false); | ||
| 754 | expect(claudeSnapshot.continuation?.groupKey).toBe( | ||
| 755 | `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, | ||
| 756 | ); | ||
| 757 | |||
| 758 | const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; | ||
| 759 | expect(cursorSnapshot.instanceId).toBe(cursorId); | ||
| 760 | expect(cursorSnapshot.driver).toBe(cursorDriverKind); | ||
| 761 | expect(cursorSnapshot.enabled).toBe(false); | ||
| 762 | expect(cursorSnapshot.continuation?.groupKey).toBe( | ||
| 763 | `${cursorDriverKind}:instance:${cursorId}`, | ||
| 764 | ); | ||
| 765 | |||
| 766 | const grokSnapshot = yield* grok!.snapshot.getSnapshot; | ||
| 767 | expect(grokSnapshot.instanceId).toBe(grokId); | ||
| 768 | expect(grokSnapshot.driver).toBe(grokDriverKind); | ||
| 769 | expect(grokSnapshot.enabled).toBe(false); | ||
| 770 | expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); | ||
| 771 | |||
| 772 | const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; | ||
| 773 | expect(openCodeSnapshot.instanceId).toBe(openCodeId); | ||
| 774 | expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); | ||
| 775 | expect(openCodeSnapshot.enabled).toBe(false); | ||
| 776 | expect(openCodeSnapshot.continuation?.groupKey).toBe( | ||
| 777 | `${openCodeDriverKind}:instance:${openCodeId}`, | ||
| 778 | ); | ||
| 779 | }).pipe(Effect.provide(layerTest)), | ||
| 780 | ); | ||
| 781 | }); | ||
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts deleted-437| ... | @@ -1,437 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryLive — runtime implementation of | ||
| 3 | * `ProviderInstanceRegistry` plus its sibling mutator. | ||
| 4 | * | ||
| 5 | * Materializes every entry in a `ProviderInstanceConfigMap`: | ||
| 6 | * | ||
| 7 | * - When the entry's `driver` matches a registered driver, the registry | ||
| 8 | * decodes the opaque `config` envelope through `driver.configSchema` | ||
| 9 | * and calls `driver.create()` inside a fresh child scope. The | ||
| 10 | * resulting `ProviderInstance` is stored keyed by instance id, | ||
| 11 | * alongside its scope so the entry can be torn down independently. | ||
| 12 | * - When the entry's `driver` is unknown to this build (fork, rollback, | ||
| 13 | * in-flight PR branch), the registry emits an `"unavailable"` shadow | ||
| 14 | * `ServerProvider` snapshot instead of failing. This is what makes | ||
| 15 | * downgrades and fork-hopping safe per the | ||
| 16 | * `forward/backward compatibility invariant` in | ||
| 17 | * `packages/contracts/src/providerInstance.ts`. | ||
| 18 | * - When the entry's config fails schema decode, the registry logs and | ||
| 19 | * emits a shadow snapshot with the schema detail — same bucket as an | ||
| 20 | * unknown driver. | ||
| 21 | * | ||
| 22 | * Unlike the pre-Slice-D layer, the registry now holds mutable state | ||
| 23 | * (`Ref`s + `PubSub`) and exposes an internal mutator | ||
| 24 | * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a | ||
| 25 | * fresh config map against the live state, tearing down removed instances | ||
| 26 | * and building new ones without disturbing unaffected instances. | ||
| 27 | * | ||
| 28 | * Every live instance runs inside its own child `Scope`. The registry's | ||
| 29 | * own scope owns all child scopes via finalizers, so closing the registry | ||
| 30 | * tears every instance down in reverse order; closing a single instance | ||
| 31 | * (via `reconcile` removing it) leaves the rest untouched. | ||
| 32 | * | ||
| 33 | * @module provider/Layers/ProviderInstanceRegistryLive | ||
| 34 | */ | ||
| 35 | import { | ||
| 36 | providerInstanceConfigEnabledFlag, | ||
| 37 | ProviderInstanceId, | ||
| 38 | type ProviderInstanceConfig, | ||
| 39 | type ProviderInstanceConfigMap, | ||
| 40 | type ProviderDriverKind, | ||
| 41 | type ServerProvider, | ||
| 42 | } from "@t3tools/contracts"; | ||
| 43 | import * as Context from "effect/Context"; | ||
| 44 | import * as Effect from "effect/Effect"; | ||
| 45 | import * as Equal from "effect/Equal"; | ||
| 46 | import * as Exit from "effect/Exit"; | ||
| 47 | import * as Layer from "effect/Layer"; | ||
| 48 | import * as PubSub from "effect/PubSub"; | ||
| 49 | import * as Ref from "effect/Ref"; | ||
| 50 | import * as Schema from "effect/Schema"; | ||
| 51 | import * as Scope from "effect/Scope"; | ||
| 52 | import * as Stream from "effect/Stream"; | ||
| 53 | |||
| 54 | import { buildUnavailableProviderSnapshot } from "../unavailableProviderSnapshot.ts"; | ||
| 55 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 56 | import * as ProviderInstanceRegistryMutator from "../Services/ProviderInstanceRegistryMutator.ts"; | ||
| 57 | import type { AnyProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; | ||
| 58 | |||
| 59 | /** | ||
| 60 | * Live registry entry: the materialized `ProviderInstance` + the fresh | ||
| 61 | * child scope its `create` effect ran in + the original `entry` envelope | ||
| 62 | * so `reconcile` can cheaply detect "no-op" updates. | ||
| 63 | */ | ||
| 64 | interface LiveEntry { | ||
| 65 | readonly instance: ProviderInstance; | ||
| 66 | readonly scope: Scope.Closeable; | ||
| 67 | readonly entry: ProviderInstanceConfig; | ||
| 68 | } | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Internal state shared between the public registry service and the | ||
| 72 | * mutator service. Both services are thin shells around these refs. | ||
| 73 | */ | ||
| 74 | interface RegistryState { | ||
| 75 | readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>; | ||
| 76 | readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>; | ||
| 77 | readonly changes: PubSub.PubSub<void>; | ||
| 78 | } | ||
| 79 | |||
| 80 | /** | ||
| 81 | * Structural equality on `ProviderInstanceConfig` envelopes. Used by | ||
| 82 | * `reconcile` to skip rebuilds when settings arrive unchanged. Config | ||
| 83 | * payloads are opaque `unknown` at the envelope layer; `Equal.equals` | ||
| 84 | * falls back to structural equality for plain records, which matches how | ||
| 85 | * the schema decode output is constructed. | ||
| 86 | */ | ||
| 87 | const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => | ||
| 88 | Equal.equals(a, b); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Resolve an entry's enabled state. An explicit false on either the | ||
| 92 | * envelope or the raw config blob wins (most restrictive) — old settings | ||
| 93 | * files can carry both flags with conflicting values, and a user's disable | ||
| 94 | * must never be silently undone. Otherwise the envelope flag wins, then the | ||
| 95 | * decoded config's flag (which carries the driver schema's default for | ||
| 96 | * built-ins and forks alike), then enabled by default. | ||
| 97 | */ | ||
| 98 | const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { | ||
| 99 | const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); | ||
| 100 | if (entry.enabled === false || rawConfigEnabled === false) { | ||
| 101 | return false; | ||
| 102 | } | ||
| 103 | return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; | ||
| 104 | }; | ||
| 105 | |||
| 106 | /** | ||
| 107 | * Build one live entry from a raw config envelope. Returns either a | ||
| 108 | * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and | ||
| 109 | * undefined entry — callers dispatch to the appropriate Ref bucket. | ||
| 110 | */ | ||
| 111 | const buildEntry = <R>(input: { | ||
| 112 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 113 | readonly parentScope: Scope.Scope; | ||
| 114 | readonly instanceId: ProviderInstanceId; | ||
| 115 | readonly rawInstanceId: string; | ||
| 116 | readonly entry: ProviderInstanceConfig; | ||
| 117 | }): Effect.Effect< | ||
| 118 | | { readonly kind: "live"; readonly live: LiveEntry } | ||
| 119 | | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, | ||
| 120 | never, | ||
| 121 | R | ||
| 122 | > => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; | ||
| 125 | const driver = driversById.get(entry.driver); | ||
| 126 | if (!driver) { | ||
| 127 | return { | ||
| 128 | kind: "unavailable" as const, | ||
| 129 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 130 | driverKind: entry.driver, | ||
| 131 | instanceId, | ||
| 132 | displayName: entry.displayName, | ||
| 133 | accentColor: entry.accentColor, | ||
| 134 | reason: `Driver '${entry.driver}' is not registered in this build.`, | ||
| 135 | }), | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | const decoder = Schema.decodeUnknownEffect(driver.configSchema); | ||
| 140 | const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); | ||
| 141 | if (decodeResult._tag === "Failure") { | ||
| 142 | const issue = decodeResult.failure; | ||
| 143 | const detail = issue.message ?? String(issue); | ||
| 144 | yield* Effect.logError("Failed to decode provider instance config", { | ||
| 145 | instanceId: rawInstanceId, | ||
| 146 | driver: entry.driver, | ||
| 147 | detail, | ||
| 148 | }); | ||
| 149 | return { | ||
| 150 | kind: "unavailable" as const, | ||
| 151 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 152 | driverKind: entry.driver, | ||
| 153 | instanceId, | ||
| 154 | displayName: entry.displayName, | ||
| 155 | accentColor: entry.accentColor, | ||
| 156 | reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, | ||
| 157 | }), | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | |||
| 161 | const typedConfig = decodeResult.success; | ||
| 162 | const childScope = yield* Scope.make(); | ||
| 163 | // Attach the child scope to the registry's parent scope: if the | ||
| 164 | // registry scope closes, each surviving instance's child scope is | ||
| 165 | // closed through this finalizer. `reconcile` manually closes the | ||
| 166 | // child scope on remove/replace; subsequent close via the parent's | ||
| 167 | // finalizer is a no-op because `Scope.close` is idempotent. | ||
| 168 | yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); | ||
| 169 | |||
| 170 | const createResult = yield* driver | ||
| 171 | .create({ | ||
| 172 | instanceId, | ||
| 173 | displayName: entry.displayName, | ||
| 174 | accentColor: entry.accentColor, | ||
| 175 | environment: entry.environment ?? [], | ||
| 176 | enabled: resolveEntryEnabled(entry, typedConfig), | ||
| 177 | config: typedConfig, | ||
| 178 | }) | ||
| 179 | .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); | ||
| 180 | if (createResult._tag === "Failure") { | ||
| 181 | yield* Effect.logError("Failed to create provider instance", { | ||
| 182 | instanceId: rawInstanceId, | ||
| 183 | driver: entry.driver, | ||
| 184 | detail: createResult.failure.detail, | ||
| 185 | }); | ||
| 186 | yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); | ||
| 187 | return { | ||
| 188 | kind: "unavailable" as const, | ||
| 189 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 190 | driverKind: entry.driver, | ||
| 191 | instanceId, | ||
| 192 | displayName: entry.displayName, | ||
| 193 | accentColor: entry.accentColor, | ||
| 194 | reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, | ||
| 195 | }), | ||
| 196 | }; | ||
| 197 | } | ||
| 198 | |||
| 199 | return { | ||
| 200 | kind: "live" as const, | ||
| 201 | live: { | ||
| 202 | instance: createResult.success, | ||
| 203 | scope: childScope, | ||
| 204 | entry, | ||
| 205 | }, | ||
| 206 | }; | ||
| 207 | }); | ||
| 208 | |||
| 209 | /** | ||
| 210 | * Reconcile-only implementation of the mutator. Exposed to the hydration | ||
| 211 | * layer; never called directly by the rest of the server. | ||
| 212 | */ | ||
| 213 | const makeReconcile = <R>(input: { | ||
| 214 | readonly state: RegistryState; | ||
| 215 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 216 | readonly parentScope: Scope.Scope; | ||
| 217 | }): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => { | ||
| 218 | const { state, driversById, parentScope } = input; | ||
| 219 | return (configMap: ProviderInstanceConfigMap) => | ||
| 220 | Effect.gen(function* () { | ||
| 221 | const previousEntries = yield* Ref.get(state.entries); | ||
| 222 | const previousUnavailable = yield* Ref.get(state.unavailable); | ||
| 223 | const nextRaw = Object.entries(configMap); | ||
| 224 | const nextKeys = new Set<ProviderInstanceId>( | ||
| 225 | nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), | ||
| 226 | ); | ||
| 227 | |||
| 228 | // 1. Close scopes for instances that disappeared or whose config | ||
| 229 | // changed. Do this BEFORE creating replacements so ids map 1-to-1 | ||
| 230 | // to live scopes at all times. | ||
| 231 | const removedIds: Array<ProviderInstanceId> = []; | ||
| 232 | const replacedIds = new Set<ProviderInstanceId>(); | ||
| 233 | for (const [instanceId, live] of previousEntries) { | ||
| 234 | if (!nextKeys.has(instanceId)) { | ||
| 235 | removedIds.push(instanceId); | ||
| 236 | continue; | ||
| 237 | } | ||
| 238 | const nextEntry = configMap[instanceId]; | ||
| 239 | if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { | ||
| 240 | replacedIds.add(instanceId); | ||
| 241 | } | ||
| 242 | } | ||
| 243 | for (const id of [...removedIds, ...replacedIds]) { | ||
| 244 | const live = previousEntries.get(id); | ||
| 245 | if (live) { | ||
| 246 | yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | // 2. Build additions and replacements. Walk `nextRaw` so the final | ||
| 251 | // entry order follows settings-author order. | ||
| 252 | const builtEntries = new Map<ProviderInstanceId, LiveEntry>(); | ||
| 253 | const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>(); | ||
| 254 | let orderChanged = false; | ||
| 255 | const previousOrder = [...previousEntries.keys()]; | ||
| 256 | const nextOrder: Array<ProviderInstanceId> = []; | ||
| 257 | |||
| 258 | for (const [rawInstanceId, entry] of nextRaw) { | ||
| 259 | const instanceId = ProviderInstanceId.make(rawInstanceId); | ||
| 260 | nextOrder.push(instanceId); | ||
| 261 | |||
| 262 | const existing = previousEntries.get(instanceId); | ||
| 263 | if (existing !== undefined && !replacedIds.has(instanceId)) { | ||
| 264 | // No-op update: keep the existing live entry and scope. | ||
| 265 | builtEntries.set(instanceId, existing); | ||
| 266 | continue; | ||
| 267 | } | ||
| 268 | |||
| 269 | const result = yield* buildEntry({ | ||
| 270 | driversById, | ||
| 271 | parentScope, | ||
| 272 | instanceId, | ||
| 273 | rawInstanceId, | ||
| 274 | entry, | ||
| 275 | }); | ||
| 276 | if (result.kind === "live") { | ||
| 277 | builtEntries.set(instanceId, result.live); | ||
| 278 | } else { | ||
| 279 | builtUnavailable.set(instanceId, result.snapshot); | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | if (previousOrder.length === nextOrder.length) { | ||
| 284 | for (let i = 0; i < previousOrder.length; i++) { | ||
| 285 | if (previousOrder[i] !== nextOrder[i]) { | ||
| 286 | orderChanged = true; | ||
| 287 | break; | ||
| 288 | } | ||
| 289 | } | ||
| 290 | } else { | ||
| 291 | orderChanged = true; | ||
| 292 | } | ||
| 293 | |||
| 294 | const entriesChanged = | ||
| 295 | orderChanged || | ||
| 296 | removedIds.length > 0 || | ||
| 297 | replacedIds.size > 0 || | ||
| 298 | builtEntries.size !== previousEntries.size; | ||
| 299 | const unavailableChanged = | ||
| 300 | builtUnavailable.size !== previousUnavailable.size || | ||
| 301 | [...builtUnavailable].some(([id, snapshot]) => { | ||
| 302 | const prev = previousUnavailable.get(id); | ||
| 303 | return prev === undefined || !Equal.equals(prev, snapshot); | ||
| 304 | }) || | ||
| 305 | [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); | ||
| 306 | |||
| 307 | yield* Ref.set(state.entries, builtEntries); | ||
| 308 | yield* Ref.set(state.unavailable, builtUnavailable); | ||
| 309 | |||
| 310 | if (entriesChanged || unavailableChanged) { | ||
| 311 | yield* PubSub.publish(state.changes, undefined); | ||
| 312 | } | ||
| 313 | }); | ||
| 314 | }; | ||
| 315 | |||
| 316 | /** | ||
| 317 | * Build the registry's runtime state from a concrete configMap. Returns a | ||
| 318 | * record containing: | ||
| 319 | * | ||
| 320 | * - `registry`: the read-only `ProviderInstanceRegistryShape` to expose | ||
| 321 | * under `ProviderInstanceRegistry`. | ||
| 322 | * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose | ||
| 323 | * under `ProviderInstanceRegistryMutator`. | ||
| 324 | * - `reconcile`: the raw reconcile function, provided for convenience so | ||
| 325 | * boot-time layers can hydrate an initial map before publishing the | ||
| 326 | * services. | ||
| 327 | * | ||
| 328 | * The scope that this effect runs in owns every per-instance child scope | ||
| 329 | * created during `reconcile`. Closing that scope closes every live | ||
| 330 | * instance. | ||
| 331 | */ | ||
| 332 | export const makeProviderInstanceRegistry = <R>(input: { | ||
| 333 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 334 | readonly configMap: ProviderInstanceConfigMap; | ||
| 335 | }): Effect.Effect< | ||
| 336 | { | ||
| 337 | readonly registry: ProviderInstanceRegistry.ProviderInstanceRegistryShape; | ||
| 338 | readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; | ||
| 339 | }, | ||
| 340 | never, | ||
| 341 | R | Scope.Scope | ||
| 342 | > => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>( | ||
| 345 | input.drivers.map((driver) => [driver.driverKind, driver]), | ||
| 346 | ); | ||
| 347 | |||
| 348 | // Capture the enclosing scope so per-instance child scopes can be | ||
| 349 | // attached to it at `reconcile` time. Without this, `reconcile` | ||
| 350 | // called later (e.g. from the hydration layer) would attach child | ||
| 351 | // scopes to the *caller's* scope instead of the registry's. | ||
| 352 | const parentScope = yield* Scope.Scope; | ||
| 353 | |||
| 354 | // Capture the driver R context at construction time so `reconcile` | ||
| 355 | // can be invoked later without re-providing driver dependencies. | ||
| 356 | // The service tag's declared `reconcile: Effect<void>` hides R from | ||
| 357 | // consumers — we materialize that here. | ||
| 358 | const driverContext = yield* Effect.context<R>(); | ||
| 359 | |||
| 360 | const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map()); | ||
| 361 | const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map()); | ||
| 362 | const changes = yield* PubSub.unbounded<void>(); | ||
| 363 | yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); | ||
| 364 | |||
| 365 | const state: RegistryState = { entries, unavailable, changes }; | ||
| 366 | const reconcileWithR = makeReconcile({ state, driversById, parentScope }); | ||
| 367 | const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = | ||
| 368 | (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); | ||
| 369 | |||
| 370 | // Hydrate the initial configMap synchronously so callers can read | ||
| 371 | // `listInstances` immediately after this effect completes. | ||
| 372 | yield* reconcile(input.configMap); | ||
| 373 | |||
| 374 | const registry: ProviderInstanceRegistry.ProviderInstanceRegistryShape = { | ||
| 375 | getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), | ||
| 376 | listInstances: Ref.get(entries).pipe( | ||
| 377 | Effect.map( | ||
| 378 | (map) => | ||
| 379 | Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>, | ||
| 380 | ), | ||
| 381 | ), | ||
| 382 | listUnavailable: Ref.get(unavailable).pipe( | ||
| 383 | Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>), | ||
| 384 | ), | ||
| 385 | // Getters: each read constructs a fresh Stream / Effect descriptor | ||
| 386 | // so multiple consumers don't share a single already-started | ||
| 387 | // Channel or subscription. Matches the pattern `ProviderRegistry` | ||
| 388 | // uses for its own `streamChanges`. | ||
| 389 | get streamChanges() { | ||
| 390 | return Stream.fromPubSub(changes); | ||
| 391 | }, | ||
| 392 | // Synchronous subscribe — callers that need to consume changes | ||
| 393 | // from a forked fibre must acquire the subscription in their own | ||
| 394 | // fibre first (via `yield* registry.subscribeChanges`) and only | ||
| 395 | // then fork a consumer loop on `Stream.fromSubscription(...)` / | ||
| 396 | // `PubSub.take(...)`. See the shape docs for the race this avoids. | ||
| 397 | get subscribeChanges() { | ||
| 398 | return PubSub.subscribe(changes); | ||
| 399 | }, | ||
| 400 | }; | ||
| 401 | |||
| 402 | const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { | ||
| 403 | reconcile, | ||
| 404 | }; | ||
| 405 | |||
| 406 | return { registry, mutator }; | ||
| 407 | }); | ||
| 408 | |||
| 409 | /** | ||
| 410 | * Layer variant that also exposes the mutator tag. Consumed by | ||
| 411 | * `ProviderInstanceRegistryHydrationLive` to reconcile on settings | ||
| 412 | * changes. Tests that exercise the mutator directly can pair this Layer | ||
| 413 | * with a test-local `ServerSettingsService`. | ||
| 414 | */ | ||
| 415 | export const layer = <R>(input: { | ||
| 416 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 417 | readonly configMap: ProviderInstanceConfigMap; | ||
| 418 | }): Layer.Layer< | ||
| 419 | | ProviderInstanceRegistry.ProviderInstanceRegistry | ||
| 420 | | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, | ||
| 421 | never, | ||
| 422 | R | ||
| 423 | > => | ||
| 424 | Layer.effectContext( | ||
| 425 | makeProviderInstanceRegistry(input).pipe( | ||
| 426 | Effect.map(({ registry, mutator }) => | ||
| 427 | Context.make(ProviderInstanceRegistry.ProviderInstanceRegistry, registry).pipe( | ||
| 428 | Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator), | ||
| 429 | ), | ||
| 430 | ), | ||
| 431 | ), | ||
| 432 | ) as Layer.Layer< | ||
| 433 | | ProviderInstanceRegistry.ProviderInstanceRegistry | ||
| 434 | | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, | ||
| 435 | never, | ||
| 436 | R | ||
| 437 | >; | ||
apps/server/src/provider/Layers/ProviderOrchestrationAdapterInfrastructure.ts deleted-27| ... | @@ -1,27 +0,0 @@ | ||
| 1 | import * as Layer from "effect/Layer"; | ||
| 2 | |||
| 3 | import * as ClaudeAdapterV2 from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | ||
| 4 | import * as CodexAdapterV2 from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; | ||
| 5 | import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; | ||
| 6 | import * as IdAllocator from "../../orchestration-v2/IdAllocator.ts"; | ||
| 7 | import * as ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; | ||
| 8 | |||
| 9 | export type ProviderOrchestrationAdapterInfrastructure = | ||
| 10 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner | ||
| 11 | | CodexAdapterV2.CodexAppServerClientFactory | ||
| 12 | | CursorAgentSdk.CursorAgentSdkRunner | ||
| 13 | | IdAllocator.IdAllocatorV2; | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Infrastructure shared by the V2 adapters materialized inside provider | ||
| 17 | * instances. `providerContinuationRequestsLayer` must be the same layer | ||
| 18 | * reference the orchestration runtime provides to its continuation worker so | ||
| 19 | * Effect layer memoization yields one shared queue. | ||
| 20 | */ | ||
| 21 | export const layer = Layer.mergeAll( | ||
| 22 | ClaudeAdapterV2.layerQueryRunner, | ||
| 23 | CodexAdapterV2.layerAppServerClientFactory, | ||
| 24 | CursorAgentSdk.layer, | ||
| 25 | IdAllocator.layer, | ||
| 26 | ProviderContinuationRequests.layer, | ||
| 27 | ); | ||
apps/server/src/provider/Layers/ProviderRegistry.test.ts deleted-3338| ... | @@ -1,3338 +0,0 @@ | ||
| 1 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | ||
| 2 | import * as CodexInstallation from "../CodexInstallation.ts"; | ||
| 3 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { describe, it, assert } from "@effect/vitest"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as PubSub from "effect/PubSub"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Scope from "effect/Scope"; | ||
| 17 | import * as Sink from "effect/Sink"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import * as TestClock from "effect/testing/TestClock"; | ||
| 20 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 21 | import { | ||
| 22 | EnvironmentId, | ||
| 23 | ClaudeSettings, | ||
| 24 | CodexSettings, | ||
| 25 | DEFAULT_SERVER_SETTINGS, | ||
| 26 | ProviderDriverKind, | ||
| 27 | ProviderInstanceId, | ||
| 28 | ServerSettings, | ||
| 29 | type ServerProvider, | ||
| 30 | type ServerProviderSlashCommand, | ||
| 31 | type ServerSettings as ContractServerSettings, | ||
| 32 | } from "@t3tools/contracts"; | ||
| 33 | import * as PlatformError from "effect/PlatformError"; | ||
| 34 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 35 | import { ChildProcessSpawner } from "effect/process"; | ||
| 36 | import { deepMerge } from "@t3tools/shared/Struct"; | ||
| 37 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 38 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 39 | |||
| 40 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | ||
| 41 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | ||
| 42 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 43 | import * as AntigravityInstallation from "../AntigravityInstallation.ts"; | ||
| 44 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 45 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; | ||
| 46 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 47 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | ||
| 48 | import * as OpenCodeServerLedger from "../OpenCodeServerLedger.ts"; | ||
| 49 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | ||
| 50 | import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; | ||
| 51 | import { | ||
| 52 | mergeProviderSnapshot, | ||
| 53 | mergeProviderSnapshots, | ||
| 54 | selectProvidersByKind, | ||
| 55 | upsertProviderWorkspaceSnapshot, | ||
| 56 | } from "./ProviderRegistry.ts"; | ||
| 57 | import * as ProviderRegistryLayer from "./ProviderRegistry.ts"; | ||
| 58 | import * as ServerConfig from "../../config.ts"; | ||
| 59 | import * as ServerSettingsModule from "../../serverSettings.ts"; | ||
| 60 | import { | ||
| 61 | readProviderStatusCache, | ||
| 62 | resolveProviderStatusCachePath, | ||
| 63 | writeProviderStatusCache, | ||
| 64 | } from "../providerStatusCache.ts"; | ||
| 65 | import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; | ||
| 66 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | ||
| 67 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 68 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; | ||
| 69 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 70 | const decodeServerSettings = Schema.decodeSync(ServerSettings); | ||
| 71 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 72 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); | ||
| 73 | |||
| 74 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | ||
| 75 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | ||
| 76 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | ||
| 77 | const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ | ||
| 78 | enabled: false, | ||
| 79 | }); | ||
| 80 | |||
| 81 | process.env.T3CODE_CURSOR_ENABLED = "1"; | ||
| 82 | |||
| 83 | // ── Test helpers ──────────────────────────────────────────────────── | ||
| 84 | |||
| 85 | const encoder = new TextEncoder(); | ||
| 86 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 87 | const withBundledCompatibility = (snapshot: ServerProvider) => | ||
| 88 | applyProviderCompatibility( | ||
| 89 | snapshot, | ||
| 90 | undefined, | ||
| 91 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 92 | ); | ||
| 93 | |||
| 94 | // Provider metadata checks use a bundled manifest and stubbed HTTP. | ||
| 95 | const layerTestHttpClient = Layer.succeed( | ||
| 96 | HttpClient.HttpClient, | ||
| 97 | HttpClient.make((request) => | ||
| 98 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | ||
| 99 | ), | ||
| 100 | ).pipe(Layer.provideMerge(ModelManifest.layerTest)); | ||
| 101 | |||
| 102 | const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 103 | reportClientActivity: () => Effect.void, | ||
| 104 | removeRpcClient: () => Effect.void, | ||
| 105 | reportHostPowerState: () => Effect.void, | ||
| 106 | snapshot: Effect.succeed({ | ||
| 107 | hostPower: { | ||
| 108 | source: "unknown", | ||
| 109 | idle: "unknown", | ||
| 110 | idleSeconds: null, | ||
| 111 | locked: "unknown", | ||
| 112 | suspended: false, | ||
| 113 | onBattery: "unknown", | ||
| 114 | lowPowerMode: "unknown", | ||
| 115 | thermalState: "unknown", | ||
| 116 | stale: true, | ||
| 117 | updatedAt: TEST_EPOCH, | ||
| 118 | }, | ||
| 119 | leases: [], | ||
| 120 | activeForegroundLeaseCount: 0, | ||
| 121 | activeScopeKeys: [], | ||
| 122 | shouldRunOpportunisticWork: true, | ||
| 123 | updatedAt: TEST_EPOCH, | ||
| 124 | }), | ||
| 125 | streamChanges: Stream.empty, | ||
| 126 | hasDemand: () => Effect.succeed(true), | ||
| 127 | shouldRunScopeWork: () => Effect.succeed(true), | ||
| 128 | shouldRunOpportunisticWork: Effect.succeed(true), | ||
| 129 | }); | ||
| 130 | |||
| 131 | function selectDescriptor( | ||
| 132 | id: string, | ||
| 133 | label: string, | ||
| 134 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 135 | ) { | ||
| 136 | return { | ||
| 137 | id, | ||
| 138 | label, | ||
| 139 | type: "select" as const, | ||
| 140 | options: [...options], | ||
| 141 | ...(options.find((option) => option.isDefault)?.id | ||
| 142 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 143 | : {}), | ||
| 144 | }; | ||
| 145 | } | ||
| 146 | |||
| 147 | function booleanDescriptor(id: string, label: string) { | ||
| 148 | return { | ||
| 149 | id, | ||
| 150 | label, | ||
| 151 | type: "boolean" as const, | ||
| 152 | }; | ||
| 153 | } | ||
| 154 | |||
| 155 | type TestClaudeCapabilities = { | ||
| 156 | readonly email: string | undefined; | ||
| 157 | readonly subscriptionType: string | undefined; | ||
| 158 | readonly tokenSource: string | undefined; | ||
| 159 | readonly apiProvider: string | undefined; | ||
| 160 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 161 | }; | ||
| 162 | |||
| 163 | function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { | ||
| 164 | return () => | ||
| 165 | Effect.succeed({ | ||
| 166 | email: undefined, | ||
| 167 | subscriptionType: undefined, | ||
| 168 | tokenSource: undefined, | ||
| 169 | apiProvider: undefined, | ||
| 170 | slashCommands: [], | ||
| 171 | ...overrides, | ||
| 172 | }); | ||
| 173 | } | ||
| 174 | |||
| 175 | const noClaudeCapabilities = () => | ||
| 176 | Effect.sync(() => undefined as TestClaudeCapabilities | undefined); | ||
| 177 | |||
| 178 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | ||
| 179 | return ChildProcessSpawner.makeHandle({ | ||
| 180 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 181 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), | ||
| 182 | isRunning: Effect.succeed(false), | ||
| 183 | kill: () => Effect.void, | ||
| 184 | unref: Effect.succeed(Effect.void), | ||
| 185 | stdin: Sink.drain, | ||
| 186 | stdout: Stream.make(encoder.encode(result.stdout)), | ||
| 187 | stderr: Stream.make(encoder.encode(result.stderr)), | ||
| 188 | all: Stream.empty, | ||
| 189 | getInputFd: () => Sink.drain, | ||
| 190 | getOutputFd: () => Stream.empty, | ||
| 191 | }); | ||
| 192 | } | ||
| 193 | |||
| 194 | function layerMockSpawner( | ||
| 195 | handler: (args: ReadonlyArray<string>) => { | ||
| 196 | stdout: string; | ||
| 197 | stderr: string; | ||
| 198 | code: number; | ||
| 199 | }, | ||
| 200 | ) { | ||
| 201 | return Layer.succeed( | ||
| 202 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 203 | ChildProcessSpawner.make((command) => { | ||
| 204 | const cmd = command as unknown as { args: ReadonlyArray<string> }; | ||
| 205 | return Effect.succeed(mockHandle(handler(cmd.args))); | ||
| 206 | }), | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | |||
| 210 | function recordingMockSpawnerLayer( | ||
| 211 | handler: (args: ReadonlyArray<string>) => { | ||
| 212 | stdout: string; | ||
| 213 | stderr: string; | ||
| 214 | code: number; | ||
| 215 | }, | ||
| 216 | ) { | ||
| 217 | const commands: Array<{ | ||
| 218 | readonly args: ReadonlyArray<string>; | ||
| 219 | readonly env: NodeJS.ProcessEnv | undefined; | ||
| 220 | }> = []; | ||
| 221 | const layer = Layer.succeed( | ||
| 222 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 223 | ChildProcessSpawner.make((command) => { | ||
| 224 | const cmd = command as unknown as { | ||
| 225 | args: ReadonlyArray<string>; | ||
| 226 | options?: { | ||
| 227 | readonly env?: NodeJS.ProcessEnv; | ||
| 228 | }; | ||
| 229 | }; | ||
| 230 | commands.push({ args: cmd.args, env: cmd.options?.env }); | ||
| 231 | return Effect.succeed(mockHandle(handler(cmd.args))); | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | return { layer, commands }; | ||
| 235 | } | ||
| 236 | |||
| 237 | function layerMockCommandSpawner( | ||
| 238 | handler: ( | ||
| 239 | command: string, | ||
| 240 | args: ReadonlyArray<string>, | ||
| 241 | ) => { stdout: string; stderr: string; code: number }, | ||
| 242 | ) { | ||
| 243 | return Layer.succeed( | ||
| 244 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 245 | ChildProcessSpawner.make((command) => { | ||
| 246 | const cmd = command as unknown as { | ||
| 247 | command: string; | ||
| 248 | args: ReadonlyArray<string>; | ||
| 249 | }; | ||
| 250 | return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | } | ||
| 254 | |||
| 255 | function layerFailingSpawner(description: string) { | ||
| 256 | return Layer.succeed( | ||
| 257 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 258 | ChildProcessSpawner.make(() => | ||
| 259 | Effect.fail( | ||
| 260 | PlatformError.systemError({ | ||
| 261 | _tag: "NotFound", | ||
| 262 | module: "ChildProcess", | ||
| 263 | method: "spawn", | ||
| 264 | description, | ||
| 265 | }), | ||
| 266 | ), | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | function layerHangingScopedSpawner(killCalls: Ref.Ref<number>) { | ||
| 272 | return Layer.succeed( | ||
| 273 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 274 | ChildProcessSpawner.make(() => | ||
| 275 | Effect.gen(function* () { | ||
| 276 | const handle = ChildProcessSpawner.makeHandle({ | ||
| 277 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 278 | exitCode: Effect.never, | ||
| 279 | isRunning: Effect.succeed(true), | ||
| 280 | kill: () => Ref.update(killCalls, (current) => current + 1), | ||
| 281 | unref: Effect.succeed(Effect.void), | ||
| 282 | stdin: Sink.drain, | ||
| 283 | stdout: Stream.never, | ||
| 284 | stderr: Stream.never, | ||
| 285 | all: Stream.never, | ||
| 286 | getInputFd: () => Sink.drain, | ||
| 287 | getOutputFd: () => Stream.empty, | ||
| 288 | }); | ||
| 289 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 290 | return handle; | ||
| 291 | }), | ||
| 292 | ), | ||
| 293 | ); | ||
| 294 | } | ||
| 295 | |||
| 296 | const codexModelCapabilities = createModelCapabilities({ | ||
| 297 | optionDescriptors: [ | ||
| 298 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 299 | { id: "high", label: "High", isDefault: true }, | ||
| 300 | { id: "low", label: "Low" }, | ||
| 301 | ]), | ||
| 302 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 303 | ], | ||
| 304 | }) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>; | ||
| 305 | |||
| 306 | function makeCodexProbeSnapshot( | ||
| 307 | input: Partial<CodexAppServerProviderSnapshot> = {}, | ||
| 308 | ): CodexAppServerProviderSnapshot { | ||
| 309 | return { | ||
| 310 | version: "1.0.0", | ||
| 311 | account: { | ||
| 312 | account: { | ||
| 313 | type: "chatgpt", | ||
| 314 | email: "test@example.com", | ||
| 315 | planType: "pro", | ||
| 316 | }, | ||
| 317 | requiresOpenaiAuth: false, | ||
| 318 | }, | ||
| 319 | models: [ | ||
| 320 | { | ||
| 321 | slug: "gpt-live-codex", | ||
| 322 | name: "GPT Live Codex", | ||
| 323 | isCustom: false, | ||
| 324 | capabilities: codexModelCapabilities, | ||
| 325 | }, | ||
| 326 | ], | ||
| 327 | skills: [], | ||
| 328 | ...input, | ||
| 329 | }; | ||
| 330 | } | ||
| 331 | |||
| 332 | function makeMutableServerSettingsService( | ||
| 333 | initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, | ||
| 334 | ) { | ||
| 335 | return Effect.gen(function* () { | ||
| 336 | const settingsRef = yield* Ref.make(initial); | ||
| 337 | const changes = yield* PubSub.unbounded<ContractServerSettings>(); | ||
| 338 | |||
| 339 | return { | ||
| 340 | start: Effect.void, | ||
| 341 | ready: Effect.void, | ||
| 342 | getSettings: Ref.get(settingsRef), | ||
| 343 | updateSettings: (patch) => | ||
| 344 | Effect.gen(function* () { | ||
| 345 | const current = yield* Ref.get(settingsRef); | ||
| 346 | const next = applyServerSettingsPatch(current, patch); | ||
| 347 | encodeServerSettings(next); | ||
| 348 | yield* Ref.set(settingsRef, next); | ||
| 349 | yield* PubSub.publish(changes, next); | ||
| 350 | return next; | ||
| 351 | }), | ||
| 352 | updateProviderInstance: (mutation, patch = {}) => | ||
| 353 | Effect.gen(function* () { | ||
| 354 | const current = yield* Ref.get(settingsRef); | ||
| 355 | const next = ServerSettingsModule.applyProviderInstanceMutation( | ||
| 356 | applyServerSettingsPatch(current, patch), | ||
| 357 | mutation, | ||
| 358 | ); | ||
| 359 | encodeServerSettings(next); | ||
| 360 | yield* Ref.set(settingsRef, next); | ||
| 361 | yield* PubSub.publish(changes, next); | ||
| 362 | return next; | ||
| 363 | }), | ||
| 364 | withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)), | ||
| 365 | get streamChanges() { | ||
| 366 | return Stream.fromPubSub(changes); | ||
| 367 | }, | ||
| 368 | get subscribeChanges() { | ||
| 369 | return PubSub.subscribe(changes).pipe( | ||
| 370 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | ||
| 371 | ); | ||
| 372 | }, | ||
| 373 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; | ||
| 374 | }); | ||
| 375 | } | ||
| 376 | |||
| 377 | // The registry writes the status cache and only then publishes the change, so | ||
| 378 | // a subscriber that sees `checkedAt` on the stream knows the file is on disk. | ||
| 379 | // Subscribed before the publish that triggers it; a spin on the file would | ||
| 380 | // race the write and lose on a slow host. | ||
| 381 | const awaitPersistedProvider = ( | ||
| 382 | registry: ProviderRegistry.ProviderRegistry["Service"], | ||
| 383 | checkedAt: string, | ||
| 384 | ) => | ||
| 385 | registry.streamChanges.pipe( | ||
| 386 | Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)), | ||
| 387 | Stream.take(1), | ||
| 388 | Stream.runDrain, | ||
| 389 | Effect.forkScoped, | ||
| 390 | ); | ||
| 391 | |||
| 392 | const layerTestNodeServices = Layer.mergeAll( | ||
| 393 | NodeServices.layer, | ||
| 394 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 395 | managedDirectory: "unused-managed-installation", | ||
| 396 | }), | ||
| 397 | Layer.mock(ServerSecretStore.ServerSecretStore)({}), | ||
| 398 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 399 | getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), | ||
| 400 | }), | ||
| 401 | ); | ||
| 402 | |||
| 403 | it.layer( | ||
| 404 | Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), | ||
| 405 | )("ProviderRegistry", (it) => { | ||
| 406 | describe("checkCodexProviderStatus", () => { | ||
| 407 | it.effect("uses the app-server account and model list for provider status", () => | ||
| 408 | Effect.gen(function* () { | ||
| 409 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 410 | Effect.succeed( | ||
| 411 | makeCodexProbeSnapshot({ | ||
| 412 | skills: [ | ||
| 413 | { | ||
| 414 | name: "github:gh-fix-ci", | ||
| 415 | path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", | ||
| 416 | enabled: true, | ||
| 417 | displayName: "CI Debug", | ||
| 418 | shortDescription: "Debug failing GitHub Actions checks", | ||
| 419 | }, | ||
| 420 | ], | ||
| 421 | }), | ||
| 422 | ), | ||
| 423 | ); | ||
| 424 | assert.strictEqual(status.status, "ready"); | ||
| 425 | assert.strictEqual(status.installed, true); | ||
| 426 | assert.strictEqual(status.version, "1.0.0"); | ||
| 427 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 428 | assert.strictEqual(status.auth.type, "chatgpt"); | ||
| 429 | assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); | ||
| 430 | assert.strictEqual(status.auth.email, "test@example.com"); | ||
| 431 | assert.deepStrictEqual(status.models, [ | ||
| 432 | { | ||
| 433 | slug: "gpt-live-codex", | ||
| 434 | name: "GPT Live Codex", | ||
| 435 | isCustom: false, | ||
| 436 | capabilities: codexModelCapabilities, | ||
| 437 | }, | ||
| 438 | ]); | ||
| 439 | assert.deepStrictEqual(status.skills, [ | ||
| 440 | { | ||
| 441 | name: "github:gh-fix-ci", | ||
| 442 | path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", | ||
| 443 | enabled: true, | ||
| 444 | displayName: "CI Debug", | ||
| 445 | shortDescription: "Debug failing GitHub Actions checks", | ||
| 446 | }, | ||
| 447 | ]); | ||
| 448 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | ||
| 449 | { | ||
| 450 | name: "goal", | ||
| 451 | description: "Set a goal Codex keeps working toward until it is done", | ||
| 452 | input: { hint: "Objective, or pause, resume, clear" }, | ||
| 453 | }, | ||
| 454 | { | ||
| 455 | name: "feedback", | ||
| 456 | description: "Send this thread and Codex logs to OpenAI", | ||
| 457 | input: { hint: "Describe the issue (optional)" }, | ||
| 458 | }, | ||
| 459 | ]); | ||
| 460 | }), | ||
| 461 | ); | ||
| 462 | |||
| 463 | it.effect("passes configured launch args to the Codex provider probe", () => | ||
| 464 | Effect.gen(function* () { | ||
| 465 | let observedLaunchArgs: string | undefined; | ||
| 466 | const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); | ||
| 467 | |||
| 468 | const status = yield* checkCodexProviderStatus(settings, (input) => { | ||
| 469 | observedLaunchArgs = input.launchArgs; | ||
| 470 | return Effect.succeed(makeCodexProbeSnapshot()); | ||
| 471 | }); | ||
| 472 | |||
| 473 | assert.strictEqual(status.status, "ready"); | ||
| 474 | assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); | ||
| 475 | }), | ||
| 476 | ); | ||
| 477 | |||
| 478 | it.effect("returns unauthenticated when app-server requires OpenAI auth", () => | ||
| 479 | Effect.gen(function* () { | ||
| 480 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 481 | Effect.succeed( | ||
| 482 | makeCodexProbeSnapshot({ | ||
| 483 | account: { | ||
| 484 | account: null, | ||
| 485 | requiresOpenaiAuth: true, | ||
| 486 | }, | ||
| 487 | }), | ||
| 488 | ), | ||
| 489 | ); | ||
| 490 | |||
| 491 | assert.strictEqual(status.status, "error"); | ||
| 492 | assert.strictEqual(status.auth.status, "unauthenticated"); | ||
| 493 | assert.strictEqual( | ||
| 494 | status.message, | ||
| 495 | "Codex CLI is not authenticated. Run `codex login` and try again.", | ||
| 496 | ); | ||
| 497 | }), | ||
| 498 | ); | ||
| 499 | |||
| 500 | it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => | ||
| 501 | Effect.gen(function* () { | ||
| 502 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 503 | Effect.succeed( | ||
| 504 | makeCodexProbeSnapshot({ | ||
| 505 | account: { | ||
| 506 | account: null, | ||
| 507 | requiresOpenaiAuth: false, | ||
| 508 | }, | ||
| 509 | }), | ||
| 510 | ), | ||
| 511 | ); | ||
| 512 | |||
| 513 | assert.strictEqual(status.status, "ready"); | ||
| 514 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 515 | }), | ||
| 516 | ); | ||
| 517 | |||
| 518 | it.effect("returns an api key label for codex api key auth", () => | ||
| 519 | Effect.gen(function* () { | ||
| 520 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 521 | Effect.succeed( | ||
| 522 | makeCodexProbeSnapshot({ | ||
| 523 | account: { | ||
| 524 | account: { type: "apiKey" }, | ||
| 525 | requiresOpenaiAuth: false, | ||
| 526 | }, | ||
| 527 | }), | ||
| 528 | ), | ||
| 529 | ); | ||
| 530 | |||
| 531 | assert.strictEqual(status.status, "ready"); | ||
| 532 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 533 | assert.strictEqual(status.auth.type, "apiKey"); | ||
| 534 | assert.strictEqual(status.auth.label, "OpenAI API Key"); | ||
| 535 | }), | ||
| 536 | ); | ||
| 537 | |||
| 538 | it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => | ||
| 539 | Effect.gen(function* () { | ||
| 540 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 541 | Effect.succeed( | ||
| 542 | makeCodexProbeSnapshot({ | ||
| 543 | account: { | ||
| 544 | account: { type: "amazonBedrock" }, | ||
| 545 | requiresOpenaiAuth: false, | ||
| 546 | }, | ||
| 547 | }), | ||
| 548 | ), | ||
| 549 | ); | ||
| 550 | |||
| 551 | assert.strictEqual(status.status, "ready"); | ||
| 552 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 553 | assert.strictEqual(status.auth.type, "amazonBedrock"); | ||
| 554 | assert.strictEqual(status.auth.label, "Amazon Bedrock"); | ||
| 555 | }), | ||
| 556 | ); | ||
| 557 | |||
| 558 | it.effect.each([ | ||
| 559 | "codex", | ||
| 560 | "/Applications/Custom App.app/Contents/Resources/codex", | ||
| 561 | "C:\\Tools\\codex.exe", | ||
| 562 | ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => | ||
| 563 | Effect.gen(function* () { | ||
| 564 | const settings = { ...defaultCodexSettings, binaryPath }; | ||
| 565 | const status = yield* checkCodexProviderStatus(settings, (input) => { | ||
| 566 | assert.strictEqual(input.binaryPath, binaryPath); | ||
| 567 | return Effect.fail( | ||
| 568 | new CodexErrors.CodexAppServerSpawnError({ | ||
| 569 | command: `${binaryPath} app-server`, | ||
| 570 | cause: new Error("spawn ENOENT"), | ||
| 571 | }), | ||
| 572 | ); | ||
| 573 | }); | ||
| 574 | assert.strictEqual(status.status, "error"); | ||
| 575 | assert.strictEqual(status.installed, false); | ||
| 576 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 577 | assert.include(status.message, binaryPath); | ||
| 578 | assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); | ||
| 579 | assert.strictEqual( | ||
| 580 | status.message?.includes("Installing ChatGPT or Codex desktop"), | ||
| 581 | binaryPath === "codex", | ||
| 582 | ); | ||
| 583 | assert.strictEqual(settings.binaryPath, binaryPath); | ||
| 584 | }), | ||
| 585 | ); | ||
| 586 | |||
| 587 | it.effect("closes the app-server probe scope when provider status times out", () => | ||
| 588 | Effect.gen(function* () { | ||
| 589 | const killCalls = yield* Ref.make(0); | ||
| 590 | const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( | ||
| 591 | Effect.provide(layerHangingScopedSpawner(killCalls)), | ||
| 592 | Effect.forkChild, | ||
| 593 | ); | ||
| 594 | |||
| 595 | yield* Effect.yieldNow; | ||
| 596 | yield* TestClock.adjust("11 seconds"); | ||
| 597 | yield* Effect.yieldNow; | ||
| 598 | |||
| 599 | const status = yield* Fiber.join(statusFiber); | ||
| 600 | assert.strictEqual(status.status, "error"); | ||
| 601 | assert.strictEqual( | ||
| 602 | status.message, | ||
| 603 | "Timed out while checking Codex app-server provider status.", | ||
| 604 | ); | ||
| 605 | assert.strictEqual(yield* Ref.get(killCalls), 1); | ||
| 606 | }), | ||
| 607 | ); | ||
| 608 | }); | ||
| 609 | |||
| 610 | describe("ProviderRegistryLive", () => { | ||
| 611 | it("stores workspace skills and commands without changing machine metadata", () => { | ||
| 612 | const provider = { | ||
| 613 | instanceId: ProviderInstanceId.make("codex"), | ||
| 614 | driver: ProviderDriverKind.make("codex"), | ||
| 615 | status: "ready", | ||
| 616 | enabled: true, | ||
| 617 | installed: true, | ||
| 618 | auth: { status: "authenticated" }, | ||
| 619 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 620 | version: "1.0.0", | ||
| 621 | models: [], | ||
| 622 | slashCommands: [{ name: "global" }], | ||
| 623 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 624 | } satisfies ServerProvider; | ||
| 625 | const scopedSnapshot = { | ||
| 626 | ...provider, | ||
| 627 | checkedAt: "2026-03-25T00:01:00.000Z", | ||
| 628 | slashCommands: [{ name: "project" }], | ||
| 629 | skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], | ||
| 630 | } satisfies ServerProvider; | ||
| 631 | |||
| 632 | const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot); | ||
| 633 | |||
| 634 | assert.deepStrictEqual(result.slashCommands, provider.slashCommands); | ||
| 635 | assert.deepStrictEqual(result.skills, provider.skills); | ||
| 636 | assert.deepStrictEqual(result.workspaceSnapshots, [ | ||
| 637 | { | ||
| 638 | cwd: "/project", | ||
| 639 | checkedAt: scopedSnapshot.checkedAt, | ||
| 640 | slashCommands: scopedSnapshot.slashCommands, | ||
| 641 | skills: scopedSnapshot.skills, | ||
| 642 | }, | ||
| 643 | ]); | ||
| 644 | |||
| 645 | const pendingSnapshot = { | ||
| 646 | ...scopedSnapshot, | ||
| 647 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 648 | slashCommandsPending: true, | ||
| 649 | } satisfies ProviderWorkspaceSnapshot; | ||
| 650 | const partial = upsertProviderWorkspaceSnapshot(result, "/project", pendingSnapshot); | ||
| 651 | assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]); | ||
| 652 | assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 653 | const otherProject = upsertProviderWorkspaceSnapshot( | ||
| 654 | partial, | ||
| 655 | "/other-project", | ||
| 656 | pendingSnapshot, | ||
| 657 | ); | ||
| 658 | assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ | ||
| 659 | COMPACT_SLASH_COMMAND, | ||
| 660 | ]); | ||
| 661 | const recovered = upsertProviderWorkspaceSnapshot(partial, "/project", { | ||
| 662 | ...scopedSnapshot, | ||
| 663 | slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], | ||
| 664 | }); | ||
| 665 | assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 666 | COMPACT_SLASH_COMMAND, | ||
| 667 | { name: "replacement" }, | ||
| 668 | ]); | ||
| 669 | assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); | ||
| 670 | }); | ||
| 671 | |||
| 672 | it("preserves previously discovered provider models when a refresh returns none", () => { | ||
| 673 | const previousProvider = { | ||
| 674 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 675 | driver: ProviderDriverKind.make("cursor"), | ||
| 676 | status: "ready", | ||
| 677 | enabled: true, | ||
| 678 | installed: true, | ||
| 679 | auth: { status: "authenticated" }, | ||
| 680 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 681 | version: "2026.04.09-f2b0fcd", | ||
| 682 | models: [ | ||
| 683 | { | ||
| 684 | slug: "claude-opus-4-6", | ||
| 685 | name: "Opus 4.6", | ||
| 686 | isCustom: false, | ||
| 687 | capabilities: createModelCapabilities({ | ||
| 688 | optionDescriptors: [ | ||
| 689 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 690 | { id: "high", label: "High", isDefault: true }, | ||
| 691 | ]), | ||
| 692 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 693 | booleanDescriptor("thinking", "Thinking"), | ||
| 694 | ], | ||
| 695 | }), | ||
| 696 | }, | ||
| 697 | ], | ||
| 698 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 699 | skills: [ | ||
| 700 | { | ||
| 701 | name: "typescript", | ||
| 702 | description: "TypeScript help", | ||
| 703 | path: "/skills/typescript/SKILL.md", | ||
| 704 | enabled: true, | ||
| 705 | }, | ||
| 706 | ], | ||
| 707 | } as const satisfies ServerProvider; | ||
| 708 | const refreshedProvider = { | ||
| 709 | ...previousProvider, | ||
| 710 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 711 | models: [], | ||
| 712 | slashCommands: [], | ||
| 713 | skills: [], | ||
| 714 | } satisfies ServerProvider; | ||
| 715 | |||
| 716 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 717 | ...previousProvider.models, | ||
| 718 | ]); | ||
| 719 | assert.deepStrictEqual( | ||
| 720 | mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | ||
| 721 | [], | ||
| 722 | ); | ||
| 723 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).skills, []); | ||
| 724 | }); | ||
| 725 | |||
| 726 | it("drops custom models the refreshed snapshot no longer carries", () => { | ||
| 727 | const previousProvider = { | ||
| 728 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 729 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 730 | status: "ready", | ||
| 731 | enabled: true, | ||
| 732 | installed: true, | ||
| 733 | auth: { status: "authenticated" }, | ||
| 734 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 735 | version: "2.1.0", | ||
| 736 | models: [ | ||
| 737 | { | ||
| 738 | slug: "claude-sonnet-4-6", | ||
| 739 | name: "Sonnet 4.6", | ||
| 740 | isCustom: false, | ||
| 741 | capabilities: null, | ||
| 742 | }, | ||
| 743 | { | ||
| 744 | slug: "removed-custom", | ||
| 745 | name: "removed-custom", | ||
| 746 | isCustom: true, | ||
| 747 | capabilities: null, | ||
| 748 | }, | ||
| 749 | ], | ||
| 750 | slashCommands: [], | ||
| 751 | skills: [], | ||
| 752 | } as const satisfies ServerProvider; | ||
| 753 | const refreshedProvider = { | ||
| 754 | ...previousProvider, | ||
| 755 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 756 | models: [previousProvider.models[0]], | ||
| 757 | } satisfies ServerProvider; | ||
| 758 | |||
| 759 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 760 | ...refreshedProvider.models, | ||
| 761 | ]); | ||
| 762 | }); | ||
| 763 | |||
| 764 | it("drops stale ACP Registry models missing from a completed discovery probe", () => { | ||
| 765 | const previousProvider = { | ||
| 766 | instanceId: ProviderInstanceId.make("acpRegistry_codex"), | ||
| 767 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 768 | status: "ready", | ||
| 769 | enabled: true, | ||
| 770 | installed: true, | ||
| 771 | auth: { status: "authenticated" }, | ||
| 772 | checkedAt: "2026-08-13T00:00:00.000Z", | ||
| 773 | version: "1.2.0", | ||
| 774 | models: [ | ||
| 775 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 776 | { | ||
| 777 | slug: "gpt-5.6-sol[low]", | ||
| 778 | name: "GPT-5.6-Sol (low)", | ||
| 779 | isCustom: false, | ||
| 780 | capabilities: null, | ||
| 781 | }, | ||
| 782 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 783 | ], | ||
| 784 | slashCommands: [], | ||
| 785 | skills: [], | ||
| 786 | } as const satisfies ServerProvider; | ||
| 787 | const refreshedProvider = { | ||
| 788 | ...previousProvider, | ||
| 789 | checkedAt: "2026-08-13T00:01:00.000Z", | ||
| 790 | models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], | ||
| 791 | } satisfies ServerProvider; | ||
| 792 | |||
| 793 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 794 | ...refreshedProvider.models, | ||
| 795 | ]); | ||
| 796 | }); | ||
| 797 | |||
| 798 | it("retains ACP Registry models while discovery has not completed", () => { | ||
| 799 | const previousProvider = { | ||
| 800 | instanceId: ProviderInstanceId.make("acpRegistry_codex"), | ||
| 801 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 802 | status: "ready", | ||
| 803 | enabled: true, | ||
| 804 | installed: true, | ||
| 805 | auth: { status: "authenticated" }, | ||
| 806 | checkedAt: "2026-08-13T00:00:00.000Z", | ||
| 807 | version: "1.2.0", | ||
| 808 | models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], | ||
| 809 | slashCommands: [], | ||
| 810 | skills: [], | ||
| 811 | } as const satisfies ServerProvider; | ||
| 812 | const checkingProvider = { | ||
| 813 | ...previousProvider, | ||
| 814 | checkedAt: "2026-08-13T00:01:00.000Z", | ||
| 815 | auth: { status: "unknown" }, | ||
| 816 | models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], | ||
| 817 | } satisfies ServerProvider; | ||
| 818 | const failedProbeProvider = { | ||
| 819 | ...checkingProvider, | ||
| 820 | status: "warning", | ||
| 821 | } satisfies ServerProvider; | ||
| 822 | |||
| 823 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, checkingProvider).models, [ | ||
| 824 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 825 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 826 | ]); | ||
| 827 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, failedProbeProvider).models, [ | ||
| 828 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 829 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 830 | ]); | ||
| 831 | }); | ||
| 832 | |||
| 833 | it("drops stale OpenCode models missing from a successful refresh", () => { | ||
| 834 | const previousProvider = { | ||
| 835 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 836 | driver: ProviderDriverKind.make("opencode"), | ||
| 837 | status: "ready", | ||
| 838 | enabled: true, | ||
| 839 | installed: true, | ||
| 840 | auth: { status: "authenticated" }, | ||
| 841 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 842 | version: "1.0.0", | ||
| 843 | models: [ | ||
| 844 | { | ||
| 845 | slug: "github/gpt-5", | ||
| 846 | name: "GPT-5", | ||
| 847 | subProvider: "GitHub", | ||
| 848 | isCustom: false, | ||
| 849 | capabilities: null, | ||
| 850 | }, | ||
| 851 | { | ||
| 852 | slug: "removed-plugin/model", | ||
| 853 | name: "Removed Plugin Model", | ||
| 854 | subProvider: "Removed Plugin", | ||
| 855 | isCustom: false, | ||
| 856 | capabilities: null, | ||
| 857 | }, | ||
| 858 | ], | ||
| 859 | slashCommands: [], | ||
| 860 | skills: [], | ||
| 861 | } as const satisfies ServerProvider; | ||
| 862 | const refreshedProvider = { | ||
| 863 | ...previousProvider, | ||
| 864 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 865 | models: [ | ||
| 866 | { | ||
| 867 | slug: "github/gpt-5", | ||
| 868 | name: "GPT-5", | ||
| 869 | subProvider: "GitHub", | ||
| 870 | isCustom: false, | ||
| 871 | capabilities: null, | ||
| 872 | }, | ||
| 873 | ], | ||
| 874 | } satisfies ServerProvider; | ||
| 875 | |||
| 876 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 877 | ...refreshedProvider.models, | ||
| 878 | ]); | ||
| 879 | }); | ||
| 880 | |||
| 881 | it("retains stale OpenCode models when a refresh fails", () => { | ||
| 882 | const previousProvider = { | ||
| 883 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 884 | driver: ProviderDriverKind.make("opencode"), | ||
| 885 | status: "ready", | ||
| 886 | enabled: true, | ||
| 887 | installed: true, | ||
| 888 | auth: { status: "authenticated" }, | ||
| 889 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 890 | version: "1.0.0", | ||
| 891 | models: [ | ||
| 892 | { | ||
| 893 | slug: "github/gpt-5", | ||
| 894 | name: "GPT-5", | ||
| 895 | subProvider: "GitHub", | ||
| 896 | isCustom: false, | ||
| 897 | capabilities: null, | ||
| 898 | }, | ||
| 899 | ], | ||
| 900 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 901 | skills: [ | ||
| 902 | { | ||
| 903 | name: "typescript", | ||
| 904 | description: "TypeScript help", | ||
| 905 | path: "/skills/typescript/SKILL.md", | ||
| 906 | enabled: true, | ||
| 907 | }, | ||
| 908 | ], | ||
| 909 | } as const satisfies ServerProvider; | ||
| 910 | const refreshedProvider = { | ||
| 911 | ...previousProvider, | ||
| 912 | status: "error", | ||
| 913 | auth: { status: "unknown" }, | ||
| 914 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 915 | models: [], | ||
| 916 | message: "Failed to refresh OpenCode models.", | ||
| 917 | } satisfies ServerProvider; | ||
| 918 | |||
| 919 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 920 | ...previousProvider.models, | ||
| 921 | ]); | ||
| 922 | assert.deepStrictEqual( | ||
| 923 | mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | ||
| 924 | previousProvider.slashCommands, | ||
| 925 | ); | ||
| 926 | assert.deepStrictEqual( | ||
| 927 | mergeProviderSnapshot(previousProvider, refreshedProvider).skills, | ||
| 928 | previousProvider.skills, | ||
| 929 | ); | ||
| 930 | }); | ||
| 931 | |||
| 932 | it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { | ||
| 933 | const previousProvider = { | ||
| 934 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 935 | driver: ProviderDriverKind.make("opencode"), | ||
| 936 | status: "ready", | ||
| 937 | enabled: true, | ||
| 938 | installed: true, | ||
| 939 | auth: { status: "authenticated" }, | ||
| 940 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 941 | version: "1.0.0", | ||
| 942 | models: [ | ||
| 943 | { | ||
| 944 | slug: "github/gpt-5", | ||
| 945 | name: "GPT-5", | ||
| 946 | subProvider: "GitHub", | ||
| 947 | isCustom: false, | ||
| 948 | capabilities: null, | ||
| 949 | }, | ||
| 950 | { | ||
| 951 | slug: "removed-plugin/model", | ||
| 952 | name: "Removed Plugin Model", | ||
| 953 | subProvider: "Removed Plugin", | ||
| 954 | isCustom: false, | ||
| 955 | capabilities: null, | ||
| 956 | }, | ||
| 957 | ], | ||
| 958 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 959 | skills: [ | ||
| 960 | { | ||
| 961 | name: "typescript", | ||
| 962 | description: "TypeScript help", | ||
| 963 | path: "/skills/typescript/SKILL.md", | ||
| 964 | enabled: true, | ||
| 965 | }, | ||
| 966 | ], | ||
| 967 | } as const satisfies ServerProvider; | ||
| 968 | const pendingProvider = { | ||
| 969 | ...previousProvider, | ||
| 970 | status: "warning", | ||
| 971 | installed: false, | ||
| 972 | auth: { status: "unknown" }, | ||
| 973 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 974 | version: null, | ||
| 975 | models: [], | ||
| 976 | message: "OpenCode provider status has not been checked in this session yet.", | ||
| 977 | } satisfies ServerProvider; | ||
| 978 | const loggedOutProvider = { | ||
| 979 | ...previousProvider, | ||
| 980 | status: "warning", | ||
| 981 | auth: { status: "unknown" }, | ||
| 982 | checkedAt: "2026-07-17T00:02:00.000Z", | ||
| 983 | models: [], | ||
| 984 | slashCommands: [], | ||
| 985 | skills: [], | ||
| 986 | message: "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 987 | } satisfies ServerProvider; | ||
| 988 | const missingProvider = { | ||
| 989 | ...previousProvider, | ||
| 990 | status: "error", | ||
| 991 | installed: false, | ||
| 992 | auth: { status: "unknown" }, | ||
| 993 | checkedAt: "2026-07-17T00:03:00.000Z", | ||
| 994 | version: null, | ||
| 995 | models: [], | ||
| 996 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 997 | } satisfies ServerProvider; | ||
| 998 | const authoritativeProvider = { | ||
| 999 | ...previousProvider, | ||
| 1000 | checkedAt: "2026-07-17T00:04:00.000Z", | ||
| 1001 | models: [previousProvider.models[0]!], | ||
| 1002 | } satisfies ServerProvider; | ||
| 1003 | const failedProvider = { | ||
| 1004 | ...authoritativeProvider, | ||
| 1005 | status: "error", | ||
| 1006 | auth: { status: "unknown" }, | ||
| 1007 | checkedAt: "2026-07-17T00:05:00.000Z", | ||
| 1008 | models: [], | ||
| 1009 | message: "Failed to refresh OpenCode models.", | ||
| 1010 | } satisfies ServerProvider; | ||
| 1011 | |||
| 1012 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ | ||
| 1013 | ...previousProvider.models, | ||
| 1014 | ]); | ||
| 1015 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).models, []); | ||
| 1016 | assert.deepStrictEqual( | ||
| 1017 | mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, | ||
| 1018 | [], | ||
| 1019 | ); | ||
| 1020 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, []); | ||
| 1021 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); | ||
| 1022 | |||
| 1023 | const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); | ||
| 1024 | const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); | ||
| 1025 | |||
| 1026 | assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); | ||
| 1027 | }); | ||
| 1028 | |||
| 1029 | describe("Codex model inventories", () => { | ||
| 1030 | const cachedProvider = { | ||
| 1031 | instanceId: ProviderInstanceId.make("codex-personal"), | ||
| 1032 | driver: ProviderDriverKind.make("codex"), | ||
| 1033 | status: "ready", | ||
| 1034 | enabled: true, | ||
| 1035 | installed: true, | ||
| 1036 | auth: { status: "authenticated" }, | ||
| 1037 | checkedAt: "2026-09-04T19:00:00.000Z", | ||
| 1038 | version: "0.153.3", | ||
| 1039 | models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map( | ||
| 1040 | (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }), | ||
| 1041 | ), | ||
| 1042 | slashCommands: [], | ||
| 1043 | skills: [], | ||
| 1044 | } satisfies ServerProvider; | ||
| 1045 | const customModel = { | ||
| 1046 | slug: "custom-model", | ||
| 1047 | name: "Custom model", | ||
| 1048 | isCustom: true, | ||
| 1049 | capabilities: null, | ||
| 1050 | } as const; | ||
| 1051 | const refreshedProvider = { | ||
| 1052 | ...cachedProvider, | ||
| 1053 | checkedAt: "2026-09-04T19:01:00.000Z", | ||
| 1054 | models: [ | ||
| 1055 | { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, | ||
| 1056 | cachedProvider.models[0]!, | ||
| 1057 | customModel, | ||
| 1058 | ], | ||
| 1059 | } satisfies ServerProvider; | ||
| 1060 | const pendingProvider = { | ||
| 1061 | ...cachedProvider, | ||
| 1062 | status: "warning", | ||
| 1063 | installed: false, | ||
| 1064 | auth: { status: "unknown" }, | ||
| 1065 | models: [customModel], | ||
| 1066 | } satisfies ServerProvider; | ||
| 1067 | const failedProvider = { | ||
| 1068 | ...pendingProvider, | ||
| 1069 | checkedAt: "2026-09-04T19:02:00.000Z", | ||
| 1070 | status: "error", | ||
| 1071 | installed: true, | ||
| 1072 | } satisfies ServerProvider; | ||
| 1073 | |||
| 1074 | it("drops retired alpha models after discovery, including without OpenAI authentication", () => { | ||
| 1075 | for (const authStatus of ["authenticated", "unknown"] as const) { | ||
| 1076 | assert.deepStrictEqual( | ||
| 1077 | mergeProviderSnapshot(cachedProvider, { | ||
| 1078 | ...refreshedProvider, | ||
| 1079 | auth: { status: authStatus }, | ||
| 1080 | }).models, | ||
| 1081 | refreshedProvider.models, | ||
| 1082 | ); | ||
| 1083 | } | ||
| 1084 | }); | ||
| 1085 | |||
| 1086 | it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { | ||
| 1087 | for (const provider of [pendingProvider, failedProvider]) { | ||
| 1088 | assert.deepStrictEqual( | ||
| 1089 | mergeProviderSnapshot( | ||
| 1090 | { | ||
| 1091 | ...cachedProvider, | ||
| 1092 | models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], | ||
| 1093 | }, | ||
| 1094 | provider, | ||
| 1095 | ).models, | ||
| 1096 | [customModel, ...cachedProvider.models], | ||
| 1097 | ); | ||
| 1098 | } | ||
| 1099 | }); | ||
| 1100 | |||
| 1101 | it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { | ||
| 1102 | const emptyProvider = { ...refreshedProvider, models: [customModel] }; | ||
| 1103 | const clearedProviders = [ | ||
| 1104 | { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, | ||
| 1105 | { ...emptyProvider, status: "disabled", enabled: false }, | ||
| 1106 | { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, | ||
| 1107 | emptyProvider, | ||
| 1108 | { ...emptyProvider, models: [] }, | ||
| 1109 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1110 | |||
| 1111 | for (const provider of clearedProviders) { | ||
| 1112 | assert.deepStrictEqual( | ||
| 1113 | mergeProviderSnapshot(cachedProvider, provider).models, | ||
| 1114 | provider.models, | ||
| 1115 | ); | ||
| 1116 | } | ||
| 1117 | }); | ||
| 1118 | |||
| 1119 | it.effect("persists removals across failed refreshes and registry restarts", () => | ||
| 1120 | Effect.gen(function* () { | ||
| 1121 | const config = yield* ServerConfig.ServerConfig; | ||
| 1122 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 1123 | cacheDir: config.providerStatusCacheDir, | ||
| 1124 | instanceId: cachedProvider.instanceId, | ||
| 1125 | }); | ||
| 1126 | yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); | ||
| 1127 | const nextProvider = yield* Ref.make<ServerProvider>(refreshedProvider); | ||
| 1128 | const instance = { | ||
| 1129 | instanceId: cachedProvider.instanceId, | ||
| 1130 | driverKind: cachedProvider.driver, | ||
| 1131 | continuationIdentity: { | ||
| 1132 | driverKind: cachedProvider.driver, | ||
| 1133 | continuationKey: "codex:instance:codex-personal", | ||
| 1134 | }, | ||
| 1135 | displayName: undefined, | ||
| 1136 | enabled: true, | ||
| 1137 | snapshot: { | ||
| 1138 | resolveMaintenance: () => | ||
| 1139 | Effect.succeed( | ||
| 1140 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1141 | provider: cachedProvider.driver, | ||
| 1142 | packageName: null, | ||
| 1143 | }), | ||
| 1144 | ), | ||
| 1145 | getSnapshot: Effect.succeed(pendingProvider), | ||
| 1146 | refresh: Ref.get(nextProvider), | ||
| 1147 | streamChanges: Stream.empty, | ||
| 1148 | applyUsageLimits: () => Effect.void, | ||
| 1149 | }, | ||
| 1150 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1151 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1152 | } satisfies ProviderInstance; | ||
| 1153 | const layerInstanceRegistry = Layer.succeed( | ||
| 1154 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1155 | { | ||
| 1156 | getInstance: (id) => | ||
| 1157 | Effect.succeed(id === instance.instanceId ? instance : undefined), | ||
| 1158 | listInstances: Effect.succeed([instance]), | ||
| 1159 | listUnavailable: Effect.succeed([]), | ||
| 1160 | streamChanges: Stream.empty, | ||
| 1161 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1162 | }, | ||
| 1163 | ); | ||
| 1164 | const retainedModels = [ | ||
| 1165 | customModel, | ||
| 1166 | ...refreshedProvider.models.filter((model) => !model.isCustom), | ||
| 1167 | ]; | ||
| 1168 | |||
| 1169 | for (const restarted of [false, true]) { | ||
| 1170 | yield* Effect.gen(function* () { | ||
| 1171 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1172 | const expectedModels = restarted | ||
| 1173 | ? retainedModels | ||
| 1174 | : [customModel, ...cachedProvider.models]; | ||
| 1175 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); | ||
| 1176 | |||
| 1177 | yield* registry.refreshInstance(instance.instanceId); | ||
| 1178 | assert.deepStrictEqual( | ||
| 1179 | (yield* readProviderStatusCache(filePath))?.models, | ||
| 1180 | restarted ? retainedModels : refreshedProvider.models, | ||
| 1181 | ); | ||
| 1182 | |||
| 1183 | yield* Ref.set(nextProvider, failedProvider); | ||
| 1184 | const afterFailure = yield* registry.refreshInstance(instance.instanceId); | ||
| 1185 | assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); | ||
| 1186 | assert.deepStrictEqual( | ||
| 1187 | (yield* readProviderStatusCache(filePath))?.models, | ||
| 1188 | retainedModels, | ||
| 1189 | ); | ||
| 1190 | }).pipe( | ||
| 1191 | Effect.provide( | ||
| 1192 | ProviderRegistryLayer.layer.pipe(Layer.provide(layerInstanceRegistry)), | ||
| 1193 | ), | ||
| 1194 | Effect.scoped, | ||
| 1195 | ); | ||
| 1196 | } | ||
| 1197 | }).pipe( | ||
| 1198 | Effect.provide( | ||
| 1199 | ServerConfig.layerTest(process.cwd(), { | ||
| 1200 | prefix: "t3-codex-retired-model-cache-", | ||
| 1201 | }).pipe(Layer.provideMerge(NodeServices.layer)), | ||
| 1202 | ), | ||
| 1203 | ), | ||
| 1204 | ); | ||
| 1205 | }); | ||
| 1206 | |||
| 1207 | describe("Antigravity model inventories", () => { | ||
| 1208 | const previousProvider = { | ||
| 1209 | instanceId: ProviderInstanceId.make("antigravity-personal"), | ||
| 1210 | driver: ProviderDriverKind.make("antigravity"), | ||
| 1211 | status: "ready", | ||
| 1212 | enabled: true, | ||
| 1213 | installed: true, | ||
| 1214 | auth: { status: "authenticated" }, | ||
| 1215 | checkedAt: "2026-09-02T00:00:00.000Z", | ||
| 1216 | version: "0.1.3", | ||
| 1217 | models: [ | ||
| 1218 | { | ||
| 1219 | slug: "gemini-3.1-pro-high", | ||
| 1220 | name: "Gemini 3.1 Pro High", | ||
| 1221 | isCustom: false, | ||
| 1222 | capabilities: null, | ||
| 1223 | }, | ||
| 1224 | { | ||
| 1225 | slug: "gemini-3-flash", | ||
| 1226 | name: "Gemini 3 Flash", | ||
| 1227 | isCustom: false, | ||
| 1228 | capabilities: null, | ||
| 1229 | }, | ||
| 1230 | ], | ||
| 1231 | slashCommands: [], | ||
| 1232 | skills: [], | ||
| 1233 | } as const satisfies ServerProvider; | ||
| 1234 | |||
| 1235 | it("removes unavailable models after a successful refresh", () => { | ||
| 1236 | for (const status of ["ready", "warning"] as const) { | ||
| 1237 | const refreshedProvider = { | ||
| 1238 | ...previousProvider, | ||
| 1239 | status, | ||
| 1240 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1241 | models: [previousProvider.models[1]], | ||
| 1242 | } satisfies ServerProvider; | ||
| 1243 | const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); | ||
| 1244 | |||
| 1245 | assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); | ||
| 1246 | |||
| 1247 | const afterFailure = mergeProviderSnapshot(afterRefresh, { | ||
| 1248 | ...refreshedProvider, | ||
| 1249 | status: "error", | ||
| 1250 | auth: { status: "unknown" }, | ||
| 1251 | models: [], | ||
| 1252 | }); | ||
| 1253 | assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); | ||
| 1254 | } | ||
| 1255 | }); | ||
| 1256 | |||
| 1257 | it("keeps cached models during health checks and temporary failures", () => { | ||
| 1258 | for (const installed of [false, true]) { | ||
| 1259 | const pendingProvider = { | ||
| 1260 | ...previousProvider, | ||
| 1261 | status: "warning", | ||
| 1262 | installed, | ||
| 1263 | auth: { status: "unknown" }, | ||
| 1264 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1265 | version: installed ? previousProvider.version : null, | ||
| 1266 | models: [], | ||
| 1267 | } satisfies ServerProvider; | ||
| 1268 | |||
| 1269 | assert.deepStrictEqual( | ||
| 1270 | mergeProviderSnapshot(previousProvider, pendingProvider).models, | ||
| 1271 | previousProvider.models, | ||
| 1272 | ); | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | for (const authStatus of ["unknown", "authenticated"] as const) { | ||
| 1276 | const failedProvider = { | ||
| 1277 | ...previousProvider, | ||
| 1278 | status: "error", | ||
| 1279 | auth: { status: authStatus }, | ||
| 1280 | checkedAt: "2026-09-02T00:02:00.000Z", | ||
| 1281 | models: [], | ||
| 1282 | } satisfies ServerProvider; | ||
| 1283 | |||
| 1284 | assert.deepStrictEqual( | ||
| 1285 | mergeProviderSnapshot(previousProvider, failedProvider).models, | ||
| 1286 | previousProvider.models, | ||
| 1287 | ); | ||
| 1288 | } | ||
| 1289 | }); | ||
| 1290 | |||
| 1291 | it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { | ||
| 1292 | const emptyProvider = { | ||
| 1293 | ...previousProvider, | ||
| 1294 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1295 | models: [], | ||
| 1296 | } satisfies ServerProvider; | ||
| 1297 | const clearedProviders = [ | ||
| 1298 | { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, | ||
| 1299 | { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, | ||
| 1300 | { ...emptyProvider, status: "disabled", enabled: false }, | ||
| 1301 | { ...emptyProvider, status: "error", enabled: false }, | ||
| 1302 | { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, | ||
| 1303 | emptyProvider, | ||
| 1304 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1305 | |||
| 1306 | for (const provider of clearedProviders) { | ||
| 1307 | const afterRemoval = mergeProviderSnapshot(previousProvider, provider); | ||
| 1308 | assert.deepStrictEqual(afterRemoval.models, []); | ||
| 1309 | |||
| 1310 | const afterFailure = mergeProviderSnapshot(afterRemoval, { | ||
| 1311 | ...emptyProvider, | ||
| 1312 | status: "error", | ||
| 1313 | auth: { status: "unknown" }, | ||
| 1314 | }); | ||
| 1315 | assert.deepStrictEqual(afterFailure.models, []); | ||
| 1316 | } | ||
| 1317 | }); | ||
| 1318 | }); | ||
| 1319 | |||
| 1320 | describe("Antigravity saved account", () => { | ||
| 1321 | const signedIn = { | ||
| 1322 | instanceId: ProviderInstanceId.make("antigravity-personal"), | ||
| 1323 | driver: ProviderDriverKind.make("antigravity"), | ||
| 1324 | status: "ready", | ||
| 1325 | enabled: true, | ||
| 1326 | installed: true, | ||
| 1327 | auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, | ||
| 1328 | checkedAt: "2026-09-05T00:00:00.000Z", | ||
| 1329 | version: "agy_acp_server_1.1.1", | ||
| 1330 | models: [ | ||
| 1331 | { | ||
| 1332 | slug: "gemini-3.7-flash-high", | ||
| 1333 | name: "Gemini 3.7 Flash", | ||
| 1334 | isCustom: false, | ||
| 1335 | capabilities: null, | ||
| 1336 | }, | ||
| 1337 | ], | ||
| 1338 | slashCommands: [{ name: "plan" }], | ||
| 1339 | skills: [], | ||
| 1340 | } as const satisfies ServerProvider; | ||
| 1341 | const uncheckedMessage = | ||
| 1342 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 1343 | const restartProbe = { | ||
| 1344 | ...signedIn, | ||
| 1345 | status: "warning", | ||
| 1346 | auth: { status: "unknown" }, | ||
| 1347 | checkedAt: "2026-09-05T00:01:00.000Z", | ||
| 1348 | message: uncheckedMessage, | ||
| 1349 | models: [], | ||
| 1350 | } as const satisfies ServerProvider; | ||
| 1351 | |||
| 1352 | it("keeps the saved Google account through restart health checks", () => { | ||
| 1353 | const merged = mergeProviderSnapshot(signedIn, restartProbe); | ||
| 1354 | const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; | ||
| 1355 | assert.deepStrictEqual(merged, { | ||
| 1356 | ...probeWithoutMessage, | ||
| 1357 | status: "ready", | ||
| 1358 | auth: signedIn.auth, | ||
| 1359 | models: signedIn.models, | ||
| 1360 | }); | ||
| 1361 | assert.equal("message" in merged, false); | ||
| 1362 | // The next periodic probe reads the merged snapshot as its previous state. | ||
| 1363 | assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged); | ||
| 1364 | }); | ||
| 1365 | |||
| 1366 | it("carries the account through the boot probe and a failed probe without hiding them", () => { | ||
| 1367 | const booting = { | ||
| 1368 | ...restartProbe, | ||
| 1369 | installed: false, | ||
| 1370 | version: null, | ||
| 1371 | message: "Checking Antigravity availability.", | ||
| 1372 | } satisfies ServerProvider; | ||
| 1373 | assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), { | ||
| 1374 | ...booting, | ||
| 1375 | auth: signedIn.auth, | ||
| 1376 | models: signedIn.models, | ||
| 1377 | }); | ||
| 1378 | |||
| 1379 | const failed = { | ||
| 1380 | ...restartProbe, | ||
| 1381 | status: "error", | ||
| 1382 | message: "Antigravity did not respond to its local health check within 90 seconds.", | ||
| 1383 | } satisfies ServerProvider; | ||
| 1384 | assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), { | ||
| 1385 | ...failed, | ||
| 1386 | auth: signedIn.auth, | ||
| 1387 | models: signedIn.models, | ||
| 1388 | }); | ||
| 1389 | }); | ||
| 1390 | |||
| 1391 | it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { | ||
| 1392 | const untouched = [ | ||
| 1393 | { ...restartProbe, auth: { status: "unauthenticated" } }, | ||
| 1394 | { ...restartProbe, status: "disabled", enabled: false }, | ||
| 1395 | { ...restartProbe, status: "error", installed: false }, | ||
| 1396 | { ...restartProbe, driver: ProviderDriverKind.make("codex") }, | ||
| 1397 | // The instance was rebuilt with another sign-in method. | ||
| 1398 | { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, | ||
| 1399 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1400 | for (const next of untouched) { | ||
| 1401 | const merged = mergeProviderSnapshot(signedIn, next); | ||
| 1402 | assert.deepStrictEqual(merged.auth, next.auth); | ||
| 1403 | assert.equal(merged.status, next.status); | ||
| 1404 | assert.equal(merged.message, next.message); | ||
| 1405 | } | ||
| 1406 | assert.deepStrictEqual( | ||
| 1407 | mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth, | ||
| 1408 | { status: "unknown" }, | ||
| 1409 | ); | ||
| 1410 | assert.equal( | ||
| 1411 | mergeProviderSnapshot( | ||
| 1412 | { ...signedIn, driver: ProviderDriverKind.make("codex") }, | ||
| 1413 | restartProbe, | ||
| 1414 | ).auth.status, | ||
| 1415 | "unknown", | ||
| 1416 | ); | ||
| 1417 | assert.deepStrictEqual( | ||
| 1418 | mergeProviderSnapshot(signedIn, { | ||
| 1419 | ...restartProbe, | ||
| 1420 | auth: { status: "unknown", type: "oauth-personal" }, | ||
| 1421 | }).auth, | ||
| 1422 | signedIn.auth, | ||
| 1423 | ); | ||
| 1424 | }); | ||
| 1425 | }); | ||
| 1426 | |||
| 1427 | it("fills missing capabilities from the previous provider snapshot", () => { | ||
| 1428 | const previousProvider = { | ||
| 1429 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 1430 | driver: ProviderDriverKind.make("cursor"), | ||
| 1431 | status: "ready", | ||
| 1432 | enabled: true, | ||
| 1433 | installed: true, | ||
| 1434 | auth: { status: "authenticated" }, | ||
| 1435 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 1436 | version: "2026.04.09-f2b0fcd", | ||
| 1437 | models: [ | ||
| 1438 | { | ||
| 1439 | slug: "claude-opus-4-6", | ||
| 1440 | name: "Opus 4.6", | ||
| 1441 | isCustom: false, | ||
| 1442 | capabilities: createModelCapabilities({ | ||
| 1443 | optionDescriptors: [ | ||
| 1444 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 1445 | { id: "high", label: "High", isDefault: true }, | ||
| 1446 | ]), | ||
| 1447 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 1448 | booleanDescriptor("thinking", "Thinking"), | ||
| 1449 | ], | ||
| 1450 | }), | ||
| 1451 | }, | ||
| 1452 | ], | ||
| 1453 | slashCommands: [], | ||
| 1454 | skills: [], | ||
| 1455 | } as const satisfies ServerProvider; | ||
| 1456 | const refreshedProvider = { | ||
| 1457 | ...previousProvider, | ||
| 1458 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 1459 | models: [ | ||
| 1460 | { | ||
| 1461 | slug: "claude-opus-4-6", | ||
| 1462 | name: "Opus 4.6", | ||
| 1463 | isCustom: false, | ||
| 1464 | capabilities: createModelCapabilities({ | ||
| 1465 | optionDescriptors: [], | ||
| 1466 | }), | ||
| 1467 | }, | ||
| 1468 | ], | ||
| 1469 | } satisfies ServerProvider; | ||
| 1470 | |||
| 1471 | assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ | ||
| 1472 | ...previousProvider.models, | ||
| 1473 | ]); | ||
| 1474 | }); | ||
| 1475 | |||
| 1476 | it.effect("does not run provider probes during layer construction", () => | ||
| 1477 | Effect.gen(function* () { | ||
| 1478 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 1479 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 1480 | const initialProvider = { | ||
| 1481 | instanceId: codexInstanceId, | ||
| 1482 | driver: codexDriver, | ||
| 1483 | status: "warning", | ||
| 1484 | enabled: true, | ||
| 1485 | installed: false, | ||
| 1486 | auth: { status: "unknown" }, | ||
| 1487 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1488 | version: null, | ||
| 1489 | message: "Checking Codex provider status.", | ||
| 1490 | models: [], | ||
| 1491 | slashCommands: [], | ||
| 1492 | skills: [], | ||
| 1493 | } as const satisfies ServerProvider; | ||
| 1494 | const refreshCalls = yield* Ref.make(0); | ||
| 1495 | const instance = { | ||
| 1496 | instanceId: codexInstanceId, | ||
| 1497 | driverKind: codexDriver, | ||
| 1498 | continuationIdentity: { | ||
| 1499 | driverKind: codexDriver, | ||
| 1500 | continuationKey: "codex:instance:codex", | ||
| 1501 | }, | ||
| 1502 | displayName: undefined, | ||
| 1503 | enabled: true, | ||
| 1504 | snapshot: { | ||
| 1505 | resolveMaintenance: () => | ||
| 1506 | Effect.succeed( | ||
| 1507 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1508 | provider: codexDriver, | ||
| 1509 | packageName: null, | ||
| 1510 | }), | ||
| 1511 | ), | ||
| 1512 | getSnapshot: Effect.succeed(initialProvider), | ||
| 1513 | refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( | ||
| 1514 | Effect.andThen(Effect.never), | ||
| 1515 | ), | ||
| 1516 | streamChanges: Stream.empty, | ||
| 1517 | applyUsageLimits: () => Effect.void, | ||
| 1518 | }, | ||
| 1519 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1520 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1521 | } satisfies ProviderInstance; | ||
| 1522 | const layerInstanceRegistry = Layer.succeed( | ||
| 1523 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1524 | { | ||
| 1525 | getInstance: (instanceId) => | ||
| 1526 | Effect.succeed(instanceId === codexInstanceId ? instance : undefined), | ||
| 1527 | listInstances: Effect.succeed([instance]), | ||
| 1528 | listUnavailable: Effect.succeed([]), | ||
| 1529 | streamChanges: Stream.empty, | ||
| 1530 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1531 | }, | ||
| 1532 | ); | ||
| 1533 | const scope = yield* Scope.make(); | ||
| 1534 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1535 | const runtimeServices = yield* Layer.build( | ||
| 1536 | ProviderRegistryLayer.layer.pipe( | ||
| 1537 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1538 | Layer.provideMerge( | ||
| 1539 | ServerConfig.layerTest(process.cwd(), { | ||
| 1540 | prefix: "t3-provider-registry-background-refresh-", | ||
| 1541 | }), | ||
| 1542 | ), | ||
| 1543 | Layer.provideMerge(NodeServices.layer), | ||
| 1544 | ), | ||
| 1545 | ).pipe(Scope.provide(scope)); | ||
| 1546 | yield* Effect.gen(function* () { | ||
| 1547 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1548 | assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); | ||
| 1549 | assert.strictEqual(yield* Ref.get(refreshCalls), 0); | ||
| 1550 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1551 | }), | ||
| 1552 | ); | ||
| 1553 | |||
| 1554 | it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => | ||
| 1555 | Effect.gen(function* () { | ||
| 1556 | const driver = ProviderDriverKind.make("codex"); | ||
| 1557 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 1558 | const machineProvider = { | ||
| 1559 | instanceId, | ||
| 1560 | driver, | ||
| 1561 | status: "ready", | ||
| 1562 | enabled: true, | ||
| 1563 | installed: true, | ||
| 1564 | auth: { status: "authenticated" }, | ||
| 1565 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1566 | version: "1.0.0", | ||
| 1567 | models: [], | ||
| 1568 | slashCommands: [{ name: "global" }], | ||
| 1569 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 1570 | } as const satisfies ServerProvider; | ||
| 1571 | const scopedProvider = { | ||
| 1572 | ...machineProvider, | ||
| 1573 | checkedAt: "2026-06-10T00:01:00.000Z", | ||
| 1574 | slashCommands: [{ name: "project" }], | ||
| 1575 | skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], | ||
| 1576 | } as const satisfies ServerProvider; | ||
| 1577 | const pendingScopedProvider = { | ||
| 1578 | ...scopedProvider, | ||
| 1579 | status: "error", | ||
| 1580 | installed: false, | ||
| 1581 | slashCommands: [], | ||
| 1582 | } as const satisfies ServerProvider; | ||
| 1583 | const snapshotCalls = yield* Ref.make(0); | ||
| 1584 | const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({ | ||
| 1585 | ...scopedProvider, | ||
| 1586 | status: "error", | ||
| 1587 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 1588 | slashCommandsPending: true, | ||
| 1589 | }); | ||
| 1590 | const cacheInvalidations = yield* Ref.make(0); | ||
| 1591 | const scanGate = yield* Ref.make<{ | ||
| 1592 | readonly started: Deferred.Deferred<void>; | ||
| 1593 | readonly release: Deferred.Deferred<void>; | ||
| 1594 | } | null>(null); | ||
| 1595 | const returnPendingSnapshot = yield* Ref.make(true); | ||
| 1596 | const probeStarted = yield* Deferred.make<void>(); | ||
| 1597 | const releaseProbe = yield* Deferred.make<void>(); | ||
| 1598 | const makeInstance = ( | ||
| 1599 | provider: ServerProvider, | ||
| 1600 | snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>, | ||
| 1601 | ): ProviderInstance => ({ | ||
| 1602 | instanceId, | ||
| 1603 | driverKind: driver, | ||
| 1604 | continuationIdentity: { | ||
| 1605 | driverKind: driver, | ||
| 1606 | continuationKey: "codex:instance:codex", | ||
| 1607 | }, | ||
| 1608 | displayName: undefined, | ||
| 1609 | enabled: true, | ||
| 1610 | snapshot: { | ||
| 1611 | resolveMaintenance: () => | ||
| 1612 | Effect.succeed( | ||
| 1613 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1614 | provider: driver, | ||
| 1615 | packageName: null, | ||
| 1616 | }), | ||
| 1617 | ), | ||
| 1618 | getSnapshot: Effect.succeed(provider), | ||
| 1619 | refresh: Effect.succeed(provider), | ||
| 1620 | streamChanges: Stream.empty, | ||
| 1621 | applyUsageLimits: () => Effect.void, | ||
| 1622 | }, | ||
| 1623 | snapshotForCwd, | ||
| 1624 | invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), | ||
| 1625 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1626 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1627 | }); | ||
| 1628 | const firstInstance = makeInstance(machineProvider, () => | ||
| 1629 | Effect.gen(function* () { | ||
| 1630 | yield* Ref.update(snapshotCalls, (count) => count + 1); | ||
| 1631 | if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; | ||
| 1632 | yield* Deferred.succeed(probeStarted, undefined); | ||
| 1633 | yield* Deferred.await(releaseProbe); | ||
| 1634 | const result = yield* Ref.get(scopedResult); | ||
| 1635 | const gate = yield* Ref.getAndSet(scanGate, null); | ||
| 1636 | if (gate) { | ||
| 1637 | yield* Deferred.succeed(gate.started, undefined); | ||
| 1638 | yield* Deferred.await(gate.release); | ||
| 1639 | } | ||
| 1640 | return result; | ||
| 1641 | }), | ||
| 1642 | ); | ||
| 1643 | const rebuiltProvider = { | ||
| 1644 | ...machineProvider, | ||
| 1645 | checkedAt: "2026-06-10T00:02:00.000Z", | ||
| 1646 | status: "warning", | ||
| 1647 | installed: false, | ||
| 1648 | auth: { status: "unknown" }, | ||
| 1649 | } satisfies ServerProvider; | ||
| 1650 | const rebuiltInstance = makeInstance(rebuiltProvider, () => | ||
| 1651 | Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), | ||
| 1652 | ); | ||
| 1653 | const registryChanges = yield* PubSub.unbounded<void>(); | ||
| 1654 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]); | ||
| 1655 | const layerInstanceRegistry = Layer.succeed( | ||
| 1656 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1657 | { | ||
| 1658 | getInstance: (requestedId) => | ||
| 1659 | Ref.get(instancesRef).pipe( | ||
| 1660 | Effect.map((instances) => | ||
| 1661 | instances.find((instance) => instance.instanceId === requestedId), | ||
| 1662 | ), | ||
| 1663 | ), | ||
| 1664 | listInstances: Ref.get(instancesRef), | ||
| 1665 | listUnavailable: Effect.succeed([]), | ||
| 1666 | streamChanges: Stream.fromPubSub(registryChanges), | ||
| 1667 | subscribeChanges: PubSub.subscribe(registryChanges), | ||
| 1668 | }, | ||
| 1669 | ); | ||
| 1670 | const scope = yield* Scope.make(); | ||
| 1671 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1672 | const runtimeServices = yield* Layer.build( | ||
| 1673 | ProviderRegistryLayer.layer.pipe( | ||
| 1674 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1675 | Layer.provideMerge( | ||
| 1676 | ServerConfig.layerTest(process.cwd(), { | ||
| 1677 | prefix: "t3-provider-registry-workspace-snapshot-", | ||
| 1678 | }), | ||
| 1679 | ), | ||
| 1680 | Layer.provideMerge(NodeServices.layer), | ||
| 1681 | ), | ||
| 1682 | ).pipe(Scope.provide(scope)); | ||
| 1683 | |||
| 1684 | yield* Effect.gen(function* () { | ||
| 1685 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1686 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1687 | assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); | ||
| 1688 | yield* Ref.set(returnPendingSnapshot, false); | ||
| 1689 | const workspaceUpdate = yield* registry.streamChanges.pipe( | ||
| 1690 | Stream.runHead, | ||
| 1691 | Effect.forkChild, | ||
| 1692 | ); | ||
| 1693 | yield* Effect.yieldNow; | ||
| 1694 | const firstRefresh = yield* registry | ||
| 1695 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1696 | .pipe(Effect.forkChild); | ||
| 1697 | yield* Deferred.await(probeStarted); | ||
| 1698 | const duplicateRefresh = yield* registry | ||
| 1699 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1700 | .pipe(Effect.forkChild); | ||
| 1701 | yield* Effect.yieldNow; | ||
| 1702 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | ||
| 1703 | yield* Deferred.succeed(releaseProbe, undefined); | ||
| 1704 | yield* Fiber.join(firstRefresh); | ||
| 1705 | yield* Fiber.join(duplicateRefresh); | ||
| 1706 | const published = yield* Fiber.join(workspaceUpdate); | ||
| 1707 | assert.strictEqual(published._tag, "Some"); | ||
| 1708 | const providers = yield* registry.getProviders; | ||
| 1709 | assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); | ||
| 1710 | assert.deepStrictEqual( | ||
| 1711 | providers[0]?.workspaceSnapshots?.[0]?.skills, | ||
| 1712 | scopedProvider.skills, | ||
| 1713 | ); | ||
| 1714 | assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 1715 | COMPACT_SLASH_COMMAND, | ||
| 1716 | ]); | ||
| 1717 | assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 1718 | yield* Ref.set(scopedResult, { | ||
| 1719 | ...scopedProvider, | ||
| 1720 | status: "error", | ||
| 1721 | slashCommandsPending: false, | ||
| 1722 | }); | ||
| 1723 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1724 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1725 | assert.deepStrictEqual( | ||
| 1726 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, | ||
| 1727 | scopedProvider.slashCommands, | ||
| 1728 | ); | ||
| 1729 | assert.strictEqual( | ||
| 1730 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, | ||
| 1731 | undefined, | ||
| 1732 | ); | ||
| 1733 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1734 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1735 | const newSkills = [ | ||
| 1736 | ...scopedProvider.skills, | ||
| 1737 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, | ||
| 1738 | ]; | ||
| 1739 | yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); | ||
| 1740 | yield* registry.refreshWorkspaceSnapshot({ | ||
| 1741 | instanceId, | ||
| 1742 | cwd: "/workspace", | ||
| 1743 | fresh: true, | ||
| 1744 | }); | ||
| 1745 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | ||
| 1746 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); | ||
| 1747 | assert.deepStrictEqual( | ||
| 1748 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | ||
| 1749 | [newSkills], | ||
| 1750 | ); | ||
| 1751 | |||
| 1752 | // A slow fresh scan that read older files must not overwrite a | ||
| 1753 | // newer scan that finished first. | ||
| 1754 | const slowStarted = yield* Deferred.make<void>(); | ||
| 1755 | const releaseSlow = yield* Deferred.make<void>(); | ||
| 1756 | yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); | ||
| 1757 | yield* Ref.set(scopedResult, scopedProvider); | ||
| 1758 | const slowScan = yield* registry | ||
| 1759 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) | ||
| 1760 | .pipe(Effect.forkChild); | ||
| 1761 | yield* Deferred.await(slowStarted); | ||
| 1762 | const latestSkills = [ | ||
| 1763 | ...newSkills, | ||
| 1764 | { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, | ||
| 1765 | ]; | ||
| 1766 | yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); | ||
| 1767 | yield* registry.refreshWorkspaceSnapshot({ | ||
| 1768 | instanceId, | ||
| 1769 | cwd: "/workspace", | ||
| 1770 | fresh: true, | ||
| 1771 | }); | ||
| 1772 | yield* Deferred.succeed(releaseSlow, undefined); | ||
| 1773 | yield* Fiber.join(slowScan); | ||
| 1774 | assert.deepStrictEqual( | ||
| 1775 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | ||
| 1776 | [latestSkills], | ||
| 1777 | ); | ||
| 1778 | |||
| 1779 | yield* Ref.set(instancesRef, [rebuiltInstance]); | ||
| 1780 | yield* PubSub.publish(registryChanges, undefined); | ||
| 1781 | let rebuilt = yield* registry.getProviders; | ||
| 1782 | for ( | ||
| 1783 | let attempt = 0; | ||
| 1784 | attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; | ||
| 1785 | attempt += 1 | ||
| 1786 | ) { | ||
| 1787 | yield* Effect.yieldNow; | ||
| 1788 | rebuilt = yield* registry.getProviders; | ||
| 1789 | } | ||
| 1790 | assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); | ||
| 1791 | assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); | ||
| 1792 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1793 | }), | ||
| 1794 | ); | ||
| 1795 | |||
| 1796 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => | ||
| 1797 | Effect.gen(function* () { | ||
| 1798 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 1799 | const openCodeDriver = ProviderDriverKind.make("opencode"); | ||
| 1800 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 1801 | const openCodeInstanceId = ProviderInstanceId.make("opencode"); | ||
| 1802 | const codexRefreshCalls = yield* Ref.make(0); | ||
| 1803 | const openCodeRefreshCalls = yield* Ref.make(0); | ||
| 1804 | const codexProvider = { | ||
| 1805 | instanceId: codexInstanceId, | ||
| 1806 | driver: codexDriver, | ||
| 1807 | status: "ready", | ||
| 1808 | enabled: true, | ||
| 1809 | installed: true, | ||
| 1810 | auth: { status: "authenticated" }, | ||
| 1811 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1812 | version: "1.0.0", | ||
| 1813 | models: [], | ||
| 1814 | slashCommands: [], | ||
| 1815 | skills: [], | ||
| 1816 | } as const satisfies ServerProvider; | ||
| 1817 | const failedOpenCodeProvider = { | ||
| 1818 | instanceId: openCodeInstanceId, | ||
| 1819 | driver: openCodeDriver, | ||
| 1820 | status: "error", | ||
| 1821 | enabled: true, | ||
| 1822 | installed: true, | ||
| 1823 | auth: { status: "unknown" }, | ||
| 1824 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1825 | version: "1.0.0", | ||
| 1826 | message: "Failed to refresh OpenCode models.", | ||
| 1827 | models: [], | ||
| 1828 | slashCommands: [], | ||
| 1829 | skills: [], | ||
| 1830 | } as const satisfies ServerProvider; | ||
| 1831 | const recoveredOpenCodeProvider = { | ||
| 1832 | ...failedOpenCodeProvider, | ||
| 1833 | status: "ready", | ||
| 1834 | auth: { status: "authenticated" }, | ||
| 1835 | checkedAt: "2026-06-10T00:01:00.000Z", | ||
| 1836 | message: "One upstream provider connected through OpenCode.", | ||
| 1837 | models: [ | ||
| 1838 | { | ||
| 1839 | slug: "github/gpt-5", | ||
| 1840 | name: "GPT-5", | ||
| 1841 | subProvider: "GitHub", | ||
| 1842 | isCustom: false, | ||
| 1843 | capabilities: null, | ||
| 1844 | }, | ||
| 1845 | ], | ||
| 1846 | } as const satisfies ServerProvider; | ||
| 1847 | const changedCatalogProvider = { | ||
| 1848 | ...recoveredOpenCodeProvider, | ||
| 1849 | checkedAt: "2026-06-10T00:02:00.000Z", | ||
| 1850 | models: [ | ||
| 1851 | { | ||
| 1852 | slug: "anthropic/claude-sonnet-4", | ||
| 1853 | name: "Claude Sonnet 4", | ||
| 1854 | subProvider: "Anthropic", | ||
| 1855 | isCustom: false, | ||
| 1856 | capabilities: null, | ||
| 1857 | }, | ||
| 1858 | ], | ||
| 1859 | } as const satisfies ServerProvider; | ||
| 1860 | const catalogSnapshot = yield* Ref.make<ServerProvider>(recoveredOpenCodeProvider); | ||
| 1861 | const instances = [ | ||
| 1862 | { | ||
| 1863 | instanceId: codexInstanceId, | ||
| 1864 | driverKind: codexDriver, | ||
| 1865 | continuationIdentity: { | ||
| 1866 | driverKind: codexDriver, | ||
| 1867 | continuationKey: "codex:instance:codex", | ||
| 1868 | }, | ||
| 1869 | displayName: undefined, | ||
| 1870 | enabled: true, | ||
| 1871 | snapshot: { | ||
| 1872 | resolveMaintenance: () => | ||
| 1873 | Effect.succeed( | ||
| 1874 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1875 | provider: codexDriver, | ||
| 1876 | packageName: null, | ||
| 1877 | }), | ||
| 1878 | ), | ||
| 1879 | getSnapshot: Effect.succeed(codexProvider), | ||
| 1880 | refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( | ||
| 1881 | Effect.as(codexProvider), | ||
| 1882 | ), | ||
| 1883 | streamChanges: Stream.empty, | ||
| 1884 | applyUsageLimits: () => Effect.void, | ||
| 1885 | }, | ||
| 1886 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1887 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1888 | }, | ||
| 1889 | { | ||
| 1890 | instanceId: openCodeInstanceId, | ||
| 1891 | driverKind: openCodeDriver, | ||
| 1892 | continuationIdentity: { | ||
| 1893 | driverKind: openCodeDriver, | ||
| 1894 | continuationKey: "opencode:instance:opencode", | ||
| 1895 | }, | ||
| 1896 | displayName: undefined, | ||
| 1897 | enabled: true, | ||
| 1898 | snapshot: { | ||
| 1899 | resolveMaintenance: () => | ||
| 1900 | Effect.succeed( | ||
| 1901 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1902 | provider: openCodeDriver, | ||
| 1903 | packageName: null, | ||
| 1904 | }), | ||
| 1905 | ), | ||
| 1906 | getSnapshot: Effect.succeed(failedOpenCodeProvider), | ||
| 1907 | refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( | ||
| 1908 | Effect.andThen(Ref.get(catalogSnapshot)), | ||
| 1909 | ), | ||
| 1910 | streamChanges: Stream.empty, | ||
| 1911 | applyUsageLimits: () => Effect.void, | ||
| 1912 | }, | ||
| 1913 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1914 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1915 | }, | ||
| 1916 | ] satisfies ReadonlyArray<ProviderInstance>; | ||
| 1917 | const layerInstanceRegistry = Layer.succeed( | ||
| 1918 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1919 | { | ||
| 1920 | getInstance: (instanceId) => | ||
| 1921 | Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), | ||
| 1922 | listInstances: Effect.succeed(instances), | ||
| 1923 | listUnavailable: Effect.succeed([]), | ||
| 1924 | streamChanges: Stream.empty, | ||
| 1925 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1926 | }, | ||
| 1927 | ); | ||
| 1928 | const scope = yield* Scope.make(); | ||
| 1929 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1930 | const runtimeServices = yield* Layer.build( | ||
| 1931 | ProviderRegistryLayer.layer.pipe( | ||
| 1932 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1933 | Layer.provideMerge( | ||
| 1934 | ServerConfig.layerTest(process.cwd(), { | ||
| 1935 | prefix: "t3-provider-registry-reconnect-refresh-", | ||
| 1936 | }), | ||
| 1937 | ), | ||
| 1938 | Layer.provideMerge(NodeServices.layer), | ||
| 1939 | ), | ||
| 1940 | ).pipe(Scope.provide(scope)); | ||
| 1941 | |||
| 1942 | yield* Effect.gen(function* () { | ||
| 1943 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1944 | const initialProviders = yield* registry.getProviders; | ||
| 1945 | assert.strictEqual( | ||
| 1946 | initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, | ||
| 1947 | "error", | ||
| 1948 | ); | ||
| 1949 | |||
| 1950 | const recoveredProviders = yield* registry.refresh(); | ||
| 1951 | assert.deepStrictEqual( | ||
| 1952 | recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) | ||
| 1953 | ?.models, | ||
| 1954 | recoveredOpenCodeProvider.models, | ||
| 1955 | ); | ||
| 1956 | assert.deepStrictEqual( | ||
| 1957 | recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), | ||
| 1958 | withBundledCompatibility(codexProvider), | ||
| 1959 | ); | ||
| 1960 | |||
| 1961 | yield* Ref.set(catalogSnapshot, changedCatalogProvider); | ||
| 1962 | const changedProviders = yield* registry.refresh(); | ||
| 1963 | assert.deepStrictEqual( | ||
| 1964 | changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, | ||
| 1965 | changedCatalogProvider.models, | ||
| 1966 | ); | ||
| 1967 | assert.deepStrictEqual( | ||
| 1968 | changedProviders.find((provider) => provider.instanceId === codexInstanceId), | ||
| 1969 | withBundledCompatibility(codexProvider), | ||
| 1970 | ); | ||
| 1971 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1972 | |||
| 1973 | assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); | ||
| 1974 | assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); | ||
| 1975 | }), | ||
| 1976 | ); | ||
| 1977 | |||
| 1978 | it("persists merged provider snapshots for the providers that were refreshed", () => { | ||
| 1979 | const previousProviders = [ | ||
| 1980 | { | ||
| 1981 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 1982 | driver: ProviderDriverKind.make("cursor"), | ||
| 1983 | status: "ready", | ||
| 1984 | enabled: true, | ||
| 1985 | installed: true, | ||
| 1986 | auth: { status: "authenticated" }, | ||
| 1987 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 1988 | version: "2026.04.09-f2b0fcd", | ||
| 1989 | models: [ | ||
| 1990 | { | ||
| 1991 | slug: "claude-opus-4-6", | ||
| 1992 | name: "Opus 4.6", | ||
| 1993 | isCustom: false, | ||
| 1994 | capabilities: createModelCapabilities({ | ||
| 1995 | optionDescriptors: [ | ||
| 1996 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 1997 | { id: "high", label: "High", isDefault: true }, | ||
| 1998 | ]), | ||
| 1999 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 2000 | booleanDescriptor("thinking", "Thinking"), | ||
| 2001 | ], | ||
| 2002 | }), | ||
| 2003 | }, | ||
| 2004 | ], | ||
| 2005 | slashCommands: [], | ||
| 2006 | skills: [], | ||
| 2007 | }, | ||
| 2008 | { | ||
| 2009 | instanceId: ProviderInstanceId.make("codex"), | ||
| 2010 | driver: ProviderDriverKind.make("codex"), | ||
| 2011 | status: "ready", | ||
| 2012 | enabled: true, | ||
| 2013 | installed: true, | ||
| 2014 | auth: { status: "authenticated" }, | ||
| 2015 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 2016 | version: "1.0.0", | ||
| 2017 | models: [], | ||
| 2018 | slashCommands: [], | ||
| 2019 | skills: [], | ||
| 2020 | }, | ||
| 2021 | ] as const satisfies ReadonlyArray<ServerProvider>; | ||
| 2022 | const refreshedCursor = { | ||
| 2023 | ...previousProviders[0], | ||
| 2024 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 2025 | models: [], | ||
| 2026 | } satisfies ServerProvider; | ||
| 2027 | |||
| 2028 | const mergedProviders = mergeProviderSnapshots(previousProviders, [refreshedCursor]); | ||
| 2029 | const persistedProviders = selectProvidersByKind( | ||
| 2030 | mergedProviders, | ||
| 2031 | new Set([ProviderDriverKind.make("cursor")]), | ||
| 2032 | ); | ||
| 2033 | |||
| 2034 | assert.deepStrictEqual(persistedProviders, [ | ||
| 2035 | { | ||
| 2036 | ...refreshedCursor, | ||
| 2037 | models: [...previousProviders[0].models], | ||
| 2038 | }, | ||
| 2039 | ]); | ||
| 2040 | }); | ||
| 2041 | |||
| 2042 | it.effect("persists the merged snapshot when a live update has empty models", () => | ||
| 2043 | Effect.gen(function* () { | ||
| 2044 | const cursorDriver = ProviderDriverKind.make("cursor"); | ||
| 2045 | const cursorInstanceId = ProviderInstanceId.make("cursor"); | ||
| 2046 | const initialProvider = { | ||
| 2047 | instanceId: cursorInstanceId, | ||
| 2048 | driver: cursorDriver, | ||
| 2049 | status: "ready", | ||
| 2050 | enabled: true, | ||
| 2051 | installed: true, | ||
| 2052 | auth: { status: "authenticated" }, | ||
| 2053 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 2054 | version: "2026.04.09-f2b0fcd", | ||
| 2055 | models: [ | ||
| 2056 | { | ||
| 2057 | slug: "claude-opus-4-6", | ||
| 2058 | name: "Opus 4.6", | ||
| 2059 | isCustom: false, | ||
| 2060 | capabilities: createModelCapabilities({ | ||
| 2061 | optionDescriptors: [ | ||
| 2062 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 2063 | { id: "high", label: "High", isDefault: true }, | ||
| 2064 | ]), | ||
| 2065 | ], | ||
| 2066 | }), | ||
| 2067 | }, | ||
| 2068 | ], | ||
| 2069 | slashCommands: [], | ||
| 2070 | skills: [], | ||
| 2071 | } as const satisfies ServerProvider; | ||
| 2072 | const refreshedProvider = { | ||
| 2073 | ...initialProvider, | ||
| 2074 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 2075 | models: [], | ||
| 2076 | } satisfies ServerProvider; | ||
| 2077 | const changes = yield* PubSub.unbounded<ServerProvider>(); | ||
| 2078 | const instance = { | ||
| 2079 | instanceId: cursorInstanceId, | ||
| 2080 | driverKind: cursorDriver, | ||
| 2081 | continuationIdentity: { | ||
| 2082 | driverKind: cursorDriver, | ||
| 2083 | continuationKey: "cursor:instance:cursor", | ||
| 2084 | }, | ||
| 2085 | displayName: undefined, | ||
| 2086 | enabled: true, | ||
| 2087 | snapshot: { | ||
| 2088 | resolveMaintenance: () => | ||
| 2089 | Effect.succeed( | ||
| 2090 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2091 | provider: cursorDriver, | ||
| 2092 | packageName: null, | ||
| 2093 | }), | ||
| 2094 | ), | ||
| 2095 | getSnapshot: Effect.succeed(initialProvider), | ||
| 2096 | refresh: Effect.succeed(refreshedProvider), | ||
| 2097 | streamChanges: Stream.fromPubSub(changes), | ||
| 2098 | applyUsageLimits: () => Effect.void, | ||
| 2099 | }, | ||
| 2100 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2101 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2102 | } satisfies ProviderInstance; | ||
| 2103 | const layerInstanceRegistry = Layer.succeed( | ||
| 2104 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2105 | { | ||
| 2106 | getInstance: (instanceId) => | ||
| 2107 | Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), | ||
| 2108 | listInstances: Effect.succeed([instance]), | ||
| 2109 | listUnavailable: Effect.succeed([]), | ||
| 2110 | streamChanges: Stream.empty, | ||
| 2111 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2112 | PubSub.subscribe(pubsub), | ||
| 2113 | ), | ||
| 2114 | }, | ||
| 2115 | ); | ||
| 2116 | const scope = yield* Scope.make(); | ||
| 2117 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2118 | const runtimeServices = yield* Layer.build( | ||
| 2119 | ProviderRegistryLayer.layer.pipe( | ||
| 2120 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2121 | Layer.provideMerge( | ||
| 2122 | ServerConfig.layerTest(process.cwd(), { | ||
| 2123 | prefix: "t3-provider-registry-merged-persist-", | ||
| 2124 | }), | ||
| 2125 | ), | ||
| 2126 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2127 | Layer.provideMerge(NodeServices.layer), | ||
| 2128 | ), | ||
| 2129 | ).pipe(Scope.provide(scope)); | ||
| 2130 | |||
| 2131 | yield* Effect.gen(function* () { | ||
| 2132 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2133 | const config = yield* ServerConfig.ServerConfig; | ||
| 2134 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 2135 | cacheDir: config.providerStatusCacheDir, | ||
| 2136 | instanceId: cursorInstanceId, | ||
| 2137 | }); | ||
| 2138 | |||
| 2139 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ | ||
| 2140 | ...initialProvider.models, | ||
| 2141 | ]); | ||
| 2142 | const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); | ||
| 2143 | yield* PubSub.publish(changes, refreshedProvider); | ||
| 2144 | yield* Fiber.join(persisted); | ||
| 2145 | const cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2146 | |||
| 2147 | assert.deepStrictEqual( | ||
| 2148 | cachedProvider, | ||
| 2149 | withBundledCompatibility({ | ||
| 2150 | ...refreshedProvider, | ||
| 2151 | models: [...initialProvider.models], | ||
| 2152 | }), | ||
| 2153 | ); | ||
| 2154 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2155 | }), | ||
| 2156 | ); | ||
| 2157 | |||
| 2158 | it.effect( | ||
| 2159 | "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", | ||
| 2160 | () => | ||
| 2161 | Effect.gen(function* () { | ||
| 2162 | const openCodeDriver = ProviderDriverKind.make("opencode"); | ||
| 2163 | const openCodeInstanceId = ProviderInstanceId.make("opencode"); | ||
| 2164 | const initialProvider = { | ||
| 2165 | instanceId: openCodeInstanceId, | ||
| 2166 | driver: openCodeDriver, | ||
| 2167 | status: "ready", | ||
| 2168 | enabled: true, | ||
| 2169 | installed: true, | ||
| 2170 | auth: { status: "authenticated" }, | ||
| 2171 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 2172 | version: "1.0.0", | ||
| 2173 | models: [ | ||
| 2174 | { | ||
| 2175 | slug: "github/gpt-5", | ||
| 2176 | name: "GPT-5", | ||
| 2177 | subProvider: "GitHub", | ||
| 2178 | isCustom: false, | ||
| 2179 | capabilities: null, | ||
| 2180 | }, | ||
| 2181 | { | ||
| 2182 | slug: "removed-plugin/model", | ||
| 2183 | name: "Removed Plugin Model", | ||
| 2184 | subProvider: "Removed Plugin", | ||
| 2185 | isCustom: false, | ||
| 2186 | capabilities: null, | ||
| 2187 | }, | ||
| 2188 | ], | ||
| 2189 | slashCommands: [], | ||
| 2190 | skills: [], | ||
| 2191 | } as const satisfies ServerProvider; | ||
| 2192 | const authoritativeProvider = { | ||
| 2193 | ...initialProvider, | ||
| 2194 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 2195 | models: [initialProvider.models[0]!], | ||
| 2196 | } satisfies ServerProvider; | ||
| 2197 | const failedProvider = { | ||
| 2198 | ...authoritativeProvider, | ||
| 2199 | status: "error", | ||
| 2200 | auth: { status: "unknown" }, | ||
| 2201 | checkedAt: "2026-07-17T00:02:00.000Z", | ||
| 2202 | models: [], | ||
| 2203 | message: "Failed to refresh OpenCode models.", | ||
| 2204 | } satisfies ServerProvider; | ||
| 2205 | const changes = yield* PubSub.unbounded<ServerProvider>(); | ||
| 2206 | const instance = { | ||
| 2207 | instanceId: openCodeInstanceId, | ||
| 2208 | driverKind: openCodeDriver, | ||
| 2209 | continuationIdentity: { | ||
| 2210 | driverKind: openCodeDriver, | ||
| 2211 | continuationKey: "opencode:instance:opencode", | ||
| 2212 | }, | ||
| 2213 | displayName: undefined, | ||
| 2214 | enabled: true, | ||
| 2215 | snapshot: { | ||
| 2216 | resolveMaintenance: () => | ||
| 2217 | Effect.succeed( | ||
| 2218 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2219 | provider: openCodeDriver, | ||
| 2220 | packageName: null, | ||
| 2221 | }), | ||
| 2222 | ), | ||
| 2223 | getSnapshot: Effect.succeed(initialProvider), | ||
| 2224 | refresh: Effect.succeed(authoritativeProvider), | ||
| 2225 | streamChanges: Stream.fromPubSub(changes), | ||
| 2226 | applyUsageLimits: () => Effect.void, | ||
| 2227 | }, | ||
| 2228 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2229 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2230 | } satisfies ProviderInstance; | ||
| 2231 | const layerInstanceRegistry = Layer.succeed( | ||
| 2232 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2233 | { | ||
| 2234 | getInstance: (instanceId) => | ||
| 2235 | Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), | ||
| 2236 | listInstances: Effect.succeed([instance]), | ||
| 2237 | listUnavailable: Effect.succeed([]), | ||
| 2238 | streamChanges: Stream.empty, | ||
| 2239 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2240 | PubSub.subscribe(pubsub), | ||
| 2241 | ), | ||
| 2242 | }, | ||
| 2243 | ); | ||
| 2244 | const scope = yield* Scope.make(); | ||
| 2245 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2246 | const runtimeServices = yield* Layer.build( | ||
| 2247 | ProviderRegistryLayer.layer.pipe( | ||
| 2248 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2249 | Layer.provideMerge( | ||
| 2250 | ServerConfig.layerTest(process.cwd(), { | ||
| 2251 | prefix: "t3-provider-registry-opencode-authoritative-persist-", | ||
| 2252 | }), | ||
| 2253 | ), | ||
| 2254 | Layer.provideMerge(NodeServices.layer), | ||
| 2255 | ), | ||
| 2256 | ).pipe(Scope.provide(scope)); | ||
| 2257 | |||
| 2258 | yield* Effect.gen(function* () { | ||
| 2259 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2260 | const config = yield* ServerConfig.ServerConfig; | ||
| 2261 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 2262 | cacheDir: config.providerStatusCacheDir, | ||
| 2263 | instanceId: openCodeInstanceId, | ||
| 2264 | }); | ||
| 2265 | |||
| 2266 | const authoritativePersisted = yield* awaitPersistedProvider( | ||
| 2267 | registry, | ||
| 2268 | authoritativeProvider.checkedAt, | ||
| 2269 | ); | ||
| 2270 | yield* PubSub.publish(changes, authoritativeProvider); | ||
| 2271 | yield* Fiber.join(authoritativePersisted); | ||
| 2272 | let cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2273 | |||
| 2274 | assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); | ||
| 2275 | |||
| 2276 | const failedPersisted = yield* awaitPersistedProvider( | ||
| 2277 | registry, | ||
| 2278 | failedProvider.checkedAt, | ||
| 2279 | ); | ||
| 2280 | yield* PubSub.publish(changes, failedProvider); | ||
| 2281 | yield* Fiber.join(failedPersisted); | ||
| 2282 | cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2283 | |||
| 2284 | assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); | ||
| 2285 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ | ||
| 2286 | authoritativeProvider.models[0]!, | ||
| 2287 | ]); | ||
| 2288 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2289 | }), | ||
| 2290 | ); | ||
| 2291 | |||
| 2292 | it.effect("returns the cached provider list when a manual refresh fails", () => | ||
| 2293 | Effect.gen(function* () { | ||
| 2294 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 2295 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 2296 | const cachedProvider = { | ||
| 2297 | instanceId: codexInstanceId, | ||
| 2298 | driver: codexDriver, | ||
| 2299 | status: "ready", | ||
| 2300 | enabled: true, | ||
| 2301 | installed: true, | ||
| 2302 | auth: { status: "authenticated" }, | ||
| 2303 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 2304 | version: "1.0.0", | ||
| 2305 | models: [], | ||
| 2306 | slashCommands: [], | ||
| 2307 | skills: [], | ||
| 2308 | } as const satisfies ServerProvider; | ||
| 2309 | const instance = { | ||
| 2310 | instanceId: codexInstanceId, | ||
| 2311 | driverKind: codexDriver, | ||
| 2312 | continuationIdentity: { | ||
| 2313 | driverKind: codexDriver, | ||
| 2314 | continuationKey: "codex:instance:codex", | ||
| 2315 | }, | ||
| 2316 | displayName: undefined, | ||
| 2317 | enabled: true, | ||
| 2318 | snapshot: { | ||
| 2319 | resolveMaintenance: () => | ||
| 2320 | Effect.succeed( | ||
| 2321 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2322 | provider: codexDriver, | ||
| 2323 | packageName: null, | ||
| 2324 | }), | ||
| 2325 | ), | ||
| 2326 | getSnapshot: Effect.succeed(cachedProvider), | ||
| 2327 | refresh: Effect.die(new Error("simulated refresh failure")), | ||
| 2328 | streamChanges: Stream.empty, | ||
| 2329 | applyUsageLimits: () => Effect.void, | ||
| 2330 | }, | ||
| 2331 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2332 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2333 | } satisfies ProviderInstance; | ||
| 2334 | const layerInstanceRegistry = Layer.succeed( | ||
| 2335 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2336 | { | ||
| 2337 | getInstance: (instanceId) => | ||
| 2338 | Effect.succeed(instanceId === codexInstanceId ? instance : undefined), | ||
| 2339 | listInstances: Effect.succeed([instance]), | ||
| 2340 | listUnavailable: Effect.succeed([]), | ||
| 2341 | streamChanges: Stream.empty, | ||
| 2342 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2343 | PubSub.subscribe(pubsub), | ||
| 2344 | ), | ||
| 2345 | }, | ||
| 2346 | ); | ||
| 2347 | const scope = yield* Scope.make(); | ||
| 2348 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2349 | const runtimeServices = yield* Layer.build( | ||
| 2350 | ProviderRegistryLayer.layer.pipe( | ||
| 2351 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2352 | Layer.provideMerge( | ||
| 2353 | ServerConfig.layerTest(process.cwd(), { | ||
| 2354 | prefix: "t3-provider-registry-refresh-failure-", | ||
| 2355 | }), | ||
| 2356 | ), | ||
| 2357 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2358 | Layer.provideMerge(NodeServices.layer), | ||
| 2359 | ), | ||
| 2360 | ).pipe(Scope.provide(scope)); | ||
| 2361 | |||
| 2362 | yield* Effect.gen(function* () { | ||
| 2363 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2364 | |||
| 2365 | assert.deepStrictEqual(yield* registry.getProviders, [ | ||
| 2366 | withBundledCompatibility(cachedProvider), | ||
| 2367 | ]); | ||
| 2368 | assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ | ||
| 2369 | withBundledCompatibility(cachedProvider), | ||
| 2370 | ]); | ||
| 2371 | assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ | ||
| 2372 | withBundledCompatibility(cachedProvider), | ||
| 2373 | ]); | ||
| 2374 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2375 | }), | ||
| 2376 | ); | ||
| 2377 | |||
| 2378 | it.effect("keeps consuming registry changes after one sync fails", () => | ||
| 2379 | Effect.gen(function* () { | ||
| 2380 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 2381 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 2382 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); | ||
| 2383 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); | ||
| 2384 | const codexProvider = { | ||
| 2385 | instanceId: codexInstanceId, | ||
| 2386 | driver: codexDriver, | ||
| 2387 | status: "ready", | ||
| 2388 | enabled: true, | ||
| 2389 | installed: true, | ||
| 2390 | auth: { status: "authenticated" }, | ||
| 2391 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 2392 | version: "1.0.0", | ||
| 2393 | models: [], | ||
| 2394 | slashCommands: [], | ||
| 2395 | skills: [], | ||
| 2396 | } as const satisfies ServerProvider; | ||
| 2397 | const claudeProvider = { | ||
| 2398 | instanceId: claudeInstanceId, | ||
| 2399 | driver: claudeDriver, | ||
| 2400 | status: "ready", | ||
| 2401 | enabled: true, | ||
| 2402 | installed: true, | ||
| 2403 | auth: { status: "authenticated" }, | ||
| 2404 | checkedAt: "2026-04-29T10:01:00.000Z", | ||
| 2405 | version: "1.0.0", | ||
| 2406 | models: [], | ||
| 2407 | slashCommands: [], | ||
| 2408 | skills: [], | ||
| 2409 | } as const satisfies ServerProvider; | ||
| 2410 | const makeInstance = (provider: ServerProvider): ProviderInstance => ({ | ||
| 2411 | instanceId: provider.instanceId, | ||
| 2412 | driverKind: provider.driver, | ||
| 2413 | continuationIdentity: { | ||
| 2414 | driverKind: provider.driver, | ||
| 2415 | continuationKey: `${provider.driver}:instance:${provider.instanceId}`, | ||
| 2416 | }, | ||
| 2417 | displayName: undefined, | ||
| 2418 | enabled: true, | ||
| 2419 | snapshot: { | ||
| 2420 | resolveMaintenance: () => | ||
| 2421 | Effect.succeed( | ||
| 2422 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2423 | provider: provider.driver, | ||
| 2424 | packageName: null, | ||
| 2425 | }), | ||
| 2426 | ), | ||
| 2427 | getSnapshot: Effect.succeed(provider), | ||
| 2428 | refresh: Effect.succeed(provider), | ||
| 2429 | streamChanges: Stream.empty, | ||
| 2430 | applyUsageLimits: () => Effect.void, | ||
| 2431 | }, | ||
| 2432 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2433 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2434 | }); | ||
| 2435 | const codexInstance = makeInstance(codexProvider); | ||
| 2436 | const claudeInstance = makeInstance(claudeProvider); | ||
| 2437 | const changes = yield* PubSub.unbounded<void>(); | ||
| 2438 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]); | ||
| 2439 | const failNextList = yield* Ref.make(false); | ||
| 2440 | const wait = () => Effect.yieldNow; | ||
| 2441 | const layerInstanceRegistry = Layer.succeed( | ||
| 2442 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2443 | { | ||
| 2444 | getInstance: (instanceId) => | ||
| 2445 | Ref.get(instancesRef).pipe( | ||
| 2446 | Effect.map((instances) => | ||
| 2447 | instances.find((instance) => instance.instanceId === instanceId), | ||
| 2448 | ), | ||
| 2449 | ), | ||
| 2450 | listInstances: Effect.gen(function* () { | ||
| 2451 | const shouldFail = yield* Ref.get(failNextList); | ||
| 2452 | if (shouldFail) { | ||
| 2453 | yield* Ref.set(failNextList, false); | ||
| 2454 | return yield* Effect.die(new Error("simulated registry list failure")); | ||
| 2455 | } | ||
| 2456 | return yield* Ref.get(instancesRef); | ||
| 2457 | }), | ||
| 2458 | listUnavailable: Effect.succeed([]), | ||
| 2459 | streamChanges: Stream.fromPubSub(changes), | ||
| 2460 | subscribeChanges: PubSub.subscribe(changes), | ||
| 2461 | }, | ||
| 2462 | ); | ||
| 2463 | const scope = yield* Scope.make(); | ||
| 2464 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2465 | const runtimeServices = yield* Layer.build( | ||
| 2466 | ProviderRegistryLayer.layer.pipe( | ||
| 2467 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2468 | Layer.provideMerge( | ||
| 2469 | ServerConfig.layerTest(process.cwd(), { | ||
| 2470 | prefix: "t3-provider-registry-sync-failure-", | ||
| 2471 | }), | ||
| 2472 | ), | ||
| 2473 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2474 | Layer.provideMerge(NodeServices.layer), | ||
| 2475 | ), | ||
| 2476 | ).pipe(Scope.provide(scope)); | ||
| 2477 | |||
| 2478 | yield* Effect.gen(function* () { | ||
| 2479 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2480 | assert.deepStrictEqual(yield* registry.getProviders, [ | ||
| 2481 | withBundledCompatibility(codexProvider), | ||
| 2482 | ]); | ||
| 2483 | |||
| 2484 | yield* Ref.set(failNextList, true); | ||
| 2485 | yield* PubSub.publish(changes, undefined); | ||
| 2486 | |||
| 2487 | yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); | ||
| 2488 | yield* PubSub.publish(changes, undefined); | ||
| 2489 | |||
| 2490 | let providers = yield* registry.getProviders; | ||
| 2491 | for ( | ||
| 2492 | let attempt = 0; | ||
| 2493 | attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); | ||
| 2494 | attempt += 1 | ||
| 2495 | ) { | ||
| 2496 | yield* wait(); | ||
| 2497 | providers = yield* registry.getProviders; | ||
| 2498 | } | ||
| 2499 | |||
| 2500 | assert.deepStrictEqual( | ||
| 2501 | providers.map((provider) => provider.instanceId).toSorted(), | ||
| 2502 | [codexInstanceId, claudeInstanceId].toSorted(), | ||
| 2503 | ); | ||
| 2504 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2505 | }), | ||
| 2506 | ); | ||
| 2507 | |||
| 2508 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real | ||
| 2509 | // `probeCodexAppServerProvider` path runs — including the full | ||
| 2510 | // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. | ||
| 2511 | // We point `binaryPath` at a name that cannot exist on any machine so | ||
| 2512 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the | ||
| 2513 | // probe wraps that as `CodexAppServerSpawnError` and | ||
| 2514 | // `checkCodexProviderStatus` turns it into the user-visible "not | ||
| 2515 | // installed" error snapshot. If the aggregator's `syncLiveSources` | ||
| 2516 | // breaks — the `codex_personal`-never-probes bug we are guarding | ||
| 2517 | // against — that snapshot never lands in `getProviders` and the | ||
| 2518 | // assertions below fail. | ||
| 2519 | it.effect("propagates real Codex probe failures to the aggregator at boot", () => | ||
| 2520 | Effect.gen(function* () { | ||
| 2521 | const missingBinary = `t3code_codex_missing_`; | ||
| 2522 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2523 | decodeServerSettings( | ||
| 2524 | deepMerge(encodedDefaultServerSettings, { | ||
| 2525 | providers: { | ||
| 2526 | // Disable every built-in probe that would otherwise spawn | ||
| 2527 | // on the CI host. `enabled: false` short-circuits each | ||
| 2528 | // driver's probe *before* it touches the spawner, so the | ||
| 2529 | // test environment stays isolated from the dev | ||
| 2530 | // machine's PATH. | ||
| 2531 | codex: { enabled: false }, | ||
| 2532 | claudeAgent: { enabled: false }, | ||
| 2533 | cursor: { enabled: false }, | ||
| 2534 | grok: { enabled: false }, | ||
| 2535 | opencode: { enabled: false }, | ||
| 2536 | }, | ||
| 2537 | // `providerInstances` keys are branded `ProviderInstanceId`; | ||
| 2538 | // the branded index signature rejects plain string literals | ||
| 2539 | // at the TS level even though the runtime schema happily | ||
| 2540 | // accepts + decodes them. Cast the patch to `unknown` so | ||
| 2541 | // the `Schema.decodeSync` below does the real validation. | ||
| 2542 | providerInstances: { | ||
| 2543 | // Matches the shape the user had in `.t3/dev/settings.json` | ||
| 2544 | // when the bug was reported: a custom enabled Codex instance | ||
| 2545 | // pointing at a binary the server has to actually spawn. | ||
| 2546 | codex_personal: { | ||
| 2547 | driver: "codex", | ||
| 2548 | displayName: "Codex Personal", | ||
| 2549 | enabled: true, | ||
| 2550 | config: { | ||
| 2551 | binaryPath: missingBinary, | ||
| 2552 | homePath: `/tmp/${missingBinary}_home`, | ||
| 2553 | }, | ||
| 2554 | }, | ||
| 2555 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 2556 | }), | ||
| 2557 | ), | ||
| 2558 | ); | ||
| 2559 | const scope = yield* Scope.make(); | ||
| 2560 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2561 | const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( | ||
| 2562 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2563 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2564 | Layer.provideMerge( | ||
| 2565 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2566 | ), | ||
| 2567 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2568 | Layer.provideMerge( | ||
| 2569 | ServerConfig.layerTest(process.cwd(), { | ||
| 2570 | prefix: "t3-provider-registry-", | ||
| 2571 | }), | ||
| 2572 | ), | ||
| 2573 | Layer.provideMerge(layerTestHttpClient), | ||
| 2574 | Layer.provideMerge( | ||
| 2575 | Layer.succeed( | ||
| 2576 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2577 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2578 | ), | ||
| 2579 | ), | ||
| 2580 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2581 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2582 | Layer.provideMerge( | ||
| 2583 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2584 | ), | ||
| 2585 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2586 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | ||
| 2587 | // outer `NodeServices.layer` on `it.layer(...)` and will | ||
| 2588 | // genuinely spawn a subprocess. The missing-binary ENOENT is | ||
| 2589 | // what exercises the same failure mode as a misconfigured | ||
| 2590 | // production `binaryPath`. | ||
| 2591 | ); | ||
| 2592 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2593 | Scope.provide(scope), | ||
| 2594 | ); | ||
| 2595 | |||
| 2596 | yield* Effect.gen(function* () { | ||
| 2597 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2598 | let providers = yield* registry.getProviders; | ||
| 2599 | for ( | ||
| 2600 | let attempts = 0; | ||
| 2601 | attempts < 50 && | ||
| 2602 | providers.find((provider) => provider.instanceId === "codex_personal")?.status !== | ||
| 2603 | "error"; | ||
| 2604 | attempts += 1 | ||
| 2605 | ) { | ||
| 2606 | yield* Effect.yieldNow; | ||
| 2607 | providers = yield* registry.getProviders; | ||
| 2608 | } | ||
| 2609 | const codexPersonal = providers.find( | ||
| 2610 | (provider) => provider.instanceId === "codex_personal", | ||
| 2611 | ); | ||
| 2612 | assert.notStrictEqual( | ||
| 2613 | codexPersonal, | ||
| 2614 | undefined, | ||
| 2615 | `Expected the aggregator to know about codex_personal; instead saw: ${providers | ||
| 2616 | .map((provider) => provider.instanceId) | ||
| 2617 | .join(", ")}`, | ||
| 2618 | ); | ||
| 2619 | assert.strictEqual( | ||
| 2620 | codexPersonal?.status, | ||
| 2621 | "error", | ||
| 2622 | "Real Codex probe against a missing binary should surface as 'error' in the aggregator", | ||
| 2623 | ); | ||
| 2624 | assert.strictEqual(codexPersonal?.installed, false); | ||
| 2625 | assert.include(codexPersonal?.message, missingBinary); | ||
| 2626 | assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); | ||
| 2627 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2628 | }), | ||
| 2629 | ); | ||
| 2630 | |||
| 2631 | // A binary path change must rebuild Codex and publish its new probe result. | ||
| 2632 | it.effect("re-probes when settings change the codex binaryPath", () => | ||
| 2633 | Effect.gen(function* () { | ||
| 2634 | const firstMissing = `t3code_codex_first_`; | ||
| 2635 | const secondMissing = `t3code_codex_second_`; | ||
| 2636 | const spawnedCommands: Array<string> = []; | ||
| 2637 | const secondProbeStarted = yield* Deferred.make<void>(); | ||
| 2638 | const releaseSecondProbe = yield* Deferred.make<void>(); | ||
| 2639 | const allowLazySettingsStream = yield* Deferred.make<void>(); | ||
| 2640 | const mutableServerSettings = yield* makeMutableServerSettingsService( | ||
| 2641 | decodeServerSettings( | ||
| 2642 | deepMerge(encodedDefaultServerSettings, { | ||
| 2643 | providers: { | ||
| 2644 | codex: { enabled: true, binaryPath: firstMissing }, | ||
| 2645 | claudeAgent: { enabled: false }, | ||
| 2646 | cursor: { enabled: false }, | ||
| 2647 | grok: { enabled: false }, | ||
| 2648 | opencode: { enabled: false }, | ||
| 2649 | }, | ||
| 2650 | }), | ||
| 2651 | ), | ||
| 2652 | ); | ||
| 2653 | const serverSettings = { | ||
| 2654 | ...mutableServerSettings, | ||
| 2655 | streamChanges: Stream.unwrap( | ||
| 2656 | Deferred.await(allowLazySettingsStream).pipe( | ||
| 2657 | Effect.as(mutableServerSettings.streamChanges), | ||
| 2658 | ), | ||
| 2659 | ), | ||
| 2660 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; | ||
| 2661 | const scope = yield* Scope.make(); | ||
| 2662 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2663 | const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( | ||
| 2664 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2665 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2666 | Layer.provideMerge( | ||
| 2667 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2668 | ), | ||
| 2669 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2670 | Layer.provideMerge( | ||
| 2671 | ServerConfig.layerTest(process.cwd(), { | ||
| 2672 | prefix: "t3-provider-registry-", | ||
| 2673 | }), | ||
| 2674 | ), | ||
| 2675 | Layer.provideMerge(layerTestHttpClient), | ||
| 2676 | Layer.provideMerge( | ||
| 2677 | Layer.succeed( | ||
| 2678 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2679 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2680 | ), | ||
| 2681 | ), | ||
| 2682 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2683 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2684 | Layer.provideMerge( | ||
| 2685 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2686 | ), | ||
| 2687 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => | ||
| 2688 | ChildProcessSpawner.make((command) => { | ||
| 2689 | if (command._tag !== "StandardCommand") return spawner.spawn(command); | ||
| 2690 | spawnedCommands.push(command.command); | ||
| 2691 | const beforeSpawn = | ||
| 2692 | command.command === secondMissing | ||
| 2693 | ? Deferred.succeed(secondProbeStarted, undefined).pipe( | ||
| 2694 | Effect.andThen(Deferred.await(releaseSecondProbe)), | ||
| 2695 | ) | ||
| 2696 | : Effect.void; | ||
| 2697 | return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); | ||
| 2698 | }), | ||
| 2699 | ), | ||
| 2700 | Layer.provideMerge(NodeServices.layer), | ||
| 2701 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2702 | ); | ||
| 2703 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2704 | Scope.provide(scope), | ||
| 2705 | ); | ||
| 2706 | |||
| 2707 | yield* Effect.gen(function* () { | ||
| 2708 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2709 | const codexSnapshots = registry.streamChanges.pipe( | ||
| 2710 | Stream.map((providers) => | ||
| 2711 | providers.find((provider) => provider.instanceId === "codex"), | ||
| 2712 | ), | ||
| 2713 | Stream.filter((provider): provider is ServerProvider => provider !== undefined), | ||
| 2714 | ); | ||
| 2715 | const firstError = yield* Stream.toPull( | ||
| 2716 | codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), | ||
| 2717 | ); | ||
| 2718 | const currentCodex = (yield* registry.getProviders).find( | ||
| 2719 | (provider) => provider.instanceId === "codex", | ||
| 2720 | ); | ||
| 2721 | const initialCodex = | ||
| 2722 | currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0]; | ||
| 2723 | assert.strictEqual(initialCodex?.status, "error"); | ||
| 2724 | assert.strictEqual(initialCodex?.installed, false); | ||
| 2725 | assert.deepStrictEqual(spawnedCommands, [firstMissing]); | ||
| 2726 | |||
| 2727 | const pendingRebuild = yield* Stream.toPull( | ||
| 2728 | codexSnapshots.pipe( | ||
| 2729 | Stream.filter((provider) => provider.status === "warning" && !provider.installed), | ||
| 2730 | ), | ||
| 2731 | ); | ||
| 2732 | yield* serverSettings.updateSettings({ | ||
| 2733 | providers: { | ||
| 2734 | codex: { enabled: true, binaryPath: secondMissing }, | ||
| 2735 | }, | ||
| 2736 | }); | ||
| 2737 | // Start the lazy stream only after publishing. A watcher that did | ||
| 2738 | // not subscribe before forking has already lost this update. | ||
| 2739 | yield* Deferred.succeed(allowLazySettingsStream, undefined); | ||
| 2740 | |||
| 2741 | // Hold the second probe until the aggregator sees the rebuilt | ||
| 2742 | // instance. Its next error must come from the new executable. | ||
| 2743 | yield* Deferred.await(secondProbeStarted); | ||
| 2744 | yield* pendingRebuild; | ||
| 2745 | const rebuiltError = yield* Stream.toPull( | ||
| 2746 | codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), | ||
| 2747 | ); | ||
| 2748 | yield* Deferred.succeed(releaseSecondProbe, undefined); | ||
| 2749 | const [reprobedCodex] = yield* rebuiltError; | ||
| 2750 | assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); | ||
| 2751 | assert.strictEqual(reprobedCodex?.status, "error"); | ||
| 2752 | assert.strictEqual(reprobedCodex?.installed, false); | ||
| 2753 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2754 | }), | ||
| 2755 | ); | ||
| 2756 | |||
| 2757 | it.effect("includes unavailable instance snapshots in getProviders", () => | ||
| 2758 | Effect.gen(function* () { | ||
| 2759 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2760 | decodeServerSettings( | ||
| 2761 | deepMerge(encodedDefaultServerSettings, { | ||
| 2762 | providers: { | ||
| 2763 | codex: { enabled: false }, | ||
| 2764 | claudeAgent: { enabled: false }, | ||
| 2765 | cursor: { enabled: false }, | ||
| 2766 | grok: { enabled: false }, | ||
| 2767 | opencode: { enabled: false }, | ||
| 2768 | }, | ||
| 2769 | providerInstances: { | ||
| 2770 | ghost_main: { | ||
| 2771 | driver: "ghostDriver", | ||
| 2772 | displayName: "A fork-only driver we don't ship", | ||
| 2773 | enabled: false, | ||
| 2774 | config: { arbitrary: "payload" }, | ||
| 2775 | }, | ||
| 2776 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 2777 | }), | ||
| 2778 | ), | ||
| 2779 | ); | ||
| 2780 | const scope = yield* Scope.make(); | ||
| 2781 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2782 | const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( | ||
| 2783 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2784 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2785 | Layer.provideMerge( | ||
| 2786 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2787 | ), | ||
| 2788 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2789 | Layer.provideMerge( | ||
| 2790 | ServerConfig.layerTest(process.cwd(), { | ||
| 2791 | prefix: "t3-provider-registry-", | ||
| 2792 | }), | ||
| 2793 | ), | ||
| 2794 | Layer.provideMerge(layerTestHttpClient), | ||
| 2795 | Layer.provideMerge( | ||
| 2796 | Layer.succeed( | ||
| 2797 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2798 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2799 | ), | ||
| 2800 | ), | ||
| 2801 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2802 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2803 | Layer.provideMerge( | ||
| 2804 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2805 | ), | ||
| 2806 | Layer.provideMerge(NodeServices.layer), | ||
| 2807 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2808 | ); | ||
| 2809 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2810 | Scope.provide(scope), | ||
| 2811 | ); | ||
| 2812 | |||
| 2813 | yield* Effect.gen(function* () { | ||
| 2814 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2815 | const providers = yield* registry.getProviders; | ||
| 2816 | const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); | ||
| 2817 | |||
| 2818 | assert.notStrictEqual(ghost, undefined); | ||
| 2819 | assert.strictEqual(ghost?.driver, "ghostDriver"); | ||
| 2820 | assert.strictEqual(ghost?.availability, "unavailable"); | ||
| 2821 | assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); | ||
| 2822 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2823 | }), | ||
| 2824 | ); | ||
| 2825 | |||
| 2826 | it.effect( | ||
| 2827 | "keeps Cursor disabled and skips provider probing when settings use their defaults", | ||
| 2828 | () => | ||
| 2829 | Effect.gen(function* () { | ||
| 2830 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2831 | decodeServerSettings( | ||
| 2832 | deepMerge(encodedDefaultServerSettings, { | ||
| 2833 | providers: { | ||
| 2834 | codex: { | ||
| 2835 | enabled: false, | ||
| 2836 | }, | ||
| 2837 | grok: { | ||
| 2838 | enabled: false, | ||
| 2839 | }, | ||
| 2840 | }, | ||
| 2841 | }), | ||
| 2842 | ), | ||
| 2843 | ); | ||
| 2844 | let cursorSpawned = false; | ||
| 2845 | const scope = yield* Scope.make(); | ||
| 2846 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2847 | const layerProviderRegistry = ProviderRegistryLayer.layer.pipe( | ||
| 2848 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2849 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2850 | Layer.provideMerge( | ||
| 2851 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2852 | ), | ||
| 2853 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2854 | Layer.provideMerge( | ||
| 2855 | ServerConfig.layerTest(process.cwd(), { | ||
| 2856 | prefix: "t3-provider-registry-", | ||
| 2857 | }), | ||
| 2858 | ), | ||
| 2859 | Layer.provideMerge(layerTestHttpClient), | ||
| 2860 | Layer.provideMerge( | ||
| 2861 | Layer.succeed( | ||
| 2862 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2863 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2864 | ), | ||
| 2865 | ), | ||
| 2866 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2867 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2868 | Layer.provideMerge( | ||
| 2869 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2870 | ), | ||
| 2871 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2872 | Layer.provideMerge( | ||
| 2873 | layerMockCommandSpawner((command, args) => { | ||
| 2874 | if (command === "cursor-agent") { | ||
| 2875 | cursorSpawned = true; | ||
| 2876 | } | ||
| 2877 | const joined = args.join(" "); | ||
| 2878 | if (joined === "--version") { | ||
| 2879 | return { | ||
| 2880 | stdout: `${command} 1.0.0\n`, | ||
| 2881 | stderr: "", | ||
| 2882 | code: 0, | ||
| 2883 | }; | ||
| 2884 | } | ||
| 2885 | if (joined === "auth status") { | ||
| 2886 | return { | ||
| 2887 | stdout: '{"authenticated":true}\n', | ||
| 2888 | stderr: "", | ||
| 2889 | code: 0, | ||
| 2890 | }; | ||
| 2891 | } | ||
| 2892 | throw new Error(`Unexpected args: ${command} ${joined}`); | ||
| 2893 | }), | ||
| 2894 | ), | ||
| 2895 | ); | ||
| 2896 | const runtimeServices = yield* Layer.build( | ||
| 2897 | Layer.mergeAll( | ||
| 2898 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2899 | layerProviderRegistry, | ||
| 2900 | ), | ||
| 2901 | ).pipe(Scope.provide(scope)); | ||
| 2902 | |||
| 2903 | yield* Effect.gen(function* () { | ||
| 2904 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2905 | const providers = yield* registry.getProviders; | ||
| 2906 | const cursorProvider = providers.find( | ||
| 2907 | (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), | ||
| 2908 | ); | ||
| 2909 | |||
| 2910 | assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ | ||
| 2911 | "antigravity", | ||
| 2912 | "claudeAgent", | ||
| 2913 | "codex", | ||
| 2914 | "cursor", | ||
| 2915 | "grok", | ||
| 2916 | "opencode", | ||
| 2917 | "pi", | ||
| 2918 | ]); | ||
| 2919 | assert.strictEqual(cursorProvider?.enabled, false); | ||
| 2920 | assert.strictEqual(cursorProvider?.status, "disabled"); | ||
| 2921 | assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); | ||
| 2922 | assert.strictEqual(cursorSpawned, false); | ||
| 2923 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2924 | }), | ||
| 2925 | ); | ||
| 2926 | |||
| 2927 | it.effect("skips codex probes entirely when the provider is disabled", () => | ||
| 2928 | Effect.gen(function* () { | ||
| 2929 | const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( | ||
| 2930 | Effect.provide(layerFailingSpawner("spawn codex ENOENT")), | ||
| 2931 | ); | ||
| 2932 | assert.strictEqual(status.enabled, false); | ||
| 2933 | assert.strictEqual(status.status, "disabled"); | ||
| 2934 | assert.strictEqual(status.installed, false); | ||
| 2935 | assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); | ||
| 2936 | }), | ||
| 2937 | ); | ||
| 2938 | }); | ||
| 2939 | |||
| 2940 | // ── checkClaudeProviderStatus tests ────────────────────────── | ||
| 2941 | |||
| 2942 | describe("checkClaudeProviderStatus", () => { | ||
| 2943 | it.effect("returns ready when claude is installed and authenticated", () => | ||
| 2944 | Effect.gen(function* () { | ||
| 2945 | const status = yield* checkClaudeProviderStatus( | ||
| 2946 | defaultClaudeSettings, | ||
| 2947 | claudeCapabilities(), | ||
| 2948 | ); | ||
| 2949 | assert.strictEqual(status.status, "ready"); | ||
| 2950 | assert.strictEqual(status.installed, true); | ||
| 2951 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 2952 | }).pipe( | ||
| 2953 | Effect.provide( | ||
| 2954 | layerMockSpawner((args) => { | ||
| 2955 | const joined = args.join(" "); | ||
| 2956 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 2957 | if (joined === "auth status") | ||
| 2958 | return { | ||
| 2959 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 2960 | stderr: "", | ||
| 2961 | code: 0, | ||
| 2962 | }; | ||
| 2963 | throw new Error(`Unexpected args: ${joined}`); | ||
| 2964 | }), | ||
| 2965 | ), | ||
| 2966 | ), | ||
| 2967 | ); | ||
| 2968 | |||
| 2969 | it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => | ||
| 2970 | Effect.gen(function* () { | ||
| 2971 | // Bedrock authenticates via external AWS credentials, so the SDK init | ||
| 2972 | // reports only `apiProvider` with no subscription or token. | ||
| 2973 | const status = yield* checkClaudeProviderStatus( | ||
| 2974 | defaultClaudeSettings, | ||
| 2975 | claudeCapabilities({ apiProvider: "bedrock" }), | ||
| 2976 | ); | ||
| 2977 | assert.strictEqual(status.status, "ready"); | ||
| 2978 | assert.strictEqual(status.installed, true); | ||
| 2979 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 2980 | assert.strictEqual(status.auth.type, "bedrock"); | ||
| 2981 | assert.strictEqual(status.auth.label, "Amazon Bedrock"); | ||
| 2982 | }).pipe( | ||
| 2983 | Effect.provide( | ||
| 2984 | layerMockSpawner((args) => { | ||
| 2985 | const joined = args.join(" "); | ||
| 2986 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 2987 | throw new Error(`Unexpected args: ${joined}`); | ||
| 2988 | }), | ||
| 2989 | ), | ||
| 2990 | ), | ||
| 2991 | ); | ||
| 2992 | |||
| 2993 | it.effect("returns a display label for claude subscription types", () => | ||
| 2994 | Effect.gen(function* () { | ||
| 2995 | const status = yield* checkClaudeProviderStatus( | ||
| 2996 | defaultClaudeSettings, | ||
| 2997 | claudeCapabilities({ subscriptionType: "maxplan" }), | ||
| 2998 | ); | ||
| 2999 | assert.strictEqual(status.status, "ready"); | ||
| 3000 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3001 | assert.strictEqual(status.auth.type, "maxplan"); | ||
| 3002 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3003 | }).pipe( | ||
| 3004 | Effect.provide( | ||
| 3005 | layerMockSpawner((args) => { | ||
| 3006 | const joined = args.join(" "); | ||
| 3007 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3008 | if (joined === "auth status") | ||
| 3009 | return { | ||
| 3010 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3011 | stderr: "", | ||
| 3012 | code: 0, | ||
| 3013 | }; | ||
| 3014 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3015 | }), | ||
| 3016 | ), | ||
| 3017 | ), | ||
| 3018 | ); | ||
| 3019 | |||
| 3020 | it.effect("reads banked resets only for subscription logins", () => | ||
| 3021 | Effect.gen(function* () { | ||
| 3022 | const check = (overrides: Partial<TestClaudeCapabilities>) => | ||
| 3023 | checkClaudeProviderStatus( | ||
| 3024 | defaultClaudeSettings, | ||
| 3025 | () => | ||
| 3026 | Effect.succeed({ | ||
| 3027 | email: undefined, | ||
| 3028 | subscriptionType: undefined, | ||
| 3029 | tokenSource: undefined, | ||
| 3030 | apiProvider: undefined, | ||
| 3031 | slashCommands: [], | ||
| 3032 | usage: { rate_limits_available: true, rate_limits: {} }, | ||
| 3033 | ...overrides, | ||
| 3034 | }), | ||
| 3035 | undefined, | ||
| 3036 | undefined, | ||
| 3037 | undefined, | ||
| 3038 | undefined, | ||
| 3039 | () => Effect.succeed({ availableCount: 2 }), | ||
| 3040 | ); | ||
| 3041 | const subscription = yield* check({ subscriptionType: "max" }); | ||
| 3042 | const bedrock = yield* check({ apiProvider: "bedrock" }); | ||
| 3043 | assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); | ||
| 3044 | assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); | ||
| 3045 | }).pipe( | ||
| 3046 | Effect.provide( | ||
| 3047 | layerMockSpawner((args) => { | ||
| 3048 | const joined = args.join(" "); | ||
| 3049 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3050 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3051 | }), | ||
| 3052 | ), | ||
| 3053 | ), | ||
| 3054 | ); | ||
| 3055 | |||
| 3056 | it.effect("does not duplicate Claude in full subscription labels", () => | ||
| 3057 | Effect.gen(function* () { | ||
| 3058 | const status = yield* checkClaudeProviderStatus( | ||
| 3059 | defaultClaudeSettings, | ||
| 3060 | claudeCapabilities({ | ||
| 3061 | subscriptionType: "Claude Max Subscription", | ||
| 3062 | }), | ||
| 3063 | ); | ||
| 3064 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3065 | assert.strictEqual(status.auth.type, "Claude Max Subscription"); | ||
| 3066 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3067 | }).pipe( | ||
| 3068 | Effect.provide( | ||
| 3069 | layerMockSpawner((args) => { | ||
| 3070 | const joined = args.join(" "); | ||
| 3071 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3072 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3073 | }), | ||
| 3074 | ), | ||
| 3075 | ), | ||
| 3076 | ); | ||
| 3077 | |||
| 3078 | it.effect("does not duplicate Claude in provider-prefixed subscription names", () => | ||
| 3079 | Effect.gen(function* () { | ||
| 3080 | const status = yield* checkClaudeProviderStatus( | ||
| 3081 | defaultClaudeSettings, | ||
| 3082 | claudeCapabilities({ | ||
| 3083 | subscriptionType: "Claude Max", | ||
| 3084 | }), | ||
| 3085 | ); | ||
| 3086 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3087 | assert.strictEqual(status.auth.type, "Claude Max"); | ||
| 3088 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3089 | }).pipe( | ||
| 3090 | Effect.provide( | ||
| 3091 | layerMockSpawner((args) => { | ||
| 3092 | const joined = args.join(" "); | ||
| 3093 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3094 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3095 | }), | ||
| 3096 | ), | ||
| 3097 | ), | ||
| 3098 | ); | ||
| 3099 | |||
| 3100 | it.effect("returns claude auth email from initialization result", () => | ||
| 3101 | Effect.gen(function* () { | ||
| 3102 | const status = yield* checkClaudeProviderStatus( | ||
| 3103 | defaultClaudeSettings, | ||
| 3104 | claudeCapabilities({ email: "claude@example.com" }), | ||
| 3105 | ); | ||
| 3106 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3107 | assert.strictEqual(status.auth.email, "claude@example.com"); | ||
| 3108 | }).pipe( | ||
| 3109 | Effect.provide( | ||
| 3110 | layerMockSpawner((args) => { | ||
| 3111 | const joined = args.join(" "); | ||
| 3112 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3113 | if (joined === "auth status") | ||
| 3114 | return { | ||
| 3115 | stdout: | ||
| 3116 | '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', | ||
| 3117 | stderr: "", | ||
| 3118 | code: 0, | ||
| 3119 | }; | ||
| 3120 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3121 | }), | ||
| 3122 | ), | ||
| 3123 | ), | ||
| 3124 | ); | ||
| 3125 | |||
| 3126 | it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { | ||
| 3127 | const claudeConfigDir = "/tmp/t3code-claude-home"; | ||
| 3128 | const recorded = recordingMockSpawnerLayer((args) => { | ||
| 3129 | const joined = args.join(" "); | ||
| 3130 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3131 | if (joined === "auth status") | ||
| 3132 | return { | ||
| 3133 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3134 | stderr: "", | ||
| 3135 | code: 0, | ||
| 3136 | }; | ||
| 3137 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3138 | }); | ||
| 3139 | |||
| 3140 | return Effect.gen(function* () { | ||
| 3141 | const status = yield* checkClaudeProviderStatus( | ||
| 3142 | { | ||
| 3143 | ...defaultClaudeSettings, | ||
| 3144 | homePath: claudeConfigDir, | ||
| 3145 | }, | ||
| 3146 | claudeCapabilities(), | ||
| 3147 | ); | ||
| 3148 | assert.strictEqual(status.status, "ready"); | ||
| 3149 | // The home is resolved through the host Path before it reaches the env. | ||
| 3150 | assert.deepStrictEqual( | ||
| 3151 | recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), | ||
| 3152 | [(yield* Path.Path).resolve(claudeConfigDir)], | ||
| 3153 | ); | ||
| 3154 | }).pipe(Effect.provide(recorded.layer)); | ||
| 3155 | }); | ||
| 3156 | |||
| 3157 | it.effect("includes probed claude slash commands in the provider snapshot", () => | ||
| 3158 | Effect.gen(function* () { | ||
| 3159 | const status = yield* checkClaudeProviderStatus( | ||
| 3160 | defaultClaudeSettings, | ||
| 3161 | claudeCapabilities({ | ||
| 3162 | subscriptionType: "maxplan", | ||
| 3163 | slashCommands: [ | ||
| 3164 | { | ||
| 3165 | name: "review", | ||
| 3166 | description: "Review a pull request", | ||
| 3167 | input: { hint: "pr-or-branch" }, | ||
| 3168 | }, | ||
| 3169 | ], | ||
| 3170 | }), | ||
| 3171 | ); | ||
| 3172 | |||
| 3173 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | ||
| 3174 | { | ||
| 3175 | name: "review", | ||
| 3176 | description: "Review a pull request", | ||
| 3177 | input: { hint: "pr-or-branch" }, | ||
| 3178 | }, | ||
| 3179 | ]); | ||
| 3180 | }).pipe( | ||
| 3181 | Effect.provide( | ||
| 3182 | layerMockSpawner((args) => { | ||
| 3183 | const joined = args.join(" "); | ||
| 3184 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3185 | if (joined === "auth status") | ||
| 3186 | return { | ||
| 3187 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3188 | stderr: "", | ||
| 3189 | code: 0, | ||
| 3190 | }; | ||
| 3191 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3192 | }), | ||
| 3193 | ), | ||
| 3194 | ), | ||
| 3195 | ); | ||
| 3196 | |||
| 3197 | it.effect("deduplicates probed claude slash commands by name", () => | ||
| 3198 | Effect.gen(function* () { | ||
| 3199 | const status = yield* checkClaudeProviderStatus( | ||
| 3200 | defaultClaudeSettings, | ||
| 3201 | claudeCapabilities({ | ||
| 3202 | subscriptionType: "maxplan", | ||
| 3203 | slashCommands: [ | ||
| 3204 | { | ||
| 3205 | name: "ui", | ||
| 3206 | description: "Explore and refine UI", | ||
| 3207 | }, | ||
| 3208 | { | ||
| 3209 | name: "ui", | ||
| 3210 | input: { hint: "component-or-screen" }, | ||
| 3211 | }, | ||
| 3212 | ], | ||
| 3213 | }), | ||
| 3214 | ); | ||
| 3215 | |||
| 3216 | assert.deepStrictEqual(status.slashCommands, [ | ||
| 3217 | COMPACT_SLASH_COMMAND, | ||
| 3218 | { | ||
| 3219 | name: "ui", | ||
| 3220 | description: "Explore and refine UI", | ||
| 3221 | input: { hint: "component-or-screen" }, | ||
| 3222 | }, | ||
| 3223 | ]); | ||
| 3224 | }).pipe( | ||
| 3225 | Effect.provide( | ||
| 3226 | layerMockSpawner((args) => { | ||
| 3227 | const joined = args.join(" "); | ||
| 3228 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3229 | if (joined === "auth status") | ||
| 3230 | return { | ||
| 3231 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3232 | stderr: "", | ||
| 3233 | code: 0, | ||
| 3234 | }; | ||
| 3235 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3236 | }), | ||
| 3237 | ), | ||
| 3238 | ), | ||
| 3239 | ); | ||
| 3240 | |||
| 3241 | it.effect("returns an api key label for claude api key auth", () => | ||
| 3242 | Effect.gen(function* () { | ||
| 3243 | const status = yield* checkClaudeProviderStatus( | ||
| 3244 | defaultClaudeSettings, | ||
| 3245 | claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), | ||
| 3246 | ); | ||
| 3247 | assert.strictEqual(status.status, "ready"); | ||
| 3248 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3249 | assert.strictEqual(status.auth.type, "apiKey"); | ||
| 3250 | assert.strictEqual(status.auth.label, "Claude API Key"); | ||
| 3251 | }).pipe( | ||
| 3252 | Effect.provide( | ||
| 3253 | layerMockSpawner((args) => { | ||
| 3254 | const joined = args.join(" "); | ||
| 3255 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3256 | if (joined === "auth status") | ||
| 3257 | return { | ||
| 3258 | stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', | ||
| 3259 | stderr: "", | ||
| 3260 | code: 0, | ||
| 3261 | }; | ||
| 3262 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3263 | }), | ||
| 3264 | ), | ||
| 3265 | ), | ||
| 3266 | ); | ||
| 3267 | |||
| 3268 | it.effect("returns unavailable when claude is missing", () => | ||
| 3269 | Effect.gen(function* () { | ||
| 3270 | const status = yield* checkClaudeProviderStatus( | ||
| 3271 | defaultClaudeSettings, | ||
| 3272 | claudeCapabilities(), | ||
| 3273 | ); | ||
| 3274 | assert.strictEqual(status.status, "error"); | ||
| 3275 | assert.strictEqual(status.installed, false); | ||
| 3276 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 3277 | assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); | ||
| 3278 | }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), | ||
| 3279 | ); | ||
| 3280 | |||
| 3281 | it.effect("returns error when version check fails with non-zero exit code", () => { | ||
| 3282 | const secretStderr = "Something went wrong: secret-token-value"; | ||
| 3283 | return Effect.gen(function* () { | ||
| 3284 | const status = yield* checkClaudeProviderStatus( | ||
| 3285 | defaultClaudeSettings, | ||
| 3286 | claudeCapabilities(), | ||
| 3287 | ); | ||
| 3288 | assert.strictEqual(status.status, "error"); | ||
| 3289 | assert.strictEqual(status.installed, true); | ||
| 3290 | assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); | ||
| 3291 | assert.ok(!(status.message ?? "").includes(secretStderr)); | ||
| 3292 | }).pipe( | ||
| 3293 | Effect.provide( | ||
| 3294 | layerMockSpawner((args) => { | ||
| 3295 | const joined = args.join(" "); | ||
| 3296 | if (joined === "--version") | ||
| 3297 | return { | ||
| 3298 | stdout: "", | ||
| 3299 | stderr: secretStderr, | ||
| 3300 | code: 1, | ||
| 3301 | }; | ||
| 3302 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3303 | }), | ||
| 3304 | ), | ||
| 3305 | ); | ||
| 3306 | }); | ||
| 3307 | |||
| 3308 | it.effect("returns warning when the Claude initialization result is unavailable", () => | ||
| 3309 | Effect.gen(function* () { | ||
| 3310 | const status = yield* checkClaudeProviderStatus( | ||
| 3311 | defaultClaudeSettings, | ||
| 3312 | noClaudeCapabilities, | ||
| 3313 | ); | ||
| 3314 | assert.strictEqual(status.status, "warning"); | ||
| 3315 | assert.strictEqual(status.installed, true); | ||
| 3316 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 3317 | assert.strictEqual( | ||
| 3318 | status.message, | ||
| 3319 | "Could not verify Claude authentication status from initialization result.", | ||
| 3320 | ); | ||
| 3321 | }).pipe( | ||
| 3322 | Effect.provide( | ||
| 3323 | layerMockSpawner((args) => { | ||
| 3324 | const joined = args.join(" "); | ||
| 3325 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3326 | if (joined === "auth status") | ||
| 3327 | return { | ||
| 3328 | stdout: '{"loggedIn":false}\n', | ||
| 3329 | stderr: "", | ||
| 3330 | code: 1, | ||
| 3331 | }; | ||
| 3332 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3333 | }), | ||
| 3334 | ), | ||
| 3335 | ), | ||
| 3336 | ); | ||
| 3337 | }); | ||
| 3338 | }); | ||
apps/server/src/provider/Layers/ProviderRegistry.ts deleted-1000| ... | @@ -1,1000 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderRegistryLive — aggregates per-instance snapshot streams into a | ||
| 3 | * single materialized list. | ||
| 4 | * | ||
| 5 | * Historically this Layer composed four per-kind Live Layers | ||
| 6 | * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a | ||
| 7 | * `ServerProviderShape`. Those Lives were deleted during the driver / | ||
| 8 | * instance refactor — every driver now carries its `snapshot: ServerProviderShape` | ||
| 9 | * bundled onto the `ProviderInstance` the registry produces. | ||
| 10 | * | ||
| 11 | * Each configured instance (including multi-instance setups like | ||
| 12 | * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, | ||
| 13 | * keyed by `instanceId`. Instances whose driver is unavailable or whose | ||
| 14 | * config failed to decode are merged from `instanceRegistry.listUnavailable` | ||
| 15 | * as shadow snapshots so the UI can render their exact unavailable reason. | ||
| 16 | * | ||
| 17 | * Cache paths on disk are now keyed by `instanceId`. Because | ||
| 18 | * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing | ||
| 19 | * `<kind>.json` files remain the on-disk location for that driver's default | ||
| 20 | * instance. Identity-less legacy cache contents are ignored and replaced by | ||
| 21 | * the first live refresh. | ||
| 22 | * | ||
| 23 | * @module ProviderRegistryLive | ||
| 24 | */ | ||
| 25 | import { | ||
| 26 | defaultInstanceIdForDriver, | ||
| 27 | ProviderDriverKind, | ||
| 28 | type ProviderInstanceId, | ||
| 29 | type ServerProvider, | ||
| 30 | type ServerProviderUpdateState, | ||
| 31 | } from "@t3tools/contracts"; | ||
| 32 | import * as Cause from "effect/Cause"; | ||
| 33 | import * as Effect from "effect/Effect"; | ||
| 34 | import * as Equal from "effect/Equal"; | ||
| 35 | import * as FileSystem from "effect/FileSystem"; | ||
| 36 | import * as Layer from "effect/Layer"; | ||
| 37 | import * as Path from "effect/Path"; | ||
| 38 | import * as PubSub from "effect/PubSub"; | ||
| 39 | import * as Ref from "effect/Ref"; | ||
| 40 | import * as Stream from "effect/Stream"; | ||
| 41 | import * as Semaphore from "effect/Semaphore"; | ||
| 42 | |||
| 43 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 44 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; | ||
| 45 | import * as ServerConfig from "../../config.ts"; | ||
| 46 | import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; | ||
| 47 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; | ||
| 48 | import { | ||
| 49 | hydrateCachedProvider, | ||
| 50 | isCachedProviderCorrelated, | ||
| 51 | orderProviderSnapshots, | ||
| 52 | readProviderStatusCache, | ||
| 53 | resolveProviderStatusCachePath, | ||
| 54 | writeProviderStatusCache, | ||
| 55 | } from "../providerStatusCache.ts"; | ||
| 56 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "../ProviderDriver.ts"; | ||
| 57 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 58 | import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; | ||
| 59 | |||
| 60 | const loadProviders = ( | ||
| 61 | providerSources: ReadonlyArray<ProviderSnapshotSource>, | ||
| 62 | ): Effect.Effect<ReadonlyArray<ServerProvider>> => | ||
| 63 | Effect.forEach( | ||
| 64 | providerSources, | ||
| 65 | (providerSource) => | ||
| 66 | providerSource.getSnapshot.pipe( | ||
| 67 | Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), | ||
| 68 | ), | ||
| 69 | { | ||
| 70 | concurrency: "unbounded", | ||
| 71 | }, | ||
| 72 | ); | ||
| 73 | |||
| 74 | const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => | ||
| 75 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 76 | provider, | ||
| 77 | packageName: null, | ||
| 78 | }); | ||
| 79 | |||
| 80 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => | ||
| 81 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | ||
| 82 | |||
| 83 | const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; | ||
| 84 | |||
| 85 | function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider { | ||
| 86 | return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd) | ||
| 87 | ? { | ||
| 88 | ...provider, | ||
| 89 | workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd), | ||
| 90 | } | ||
| 91 | : provider; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function upsertProviderWorkspaceSnapshot( | ||
| 95 | provider: ServerProvider, | ||
| 96 | cwd: string, | ||
| 97 | scopedSnapshot: ProviderWorkspaceSnapshot, | ||
| 98 | ): ServerProvider { | ||
| 99 | const workspaceSnapshot = { | ||
| 100 | cwd, | ||
| 101 | checkedAt: scopedSnapshot.checkedAt, | ||
| 102 | slashCommands: scopedSnapshot.slashCommandsPending | ||
| 103 | ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? | ||
| 104 | scopedSnapshot.slashCommands) | ||
| 105 | : scopedSnapshot.slashCommands, | ||
| 106 | ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), | ||
| 107 | skills: scopedSnapshot.skills, | ||
| 108 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; | ||
| 109 | return { | ||
| 110 | ...provider, | ||
| 111 | workspaceSnapshots: [ | ||
| 112 | ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), | ||
| 113 | workspaceSnapshot, | ||
| 114 | ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { | ||
| 119 | if (provider.driver === ProviderDriverKind.make("acpRegistry")) { | ||
| 120 | // ACP Registry discovery probes return the agent's complete inventory, so | ||
| 121 | // a completed probe (ready and authenticated) replaces the model list — | ||
| 122 | // otherwise agents that rename or collapse models leave stale entries | ||
| 123 | // pinned forever through the snapshot cache. Readiness-only and failed | ||
| 124 | // probe snapshots only know the "default" placeholder and stay partial. | ||
| 125 | return !( | ||
| 126 | provider.installed && | ||
| 127 | provider.status === "ready" && | ||
| 128 | provider.auth.status === "authenticated" | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); | ||
| 133 | const isCodex = provider.driver === ProviderDriverKind.make("codex"); | ||
| 134 | if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { | ||
| 135 | return true; | ||
| 136 | } | ||
| 137 | |||
| 138 | if ( | ||
| 139 | (isAntigravity || isCodex) && | ||
| 140 | (!provider.enabled || provider.auth.status === "unauthenticated") | ||
| 141 | ) { | ||
| 142 | return false; | ||
| 143 | } | ||
| 144 | |||
| 145 | // Successful discovery replaces these inventories so cached retired models disappear. | ||
| 146 | // Antigravity's local health check does not authenticate or discover models. | ||
| 147 | const isPendingAntigravityAuthentication = | ||
| 148 | isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; | ||
| 149 | const isPendingInitialProbe = | ||
| 150 | provider.enabled && !provider.installed && provider.status === "warning"; | ||
| 151 | const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; | ||
| 152 | return ( | ||
| 153 | isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail | ||
| 154 | ); | ||
| 155 | }; | ||
| 156 | |||
| 157 | const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => | ||
| 158 | provider.driver === ProviderDriverKind.make("opencode") && | ||
| 159 | shouldRetainMissingProviderModels(provider); | ||
| 160 | |||
| 161 | const mergeProviderModels = ( | ||
| 162 | provider: ServerProvider, | ||
| 163 | previousModels: ReadonlyArray<ServerProvider["models"][number]>, | ||
| 164 | nextModels: ReadonlyArray<ServerProvider["models"][number]>, | ||
| 165 | ): ReadonlyArray<ServerProvider["models"][number]> => { | ||
| 166 | const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); | ||
| 167 | // Custom rows are derived from settings and every snapshot carries the full | ||
| 168 | // current list, so a custom model missing from `nextModels` was removed by | ||
| 169 | // the user and must not be resurrected from the previous snapshot. | ||
| 170 | const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); | ||
| 171 | |||
| 172 | if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { | ||
| 173 | return retainablePreviousModels; | ||
| 174 | } | ||
| 175 | |||
| 176 | const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); | ||
| 177 | const mergedModels = nextModels.map((model) => { | ||
| 178 | const previousModel = previousBySlug.get(model.slug); | ||
| 179 | if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { | ||
| 180 | return model; | ||
| 181 | } | ||
| 182 | return { | ||
| 183 | ...model, | ||
| 184 | capabilities: previousModel.capabilities, | ||
| 185 | }; | ||
| 186 | }); | ||
| 187 | const nextSlugs = new Set(nextModels.map((model) => model.slug)); | ||
| 188 | return shouldRetainMissingModels | ||
| 189 | ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] | ||
| 190 | : mergedModels; | ||
| 191 | }; | ||
| 192 | |||
| 193 | /** | ||
| 194 | * Antigravity's health check only initializes the agent, so after a server | ||
| 195 | * restart it reports the account as unchecked. The saved Google login still | ||
| 196 | * works, and the previous snapshot proves it. Carry that account state until | ||
| 197 | * a session, refresh, or sign-out reports something new. A confirmed missing | ||
| 198 | * installation, sign-out, disabled instance, or a changed sign-in method is | ||
| 199 | * never overridden. | ||
| 200 | */ | ||
| 201 | const carrySavedAntigravityAccount = ( | ||
| 202 | previousProvider: ServerProvider, | ||
| 203 | nextProvider: ServerProvider, | ||
| 204 | ): Pick<ServerProvider, "auth" | "status"> | undefined => { | ||
| 205 | const antigravity = ProviderDriverKind.make("antigravity"); | ||
| 206 | if ( | ||
| 207 | nextProvider.driver !== antigravity || | ||
| 208 | previousProvider.driver !== antigravity || | ||
| 209 | !nextProvider.enabled || | ||
| 210 | nextProvider.auth.status !== "unknown" || | ||
| 211 | previousProvider.auth.status !== "authenticated" || | ||
| 212 | (nextProvider.auth.type !== undefined && | ||
| 213 | nextProvider.auth.type !== previousProvider.auth.type) || | ||
| 214 | (!nextProvider.installed && nextProvider.status !== "warning") | ||
| 215 | ) { | ||
| 216 | return undefined; | ||
| 217 | } | ||
| 218 | // The pending boot probe (`installed: false`, warning) and a failed probe | ||
| 219 | // keep their own status; only a passed health check reads as ready. | ||
| 220 | const status = | ||
| 221 | nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; | ||
| 222 | return { auth: previousProvider.auth, status }; | ||
| 223 | }; | ||
| 224 | |||
| 225 | export const mergeProviderSnapshot = ( | ||
| 226 | previousProvider: ServerProvider | undefined, | ||
| 227 | nextProvider: ServerProvider, | ||
| 228 | ): ServerProvider => { | ||
| 229 | if (!previousProvider) { | ||
| 230 | return nextProvider; | ||
| 231 | } | ||
| 232 | const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); | ||
| 233 | // "Google account access is not checked yet" describes the probe, not the | ||
| 234 | // account; it must not outlive the state it explained. | ||
| 235 | const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; | ||
| 236 | return { | ||
| 237 | ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), | ||
| 238 | ...savedAccount, | ||
| 239 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), | ||
| 240 | ...(nextProvider.workspaceSnapshots !== undefined | ||
| 241 | ? { workspaceSnapshots: nextProvider.workspaceSnapshots } | ||
| 242 | : previousProvider.workspaceSnapshots !== undefined | ||
| 243 | ? { workspaceSnapshots: previousProvider.workspaceSnapshots } | ||
| 244 | : {}), | ||
| 245 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) | ||
| 246 | ? { | ||
| 247 | slashCommands: | ||
| 248 | nextProvider.slashCommands.length === 0 | ||
| 249 | ? previousProvider.slashCommands | ||
| 250 | : nextProvider.slashCommands, | ||
| 251 | skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, | ||
| 252 | } | ||
| 253 | : {}), | ||
| 254 | }; | ||
| 255 | }; | ||
| 256 | |||
| 257 | export const mergeProviderSnapshots = ( | ||
| 258 | previousProviders: ReadonlyArray<ServerProvider>, | ||
| 259 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 260 | ): ReadonlyArray<ServerProvider> => { | ||
| 261 | const mergedProviders = new Map( | ||
| 262 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | ||
| 263 | ); | ||
| 264 | |||
| 265 | for (const provider of nextProviders) { | ||
| 266 | mergedProviders.set( | ||
| 267 | snapshotInstanceKey(provider), | ||
| 268 | mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 272 | return orderProviderSnapshots([...mergedProviders.values()]); | ||
| 273 | }; | ||
| 274 | |||
| 275 | export const selectProvidersByKind = ( | ||
| 276 | providers: ReadonlyArray<ServerProvider>, | ||
| 277 | providerKinds: ReadonlySet<ProviderDriverKind>, | ||
| 278 | ): ReadonlyArray<ServerProvider> => | ||
| 279 | providers.filter((provider) => providerKinds.has(provider.driver)); | ||
| 280 | |||
| 281 | const haveProvidersChanged = ( | ||
| 282 | previousProviders: ReadonlyArray<ServerProvider>, | ||
| 283 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 284 | ): boolean => !Equal.equals(previousProviders, nextProviders); | ||
| 285 | |||
| 286 | const correlateSnapshotWithSource = ( | ||
| 287 | source: ProviderSnapshotSource, | ||
| 288 | snapshot: ServerProvider, | ||
| 289 | ): Effect.Effect<ServerProvider> => { | ||
| 290 | if (snapshot.instanceId !== source.instanceId) { | ||
| 291 | return Effect.die( | ||
| 292 | new Error( | ||
| 293 | `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, | ||
| 294 | ), | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | if (snapshot.driver !== source.driverKind) { | ||
| 298 | return Effect.die( | ||
| 299 | new Error( | ||
| 300 | `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | } | ||
| 304 | return Effect.succeed(snapshot); | ||
| 305 | }; | ||
| 306 | |||
| 307 | /** | ||
| 308 | * Key a snapshot for aggregation and persistence. Snapshot sources | ||
| 309 | * must be correlated by instance id before reaching this map; missing | ||
| 310 | * identities are defects, not runtime routing fallbacks. | ||
| 311 | */ | ||
| 312 | const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { | ||
| 313 | return provider.instanceId; | ||
| 314 | }; | ||
| 315 | |||
| 316 | // Project a live `ProviderInstance` into the aggregator's consumption | ||
| 317 | // shape. Each call re-captures the instance's `snapshot` closures, so | ||
| 318 | // after `ProviderInstanceRegistry` rebuilds an instance (e.g. because | ||
| 319 | // its settings changed), a fresh source rides the new PubSub instead | ||
| 320 | // of a closed one. | ||
| 321 | const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ | ||
| 322 | instanceId: instance.instanceId, | ||
| 323 | driverKind: instance.driverKind, | ||
| 324 | getSnapshot: instance.snapshot.getSnapshot, | ||
| 325 | refresh: instance.snapshot.refresh, | ||
| 326 | streamChanges: instance.snapshot.streamChanges, | ||
| 327 | }); | ||
| 328 | |||
| 329 | export const layer = Layer.effect( | ||
| 330 | ProviderRegistry.ProviderRegistry, | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; | ||
| 333 | const manifestService = yield* ModelManifest.ModelManifest; | ||
| 334 | const serviceScope = yield* Effect.scope; | ||
| 335 | const config = yield* ServerConfig.ServerConfig; | ||
| 336 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 337 | const path = yield* Path.Path; | ||
| 338 | |||
| 339 | // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for | ||
| 340 | // coalesced updates across every instance. | ||
| 341 | const changesPubSub = yield* Effect.acquireRelease( | ||
| 342 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), | ||
| 343 | PubSub.shutdown, | ||
| 344 | ); | ||
| 345 | |||
| 346 | // Boot-only: hydrate `providersRef` from the on-disk per-instance | ||
| 347 | // cache so the UI has something to render during the first refresh. | ||
| 348 | // Instances added post-boot skip this path; their first entry in | ||
| 349 | // `providersRef` comes from the reactive `syncLiveSources` pass | ||
| 350 | // below. | ||
| 351 | const bootInstances = yield* instanceRegistry.listInstances; | ||
| 352 | const bootSources = bootInstances.map(buildSnapshotSource); | ||
| 353 | const fallbackProviders = yield* loadProviders(bootSources); | ||
| 354 | const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>(); | ||
| 355 | for (let index = 0; index < fallbackProviders.length; index++) { | ||
| 356 | const provider = fallbackProviders[index]; | ||
| 357 | const source = bootSources[index]; | ||
| 358 | if (provider === undefined || source === undefined) { | ||
| 359 | continue; | ||
| 360 | } | ||
| 361 | fallbackByInstance.set(source.instanceId, provider); | ||
| 362 | } | ||
| 363 | |||
| 364 | const cachedProviders = yield* Effect.forEach( | ||
| 365 | bootSources, | ||
| 366 | (source) => | ||
| 367 | Effect.gen(function* () { | ||
| 368 | // One cache file per configured instance. For the default | ||
| 369 | // instance of a built-in kind the path equals `<kind>.json` — | ||
| 370 | // identical to the legacy filename. We still require the cache | ||
| 371 | // payload to carry matching instance id + driver kind; old | ||
| 372 | // identity-less payloads are discarded and the awaited refresh | ||
| 373 | // below repopulates the cache. | ||
| 374 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 375 | cacheDir: config.providerStatusCacheDir, | ||
| 376 | instanceId: source.instanceId, | ||
| 377 | }).pipe(Effect.provideService(Path.Path, path)); | ||
| 378 | const fallbackProvider = fallbackByInstance.get(source.instanceId); | ||
| 379 | if (fallbackProvider === undefined) { | ||
| 380 | return undefined; | ||
| 381 | } | ||
| 382 | return yield* readProviderStatusCache(filePath).pipe( | ||
| 383 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 384 | Effect.flatMap((cachedProvider) => { | ||
| 385 | if (cachedProvider === undefined) { | ||
| 386 | return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 387 | } | ||
| 388 | const correlation = { | ||
| 389 | cachedProvider, | ||
| 390 | fallbackProvider, | ||
| 391 | } as const; | ||
| 392 | if (!isCachedProviderCorrelated(correlation)) { | ||
| 393 | return Effect.logWarning("provider status cache identity mismatch, ignoring", { | ||
| 394 | path: filePath, | ||
| 395 | instanceId: source.instanceId, | ||
| 396 | cachedInstanceId: cachedProvider.instanceId ?? null, | ||
| 397 | driver: source.driverKind, | ||
| 398 | cachedDriver: cachedProvider.driver ?? null, | ||
| 399 | }).pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 400 | } | ||
| 401 | return Effect.succeed(hydrateCachedProvider(correlation)); | ||
| 402 | }), | ||
| 403 | ); | ||
| 404 | }), | ||
| 405 | { concurrency: "unbounded" }, | ||
| 406 | ).pipe( | ||
| 407 | Effect.map((providers) => | ||
| 408 | orderProviderSnapshots( | ||
| 409 | providers.filter((provider): provider is ServerProvider => provider !== undefined), | ||
| 410 | ), | ||
| 411 | ), | ||
| 412 | ); | ||
| 413 | const initialManifest = yield* manifestService.current; | ||
| 414 | const classifyCompatibility = ( | ||
| 415 | provider: ServerProvider, | ||
| 416 | manifest: ModelManifest.ModelManifestData, | ||
| 417 | ) => | ||
| 418 | applyProviderCompatibility( | ||
| 419 | provider, | ||
| 420 | manifest.compatibility, | ||
| 421 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 422 | ); | ||
| 423 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | ||
| 424 | cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), | ||
| 425 | ); | ||
| 426 | const workspaceRefreshesRef = yield* Ref.make< | ||
| 427 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> | ||
| 428 | >(new Map()); | ||
| 429 | const maintenanceActionStatesRef = yield* Ref.make< | ||
| 430 | ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> | ||
| 431 | >(new Map()); | ||
| 432 | |||
| 433 | // Live-source registry — the dynamic counterpart to the boot-time | ||
| 434 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` | ||
| 435 | // reference is used for identity equality so "no-op" reconciles | ||
| 436 | // (settings unchanged) skip re-subscribing + re-probing. | ||
| 437 | const liveSubsRef = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstance>>( | ||
| 438 | new Map(), | ||
| 439 | ); | ||
| 440 | // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't | ||
| 441 | // interleave two passes clobbering each other's fiber bookkeeping. | ||
| 442 | const syncSemaphore = yield* Semaphore.make(1); | ||
| 443 | |||
| 444 | const getLiveSources: Effect.Effect<ReadonlyArray<ProviderSnapshotSource>> = Ref.get( | ||
| 445 | liveSubsRef, | ||
| 446 | ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); | ||
| 447 | |||
| 448 | const persistProvider = (provider: ServerProvider) => | ||
| 449 | Effect.gen(function* () { | ||
| 450 | // Persist every instance — the file name is the instance id, so | ||
| 451 | // multi-instance setups (e.g. `codex_personal`, `codex_work`) each | ||
| 452 | // get their own cache. We resolve the path fresh so snapshots | ||
| 453 | // produced by newly-added instances post-boot still land on disk | ||
| 454 | // without the aggregator holding a stale `cachePathByInstance` | ||
| 455 | // entry. | ||
| 456 | const key = snapshotInstanceKey(provider); | ||
| 457 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 458 | cacheDir: config.providerStatusCacheDir, | ||
| 459 | instanceId: key, | ||
| 460 | }).pipe(Effect.provideService(Path.Path, path)); | ||
| 461 | const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; | ||
| 462 | yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( | ||
| 463 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 464 | Effect.provideService(Path.Path, path), | ||
| 465 | Effect.tapError(Effect.logError), | ||
| 466 | Effect.ignore, | ||
| 467 | ); | ||
| 468 | }); | ||
| 469 | |||
| 470 | const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( | ||
| 471 | provider: ServerProvider, | ||
| 472 | ) { | ||
| 473 | const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); | ||
| 474 | const updateState = maintenanceActionStates.get(provider.instanceId)?.update; | ||
| 475 | if (!updateState) { | ||
| 476 | const { updateState: _updateState, ...providerWithoutUpdateState } = provider; | ||
| 477 | return providerWithoutUpdateState; | ||
| 478 | } | ||
| 479 | return { | ||
| 480 | ...provider, | ||
| 481 | updateState, | ||
| 482 | }; | ||
| 483 | }); | ||
| 484 | |||
| 485 | const upsertProviders = Effect.fn("upsertProviders")(function* ( | ||
| 486 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 487 | options?: { | ||
| 488 | readonly publish?: boolean; | ||
| 489 | readonly persist?: boolean; | ||
| 490 | readonly replace?: boolean; | ||
| 491 | }, | ||
| 492 | ) { | ||
| 493 | const manifest = yield* manifestService.current; | ||
| 494 | const nextProvidersWithUpdateState = yield* Effect.forEach( | ||
| 495 | nextProviders, | ||
| 496 | applyProviderUpdateState, | ||
| 497 | { | ||
| 498 | concurrency: "unbounded", | ||
| 499 | }, | ||
| 500 | ); | ||
| 501 | const [previousProviders, providers, providersToPersist] = yield* Ref.modify( | ||
| 502 | providersRef, | ||
| 503 | (previousProviders) => { | ||
| 504 | const mergedProviders = new Map( | ||
| 505 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | ||
| 506 | ); | ||
| 507 | const updatedKeys = new Set<ProviderInstanceId>(); | ||
| 508 | |||
| 509 | for (const provider of nextProvidersWithUpdateState) { | ||
| 510 | const key = snapshotInstanceKey(provider); | ||
| 511 | updatedKeys.add(key); | ||
| 512 | mergedProviders.set( | ||
| 513 | key, | ||
| 514 | options?.replace === true | ||
| 515 | ? provider | ||
| 516 | : mergeProviderSnapshot(mergedProviders.get(key), provider), | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | |||
| 520 | const providers = orderProviderSnapshots( | ||
| 521 | [...mergedProviders.values()].map((provider) => | ||
| 522 | classifyCompatibility(provider, manifest), | ||
| 523 | ), | ||
| 524 | ); | ||
| 525 | const providersToPersist = providers.filter((provider) => | ||
| 526 | updatedKeys.has(snapshotInstanceKey(provider)), | ||
| 527 | ); | ||
| 528 | return [[previousProviders, providers, providersToPersist] as const, providers]; | ||
| 529 | }, | ||
| 530 | ); | ||
| 531 | |||
| 532 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 533 | if (options?.persist !== false) { | ||
| 534 | yield* Effect.forEach(providersToPersist, persistProvider, { | ||
| 535 | concurrency: "unbounded", | ||
| 536 | discard: true, | ||
| 537 | }); | ||
| 538 | } | ||
| 539 | if (options?.publish !== false) { | ||
| 540 | yield* PubSub.publish(changesPubSub, providers); | ||
| 541 | } | ||
| 542 | } | ||
| 543 | |||
| 544 | return providers; | ||
| 545 | }); | ||
| 546 | |||
| 547 | const compatibilityRefreshRunning = yield* Ref.make(false); | ||
| 548 | const syncProvider = Effect.fn("syncProvider")(function* ( | ||
| 549 | provider: ServerProvider, | ||
| 550 | options?: { | ||
| 551 | readonly publish?: boolean; | ||
| 552 | }, | ||
| 553 | ) { | ||
| 554 | const providers = yield* upsertProviders([provider], options); | ||
| 555 | // Reclassify the current read model after fetching. Never republish the | ||
| 556 | // probe captured before the fetch: a newer health result may have landed. | ||
| 557 | if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { | ||
| 558 | yield* manifestService.refresh.pipe( | ||
| 559 | Effect.andThen(upsertProviders([], { persist: false })), | ||
| 560 | Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), | ||
| 561 | Effect.forkIn(serviceScope), | ||
| 562 | ); | ||
| 563 | } | ||
| 564 | return providers; | ||
| 565 | }); | ||
| 566 | |||
| 567 | const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( | ||
| 568 | function* (input: { | ||
| 569 | readonly instanceId: ProviderInstanceId; | ||
| 570 | readonly action: "update"; | ||
| 571 | readonly state: ServerProviderUpdateState | null; | ||
| 572 | }) { | ||
| 573 | yield* Ref.update(maintenanceActionStatesRef, (previous) => { | ||
| 574 | const previousActions = previous.get(input.instanceId); | ||
| 575 | const nextActions = { ...previousActions }; | ||
| 576 | if (input.state === null || input.state.status === "idle") { | ||
| 577 | delete nextActions[input.action]; | ||
| 578 | } else { | ||
| 579 | nextActions[input.action] = input.state; | ||
| 580 | } | ||
| 581 | |||
| 582 | const next = new Map(previous); | ||
| 583 | if (Object.keys(nextActions).length === 0) { | ||
| 584 | next.delete(input.instanceId); | ||
| 585 | } else { | ||
| 586 | next.set(input.instanceId, nextActions); | ||
| 587 | } | ||
| 588 | return next; | ||
| 589 | }); | ||
| 590 | |||
| 591 | const existingProviders = yield* Ref.get(providersRef); | ||
| 592 | const matchingProvider = existingProviders.find( | ||
| 593 | (candidate) => candidate.instanceId === input.instanceId, | ||
| 594 | ); | ||
| 595 | if (!matchingProvider) { | ||
| 596 | return existingProviders; | ||
| 597 | } | ||
| 598 | |||
| 599 | const nextProvider = yield* applyProviderUpdateState(matchingProvider); | ||
| 600 | return yield* upsertProviders([nextProvider], { | ||
| 601 | persist: false, | ||
| 602 | }); | ||
| 603 | }, | ||
| 604 | ); | ||
| 605 | |||
| 606 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( | ||
| 607 | providerSource: ProviderSnapshotSource, | ||
| 608 | ) { | ||
| 609 | return yield* providerSource.refresh.pipe( | ||
| 610 | Effect.flatMap((nextProvider) => | ||
| 611 | correlateSnapshotWithSource(providerSource, nextProvider).pipe( | ||
| 612 | Effect.flatMap(syncProvider), | ||
| 613 | ), | ||
| 614 | ), | ||
| 615 | ); | ||
| 616 | }); | ||
| 617 | |||
| 618 | const refreshAll = Effect.fn("refreshAll")(function* () { | ||
| 619 | const sources = yield* getLiveSources; | ||
| 620 | return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { | ||
| 621 | concurrency: "unbounded", | ||
| 622 | discard: true, | ||
| 623 | }).pipe(Effect.andThen(Ref.get(providersRef))); | ||
| 624 | }); | ||
| 625 | |||
| 626 | const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { | ||
| 627 | if (provider === undefined) { | ||
| 628 | return yield* refreshAll(); | ||
| 629 | } | ||
| 630 | // Kind-scoped refreshes target the default instance for that driver. | ||
| 631 | const defaultInstanceId = defaultInstanceIdForDriver(provider); | ||
| 632 | const sources = yield* getLiveSources; | ||
| 633 | const providerSource = sources.find( | ||
| 634 | (candidate) => candidate.instanceId === defaultInstanceId, | ||
| 635 | ); | ||
| 636 | if (!providerSource) { | ||
| 637 | return yield* Ref.get(providersRef); | ||
| 638 | } | ||
| 639 | return yield* refreshOneSource(providerSource); | ||
| 640 | }); | ||
| 641 | |||
| 642 | const refreshInstance = Effect.fn("refreshInstance")(function* ( | ||
| 643 | instanceId: ProviderInstanceId, | ||
| 644 | ) { | ||
| 645 | const sources = yield* getLiveSources; | ||
| 646 | const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); | ||
| 647 | if (!providerSource) { | ||
| 648 | return yield* Ref.get(providersRef); | ||
| 649 | } | ||
| 650 | return yield* refreshOneSource(providerSource); | ||
| 651 | }); | ||
| 652 | |||
| 653 | const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( | ||
| 654 | "getProviderMaintenanceCapabilitiesForInstance", | ||
| 655 | )(function* ( | ||
| 656 | instanceId: ProviderInstanceId, | ||
| 657 | provider: ProviderDriverKind, | ||
| 658 | options?: { readonly fresh?: boolean }, | ||
| 659 | ) { | ||
| 660 | // Read the instance registry, not `liveSubsRef`: the latter trails | ||
| 661 | // reconciliation, and an update must never run a retired instance's | ||
| 662 | // command against a freshly configured executable. | ||
| 663 | const instance = yield* instanceRegistry.getInstance(instanceId); | ||
| 664 | if (!instance || instance.driverKind !== provider) { | ||
| 665 | return makeManualProviderMaintenanceCapabilities(provider); | ||
| 666 | } | ||
| 667 | return yield* instance.snapshot.resolveMaintenance(options); | ||
| 668 | }); | ||
| 669 | |||
| 670 | /** | ||
| 671 | * Diff the aggregator's live-source set against the current | ||
| 672 | * `ProviderInstanceRegistry` and: | ||
| 673 | * - subscribe to each newly-added or rebuilt instance's | ||
| 674 | * `streamChanges` (so periodic + enrichment refreshes land in | ||
| 675 | * `providersRef`); | ||
| 676 | * - read each newly-added/rebuilt instance's current snapshot after | ||
| 677 | * subscribing, closing the race with its independently-running | ||
| 678 | * background startup probe; | ||
| 679 | * - prune `providersRef` of instances that no longer exist. | ||
| 680 | * | ||
| 681 | * Provider refreshes are owned by each managed provider and never run | ||
| 682 | * on this layer's construction path. Consumers see cached or pending | ||
| 683 | * snapshots immediately, then receive live probe results through the | ||
| 684 | * already-attached change stream. | ||
| 685 | * | ||
| 686 | * Per-instance subscription fibers are not tracked explicitly. When | ||
| 687 | * a rebuilt instance's old child scope closes, its PubSub shuts | ||
| 688 | * down and our `Stream.runForEach` fiber exits naturally. | ||
| 689 | */ | ||
| 690 | const syncLiveSources = syncSemaphore.withPermits(1)( | ||
| 691 | Effect.gen(function* () { | ||
| 692 | const instances = yield* instanceRegistry.listInstances; | ||
| 693 | const unavailableProviders = yield* instanceRegistry.listUnavailable; | ||
| 694 | const nextByInstance = new Map<ProviderInstanceId, ProviderInstance>( | ||
| 695 | instances.map((instance) => [instance.instanceId, instance] as const), | ||
| 696 | ); | ||
| 697 | const knownInstanceIds = new Set<ProviderInstanceId>(nextByInstance.keys()); | ||
| 698 | for (const provider of unavailableProviders) { | ||
| 699 | knownInstanceIds.add(snapshotInstanceKey(provider)); | ||
| 700 | } | ||
| 701 | const previousSubs = yield* Ref.get(liveSubsRef); | ||
| 702 | |||
| 703 | // Carry over subscriptions for instances whose identity is | ||
| 704 | // unchanged (reconcile treated them as no-op). Instances that | ||
| 705 | // disappeared, or were rebuilt with a different reference, | ||
| 706 | // fall through to the "newly-added" branch below. | ||
| 707 | const carriedOver = new Map<ProviderInstanceId, ProviderInstance>(); | ||
| 708 | for (const [instanceId, previousInstance] of previousSubs) { | ||
| 709 | const nextInstance = nextByInstance.get(instanceId); | ||
| 710 | if (nextInstance !== undefined && nextInstance === previousInstance) { | ||
| 711 | carriedOver.set(instanceId, previousInstance); | ||
| 712 | } | ||
| 713 | } | ||
| 714 | |||
| 715 | // Collect new/rebuilt instances in `nextByInstance` insertion | ||
| 716 | // order (which preserves settings-author order). | ||
| 717 | const newlyAdded: Array<readonly [ProviderInstanceId, ProviderInstance]> = []; | ||
| 718 | for (const [instanceId, instance] of nextByInstance) { | ||
| 719 | if (carriedOver.has(instanceId)) { | ||
| 720 | continue; | ||
| 721 | } | ||
| 722 | newlyAdded.push([instanceId, instance] as const); | ||
| 723 | } | ||
| 724 | |||
| 725 | const rebuiltInstanceIds = new Set( | ||
| 726 | newlyAdded | ||
| 727 | .map(([instanceId]) => instanceId) | ||
| 728 | .filter((instanceId) => previousSubs.has(instanceId)), | ||
| 729 | ); | ||
| 730 | if (rebuiltInstanceIds.size > 0) { | ||
| 731 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 732 | providersRef, | ||
| 733 | (previousProviders) => { | ||
| 734 | const providers = previousProviders.map((provider) => { | ||
| 735 | if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; | ||
| 736 | const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; | ||
| 737 | return machineSnapshot; | ||
| 738 | }); | ||
| 739 | return [[previousProviders, providers] as const, providers]; | ||
| 740 | }, | ||
| 741 | ); | ||
| 742 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 743 | yield* PubSub.publish(changesPubSub, providers); | ||
| 744 | } | ||
| 745 | } | ||
| 746 | |||
| 747 | // Fork long-lived subscriptions to each new/rebuilt instance's | ||
| 748 | // change stream before reading its current snapshot. If the | ||
| 749 | // driver's own initial probe finishes during this sync, either | ||
| 750 | // the current read or the active subscriber observes the result. | ||
| 751 | for (const [, instance] of newlyAdded) { | ||
| 752 | const source = buildSnapshotSource(instance); | ||
| 753 | yield* Stream.runForEach(source.streamChanges, (provider) => | ||
| 754 | correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), | ||
| 755 | ).pipe(Effect.forkScoped); | ||
| 756 | } | ||
| 757 | yield* Effect.yieldNow; | ||
| 758 | |||
| 759 | // Snapshot current state without starting a probe. Managed providers | ||
| 760 | // launch their startup refresh independently, so this closes the | ||
| 761 | // subscription race without putting external work on the registry | ||
| 762 | // or HTTP server construction path. | ||
| 763 | yield* Effect.forEach( | ||
| 764 | newlyAdded, | ||
| 765 | ([, instance]) => | ||
| 766 | Effect.gen(function* () { | ||
| 767 | const source = buildSnapshotSource(instance); | ||
| 768 | const provider = yield* source.getSnapshot; | ||
| 769 | yield* correlateSnapshotWithSource(source, provider).pipe( | ||
| 770 | Effect.flatMap(syncProvider), | ||
| 771 | ); | ||
| 772 | }).pipe(Effect.ignoreCause({ log: true })), | ||
| 773 | { concurrency: "unbounded", discard: true }, | ||
| 774 | ); | ||
| 775 | yield* upsertProviders(unavailableProviders, { | ||
| 776 | persist: false, | ||
| 777 | replace: true, | ||
| 778 | }); | ||
| 779 | |||
| 780 | const nextSubs = new Map(carriedOver); | ||
| 781 | for (const [instanceId, instance] of newlyAdded) { | ||
| 782 | nextSubs.set(instanceId, instance); | ||
| 783 | } | ||
| 784 | yield* Ref.set(liveSubsRef, nextSubs); | ||
| 785 | |||
| 786 | // Drop aggregator state for instances that have disappeared — | ||
| 787 | // otherwise the UI would keep rendering ghosts. | ||
| 788 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 789 | providersRef, | ||
| 790 | (previousProviders) => { | ||
| 791 | const providers = orderProviderSnapshots( | ||
| 792 | previousProviders.filter((provider) => | ||
| 793 | knownInstanceIds.has(snapshotInstanceKey(provider)), | ||
| 794 | ), | ||
| 795 | ); | ||
| 796 | return [[previousProviders, providers] as const, providers]; | ||
| 797 | }, | ||
| 798 | ); | ||
| 799 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 800 | yield* PubSub.publish(changesPubSub, providers); | ||
| 801 | } | ||
| 802 | yield* Ref.update(maintenanceActionStatesRef, (previous) => { | ||
| 803 | const next = new Map(previous); | ||
| 804 | for (const instanceId of previous.keys()) { | ||
| 805 | if (!knownInstanceIds.has(instanceId)) { | ||
| 806 | next.delete(instanceId); | ||
| 807 | } | ||
| 808 | } | ||
| 809 | return next; | ||
| 810 | }); | ||
| 811 | }), | ||
| 812 | ); | ||
| 813 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( | ||
| 814 | Effect.catchCause((cause) => { | ||
| 815 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 816 | return Effect.interrupt; | ||
| 817 | } | ||
| 818 | return Effect.logError( | ||
| 819 | "provider registry instance sync failed; keeping subscription alive", | ||
| 820 | { | ||
| 821 | cause: Cause.pretty(cause), | ||
| 822 | }, | ||
| 823 | ); | ||
| 824 | }), | ||
| 825 | ); | ||
| 826 | |||
| 827 | // Seed `providersRef` with the boot-time fallback snapshots so | ||
| 828 | // consumers calling `getProviders` immediately after layer build see | ||
| 829 | // a populated list — even before the first `syncLiveSources` refresh | ||
| 830 | // resolves. Cached snapshots (already in `providersRef`) merge with | ||
| 831 | // these via `upsertProviders` so on-disk state wins where present | ||
| 832 | // and pending fallbacks fill the gaps. | ||
| 833 | yield* upsertProviders(fallbackProviders, { publish: false }); | ||
| 834 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 835 | // `subscribeChanges` acquires the dequeue synchronously in this | ||
| 836 | // fibre; the subscription is active the instant this `yield*` | ||
| 837 | // returns. Forking the consumer loop later cannot lose a publish | ||
| 838 | // because no publish can reach a not-yet-subscribed dequeue. | ||
| 839 | // | ||
| 840 | // (Contrast with the pre-fix code that did | ||
| 841 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 842 | // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, | ||
| 843 | // and `forkScoped` only schedules the fibre — so a reconcile that | ||
| 844 | // published between "fibre scheduled" and "fibre starts running" | ||
| 845 | // was dropped, which made any settings change that replaced an | ||
| 846 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 847 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 848 | // `subscribeChanges` acquires the `PubSub.Subscription` synchronously | ||
| 849 | // in this fibre; the subscription is registered with the PubSub the | ||
| 850 | // instant this `yield*` returns, so any subsequent publish is | ||
| 851 | // buffered in the subscription regardless of when the consumer | ||
| 852 | // fibre below actually starts running. | ||
| 853 | // | ||
| 854 | // (Contrast with the pre-fix code that did | ||
| 855 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 856 | // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, | ||
| 857 | // which defers `PubSub.subscribe` to stream start. `forkScoped` only | ||
| 858 | // schedules the consumer fibre — so a reconcile that published | ||
| 859 | // between "fibre scheduled" and "fibre starts running + subscribes" | ||
| 860 | // was dropped, which made any settings change that replaced an | ||
| 861 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 862 | const instanceChanges = yield* instanceRegistry.subscribeChanges; | ||
| 863 | // Initial sync attaches subscriptions and snapshots current state for | ||
| 864 | // every instance present at boot. Provider probes are already running in | ||
| 865 | // their managed background fibers and never block this layer. | ||
| 866 | yield* syncLiveSources; | ||
| 867 | // React to registry mutations — instance added / removed / rebuilt. | ||
| 868 | // `Stream.fromSubscription` builds a stream over the pre-acquired | ||
| 869 | // subscription rather than subscribing on stream start, which is | ||
| 870 | // what closes the race. | ||
| 871 | yield* Stream.runForEach( | ||
| 872 | Stream.fromSubscription(instanceChanges), | ||
| 873 | () => syncLiveSourcesAndContinue, | ||
| 874 | ).pipe(Effect.forkScoped); | ||
| 875 | |||
| 876 | const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( | ||
| 877 | cause: Cause.Cause<unknown>, | ||
| 878 | ) { | ||
| 879 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 880 | return yield* Effect.interrupt; | ||
| 881 | } | ||
| 882 | yield* Effect.logError("provider registry refresh failed; preserving cached providers", { | ||
| 883 | cause: Cause.pretty(cause), | ||
| 884 | }); | ||
| 885 | return yield* Ref.get(providersRef); | ||
| 886 | }); | ||
| 887 | |||
| 888 | const updateProviders = ( | ||
| 889 | update: (providers: ReadonlyArray<ServerProvider>) => ReadonlyArray<ServerProvider>, | ||
| 890 | ) => | ||
| 891 | Ref.modify(providersRef, (currentProviders) => { | ||
| 892 | const nextProviders = update(currentProviders); | ||
| 893 | return [[currentProviders, nextProviders] as const, nextProviders]; | ||
| 894 | }).pipe( | ||
| 895 | Effect.tap(([previousProviders, nextProviders]) => | ||
| 896 | haveProvidersChanged(previousProviders, nextProviders) | ||
| 897 | ? PubSub.publish(changesPubSub, nextProviders) | ||
| 898 | : Effect.void, | ||
| 899 | ), | ||
| 900 | Effect.map(([, nextProviders]) => nextProviders), | ||
| 901 | ); | ||
| 902 | |||
| 903 | const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { | ||
| 904 | readonly instanceId: ProviderInstanceId; | ||
| 905 | readonly cwd: string; | ||
| 906 | readonly fresh?: boolean; | ||
| 907 | }) { | ||
| 908 | // Fresh scans drop other instances' snapshots for this cwd first, so a | ||
| 909 | // composer on one of them scans again on next use, even when this | ||
| 910 | // instance is gone or cannot be scanned. | ||
| 911 | if (input.fresh) { | ||
| 912 | yield* updateProviders((providers) => | ||
| 913 | providers.map((candidate) => | ||
| 914 | candidate.instanceId === input.instanceId | ||
| 915 | ? candidate | ||
| 916 | : dropProviderWorkspaceSnapshot(candidate, input.cwd), | ||
| 917 | ), | ||
| 918 | ); | ||
| 919 | } | ||
| 920 | const providers = yield* Ref.get(providersRef); | ||
| 921 | const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); | ||
| 922 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => | ||
| 923 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); | ||
| 924 | const scannedFrom = workspaceSnapshotOf(provider); | ||
| 925 | if ( | ||
| 926 | !provider || | ||
| 927 | !provider.enabled || | ||
| 928 | (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) | ||
| 929 | ) { | ||
| 930 | return providers; | ||
| 931 | } | ||
| 932 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | ||
| 933 | if (!instance?.snapshotForCwd) return providers; | ||
| 934 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { | ||
| 935 | const current = refreshes.get(instance); | ||
| 936 | if (current?.has(input.cwd)) return [false, refreshes] as const; | ||
| 937 | const next = new Map(refreshes); | ||
| 938 | next.set(instance, new Set(current).add(input.cwd)); | ||
| 939 | return [true, next] as const; | ||
| 940 | }); | ||
| 941 | // A fresh scan never joins a running one, which may predate the change. | ||
| 942 | if (!claimed && !input.fresh) return yield* Ref.get(providersRef); | ||
| 943 | // Fresh scans also re-read the machine snapshot after invalidating caches. | ||
| 944 | const refreshMachineSnapshot = input.fresh | ||
| 945 | ? (instance.invalidateCaches ?? Effect.void).pipe( | ||
| 946 | Effect.andThen(refreshInstance(input.instanceId)), | ||
| 947 | ) | ||
| 948 | : Effect.void; | ||
| 949 | return yield* refreshMachineSnapshot.pipe( | ||
| 950 | Effect.andThen(instance.snapshotForCwd(input.cwd)), | ||
| 951 | Effect.flatMap((scopedSnapshot) => | ||
| 952 | scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined | ||
| 953 | ? Ref.get(providersRef) | ||
| 954 | : instanceRegistry.getInstance(input.instanceId).pipe( | ||
| 955 | Effect.flatMap((currentInstance) => { | ||
| 956 | if (currentInstance !== instance) return Ref.get(providersRef); | ||
| 957 | // Write only if the cwd's snapshot did not change during the | ||
| 958 | // scan. A session event or another scan that landed first is newer. | ||
| 959 | return updateProviders((currentProviders) => | ||
| 960 | currentProviders.map((candidate) => | ||
| 961 | candidate.instanceId === input.instanceId && | ||
| 962 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) | ||
| 963 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | ||
| 964 | : candidate, | ||
| 965 | ), | ||
| 966 | ); | ||
| 967 | }), | ||
| 968 | ), | ||
| 969 | ), | ||
| 970 | Effect.ensuring( | ||
| 971 | claimed | ||
| 972 | ? Ref.update(workspaceRefreshesRef, (refreshes) => { | ||
| 973 | const next = new Map(refreshes); | ||
| 974 | const current = new Set(next.get(instance)); | ||
| 975 | current.delete(input.cwd); | ||
| 976 | if (current.size) next.set(instance, current); | ||
| 977 | else next.delete(instance); | ||
| 978 | return next; | ||
| 979 | }) | ||
| 980 | : Effect.void, | ||
| 981 | ), | ||
| 982 | ); | ||
| 983 | }); | ||
| 984 | |||
| 985 | return { | ||
| 986 | getProviders: Ref.get(providersRef), | ||
| 987 | refresh: (provider?: ProviderDriverKind) => | ||
| 988 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 989 | refreshInstance: (instanceId: ProviderInstanceId) => | ||
| 990 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 991 | refreshWorkspaceSnapshot: (input) => | ||
| 992 | refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 993 | getProviderMaintenanceCapabilitiesForInstance, | ||
| 994 | setProviderMaintenanceActionState, | ||
| 995 | get streamChanges() { | ||
| 996 | return Stream.fromPubSub(changesPubSub); | ||
| 997 | }, | ||
| 998 | } satisfies ProviderRegistry.ProviderRegistryShape; | ||
| 999 | }), | ||
| 1000 | ); | ||
apps/server/src/provider/Layers/ProviderUsageLimitsIngestion.ts deleted-7| ... | @@ -1,7 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * V2 drivers fold live account usage events directly into their managed | ||
| 3 | * snapshots. Retained as an empty layer for the shared server composition. | ||
| 4 | */ | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | |||
| 7 | export const layer = Layer.empty; | ||
apps/server/src/provider/Layers/claudeResetCredits.test.ts deleted-259| ... | @@ -1,259 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it as effectIt } from "@effect/vitest"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as TestClock from "effect/testing/TestClock"; | ||
| 11 | import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; | ||
| 12 | import { describe, expect, it } from "vite-plus/test"; | ||
| 13 | |||
| 14 | import * as ClaudeResetCredits from "./claudeResetCredits.ts"; | ||
| 15 | |||
| 16 | const NOW = Date.parse("2026-09-22T12:00:00.000Z"); | ||
| 17 | const grant = (overrides: Record<string, unknown>) => ({ | ||
| 18 | id: "grant_a", | ||
| 19 | resets_left: 1, | ||
| 20 | usable_now: true, | ||
| 21 | ...overrides, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const writeLogin = Effect.gen(function* () { | ||
| 25 | const fs = yield* FileSystem.FileSystem; | ||
| 26 | const path = yield* Path.Path; | ||
| 27 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 28 | yield* fs.writeFileString( | ||
| 29 | path.join(directory, ".credentials.json"), | ||
| 30 | '{"claudeAiOauth":{"accessToken":"oauth-token"}}', | ||
| 31 | ); | ||
| 32 | const accountConfigPath = path.join(directory, ".claude.json"); | ||
| 33 | yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); | ||
| 34 | return { configDir: directory, accountConfigPath }; | ||
| 35 | }); | ||
| 36 | |||
| 37 | const respond = (status: number, body: unknown) => | ||
| 38 | HttpClient.make((request) => | ||
| 39 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), | ||
| 40 | ); | ||
| 41 | const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); | ||
| 42 | |||
| 43 | describe("claudeResetCreditsToContract", () => { | ||
| 44 | it("counts live grants and pins the next usable one", () => { | ||
| 45 | expect( | ||
| 46 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 47 | { | ||
| 48 | eligible: true, | ||
| 49 | next_grant_id: "grant_a", | ||
| 50 | grants: [ | ||
| 51 | grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), | ||
| 52 | grant({ id: "paused", paused: true }), | ||
| 53 | grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), | ||
| 54 | grant({ id: "garbled", ends_at: "not a date" }), | ||
| 55 | grant({ id: "date_only", ends_at: "2026-10-01" }), | ||
| 56 | grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), | ||
| 57 | grant({ id: "empty", ends_at: "" }), | ||
| 58 | grant({ id: "Not Valid" }), | ||
| 59 | grant({ id: "grant_b", resets_left: 3, usable_now: false }), | ||
| 60 | ], | ||
| 61 | }, | ||
| 62 | NOW, | ||
| 63 | ), | ||
| 64 | ).toEqual({ | ||
| 65 | availableCount: 2, | ||
| 66 | nextCreditId: "grant_a", | ||
| 67 | nextExpiresAt: "2026-10-01T00:00:00.000Z", | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("offers nothing to redeem without a usable next grant or an eligible account", () => { | ||
| 72 | expect( | ||
| 73 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 74 | { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, | ||
| 75 | NOW, | ||
| 76 | ), | ||
| 77 | ).toEqual({ availableCount: 0 }); | ||
| 78 | expect( | ||
| 79 | ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), | ||
| 80 | ).toEqual({ | ||
| 81 | availableCount: 0, | ||
| 82 | }); | ||
| 83 | expect( | ||
| 84 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 85 | { eligible: false, grants: [grant({})] }, | ||
| 86 | NOW, | ||
| 87 | ), | ||
| 88 | ).toBeUndefined(); | ||
| 89 | expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { | ||
| 94 | it.effect("reads the grants with the CLI's request", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const { configDir } = yield* writeLogin; | ||
| 97 | const client = HttpClient.make((request) => { | ||
| 98 | expect(request.method).toBe("GET"); | ||
| 99 | expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); | ||
| 100 | expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); | ||
| 101 | expect(request.headers.authorization).toBe("Bearer oauth-token"); | ||
| 102 | expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); | ||
| 103 | expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); | ||
| 104 | return Effect.succeed( | ||
| 105 | HttpClientResponse.fromWeb( | ||
| 106 | request, | ||
| 107 | Response.json({ | ||
| 108 | cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | }); | ||
| 113 | const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 114 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 115 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 116 | ); | ||
| 117 | expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("reads nothing from keychain logins or failed requests", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const { configDir } = yield* writeLogin; | ||
| 124 | const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 125 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 126 | Effect.provideService(HttpClient.HttpClient, refuseRequests), | ||
| 127 | ); | ||
| 128 | const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 129 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 130 | Effect.provideService(HttpClient.HttpClient, respond(429, {})), | ||
| 131 | ); | ||
| 132 | expect([darwin, limited]).toEqual([undefined, undefined]); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | }); | ||
| 136 | |||
| 137 | const ClaimBody = Schema.fromJsonString( | ||
| 138 | Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), | ||
| 139 | ); | ||
| 140 | const decodeClaimBody = Schema.decodeEffect(ClaimBody); | ||
| 141 | |||
| 142 | const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => | ||
| 143 | Effect.gen(function* () { | ||
| 144 | const login = yield* writeLogin; | ||
| 145 | return yield* ClaudeResetCredits.consumeClaudeResetCredit({ | ||
| 146 | ...login, | ||
| 147 | version: "2.1.0", | ||
| 148 | ...ids, | ||
| 149 | }).pipe( | ||
| 150 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 151 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 152 | Effect.result, | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 156 | effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { | ||
| 157 | it.effect("claims the grant for the organization", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const client = HttpClient.make((request) => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | expect(request.method).toBe("POST"); | ||
| 162 | expect(request.url).toBe( | ||
| 163 | "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", | ||
| 164 | ); | ||
| 165 | expect(request.headers.authorization).toBe("Bearer oauth-token"); | ||
| 166 | const body = | ||
| 167 | request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; | ||
| 168 | expect(yield* decodeClaimBody(body)).toEqual({ | ||
| 169 | program: "cedar_ember", | ||
| 170 | grant_id: "grant_a", | ||
| 171 | request_id: "r-1", | ||
| 172 | }); | ||
| 173 | return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); | ||
| 174 | }).pipe(Effect.orDie), | ||
| 175 | ); | ||
| 176 | expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | it.effect("maps each answer to an outcome or a failure", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | for (const [result, outcome] of [ | ||
| 183 | ["not_limited", "nothingToReset"], | ||
| 184 | ["already_used", "alreadyRedeemed"], | ||
| 185 | ["ineligible", "noCredit"], | ||
| 186 | ] as const) { | ||
| 187 | expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); | ||
| 188 | } | ||
| 189 | for (const client of [ | ||
| 190 | respond(200, { result: "cooldown" }), | ||
| 191 | respond(429, {}), | ||
| 192 | respond(401, {}), | ||
| 193 | ]) { | ||
| 194 | const result = yield* consume(client); | ||
| 195 | expect(result).toMatchObject({ _tag: "Failure" }); | ||
| 196 | // Claude answered, so a retry must be a new claim. | ||
| 197 | if (result._tag === "Failure") { | ||
| 198 | expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); | ||
| 199 | } | ||
| 200 | } | ||
| 201 | // No answer, or Claude could not confirm the claim: a retry is the same claim. | ||
| 202 | for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { | ||
| 203 | const unanswered = yield* consume(client); | ||
| 204 | expect(unanswered).toMatchObject({ _tag: "Failure" }); | ||
| 205 | if (unanswered._tag === "Failure") { | ||
| 206 | expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( | ||
| 207 | false, | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | } | ||
| 211 | }), | ||
| 212 | ); | ||
| 213 | |||
| 214 | it.effect("times out a stalled claim body", () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const login = yield* writeLogin; | ||
| 217 | const readingBody = yield* Deferred.make<void>(); | ||
| 218 | const client = HttpClient.make((request) => { | ||
| 219 | const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); | ||
| 220 | Object.defineProperty(response, "json", { | ||
| 221 | value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), | ||
| 222 | }); | ||
| 223 | return Effect.succeed(response); | ||
| 224 | }); | ||
| 225 | const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ | ||
| 226 | ...login, | ||
| 227 | version: "2.1.0", | ||
| 228 | grantId: "grant_a", | ||
| 229 | requestId: "r-1", | ||
| 230 | }).pipe( | ||
| 231 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 232 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 233 | Effect.result, | ||
| 234 | Effect.forkChild, | ||
| 235 | ); | ||
| 236 | yield* Deferred.await(readingBody); | ||
| 237 | yield* TestClock.adjust("26 seconds"); | ||
| 238 | expect(yield* Fiber.join(claim)).toMatchObject({ | ||
| 239 | _tag: "Failure", | ||
| 240 | failure: { | ||
| 241 | _tag: "ClaudeResetCreditError", | ||
| 242 | reason: "requestFailed", | ||
| 243 | cause: { _tag: "TimeoutError" }, | ||
| 244 | }, | ||
| 245 | }); | ||
| 246 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 247 | ); | ||
| 248 | |||
| 249 | it.effect("refuses malformed ids without sending anything", () => | ||
| 250 | Effect.gen(function* () { | ||
| 251 | for (const ids of [ | ||
| 252 | { grantId: "Bad Grant", requestId: "r-1" }, | ||
| 253 | { grantId: "grant_a", requestId: "has space" }, | ||
| 254 | ]) { | ||
| 255 | expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); | ||
| 256 | } | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | }); | ||
apps/server/src/provider/Layers/claudeResetCredits.ts deleted-270| ... | @@ -1,270 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the | ||
| 3 | * grants from the OAuth usage endpoint and claims one against the | ||
| 4 | * organization; this module does the same with the credentials the CLI keeps | ||
| 5 | * in its config directory. macOS keeps them in the keychain, so there the | ||
| 6 | * feature is not offered. | ||
| 7 | * | ||
| 8 | * @module provider/Layers/claudeResetCredits | ||
| 9 | */ | ||
| 10 | import * as NodeOS from "node:os"; | ||
| 11 | import type { | ||
| 12 | ProviderConsumeResetCreditOutcome, | ||
| 13 | ServerProviderResetCredits, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Path from "effect/Path"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 23 | |||
| 24 | const API_BASE = "https://api.anthropic.com"; | ||
| 25 | const PROGRAM = "cedar_ember"; | ||
| 26 | const GRANT_ID = /^[a-z0-9_-]{1,40}$/; | ||
| 27 | const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; | ||
| 28 | const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; | ||
| 29 | |||
| 30 | const Credentials = Schema.Struct({ | ||
| 31 | claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), | ||
| 32 | }); | ||
| 33 | const Config = Schema.Struct({ | ||
| 34 | oauthAccount: Schema.optional( | ||
| 35 | Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), | ||
| 36 | ), | ||
| 37 | }); | ||
| 38 | const Grant = Schema.Struct({ | ||
| 39 | id: Schema.String.check(Schema.isPattern(GRANT_ID)), | ||
| 40 | resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | ||
| 41 | ends_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 42 | paused: Schema.optional(Schema.Boolean), | ||
| 43 | usable_now: Schema.optional(Schema.Boolean), | ||
| 44 | }); | ||
| 45 | const decodeGrant = Schema.decodeUnknownOption(Grant); | ||
| 46 | const CedarEmber = Schema.Struct({ | ||
| 47 | eligible: Schema.Boolean, | ||
| 48 | grants: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 49 | next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 50 | }); | ||
| 51 | const UsageResponse = Schema.Struct({ | ||
| 52 | cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), | ||
| 53 | }); | ||
| 54 | const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); | ||
| 55 | const ClaimResponse = Schema.Struct({ | ||
| 56 | result: Schema.Literals([ | ||
| 57 | "reset", | ||
| 58 | "already_used", | ||
| 59 | "not_limited", | ||
| 60 | "cooldown", | ||
| 61 | "ineligible", | ||
| 62 | "unavailable", | ||
| 63 | ]), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const RESET_CREDIT_FAILURES = { | ||
| 67 | malformedCredit: "Claude returned a malformed reset credit.", | ||
| 68 | loginUnreadable: "Claude could not read its login.", | ||
| 69 | accountUnreadable: "Claude could not read its account.", | ||
| 70 | signedOut: "Sign in to Claude again to redeem resets.", | ||
| 71 | rateLimited: "Claude is rate limiting resets. Try again soon.", | ||
| 72 | coolingDown: "Claude resets are cooling down. Try again later.", | ||
| 73 | unconfirmed: | ||
| 74 | "Claude could not confirm the reset. If you are still limited in a moment, try again.", | ||
| 75 | requestFailed: "Claude could not redeem the reset.", | ||
| 76 | } as const; | ||
| 77 | |||
| 78 | class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()( | ||
| 79 | "ClaudeResetCreditError", | ||
| 80 | { | ||
| 81 | reason: Schema.Literals( | ||
| 82 | Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>, | ||
| 83 | ), | ||
| 84 | cause: Schema.optional(Schema.Defect()), | ||
| 85 | }, | ||
| 86 | ) { | ||
| 87 | override get message(): string { | ||
| 88 | return RESET_CREDIT_FAILURES[this.reason]; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); | ||
| 93 | |||
| 94 | /** | ||
| 95 | * Every reset failure except `requestFailed` and `unconfirmed` is final: | ||
| 96 | * Claude answered, or nothing was sent. An unanswered or unconfirmed claim | ||
| 97 | * retries with the same request id. | ||
| 98 | */ | ||
| 99 | export const isSettledClaudeResetCreditFailure = (error: unknown) => | ||
| 100 | isClaudeResetCreditError(error) && | ||
| 101 | error.reason !== "requestFailed" && | ||
| 102 | error.reason !== "unconfirmed"; | ||
| 103 | |||
| 104 | /** Rejects unparseable and calendar-invalid timestamps such as February 30. */ | ||
| 105 | const isFutureTimestamp = (value: string, nowMs: number) => { | ||
| 106 | if (!COMPLETE_TIMESTAMP.test(value)) return false; | ||
| 107 | const [year, month, day] = value.slice(0, 10).split("-").map(Number); | ||
| 108 | return ( | ||
| 109 | Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) | ||
| 110 | ); | ||
| 111 | }; | ||
| 112 | |||
| 113 | /** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ | ||
| 114 | export function claudeResetCreditsToContract( | ||
| 115 | block: unknown, | ||
| 116 | nowMs: number, | ||
| 117 | ): ServerProviderResetCredits | undefined { | ||
| 118 | const parsed = decodeCedarEmber(block); | ||
| 119 | if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; | ||
| 120 | const live = (parsed.value.grants ?? []) | ||
| 121 | .flatMap((raw) => Option.toArray(decodeGrant(raw))) | ||
| 122 | .filter( | ||
| 123 | (grant) => | ||
| 124 | !grant.paused && | ||
| 125 | grant.usable_now && | ||
| 126 | (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), | ||
| 127 | ); | ||
| 128 | const next = live.find((grant) => grant.id === parsed.value.next_grant_id); | ||
| 129 | const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); | ||
| 130 | return { | ||
| 131 | availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, | ||
| 132 | ...(Option.isSome(nextExpiresAt) | ||
| 133 | ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } | ||
| 134 | : {}), | ||
| 135 | ...(next ? { nextCreditId: next.id } : {}), | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | const readJson = <S extends Schema.Top>(schema: S, file: string) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | const fs = yield* FileSystem.FileSystem; | ||
| 142 | return yield* fs.readFileString(file).pipe( | ||
| 143 | Effect.catchTags({ | ||
| 144 | PlatformError: (error) => | ||
| 145 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 146 | }), | ||
| 147 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), | ||
| 148 | ); | ||
| 149 | }); | ||
| 150 | |||
| 151 | const readAccessToken = (configDir: string) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | if ((yield* HostProcessPlatform) === "darwin") return undefined; | ||
| 154 | const path = yield* Path.Path; | ||
| 155 | const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); | ||
| 156 | return credentials.claudeAiOauth?.accessToken?.trim() || undefined; | ||
| 157 | }); | ||
| 158 | |||
| 159 | const withClaudeHeaders = (token: string, version: string) => | ||
| 160 | HttpClientRequest.setHeaders({ | ||
| 161 | authorization: `Bearer ${token}`, | ||
| 162 | "anthropic-beta": "oauth-2025-04-20", | ||
| 163 | "user-agent": `claude-cli/${version} (external, cli)`, | ||
| 164 | }); | ||
| 165 | |||
| 166 | /** | ||
| 167 | * Reads the banked resets for the login in `configDir`. Any failure reads as | ||
| 168 | * "no resets" so the usage bars never break on this optional extra. | ||
| 169 | */ | ||
| 170 | export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( | ||
| 171 | function* (configDir: string, version: string) { | ||
| 172 | const token = yield* readAccessToken(configDir); | ||
| 173 | if (!token) return undefined; | ||
| 174 | const client = yield* HttpClient.HttpClient; | ||
| 175 | const response = yield* client.execute( | ||
| 176 | HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { | ||
| 177 | urlParams: { cedar_ember: "1", skip_spend: "1" }, | ||
| 178 | }).pipe(withClaudeHeaders(token, version)), | ||
| 179 | ); | ||
| 180 | const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( | ||
| 181 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 182 | ); | ||
| 183 | return claudeResetCreditsToContract( | ||
| 184 | body.cedar_ember, | ||
| 185 | DateTime.toEpochMillis(yield* DateTime.now), | ||
| 186 | ); | ||
| 187 | }, | ||
| 188 | Effect.timeout("10 seconds"), | ||
| 189 | Effect.orElseSucceed(() => undefined), | ||
| 190 | ); | ||
| 191 | |||
| 192 | /** The CLI keeps the account record beside its settings, or in the home directory by default. */ | ||
| 193 | export const claudeAccountConfigPath = (configDir: string | undefined) => | ||
| 194 | Effect.map(Path.Path, (path) => | ||
| 195 | configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), | ||
| 196 | ); | ||
| 197 | |||
| 198 | const CLAIM_OUTCOMES = { | ||
| 199 | reset: "reset", | ||
| 200 | not_limited: "nothingToReset", | ||
| 201 | already_used: "alreadyRedeemed", | ||
| 202 | ineligible: "noCredit", | ||
| 203 | } as const satisfies Record<string, ProviderConsumeResetCreditOutcome>; | ||
| 204 | |||
| 205 | /** | ||
| 206 | * Claims `grantId`. `requestId` is the idempotency key: a retry with the same | ||
| 207 | * id is the same claim. Ids are checked before anything is sent. | ||
| 208 | */ | ||
| 209 | export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { | ||
| 210 | readonly configDir: string; | ||
| 211 | readonly accountConfigPath: string; | ||
| 212 | readonly version: string; | ||
| 213 | readonly grantId: string; | ||
| 214 | readonly requestId: string; | ||
| 215 | }) { | ||
| 216 | if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { | ||
| 217 | return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); | ||
| 218 | } | ||
| 219 | const token = yield* readAccessToken(input.configDir).pipe( | ||
| 220 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), | ||
| 221 | ); | ||
| 222 | const config = yield* readJson(Config, input.accountConfigPath).pipe( | ||
| 223 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), | ||
| 224 | ); | ||
| 225 | const organization = config.oauthAccount?.organizationUuid?.trim(); | ||
| 226 | if (!token || !organization) { | ||
| 227 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); | ||
| 228 | } | ||
| 229 | const client = yield* HttpClient.HttpClient; | ||
| 230 | const response = yield* client | ||
| 231 | .execute( | ||
| 232 | HttpClientRequest.post( | ||
| 233 | new URL( | ||
| 234 | `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, | ||
| 235 | API_BASE, | ||
| 236 | ), | ||
| 237 | ).pipe( | ||
| 238 | withClaudeHeaders(token, input.version), | ||
| 239 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 240 | program: PROGRAM, | ||
| 241 | grant_id: input.grantId, | ||
| 242 | request_id: input.requestId, | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ) | ||
| 246 | .pipe( | ||
| 247 | Effect.timeout("25 seconds"), | ||
| 248 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), | ||
| 249 | ); | ||
| 250 | if (response.status === 429) { | ||
| 251 | return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); | ||
| 252 | } | ||
| 253 | if (response.status === 401 || response.status === 403) { | ||
| 254 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); | ||
| 255 | } | ||
| 256 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 257 | Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), | ||
| 258 | Effect.timeout("25 seconds"), | ||
| 259 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), | ||
| 260 | ); | ||
| 261 | if (body.result === "cooldown") { | ||
| 262 | return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); | ||
| 263 | } | ||
| 264 | // Claude could not say whether the claim landed, so, like the CLI, keep the | ||
| 265 | // request id and let the retry ask about the same claim. | ||
| 266 | if (body.result === "unavailable") { | ||
| 267 | return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); | ||
| 268 | } | ||
| 269 | return CLAIM_OUTCOMES[body.result]; | ||
| 270 | }); | ||
apps/server/src/provider/Layers/claudeUsageLimits.test.ts deleted-180| ... | @@ -1,180 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; | ||
| 4 | |||
| 5 | const checkedAt = "2026-07-18T10:00:00.000Z"; | ||
| 6 | const noNames = { overageIncluded: undefined } as const; | ||
| 7 | |||
| 8 | describe("claudeUsageResponseToLimits", () => { | ||
| 9 | it("maps the session, weekly, and model-scoped weekly windows", () => { | ||
| 10 | expect( | ||
| 11 | claudeUsageResponseToLimits({ | ||
| 12 | checkedAt, | ||
| 13 | response: { | ||
| 14 | rate_limits_available: true, | ||
| 15 | rate_limits: { | ||
| 16 | five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, | ||
| 17 | seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, | ||
| 18 | seven_day_opus: { utilization: 3, resets_at: null }, | ||
| 19 | // Newer CLIs add this on top of the typed keys; the pinned SDK | ||
| 20 | // typings do not know it yet. | ||
| 21 | ...({ | ||
| 22 | model_scoped: [ | ||
| 23 | { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, | ||
| 24 | { display_name: "Ghost", utilization: null, resets_at: null }, | ||
| 25 | ], | ||
| 26 | } as object), | ||
| 27 | extra_usage: { | ||
| 28 | is_enabled: false, | ||
| 29 | monthly_limit: null, | ||
| 30 | used_credits: null, | ||
| 31 | utilization: null, | ||
| 32 | }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | names: { overageIncluded: "Fable" }, | ||
| 38 | limits: { | ||
| 39 | checkedAt, | ||
| 40 | windows: [ | ||
| 41 | { | ||
| 42 | id: "five_hour", | ||
| 43 | kind: "session", | ||
| 44 | label: "Session", | ||
| 45 | usedPercent: 54, | ||
| 46 | windowDurationMins: 300, | ||
| 47 | resetsAt: "2026-07-18T14:39:00.000Z", | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | id: "seven_day", | ||
| 51 | kind: "weekly", | ||
| 52 | label: "Weekly", | ||
| 53 | usedPercent: 18.4, | ||
| 54 | windowDurationMins: 10080, | ||
| 55 | resetsAt: "2026-07-24T08:59:00.000Z", | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | id: "seven_day_fable", | ||
| 59 | kind: "weekly", | ||
| 60 | label: "Weekly · Fable", | ||
| 61 | usedPercent: 73, | ||
| 62 | windowDurationMins: 10080, | ||
| 63 | resetsAt: "2026-07-24T08:59:00.000Z", | ||
| 64 | }, | ||
| 65 | ], | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("names the overage-included bucket only from a scoped entry that drew a row", () => { | ||
| 71 | expect( | ||
| 72 | claudeUsageResponseToLimits({ | ||
| 73 | checkedAt, | ||
| 74 | response: { | ||
| 75 | rate_limits_available: true, | ||
| 76 | rate_limits: { | ||
| 77 | ...({ | ||
| 78 | model_scoped: [ | ||
| 79 | { display_name: "Ghost", utilization: null, resets_at: null }, | ||
| 80 | { display_name: "Fable", utilization: 5, resets_at: null }, | ||
| 81 | ], | ||
| 82 | } as object), | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }).names, | ||
| 86 | ).toEqual({ overageIncluded: "Fable" }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("reports API key and Bedrock accounts as unsupported", () => { | ||
| 90 | expect( | ||
| 91 | claudeUsageResponseToLimits({ | ||
| 92 | checkedAt, | ||
| 93 | response: { rate_limits_available: false, rate_limits: null }, | ||
| 94 | }).limits, | ||
| 95 | ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("skips a window the endpoint reports without a utilization", () => { | ||
| 99 | expect( | ||
| 100 | claudeUsageResponseToLimits({ | ||
| 101 | checkedAt, | ||
| 102 | response: { | ||
| 103 | rate_limits_available: true, | ||
| 104 | rate_limits: { | ||
| 105 | five_hour: { utilization: null, resets_at: null }, | ||
| 106 | seven_day: { utilization: 250, resets_at: null }, | ||
| 107 | }, | ||
| 108 | }, | ||
| 109 | }).limits.windows, | ||
| 110 | ).toEqual([ | ||
| 111 | { | ||
| 112 | id: "seven_day", | ||
| 113 | kind: "weekly", | ||
| 114 | label: "Weekly", | ||
| 115 | usedPercent: 100, | ||
| 116 | windowDurationMins: 10080, | ||
| 117 | }, | ||
| 118 | ]); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("claudeRateLimitEventToUpdate", () => { | ||
| 123 | it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { | ||
| 124 | expect( | ||
| 125 | claudeRateLimitEventToUpdate( | ||
| 126 | { | ||
| 127 | status: "allowed_warning", | ||
| 128 | rateLimitType: "seven_day", | ||
| 129 | utilization: 0.85, | ||
| 130 | resetsAt: 1_784_000_000, | ||
| 131 | }, | ||
| 132 | noNames, | ||
| 133 | ), | ||
| 134 | ).toEqual({ | ||
| 135 | windows: [ | ||
| 136 | { | ||
| 137 | id: "seven_day", | ||
| 138 | kind: "weekly", | ||
| 139 | label: "Weekly", | ||
| 140 | usedPercent: 85, | ||
| 141 | windowDurationMins: 10080, | ||
| 142 | resetsAt: "2026-07-14T03:33:20.000Z", | ||
| 143 | }, | ||
| 144 | ], | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | it("lands the streamed overage-included bucket on the row the probe named", () => { | ||
| 149 | const event = { | ||
| 150 | status: "allowed", | ||
| 151 | rateLimitType: "seven_day_overage_included" as never, | ||
| 152 | utilization: 0.4, | ||
| 153 | } as const; | ||
| 154 | // No probe has named the bucket yet: guessing would open a stray row. | ||
| 155 | expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); | ||
| 156 | expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ | ||
| 157 | windows: [ | ||
| 158 | { | ||
| 159 | id: "seven_day_fable", | ||
| 160 | kind: "weekly", | ||
| 161 | label: "Weekly · Fable", | ||
| 162 | usedPercent: 40, | ||
| 163 | windowDurationMins: 10080, | ||
| 164 | }, | ||
| 165 | ], | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it("ignores windows the page does not render and events without a utilization", () => { | ||
| 170 | expect( | ||
| 171 | claudeRateLimitEventToUpdate( | ||
| 172 | { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, | ||
| 173 | noNames, | ||
| 174 | ), | ||
| 175 | ).toBeUndefined(); | ||
| 176 | expect( | ||
| 177 | claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), | ||
| 178 | ).toBeUndefined(); | ||
| 179 | }); | ||
| 180 | }); | ||
apps/server/src/provider/Layers/claudeUsageLimits.ts deleted-199| ... | @@ -1,199 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Claude Code subscription usage. Both sources produce windows with the same | ||
| 3 | * ids so a turn-driven `rate_limit_event` lands on the row the SDK's | ||
| 4 | * `get_usage` read established: | ||
| 5 | * | ||
| 6 | * - `get_usage` (on demand, during the capabilities probe) reports every | ||
| 7 | * window at once as 0–100 percentages with ISO reset times. | ||
| 8 | * - `rate_limit_event` (streamed during a turn) names one window at a time | ||
| 9 | * with a 0–1 utilization fraction and an epoch-seconds reset. | ||
| 10 | * | ||
| 11 | * @module provider/Layers/claudeUsageLimits | ||
| 12 | */ | ||
| 13 | import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; | ||
| 14 | import type { | ||
| 15 | ProviderUsageLimitsUpdate, | ||
| 16 | ServerProviderUsageLimits, | ||
| 17 | ServerProviderUsageWindow, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import * as DateTime from "effect/DateTime"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Ref from "effect/Ref"; | ||
| 23 | |||
| 24 | import { | ||
| 25 | clampPercent, | ||
| 26 | makeUnavailableUsageLimits, | ||
| 27 | makeUsageLimits, | ||
| 28 | } from "../providerUsageLimits.ts"; | ||
| 29 | |||
| 30 | const SESSION_MINS = 5 * 60; | ||
| 31 | const WEEK_MINS = 7 * 24 * 60; | ||
| 32 | |||
| 33 | /** | ||
| 34 | * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped | ||
| 35 | * weeklies are additive on top of these: the CLI reports them under | ||
| 36 | * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included | ||
| 37 | * model bucket (Fable today) as `seven_day_overage_included`. | ||
| 38 | */ | ||
| 39 | const WINDOWS: Readonly< | ||
| 40 | Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">> | ||
| 41 | > = { | ||
| 42 | five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, | ||
| 43 | seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, | ||
| 44 | }; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * The streamed event names the overage-included bucket by type | ||
| 48 | * (`seven_day_overage_included`), while `get_usage` names it by the model's | ||
| 49 | * `display_name`. Which model that is changes over time, so the probe records | ||
| 50 | * the name it saw and the event mapper reuses it; the mid-turn update then | ||
| 51 | * lands on the row the probe drew instead of opening a second one. | ||
| 52 | */ | ||
| 53 | const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; | ||
| 54 | |||
| 55 | export interface ClaudeScopedLimitNames { | ||
| 56 | readonly overageIncluded: string | undefined; | ||
| 57 | } | ||
| 58 | |||
| 59 | export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({ | ||
| 60 | overageIncluded: undefined, | ||
| 61 | }); | ||
| 62 | |||
| 63 | function scopedWindowId(displayName: string): string { | ||
| 64 | return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | function scopedWindow( | ||
| 68 | displayName: string, | ||
| 69 | usedPercent: number, | ||
| 70 | resetsAt: string | undefined, | ||
| 71 | ): ServerProviderUsageWindow { | ||
| 72 | return { | ||
| 73 | id: scopedWindowId(displayName), | ||
| 74 | kind: "weekly", | ||
| 75 | label: `Weekly · ${displayName}`, | ||
| 76 | windowDurationMins: WEEK_MINS, | ||
| 77 | usedPercent: clampPercent(usedPercent), | ||
| 78 | ...(resetsAt ? { resetsAt } : {}), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** | ||
| 83 | * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is | ||
| 84 | * read structurally until the `.d.ts` catches up. | ||
| 85 | */ | ||
| 86 | interface ModelScopedWindow { | ||
| 87 | readonly display_name: string; | ||
| 88 | readonly utilization: number | null; | ||
| 89 | readonly resets_at: string | null; | ||
| 90 | } | ||
| 91 | |||
| 92 | function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> { | ||
| 93 | const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; | ||
| 94 | if (!Array.isArray(raw)) return []; | ||
| 95 | return raw.filter( | ||
| 96 | (entry): entry is ModelScopedWindow => | ||
| 97 | typeof entry === "object" && | ||
| 98 | entry !== null && | ||
| 99 | typeof (entry as ModelScopedWindow).display_name === "string", | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | function isoFromEpochSeconds(value: number | undefined): string | undefined { | ||
| 104 | if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; | ||
| 105 | const dt = DateTime.make(value * 1000); | ||
| 106 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 107 | } | ||
| 108 | |||
| 109 | function isoFromString(value: string | null | undefined): string | undefined { | ||
| 110 | if (!value) return undefined; | ||
| 111 | const dt = DateTime.make(value); | ||
| 112 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 113 | } | ||
| 114 | |||
| 115 | function makeWindow( | ||
| 116 | id: keyof typeof WINDOWS & string, | ||
| 117 | usedPercent: number, | ||
| 118 | resetsAt: string | undefined, | ||
| 119 | ): ServerProviderUsageWindow { | ||
| 120 | const window = WINDOWS[id]!; | ||
| 121 | return { | ||
| 122 | id, | ||
| 123 | ...window, | ||
| 124 | usedPercent: clampPercent(usedPercent), | ||
| 125 | ...(resetsAt ? { resetsAt } : {}), | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | /** | ||
| 130 | * Utilization is a 0–1 fraction on the streamed event. An overage-included | ||
| 131 | * event before any probe has named the bucket is dropped: guessing a name | ||
| 132 | * would draw a row the next probe cannot reconcile. | ||
| 133 | */ | ||
| 134 | export function claudeRateLimitEventToUpdate( | ||
| 135 | info: SDKRateLimitInfo, | ||
| 136 | names: ClaudeScopedLimitNames, | ||
| 137 | ): ProviderUsageLimitsUpdate | undefined { | ||
| 138 | const type: string | undefined = info.rateLimitType; | ||
| 139 | if (!type || typeof info.utilization !== "number") { | ||
| 140 | return undefined; | ||
| 141 | } | ||
| 142 | const usedPercent = info.utilization * 100; | ||
| 143 | const resetsAt = isoFromEpochSeconds(info.resetsAt); | ||
| 144 | if (type in WINDOWS) { | ||
| 145 | return { windows: [makeWindow(type, usedPercent, resetsAt)] }; | ||
| 146 | } | ||
| 147 | if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { | ||
| 148 | return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; | ||
| 149 | } | ||
| 150 | return undefined; | ||
| 151 | } | ||
| 152 | |||
| 153 | /** | ||
| 154 | * Percentages on the `get_usage` response are already 0–100. Also yields the | ||
| 155 | * scoped-bucket names the response carried, for the event mapper to reuse. | ||
| 156 | */ | ||
| 157 | export function claudeUsageResponseToLimits(input: { | ||
| 158 | readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | ||
| 159 | readonly checkedAt: string; | ||
| 160 | }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { | ||
| 161 | const { response, checkedAt } = input; | ||
| 162 | if (!response.rate_limits_available || !response.rate_limits) { | ||
| 163 | return { | ||
| 164 | limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), | ||
| 165 | names: { overageIncluded: undefined }, | ||
| 166 | }; | ||
| 167 | } | ||
| 168 | const windows: ServerProviderUsageWindow[] = []; | ||
| 169 | for (const id of Object.keys(WINDOWS)) { | ||
| 170 | const window = response.rate_limits[id as "five_hour" | "seven_day"]; | ||
| 171 | if (!window || typeof window.utilization !== "number") continue; | ||
| 172 | windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); | ||
| 173 | } | ||
| 174 | // The CLI filters `model_scoped` to the overage-included allowlist, which | ||
| 175 | // today holds one model; the first entry is the one the event refers to. | ||
| 176 | let overageIncluded: string | undefined; | ||
| 177 | for (const entry of readModelScoped(response.rate_limits)) { | ||
| 178 | if (typeof entry.utilization !== "number") continue; | ||
| 179 | windows.push( | ||
| 180 | scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), | ||
| 181 | ); | ||
| 182 | // Only a bucket that drew a row may receive events; naming one that was | ||
| 183 | // skipped would let a mid-turn event open a row the probe never showed. | ||
| 184 | overageIncluded ??= entry.display_name; | ||
| 185 | } | ||
| 186 | return { | ||
| 187 | limits: makeUsageLimits({ checkedAt, windows }), | ||
| 188 | names: { overageIncluded }, | ||
| 189 | }; | ||
| 190 | } | ||
| 191 | |||
| 192 | /** Probe-side helper: map the response and remember the scoped names for events. */ | ||
| 193 | export const recordClaudeUsageResponse = ( | ||
| 194 | namesRef: Ref.Ref<ClaudeScopedLimitNames>, | ||
| 195 | input: Parameters<typeof claudeUsageResponseToLimits>[0], | ||
| 196 | ): Effect.Effect<ServerProviderUsageLimits> => { | ||
| 197 | const { limits, names } = claudeUsageResponseToLimits(input); | ||
| 198 | return Ref.set(namesRef, names).pipe(Effect.as(limits)); | ||
| 199 | }; | ||
apps/server/src/provider/Layers/codexLaunchArgs.test.ts deleted-59| ... | @@ -1,59 +0,0 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | |||
| 3 | import { describe, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | codexAppServerArgs, | ||
| 7 | codexExecLaunchArgs, | ||
| 8 | resolveCodexLaunchArgs, | ||
| 9 | } from "./codexLaunchArgs.ts"; | ||
| 10 | |||
| 11 | describe("resolveCodexLaunchArgs", () => { | ||
| 12 | it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { | ||
| 13 | NodeAssert.equal( | ||
| 14 | resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), | ||
| 15 | "--enable foo", | ||
| 16 | ); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { | ||
| 20 | NodeAssert.equal( | ||
| 21 | resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), | ||
| 22 | "--strict-config", | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("ignores whitespace-only environment values", () => { | ||
| 27 | NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("codexAppServerArgs", () => { | ||
| 32 | it("returns the app-server command for empty launch args", () => { | ||
| 33 | NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("appends parsed launch args after app-server", () => { | ||
| 37 | NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ | ||
| 38 | "app-server", | ||
| 39 | "--strict-config", | ||
| 40 | "--enable", | ||
| 41 | "foo", | ||
| 42 | ]); | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | describe("codexExecLaunchArgs", () => { | ||
| 47 | it("keeps shared codex flags and omits app-server-only flags", () => { | ||
| 48 | NodeAssert.deepStrictEqual( | ||
| 49 | codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), | ||
| 50 | ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], | ||
| 51 | ); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("does not pair value-taking flags with adjacent flags", () => { | ||
| 55 | NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ | ||
| 56 | "--strict-config", | ||
| 57 | ]); | ||
| 58 | }); | ||
| 59 | }); | ||
apps/server/src/provider/Layers/codexLaunchArgs.ts deleted-39| ... | @@ -1,39 +0,0 @@ | ||
| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; | ||
| 2 | |||
| 3 | const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; | ||
| 4 | |||
| 5 | export const resolveCodexLaunchArgs = ( | ||
| 6 | launchArgs?: string, | ||
| 7 | environment: NodeJS.ProcessEnv = process.env, | ||
| 8 | ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; | ||
| 9 | |||
| 10 | const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> => tokenizeCliArgs(launchArgs); | ||
| 11 | |||
| 12 | export const codexAppServerArgs = (launchArgs?: string) => [ | ||
| 13 | "app-server", | ||
| 14 | ...codexLaunchArgv(launchArgs), | ||
| 15 | ]; | ||
| 16 | |||
| 17 | export const codexExecLaunchArgs = (launchArgs?: string) => { | ||
| 18 | const args = codexLaunchArgv(launchArgs); | ||
| 19 | const execArgs: Array<string> = []; | ||
| 20 | |||
| 21 | for (let index = 0; index < args.length; index++) { | ||
| 22 | const arg = args[index]; | ||
| 23 | if (arg === undefined) continue; | ||
| 24 | |||
| 25 | if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { | ||
| 26 | execArgs.push(arg); | ||
| 27 | } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { | ||
| 28 | const value = args[index + 1]; | ||
| 29 | if (value !== undefined && !value.startsWith("-")) { | ||
| 30 | execArgs.push(arg, value); | ||
| 31 | index++; | ||
| 32 | } | ||
| 33 | } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { | ||
| 34 | execArgs.push(arg); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | return execArgs; | ||
| 39 | }; | ||
apps/server/src/provider/Layers/codexUsageLimits.test.ts deleted-316| ... | @@ -1,316 +0,0 @@ | ||
| 1 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | codexRateLimitsFailureMessage, | ||
| 6 | codexRateLimitsToLimits, | ||
| 7 | codexRateLimitsToUpdate, | ||
| 8 | codexResetCreditsToContract, | ||
| 9 | codexUsageLimitMessage, | ||
| 10 | mergeCodexRateLimits, | ||
| 11 | } from "./codexUsageLimits.ts"; | ||
| 12 | |||
| 13 | const checkedAt = "2026-07-18T10:00:00.000Z"; | ||
| 14 | |||
| 15 | describe("codexRateLimitsToLimits", () => { | ||
| 16 | it("maps primary and secondary onto the session and weekly windows", () => { | ||
| 17 | expect( | ||
| 18 | codexRateLimitsToLimits({ | ||
| 19 | checkedAt, | ||
| 20 | snapshot: { | ||
| 21 | planType: "plus", | ||
| 22 | primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, | ||
| 23 | secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, | ||
| 24 | }, | ||
| 25 | }), | ||
| 26 | ).toEqual({ | ||
| 27 | checkedAt, | ||
| 28 | windows: [ | ||
| 29 | { | ||
| 30 | id: "primary", | ||
| 31 | kind: "session", | ||
| 32 | label: "Session", | ||
| 33 | usedPercent: 12, | ||
| 34 | windowDurationMins: 300, | ||
| 35 | resetsAt: "2026-07-14T03:33:20.000Z", | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | id: "secondary", | ||
| 39 | kind: "weekly", | ||
| 40 | label: "Weekly", | ||
| 41 | usedPercent: 47, | ||
| 42 | windowDurationMins: 10080, | ||
| 43 | resetsAt: "2026-07-19T22:26:40.000Z", | ||
| 44 | }, | ||
| 45 | ], | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("treats a lone duration-less primary as monthly on Free and Go", () => { | ||
| 50 | expect( | ||
| 51 | codexRateLimitsToLimits({ | ||
| 52 | checkedAt, | ||
| 53 | snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, | ||
| 54 | }).windows, | ||
| 55 | ).toEqual([ | ||
| 56 | { | ||
| 57 | id: "primary", | ||
| 58 | kind: "monthly", | ||
| 59 | label: "Monthly", | ||
| 60 | usedPercent: 80, | ||
| 61 | windowDurationMins: 43_200, | ||
| 62 | }, | ||
| 63 | ]); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("selects the main Codex allowance and leaves Spark out", () => { | ||
| 67 | const spark = { | ||
| 68 | limitId: "codex_bengalfox", | ||
| 69 | primary: { usedPercent: 0, windowDurationMins: 300 }, | ||
| 70 | secondary: { usedPercent: 90, windowDurationMins: 10080 }, | ||
| 71 | }; | ||
| 72 | expect( | ||
| 73 | codexRateLimitsToLimits({ | ||
| 74 | checkedAt, | ||
| 75 | snapshot: spark, | ||
| 76 | rateLimitsByLimitId: { | ||
| 77 | codex_bengalfox: spark, | ||
| 78 | codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } }, | ||
| 79 | }, | ||
| 80 | }).windows, | ||
| 81 | ).toEqual([ | ||
| 82 | { | ||
| 83 | id: "secondary", | ||
| 84 | kind: "weekly", | ||
| 85 | label: "Weekly", | ||
| 86 | usedPercent: 42, | ||
| 87 | windowDurationMins: 10080, | ||
| 88 | }, | ||
| 89 | ]); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it.each([undefined, null, {}])( | ||
| 93 | "supports legacy reads with no bucket map: %j", | ||
| 94 | (rateLimitsByLimitId) => { | ||
| 95 | const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } }; | ||
| 96 | expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual( | ||
| 97 | codexRateLimitsToLimits({ checkedAt, snapshot }), | ||
| 98 | ); | ||
| 99 | }, | ||
| 100 | ); | ||
| 101 | |||
| 102 | it("does not show a model-specific legacy snapshot as the main allowance", () => { | ||
| 103 | expect( | ||
| 104 | codexRateLimitsToLimits({ | ||
| 105 | checkedAt, | ||
| 106 | snapshot: { | ||
| 107 | limitId: "codex_bengalfox", | ||
| 108 | secondary: { usedPercent: 90 }, | ||
| 109 | }, | ||
| 110 | }).windows, | ||
| 111 | ).toEqual([]); | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | |||
| 115 | describe("codexRateLimitsToUpdate", () => { | ||
| 116 | it("carries only the windows the notification names", () => { | ||
| 117 | expect( | ||
| 118 | codexRateLimitsToUpdate({ | ||
| 119 | secondary: { usedPercent: 51, windowDurationMins: 10080 }, | ||
| 120 | }), | ||
| 121 | ).toEqual({ | ||
| 122 | windows: [ | ||
| 123 | { | ||
| 124 | id: "secondary", | ||
| 125 | kind: "weekly", | ||
| 126 | label: "Weekly", | ||
| 127 | usedPercent: 51, | ||
| 128 | windowDurationMins: 10080, | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | }); | ||
| 132 | expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("ignores Spark notifications so they cannot overwrite the main allowance", () => { | ||
| 136 | expect( | ||
| 137 | codexRateLimitsToUpdate({ | ||
| 138 | limitId: "codex_bengalfox", | ||
| 139 | primary: { usedPercent: 0, windowDurationMins: 300 }, | ||
| 140 | secondary: { usedPercent: 90, windowDurationMins: 10080 }, | ||
| 141 | }), | ||
| 142 | ).toBeUndefined(); | ||
| 143 | expect( | ||
| 144 | codexRateLimitsToUpdate({ | ||
| 145 | limitId: "codex", | ||
| 146 | secondary: { usedPercent: 42, windowDurationMins: 10080 }, | ||
| 147 | })?.windows, | ||
| 148 | ).toEqual([ | ||
| 149 | { | ||
| 150 | id: "secondary", | ||
| 151 | kind: "weekly", | ||
| 152 | label: "Weekly", | ||
| 153 | usedPercent: 42, | ||
| 154 | windowDurationMins: 10080, | ||
| 155 | }, | ||
| 156 | ]); | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | describe("codexRateLimitsFailureMessage", () => { | ||
| 161 | it("keeps the JSON-RPC code and nothing else from a request failure", () => { | ||
| 162 | expect( | ||
| 163 | codexRateLimitsFailureMessage( | ||
| 164 | new CodexErrors.CodexAppServerRequestError({ | ||
| 165 | code: -32603, | ||
| 166 | errorMessage: | ||
| 167 | "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", | ||
| 168 | }), | ||
| 169 | ), | ||
| 170 | ).toBe("Codex could not read usage (JSON-RPC -32603)."); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("phrases a dead process differently from a bad answer", () => { | ||
| 174 | expect( | ||
| 175 | codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), | ||
| 176 | ).toBe("Codex exited before it could report usage."); | ||
| 177 | }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | describe("codexResetCreditsToContract", () => { | ||
| 181 | it("counts available credits and reports the soonest expiry", () => { | ||
| 182 | expect( | ||
| 183 | codexResetCreditsToContract({ | ||
| 184 | availableCount: 2, | ||
| 185 | credits: [ | ||
| 186 | { status: "available", expiresAt: 1_784_500_000 }, | ||
| 187 | { status: "redeemed", expiresAt: 1_700_000_000 }, | ||
| 188 | { status: "available", expiresAt: 1_784_000_000 }, | ||
| 189 | ], | ||
| 190 | }), | ||
| 191 | ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); | ||
| 192 | expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); | ||
| 193 | expect(codexResetCreditsToContract(null)).toBeUndefined(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | it("rides along on the probe's limits", () => { | ||
| 197 | expect( | ||
| 198 | codexRateLimitsToLimits({ | ||
| 199 | checkedAt, | ||
| 200 | snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, | ||
| 201 | resetCredits: { availableCount: 1 }, | ||
| 202 | }).resetCredits, | ||
| 203 | ).toEqual({ availableCount: 1 }); | ||
| 204 | }); | ||
| 205 | }); | ||
| 206 | |||
| 207 | describe("codexUsageLimitMessage", () => { | ||
| 208 | const at = "2026-01-01T00:00:00.000Z"; | ||
| 209 | const atSeconds = Date.parse(at) / 1000; | ||
| 210 | |||
| 211 | it("names the exhausted window and the workspace's missing credits", () => { | ||
| 212 | expect( | ||
| 213 | codexUsageLimitMessage( | ||
| 214 | { | ||
| 215 | limitId: "codex", | ||
| 216 | rateLimitReachedType: "workspace_owner_credits_depleted", | ||
| 217 | primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, | ||
| 218 | secondary: { | ||
| 219 | usedPercent: 100, | ||
| 220 | resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, | ||
| 221 | windowDurationMins: 10_080, | ||
| 222 | }, | ||
| 223 | }, | ||
| 224 | at, | ||
| 225 | ), | ||
| 226 | ).toBe( | ||
| 227 | "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.", | ||
| 228 | ); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("points a reached spend cap at the workspace owner", () => { | ||
| 232 | expect( | ||
| 233 | codexUsageLimitMessage( | ||
| 234 | { | ||
| 235 | limitId: "codex", | ||
| 236 | rateLimitReachedType: "workspace_member_usage_limit_reached", | ||
| 237 | primary: { | ||
| 238 | usedPercent: 100, | ||
| 239 | resetsAt: atSeconds + 3 * 3_600 + 20 * 60, | ||
| 240 | windowDurationMins: 300, | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | at, | ||
| 244 | ), | ||
| 245 | ).toBe( | ||
| 246 | "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.", | ||
| 247 | ); | ||
| 248 | }); | ||
| 249 | |||
| 250 | it("names no window when credits run out without one", () => { | ||
| 251 | expect( | ||
| 252 | codexUsageLimitMessage( | ||
| 253 | { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, | ||
| 254 | at, | ||
| 255 | ), | ||
| 256 | ).toBe( | ||
| 257 | "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.", | ||
| 258 | ); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("says only what it knows without a snapshot", () => { | ||
| 262 | expect(codexUsageLimitMessage(undefined, at)).toBe( | ||
| 263 | "Codex usage limit reached. Send the message again once the limit resets.", | ||
| 264 | ); | ||
| 265 | }); | ||
| 266 | }); | ||
| 267 | |||
| 268 | describe("mergeCodexRateLimits", () => { | ||
| 269 | it("keeps windows an update does not carry", () => { | ||
| 270 | const merged = mergeCodexRateLimits( | ||
| 271 | { | ||
| 272 | limitId: "codex", | ||
| 273 | planType: "business", | ||
| 274 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 275 | }, | ||
| 276 | { rateLimitReachedType: "rate_limit_reached" }, | ||
| 277 | ); | ||
| 278 | |||
| 279 | expect(merged).toEqual({ | ||
| 280 | limitId: "codex", | ||
| 281 | planType: "business", | ||
| 282 | rateLimitReachedType: "rate_limit_reached", | ||
| 283 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 284 | }); | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { | ||
| 288 | const main = { | ||
| 289 | limitId: "codex", | ||
| 290 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 291 | }; | ||
| 292 | expect( | ||
| 293 | mergeCodexRateLimits(main, { | ||
| 294 | limitId: "spark", | ||
| 295 | primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 296 | }), | ||
| 297 | ).toBe(main); | ||
| 298 | }); | ||
| 299 | }); | ||
| 300 | |||
| 301 | import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; | ||
| 302 | |||
| 303 | describe("codexUsageLimitResetAt", () => { | ||
| 304 | it("waits for every exhausted window and never invents an unknown reset", () => { | ||
| 305 | expect( | ||
| 306 | codexUsageLimitResetAt({ | ||
| 307 | primary: { usedPercent: 100, resetsAt: 2000000000 }, | ||
| 308 | secondary: { usedPercent: 100, resetsAt: 2000100000 }, | ||
| 309 | }), | ||
| 310 | ).toBe("2033-05-19T07:20:00.000Z"); | ||
| 311 | expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull(); | ||
| 312 | expect( | ||
| 313 | codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }), | ||
| 314 | ).toBeNull(); | ||
| 315 | }); | ||
| 316 | }); | ||
apps/server/src/provider/Layers/codexUsageLimits.ts deleted-250| ... | @@ -1,250 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Codex subscription usage. The `account/rateLimits/read` response and the | ||
| 3 | * `account/rateLimits/updated` notification carry the same snapshot shape, so | ||
| 4 | * one mapper serves the status probe and the turn-driven update; both emit | ||
| 5 | * windows with the same ids so they merge onto the same rows. | ||
| 6 | * | ||
| 7 | * @module provider/Layers/codexUsageLimits | ||
| 8 | */ | ||
| 9 | import type { | ||
| 10 | ProviderUsageLimitsUpdate, | ||
| 11 | ServerProviderResetCredits, | ||
| 12 | ServerProviderUsageLimits, | ||
| 13 | ServerProviderUsageWindow, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import type * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 18 | |||
| 19 | import { clampPercent, makeUsageLimits } from "../providerUsageLimits.ts"; | ||
| 20 | |||
| 21 | interface CodexRateLimitWindow { | ||
| 22 | readonly usedPercent: number; | ||
| 23 | readonly resetsAt?: number | null; | ||
| 24 | readonly windowDurationMins?: number | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | /** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ | ||
| 28 | export interface CodexRateLimitSnapshot { | ||
| 29 | readonly limitId?: string | null; | ||
| 30 | readonly planType?: string | null; | ||
| 31 | readonly rateLimitReachedType?: string | null; | ||
| 32 | readonly primary?: CodexRateLimitWindow | null; | ||
| 33 | readonly secondary?: CodexRateLimitWindow | null; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** Structural view of the read response's `rateLimitResetCredits`. */ | ||
| 37 | export interface CodexResetCreditsSummary { | ||
| 38 | readonly availableCount: number; | ||
| 39 | readonly credits?: ReadonlyArray<{ | ||
| 40 | readonly status: string; | ||
| 41 | readonly expiresAt?: number | null; | ||
| 42 | }> | null; | ||
| 43 | } | ||
| 44 | |||
| 45 | const SESSION_MINS = 5 * 60; | ||
| 46 | const WEEK_MINS = 7 * 24 * 60; | ||
| 47 | const MONTH_MINS = 30 * 24 * 60; | ||
| 48 | |||
| 49 | function isoFromEpochSeconds(value: number | null | undefined): string | undefined { | ||
| 50 | if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; | ||
| 51 | const dt = DateTime.make(value * 1000); | ||
| 52 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 53 | } | ||
| 54 | |||
| 55 | function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { | ||
| 56 | if (mins >= MONTH_MINS) return "monthly"; | ||
| 57 | if (mins >= WEEK_MINS) return "weekly"; | ||
| 58 | return "session"; | ||
| 59 | } | ||
| 60 | |||
| 61 | function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { | ||
| 62 | return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; | ||
| 63 | } | ||
| 64 | |||
| 65 | /** | ||
| 66 | * `primary` / `secondary` are positions, not durations. Codex usually sends | ||
| 67 | * `windowDurationMins`; when it does not, paid plans expose the 5-hour and | ||
| 68 | * weekly pair and Free/Go expose one monthly allowance. | ||
| 69 | */ | ||
| 70 | function codexRateLimitsToWindows( | ||
| 71 | snapshot: CodexRateLimitSnapshot, | ||
| 72 | ): ReadonlyArray<ServerProviderUsageWindow> { | ||
| 73 | // Show the main allowance only. Model-specific notifications (such as Spark) | ||
| 74 | // must not replace its primary/secondary rows. Older CLIs omit the limit id. | ||
| 75 | if (snapshot.limitId && snapshot.limitId !== "codex") return []; | ||
| 76 | const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; | ||
| 77 | const positions = [ | ||
| 78 | ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], | ||
| 79 | ["secondary", snapshot.secondary, WEEK_MINS], | ||
| 80 | ] as const; | ||
| 81 | const windows: ServerProviderUsageWindow[] = []; | ||
| 82 | for (const [id, window, fallbackMins] of positions) { | ||
| 83 | if (!window || !Number.isFinite(window.usedPercent)) continue; | ||
| 84 | const windowDurationMins = | ||
| 85 | typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; | ||
| 86 | const kind = kindForDuration(windowDurationMins); | ||
| 87 | const resetsAt = isoFromEpochSeconds(window.resetsAt); | ||
| 88 | windows.push({ | ||
| 89 | id, | ||
| 90 | kind, | ||
| 91 | label: labelForKind(kind), | ||
| 92 | usedPercent: clampPercent(window.usedPercent), | ||
| 93 | windowDurationMins, | ||
| 94 | ...(resetsAt ? { resetsAt } : {}), | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | return windows; | ||
| 98 | } | ||
| 99 | |||
| 100 | export function codexResetCreditsToContract( | ||
| 101 | summary: CodexResetCreditsSummary | null | undefined, | ||
| 102 | ): ServerProviderResetCredits | undefined { | ||
| 103 | if (!summary) return undefined; | ||
| 104 | const expiries = (summary.credits ?? []) | ||
| 105 | .filter((credit) => credit.status === "available") | ||
| 106 | .map((credit) => credit.expiresAt) | ||
| 107 | .filter((value): value is number => typeof value === "number"); | ||
| 108 | const nextExpiresAt = | ||
| 109 | expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined; | ||
| 110 | return { | ||
| 111 | availableCount: Math.max(0, summary.availableCount), | ||
| 112 | ...(nextExpiresAt ? { nextExpiresAt } : {}), | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function codexRateLimitsToLimits(input: { | ||
| 117 | readonly snapshot: CodexRateLimitSnapshot; | ||
| 118 | readonly rateLimitsByLimitId?: | ||
| 119 | | Readonly<Record<string, CodexRateLimitSnapshot>> | ||
| 120 | | null | ||
| 121 | | undefined; | ||
| 122 | readonly resetCredits?: CodexResetCreditsSummary | null | undefined; | ||
| 123 | readonly checkedAt: string; | ||
| 124 | }): ServerProviderUsageLimits { | ||
| 125 | const resetCredits = codexResetCreditsToContract(input.resetCredits); | ||
| 126 | // Select the main bucket explicitly; the legacy snapshot can name another limit. | ||
| 127 | const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot); | ||
| 128 | return { | ||
| 129 | ...makeUsageLimits({ | ||
| 130 | checkedAt: input.checkedAt, | ||
| 131 | windows, | ||
| 132 | }), | ||
| 133 | ...(resetCredits ? { resetCredits } : {}), | ||
| 134 | }; | ||
| 135 | } | ||
| 136 | |||
| 137 | export function codexRateLimitsToUpdate( | ||
| 138 | snapshot: CodexRateLimitSnapshot, | ||
| 139 | ): ProviderUsageLimitsUpdate | undefined { | ||
| 140 | const windows = codexRateLimitsToWindows(snapshot); | ||
| 141 | return windows.length > 0 ? { windows } : undefined; | ||
| 142 | } | ||
| 143 | |||
| 144 | /** | ||
| 145 | * A bounded, client-safe reason for a failed `account/rateLimits/read`. The | ||
| 146 | * raw error is for the log; only the category and, for a JSON-RPC failure, | ||
| 147 | * the code reach the Limits view. | ||
| 148 | */ | ||
| 149 | export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { | ||
| 150 | switch (error._tag) { | ||
| 151 | case "CodexAppServerRequestError": | ||
| 152 | return `Codex could not read usage (JSON-RPC ${error.code}).`; | ||
| 153 | case "CodexAppServerSpawnError": | ||
| 154 | return "Codex could not be started to read usage."; | ||
| 155 | case "CodexAppServerProcessExitedError": | ||
| 156 | return "Codex exited before it could report usage."; | ||
| 157 | default: | ||
| 158 | return "Codex did not answer the usage request."; | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | /** | ||
| 163 | * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a | ||
| 164 | * field the update omits keeps the value observed earlier in the session, so a | ||
| 165 | * later notification that only names the limit it reached must not drop the | ||
| 166 | * windows an earlier one carried. | ||
| 167 | */ | ||
| 168 | export function mergeCodexRateLimits( | ||
| 169 | previous: CodexRateLimitSnapshot | undefined, | ||
| 170 | update: CodexRateLimitSnapshot, | ||
| 171 | ): CodexRateLimitSnapshot | undefined { | ||
| 172 | // Model-specific snapshots (such as Spark) describe a different allowance | ||
| 173 | // and must not overwrite the main one, the same rule the usage rows apply. | ||
| 174 | if (update.limitId && update.limitId !== "codex") return previous; | ||
| 175 | if (!previous) return update; | ||
| 176 | return { | ||
| 177 | ...previous, | ||
| 178 | ...(update.limitId !== undefined ? { limitId: update.limitId } : {}), | ||
| 179 | ...(update.planType !== undefined ? { planType: update.planType } : {}), | ||
| 180 | ...(update.rateLimitReachedType !== undefined | ||
| 181 | ? { rateLimitReachedType: update.rateLimitReachedType } | ||
| 182 | : {}), | ||
| 183 | ...(update.primary !== undefined ? { primary: update.primary } : {}), | ||
| 184 | ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | /** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */ | ||
| 189 | function formatCodexUsageLimitWait(waitMs: number): string { | ||
| 190 | const totalMinutes = Math.ceil(waitMs / 60_000); | ||
| 191 | const days = Math.floor(totalMinutes / (24 * 60)); | ||
| 192 | const hours = Math.floor((totalMinutes % (24 * 60)) / 60); | ||
| 193 | const minutes = totalMinutes % 60; | ||
| 194 | if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; | ||
| 195 | if (hours === 0) return `${totalMinutes}m`; | ||
| 196 | return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; | ||
| 197 | } | ||
| 198 | |||
| 199 | function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { | ||
| 200 | switch (rateLimitReachedType) { | ||
| 201 | case "workspace_owner_credits_depleted": | ||
| 202 | case "workspace_member_credits_depleted": | ||
| 203 | 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."; | ||
| 204 | case "workspace_owner_usage_limit_reached": | ||
| 205 | case "workspace_member_usage_limit_reached": | ||
| 206 | return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; | ||
| 207 | default: | ||
| 208 | return " Send the message again once the limit resets."; | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | /** | ||
| 213 | * The message a usage-limit stop shows instead of the provider sentence, which | ||
| 214 | * on a Business workspace blames credits for a window that simply ran out. The | ||
| 215 | * window named is the exhausted one that has yet to reset, latest first; `atIso` | ||
| 216 | * is the stopping event's timestamp, not the wall clock. | ||
| 217 | */ | ||
| 218 | export function codexUsageLimitMessage( | ||
| 219 | snapshot: CodexRateLimitSnapshot | undefined, | ||
| 220 | atIso: string, | ||
| 221 | ): string { | ||
| 222 | const atMs = Date.parse(atIso); | ||
| 223 | const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; | ||
| 224 | let reset = ""; | ||
| 225 | let latestResetMs = Number.NEGATIVE_INFINITY; | ||
| 226 | for (const window of windows) { | ||
| 227 | if (window.usedPercent < 100 || !window.resetsAt) continue; | ||
| 228 | const resetMs = Date.parse(window.resetsAt); | ||
| 229 | if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; | ||
| 230 | latestResetMs = resetMs; | ||
| 231 | reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; | ||
| 232 | } | ||
| 233 | return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; | ||
| 234 | } | ||
| 235 | |||
| 236 | /** All exhausted windows must reset before a continuation can run. */ | ||
| 237 | export function codexUsageLimitResetAt( | ||
| 238 | snapshot: CodexRateLimitSnapshot | undefined, | ||
| 239 | ): string | null { | ||
| 240 | if (!snapshot) return null; | ||
| 241 | const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100); | ||
| 242 | if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null; | ||
| 243 | return windows.reduce<string | null>( | ||
| 244 | (latest, window) => | ||
| 245 | latest === null || Date.parse(window.resetsAt!) > Date.parse(latest) | ||
| 246 | ? window.resetsAt! | ||
| 247 | : latest, | ||
| 248 | null, | ||
| 249 | ); | ||
| 250 | } | ||
apps/server/src/provider/Layers/cursorUsageLimits.test.ts deleted-264| ... | @@ -1,264 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { NodeServices } from "@effect/platform-node"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 8 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; | ||
| 9 | |||
| 10 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | ||
| 11 | effect.pipe(Effect.provide(NodeServices.layer)); | ||
| 12 | |||
| 13 | describe("Cursor usage limits", () => { | ||
| 14 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 15 | |||
| 16 | it("uses the advertised percentages and billing-cycle reset", () => { | ||
| 17 | const limits = cursorUsageResponseToLimits( | ||
| 18 | { | ||
| 19 | billingCycleEnd: "1789876386000", | ||
| 20 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, | ||
| 21 | }, | ||
| 22 | checkedAt, | ||
| 23 | ); | ||
| 24 | expect(limits.windows).toEqual( | ||
| 25 | expect.arrayContaining([ | ||
| 26 | { | ||
| 27 | id: "totalPercentUsed", | ||
| 28 | kind: "monthly", | ||
| 29 | label: "Overall", | ||
| 30 | usedPercent: 72.4, | ||
| 31 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | id: "autoPercentUsed", | ||
| 35 | kind: "monthly", | ||
| 36 | label: "Cursor Models", | ||
| 37 | usedPercent: 69.5, | ||
| 38 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | id: "apiPercentUsed", | ||
| 42 | kind: "monthly", | ||
| 43 | label: "Other Models", | ||
| 44 | usedPercent: 100, | ||
| 45 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 46 | }, | ||
| 47 | ]), | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("does not invent unused allowance for absent buckets", () => { | ||
| 52 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( | ||
| 53 | "unsupported", | ||
| 54 | ); | ||
| 55 | expect( | ||
| 56 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, | ||
| 57 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); | ||
| 58 | expect( | ||
| 59 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, | ||
| 60 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | yield* withNodeServices( | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const fs = yield* FileSystem.FileSystem; | ||
| 68 | const path = yield* Path.Path; | ||
| 69 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 70 | yield* fs.makeDirectory(path.join(directory, "cursor")); | ||
| 71 | yield* fs.writeFileString( | ||
| 72 | path.join(directory, "cursor", "auth.json"), | ||
| 73 | '{"accessToken":"instance-token"}', | ||
| 74 | ); | ||
| 75 | const client = HttpClient.make((request) => { | ||
| 76 | expect(request.url).toBe( | ||
| 77 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", | ||
| 78 | ); | ||
| 79 | expect(request.method).toBe("POST"); | ||
| 80 | expect(request.headers.authorization).toBe("Bearer instance-token"); | ||
| 81 | expect(request.headers["connect-protocol-version"]).toBe("1"); | ||
| 82 | return Effect.succeed( | ||
| 83 | HttpClientResponse.fromWeb( | ||
| 84 | request, | ||
| 85 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), | ||
| 86 | ), | ||
| 87 | ); | ||
| 88 | }); | ||
| 89 | yield* fs.makeDirectory(path.join(directory, ".cursor")); | ||
| 90 | yield* fs.writeFileString( | ||
| 91 | path.join(directory, ".cursor", "auth.json"), | ||
| 92 | '{"accessToken":"instance-token"}', | ||
| 93 | ); | ||
| 94 | for (const platform of ["linux", "darwin"] as const) { | ||
| 95 | const limits = yield* readCursorUsageLimits( | ||
| 96 | { apiEndpoint: "https://cursor.example/" }, | ||
| 97 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, | ||
| 98 | ).pipe( | ||
| 99 | Effect.provideService(HostProcessPlatform, platform), | ||
| 100 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 101 | ); | ||
| 102 | expect(limits.windows[0]?.usedPercent).toBe(42); | ||
| 103 | } | ||
| 104 | }).pipe(Effect.scoped), | ||
| 105 | ); | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 109 | it.effect( | ||
| 110 | "never reads stale files for keychain or memory logins, but accepts an explicit auth token", | ||
| 111 | () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | for (const platform of ["linux", "darwin"] as const) { | ||
| 114 | for (const token of [undefined, "explicit-token"]) { | ||
| 115 | const limits = yield* withNodeServices( | ||
| 116 | readCursorUsageLimits( | ||
| 117 | { apiEndpoint: "" }, | ||
| 118 | { | ||
| 119 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", | ||
| 120 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), | ||
| 121 | }, | ||
| 122 | false, | ||
| 123 | async () => { | ||
| 124 | throw new Error("must not read Keychain before opt-in"); | ||
| 125 | }, | ||
| 126 | ).pipe( | ||
| 127 | Effect.provideService(HostProcessPlatform, platform), | ||
| 128 | Effect.provideService( | ||
| 129 | FileSystem.FileSystem, | ||
| 130 | FileSystem.makeNoop({ | ||
| 131 | readFileString: () => Effect.die("must not read an unrelated credential file"), | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | Effect.provideService( | ||
| 135 | HttpClient.HttpClient, | ||
| 136 | HttpClient.make((request) => { | ||
| 137 | expect(token).toBe("explicit-token"); | ||
| 138 | expect(request.headers.authorization).toBe("Bearer explicit-token"); | ||
| 139 | return Effect.succeed( | ||
| 140 | HttpClientResponse.fromWeb( | ||
| 141 | request, | ||
| 142 | Response.json({ planUsage: { totalPercentUsed: 10 } }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); | ||
| 150 | else expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 151 | } | ||
| 152 | } | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("reports failed requests without exposing credentials or response bodies", () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const limits = yield* withNodeServices( | ||
| 159 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( | ||
| 160 | Effect.provideService( | ||
| 161 | HttpClient.HttpClient, | ||
| 162 | HttpClient.make((request) => | ||
| 163 | Effect.succeed( | ||
| 164 | HttpClientResponse.fromWeb( | ||
| 165 | request, | ||
| 166 | new Response("private response", { status: 401 }), | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | expect(limits.unavailable).toEqual({ | ||
| 174 | reason: "probeFailed", | ||
| 175 | message: "Cursor could not read usage limits.", | ||
| 176 | }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | it.effect("does not use a stored login for an explicit API key", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const limits = yield* withNodeServices( | ||
| 183 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( | ||
| 184 | Effect.provideService( | ||
| 185 | HttpClient.HttpClient, | ||
| 186 | HttpClient.make(() => Effect.die("must not request usage")), | ||
| 187 | ), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 191 | }), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const limits = yield* withNodeServices( | ||
| 197 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( | ||
| 198 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 199 | Effect.provideService( | ||
| 200 | FileSystem.FileSystem, | ||
| 201 | FileSystem.makeNoop({ | ||
| 202 | readFileString: () => Effect.die("must not read a stale credential file"), | ||
| 203 | }), | ||
| 204 | ), | ||
| 205 | Effect.provideService( | ||
| 206 | HttpClient.HttpClient, | ||
| 207 | HttpClient.make((request) => { | ||
| 208 | expect(request.headers.authorization).toBe("Bearer keychain-token"); | ||
| 209 | return Effect.succeed( | ||
| 210 | HttpClientResponse.fromWeb( | ||
| 211 | request, | ||
| 212 | Response.json({ planUsage: { totalPercentUsed: 42 } }), | ||
| 213 | ), | ||
| 214 | ); | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | expect(limits.windows[0]?.usedPercent).toBe(42); | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | |||
| 223 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const limits = yield* withNodeServices( | ||
| 226 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { | ||
| 227 | throw new Error("Keychain initialization failed"); | ||
| 228 | }).pipe( | ||
| 229 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 230 | Effect.provideService( | ||
| 231 | HttpClient.HttpClient, | ||
| 232 | HttpClient.make(() => Effect.die("must not request limits without a login")), | ||
| 233 | ), | ||
| 234 | ), | ||
| 235 | ); | ||
| 236 | expect(limits.unavailable?.reason).toBe("probeFailed"); | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | |||
| 240 | it.effect("does not read Keychain or send its token to a custom endpoint", () => | ||
| 241 | Effect.gen(function* () { | ||
| 242 | for (const [apiEndpoint, environment] of [ | ||
| 243 | ["http://localhost:3000", {}], | ||
| 244 | ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], | ||
| 245 | ["https://cursor-proxy.example", {}], | ||
| 246 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], | ||
| 247 | ] as const) { | ||
| 248 | const limits = yield* withNodeServices( | ||
| 249 | readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { | ||
| 250 | throw new Error("must not read Keychain for a custom endpoint"); | ||
| 251 | }).pipe( | ||
| 252 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 253 | Effect.provideService( | ||
| 254 | HttpClient.HttpClient, | ||
| 255 | HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | ); | ||
| 259 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 260 | expect(limits.unavailable?.message).toContain("default Cursor endpoint"); | ||
| 261 | } | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | }); | ||
apps/server/src/provider/Layers/cursorUsageLimits.ts deleted-151| ... | @@ -1,151 +0,0 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 12 | import { | ||
| 13 | clampPercent, | ||
| 14 | makeUnavailableUsageLimits, | ||
| 15 | makeUsageLimits, | ||
| 16 | } from "../providerUsageLimits.ts"; | ||
| 17 | import { readMacCursorAccessToken } from "../cursorKeychainToken.ts"; | ||
| 18 | |||
| 19 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | ||
| 20 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; | ||
| 21 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | ||
| 22 | const CursorUsageResponse = Schema.Struct({ | ||
| 23 | billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), | ||
| 24 | planUsage: Schema.optional( | ||
| 25 | Schema.Struct({ | ||
| 26 | totalPercentUsed: Schema.optional(Schema.Number), | ||
| 27 | autoPercentUsed: Schema.optional(Schema.Number), | ||
| 28 | apiPercentUsed: Schema.optional(Schema.Number), | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | }); | ||
| 32 | |||
| 33 | /** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ | ||
| 34 | export function cursorUsageResponseToLimits( | ||
| 35 | response: typeof CursorUsageResponse.Type, | ||
| 36 | checkedAt: string, | ||
| 37 | ) { | ||
| 38 | const reset = DateTime.make(Number(response.billingCycleEnd)); | ||
| 39 | const resetsAt = | ||
| 40 | Number(response.billingCycleEnd) > 0 && Option.isSome(reset) | ||
| 41 | ? DateTime.formatIso(reset.value) | ||
| 42 | : undefined; | ||
| 43 | const windows: ServerProviderUsageWindow[] = []; | ||
| 44 | if (response.planUsage) { | ||
| 45 | for (const { id, label } of CURSOR_USAGE_WINDOWS) { | ||
| 46 | const usedPercent = response.planUsage[id]; | ||
| 47 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; | ||
| 48 | windows.push({ | ||
| 49 | id, | ||
| 50 | kind: "monthly", | ||
| 51 | label, | ||
| 52 | usedPercent: clampPercent(usedPercent), | ||
| 53 | ...(resetsAt ? { resetsAt } : {}), | ||
| 54 | }); | ||
| 55 | } | ||
| 56 | } | ||
| 57 | return windows.length > 0 | ||
| 58 | ? makeUsageLimits({ checkedAt, windows }) | ||
| 59 | : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 60 | } | ||
| 61 | |||
| 62 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | ||
| 63 | settings: Pick<CursorSettings, "apiEndpoint">, | ||
| 64 | environment: NodeJS.ProcessEnv = process.env, | ||
| 65 | allowKeychain = false, | ||
| 66 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | ||
| 67 | ) { | ||
| 68 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 69 | return yield* Effect.gen(function* () { | ||
| 70 | const fs = yield* FileSystem.FileSystem; | ||
| 71 | const path = yield* Path.Path; | ||
| 72 | const platform = yield* HostProcessPlatform; | ||
| 73 | const endpoint = ( | ||
| 74 | settings.apiEndpoint?.trim() || | ||
| 75 | environment.CURSOR_API_ENDPOINT?.trim() || | ||
| 76 | DEFAULT_CURSOR_API_ENDPOINT | ||
| 77 | ).replace(/\/$/, ""); | ||
| 78 | let token = environment.CURSOR_AUTH_TOKEN?.trim(); | ||
| 79 | // An explicit API key can name a different account from the stored login. | ||
| 80 | if (!token && environment.CURSOR_API_KEY?.trim()) { | ||
| 81 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 82 | } | ||
| 83 | const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | ||
| 84 | if (!token && credentialStore === "memory") { | ||
| 85 | return makeUnavailableUsageLimits({ | ||
| 86 | checkedAt, | ||
| 87 | reason: "unsupported", | ||
| 88 | message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | if (!token && platform === "darwin" && credentialStore !== "file") { | ||
| 92 | if (!allowKeychain) { | ||
| 93 | return makeUnavailableUsageLimits({ | ||
| 94 | checkedAt, | ||
| 95 | reason: "unsupported", | ||
| 96 | message: "Enable Cursor account usage in T3 Code to read its Keychain login.", | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { | ||
| 100 | return makeUnavailableUsageLimits({ | ||
| 101 | checkedAt, | ||
| 102 | reason: "unsupported", | ||
| 103 | message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", | ||
| 104 | }); | ||
| 105 | } | ||
| 106 | token = (yield* Effect.tryPromise(keychainToken))?.trim(); | ||
| 107 | } else if (!token) { | ||
| 108 | const home = | ||
| 109 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | ||
| 110 | const directory = | ||
| 111 | platform === "win32" | ||
| 112 | ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | ||
| 113 | : platform === "darwin" | ||
| 114 | ? path.join(home, ".cursor") | ||
| 115 | : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | ||
| 116 | const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | ||
| 117 | Effect.catchTags({ | ||
| 118 | PlatformError: (error) => | ||
| 119 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 120 | }), | ||
| 121 | Effect.flatMap(decodeCredentials), | ||
| 122 | ); | ||
| 123 | token = credentials.accessToken?.trim(); | ||
| 124 | } | ||
| 125 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 126 | const client = yield* HttpClient.HttpClient; | ||
| 127 | const response = yield* client.execute( | ||
| 128 | HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( | ||
| 129 | HttpClientRequest.bearerToken(token), | ||
| 130 | HttpClientRequest.setHeaders({ | ||
| 131 | "connect-protocol-version": "1", | ||
| 132 | "x-cursor-client-type": "cli", | ||
| 133 | }), | ||
| 134 | HttpClientRequest.bodyJsonUnsafe({}), | ||
| 135 | ), | ||
| 136 | ); | ||
| 137 | const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( | ||
| 138 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 139 | ); | ||
| 140 | return cursorUsageResponseToLimits(body, checkedAt); | ||
| 141 | }).pipe( | ||
| 142 | Effect.timeout("10 seconds"), | ||
| 143 | Effect.orElseSucceed(() => | ||
| 144 | makeUnavailableUsageLimits({ | ||
| 145 | checkedAt, | ||
| 146 | reason: "probeFailed", | ||
| 147 | message: "Cursor could not read usage limits.", | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | }); | ||
apps/server/src/provider/Layers/grokUsageLimits.ts deleted-175| ... | @@ -1,175 +0,0 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 10 | import { | ||
| 11 | clampPercent, | ||
| 12 | makeUnavailableUsageLimits, | ||
| 13 | makeUsageLimits, | ||
| 14 | } from "../providerUsageLimits.ts"; | ||
| 15 | |||
| 16 | const GrokCredentials = Schema.Record( | ||
| 17 | Schema.String, | ||
| 18 | Schema.Struct({ | ||
| 19 | key: Schema.optional(Schema.String), | ||
| 20 | auth_mode: Schema.optional(Schema.String), | ||
| 21 | email: Schema.optional(Schema.String), | ||
| 22 | }), | ||
| 23 | ); | ||
| 24 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); | ||
| 25 | const GrokUsageResponse = Schema.Struct({ | ||
| 26 | config: Schema.optional( | ||
| 27 | Schema.Struct({ | ||
| 28 | creditUsagePercent: Schema.optional(Schema.Number), | ||
| 29 | currentPeriod: Schema.optional( | ||
| 30 | Schema.Struct({ | ||
| 31 | type: Schema.optional(Schema.String), | ||
| 32 | end: Schema.optional(Schema.String), | ||
| 33 | }), | ||
| 34 | ), | ||
| 35 | }), | ||
| 36 | ), | ||
| 37 | }); | ||
| 38 | |||
| 39 | export function grokUsageResponseToLimits( | ||
| 40 | response: typeof GrokUsageResponse.Type, | ||
| 41 | checkedAt: string, | ||
| 42 | ) { | ||
| 43 | const usedPercent = response.config?.creditUsagePercent; | ||
| 44 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) { | ||
| 45 | // A billing read that succeeded but carries no percentage is an account | ||
| 46 | // with nothing metered yet, not one that can never report: xAI omits the | ||
| 47 | // field entirely (rather than sending 0) until usage registers, then fills | ||
| 48 | // it in. Calling that `unsupported` would strand the account — the Limits | ||
| 49 | // view drops unsupported entries and deliberately mutes their notice, so a | ||
| 50 | // freshly signed-in Grok account would vanish with no explanation until it | ||
| 51 | // happened to be used, and `applyUsageLimitsUpdate` would refuse the | ||
| 52 | // mid-turn windows that could have recovered it. | ||
| 53 | return makeUsageLimits({ checkedAt, windows: [] }); | ||
| 54 | } | ||
| 55 | const period = response.config?.currentPeriod; | ||
| 56 | const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); | ||
| 57 | const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; | ||
| 58 | const reset = period?.end ? DateTime.make(period.end) : Option.none(); | ||
| 59 | const window: ServerProviderUsageWindow = { | ||
| 60 | id: "subscription", | ||
| 61 | kind, | ||
| 62 | label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", | ||
| 63 | usedPercent: clampPercent(usedPercent), | ||
| 64 | ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), | ||
| 65 | }; | ||
| 66 | return makeUsageLimits({ checkedAt, windows: [window] }); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * The grok.com login the CLI uses by default, or undefined when the CLI is | ||
| 71 | * configured to pick another account, endpoint, or an API key. | ||
| 72 | */ | ||
| 73 | const readGrokCredential = Effect.fn("readGrokCredential")(function* ( | ||
| 74 | environment: NodeJS.ProcessEnv, | ||
| 75 | ) { | ||
| 76 | // T3's ACP adapter explicitly selects API-key auth when this variable is set. | ||
| 77 | if (environment.XAI_API_KEY?.trim()) return undefined; | ||
| 78 | // Alternate auth deployments can select another scope or account from the same file. | ||
| 79 | if ( | ||
| 80 | [ | ||
| 81 | "GROK_OIDC_ISSUER", | ||
| 82 | "GROK_OIDC_CLIENT_ID", | ||
| 83 | "GROK_OAUTH2_ISSUER", | ||
| 84 | "GROK_OAUTH2_CLIENT_ID", | ||
| 85 | "GROK_OAUTH2_PRINCIPAL_TYPE", | ||
| 86 | "GROK_OAUTH2_PRINCIPAL_ID", | ||
| 87 | "GROK_AUTH_PROVIDER_COMMAND", | ||
| 88 | "GROK_LOCAL_AUTH", | ||
| 89 | "GROK_CLI_CHAT_PROXY_BASE_URL", | ||
| 90 | "GROK_MODELS_BASE_URL", | ||
| 91 | "GROK_CONFIG", | ||
| 92 | "GROK_CONFIG_PATH", | ||
| 93 | ].some((name) => environment[name]?.trim()) | ||
| 94 | ) { | ||
| 95 | return undefined; | ||
| 96 | } | ||
| 97 | const fs = yield* FileSystem.FileSystem; | ||
| 98 | const path = yield* Path.Path; | ||
| 99 | const home = | ||
| 100 | environment.GROK_HOME?.trim() || | ||
| 101 | path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); | ||
| 102 | for (const configPath of [ | ||
| 103 | path.join(home, "config.toml"), | ||
| 104 | path.join(home, "managed_config.toml"), | ||
| 105 | path.join(home, "requirements.toml"), | ||
| 106 | "/etc/grok/managed_config.toml", | ||
| 107 | "/etc/grok/requirements.toml", | ||
| 108 | ]) { | ||
| 109 | const config = yield* fs.readFileString(configPath).pipe( | ||
| 110 | Effect.catchTags({ | ||
| 111 | PlatformError: (error) => | ||
| 112 | error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. | ||
| 116 | if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { | ||
| 117 | return undefined; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | const contents = | ||
| 121 | environment.GROK_AUTH?.trim() || | ||
| 122 | (yield* fs.readFileString(path.join(home, "auth.json")).pipe( | ||
| 123 | Effect.catchTags({ | ||
| 124 | PlatformError: (error) => | ||
| 125 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 126 | }), | ||
| 127 | )); | ||
| 128 | const credentials = yield* decodeCredentials(contents); | ||
| 129 | // Never pick an arbitrary account from other deployments stored in the same file. | ||
| 130 | const credential = | ||
| 131 | credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? | ||
| 132 | credentials["https://accounts.x.ai/sign-in"]; | ||
| 133 | return credential?.auth_mode === "api_key" ? undefined : credential; | ||
| 134 | }); | ||
| 135 | |||
| 136 | /** | ||
| 137 | * Reads the default grok.com login once and reports its usage limits along with | ||
| 138 | * its email, so the email always names the account whose quota was read. | ||
| 139 | */ | ||
| 140 | export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( | ||
| 141 | environment: NodeJS.ProcessEnv = process.env, | ||
| 142 | ) { | ||
| 143 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 144 | const probeFailed = makeUnavailableUsageLimits({ | ||
| 145 | checkedAt, | ||
| 146 | reason: "probeFailed", | ||
| 147 | message: "Grok could not read usage limits.", | ||
| 148 | }); | ||
| 149 | const credential = yield* Effect.option( | ||
| 150 | readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), | ||
| 151 | ); | ||
| 152 | if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; | ||
| 153 | const email = credential.value?.email?.trim() || undefined; | ||
| 154 | const token = credential.value?.key?.trim(); | ||
| 155 | if (!token) { | ||
| 156 | return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; | ||
| 157 | } | ||
| 158 | // A failed quota request still knows which account it asked about. | ||
| 159 | const usageLimits = yield* Effect.gen(function* () { | ||
| 160 | const client = yield* HttpClient.HttpClient; | ||
| 161 | const response = yield* client.execute( | ||
| 162 | HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( | ||
| 163 | HttpClientRequest.bearerToken(token), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( | ||
| 167 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 168 | ); | ||
| 169 | return grokUsageResponseToLimits(body, checkedAt); | ||
| 170 | }).pipe( | ||
| 171 | Effect.timeout("10 seconds"), | ||
| 172 | Effect.orElseSucceed(() => probeFailed), | ||
| 173 | ); | ||
| 174 | return { email, usageLimits }; | ||
| 175 | }); | ||
apps/server/src/provider/Layers/openCodeUsageLimits.ts deleted-119| ... | @@ -1,119 +0,0 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | clampPercent, | ||
| 15 | makeUnavailableUsageLimits, | ||
| 16 | makeUsageLimits, | ||
| 17 | } from "../providerUsageLimits.ts"; | ||
| 18 | |||
| 19 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); | ||
| 20 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); | ||
| 21 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); | ||
| 22 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); | ||
| 23 | const UsageWindow = Schema.Struct({ | ||
| 24 | percent: Schema.Finite, | ||
| 25 | resetsAt: Schema.DateTimeUtcFromString, | ||
| 26 | }); | ||
| 27 | const UsageResponse = Schema.Struct({ | ||
| 28 | usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), | ||
| 29 | }); | ||
| 30 | |||
| 31 | /** External OpenCode servers own their credentials; never read the host's account for them. */ | ||
| 32 | export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { | ||
| 33 | readonly enabled: boolean; | ||
| 34 | readonly serverUrl: string; | ||
| 35 | readonly environment: NodeJS.ProcessEnv; | ||
| 36 | }) { | ||
| 37 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 38 | const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 39 | if (!input.enabled || input.serverUrl.trim()) return unsupported; | ||
| 40 | |||
| 41 | return yield* Effect.gen(function* () { | ||
| 42 | const fs = yield* FileSystem.FileSystem; | ||
| 43 | const path = yield* Path.Path; | ||
| 44 | const env = input.environment; | ||
| 45 | const dataHome = | ||
| 46 | env.XDG_DATA_HOME || | ||
| 47 | path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); | ||
| 48 | const authPath = path.join(dataHome, "opencode", "auth.json"); | ||
| 49 | const contents = | ||
| 50 | env.OPENCODE_AUTH_CONTENT || | ||
| 51 | (yield* fs.readFileString(authPath).pipe( | ||
| 52 | Effect.catchTags({ | ||
| 53 | PlatformError: (error) => | ||
| 54 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 55 | }), | ||
| 56 | )); | ||
| 57 | const auth = yield* decodeAuthFile(contents); | ||
| 58 | const apiAuth = decodeApiAuth(auth["opencode-go"]); | ||
| 59 | // OpenCode overlays stored API credentials after environment credentials. | ||
| 60 | const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); | ||
| 61 | if (!apiKey) return unsupported; | ||
| 62 | |||
| 63 | const client = yield* HttpClient.HttpClient; | ||
| 64 | const response = yield* client.execute( | ||
| 65 | HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( | ||
| 66 | HttpClientRequest.bearerToken(apiKey), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | // A valid Zen key can exist without a Go subscription. | ||
| 70 | if (response.status === 403) return unsupported; | ||
| 71 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 72 | Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), | ||
| 73 | ); | ||
| 74 | const windows: ServerProviderUsageWindow[] = [ | ||
| 75 | { | ||
| 76 | id: "go_rolling", | ||
| 77 | kind: "session", | ||
| 78 | label: "Go · Session", | ||
| 79 | windowDurationMins: 5 * 60, | ||
| 80 | usedPercent: clampPercent(body.usage.rolling.percent), | ||
| 81 | resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | id: "go_weekly", | ||
| 85 | kind: "weekly", | ||
| 86 | label: "Go · Weekly", | ||
| 87 | windowDurationMins: 7 * 24 * 60, | ||
| 88 | usedPercent: clampPercent(body.usage.weekly.percent), | ||
| 89 | resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), | ||
| 90 | }, | ||
| 91 | { | ||
| 92 | id: "go_monthly", | ||
| 93 | kind: "monthly", | ||
| 94 | label: "Go · Monthly", | ||
| 95 | usedPercent: clampPercent(body.usage.monthly.percent), | ||
| 96 | resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), | ||
| 97 | }, | ||
| 98 | ]; | ||
| 99 | return { | ||
| 100 | ...makeUsageLimits({ checkedAt, windows }), | ||
| 101 | // Go's usage response has no account ID. An unkeyed hash matches across | ||
| 102 | // environments without a shared secret. It permits offline guesses, but | ||
| 103 | // Go keys are randomly generated. | ||
| 104 | credentialFingerprint: NodeCrypto.createHash("sha256") | ||
| 105 | .update("opencode-go\0") | ||
| 106 | .update(apiKey) | ||
| 107 | .digest("hex"), | ||
| 108 | }; | ||
| 109 | }).pipe( | ||
| 110 | Effect.timeout("5 seconds"), | ||
| 111 | Effect.orElseSucceed(() => | ||
| 112 | makeUnavailableUsageLimits({ | ||
| 113 | checkedAt, | ||
| 114 | reason: "probeFailed", | ||
| 115 | message: "OpenCode Go could not read usage.", | ||
| 116 | }), | ||
| 117 | ), | ||
| 118 | ); | ||
| 119 | }); | ||
apps/server/src/provider/Layers/piThinkingCapabilities.test.ts deleted-52| ... | @@ -1,52 +0,0 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; | ||
| 4 | |||
| 5 | describe("thinkingCapabilitiesForPiModel", () => { | ||
| 6 | it("gives every choice its real level id and tags Pi's default", () => { | ||
| 7 | const capabilities = thinkingCapabilitiesForPiModel( | ||
| 8 | { | ||
| 9 | reasoning: true, | ||
| 10 | thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, | ||
| 11 | }, | ||
| 12 | "xhigh", | ||
| 13 | ); | ||
| 14 | const descriptors = capabilities.optionDescriptors ?? []; | ||
| 15 | const thinking = descriptors[0]; | ||
| 16 | assert.equal(thinking?.id, "thinking"); | ||
| 17 | assert.equal(thinking?.type, "select"); | ||
| 18 | if (thinking?.type !== "select") return; | ||
| 19 | assert.deepEqual( | ||
| 20 | thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), | ||
| 21 | [ | ||
| 22 | ["minimal", "Minimal", false], | ||
| 23 | ["low", "Low", false], | ||
| 24 | ["medium", "Medium", false], | ||
| 25 | ["high", "High", false], | ||
| 26 | ["xhigh", "Extra High", true], | ||
| 27 | ], | ||
| 28 | ); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("clamps Pi's default to each model's supported levels", () => { | ||
| 32 | const capabilities = thinkingCapabilitiesForPiModel( | ||
| 33 | { | ||
| 34 | reasoning: true, | ||
| 35 | thinkingLevelMap: { xhigh: "extra_high", max: null }, | ||
| 36 | }, | ||
| 37 | "max", | ||
| 38 | ); | ||
| 39 | const thinking = capabilities.optionDescriptors?.[0]; | ||
| 40 | assert.equal(thinking?.type, "select"); | ||
| 41 | if (thinking?.type !== "select") return; | ||
| 42 | assert.deepInclude(thinking.options, { | ||
| 43 | id: "xhigh", | ||
| 44 | label: "Extra High", | ||
| 45 | isDefault: true, | ||
| 46 | }); | ||
| 47 | assert.notInclude( | ||
| 48 | thinking.options.map((option) => option.id), | ||
| 49 | "max", | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/server/src/provider/Layers/piThinkingCapabilities.ts deleted-97| ... | @@ -1,97 +0,0 @@ | ||
| 1 | import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; | ||
| 2 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 3 | import * as Predicate from "effect/Predicate"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per | ||
| 7 | * model via `thinkingLevelMap`; advertising them globally makes | ||
| 8 | * `set_thinking_level` fail on models that lack them. | ||
| 9 | */ | ||
| 10 | const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; | ||
| 11 | |||
| 12 | export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; | ||
| 13 | |||
| 14 | const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = { | ||
| 15 | off: "Off", | ||
| 16 | minimal: "Minimal", | ||
| 17 | low: "Low", | ||
| 18 | medium: "Medium", | ||
| 19 | high: "High", | ||
| 20 | xhigh: "Extra High", | ||
| 21 | max: "Max", | ||
| 22 | }; | ||
| 23 | |||
| 24 | export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 25 | optionDescriptors: [], | ||
| 26 | }); | ||
| 27 | |||
| 28 | export function thinkingCapabilitiesForPiModel( | ||
| 29 | model: unknown, | ||
| 30 | defaultThinkingLevel: unknown, | ||
| 31 | ): ModelCapabilities { | ||
| 32 | const levels = supportedPiThinkingLevelsFromModel(model); | ||
| 33 | if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; | ||
| 34 | const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); | ||
| 35 | return createModelCapabilities({ | ||
| 36 | optionDescriptors: [ | ||
| 37 | { | ||
| 38 | id: "thinking", | ||
| 39 | label: "Thinking", | ||
| 40 | type: "select", | ||
| 41 | options: levels.map((level): ProviderOptionChoice => ({ | ||
| 42 | id: level, | ||
| 43 | label: PI_THINKING_LEVEL_LABELS[level], | ||
| 44 | ...(level === defaultLevel ? { isDefault: true } : {}), | ||
| 45 | })), | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | |||
| 51 | /** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ | ||
| 52 | function clampPiThinkingLevel( | ||
| 53 | input: unknown, | ||
| 54 | availableLevels: ReadonlyArray<PiThinkingLevel>, | ||
| 55 | ): PiThinkingLevel | undefined { | ||
| 56 | if (typeof input !== "string") return undefined; | ||
| 57 | const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); | ||
| 58 | if (requestedIndex === -1) return undefined; | ||
| 59 | const exact = availableLevels.find((level) => level === input); | ||
| 60 | if (exact !== undefined) return exact; | ||
| 61 | for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { | ||
| 62 | const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | ||
| 63 | if (higher !== undefined) return higher; | ||
| 64 | } | ||
| 65 | for (let index = requestedIndex - 1; index >= 0; index -= 1) { | ||
| 66 | const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | ||
| 67 | if (lower !== undefined) return lower; | ||
| 68 | } | ||
| 69 | return availableLevels[0]; | ||
| 70 | } | ||
| 71 | |||
| 72 | /** | ||
| 73 | * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. | ||
| 74 | * | ||
| 75 | * A reasoning model always exposes off through high unless a map entry is | ||
| 76 | * `null`. Extra High and Max appear only when the map has a non-null entry. | ||
| 77 | */ | ||
| 78 | function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> { | ||
| 79 | if (recordField(model, "reasoning") !== true) return []; | ||
| 80 | const thinkingLevelMap = thinkingLevelMapFromModel(model); | ||
| 81 | return PI_THINKING_LEVELS.filter((level) => { | ||
| 82 | const mapped = thinkingLevelMap?.[level]; | ||
| 83 | if (mapped === null) return false; | ||
| 84 | if (level === "xhigh" || level === "max") return mapped !== undefined; | ||
| 85 | return true; | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined { | ||
| 90 | const value = recordField(model, "thinkingLevelMap"); | ||
| 91 | if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; | ||
| 92 | return value as Record<string, unknown>; | ||
| 93 | } | ||
| 94 | |||
| 95 | function recordField(input: unknown, key: string): unknown { | ||
| 96 | return Predicate.isObject(input) ? input[key] : undefined; | ||
| 97 | } | ||
apps/server/src/provider/Layers/resetCreditCoordinator.test.ts deleted-105| ... | @@ -1,105 +0,0 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 8 | |||
| 9 | describe("ResetCreditCoordinator", () => { | ||
| 10 | it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => | ||
| 11 | Effect.gen(function* () { | ||
| 12 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 13 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 14 | const attempts = yield* Ref.make(0); | ||
| 15 | const consume = (key: string) => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | yield* Ref.update(keys, (seen) => [...seen, key]); | ||
| 18 | const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); | ||
| 19 | if (attempt === 1) return yield* Effect.fail("timed out" as const); | ||
| 20 | return "reset" as const; | ||
| 21 | }); | ||
| 22 | |||
| 23 | const first = yield* redeem("acct", consume).pipe(Effect.result); | ||
| 24 | assert.isTrue(first._tag === "Failure"); | ||
| 25 | const second = yield* redeem("acct", consume); | ||
| 26 | assert.strictEqual(second, "reset"); | ||
| 27 | // A fresh redemption after success must be a fresh attempt. | ||
| 28 | yield* redeem("acct", consume); | ||
| 29 | |||
| 30 | const seen = yield* Ref.get(keys); | ||
| 31 | assert.strictEqual(seen.length, 3); | ||
| 32 | assert.strictEqual(seen[0], seen[1]); | ||
| 33 | assert.notStrictEqual(seen[1], seen[2]); | ||
| 34 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 35 | ); | ||
| 36 | |||
| 37 | it.effect("starts a fresh attempt after a settled failure", () => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 40 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 41 | const consume = (key: string) => | ||
| 42 | Ref.update(keys, (seen) => [...seen, key]).pipe( | ||
| 43 | Effect.andThen(Effect.fail("cooldown" as const)), | ||
| 44 | ); | ||
| 45 | const isSettled = (error: "cooldown") => error === "cooldown"; | ||
| 46 | |||
| 47 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); | ||
| 48 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); | ||
| 49 | |||
| 50 | const seen = yield* Ref.get(keys); | ||
| 51 | assert.strictEqual(seen.length, 2); | ||
| 52 | assert.notStrictEqual(seen[0], seen[1]); | ||
| 53 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("serialises concurrent redemptions on the same account, not per caller", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 59 | const release = yield* Deferred.make<void>(); | ||
| 60 | const inFlight = yield* Ref.make(0); | ||
| 61 | const peak = yield* Ref.make(0); | ||
| 62 | const consume = () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); | ||
| 65 | yield* Ref.update(peak, (p) => Math.max(p, now)); | ||
| 66 | yield* Deferred.await(release); | ||
| 67 | yield* Ref.update(inFlight, (n) => n - 1); | ||
| 68 | return "reset" as const; | ||
| 69 | }); | ||
| 70 | |||
| 71 | // Two instances of the same account redeem at once. | ||
| 72 | const a = yield* redeem("acct", consume).pipe(Effect.forkChild); | ||
| 73 | const b = yield* redeem("acct", consume).pipe(Effect.forkChild); | ||
| 74 | yield* Effect.yieldNow; | ||
| 75 | yield* Deferred.succeed(release, undefined); | ||
| 76 | yield* Fiber.join(a); | ||
| 77 | yield* Fiber.join(b); | ||
| 78 | |||
| 79 | assert.strictEqual(yield* Ref.get(peak), 1); | ||
| 80 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 81 | ); | ||
| 82 | |||
| 83 | it.effect("keeps different accounts independent", () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 86 | const release = yield* Deferred.make<void>(); | ||
| 87 | const peak = yield* Ref.make(0); | ||
| 88 | const inFlight = yield* Ref.make(0); | ||
| 89 | const consume = () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); | ||
| 92 | yield* Ref.update(peak, (p) => Math.max(p, now)); | ||
| 93 | yield* Deferred.await(release); | ||
| 94 | return "reset" as const; | ||
| 95 | }); | ||
| 96 | const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); | ||
| 97 | const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); | ||
| 98 | yield* Effect.yieldNow; | ||
| 99 | yield* Deferred.succeed(release, undefined); | ||
| 100 | yield* Fiber.join(a); | ||
| 101 | yield* Fiber.join(b); | ||
| 102 | assert.strictEqual(yield* Ref.get(peak), 2); | ||
| 103 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 104 | ); | ||
| 105 | }); | ||
apps/server/src/provider/Layers/resetCreditCoordinator.ts deleted-111| ... | @@ -1,111 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Redeeming a reset credit is an account-level action: instances that share | ||
| 3 | * the directory holding a provider's login share the credit, so their | ||
| 4 | * redemptions must serialise on that directory, not the instance. This | ||
| 5 | * service keeps one lock and one pending idempotency key per account key so | ||
| 6 | * overlapping confirmations from any instance queue rather than spending two | ||
| 7 | * credits, and a retry after a timeout re-sends the same attempt. | ||
| 8 | * | ||
| 9 | * @module provider/Layers/resetCreditCoordinator | ||
| 10 | */ | ||
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import type * as PlatformError from "effect/PlatformError"; | ||
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Semaphore from "effect/Semaphore"; | ||
| 19 | |||
| 20 | interface AccountRedemptionState { | ||
| 21 | readonly lock: Semaphore.Semaphore; | ||
| 22 | readonly pendingKey: Ref.Ref<string | null>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export class ResetCreditCoordinator extends Context.Service< | ||
| 26 | ResetCreditCoordinator, | ||
| 27 | { | ||
| 28 | /** | ||
| 29 | * Run `consume` under the account's lock with a stable idempotency key. | ||
| 30 | * The key is cleared when the provider reports an outcome, or when | ||
| 31 | * `isSettled` says a failure was a final answer (such as a cooldown). | ||
| 32 | * Any other failure (timeout included) keeps it so the next attempt is | ||
| 33 | * the same attempt. | ||
| 34 | */ | ||
| 35 | readonly redeem: <E, R>( | ||
| 36 | accountKey: string, | ||
| 37 | consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>, | ||
| 38 | isSettled?: (error: E) => boolean, | ||
| 39 | ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>; | ||
| 40 | } | ||
| 41 | >()("t3/provider/Layers/resetCreditCoordinator") {} | ||
| 42 | |||
| 43 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 44 | export const make = Effect.gen(function* () { | ||
| 45 | const crypto = yield* Crypto.Crypto; | ||
| 46 | const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map()); | ||
| 47 | |||
| 48 | // Get-or-create through one Ref.modify so two first redemptions for the | ||
| 49 | // same account cannot each install their own lock. | ||
| 50 | const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { | ||
| 51 | const existing = (yield* Ref.get(statesRef)).get(accountKey); | ||
| 52 | if (existing) return existing; | ||
| 53 | const candidate = { | ||
| 54 | lock: yield* Semaphore.make(1), | ||
| 55 | pendingKey: yield* Ref.make<string | null>(null), | ||
| 56 | }; | ||
| 57 | return yield* Ref.modify(statesRef, (states) => { | ||
| 58 | const current = states.get(accountKey); | ||
| 59 | if (current) return [current, states] as const; | ||
| 60 | const next = new Map(states); | ||
| 61 | next.set(accountKey, candidate); | ||
| 62 | return [candidate, next] as const; | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const state = yield* stateFor(accountKey); | ||
| 69 | return yield* state.lock.withPermits(1)( | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const existing = yield* Ref.get(state.pendingKey); | ||
| 72 | const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); | ||
| 73 | yield* Ref.set(state.pendingKey, idempotencyKey); | ||
| 74 | const outcome = yield* consume(idempotencyKey).pipe( | ||
| 75 | Effect.tapError((error) => | ||
| 76 | isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, | ||
| 77 | ), | ||
| 78 | ); | ||
| 79 | yield* Ref.set(state.pendingKey, null); | ||
| 80 | return outcome; | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | }); | ||
| 84 | |||
| 85 | return { redeem } satisfies ResetCreditCoordinator["Service"]; | ||
| 86 | }); | ||
| 87 | |||
| 88 | export const layer = Layer.effect(ResetCreditCoordinator, make); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Self-contained for tests: a counter-backed Crypto so keys are deterministic | ||
| 92 | * and distinct without the platform layer. | ||
| 93 | */ | ||
| 94 | export const layerTest = Layer.effect( | ||
| 95 | ResetCreditCoordinator, | ||
| 96 | Effect.gen(function* () { | ||
| 97 | let counter = 0; | ||
| 98 | return yield* make.pipe( | ||
| 99 | Effect.provideService( | ||
| 100 | Crypto.Crypto, | ||
| 101 | Crypto.make({ | ||
| 102 | randomBytes: (size) => { | ||
| 103 | counter += 1; | ||
| 104 | return new Uint8Array(size).fill(counter); | ||
| 105 | }, | ||
| 106 | digest: (_algorithm, data) => Effect.succeed(data), | ||
| 107 | }), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | }), | ||
| 111 | ); | ||
apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs deleted-66| ... | @@ -1,66 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeReadline from "node:readline"; | ||
| 4 | const args = process.argv.slice(2); | ||
| 5 | const mcpConfigIndex = args.indexOf("--mcp-config"); | ||
| 6 | const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; | ||
| 7 | let mcpConfig; | ||
| 8 | if (rawMcpConfig) { | ||
| 9 | const contents = NodeFS.existsSync(rawMcpConfig) | ||
| 10 | ? NodeFS.readFileSync(rawMcpConfig, "utf8") | ||
| 11 | : rawMcpConfig; | ||
| 12 | try { | ||
| 13 | mcpConfig = JSON.parse(contents); | ||
| 14 | } catch { | ||
| 15 | mcpConfig = contents; | ||
| 16 | } | ||
| 17 | } | ||
| 18 | NodeFS.writeFileSync( | ||
| 19 | process.env.T3_PROBE_INVOCATION_PATH, | ||
| 20 | JSON.stringify({ | ||
| 21 | args, | ||
| 22 | cwd: process.cwd(), | ||
| 23 | connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, | ||
| 24 | mcpConfig, | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | const lines = NodeReadline.createInterface({ input: process.stdin }); | ||
| 28 | lines.on("line", (line) => { | ||
| 29 | const message = JSON.parse(line); | ||
| 30 | if (message.type !== "control_request") return; | ||
| 31 | const reply = (response) => | ||
| 32 | process.stdout.write( | ||
| 33 | JSON.stringify({ | ||
| 34 | type: "control_response", | ||
| 35 | response: { subtype: "success", request_id: message.request_id, response }, | ||
| 36 | }) + "\n", | ||
| 37 | ); | ||
| 38 | if (message.request?.subtype === "initialize") { | ||
| 39 | reply({ | ||
| 40 | commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], | ||
| 41 | agents: [], | ||
| 42 | output_style: "default", | ||
| 43 | available_output_styles: ["default"], | ||
| 44 | models: [], | ||
| 45 | account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 46 | }); | ||
| 47 | } | ||
| 48 | // The probe follows initialize with get_usage on the same process. | ||
| 49 | if (message.request?.subtype === "get_usage") { | ||
| 50 | reply({ | ||
| 51 | session: {}, | ||
| 52 | subscription_type: "pro", | ||
| 53 | rate_limits_available: true, | ||
| 54 | rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, | ||
| 55 | behaviors: null, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | }); | ||
| 59 | // Stay alive for follow-up control requests, but never outlive the | ||
| 60 | // parent: the probe aborts the SDK without awaiting the child, so an | ||
| 61 | // unconditional interval would strand this process until reboot. | ||
| 62 | const keepAlive = setInterval(() => {}, 1_000); | ||
| 63 | lines.on("close", () => { | ||
| 64 | clearInterval(keepAlive); | ||
| 65 | process.exit(0); | ||
| 66 | }); | ||
apps/server/src/provider/Layers/testing/ProviderInstanceRegistryLive.fixture.mjs deleted-71| ... | @@ -1,71 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeReadline from "node:readline"; | ||
| 4 | if (process.argv.includes("--version")) { | ||
| 5 | process.stdout.write("claude 2.1.219\n"); | ||
| 6 | process.exit(0); | ||
| 7 | } | ||
| 8 | const lines = NodeReadline.createInterface({ input: process.stdin }); | ||
| 9 | lines.on("line", (line) => { | ||
| 10 | const message = JSON.parse(line); | ||
| 11 | if (message.type !== "control_request") return; | ||
| 12 | if (message.request?.subtype === "get_usage") { | ||
| 13 | const marker = process.env.T3_CLAUDE_RESET_MARKER; | ||
| 14 | if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { | ||
| 15 | process.stdout.write( | ||
| 16 | JSON.stringify({ | ||
| 17 | type: "control_response", | ||
| 18 | response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, | ||
| 19 | }) + "\n", | ||
| 20 | ); | ||
| 21 | return; | ||
| 22 | } | ||
| 23 | process.stdout.write( | ||
| 24 | JSON.stringify({ | ||
| 25 | type: "control_response", | ||
| 26 | response: { | ||
| 27 | subtype: "success", | ||
| 28 | request_id: message.request_id, | ||
| 29 | response: { | ||
| 30 | session: {}, | ||
| 31 | subscription_type: "pro", | ||
| 32 | rate_limits_available: true, | ||
| 33 | rate_limits: { | ||
| 34 | five_hour: { | ||
| 35 | utilization: marker && NodeFS.existsSync(marker) ? 0 : 100, | ||
| 36 | resets_at: null, | ||
| 37 | }, | ||
| 38 | }, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | }) + "\n", | ||
| 42 | ); | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | if (message.request?.subtype !== "initialize") return; | ||
| 46 | process.stdout.write( | ||
| 47 | JSON.stringify({ | ||
| 48 | type: "control_response", | ||
| 49 | response: { | ||
| 50 | subtype: "success", | ||
| 51 | request_id: message.request_id, | ||
| 52 | response: { | ||
| 53 | commands: [], | ||
| 54 | agents: [], | ||
| 55 | models: [], | ||
| 56 | output_style: "default", | ||
| 57 | available_output_styles: ["default"], | ||
| 58 | account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 59 | }, | ||
| 60 | }, | ||
| 61 | }) + "\n", | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | // Stay alive for follow-up control requests, but never outlive the | ||
| 65 | // parent: the probe aborts the SDK without awaiting the child, so an | ||
| 66 | // unconditional interval would strand this process until reboot. | ||
| 67 | const keepAlive = setInterval(() => {}, 1_000); | ||
| 68 | lines.on("close", () => { | ||
| 69 | clearInterval(keepAlive); | ||
| 70 | process.exit(0); | ||
| 71 | }); | ||
apps/server/src/provider/OpenCodeProvider.test.ts created+873| ... | @@ -0,0 +1,873 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { it } from "@effect/vitest"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 14 | import { beforeEach } from "vite-plus/test"; | ||
| 15 | |||
| 16 | import { OpenCodeSettings } from "@t3tools/contracts"; | ||
| 17 | import * as ServerConfig from "../config.ts"; | ||
| 18 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | ||
| 19 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | ||
| 20 | import { | ||
| 21 | checkOpenCodeProviderStatus, | ||
| 22 | loadOpenCode2Workspace, | ||
| 23 | makeOpenCode2ModelLoader, | ||
| 24 | type OpenCode2Model, | ||
| 25 | type OpenCode2Workspace, | ||
| 26 | openCode2CommandsToServerProviderSlashCommands, | ||
| 27 | openCode2SkillsToServerProviderSkills, | ||
| 28 | openCodeCommandsToServerProviderSlashCommands, | ||
| 29 | } from "./OpenCodeProvider.ts"; | ||
| 30 | import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; | ||
| 31 | import { probeOpenCodeRuntime } from "./opencodeVersionProbe.ts"; | ||
| 32 | import { | ||
| 33 | OPENCODE_1_RESPONSES, | ||
| 34 | OPENCODE_2_RESPONSES, | ||
| 35 | replayOpenCodeServer, | ||
| 36 | } from "./testFixtures/opencodeProbeResponses.ts"; | ||
| 37 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | ||
| 38 | |||
| 39 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | ||
| 40 | |||
| 41 | it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const resetsAt = "2026-09-17T12:00:00.000Z"; | ||
| 44 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 45 | enabled: true, | ||
| 46 | serverUrl: "", | ||
| 47 | environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, | ||
| 48 | }).pipe( | ||
| 49 | Effect.provideService( | ||
| 50 | FileSystem.FileSystem, | ||
| 51 | FileSystem.makeNoop({ | ||
| 52 | readFileString: (path) => { | ||
| 53 | NodeAssert.equal(path, "/instance/data/opencode/auth.json"); | ||
| 54 | return Effect.succeed( | ||
| 55 | JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), | ||
| 56 | ); | ||
| 57 | }, | ||
| 58 | }), | ||
| 59 | ), | ||
| 60 | Effect.provideService( | ||
| 61 | HttpClient.HttpClient, | ||
| 62 | HttpClient.make((request) => { | ||
| 63 | NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); | ||
| 64 | NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); | ||
| 65 | return Effect.succeed( | ||
| 66 | HttpClientResponse.fromWeb( | ||
| 67 | request, | ||
| 68 | Response.json({ | ||
| 69 | usage: { | ||
| 70 | rolling: { percent: 0, resetsAt }, | ||
| 71 | weekly: { percent: 10, resetsAt }, | ||
| 72 | monthly: { percent: 125, resetsAt }, | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | }), | ||
| 78 | ), | ||
| 79 | Effect.provide(NodeServices.layer), | ||
| 80 | ); | ||
| 81 | NodeAssert.equal(limits.unavailable, undefined); | ||
| 82 | NodeAssert.equal( | ||
| 83 | limits.credentialFingerprint, | ||
| 84 | NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), | ||
| 85 | ); | ||
| 86 | NodeAssert.deepEqual( | ||
| 87 | limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ | ||
| 88 | kind, | ||
| 89 | usedPercent, | ||
| 90 | reset, | ||
| 91 | })), | ||
| 92 | [ | ||
| 93 | { kind: "session", usedPercent: 0, reset: resetsAt }, | ||
| 94 | { kind: "weekly", usedPercent: 10, reset: resetsAt }, | ||
| 95 | { kind: "monthly", usedPercent: 100, reset: resetsAt }, | ||
| 96 | ], | ||
| 97 | ); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("does not read local credentials for external or disabled OpenCode instances", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | for (const settings of [ | ||
| 104 | { enabled: true, serverUrl: "https://remote.example" }, | ||
| 105 | { enabled: false, serverUrl: "" }, | ||
| 106 | ]) { | ||
| 107 | const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( | ||
| 108 | Effect.provideService( | ||
| 109 | FileSystem.FileSystem, | ||
| 110 | FileSystem.makeNoop({ | ||
| 111 | readFileString: () => Effect.die("unexpected credential read"), | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | Effect.provideService( | ||
| 115 | HttpClient.HttpClient, | ||
| 116 | HttpClient.make(() => Effect.die("unexpected usage request")), | ||
| 117 | ), | ||
| 118 | Effect.provide(NodeServices.layer), | ||
| 119 | ); | ||
| 120 | NodeAssert.equal(limits.unavailable?.reason, "unsupported"); | ||
| 121 | } | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | for (const [status, reason] of [ | ||
| 128 | [403, "unsupported"], | ||
| 129 | [401, "probeFailed"], | ||
| 130 | [200, "probeFailed"], | ||
| 131 | ] as const) { | ||
| 132 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 133 | enabled: true, | ||
| 134 | serverUrl: "", | ||
| 135 | environment: { | ||
| 136 | OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', | ||
| 137 | }, | ||
| 138 | }).pipe( | ||
| 139 | Effect.provideService( | ||
| 140 | FileSystem.FileSystem, | ||
| 141 | FileSystem.makeNoop({ | ||
| 142 | readFileString: () => Effect.die("inline credentials must bypass disk"), | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | Effect.provideService( | ||
| 146 | HttpClient.HttpClient, | ||
| 147 | HttpClient.make((request) => | ||
| 148 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | Effect.provide(NodeServices.layer), | ||
| 152 | ); | ||
| 153 | NodeAssert.equal(limits.unavailable?.reason, reason); | ||
| 154 | NodeAssert.equal(limits.credentialFingerprint, undefined); | ||
| 155 | NodeAssert.deepEqual(limits.windows, []); | ||
| 156 | } | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | /** | ||
| 161 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | ||
| 162 | * are deleted. The snapshot-producing logic they wrapped now lives in the | ||
| 163 | * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which | ||
| 164 | * drivers call directly when building their per-instance snapshot | ||
| 165 | * `ServerProviderShape`. Tests mirror that shape: build a settings payload, | ||
| 166 | * invoke the check, assert on the returned snapshot. | ||
| 167 | */ | ||
| 168 | |||
| 169 | const runtimeMock = { | ||
| 170 | state: { | ||
| 171 | runVersionError: null as Error | null, | ||
| 172 | runVersionPending: false, | ||
| 173 | versionStdout: DEFAULT_VERSION_STDOUT, | ||
| 174 | inventoryError: null as Error | null, | ||
| 175 | connectionError: null as Error | null, | ||
| 176 | inventoryCwd: null as string | null, | ||
| 177 | closeCalls: 0, | ||
| 178 | sdkClientInputs: [] as Array<{ | ||
| 179 | baseUrl: string; | ||
| 180 | directory: string; | ||
| 181 | serverPassword?: string; | ||
| 182 | }>, | ||
| 183 | inventory: { | ||
| 184 | providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, | ||
| 185 | agents: [] as unknown[], | ||
| 186 | skills: [] as unknown[], | ||
| 187 | } as unknown, | ||
| 188 | }, | ||
| 189 | reset() { | ||
| 190 | this.state.runVersionError = null; | ||
| 191 | this.state.runVersionPending = false; | ||
| 192 | this.state.versionStdout = DEFAULT_VERSION_STDOUT; | ||
| 193 | this.state.inventoryError = null; | ||
| 194 | this.state.connectionError = null; | ||
| 195 | this.state.inventoryCwd = null; | ||
| 196 | this.state.closeCalls = 0; | ||
| 197 | this.state.sdkClientInputs.length = 0; | ||
| 198 | this.state.inventory = { | ||
| 199 | providerList: { connected: [], all: [] as unknown[], default: {} }, | ||
| 200 | agents: [] as unknown[], | ||
| 201 | skills: [] as unknown[], | ||
| 202 | }; | ||
| 203 | }, | ||
| 204 | }; | ||
| 205 | |||
| 206 | const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { | ||
| 207 | startOpenCodeServerProcess: ({ serverPassword, environment }) => | ||
| 208 | Effect.gen(function* () { | ||
| 209 | yield* Effect.addFinalizer(() => | ||
| 210 | Effect.sync(() => { | ||
| 211 | runtimeMock.state.closeCalls += 1; | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ | ||
| 215 | external: false, | ||
| 216 | ...(serverPassword !== undefined ? { serverPassword } : {}), | ||
| 217 | ...(environment !== undefined ? { environment } : {}), | ||
| 218 | }); | ||
| 219 | return { | ||
| 220 | url: "http://127.0.0.1:4301", | ||
| 221 | ...(effectiveServerPassword !== undefined | ||
| 222 | ? { serverPassword: effectiveServerPassword } | ||
| 223 | : {}), | ||
| 224 | version: "1.14.19", | ||
| 225 | isRunning: Effect.succeed(true), | ||
| 226 | exitCode: Effect.never, | ||
| 227 | }; | ||
| 228 | }), | ||
| 229 | connectToOpenCodeServer: ({ serverUrl, serverPassword }) => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | if (runtimeMock.state.connectionError) { | ||
| 232 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 233 | operation: "global.health", | ||
| 234 | detail: runtimeMock.state.connectionError.message, | ||
| 235 | cause: runtimeMock.state.connectionError, | ||
| 236 | }); | ||
| 237 | } | ||
| 238 | if (!serverUrl) { | ||
| 239 | yield* Effect.addFinalizer(() => | ||
| 240 | Effect.sync(() => { | ||
| 241 | runtimeMock.state.closeCalls += 1; | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | return { | ||
| 246 | url: serverUrl ?? "http://127.0.0.1:4301", | ||
| 247 | ...(serverPassword ? { serverPassword } : {}), | ||
| 248 | version: "1.14.19", | ||
| 249 | exitCode: null, | ||
| 250 | external: Boolean(serverUrl), | ||
| 251 | }; | ||
| 252 | }), | ||
| 253 | runOpenCodeCommand: () => | ||
| 254 | runtimeMock.state.runVersionPending | ||
| 255 | ? Effect.never | ||
| 256 | : runtimeMock.state.runVersionError | ||
| 257 | ? Effect.fail( | ||
| 258 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 259 | operation: "runOpenCodeCommand", | ||
| 260 | detail: runtimeMock.state.runVersionError.message, | ||
| 261 | cause: runtimeMock.state.runVersionError, | ||
| 262 | }), | ||
| 263 | ) | ||
| 264 | : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), | ||
| 265 | createOpenCodeSdkClient: (input) => { | ||
| 266 | runtimeMock.state.sdkClientInputs.push(input); | ||
| 267 | return {} as unknown as ReturnType< | ||
| 268 | OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"] | ||
| 269 | >; | ||
| 270 | }, | ||
| 271 | loadOpenCodeInventory: () => | ||
| 272 | runtimeMock.state.inventoryError | ||
| 273 | ? Effect.fail( | ||
| 274 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 275 | operation: "loadOpenCodeInventory", | ||
| 276 | detail: runtimeMock.state.inventoryError.message, | ||
| 277 | cause: runtimeMock.state.inventoryError, | ||
| 278 | }), | ||
| 279 | ) | ||
| 280 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory), | ||
| 281 | loadInventoryFromCli: ({ cwd }) => { | ||
| 282 | runtimeMock.state.inventoryCwd = cwd; | ||
| 283 | return runtimeMock.state.inventoryError | ||
| 284 | ? Effect.fail( | ||
| 285 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 286 | operation: "loadInventoryFromCli", | ||
| 287 | detail: runtimeMock.state.inventoryError.message, | ||
| 288 | cause: runtimeMock.state.inventoryError, | ||
| 289 | }), | ||
| 290 | ) | ||
| 291 | : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory); | ||
| 292 | }, | ||
| 293 | loadOpenCodeSkills: () => Effect.succeed([]), | ||
| 294 | loadSkillsFromCli: () => Effect.succeed([]), | ||
| 295 | }; | ||
| 296 | |||
| 297 | beforeEach(() => { | ||
| 298 | runtimeMock.reset(); | ||
| 299 | }); | ||
| 300 | |||
| 301 | it("keeps native and MCP commands while preserving compaction and separate skills", () => { | ||
| 302 | NodeAssert.deepEqual( | ||
| 303 | openCodeCommandsToServerProviderSlashCommands([ | ||
| 304 | { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] }, | ||
| 305 | { name: "review", source: "command", hints: [] }, | ||
| 306 | { name: "compact", source: "command", hints: [] }, | ||
| 307 | { name: "skill", source: "skill", hints: [] }, | ||
| 308 | { name: "mcp:search", source: "mcp", hints: ["query"] }, | ||
| 309 | ]).slice(1), | ||
| 310 | [ | ||
| 311 | { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } }, | ||
| 312 | { name: "mcp:search", input: { hint: "query" } }, | ||
| 313 | ], | ||
| 314 | ); | ||
| 315 | }); | ||
| 316 | |||
| 317 | const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( | ||
| 318 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | ||
| 319 | Layer.provideMerge(NodeServices.layer), | ||
| 320 | ); | ||
| 321 | |||
| 322 | const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings => | ||
| 323 | decodeOpenCodeSettings({ | ||
| 324 | enabled: true, | ||
| 325 | binaryPath: "opencode", | ||
| 326 | serverUrl: "", | ||
| 327 | serverPassword: "", | ||
| 328 | customModels: [], | ||
| 329 | ...overrides, | ||
| 330 | }); | ||
| 331 | |||
| 332 | const checkProvider = Effect.fn("checkProvider")(function* ( | ||
| 333 | settings: OpenCodeSettings, | ||
| 334 | cwd = process.cwd(), | ||
| 335 | environment?: NodeJS.ProcessEnv, | ||
| 336 | server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword), | ||
| 337 | openCode2Models: Effect.Effect< | ||
| 338 | ReadonlyArray<OpenCode2Model>, | ||
| 339 | OpenCodeRuntime.OpenCodeRuntimeError | ||
| 340 | > = Effect.succeed([]), | ||
| 341 | ) { | ||
| 342 | return yield* Effect.scoped( | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const serverOwner = yield* OpenCodeServerOwner.make({ | ||
| 345 | binaryPath: settings.binaryPath, | ||
| 346 | directory: cwd, | ||
| 347 | ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), | ||
| 348 | ...(environment ? { environment } : {}), | ||
| 349 | }); | ||
| 350 | const probe = probeOpenCodeRuntime(settings, environment).pipe( | ||
| 351 | Effect.provideService(HttpClient.HttpClient, server), | ||
| 352 | Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble), | ||
| 353 | ); | ||
| 354 | return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe( | ||
| 355 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | ||
| 356 | ); | ||
| 357 | }), | ||
| 358 | ); | ||
| 359 | }); | ||
| 360 | |||
| 361 | it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { | ||
| 362 | it.effect("shows a codex-style missing binary message", () => | ||
| 363 | Effect.gen(function* () { | ||
| 364 | runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | ||
| 365 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 366 | |||
| 367 | NodeAssert.equal(snapshot.status, "error"); | ||
| 368 | NodeAssert.equal(snapshot.installed, false); | ||
| 369 | NodeAssert.equal( | ||
| 370 | snapshot.message, | ||
| 371 | "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 372 | ); | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | |||
| 376 | it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | ||
| 377 | Effect.gen(function* () { | ||
| 378 | runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | ||
| 379 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 380 | |||
| 381 | NodeAssert.equal(snapshot.status, "error"); | ||
| 382 | NodeAssert.equal(snapshot.installed, true); | ||
| 383 | NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); | ||
| 384 | }), | ||
| 385 | ); | ||
| 386 | |||
| 387 | it.effect("times out a hanging local CLI version probe", () => | ||
| 388 | Effect.gen(function* () { | ||
| 389 | runtimeMock.state.runVersionPending = true; | ||
| 390 | const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild); | ||
| 391 | |||
| 392 | yield* Effect.yieldNow; | ||
| 393 | yield* TestClock.adjust("4 seconds"); | ||
| 394 | const snapshot = yield* Fiber.join(probeFiber); | ||
| 395 | |||
| 396 | NodeAssert.equal(snapshot.status, "error"); | ||
| 397 | NodeAssert.equal(snapshot.installed, true); | ||
| 398 | NodeAssert.equal( | ||
| 399 | snapshot.message, | ||
| 400 | "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.", | ||
| 401 | ); | ||
| 402 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 403 | ); | ||
| 404 | |||
| 405 | it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | runtimeMock.state.inventory = { | ||
| 408 | providerList: { | ||
| 409 | connected: ["openai"], | ||
| 410 | all: [ | ||
| 411 | { | ||
| 412 | id: "openai", | ||
| 413 | name: "OpenAI", | ||
| 414 | models: { | ||
| 415 | "gpt-5.4": { | ||
| 416 | id: "gpt-5.4", | ||
| 417 | name: "GPT-5.4", | ||
| 418 | variants: { | ||
| 419 | none: {}, | ||
| 420 | low: {}, | ||
| 421 | medium: {}, | ||
| 422 | high: {}, | ||
| 423 | xhigh: {}, | ||
| 424 | }, | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | }, | ||
| 428 | ], | ||
| 429 | default: {}, | ||
| 430 | }, | ||
| 431 | agents: [ | ||
| 432 | { name: "build", hidden: false, mode: "primary" }, | ||
| 433 | { name: "plan", hidden: false, mode: "primary" }, | ||
| 434 | ], | ||
| 435 | }; | ||
| 436 | |||
| 437 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 438 | const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); | ||
| 439 | |||
| 440 | NodeAssert.ok(model); | ||
| 441 | const variantDescriptor = model.capabilities?.optionDescriptors?.find( | ||
| 442 | (descriptor) => descriptor.id === "variant" && descriptor.type === "select", | ||
| 443 | ); | ||
| 444 | NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); | ||
| 445 | NodeAssert.equal( | ||
| 446 | variantDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 447 | "medium", | ||
| 448 | ); | ||
| 449 | const agentDescriptor = model.capabilities?.optionDescriptors?.find( | ||
| 450 | (descriptor) => descriptor.id === "agent" && descriptor.type === "select", | ||
| 451 | ); | ||
| 452 | NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); | ||
| 453 | NodeAssert.equal( | ||
| 454 | agentDescriptor.options.find((option) => option.isDefault === true)?.id, | ||
| 455 | "build", | ||
| 456 | ); | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | |||
| 460 | it.effect("includes OpenCode skills in the provider snapshot", () => | ||
| 461 | Effect.gen(function* () { | ||
| 462 | runtimeMock.state.inventory = { | ||
| 463 | providerList: { | ||
| 464 | connected: ["openai"], | ||
| 465 | all: [ | ||
| 466 | { | ||
| 467 | id: "openai", | ||
| 468 | name: "OpenAI", | ||
| 469 | models: { | ||
| 470 | "gpt-5.4": { | ||
| 471 | id: "gpt-5.4", | ||
| 472 | name: "GPT-5.4", | ||
| 473 | variants: {}, | ||
| 474 | }, | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | ], | ||
| 478 | default: {}, | ||
| 479 | }, | ||
| 480 | agents: [], | ||
| 481 | skills: [ | ||
| 482 | { | ||
| 483 | name: "openclaw-review", | ||
| 484 | description: "Review OpenClaw workflow changes.", | ||
| 485 | location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 486 | }, | ||
| 487 | { | ||
| 488 | name: "openclaw-triage", | ||
| 489 | description: "Triage OpenClaw routing issues.", | ||
| 490 | location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 491 | }, | ||
| 492 | { | ||
| 493 | name: "missing-location", | ||
| 494 | description: "This incomplete SDK row should be skipped.", | ||
| 495 | location: "", | ||
| 496 | }, | ||
| 497 | ], | ||
| 498 | }; | ||
| 499 | |||
| 500 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 501 | |||
| 502 | NodeAssert.deepEqual( | ||
| 503 | snapshot.skills.map((skill) => ({ | ||
| 504 | name: skill.name, | ||
| 505 | path: skill.path, | ||
| 506 | enabled: skill.enabled, | ||
| 507 | shortDescription: skill.shortDescription, | ||
| 508 | })), | ||
| 509 | [ | ||
| 510 | { | ||
| 511 | name: "openclaw-review", | ||
| 512 | path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", | ||
| 513 | enabled: true, | ||
| 514 | shortDescription: "Review OpenClaw workflow changes.", | ||
| 515 | }, | ||
| 516 | { | ||
| 517 | name: "openclaw-triage", | ||
| 518 | path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", | ||
| 519 | enabled: true, | ||
| 520 | shortDescription: "Triage OpenClaw routing issues.", | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | ); | ||
| 524 | }), | ||
| 525 | ); | ||
| 526 | |||
| 527 | it.effect("loads local inventory from a scoped OpenCode server", () => | ||
| 528 | Effect.gen(function* () { | ||
| 529 | yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); | ||
| 530 | |||
| 531 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 532 | { | ||
| 533 | baseUrl: "http://127.0.0.1:4301", | ||
| 534 | directory: process.cwd(), | ||
| 535 | serverPassword: "secret-password", | ||
| 536 | }, | ||
| 537 | ]); | ||
| 538 | NodeAssert.equal(runtimeMock.state.closeCalls, 1); | ||
| 539 | NodeAssert.equal(runtimeMock.state.inventoryCwd, null); | ||
| 540 | }), | ||
| 541 | ); | ||
| 542 | |||
| 543 | it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () => | ||
| 544 | Effect.gen(function* () { | ||
| 545 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | ||
| 546 | const snapshot = yield* checkProvider( | ||
| 547 | makeOpenCodeSettings(), | ||
| 548 | process.cwd(), | ||
| 549 | undefined, | ||
| 550 | undefined, | ||
| 551 | Effect.succeed([ | ||
| 552 | { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] }, | ||
| 553 | { | ||
| 554 | providerID: "opencode", | ||
| 555 | id: "space-bunny-free", | ||
| 556 | name: "Space Bunny Free", | ||
| 557 | variants: [{ id: "low" }, { id: "medium" }, { id: "high" }], | ||
| 558 | }, | ||
| 559 | ]), | ||
| 560 | ); | ||
| 561 | |||
| 562 | NodeAssert.equal(snapshot.status, "ready"); | ||
| 563 | NodeAssert.equal(snapshot.version, "2.0.18"); | ||
| 564 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | ||
| 565 | "approval-required", | ||
| 566 | "auto-accept-edits", | ||
| 567 | "auto", | ||
| 568 | "full-access", | ||
| 569 | ]); | ||
| 570 | // Plan mode runs as OpenCode's plan agent, and every session can compact. | ||
| 571 | NodeAssert.equal(snapshot.showInteractionModeToggle, true); | ||
| 572 | NodeAssert.deepEqual( | ||
| 573 | snapshot.slashCommands.map((command) => command.name), | ||
| 574 | ["compact"], | ||
| 575 | ); | ||
| 576 | NodeAssert.deepEqual( | ||
| 577 | snapshot.models.map((model) => model.slug), | ||
| 578 | ["opencode/big-pickle", "opencode/space-bunny-free"], | ||
| 579 | ); | ||
| 580 | const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free") | ||
| 581 | ?.capabilities?.optionDescriptors?.[0]; | ||
| 582 | NodeAssert.deepEqual( | ||
| 583 | variant?.type === "select" ? variant.options.map((option) => option.id) : [], | ||
| 584 | ["low", "medium", "high"], | ||
| 585 | ); | ||
| 586 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | |||
| 590 | it.effect("reports a failed OpenCode 2 model list without the server's response", () => | ||
| 591 | Effect.gen(function* () { | ||
| 592 | runtimeMock.state.versionStdout = "opencode v2.0.18\n"; | ||
| 593 | const snapshot = yield* checkProvider( | ||
| 594 | makeOpenCodeSettings(), | ||
| 595 | process.cwd(), | ||
| 596 | undefined, | ||
| 597 | undefined, | ||
| 598 | Effect.fail( | ||
| 599 | new OpenCodeRuntime.OpenCodeRuntimeError({ | ||
| 600 | operation: "model.list", | ||
| 601 | detail: 'status=500 body={"token":"leaked-response-body"}', | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ); | ||
| 605 | |||
| 606 | NodeAssert.equal(snapshot.status, "error"); | ||
| 607 | NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); | ||
| 608 | // A failed catalog read still leaves the session able to compact. | ||
| 609 | NodeAssert.deepEqual( | ||
| 610 | snapshot.slashCommands.map((command) => command.name), | ||
| 611 | ["compact"], | ||
| 612 | ); | ||
| 613 | }), | ||
| 614 | ); | ||
| 615 | |||
| 616 | it.effect("uses an environment-only password for local inventory", () => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { | ||
| 619 | OPENCODE_SERVER_PASSWORD: "environment-password", | ||
| 620 | }); | ||
| 621 | |||
| 622 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 623 | { | ||
| 624 | baseUrl: "http://127.0.0.1:4301", | ||
| 625 | directory: process.cwd(), | ||
| 626 | serverPassword: "environment-password", | ||
| 627 | }, | ||
| 628 | ]); | ||
| 629 | }), | ||
| 630 | ); | ||
| 631 | |||
| 632 | it.effect("uses the settings password when local environment auth differs", () => | ||
| 633 | Effect.gen(function* () { | ||
| 634 | yield* checkProvider( | ||
| 635 | makeOpenCodeSettings({ serverPassword: "settings-password" }), | ||
| 636 | process.cwd(), | ||
| 637 | { OPENCODE_SERVER_PASSWORD: "environment-password" }, | ||
| 638 | ); | ||
| 639 | |||
| 640 | NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); | ||
| 641 | }), | ||
| 642 | ); | ||
| 643 | |||
| 644 | it.effect("reports local model inventory failures without treating them as empty", () => | ||
| 645 | Effect.gen(function* () { | ||
| 646 | runtimeMock.state.inventoryError = new Error("opencode models failed"); | ||
| 647 | const snapshot = yield* checkProvider(makeOpenCodeSettings()); | ||
| 648 | |||
| 649 | NodeAssert.equal(snapshot.status, "error"); | ||
| 650 | NodeAssert.equal(snapshot.installed, true); | ||
| 651 | NodeAssert.equal(snapshot.models.length, 0); | ||
| 652 | NodeAssert.equal( | ||
| 653 | snapshot.message, | ||
| 654 | "Failed to load OpenCode provider inventory: opencode models failed", | ||
| 655 | ); | ||
| 656 | }), | ||
| 657 | ); | ||
| 658 | }); | ||
| 659 | |||
| 660 | it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => { | ||
| 661 | it.effect("does not send a local environment password to a configured server", () => | ||
| 662 | Effect.gen(function* () { | ||
| 663 | const snapshot = yield* checkProvider( | ||
| 664 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | ||
| 665 | process.cwd(), | ||
| 666 | { OPENCODE_SERVER_PASSWORD: "local-secret" }, | ||
| 667 | ); | ||
| 668 | |||
| 669 | NodeAssert.equal(snapshot.version, "1.14.19"); | ||
| 670 | NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ | ||
| 671 | { | ||
| 672 | baseUrl: "http://127.0.0.1:9999", | ||
| 673 | directory: process.cwd(), | ||
| 674 | }, | ||
| 675 | ]); | ||
| 676 | }), | ||
| 677 | ); | ||
| 678 | |||
| 679 | it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () => | ||
| 680 | Effect.gen(function* () { | ||
| 681 | const settings = makeOpenCodeSettings({ | ||
| 682 | serverUrl: "http://127.0.0.1:9999", | ||
| 683 | serverPassword: "secret-password", | ||
| 684 | }); | ||
| 685 | const snapshot = yield* checkProvider( | ||
| 686 | settings, | ||
| 687 | process.cwd(), | ||
| 688 | undefined, | ||
| 689 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | ||
| 690 | ); | ||
| 691 | |||
| 692 | NodeAssert.equal(snapshot.status, "warning"); | ||
| 693 | NodeAssert.equal(snapshot.version, "2.0.18"); | ||
| 694 | NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [ | ||
| 695 | "approval-required", | ||
| 696 | "auto-accept-edits", | ||
| 697 | "auto", | ||
| 698 | "full-access", | ||
| 699 | ]); | ||
| 700 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 701 | }), | ||
| 702 | ); | ||
| 703 | |||
| 704 | it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => | ||
| 705 | Effect.gen(function* () { | ||
| 706 | const snapshot = yield* checkProvider( | ||
| 707 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }), | ||
| 708 | process.cwd(), | ||
| 709 | undefined, | ||
| 710 | replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), | ||
| 711 | ); | ||
| 712 | |||
| 713 | NodeAssert.equal(snapshot.status, "error"); | ||
| 714 | NodeAssert.equal( | ||
| 715 | snapshot.message, | ||
| 716 | "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 717 | ); | ||
| 718 | }), | ||
| 719 | ); | ||
| 720 | |||
| 721 | it.effect("rejects an unsupported server before loading inventory", () => | ||
| 722 | Effect.gen(function* () { | ||
| 723 | runtimeMock.state.connectionError = new Error( | ||
| 724 | "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", | ||
| 725 | ); | ||
| 726 | const snapshot = yield* checkProvider( | ||
| 727 | makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), | ||
| 728 | ); | ||
| 729 | |||
| 730 | NodeAssert.equal(snapshot.status, "error"); | ||
| 731 | NodeAssert.equal(snapshot.models.length, 0); | ||
| 732 | NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); | ||
| 733 | NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); | ||
| 734 | }), | ||
| 735 | ); | ||
| 736 | |||
| 737 | it.effect("surfaces a friendly auth error for configured servers", () => | ||
| 738 | Effect.gen(function* () { | ||
| 739 | runtimeMock.state.connectionError = new Error("401 Unauthorized"); | ||
| 740 | const snapshot = yield* checkProvider( | ||
| 741 | makeOpenCodeSettings({ | ||
| 742 | serverUrl: "http://127.0.0.1:9999", | ||
| 743 | serverPassword: "secret-password", | ||
| 744 | }), | ||
| 745 | ); | ||
| 746 | |||
| 747 | NodeAssert.equal(snapshot.status, "error"); | ||
| 748 | NodeAssert.equal(snapshot.installed, true); | ||
| 749 | NodeAssert.equal( | ||
| 750 | snapshot.message, | ||
| 751 | "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 752 | ); | ||
| 753 | }), | ||
| 754 | ); | ||
| 755 | |||
| 756 | it.effect("surfaces a friendly connection error for configured servers", () => | ||
| 757 | Effect.gen(function* () { | ||
| 758 | runtimeMock.state.connectionError = new Error( | ||
| 759 | "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | ||
| 760 | ); | ||
| 761 | const snapshot = yield* checkProvider( | ||
| 762 | makeOpenCodeSettings({ | ||
| 763 | serverUrl: "http://127.0.0.1:9999", | ||
| 764 | serverPassword: "secret-password", | ||
| 765 | }), | ||
| 766 | ); | ||
| 767 | |||
| 768 | NodeAssert.equal(snapshot.status, "error"); | ||
| 769 | NodeAssert.equal(snapshot.installed, true); | ||
| 770 | NodeAssert.equal( | ||
| 771 | snapshot.message, | ||
| 772 | "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.", | ||
| 773 | ); | ||
| 774 | }), | ||
| 775 | ); | ||
| 776 | }); | ||
| 777 | |||
| 778 | const bigPickle: OpenCode2Model = { | ||
| 779 | providerID: "opencode", | ||
| 780 | id: "big-pickle", | ||
| 781 | name: "Big Pickle", | ||
| 782 | variants: [], | ||
| 783 | }; | ||
| 784 | |||
| 785 | it.effect("waits for a fresh OpenCode 2 server to list its models", () => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | // A fresh server lists nothing until its catalog loads. | ||
| 788 | const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]]; | ||
| 789 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); | ||
| 790 | const fiber = yield* load.pipe(Effect.forkChild); | ||
| 791 | yield* TestClock.adjust("1 second"); | ||
| 792 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | ||
| 793 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 794 | ); | ||
| 795 | |||
| 796 | it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () => | ||
| 797 | Effect.gen(function* () { | ||
| 798 | let listed: ReadonlyArray<OpenCode2Model> = [bigPickle]; | ||
| 799 | const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed)); | ||
| 800 | NodeAssert.deepEqual(yield* load, [bigPickle]); | ||
| 801 | listed = []; | ||
| 802 | const fiber = yield* load.pipe(Effect.forkChild); | ||
| 803 | yield* TestClock.adjust("6 seconds"); | ||
| 804 | NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]); | ||
| 805 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 806 | ); | ||
| 807 | |||
| 808 | // What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). | ||
| 809 | const builtinCommands = [ | ||
| 810 | { name: "init", description: "guided AGENTS.md setup" }, | ||
| 811 | { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, | ||
| 812 | ]; | ||
| 813 | const builtinSkills = [ | ||
| 814 | { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" }, | ||
| 815 | { id: "report", name: "Report", path: "/builtin/report.md" }, | ||
| 816 | ]; | ||
| 817 | const scanned: OpenCode2Workspace = { | ||
| 818 | commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }], | ||
| 819 | skills: [ | ||
| 820 | ...builtinSkills, | ||
| 821 | { | ||
| 822 | id: "greet", | ||
| 823 | name: "greet", | ||
| 824 | path: "/work/.opencode/skills/greet/SKILL.md", | ||
| 825 | description: "Greets the user with the secret word MANGO.", | ||
| 826 | }, | ||
| 827 | ], | ||
| 828 | }; | ||
| 829 | |||
| 830 | it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => | ||
| 831 | Effect.gen(function* () { | ||
| 832 | // A directory the server has not served yet lists no commands until its scan ends. | ||
| 833 | const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned]; | ||
| 834 | const scanEnded = yield* Deferred.make<void>(); | ||
| 835 | const load = yield* loadOpenCode2Workspace( | ||
| 836 | Effect.sync(() => reads.shift()!), | ||
| 837 | Deferred.await(scanEnded), | ||
| 838 | ).pipe(Effect.forkChild); | ||
| 839 | yield* Effect.yieldNow; | ||
| 840 | NodeAssert.equal(reads.length, 1); | ||
| 841 | yield* Deferred.succeed(scanEnded, undefined); | ||
| 842 | NodeAssert.deepEqual(yield* Fiber.join(load), scanned); | ||
| 843 | }), | ||
| 844 | ); | ||
| 845 | |||
| 846 | it.effect("takes a served OpenCode 2 directory's first listing as is", () => | ||
| 847 | Effect.gen(function* () { | ||
| 848 | const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never); | ||
| 849 | NodeAssert.deepEqual(workspace, scanned); | ||
| 850 | }), | ||
| 851 | ); | ||
| 852 | |||
| 853 | it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => { | ||
| 854 | NodeAssert.deepEqual( | ||
| 855 | openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [ | ||
| 856 | skill.name, | ||
| 857 | skill.displayName, | ||
| 858 | skill.path, | ||
| 859 | ]), | ||
| 860 | [ | ||
| 861 | ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"], | ||
| 862 | ["opencode", "OpenCode", "/builtin/opencode.md"], | ||
| 863 | ["report", "Report", "/builtin/report.md"], | ||
| 864 | ], | ||
| 865 | ); | ||
| 866 | NodeAssert.deepEqual( | ||
| 867 | openCode2CommandsToServerProviderSlashCommands([ | ||
| 868 | ...scanned.commands, | ||
| 869 | { name: "compact", description: "a workspace command named like T3's own" }, | ||
| 870 | ]).map((command) => command.name), | ||
| 871 | ["compact", "init", "review", "hello"], | ||
| 872 | ); | ||
| 873 | }); | ||
apps/server/src/provider/OpenCodeProvider.ts created+747| ... | @@ -0,0 +1,747 @@ | ||
| 1 | import { | ||
| 2 | type ModelCapabilities, | ||
| 3 | type OpenCodeSettings, | ||
| 4 | type ServerProviderModel, | ||
| 5 | type ServerProviderSkill, | ||
| 6 | type ServerProviderSlashCommand, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Cause from "effect/Cause"; | ||
| 9 | import * as Data from "effect/Data"; | ||
| 10 | import * as DateTime from "effect/DateTime"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Schedule from "effect/Schedule"; | ||
| 13 | |||
| 14 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 16 | import { | ||
| 17 | buildServerProvider, | ||
| 18 | COMPACT_SLASH_COMMAND, | ||
| 19 | nonEmptyTrimmed, | ||
| 20 | providerModelsFromSettings, | ||
| 21 | type ServerProviderDraft, | ||
| 22 | } from "./providerSnapshot.ts"; | ||
| 23 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | ||
| 24 | import type { ProbedOpenCode } from "./opencodeVersionProbe.ts"; | ||
| 25 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | ||
| 26 | import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; | ||
| 27 | |||
| 28 | const OPENCODE_PRESENTATION = { | ||
| 29 | displayName: "OpenCode", | ||
| 30 | showInteractionModeToggle: false, | ||
| 31 | } as const; | ||
| 32 | |||
| 33 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ | ||
| 34 | readonly cause?: unknown; | ||
| 35 | readonly detail: string; | ||
| 36 | }> {} | ||
| 37 | |||
| 38 | function normalizeProbeMessage(message: string): string | undefined { | ||
| 39 | const trimmed = message.trim(); | ||
| 40 | if (trimmed.length === 0) { | ||
| 41 | return undefined; | ||
| 42 | } | ||
| 43 | if ( | ||
| 44 | trimmed === "An error occurred in Effect.tryPromise" || | ||
| 45 | trimmed === "An error occurred in Effect.try" | ||
| 46 | ) { | ||
| 47 | return undefined; | ||
| 48 | } | ||
| 49 | return trimmed; | ||
| 50 | } | ||
| 51 | |||
| 52 | function normalizedErrorMessage(cause: unknown): string | undefined { | ||
| 53 | if (cause instanceof OpenCodeProbeError) { | ||
| 54 | return normalizeProbeMessage(cause.detail); | ||
| 55 | } | ||
| 56 | |||
| 57 | if (!(cause instanceof Error)) { | ||
| 58 | return undefined; | ||
| 59 | } | ||
| 60 | |||
| 61 | return normalizeProbeMessage(cause.message); | ||
| 62 | } | ||
| 63 | |||
| 64 | function formatOpenCodeProbeError(input: { | ||
| 65 | readonly cause: unknown; | ||
| 66 | readonly isExternalServer: boolean; | ||
| 67 | readonly phase: "version" | "inventory"; | ||
| 68 | readonly serverUrl: string; | ||
| 69 | }): { readonly installed: boolean; readonly message: string } { | ||
| 70 | const detail = normalizedErrorMessage(input.cause); | ||
| 71 | const lower = detail?.toLowerCase() ?? ""; | ||
| 72 | |||
| 73 | if (input.isExternalServer) { | ||
| 74 | if ( | ||
| 75 | lower.includes("401") || | ||
| 76 | lower.includes("403") || | ||
| 77 | lower.includes("unauthorized") || | ||
| 78 | lower.includes("forbidden") | ||
| 79 | ) { | ||
| 80 | return { | ||
| 81 | installed: true, | ||
| 82 | message: "OpenCode server rejected authentication. Check the server URL and password.", | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | if ( | ||
| 87 | lower.includes("econnrefused") || | ||
| 88 | lower.includes("enotfound") || | ||
| 89 | lower.includes("fetch failed") || | ||
| 90 | lower.includes("networkerror") || | ||
| 91 | lower.includes("timed out") || | ||
| 92 | lower.includes("timeout") || | ||
| 93 | lower.includes("socket hang up") | ||
| 94 | ) { | ||
| 95 | return { | ||
| 96 | installed: true, | ||
| 97 | message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | |||
| 101 | return { | ||
| 102 | installed: true, | ||
| 103 | message: detail ?? "Failed to connect to the configured OpenCode server.", | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | |||
| 107 | if (lower.includes("enoent") || lower.includes("notfound")) { | ||
| 108 | return { | ||
| 109 | installed: false, | ||
| 110 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 111 | }; | ||
| 112 | } | ||
| 113 | |||
| 114 | if (lower.includes("quarantine")) { | ||
| 115 | return { | ||
| 116 | installed: true, | ||
| 117 | message: | ||
| 118 | "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | if (lower.includes("invalid code signature") || lower.includes("corrupted")) { | ||
| 123 | return { | ||
| 124 | installed: true, | ||
| 125 | message: | ||
| 126 | "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", | ||
| 127 | }; | ||
| 128 | } | ||
| 129 | |||
| 130 | const failureLabel = | ||
| 131 | input.phase === "inventory" | ||
| 132 | ? "Failed to load OpenCode provider inventory" | ||
| 133 | : "Failed to execute OpenCode CLI health check"; | ||
| 134 | return { | ||
| 135 | installed: true, | ||
| 136 | message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | function titleCaseSlug(value: string): string { | ||
| 141 | const segments: Array<string> = []; | ||
| 142 | for (const segment of value.split(/[-_/]+/)) { | ||
| 143 | if (segment.length > 0) { | ||
| 144 | segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); | ||
| 145 | } | ||
| 146 | } | ||
| 147 | return segments.join(" "); | ||
| 148 | } | ||
| 149 | |||
| 150 | function inferDefaultVariant( | ||
| 151 | providerID: string, | ||
| 152 | variants: ReadonlyArray<string>, | ||
| 153 | ): string | undefined { | ||
| 154 | if (variants.length === 1) { | ||
| 155 | return variants[0]; | ||
| 156 | } | ||
| 157 | if (providerID === "anthropic" || providerID.startsWith("google")) { | ||
| 158 | return variants.includes("high") ? "high" : undefined; | ||
| 159 | } | ||
| 160 | if (providerID === "openai" || providerID === "opencode") { | ||
| 161 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | ||
| 162 | } | ||
| 163 | return undefined; | ||
| 164 | } | ||
| 165 | |||
| 166 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { | ||
| 167 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; | ||
| 168 | } | ||
| 169 | |||
| 170 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 171 | optionDescriptors: [ | ||
| 172 | { | ||
| 173 | id: "variant", | ||
| 174 | label: "Reasoning", | ||
| 175 | type: "select", | ||
| 176 | options: [ | ||
| 177 | { id: "low", label: "Low" }, | ||
| 178 | { id: "medium", label: "Medium", isDefault: true }, | ||
| 179 | { id: "high", label: "High" }, | ||
| 180 | { id: "xhigh", label: "Extra High" }, | ||
| 181 | ], | ||
| 182 | currentValue: "medium", | ||
| 183 | }, | ||
| 184 | { | ||
| 185 | id: "agent", | ||
| 186 | label: "Agent", | ||
| 187 | type: "select", | ||
| 188 | options: [ | ||
| 189 | { id: "build", label: "Build", isDefault: true }, | ||
| 190 | { id: "plan", label: "Plan" }, | ||
| 191 | ], | ||
| 192 | currentValue: "build", | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | }); | ||
| 196 | |||
| 197 | function openCodeCapabilitiesForModel(input: { | ||
| 198 | readonly providerID: string; | ||
| 199 | readonly model: ProviderListResponse["all"][number]["models"][string]; | ||
| 200 | readonly agents: ReadonlyArray<Agent>; | ||
| 201 | }): ModelCapabilities { | ||
| 202 | const rawVariantValues = Object.keys(input.model.variants ?? {}); | ||
| 203 | // When a model advertises no variants, synthesize the standard reasoning | ||
| 204 | // levels so the composer still offers a Reasoning selector (mirrors the | ||
| 205 | // Codex/Grok experience where reasoning is always configurable). The set | ||
| 206 | // covers the common OpenCode variant spectrum; `inferDefaultVariant` | ||
| 207 | // picks the provider-appropriate default (e.g. medium for openai/opencode). | ||
| 208 | const variantValues = | ||
| 209 | rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; | ||
| 210 | const defaultVariant = inferDefaultVariant(input.providerID, variantValues); | ||
| 211 | const variantOptions = variantValues.map((value) => | ||
| 212 | defaultVariant === value | ||
| 213 | ? { id: value, label: titleCaseSlug(value), isDefault: true as const } | ||
| 214 | : { id: value, label: titleCaseSlug(value) }, | ||
| 215 | ); | ||
| 216 | const primaryAgents = input.agents.filter( | ||
| 217 | (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), | ||
| 218 | ); | ||
| 219 | const defaultAgent = inferDefaultAgent(primaryAgents); | ||
| 220 | const agentOptions = primaryAgents.map((agent) => | ||
| 221 | defaultAgent === agent.name | ||
| 222 | ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } | ||
| 223 | : { id: agent.name, label: titleCaseSlug(agent.name) }, | ||
| 224 | ); | ||
| 225 | return createModelCapabilities({ | ||
| 226 | optionDescriptors: [ | ||
| 227 | ...(variantOptions.length > 0 | ||
| 228 | ? [ | ||
| 229 | { | ||
| 230 | id: "variant", | ||
| 231 | label: "Reasoning", | ||
| 232 | type: "select" as const, | ||
| 233 | options: variantOptions, | ||
| 234 | ...(defaultVariant ? { currentValue: defaultVariant } : {}), | ||
| 235 | }, | ||
| 236 | ] | ||
| 237 | : []), | ||
| 238 | ...(agentOptions.length > 0 | ||
| 239 | ? [ | ||
| 240 | { | ||
| 241 | id: "agent", | ||
| 242 | label: "Agent", | ||
| 243 | type: "select" as const, | ||
| 244 | options: agentOptions, | ||
| 245 | ...(defaultAgent ? { currentValue: defaultAgent } : {}), | ||
| 246 | }, | ||
| 247 | ] | ||
| 248 | : []), | ||
| 249 | ], | ||
| 250 | }); | ||
| 251 | } | ||
| 252 | |||
| 253 | function flattenOpenCodeModels( | ||
| 254 | input: OpenCodeRuntime.OpenCodeInventory, | ||
| 255 | ): ReadonlyArray<ServerProviderModel> { | ||
| 256 | const connected = new Set(input.providerList.connected); | ||
| 257 | const models: Array<ServerProviderModel> = []; | ||
| 258 | |||
| 259 | for (const provider of input.providerList.all) { | ||
| 260 | if (!connected.has(provider.id)) { | ||
| 261 | continue; | ||
| 262 | } | ||
| 263 | |||
| 264 | for (const model of Object.values(provider.models)) { | ||
| 265 | const name = nonEmptyTrimmed(model.name); | ||
| 266 | if (!name) { | ||
| 267 | continue; | ||
| 268 | } | ||
| 269 | |||
| 270 | const subProvider = nonEmptyTrimmed(provider.name); | ||
| 271 | models.push({ | ||
| 272 | slug: `${provider.id}/${model.id}`, | ||
| 273 | name, | ||
| 274 | ...(subProvider ? { subProvider } : {}), | ||
| 275 | isCustom: false, | ||
| 276 | capabilities: openCodeCapabilitiesForModel({ | ||
| 277 | providerID: provider.id, | ||
| 278 | model, | ||
| 279 | agents: input.agents, | ||
| 280 | }), | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | return models.toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 286 | } | ||
| 287 | |||
| 288 | function trimOptional(value: string | null | undefined): string | undefined { | ||
| 289 | const trimmed = value?.trim(); | ||
| 290 | return trimmed && trimmed.length > 0 ? trimmed : undefined; | ||
| 291 | } | ||
| 292 | |||
| 293 | export function openCodeSkillsToServerProviderSkills( | ||
| 294 | input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined, | ||
| 295 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 296 | const skills: ServerProviderSkill[] = []; | ||
| 297 | for (const skill of input ?? []) { | ||
| 298 | const name = trimOptional(skill.name); | ||
| 299 | const path = trimOptional(skill.location); | ||
| 300 | if (!name || !path) { | ||
| 301 | continue; | ||
| 302 | } | ||
| 303 | |||
| 304 | const description = trimOptional(skill.description); | ||
| 305 | skills.push({ | ||
| 306 | name, | ||
| 307 | path, | ||
| 308 | enabled: true, | ||
| 309 | ...(description ? { description, shortDescription: description } : {}), | ||
| 310 | }); | ||
| 311 | } | ||
| 312 | |||
| 313 | return skills.toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 314 | } | ||
| 315 | |||
| 316 | export function openCodeCommandsToServerProviderSlashCommands( | ||
| 317 | input: OpenCodeRuntime.OpenCodeInventory["commands"], | ||
| 318 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 319 | const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | ||
| 320 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 321 | for (const command of input ?? []) { | ||
| 322 | const name = trimOptional(command.name); | ||
| 323 | if (!name || names.has(name) || command.source === "skill") continue; | ||
| 324 | names.add(name); | ||
| 325 | const description = trimOptional(command.description); | ||
| 326 | const hint = trimOptional(command.hints.join(" ")); | ||
| 327 | commands.push({ | ||
| 328 | name, | ||
| 329 | ...(description ? { description } : {}), | ||
| 330 | ...(hint ? { input: { hint } } : {}), | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | return commands; | ||
| 334 | } | ||
| 335 | |||
| 336 | export const makePendingOpenCodeProvider = ( | ||
| 337 | openCodeSettings: OpenCodeSettings, | ||
| 338 | ): Effect.Effect<ServerProviderDraft> => | ||
| 339 | Effect.gen(function* () { | ||
| 340 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 341 | const models = providerModelsFromSettings( | ||
| 342 | [], | ||
| 343 | openCodeSettings.customModels, | ||
| 344 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 345 | ); | ||
| 346 | |||
| 347 | if (!openCodeSettings.enabled) { | ||
| 348 | return buildServerProvider({ | ||
| 349 | presentation: OPENCODE_PRESENTATION, | ||
| 350 | enabled: false, | ||
| 351 | checkedAt, | ||
| 352 | models, | ||
| 353 | probe: { | ||
| 354 | installed: false, | ||
| 355 | version: null, | ||
| 356 | status: "warning", | ||
| 357 | auth: { status: "unknown" }, | ||
| 358 | message: | ||
| 359 | openCodeSettings.serverUrl.trim().length > 0 | ||
| 360 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 361 | : "OpenCode is disabled in T3 Code settings.", | ||
| 362 | }, | ||
| 363 | }); | ||
| 364 | } | ||
| 365 | |||
| 366 | return buildServerProvider({ | ||
| 367 | presentation: OPENCODE_PRESENTATION, | ||
| 368 | enabled: true, | ||
| 369 | checkedAt, | ||
| 370 | models, | ||
| 371 | probe: { | ||
| 372 | installed: false, | ||
| 373 | version: null, | ||
| 374 | status: "warning", | ||
| 375 | auth: { status: "unknown" }, | ||
| 376 | message: "OpenCode provider status has not been checked in this session yet.", | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | }); | ||
| 380 | |||
| 381 | /** One model an OpenCode 2 server lists, as the status check reads it. */ | ||
| 382 | export interface OpenCode2Model { | ||
| 383 | readonly providerID: string; | ||
| 384 | readonly id: string; | ||
| 385 | readonly name: string; | ||
| 386 | readonly variants: ReadonlyArray<{ readonly id: string }>; | ||
| 387 | } | ||
| 388 | |||
| 389 | /** | ||
| 390 | * A server loads its catalog lazily and lists nothing for its first few | ||
| 391 | * hundred milliseconds, and a local instance starts a fresh server for each | ||
| 392 | * status check. So an empty list is read again for a while, and the last | ||
| 393 | * non-empty list stands in if the catalog still has not loaded. | ||
| 394 | */ | ||
| 395 | export const makeOpenCode2ModelLoader = <E>( | ||
| 396 | list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>, | ||
| 397 | ) => | ||
| 398 | Effect.sync(() => { | ||
| 399 | let lastLoaded: ReadonlyArray<OpenCode2Model> = []; | ||
| 400 | return list.pipe( | ||
| 401 | Effect.repeat({ | ||
| 402 | until: (models) => models.length > 0, | ||
| 403 | schedule: Schedule.spaced("250 millis"), | ||
| 404 | }), | ||
| 405 | Effect.timeoutOption("5 seconds"), | ||
| 406 | Effect.map((loaded) => { | ||
| 407 | if (loaded._tag === "Some" && loaded.value.length > 0) lastLoaded = loaded.value; | ||
| 408 | return lastLoaded; | ||
| 409 | }), | ||
| 410 | ); | ||
| 411 | }); | ||
| 412 | |||
| 413 | /** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ | ||
| 414 | export interface OpenCode2Workspace { | ||
| 415 | readonly skills: ReadonlyArray<{ | ||
| 416 | readonly id: string; | ||
| 417 | readonly name: string; | ||
| 418 | readonly path: string; | ||
| 419 | readonly description?: string | undefined; | ||
| 420 | }>; | ||
| 421 | readonly commands: ReadonlyArray<{ | ||
| 422 | readonly name: string; | ||
| 423 | readonly description?: string | undefined; | ||
| 424 | }>; | ||
| 425 | } | ||
| 426 | |||
| 427 | /** | ||
| 428 | * A server scans a directory it has not served yet in the background: its | ||
| 429 | * first read lists no commands at all (not even the built-ins), and | ||
| 430 | * `command.updated` plus `skill.updated` for the directory end the scan. A | ||
| 431 | * directory it already serves lists everything at once. `scanned` must be | ||
| 432 | * listening before `read` runs, since the read is what starts the scan. | ||
| 433 | */ | ||
| 434 | export const loadOpenCode2Workspace = <E>( | ||
| 435 | read: Effect.Effect<OpenCode2Workspace, E>, | ||
| 436 | scanned: Effect.Effect<void>, | ||
| 437 | ) => | ||
| 438 | Effect.gen(function* () { | ||
| 439 | const first = yield* read; | ||
| 440 | if (first.commands.length > 0) return first; | ||
| 441 | yield* scanned.pipe(Effect.timeoutOption("10 seconds")); | ||
| 442 | return yield* read; | ||
| 443 | }); | ||
| 444 | |||
| 445 | export function openCode2SkillsToServerProviderSkills( | ||
| 446 | skills: OpenCode2Workspace["skills"], | ||
| 447 | ): ReadonlyArray<ServerProviderSkill> { | ||
| 448 | return skills | ||
| 449 | .map((skill) => { | ||
| 450 | const description = trimOptional(skill.description); | ||
| 451 | return { | ||
| 452 | name: skill.id, | ||
| 453 | path: skill.path, | ||
| 454 | enabled: true, | ||
| 455 | ...(skill.name === skill.id ? {} : { displayName: skill.name }), | ||
| 456 | ...(description ? { description, shortDescription: description } : {}), | ||
| 457 | }; | ||
| 458 | }) | ||
| 459 | .toSorted((left, right) => left.name.localeCompare(right.name)); | ||
| 460 | } | ||
| 461 | |||
| 462 | export function openCode2CommandsToServerProviderSlashCommands( | ||
| 463 | commands: OpenCode2Workspace["commands"], | ||
| 464 | ): ReadonlyArray<ServerProviderSlashCommand> { | ||
| 465 | const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; | ||
| 466 | const names = new Set([COMPACT_SLASH_COMMAND.name]); | ||
| 467 | for (const command of commands) { | ||
| 468 | const name = trimOptional(command.name); | ||
| 469 | if (!name || names.has(name)) continue; | ||
| 470 | names.add(name); | ||
| 471 | const description = trimOptional(command.description); | ||
| 472 | slashCommands.push({ name, ...(description ? { description } : {}) }); | ||
| 473 | } | ||
| 474 | return slashCommands; | ||
| 475 | } | ||
| 476 | |||
| 477 | /** | ||
| 478 | * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: | ||
| 479 | * OpenCode has no reviewer that approves routine actions. Plan mode is its | ||
| 480 | * `plan` agent, so the composer's mode toggle drives it. | ||
| 481 | */ | ||
| 482 | const OPENCODE_2_PRESENTATION = { | ||
| 483 | ...OPENCODE_PRESENTATION, | ||
| 484 | showInteractionModeToggle: true, | ||
| 485 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], | ||
| 486 | } as const; | ||
| 487 | |||
| 488 | function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities { | ||
| 489 | const variants = model.variants.map((variant) => variant.id); | ||
| 490 | const defaultVariant = inferDefaultVariant(model.providerID, variants); | ||
| 491 | return createModelCapabilities({ | ||
| 492 | optionDescriptors: | ||
| 493 | variants.length === 0 | ||
| 494 | ? [] | ||
| 495 | : [ | ||
| 496 | { | ||
| 497 | id: "variant", | ||
| 498 | label: "Reasoning", | ||
| 499 | type: "select", | ||
| 500 | options: variants.map((id) => ({ | ||
| 501 | id, | ||
| 502 | label: titleCaseSlug(id), | ||
| 503 | ...(id === defaultVariant ? { isDefault: true } : {}), | ||
| 504 | })), | ||
| 505 | ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }), | ||
| 506 | }, | ||
| 507 | ], | ||
| 508 | }); | ||
| 509 | } | ||
| 510 | |||
| 511 | const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( | ||
| 512 | settings: OpenCodeSettings, | ||
| 513 | version: string, | ||
| 514 | checkedAt: string, | ||
| 515 | loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>, | ||
| 516 | ) { | ||
| 517 | const result = yield* Effect.exit(loadModels); | ||
| 518 | const probe = (status: "ready" | "warning" | "error", message: string) => ({ | ||
| 519 | installed: true, | ||
| 520 | version, | ||
| 521 | status, | ||
| 522 | auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) }, | ||
| 523 | message, | ||
| 524 | }); | ||
| 525 | if (result._tag === "Failure") { | ||
| 526 | // The detail can carry a response body; it stays in the log, not the status. | ||
| 527 | yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); | ||
| 528 | return buildServerProvider({ | ||
| 529 | presentation: OPENCODE_2_PRESENTATION, | ||
| 530 | enabled: true, | ||
| 531 | checkedAt, | ||
| 532 | models: providerModelsFromSettings( | ||
| 533 | [], | ||
| 534 | settings.customModels, | ||
| 535 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 536 | ), | ||
| 537 | // Compaction does not depend on the model catalog. | ||
| 538 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 539 | probe: probe("error", "OpenCode could not load its model list."), | ||
| 540 | }); | ||
| 541 | } | ||
| 542 | const models = providerModelsFromSettings( | ||
| 543 | result.value | ||
| 544 | .map((model) => ({ | ||
| 545 | slug: `${model.providerID}/${model.id}`, | ||
| 546 | name: model.name, | ||
| 547 | isCustom: false, | ||
| 548 | capabilities: openCode2ModelCapabilities(model), | ||
| 549 | })) | ||
| 550 | .toSorted((left, right) => left.name.localeCompare(right.name)), | ||
| 551 | settings.customModels, | ||
| 552 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 553 | ); | ||
| 554 | return buildServerProvider({ | ||
| 555 | presentation: OPENCODE_2_PRESENTATION, | ||
| 556 | enabled: true, | ||
| 557 | checkedAt, | ||
| 558 | models, | ||
| 559 | // Every session can compact; a workspace snapshot adds that directory's commands. | ||
| 560 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 561 | probe: | ||
| 562 | result.value.length > 0 | ||
| 563 | ? probe( | ||
| 564 | "ready", | ||
| 565 | `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`, | ||
| 566 | ) | ||
| 567 | : probe("warning", "OpenCode 2 is running, but it did not list any models yet."), | ||
| 568 | }); | ||
| 569 | }); | ||
| 570 | |||
| 571 | /** | ||
| 572 | * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, | ||
| 573 | * the version endpoints for a configured server. The status check refreshes it, so an in-place | ||
| 574 | * upgrade re-routes the instance. | ||
| 575 | */ | ||
| 576 | export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( | ||
| 577 | openCodeSettings: OpenCodeSettings, | ||
| 578 | cwd: string, | ||
| 579 | probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>, | ||
| 580 | loadOpenCode2Models: Effect.Effect< | ||
| 581 | ReadonlyArray<OpenCode2Model>, | ||
| 582 | OpenCodeRuntime.OpenCodeRuntimeError | ||
| 583 | >, | ||
| 584 | ): Effect.fn.Return< | ||
| 585 | ServerProviderDraft, | ||
| 586 | never, | ||
| 587 | OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner | ||
| 588 | > { | ||
| 589 | const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; | ||
| 590 | const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; | ||
| 591 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 592 | const customModels = openCodeSettings.customModels; | ||
| 593 | const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; | ||
| 594 | |||
| 595 | const fallback = ( | ||
| 596 | cause: unknown, | ||
| 597 | version: string | null = null, | ||
| 598 | phase: "version" | "inventory" = "version", | ||
| 599 | ) => { | ||
| 600 | const failure = formatOpenCodeProbeError({ | ||
| 601 | cause, | ||
| 602 | isExternalServer, | ||
| 603 | phase, | ||
| 604 | serverUrl: openCodeSettings.serverUrl, | ||
| 605 | }); | ||
| 606 | return buildServerProvider({ | ||
| 607 | presentation: OPENCODE_PRESENTATION, | ||
| 608 | enabled: openCodeSettings.enabled, | ||
| 609 | checkedAt, | ||
| 610 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 611 | probe: { | ||
| 612 | installed: failure.installed, | ||
| 613 | version, | ||
| 614 | status: "error", | ||
| 615 | auth: { status: "unknown" }, | ||
| 616 | message: failure.message, | ||
| 617 | }, | ||
| 618 | }); | ||
| 619 | }; | ||
| 620 | |||
| 621 | if (!openCodeSettings.enabled) { | ||
| 622 | return buildServerProvider({ | ||
| 623 | presentation: OPENCODE_PRESENTATION, | ||
| 624 | enabled: false, | ||
| 625 | checkedAt, | ||
| 626 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 627 | probe: { | ||
| 628 | installed: false, | ||
| 629 | version: null, | ||
| 630 | status: "warning", | ||
| 631 | auth: { status: "unknown" }, | ||
| 632 | message: isExternalServer | ||
| 633 | ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | ||
| 634 | : "OpenCode is disabled in T3 Code settings.", | ||
| 635 | }, | ||
| 636 | }); | ||
| 637 | } | ||
| 638 | |||
| 639 | const probedExit = yield* Effect.exit( | ||
| 640 | probeRuntime.pipe( | ||
| 641 | Effect.mapError( | ||
| 642 | (cause) => | ||
| 643 | new OpenCodeProbeError({ | ||
| 644 | cause, | ||
| 645 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | ||
| 646 | }), | ||
| 647 | ), | ||
| 648 | ), | ||
| 649 | ); | ||
| 650 | if (probedExit._tag === "Failure") return fallback(Cause.squash(probedExit.cause)); | ||
| 651 | const probed = probedExit.value; | ||
| 652 | if (probed.generation === "v2") { | ||
| 653 | return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models); | ||
| 654 | } | ||
| 655 | let version: string | null = probed.version; | ||
| 656 | if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) { | ||
| 657 | return buildServerProvider({ | ||
| 658 | presentation: OPENCODE_PRESENTATION, | ||
| 659 | enabled: openCodeSettings.enabled, | ||
| 660 | checkedAt, | ||
| 661 | models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), | ||
| 662 | probe: { | ||
| 663 | installed: true, | ||
| 664 | version: probed.version, | ||
| 665 | status: "error", | ||
| 666 | auth: { status: "unknown" }, | ||
| 667 | message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, | ||
| 668 | }, | ||
| 669 | }); | ||
| 670 | } | ||
| 671 | |||
| 672 | const loadInventory = (server: { | ||
| 673 | readonly url: string; | ||
| 674 | readonly serverPassword?: string; | ||
| 675 | readonly version: string; | ||
| 676 | }) => | ||
| 677 | openCodeRuntime | ||
| 678 | .loadOpenCodeInventory( | ||
| 679 | openCodeRuntime.createOpenCodeSdkClient({ | ||
| 680 | baseUrl: server.url, | ||
| 681 | directory: cwd, | ||
| 682 | ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), | ||
| 683 | }), | ||
| 684 | ) | ||
| 685 | .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); | ||
| 686 | const inventoryEffect = isExternalServer | ||
| 687 | ? openCodeRuntime | ||
| 688 | .connectToOpenCodeServer({ | ||
| 689 | binaryPath: openCodeSettings.binaryPath, | ||
| 690 | directory: cwd, | ||
| 691 | serverUrl: openCodeSettings.serverUrl, | ||
| 692 | ...(openCodeSettings.serverPassword | ||
| 693 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 694 | : {}), | ||
| 695 | }) | ||
| 696 | .pipe(Effect.flatMap(loadInventory), Effect.scoped) | ||
| 697 | : serverOwner.withServer(loadInventory); | ||
| 698 | const inventoryExit = yield* Effect.exit( | ||
| 699 | inventoryEffect.pipe( | ||
| 700 | Effect.mapError( | ||
| 701 | (cause) => | ||
| 702 | new OpenCodeProbeError({ | ||
| 703 | cause, | ||
| 704 | detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), | ||
| 705 | }), | ||
| 706 | ), | ||
| 707 | ), | ||
| 708 | ); | ||
| 709 | if (inventoryExit._tag === "Failure") { | ||
| 710 | return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); | ||
| 711 | } | ||
| 712 | |||
| 713 | version = inventoryExit.value.version; | ||
| 714 | |||
| 715 | const models = providerModelsFromSettings( | ||
| 716 | flattenOpenCodeModels(inventoryExit.value.inventory), | ||
| 717 | customModels, | ||
| 718 | DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 719 | ); | ||
| 720 | const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); | ||
| 721 | const connectedCount = inventoryExit.value.inventory.providerList.connected.length; | ||
| 722 | return buildServerProvider({ | ||
| 723 | presentation: OPENCODE_PRESENTATION, | ||
| 724 | enabled: true, | ||
| 725 | checkedAt, | ||
| 726 | models, | ||
| 727 | skills, | ||
| 728 | slashCommands: openCodeCommandsToServerProviderSlashCommands( | ||
| 729 | inventoryExit.value.inventory.commands, | ||
| 730 | ), | ||
| 731 | probe: { | ||
| 732 | installed: true, | ||
| 733 | version, | ||
| 734 | status: connectedCount > 0 ? "ready" : "warning", | ||
| 735 | auth: { | ||
| 736 | status: connectedCount > 0 ? "authenticated" : "unknown", | ||
| 737 | type: "opencode", | ||
| 738 | }, | ||
| 739 | message: | ||
| 740 | connectedCount > 0 | ||
| 741 | ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | ||
| 742 | : isExternalServer | ||
| 743 | ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | ||
| 744 | : "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 745 | }, | ||
| 746 | }); | ||
| 747 | }); | ||
apps/server/src/provider/PiProvider.test.ts created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Sink from "effect/Sink"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 7 | |||
| 8 | import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./PiProvider.ts"; | ||
| 9 | |||
| 10 | const encoder = new TextEncoder(); | ||
| 11 | |||
| 12 | function processHandle(input: { | ||
| 13 | readonly stdout?: string; | ||
| 14 | readonly stderr?: string; | ||
| 15 | readonly exitCode?: number; | ||
| 16 | }) { | ||
| 17 | const bytes = (value: string | undefined) => | ||
| 18 | value === undefined || value.length === 0 | ||
| 19 | ? Stream.empty | ||
| 20 | : Stream.succeed(encoder.encode(value)); | ||
| 21 | return ChildProcessSpawner.makeHandle({ | ||
| 22 | pid: ChildProcessSpawner.ProcessId(900_000_001), | ||
| 23 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | ||
| 24 | isRunning: Effect.succeed(false), | ||
| 25 | kill: () => Effect.void, | ||
| 26 | unref: Effect.succeed(Effect.void), | ||
| 27 | stdin: Sink.drain, | ||
| 28 | stdout: bytes(input.stdout), | ||
| 29 | stderr: bytes(input.stderr), | ||
| 30 | all: Stream.empty, | ||
| 31 | getInputFd: () => Sink.drain, | ||
| 32 | getOutputFd: () => Stream.empty, | ||
| 33 | }); | ||
| 34 | } | ||
| 35 | |||
| 36 | function piProbeSpawner(version: string) { | ||
| 37 | return ChildProcessSpawner.make((command) => { | ||
| 38 | const args = ChildProcess.isStandardCommand(command) ? command.args : []; | ||
| 39 | return Effect.succeed( | ||
| 40 | args.includes("--version") | ||
| 41 | ? processHandle({ stdout: `pi ${version}\n` }) | ||
| 42 | : processHandle({ stderr: "RPC startup failed", exitCode: 1 }), | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | |||
| 47 | const settings = { | ||
| 48 | enabled: true, | ||
| 49 | binaryPath: "pi", | ||
| 50 | launchArgs: "", | ||
| 51 | customModels: [], | ||
| 52 | } as const; | ||
| 53 | |||
| 54 | describe("PiProvider", () => { | ||
| 55 | it.effect("requires the first published Pi version with entries and settlement hooks", () => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | ||
| 58 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.80.3")), | ||
| 59 | ); | ||
| 60 | assert.equal(snapshot.status, "error"); | ||
| 61 | assert.equal(snapshot.version, "0.80.3"); | ||
| 62 | assert.include(snapshot.message ?? "", `Pi ${MINIMUM_PI_VERSION} or newer`); | ||
| 63 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 64 | ); | ||
| 65 | |||
| 66 | it.effect("keeps compatible Pi selectable when optional discovery fails", () => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const snapshot = yield* checkPiProviderStatus(settings).pipe( | ||
| 69 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, piProbeSpawner("0.84.3")), | ||
| 70 | ); | ||
| 71 | assert.equal(snapshot.status, "ready"); | ||
| 72 | assert.equal(snapshot.auth.status, "unknown"); | ||
| 73 | assert.deepEqual( | ||
| 74 | snapshot.models.map((model) => model.slug), | ||
| 75 | ["default"], | ||
| 76 | ); | ||
| 77 | assert.include(snapshot.message ?? "", "could not refresh its models and commands"); | ||
| 78 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 79 | ); | ||
| 80 | }); | ||
apps/server/src/provider/PiProvider.ts created+441| ... | @@ -0,0 +1,441 @@ | ||
| 1 | /** | ||
| 2 | * PiProvider — snapshot/probe layer for the Pi coding agent. | ||
| 3 | * | ||
| 4 | * Health is probed with `pi --version`. Models, the user's default model, and | ||
| 5 | * the user's commands (extension slash commands, prompt templates, skills) | ||
| 6 | * are discovered through a short-lived ephemeral RPC session | ||
| 7 | * (`pi --mode rpc --no-session`), so everything the user configured in | ||
| 8 | * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — | ||
| 9 | * shows up in T3 without any hardcoded catalog. | ||
| 10 | */ | ||
| 11 | import { | ||
| 12 | type CustomModelSetting, | ||
| 13 | type PiSettings, | ||
| 14 | type ServerProvider, | ||
| 15 | type ServerProviderModel, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 19 | import { compareSemverVersions } from "@t3tools/shared/semver"; | ||
| 20 | import * as DateTime from "effect/DateTime"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import * as Exit from "effect/Exit"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as Result from "effect/Result"; | ||
| 26 | import { HttpClient } from "effect/http"; | ||
| 27 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 28 | |||
| 29 | import { | ||
| 30 | buildPiRpcLaunch, | ||
| 31 | resolvePiLaunchArgs, | ||
| 32 | } from "../orchestration-v2/Adapters/piT3McpInjection.ts"; | ||
| 33 | import { | ||
| 34 | makePiRpcConnection, | ||
| 35 | piRecordField as recordField, | ||
| 36 | piRecordString as recordString, | ||
| 37 | } from "../orchestration-v2/Adapters/PiRpc.ts"; | ||
| 38 | import { | ||
| 39 | buildServerProvider, | ||
| 40 | isCommandMissingCause, | ||
| 41 | parseGenericCliVersion, | ||
| 42 | providerModelsFromSettings, | ||
| 43 | spawnAndCollect, | ||
| 44 | type ServerProviderDraft, | ||
| 45 | } from "./providerSnapshot.ts"; | ||
| 46 | import { | ||
| 47 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 48 | type ProviderMaintenanceCapabilities, | ||
| 49 | } from "./providerMaintenance.ts"; | ||
| 50 | import { | ||
| 51 | EMPTY_PI_MODEL_CAPABILITIES, | ||
| 52 | thinkingCapabilitiesForPiModel, | ||
| 53 | } from "./piThinkingCapabilities.ts"; | ||
| 54 | import { | ||
| 55 | parsePiDiscoveredCommands, | ||
| 56 | withPiBuiltinSlashCommands, | ||
| 57 | type PiDiscoveredCommands, | ||
| 58 | } from "./PiCommands.ts"; | ||
| 59 | |||
| 60 | const PI_PRESENTATION = { | ||
| 61 | displayName: "Pi", | ||
| 62 | showInteractionModeToggle: false, | ||
| 63 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], | ||
| 64 | // The adapter reports context usage from Pi's streaming usage while a | ||
| 65 | // turn runs, so clients can reserve the meter before the first settle. | ||
| 66 | reportsContextWindow: true, | ||
| 67 | requiresNewThreadForModelChange: false, | ||
| 68 | } as const; | ||
| 69 | |||
| 70 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 71 | const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; | ||
| 72 | /** | ||
| 73 | * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. | ||
| 74 | * Version 0.80.5 was the first published package containing both hooks. T3 | ||
| 75 | * needs them for rollback boundaries and reliable turn terminalization. | ||
| 76 | */ | ||
| 77 | export const MINIMUM_PI_VERSION = "0.80.5"; | ||
| 78 | |||
| 79 | /** Deferring to the user's own settings.json default model. */ | ||
| 80 | const PI_DEFAULT_MODEL: ServerProviderModel = { | ||
| 81 | slug: "default", | ||
| 82 | name: "Pi default", | ||
| 83 | isCustom: false, | ||
| 84 | capabilities: EMPTY_PI_MODEL_CAPABILITIES, | ||
| 85 | }; | ||
| 86 | |||
| 87 | interface PiDiscovery extends PiDiscoveredCommands { | ||
| 88 | readonly models: ReadonlyArray<ServerProviderModel>; | ||
| 89 | readonly authenticated: boolean; | ||
| 90 | } | ||
| 91 | |||
| 92 | function piModelsFromSettings( | ||
| 93 | customModels: ReadonlyArray<CustomModelSetting> | undefined, | ||
| 94 | discovered: ReadonlyArray<ServerProviderModel> = [], | ||
| 95 | ): ReadonlyArray<ServerProviderModel> { | ||
| 96 | return providerModelsFromSettings( | ||
| 97 | [PI_DEFAULT_MODEL, ...discovered], | ||
| 98 | customModels ?? [], | ||
| 99 | EMPTY_PI_MODEL_CAPABILITIES, | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | function parseDiscoveredModels( | ||
| 104 | data: unknown, | ||
| 105 | defaultThinkingLevel: unknown, | ||
| 106 | ): ReadonlyArray<ServerProviderModel> { | ||
| 107 | const models = recordField(data, "models"); | ||
| 108 | if (!Array.isArray(models)) return []; | ||
| 109 | const seen = new Set<string>(); | ||
| 110 | const parsed: Array<ServerProviderModel> = []; | ||
| 111 | for (const model of models) { | ||
| 112 | const provider = recordString(model, "provider"); | ||
| 113 | const id = recordString(model, "id"); | ||
| 114 | if (provider === undefined || id === undefined) continue; | ||
| 115 | const slug = `${provider}/${id}`; | ||
| 116 | if (seen.has(slug)) continue; | ||
| 117 | seen.add(slug); | ||
| 118 | parsed.push({ | ||
| 119 | slug, | ||
| 120 | name: recordString(model, "name") ?? slug, | ||
| 121 | isCustom: false, | ||
| 122 | capabilities: thinkingCapabilitiesForPiModel(model, defaultThinkingLevel), | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | return parsed; | ||
| 126 | } | ||
| 127 | |||
| 128 | const discoverPiViaRpc = ( | ||
| 129 | piSettings: PiSettings, | ||
| 130 | environment: NodeJS.ProcessEnv, | ||
| 131 | launchArgs: ReadonlyArray<string>, | ||
| 132 | cwd?: string, | ||
| 133 | ) => | ||
| 134 | Effect.gen(function* () { | ||
| 135 | const launch = buildPiRpcLaunch({ | ||
| 136 | launchArgs, | ||
| 137 | environment, | ||
| 138 | mcpSession: undefined, | ||
| 139 | extensionPath: undefined, | ||
| 140 | ephemeral: true, | ||
| 141 | }); | ||
| 142 | const connection = yield* makePiRpcConnection({ | ||
| 143 | command: piSettings.binaryPath || "pi", | ||
| 144 | args: launch.args, | ||
| 145 | cwd, | ||
| 146 | env: launch.env, | ||
| 147 | }); | ||
| 148 | yield* Stream.fromQueue(connection.events).pipe( | ||
| 149 | Stream.runDrain, | ||
| 150 | Effect.ignore, | ||
| 151 | Effect.forkScoped, | ||
| 152 | ); | ||
| 153 | const stateData = yield* connection.request({ type: "get_state" }); | ||
| 154 | const modelsData = yield* connection.request({ type: "get_available_models" }); | ||
| 155 | const commandsData = yield* connection | ||
| 156 | .request({ type: "get_commands" }) | ||
| 157 | .pipe(Effect.orElseSucceed(() => undefined)); | ||
| 158 | const discoveredModels = parseDiscoveredModels( | ||
| 159 | modelsData, | ||
| 160 | recordString(stateData, "thinkingLevel"), | ||
| 161 | ); | ||
| 162 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); | ||
| 163 | return { | ||
| 164 | models: discoveredModels, | ||
| 165 | slashCommands: withPiBuiltinSlashCommands(slashCommands), | ||
| 166 | skills, | ||
| 167 | authenticated: discoveredModels.length > 0, | ||
| 168 | } satisfies PiDiscovery; | ||
| 169 | }).pipe(Effect.scoped); | ||
| 170 | |||
| 171 | const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const command = piSettings.binaryPath || "pi"; | ||
| 174 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { | ||
| 175 | env: environment, | ||
| 176 | }); | ||
| 177 | return yield* spawnAndCollect( | ||
| 178 | command, | ||
| 179 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | ||
| 180 | env: environment, | ||
| 181 | shell: spawnCommand.shell, | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | }); | ||
| 185 | |||
| 186 | export function buildInitialPiProviderSnapshot( | ||
| 187 | piSettings: PiSettings, | ||
| 188 | ): Effect.Effect<ServerProviderDraft> { | ||
| 189 | return Effect.gen(function* () { | ||
| 190 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | ||
| 191 | const models = piModelsFromSettings(piSettings.customModels); | ||
| 192 | if (!piSettings.enabled) { | ||
| 193 | return buildServerProvider({ | ||
| 194 | presentation: PI_PRESENTATION, | ||
| 195 | enabled: false, | ||
| 196 | checkedAt, | ||
| 197 | models, | ||
| 198 | probe: { | ||
| 199 | installed: false, | ||
| 200 | version: null, | ||
| 201 | status: "warning", | ||
| 202 | auth: { status: "unknown" }, | ||
| 203 | message: "Pi is disabled in T3 Code settings.", | ||
| 204 | }, | ||
| 205 | }); | ||
| 206 | } | ||
| 207 | return buildServerProvider({ | ||
| 208 | presentation: PI_PRESENTATION, | ||
| 209 | enabled: true, | ||
| 210 | checkedAt, | ||
| 211 | models, | ||
| 212 | probe: { | ||
| 213 | installed: true, | ||
| 214 | version: null, | ||
| 215 | status: "warning", | ||
| 216 | auth: { status: "unknown" }, | ||
| 217 | message: "Checking Pi CLI availability...", | ||
| 218 | }, | ||
| 219 | }); | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | |||
| 223 | export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( | ||
| 224 | piSettings: PiSettings, | ||
| 225 | environment: NodeJS.ProcessEnv = process.env, | ||
| 226 | cwd?: string, | ||
| 227 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { | ||
| 228 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 229 | const fallbackModels = piModelsFromSettings(piSettings.customModels); | ||
| 230 | |||
| 231 | if (!piSettings.enabled) { | ||
| 232 | return buildServerProvider({ | ||
| 233 | presentation: PI_PRESENTATION, | ||
| 234 | enabled: false, | ||
| 235 | checkedAt, | ||
| 236 | models: fallbackModels, | ||
| 237 | probe: { | ||
| 238 | installed: false, | ||
| 239 | version: null, | ||
| 240 | status: "warning", | ||
| 241 | auth: { status: "unknown" }, | ||
| 242 | message: "Pi is disabled in T3 Code settings.", | ||
| 243 | }, | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | |||
| 247 | const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( | ||
| 248 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), | ||
| 249 | Effect.result, | ||
| 250 | ); | ||
| 251 | |||
| 252 | if (Result.isFailure(versionResult)) { | ||
| 253 | const error = versionResult.failure; | ||
| 254 | yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); | ||
| 255 | return buildServerProvider({ | ||
| 256 | presentation: PI_PRESENTATION, | ||
| 257 | enabled: piSettings.enabled, | ||
| 258 | checkedAt, | ||
| 259 | models: fallbackModels, | ||
| 260 | probe: { | ||
| 261 | installed: !isCommandMissingCause(error), | ||
| 262 | version: null, | ||
| 263 | status: "error", | ||
| 264 | auth: { status: "unknown" }, | ||
| 265 | message: isCommandMissingCause(error) | ||
| 266 | ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g @earendil-works/pi-coding-agent`." | ||
| 267 | : "Failed to execute Pi CLI health check.", | ||
| 268 | }, | ||
| 269 | }); | ||
| 270 | } | ||
| 271 | |||
| 272 | if (Option.isNone(versionResult.success)) { | ||
| 273 | return buildServerProvider({ | ||
| 274 | presentation: PI_PRESENTATION, | ||
| 275 | enabled: piSettings.enabled, | ||
| 276 | checkedAt, | ||
| 277 | models: fallbackModels, | ||
| 278 | probe: { | ||
| 279 | installed: true, | ||
| 280 | version: null, | ||
| 281 | status: "error", | ||
| 282 | auth: { status: "unknown" }, | ||
| 283 | message: "Pi CLI is installed but timed out while running `pi --version`.", | ||
| 284 | }, | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | |||
| 288 | const versionOutput = versionResult.success.value; | ||
| 289 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); | ||
| 290 | if (versionOutput.code !== 0) { | ||
| 291 | return buildServerProvider({ | ||
| 292 | presentation: PI_PRESENTATION, | ||
| 293 | enabled: piSettings.enabled, | ||
| 294 | checkedAt, | ||
| 295 | models: fallbackModels, | ||
| 296 | probe: { | ||
| 297 | installed: true, | ||
| 298 | version, | ||
| 299 | status: "error", | ||
| 300 | auth: { status: "unknown" }, | ||
| 301 | message: "Pi CLI is installed but failed to run.", | ||
| 302 | }, | ||
| 303 | }); | ||
| 304 | } | ||
| 305 | |||
| 306 | if (version === null) { | ||
| 307 | return buildServerProvider({ | ||
| 308 | presentation: PI_PRESENTATION, | ||
| 309 | enabled: piSettings.enabled, | ||
| 310 | checkedAt, | ||
| 311 | models: fallbackModels, | ||
| 312 | probe: { | ||
| 313 | installed: true, | ||
| 314 | version: null, | ||
| 315 | status: "error", | ||
| 316 | auth: { status: "unknown" }, | ||
| 317 | message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, | ||
| 318 | }, | ||
| 319 | }); | ||
| 320 | } | ||
| 321 | |||
| 322 | if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { | ||
| 323 | return buildServerProvider({ | ||
| 324 | presentation: PI_PRESENTATION, | ||
| 325 | enabled: piSettings.enabled, | ||
| 326 | checkedAt, | ||
| 327 | models: fallbackModels, | ||
| 328 | probe: { | ||
| 329 | installed: true, | ||
| 330 | version, | ||
| 331 | status: "error", | ||
| 332 | auth: { status: "unknown" }, | ||
| 333 | message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, | ||
| 334 | }, | ||
| 335 | }); | ||
| 336 | } | ||
| 337 | |||
| 338 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); | ||
| 339 | if (!resolvedLaunchArgs.ok) { | ||
| 340 | return buildServerProvider({ | ||
| 341 | presentation: PI_PRESENTATION, | ||
| 342 | enabled: piSettings.enabled, | ||
| 343 | checkedAt, | ||
| 344 | models: fallbackModels, | ||
| 345 | probe: { | ||
| 346 | installed: true, | ||
| 347 | version, | ||
| 348 | status: "error", | ||
| 349 | auth: { status: "unknown" }, | ||
| 350 | message: resolvedLaunchArgs.message, | ||
| 351 | }, | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | |||
| 355 | const discoveryExit = yield* discoverPiViaRpc( | ||
| 356 | piSettings, | ||
| 357 | environment, | ||
| 358 | resolvedLaunchArgs.args, | ||
| 359 | cwd, | ||
| 360 | ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); | ||
| 361 | if (Exit.isFailure(discoveryExit)) { | ||
| 362 | yield* Effect.logWarning("Pi RPC discovery failed.", { | ||
| 363 | errorTag: causeErrorTag(discoveryExit.cause), | ||
| 364 | }); | ||
| 365 | return buildServerProvider({ | ||
| 366 | presentation: PI_PRESENTATION, | ||
| 367 | enabled: piSettings.enabled, | ||
| 368 | checkedAt, | ||
| 369 | models: fallbackModels, | ||
| 370 | probe: { | ||
| 371 | installed: true, | ||
| 372 | version, | ||
| 373 | status: "ready", | ||
| 374 | auth: { status: "unknown" }, | ||
| 375 | message: | ||
| 376 | "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", | ||
| 377 | }, | ||
| 378 | }); | ||
| 379 | } | ||
| 380 | if (Option.isNone(discoveryExit.value)) { | ||
| 381 | return buildServerProvider({ | ||
| 382 | presentation: PI_PRESENTATION, | ||
| 383 | enabled: piSettings.enabled, | ||
| 384 | checkedAt, | ||
| 385 | models: fallbackModels, | ||
| 386 | probe: { | ||
| 387 | installed: true, | ||
| 388 | version, | ||
| 389 | status: "ready", | ||
| 390 | auth: { status: "unknown" }, | ||
| 391 | message: | ||
| 392 | "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", | ||
| 393 | }, | ||
| 394 | }); | ||
| 395 | } | ||
| 396 | |||
| 397 | const discovery = discoveryExit.value.value; | ||
| 398 | const models = piModelsFromSettings(piSettings.customModels, discovery.models); | ||
| 399 | return buildServerProvider({ | ||
| 400 | presentation: PI_PRESENTATION, | ||
| 401 | enabled: piSettings.enabled, | ||
| 402 | checkedAt, | ||
| 403 | models, | ||
| 404 | slashCommands: discovery.slashCommands, | ||
| 405 | skills: discovery.skills, | ||
| 406 | probe: { | ||
| 407 | installed: true, | ||
| 408 | version, | ||
| 409 | status: discovery.authenticated ? "ready" : "warning", | ||
| 410 | auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, | ||
| 411 | ...(discovery.authenticated | ||
| 412 | ? {} | ||
| 413 | : { | ||
| 414 | message: | ||
| 415 | "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", | ||
| 416 | }), | ||
| 417 | }, | ||
| 418 | }); | ||
| 419 | }); | ||
| 420 | |||
| 421 | export const enrichPiSnapshot = (input: { | ||
| 422 | readonly snapshot: ServerProvider; | ||
| 423 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; | ||
| 424 | readonly enableProviderUpdateChecks?: boolean; | ||
| 425 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; | ||
| 426 | readonly httpClient: HttpClient.HttpClient; | ||
| 427 | }): Effect.Effect<void> => { | ||
| 428 | const { snapshot, publishSnapshot } = input; | ||
| 429 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { | ||
| 430 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, | ||
| 431 | }).pipe( | ||
| 432 | Effect.provideService(HttpClient.HttpClient, input.httpClient), | ||
| 433 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), | ||
| 434 | Effect.catchCause((cause) => | ||
| 435 | Effect.logWarning("Pi version advisory enrichment failed", { | ||
| 436 | errorTag: causeErrorTag(cause), | ||
| 437 | }), | ||
| 438 | ), | ||
| 439 | Effect.asVoid, | ||
| 440 | ); | ||
| 441 | }; | ||
apps/server/src/provider/ProviderAuthFlow.ts+1-1| ... | @@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -21,7 +21,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 21 | import * as Stream from "effect/Stream"; | 21 | import * as Stream from "effect/Stream"; |
| 22 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 22 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 23 | 23 | ||
| 24 | import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; | 24 | import type * as ProviderAuthService from "./ProviderAuthService.ts"; |
| 25 | 25 | ||
| 26 | export interface ProviderAuthFlowContext { | 26 | export interface ProviderAuthFlowContext { |
| 27 | readonly flowId: string; | 27 | readonly flowId: string; |
apps/server/src/provider/ProviderAuthService.test.ts created+979| ... | @@ -0,0 +1,979 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ProviderSessionId, | ||
| 6 | ProviderSetupError, | ||
| 7 | ProviderThreadId, | ||
| 8 | ThreadId, | ||
| 9 | type OrchestrationV2ProviderSession, | ||
| 10 | type OrchestrationV2ThreadShell, | ||
| 11 | type ProviderAuthState, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Deferred from "effect/Deferred"; | ||
| 16 | import * as Fiber from "effect/Fiber"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as PubSub from "effect/PubSub"; | ||
| 19 | import * as Queue from "effect/Queue"; | ||
| 20 | import * as Stream from "effect/Stream"; | ||
| 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 22 | |||
| 23 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 24 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| 25 | import { AcpProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AcpAdapterV2.ts"; | ||
| 26 | import type { ProviderInstance } from "./ProviderDriver.ts"; | ||
| 27 | import * as ProviderAuthService from "./ProviderAuthService.ts"; | ||
| 28 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| 29 | |||
| 30 | const instanceId = ProviderInstanceId.make("antigravity-personal"); | ||
| 31 | const otherInstanceId = ProviderInstanceId.make("antigravity-work"); | ||
| 32 | const unsupportedInstanceId = ProviderInstanceId.make("codex"); | ||
| 33 | const driverKind = ProviderDriverKind.make("antigravity"); | ||
| 34 | const owner = "paired-client-owner"; | ||
| 35 | const otherOwner = "paired-client-other"; | ||
| 36 | const flowId = "test-sign-in-flow"; | ||
| 37 | const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; | ||
| 38 | const now = "2026-09-02T00:00:00.000Z"; | ||
| 39 | const idleAuthState: ProviderAuthState = { | ||
| 40 | instanceId, | ||
| 41 | phase: "idle", | ||
| 42 | flowId: null, | ||
| 43 | authorizationUrl: null, | ||
| 44 | expiresAt: null, | ||
| 45 | message: null, | ||
| 46 | }; | ||
| 47 | const waitingAuthState: ProviderAuthState = { | ||
| 48 | ...idleAuthState, | ||
| 49 | phase: "waiting", | ||
| 50 | flowId, | ||
| 51 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state", | ||
| 52 | expiresAt: "2026-09-02T00:05:00.000Z", | ||
| 53 | }; | ||
| 54 | |||
| 55 | function makeInstance(input: { | ||
| 56 | instanceId: ProviderInstanceId; | ||
| 57 | enabled: boolean; | ||
| 58 | auth?: ProviderAuthService.ProviderAuthController; | ||
| 59 | }): ProviderInstance { | ||
| 60 | return { | ||
| 61 | ...input, | ||
| 62 | driverKind, | ||
| 63 | displayName: undefined, | ||
| 64 | continuationIdentity: { driverKind, continuationKey: input.instanceId }, | ||
| 65 | get snapshot(): never { | ||
| 66 | throw new Error("Auth routing must not refresh the provider snapshot."); | ||
| 67 | }, | ||
| 68 | get orchestrationAdapter(): never { | ||
| 69 | throw new Error("Auth routing must not start an adapter session."); | ||
| 70 | }, | ||
| 71 | get textGeneration(): never { | ||
| 72 | throw new Error("Auth routing must not generate text."); | ||
| 73 | }, | ||
| 74 | }; | ||
| 75 | } | ||
| 76 | |||
| 77 | const nowUtc = DateTime.makeUnsafe(now); | ||
| 78 | |||
| 79 | /** | ||
| 80 | * A thread bound to a provider instance with (or without) a live native | ||
| 81 | * thread. The auth service reads only the routing fields, so the rest of the | ||
| 82 | * shell row is left out. | ||
| 83 | */ | ||
| 84 | function makeThread( | ||
| 85 | thread: string, | ||
| 86 | input: { readonly providerInstanceId?: ProviderInstanceId; readonly active?: boolean } = {}, | ||
| 87 | ): OrchestrationV2ThreadShell { | ||
| 88 | const shell: Pick< | ||
| 89 | OrchestrationV2ThreadShell, | ||
| 90 | "id" | "providerInstanceId" | "activeProviderThreadId" | ||
| 91 | > = { | ||
| 92 | id: ThreadId.make(thread), | ||
| 93 | providerInstanceId: input.providerInstanceId ?? instanceId, | ||
| 94 | activeProviderThreadId: | ||
| 95 | input.active === false ? null : ProviderThreadId.make(`${thread}:native`), | ||
| 96 | }; | ||
| 97 | return shell as unknown as OrchestrationV2ThreadShell; | ||
| 98 | } | ||
| 99 | |||
| 100 | function makeSession( | ||
| 101 | session: string, | ||
| 102 | status: OrchestrationV2ProviderSession["status"] = "ready", | ||
| 103 | providerInstanceId = instanceId, | ||
| 104 | ): OrchestrationV2ProviderSession { | ||
| 105 | return { | ||
| 106 | id: ProviderSessionId.make(session), | ||
| 107 | driver: driverKind, | ||
| 108 | providerInstanceId, | ||
| 109 | status, | ||
| 110 | cwd: "/workspace", | ||
| 111 | model: null, | ||
| 112 | capabilities: AcpProviderCapabilitiesV2, | ||
| 113 | createdAt: nowUtc, | ||
| 114 | updatedAt: nowUtc, | ||
| 115 | lastError: null, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | function sessionThreads(list: ReadonlyArray<OrchestrationV2ProviderSession>) { | ||
| 120 | return { | ||
| 121 | threads: list.map((session) => makeThread(session.id)), | ||
| 122 | sessions: new Map(list.map((session) => [ThreadId.make(session.id), [session]])), | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* ( | ||
| 127 | input: { | ||
| 128 | enabled?: boolean; | ||
| 129 | threads?: ReadonlyArray<OrchestrationV2ThreadShell>; | ||
| 130 | sessions?: ReadonlyMap<ThreadId, ReadonlyArray<OrchestrationV2ProviderSession>>; | ||
| 131 | shellError?: boolean; | ||
| 132 | stopError?: boolean; | ||
| 133 | logoutError?: ProviderSetupError; | ||
| 134 | sharedCredentials?: boolean; | ||
| 135 | sharedBusy?: boolean; | ||
| 136 | sharedBusyEffect?: Effect.Effect<boolean>; | ||
| 137 | responds?: boolean; | ||
| 138 | beforeLogout?: Effect.Effect<void>; | ||
| 139 | beforeStop?: Effect.Effect<void>; | ||
| 140 | beforeListSessions?: Effect.Effect<void>; | ||
| 141 | onLookup?: Effect.Effect<void>; | ||
| 142 | onListThreads?: (instances: ProviderInstance[]) => void; | ||
| 143 | } = {}, | ||
| 144 | ) { | ||
| 145 | const actions: string[] = []; | ||
| 146 | const registryChanges = yield* PubSub.unbounded<void>(); | ||
| 147 | const threads = input.threads ?? []; | ||
| 148 | const sessions = new Map( | ||
| 149 | [...(input.sessions?.entries() ?? [])].map(([threadId, list]) => [threadId, [...list]]), | ||
| 150 | ); | ||
| 151 | const released: string[] = []; | ||
| 152 | const idle = idleAuthState; | ||
| 153 | let state = idle; | ||
| 154 | let flowOwner: string | undefined; | ||
| 155 | let gateClosed = false; | ||
| 156 | |||
| 157 | const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* ( | ||
| 158 | ownerSessionId: string, | ||
| 159 | requestedFlowId: string, | ||
| 160 | operation: string, | ||
| 161 | ) { | ||
| 162 | if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) { | ||
| 163 | return yield* new ProviderSetupError({ | ||
| 164 | instanceId, | ||
| 165 | operation, | ||
| 166 | detail: "This sign-in belongs to another client or has expired.", | ||
| 167 | }); | ||
| 168 | } | ||
| 169 | }); | ||
| 170 | |||
| 171 | const auth: ProviderAuthService.ProviderAuthController = { | ||
| 172 | ...(input.sharedCredentials | ||
| 173 | ? { credentialBinding: { owner: "provider" as const, key: "shared" } } | ||
| 174 | : {}), | ||
| 175 | ...(input.responds | ||
| 176 | ? { | ||
| 177 | respond: Effect.fn(function* (ownerSessionId, request) { | ||
| 178 | yield* checkOwner(ownerSessionId, request.flowId, "respond"); | ||
| 179 | actions.push(`respond:${request.response.type}`); | ||
| 180 | return state; | ||
| 181 | }), | ||
| 182 | } | ||
| 183 | : {}), | ||
| 184 | start: Effect.fn(function* (ownerSessionId, stopSessions) { | ||
| 185 | gateClosed = true; | ||
| 186 | actions.push("close-gate"); | ||
| 187 | yield* stopSessions ?? Effect.void; | ||
| 188 | flowOwner = ownerSessionId; | ||
| 189 | state = waitingAuthState; | ||
| 190 | actions.push("start-sign-in"); | ||
| 191 | return state; | ||
| 192 | }), | ||
| 193 | complete: Effect.fn(function* (ownerSessionId, request) { | ||
| 194 | yield* checkOwner(ownerSessionId, request.flowId, "complete"); | ||
| 195 | if (request.callbackUrl !== callbackUrl) { | ||
| 196 | return yield* new ProviderSetupError({ | ||
| 197 | instanceId, | ||
| 198 | operation: "complete", | ||
| 199 | detail: "The redirect URL does not match this sign-in.", | ||
| 200 | }); | ||
| 201 | } | ||
| 202 | state = { ...idle, flowId, phase: "succeeded" }; | ||
| 203 | return state; | ||
| 204 | }), | ||
| 205 | cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) { | ||
| 206 | yield* checkOwner(ownerSessionId, requestedFlowId, "cancel"); | ||
| 207 | state = { ...idle, flowId, phase: "cancelled" }; | ||
| 208 | return state; | ||
| 209 | }), | ||
| 210 | logout: Effect.fn(function* (stopSessions) { | ||
| 211 | gateClosed = true; | ||
| 212 | actions.push("close-gate"); | ||
| 213 | yield* stopSessions; | ||
| 214 | if (input.logoutError) return yield* input.logoutError; | ||
| 215 | yield* input.beforeLogout ?? Effect.void; | ||
| 216 | actions.push("native-logout"); | ||
| 217 | state = idle; | ||
| 218 | flowOwner = undefined; | ||
| 219 | return state; | ||
| 220 | }), | ||
| 221 | subscribe: (ownerSessionId) => | ||
| 222 | Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))), | ||
| 223 | isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", | ||
| 224 | }; | ||
| 225 | const instances = [ | ||
| 226 | makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), | ||
| 227 | makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), | ||
| 228 | ...(input.sharedCredentials | ||
| 229 | ? [ | ||
| 230 | makeInstance({ | ||
| 231 | instanceId: otherInstanceId, | ||
| 232 | enabled: true, | ||
| 233 | auth: { | ||
| 234 | ...auth, | ||
| 235 | isChangingCredentials: | ||
| 236 | input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), | ||
| 237 | invalidate: Effect.sync(() => { | ||
| 238 | actions.push("invalidate-shared"); | ||
| 239 | }), | ||
| 240 | }, | ||
| 241 | }), | ||
| 242 | ] | ||
| 243 | : []), | ||
| 244 | ]; | ||
| 245 | const service = yield* ProviderAuthService.makeProviderAuthService.pipe( | ||
| 246 | Effect.provide( | ||
| 247 | Layer.mergeAll( | ||
| 248 | Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ | ||
| 249 | getInstance: (id) => | ||
| 250 | Effect.gen(function* () { | ||
| 251 | const found = instances.find((instance) => instance.instanceId === id); | ||
| 252 | yield* input.onLookup ?? Effect.void; | ||
| 253 | return found; | ||
| 254 | }), | ||
| 255 | listInstances: Effect.succeed(instances), | ||
| 256 | subscribeChanges: PubSub.subscribe(registryChanges), | ||
| 257 | }), | ||
| 258 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | ||
| 259 | getRecoveryThreadIds: () => | ||
| 260 | Effect.gen(function* () { | ||
| 261 | assert.isTrue(gateClosed); | ||
| 262 | actions.push("list-threads"); | ||
| 263 | input.onListThreads?.(instances); | ||
| 264 | yield* input.beforeListSessions ?? Effect.void; | ||
| 265 | return yield* input.shellError | ||
| 266 | ? Effect.fail( | ||
| 267 | new ProjectionStore.ProjectionStoreReadError({ | ||
| 268 | threadId: ThreadId.make("shell"), | ||
| 269 | cause: new Error("private database diagnostics"), | ||
| 270 | }), | ||
| 271 | ) | ||
| 272 | : Effect.succeed(threads.map((thread) => thread.id)); | ||
| 273 | }), | ||
| 274 | getThreadRecords: (threadId) => | ||
| 275 | Effect.sync(() => { | ||
| 276 | actions.push(`sessions:${threadId}`); | ||
| 277 | return { | ||
| 278 | providerSessions: sessions.get(threadId) ?? [], | ||
| 279 | } as never; | ||
| 280 | }), | ||
| 281 | }), | ||
| 282 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ | ||
| 283 | release: ({ providerSessionId, reason }) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | assert.isTrue(gateClosed); | ||
| 286 | actions.push(`stop:${providerSessionId}`); | ||
| 287 | yield* input.beforeStop ?? Effect.void; | ||
| 288 | if (input.stopError) { | ||
| 289 | return yield* Effect.fail( | ||
| 290 | new ProviderSessionManager.ProviderSessionReleaseError({ | ||
| 291 | providerSessionId, | ||
| 292 | reason, | ||
| 293 | cause: new Error("private process diagnostics"), | ||
| 294 | }), | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | released.push(providerSessionId); | ||
| 298 | for (const [threadId, list] of sessions) { | ||
| 299 | const remaining = list.filter((session) => session.id !== providerSessionId); | ||
| 300 | if (remaining.length === 0) sessions.delete(threadId); | ||
| 301 | else sessions.set(threadId, remaining); | ||
| 302 | } | ||
| 303 | }), | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | ), | ||
| 307 | ); | ||
| 308 | return { | ||
| 309 | service, | ||
| 310 | actions, | ||
| 311 | released, | ||
| 312 | sessions, | ||
| 313 | auth, | ||
| 314 | addInstance: (instance: ProviderInstance) => instances.push(instance), | ||
| 315 | replaceInstance: (replacement: ProviderInstance) => { | ||
| 316 | const index = instances.findIndex( | ||
| 317 | (instance) => instance.instanceId === replacement.instanceId, | ||
| 318 | ); | ||
| 319 | assert.isAtLeast(index, 0); | ||
| 320 | instances[index] = replacement; | ||
| 321 | }, | ||
| 322 | replaceAuth: (next: ProviderAuthService.ProviderAuthController) => { | ||
| 323 | instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); | ||
| 324 | }, | ||
| 325 | }; | ||
| 326 | }); | ||
| 327 | |||
| 328 | const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( | ||
| 329 | function* (flowOwner: string) { | ||
| 330 | const state = yield* SubscriptionRef.make(idleAuthState); | ||
| 331 | const close = yield* Deferred.make<void>(); | ||
| 332 | const closedSubscriptions = yield* Queue.unbounded<string>(); | ||
| 333 | const unused = () => Effect.die("Unexpected auth operation in a subscription test."); | ||
| 334 | const auth: ProviderAuthService.ProviderAuthController = { | ||
| 335 | start: unused, | ||
| 336 | complete: unused, | ||
| 337 | cancel: unused, | ||
| 338 | logout: unused, | ||
| 339 | subscribe: (ownerSessionId) => | ||
| 340 | SubscriptionRef.changes(state).pipe( | ||
| 341 | Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)), | ||
| 342 | Stream.interruptWhen(Deferred.await(close)), | ||
| 343 | Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)), | ||
| 344 | ), | ||
| 345 | }; | ||
| 346 | return { | ||
| 347 | auth, | ||
| 348 | state, | ||
| 349 | closedSubscriptions, | ||
| 350 | close: Deferred.succeed(close, undefined), | ||
| 351 | }; | ||
| 352 | }, | ||
| 353 | ); | ||
| 354 | |||
| 355 | const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")( | ||
| 356 | function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) { | ||
| 357 | const changes = yield* PubSub.unbounded<void>(); | ||
| 358 | let current: ProviderInstance | undefined = initial; | ||
| 359 | let pendingReplacement = replaceDuringFirstLookup; | ||
| 360 | let subscribed = false; | ||
| 361 | const service = yield* ProviderAuthService.makeProviderAuthService.pipe( | ||
| 362 | Effect.provide( | ||
| 363 | Layer.mergeAll( | ||
| 364 | Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ | ||
| 365 | subscribeChanges: Effect.gen(function* () { | ||
| 366 | const subscription = yield* PubSub.subscribe(changes); | ||
| 367 | subscribed = true; | ||
| 368 | return subscription; | ||
| 369 | }), | ||
| 370 | getInstance: () => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | const instance = current; | ||
| 373 | if (pendingReplacement) { | ||
| 374 | assert.isTrue(subscribed, "Registry changes must be subscribed before lookup."); | ||
| 375 | current = pendingReplacement; | ||
| 376 | pendingReplacement = undefined; | ||
| 377 | yield* PubSub.publish(changes, undefined); | ||
| 378 | } | ||
| 379 | return instance; | ||
| 380 | }), | ||
| 381 | }), | ||
| 382 | Layer.mock(ProjectionStore.ProjectionStoreV2)({}), | ||
| 383 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), | ||
| 384 | ), | ||
| 385 | ), | ||
| 386 | ); | ||
| 387 | return { | ||
| 388 | service, | ||
| 389 | replace: Effect.fn(function* (replacement: ProviderInstance | undefined) { | ||
| 390 | current = replacement; | ||
| 391 | yield* PubSub.publish(changes, undefined); | ||
| 392 | }), | ||
| 393 | }; | ||
| 394 | }, | ||
| 395 | ); | ||
| 396 | |||
| 397 | const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* ( | ||
| 398 | stream: Stream.Stream<ProviderAuthState, ProviderSetupError>, | ||
| 399 | ) { | ||
| 400 | const states = yield* Queue.unbounded<ProviderAuthState>(); | ||
| 401 | const fiber = yield* stream.pipe( | ||
| 402 | Stream.runForEach((state) => Queue.offer(states, state)), | ||
| 403 | Effect.forkScoped, | ||
| 404 | ); | ||
| 405 | return { states, fiber }; | ||
| 406 | }); | ||
| 407 | |||
| 408 | describe("ProviderAuthService", () => { | ||
| 409 | it.effect( | ||
| 410 | "stops sessions sharing credentials and invalidates their processes before logout", | ||
| 411 | () => | ||
| 412 | Effect.gen(function* () { | ||
| 413 | const { service, actions, sessions } = yield* makeHarness({ | ||
| 414 | sharedCredentials: true, | ||
| 415 | ...sessionThreads([ | ||
| 416 | makeSession("target"), | ||
| 417 | makeSession("shared", "ready", otherInstanceId), | ||
| 418 | makeSession("unrelated", "ready", unsupportedInstanceId), | ||
| 419 | ]), | ||
| 420 | }); | ||
| 421 | yield* service.logout({ instanceId }); | ||
| 422 | assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); | ||
| 423 | assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); | ||
| 424 | assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); | ||
| 425 | }), | ||
| 426 | ); | ||
| 427 | it.effect("rejects overlapping changes to a shared sign-in", () => | ||
| 428 | Effect.gen(function* () { | ||
| 429 | const { service, actions } = yield* makeHarness({ | ||
| 430 | sharedCredentials: true, | ||
| 431 | sharedBusy: true, | ||
| 432 | }); | ||
| 433 | for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { | ||
| 434 | const error = yield* task.pipe(Effect.flip); | ||
| 435 | assert.include(error.detail, "shared sign-in"); | ||
| 436 | } | ||
| 437 | assert.deepStrictEqual(actions, []); | ||
| 438 | }), | ||
| 439 | ); | ||
| 440 | it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | const { service, actions } = yield* makeHarness({ responds: true }); | ||
| 443 | yield* service.start({ instanceId }, owner); | ||
| 444 | const request = { | ||
| 445 | instanceId, | ||
| 446 | flowId, | ||
| 447 | interactionId: "consent", | ||
| 448 | response: { type: "browser" as const, action: "accept" as const }, | ||
| 449 | }; | ||
| 450 | const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); | ||
| 451 | assert.strictEqual(rejected.operation, "respond"); | ||
| 452 | yield* service.respond(request, owner); | ||
| 453 | assert.strictEqual(actions.at(-1), "respond:browser"); | ||
| 454 | const unsupported = yield* makeHarness(); | ||
| 455 | const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); | ||
| 456 | assert.include(error.detail, "does not accept"); | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | it.effect("stops routed sessions before sign-in, including for a disabled instance", () => | ||
| 460 | Effect.gen(function* () { | ||
| 461 | const { service, actions, released } = yield* makeHarness({ | ||
| 462 | enabled: false, | ||
| 463 | threads: [makeThread("active")], | ||
| 464 | sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), | ||
| 465 | }); | ||
| 466 | const state = yield* service.start({ instanceId }, owner); | ||
| 467 | |||
| 468 | assert.strictEqual(state.instanceId, instanceId); | ||
| 469 | assert.strictEqual(state.phase, "waiting"); | ||
| 470 | assert.deepStrictEqual(released, ["active-session"]); | ||
| 471 | assert.deepStrictEqual(actions, [ | ||
| 472 | "close-gate", | ||
| 473 | "list-threads", | ||
| 474 | "sessions:active", | ||
| 475 | "stop:active-session", | ||
| 476 | "start-sign-in", | ||
| 477 | ]); | ||
| 478 | }), | ||
| 479 | ); | ||
| 480 | |||
| 481 | it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => | ||
| 482 | Effect.gen(function* () { | ||
| 483 | const { service } = yield* makeHarness(); | ||
| 484 | const waiting = yield* service.start({ instanceId }, owner); | ||
| 485 | const ownerStates = yield* service | ||
| 486 | .subscribe({ instanceId }, owner) | ||
| 487 | .pipe(Stream.take(1), Stream.runCollect); | ||
| 488 | const otherStates = yield* service | ||
| 489 | .subscribe({ instanceId }, otherOwner) | ||
| 490 | .pipe(Stream.take(1), Stream.runCollect); | ||
| 491 | |||
| 492 | assert.deepStrictEqual(ownerStates, [waiting]); | ||
| 493 | assert.strictEqual(otherStates[0]?.authorizationUrl, null); | ||
| 494 | assert.strictEqual(otherStates[0]?.flowId, null); | ||
| 495 | |||
| 496 | const otherError = yield* Effect.flip( | ||
| 497 | service.complete({ instanceId, flowId, callbackUrl }, otherOwner), | ||
| 498 | ); | ||
| 499 | assert.strictEqual(otherError.operation, "complete"); | ||
| 500 | const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner); | ||
| 501 | assert.strictEqual(complete.phase, "succeeded"); | ||
| 502 | assert.strictEqual(complete.authorizationUrl, null); | ||
| 503 | }), | ||
| 504 | ); | ||
| 505 | |||
| 506 | it.effect("lets only the flow owner cancel sign-in", () => | ||
| 507 | Effect.gen(function* () { | ||
| 508 | const { service } = yield* makeHarness(); | ||
| 509 | yield* service.start({ instanceId }, owner); | ||
| 510 | const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner)); | ||
| 511 | assert.strictEqual(error.operation, "cancel"); | ||
| 512 | |||
| 513 | const cancelled = yield* service.cancel({ instanceId, flowId }, owner); | ||
| 514 | assert.strictEqual(cancelled.phase, "cancelled"); | ||
| 515 | assert.strictEqual(cancelled.authorizationUrl, null); | ||
| 516 | }), | ||
| 517 | ); | ||
| 518 | |||
| 519 | it.effect.each([ | ||
| 520 | { change: "enable", initialEnabled: false, closeBeforeReplacement: true }, | ||
| 521 | { change: "config", initialEnabled: true, closeBeforeReplacement: false }, | ||
| 522 | ])( | ||
| 523 | "keeps private auth subscriptions current after an instance $change change", | ||
| 524 | ({ initialEnabled, closeBeforeReplacement }) => | ||
| 525 | Effect.gen(function* () { | ||
| 526 | const first = yield* makeStreamingController(owner); | ||
| 527 | const replacement = yield* makeStreamingController(otherOwner); | ||
| 528 | yield* SubscriptionRef.set(first.state, waitingAuthState); | ||
| 529 | const { service, replace } = yield* makeSubscriptionHarness( | ||
| 530 | makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }), | ||
| 531 | ); | ||
| 532 | const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner)); | ||
| 533 | const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner)); | ||
| 534 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState); | ||
| 535 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); | ||
| 536 | |||
| 537 | if (closeBeforeReplacement) { | ||
| 538 | yield* first.close; | ||
| 539 | const closed = yield* Queue.takeN(first.closedSubscriptions, 2); | ||
| 540 | assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); | ||
| 541 | } | ||
| 542 | yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth })); | ||
| 543 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); | ||
| 544 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState); | ||
| 545 | if (!closeBeforeReplacement) { | ||
| 546 | const closed = yield* Queue.takeN(first.closedSubscriptions, 2); | ||
| 547 | assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner])); | ||
| 548 | } | ||
| 549 | |||
| 550 | const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" }; | ||
| 551 | yield* SubscriptionRef.set(first.state, staleState); | ||
| 552 | const newWaiting: ProviderAuthState = { | ||
| 553 | ...waitingAuthState, | ||
| 554 | flowId: "replacement-flow", | ||
| 555 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state", | ||
| 556 | }; | ||
| 557 | yield* SubscriptionRef.set(replacement.state, newWaiting); | ||
| 558 | assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState); | ||
| 559 | assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting); | ||
| 560 | }), | ||
| 561 | ); | ||
| 562 | |||
| 563 | it.effect("does not miss a replacement during the first instance lookup", () => | ||
| 564 | Effect.gen(function* () { | ||
| 565 | const first = yield* makeStreamingController(owner); | ||
| 566 | const replacement = yield* makeStreamingController(owner); | ||
| 567 | yield* SubscriptionRef.set(replacement.state, waitingAuthState); | ||
| 568 | const { service } = yield* makeSubscriptionHarness( | ||
| 569 | makeInstance({ instanceId, enabled: false, auth: first.auth }), | ||
| 570 | makeInstance({ instanceId, enabled: true, auth: replacement.auth }), | ||
| 571 | ); | ||
| 572 | |||
| 573 | const states = yield* service.subscribe({ instanceId }, owner).pipe( | ||
| 574 | Stream.filter((state) => state.phase === "waiting"), | ||
| 575 | Stream.take(1), | ||
| 576 | Stream.runCollect, | ||
| 577 | ); | ||
| 578 | |||
| 579 | assert.deepStrictEqual(states, [waitingAuthState]); | ||
| 580 | }), | ||
| 581 | ); | ||
| 582 | |||
| 583 | it.effect("ends the subscription with a setup error when the instance is removed", () => | ||
| 584 | Effect.gen(function* () { | ||
| 585 | const controller = yield* makeStreamingController(owner); | ||
| 586 | const { service, replace } = yield* makeSubscriptionHarness( | ||
| 587 | makeInstance({ instanceId, enabled: true, auth: controller.auth }), | ||
| 588 | ); | ||
| 589 | const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner)); | ||
| 590 | assert.deepStrictEqual(yield* Queue.take(states), idleAuthState); | ||
| 591 | |||
| 592 | yield* replace(undefined); | ||
| 593 | const error = yield* Effect.flip(Fiber.join(fiber)); | ||
| 594 | |||
| 595 | assert.instanceOf(error, ProviderSetupError); | ||
| 596 | assert.strictEqual(error.instanceId, instanceId); | ||
| 597 | assert.strictEqual(error.operation, "subscribe"); | ||
| 598 | assert.include(error.detail, "no longer available"); | ||
| 599 | assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner); | ||
| 600 | }), | ||
| 601 | ); | ||
| 602 | |||
| 603 | it.effect.each([ | ||
| 604 | { id: ProviderInstanceId.make("missing"), detail: "no longer available" }, | ||
| 605 | { id: unsupportedInstanceId, detail: "does not support sign-in" }, | ||
| 606 | ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const { service, actions } = yield* makeHarness(); | ||
| 609 | const operations: Effect.Effect< | ||
| 610 | ProviderAuthState | ReadonlyArray<ProviderAuthState>, | ||
| 611 | ProviderSetupError | ||
| 612 | >[] = [ | ||
| 613 | service.start({ instanceId: id }, owner), | ||
| 614 | service.complete({ instanceId: id, flowId, callbackUrl }, owner), | ||
| 615 | service.cancel({ instanceId: id, flowId }, owner), | ||
| 616 | service.logout({ instanceId: id }), | ||
| 617 | Stream.runCollect(service.subscribe({ instanceId: id }, owner)), | ||
| 618 | ]; | ||
| 619 | for (const operation of operations) { | ||
| 620 | const error = yield* Effect.flip(operation); | ||
| 621 | assert.instanceOf(error, ProviderSetupError); | ||
| 622 | assert.strictEqual(error.instanceId, id); | ||
| 623 | assert.include(error.detail, detail); | ||
| 624 | } | ||
| 625 | assert.deepStrictEqual(actions, []); | ||
| 626 | }), | ||
| 627 | ); | ||
| 628 | |||
| 629 | it.effect("stops every live session on the instance before native logout", () => | ||
| 630 | Effect.gen(function* () { | ||
| 631 | const { service, actions, released } = yield* makeHarness({ | ||
| 632 | threads: [ | ||
| 633 | makeThread("running"), | ||
| 634 | makeThread("starting"), | ||
| 635 | makeThread("idle", { active: false }), | ||
| 636 | makeThread("other-instance", { providerInstanceId: otherInstanceId }), | ||
| 637 | ], | ||
| 638 | sessions: new Map([ | ||
| 639 | [ | ||
| 640 | ThreadId.make("running"), | ||
| 641 | [ | ||
| 642 | makeSession("running-session", "running"), | ||
| 643 | makeSession("stopped-session", "stopped"), | ||
| 644 | makeSession("errored-session", "error"), | ||
| 645 | makeSession("foreign-session", "ready", otherInstanceId), | ||
| 646 | ], | ||
| 647 | ], | ||
| 648 | [ThreadId.make("starting"), [makeSession("starting-session", "starting")]], | ||
| 649 | [ThreadId.make("idle"), [makeSession("idle-session")]], | ||
| 650 | [ | ||
| 651 | ThreadId.make("other-instance"), | ||
| 652 | [makeSession("other-session", "ready", otherInstanceId)], | ||
| 653 | ], | ||
| 654 | ]), | ||
| 655 | }); | ||
| 656 | const state = yield* service.logout({ instanceId }); | ||
| 657 | |||
| 658 | assert.strictEqual(state.phase, "idle"); | ||
| 659 | assert.deepStrictEqual(actions, [ | ||
| 660 | "close-gate", | ||
| 661 | "list-threads", | ||
| 662 | "sessions:running", | ||
| 663 | "stop:running-session", | ||
| 664 | "sessions:starting", | ||
| 665 | "stop:starting-session", | ||
| 666 | "sessions:idle", | ||
| 667 | "stop:idle-session", | ||
| 668 | "sessions:other-instance", | ||
| 669 | "native-logout", | ||
| 670 | ]); | ||
| 671 | assert.deepStrictEqual(released, ["running-session", "starting-session", "idle-session"]); | ||
| 672 | }), | ||
| 673 | ); | ||
| 674 | |||
| 675 | it.effect( | ||
| 676 | "releases a shared native session once after a thread's selected provider changes", | ||
| 677 | () => | ||
| 678 | Effect.gen(function* () { | ||
| 679 | const shared = makeSession("shared-native-session"); | ||
| 680 | const { service, released } = yield* makeHarness({ | ||
| 681 | threads: [ | ||
| 682 | makeThread("changed-selection", { providerInstanceId: otherInstanceId }), | ||
| 683 | makeThread("same-session"), | ||
| 684 | ], | ||
| 685 | sessions: new Map([ | ||
| 686 | [ThreadId.make("changed-selection"), [shared]], | ||
| 687 | [ThreadId.make("same-session"), [shared]], | ||
| 688 | ]), | ||
| 689 | }); | ||
| 690 | |||
| 691 | yield* service.logout({ instanceId }); | ||
| 692 | |||
| 693 | assert.deepStrictEqual(released, [shared.id]); | ||
| 694 | }), | ||
| 695 | ); | ||
| 696 | |||
| 697 | it.effect.each([ | ||
| 698 | { text: "/logout", hasAttachments: false, handled: true }, | ||
| 699 | { text: " \n/logout\t", hasAttachments: false, handled: true }, | ||
| 700 | { text: "/logout", hasAttachments: true, handled: false }, | ||
| 701 | { text: "/logout please", hasAttachments: false, handled: false }, | ||
| 702 | { text: "Explain /logout", hasAttachments: false, handled: false }, | ||
| 703 | { text: "/Logout", hasAttachments: false, handled: false }, | ||
| 704 | ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => | ||
| 705 | Effect.gen(function* () { | ||
| 706 | const { service, actions } = yield* makeHarness(); | ||
| 707 | const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); | ||
| 708 | |||
| 709 | assert.strictEqual(result, handled); | ||
| 710 | assert.deepStrictEqual( | ||
| 711 | actions, | ||
| 712 | handled ? ["close-gate", "list-threads", "native-logout"] : [], | ||
| 713 | ); | ||
| 714 | }), | ||
| 715 | ); | ||
| 716 | |||
| 717 | it.effect("does not intercept commands for providers without an auth controller", () => | ||
| 718 | Effect.gen(function* () { | ||
| 719 | const { service, actions } = yield* makeHarness(); | ||
| 720 | for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { | ||
| 721 | assert.isFalse( | ||
| 722 | yield* service.tryHandlePromptCommand({ | ||
| 723 | instanceId: id, | ||
| 724 | text: "/logout", | ||
| 725 | hasAttachments: false, | ||
| 726 | }), | ||
| 727 | ); | ||
| 728 | } | ||
| 729 | assert.deepStrictEqual(actions, []); | ||
| 730 | }), | ||
| 731 | ); | ||
| 732 | |||
| 733 | it.effect("does not log out when the thread shell cannot be read", () => | ||
| 734 | Effect.gen(function* () { | ||
| 735 | const { service, actions } = yield* makeHarness({ shellError: true }); | ||
| 736 | const error = yield* Effect.flip(service.logout({ instanceId })); | ||
| 737 | |||
| 738 | assert.instanceOf(error, ProviderSetupError); | ||
| 739 | assert.strictEqual(error.instanceId, instanceId); | ||
| 740 | assert.strictEqual(error.operation, "stopSessions"); | ||
| 741 | assert.notInclude(error.detail, "private database diagnostics"); | ||
| 742 | assert.deepStrictEqual(actions, ["close-gate", "list-threads"]); | ||
| 743 | }), | ||
| 744 | ); | ||
| 745 | |||
| 746 | it.effect("does not log out or consume the command when stopping a session fails", () => | ||
| 747 | Effect.gen(function* () { | ||
| 748 | const { service, actions, released } = yield* makeHarness({ | ||
| 749 | threads: [makeThread("active")], | ||
| 750 | sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), | ||
| 751 | stopError: true, | ||
| 752 | }); | ||
| 753 | const error = yield* Effect.flip( | ||
| 754 | service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), | ||
| 755 | ); | ||
| 756 | |||
| 757 | assert.instanceOf(error, ProviderSetupError); | ||
| 758 | assert.strictEqual(error.operation, "stopSessions"); | ||
| 759 | assert.notInclude(error.detail, "private process diagnostics"); | ||
| 760 | assert.deepStrictEqual(released, []); | ||
| 761 | assert.deepStrictEqual(actions, [ | ||
| 762 | "close-gate", | ||
| 763 | "list-threads", | ||
| 764 | "sessions:active", | ||
| 765 | "stop:active-session", | ||
| 766 | ]); | ||
| 767 | }), | ||
| 768 | ); | ||
| 769 | |||
| 770 | it.effect("returns native logout failures to the command caller", () => | ||
| 771 | Effect.gen(function* () { | ||
| 772 | const logoutError = new ProviderSetupError({ | ||
| 773 | instanceId, | ||
| 774 | operation: "logout", | ||
| 775 | detail: "Native sign-out failed. Try again.", | ||
| 776 | }); | ||
| 777 | const { service } = yield* makeHarness({ logoutError }); | ||
| 778 | const error = yield* Effect.flip( | ||
| 779 | service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), | ||
| 780 | ); | ||
| 781 | |||
| 782 | assert.strictEqual(error, logoutError); | ||
| 783 | }), | ||
| 784 | ); | ||
| 785 | }); | ||
| 786 | |||
| 787 | it.effect("queued logout prompts resolve the current controller after provider replacement", () => | ||
| 788 | Effect.gen(function* () { | ||
| 789 | const entered = yield* Deferred.make<void>(); | ||
| 790 | const release = yield* Deferred.make<void>(); | ||
| 791 | const lookup = yield* Deferred.make<void>(); | ||
| 792 | let observeLookup = false; | ||
| 793 | const harness = yield* makeHarness({ | ||
| 794 | beforeLogout: Deferred.succeed(entered, undefined).pipe( | ||
| 795 | Effect.andThen(Deferred.await(release)), | ||
| 796 | ), | ||
| 797 | onLookup: Effect.suspend(() => | ||
| 798 | observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, | ||
| 799 | ), | ||
| 800 | }); | ||
| 801 | const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 802 | yield* Deferred.await(entered); | ||
| 803 | observeLookup = true; | ||
| 804 | const queued = yield* harness.service | ||
| 805 | .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) | ||
| 806 | .pipe(Effect.forkChild); | ||
| 807 | yield* Deferred.await(lookup); | ||
| 808 | let replacementLoggedOut = false; | ||
| 809 | harness.replaceAuth({ | ||
| 810 | ...harness.auth, | ||
| 811 | logout: (stopSessions) => | ||
| 812 | stopSessions.pipe( | ||
| 813 | Effect.andThen( | ||
| 814 | Effect.sync(() => { | ||
| 815 | replacementLoggedOut = true; | ||
| 816 | return idleAuthState; | ||
| 817 | }), | ||
| 818 | ), | ||
| 819 | ), | ||
| 820 | }); | ||
| 821 | yield* Deferred.succeed(release, undefined); | ||
| 822 | yield* Fiber.join(first); | ||
| 823 | assert.isTrue(yield* Fiber.join(queued)); | ||
| 824 | assert.isTrue(replacementLoggedOut); | ||
| 825 | assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); | ||
| 826 | }).pipe(Effect.scoped), | ||
| 827 | ); | ||
| 828 | |||
| 829 | it.effect.each(["start", "logout", "prompt"] as const)( | ||
| 830 | "%s uses the same credential controller for shared exclusion and mutation during replacement", | ||
| 831 | (action) => | ||
| 832 | Effect.gen(function* () { | ||
| 833 | const checked = yield* Deferred.make<void>(); | ||
| 834 | const continueCheck = yield* Deferred.make<void>(); | ||
| 835 | const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); | ||
| 836 | const harness = yield* makeHarness({ | ||
| 837 | sharedCredentials: true, | ||
| 838 | sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( | ||
| 839 | Effect.andThen(Deferred.await(continueCheck)), | ||
| 840 | Effect.as(false), | ||
| 841 | ), | ||
| 842 | ...sessionThreads([ | ||
| 843 | makeSession("old-shared", "ready", otherInstanceId), | ||
| 844 | makeSession("replacement-shared", "ready", replacementPeerId), | ||
| 845 | ]), | ||
| 846 | }); | ||
| 847 | harness.addInstance( | ||
| 848 | makeInstance({ | ||
| 849 | instanceId: replacementPeerId, | ||
| 850 | enabled: true, | ||
| 851 | auth: { | ||
| 852 | ...harness.auth, | ||
| 853 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | ||
| 854 | isChangingCredentials: Effect.succeed(true), | ||
| 855 | invalidate: Effect.die( | ||
| 856 | "The unrelated replacement credential binding must stay intact.", | ||
| 857 | ), | ||
| 858 | }, | ||
| 859 | }), | ||
| 860 | ); | ||
| 861 | const operation = | ||
| 862 | action === "start" | ||
| 863 | ? harness.service.start({ instanceId }, owner) | ||
| 864 | : action === "logout" | ||
| 865 | ? harness.service.logout({ instanceId }) | ||
| 866 | : harness.service.tryHandlePromptCommand({ | ||
| 867 | instanceId, | ||
| 868 | text: "/logout", | ||
| 869 | hasAttachments: false, | ||
| 870 | }); | ||
| 871 | const running = yield* operation.pipe(Effect.forkChild); | ||
| 872 | yield* Deferred.await(checked); | ||
| 873 | let replacementMutations = 0; | ||
| 874 | harness.replaceAuth({ | ||
| 875 | ...harness.auth, | ||
| 876 | credentialBinding: { owner: "provider", key: "replacement-binding" }, | ||
| 877 | start: () => | ||
| 878 | Effect.sync(() => { | ||
| 879 | replacementMutations++; | ||
| 880 | return waitingAuthState; | ||
| 881 | }), | ||
| 882 | logout: () => | ||
| 883 | Effect.sync(() => { | ||
| 884 | replacementMutations++; | ||
| 885 | return idleAuthState; | ||
| 886 | }), | ||
| 887 | }); | ||
| 888 | yield* Deferred.succeed(continueCheck, undefined); | ||
| 889 | yield* Fiber.join(running); | ||
| 890 | assert.equal(replacementMutations, 0); | ||
| 891 | assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); | ||
| 892 | assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); | ||
| 893 | assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); | ||
| 894 | const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); | ||
| 895 | assert.include(blocked.detail, "shared sign-in"); | ||
| 896 | assert.equal(replacementMutations, 0); | ||
| 897 | }).pipe(Effect.scoped), | ||
| 898 | ); | ||
| 899 | |||
| 900 | it.effect.each([ | ||
| 901 | { owner: "provider" as const, key: "different-binding" }, | ||
| 902 | { owner: "t3" as const, key: "shared" }, | ||
| 903 | ])( | ||
| 904 | "does not invalidate a peer that switches credential binding during session draining %#", | ||
| 905 | (binding) => | ||
| 906 | Effect.gen(function* () { | ||
| 907 | const draining = yield* Deferred.make<void>(); | ||
| 908 | const continueDrain = yield* Deferred.make<void>(); | ||
| 909 | const harness = yield* makeHarness({ | ||
| 910 | sharedCredentials: true, | ||
| 911 | ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), | ||
| 912 | beforeStop: Deferred.succeed(draining, undefined).pipe( | ||
| 913 | Effect.andThen(Deferred.await(continueDrain)), | ||
| 914 | ), | ||
| 915 | }); | ||
| 916 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 917 | yield* Deferred.await(draining); | ||
| 918 | let replacementInvalidated = false; | ||
| 919 | harness.replaceInstance( | ||
| 920 | makeInstance({ | ||
| 921 | instanceId: otherInstanceId, | ||
| 922 | enabled: true, | ||
| 923 | auth: { | ||
| 924 | ...harness.auth, | ||
| 925 | credentialBinding: binding, | ||
| 926 | invalidate: Effect.sync(() => { | ||
| 927 | replacementInvalidated = true; | ||
| 928 | }), | ||
| 929 | }, | ||
| 930 | }), | ||
| 931 | ); | ||
| 932 | yield* Deferred.succeed(continueDrain, undefined); | ||
| 933 | yield* Fiber.join(logout); | ||
| 934 | assert.isFalse(replacementInvalidated); | ||
| 935 | assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); | ||
| 936 | assert.include(harness.actions, "native-logout"); | ||
| 937 | }).pipe(Effect.scoped), | ||
| 938 | ); | ||
| 939 | |||
| 940 | it.effect.each(["selection", "drain"] as const)( | ||
| 941 | "preserves replacement peer sessions when credentials change during session %s", | ||
| 942 | (phase) => | ||
| 943 | Effect.gen(function* () { | ||
| 944 | const entered = yield* Deferred.make<void>(); | ||
| 945 | const proceed = yield* Deferred.make<void>(); | ||
| 946 | const block = Deferred.succeed(entered, undefined).pipe( | ||
| 947 | Effect.andThen(Deferred.await(proceed)), | ||
| 948 | ); | ||
| 949 | const harness = yield* makeHarness({ | ||
| 950 | sharedCredentials: true, | ||
| 951 | ...sessionThreads([ | ||
| 952 | makeSession("target-draining"), | ||
| 953 | makeSession("peer-replacement", "ready", otherInstanceId), | ||
| 954 | makeSession("peer-persisted", "running", otherInstanceId), | ||
| 955 | ]), | ||
| 956 | ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), | ||
| 957 | }); | ||
| 958 | const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); | ||
| 959 | yield* Deferred.await(entered); | ||
| 960 | harness.replaceInstance( | ||
| 961 | makeInstance({ | ||
| 962 | instanceId: otherInstanceId, | ||
| 963 | enabled: true, | ||
| 964 | auth: { | ||
| 965 | ...harness.auth, | ||
| 966 | credentialBinding: { owner: "provider", key: "replacement-credentials" }, | ||
| 967 | invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), | ||
| 968 | }, | ||
| 969 | }), | ||
| 970 | ); | ||
| 971 | yield* Deferred.succeed(proceed, undefined); | ||
| 972 | yield* Fiber.join(logout); | ||
| 973 | assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); | ||
| 974 | assert.notInclude(harness.actions, "stop:peer-replacement"); | ||
| 975 | assert.notInclude(harness.actions, "stop:peer-persisted"); | ||
| 976 | assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); | ||
| 977 | assert.include(harness.actions, "native-logout"); | ||
| 978 | }).pipe(Effect.scoped), | ||
| 979 | ); | ||
apps/server/src/provider/ProviderAuthService.ts created+348| ... | @@ -0,0 +1,348 @@ | ||
| 1 | import { | ||
| 2 | ProviderSetupError, | ||
| 3 | type ChatGptReconnectProfile, | ||
| 4 | type ChatGptTransferredProfile, | ||
| 5 | type ProviderAuthRespondInput, | ||
| 6 | type ProviderAuthStartInput, | ||
| 7 | type ProviderAuthState, | ||
| 8 | type ProviderInstanceId, | ||
| 9 | type ProviderSessionId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import type * as Scope from "effect/Scope"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as Semaphore from "effect/Semaphore"; | ||
| 17 | |||
| 18 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| 19 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ||
| 20 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| 21 | |||
| 22 | export interface ProviderAuthController { | ||
| 23 | /** Equal keys mean these instances share credentials on this environment. */ | ||
| 24 | readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; | ||
| 25 | readonly reconnectProfile?: ( | ||
| 26 | methodId: string, | ||
| 27 | ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>; | ||
| 28 | readonly importProfile?: ( | ||
| 29 | profile: ChatGptTransferredProfile, | ||
| 30 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 31 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 32 | readonly adoptCredentials?: ( | ||
| 33 | update: Effect.Effect<void, ProviderSetupError>, | ||
| 34 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 35 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 36 | readonly isChangingCredentials?: Effect.Effect<boolean>; | ||
| 37 | readonly invalidate?: Effect.Effect<void>; | ||
| 38 | readonly refreshMethods?: Effect.Effect<void>; | ||
| 39 | readonly withAccess?: <A, E, R>( | ||
| 40 | task: Effect.Effect<A, E, R>, | ||
| 41 | ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>; | ||
| 42 | readonly start: ( | ||
| 43 | ownerSessionId: string, | ||
| 44 | stopSessions?: Effect.Effect<void, ProviderSetupError>, | ||
| 45 | methodId?: string, | ||
| 46 | returnUrl?: string, | ||
| 47 | callbackMode?: "server" | "client", | ||
| 48 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 49 | readonly complete: ( | ||
| 50 | ownerSessionId: string, | ||
| 51 | input: { readonly flowId: string; readonly callbackUrl: string }, | ||
| 52 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 53 | readonly cancel: ( | ||
| 54 | ownerSessionId: string, | ||
| 55 | flowId: string, | ||
| 56 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 57 | readonly respond?: ( | ||
| 58 | ownerSessionId: string, | ||
| 59 | input: ProviderAuthRespondInput, | ||
| 60 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 61 | /** The controller closes process admission before it stops routed sessions. */ | ||
| 62 | readonly logout: ( | ||
| 63 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 64 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 65 | readonly subscribe: (ownerSessionId: string) => Stream.Stream<ProviderAuthState>; | ||
| 66 | readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; | ||
| 67 | } | ||
| 68 | |||
| 69 | interface ProviderAuthTarget { | ||
| 70 | readonly instanceId: ProviderInstanceId; | ||
| 71 | } | ||
| 72 | |||
| 73 | export class ProviderAuthService extends Context.Service< | ||
| 74 | ProviderAuthService, | ||
| 75 | { | ||
| 76 | readonly reconnectProfile: ( | ||
| 77 | input: ProviderAuthTarget & { methodId: string }, | ||
| 78 | ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>; | ||
| 79 | readonly importProfile: ( | ||
| 80 | input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, | ||
| 81 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 82 | readonly start: ( | ||
| 83 | input: ProviderAuthStartInput, | ||
| 84 | ownerSessionId: string, | ||
| 85 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 86 | readonly complete: ( | ||
| 87 | input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, | ||
| 88 | ownerSessionId: string, | ||
| 89 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 90 | readonly respond: ( | ||
| 91 | input: ProviderAuthRespondInput, | ||
| 92 | ownerSessionId: string, | ||
| 93 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 94 | readonly cancel: ( | ||
| 95 | input: ProviderAuthTarget & { readonly flowId: string }, | ||
| 96 | ownerSessionId: string, | ||
| 97 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 98 | readonly logout: ( | ||
| 99 | input: ProviderAuthTarget, | ||
| 100 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 101 | readonly subscribe: ( | ||
| 102 | input: ProviderAuthTarget, | ||
| 103 | ownerSessionId: string, | ||
| 104 | ) => Stream.Stream<ProviderAuthState, ProviderSetupError>; | ||
| 105 | readonly tryHandlePromptCommand: ( | ||
| 106 | input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, | ||
| 107 | ) => Effect.Effect<boolean, ProviderSetupError>; | ||
| 108 | } | ||
| 109 | >()("t3/provider/ProviderAuthService") {} | ||
| 110 | |||
| 111 | export const makeProviderAuthService = Effect.gen(function* () { | ||
| 112 | const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; | ||
| 113 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 114 | const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | ||
| 115 | const credentialChanges = yield* Semaphore.make(1); | ||
| 116 | |||
| 117 | const getController = Effect.fn("ProviderAuthService.getController")(function* ( | ||
| 118 | instanceId: ProviderInstanceId, | ||
| 119 | operation: string, | ||
| 120 | ) { | ||
| 121 | const instance = yield* registry.getInstance(instanceId); | ||
| 122 | if (!instance?.auth) { | ||
| 123 | return yield* new ProviderSetupError({ | ||
| 124 | instanceId, | ||
| 125 | operation, | ||
| 126 | detail: instance | ||
| 127 | ? "This provider does not support sign-in in T3 Code." | ||
| 128 | : "This provider instance is no longer available.", | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | return instance.auth; | ||
| 132 | }); | ||
| 133 | |||
| 134 | // Native sessions may still belong to the previous provider after the | ||
| 135 | // selected model changes. Read session bindings, not the selected model, | ||
| 136 | // when invalidating credentials for sign-in or sign-out. | ||
| 137 | const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( | ||
| 138 | instanceId: ProviderInstanceId, | ||
| 139 | binding: ProviderAuthController["credentialBinding"], | ||
| 140 | ) { | ||
| 141 | const failure = (detail: string) => | ||
| 142 | new ProviderSetupError({ instanceId, operation: "stopSessions", detail }); | ||
| 143 | const affectedIds = new Set([ | ||
| 144 | instanceId, | ||
| 145 | ...(binding === undefined | ||
| 146 | ? [] | ||
| 147 | : (yield* registry.listInstances) | ||
| 148 | .filter( | ||
| 149 | (instance) => | ||
| 150 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 151 | instance.auth.credentialBinding.owner === binding.owner, | ||
| 152 | ) | ||
| 153 | .map((instance) => instance.instanceId)), | ||
| 154 | ]); | ||
| 155 | const threadIds = yield* projections | ||
| 156 | .getRecoveryThreadIds("runtime") | ||
| 157 | .pipe( | ||
| 158 | Effect.mapError(() => failure("Could not read the provider's active sessions. Try again.")), | ||
| 159 | ); | ||
| 160 | const released = new Set<ProviderSessionId>(); | ||
| 161 | yield* Effect.forEach( | ||
| 162 | threadIds, | ||
| 163 | (threadId) => | ||
| 164 | projections.getThreadRecords(threadId, ["providerSessions"]).pipe( | ||
| 165 | Effect.flatMap((projection) => | ||
| 166 | Effect.forEach( | ||
| 167 | projection.providerSessions.filter( | ||
| 168 | (session) => | ||
| 169 | affectedIds.has(session.providerInstanceId) && | ||
| 170 | session.status !== "stopped" && | ||
| 171 | session.status !== "error" && | ||
| 172 | !released.has(session.id), | ||
| 173 | ), | ||
| 174 | (session) => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | if (session.providerInstanceId !== instanceId) { | ||
| 177 | const current = yield* registry.getInstance(session.providerInstanceId); | ||
| 178 | if ( | ||
| 179 | !binding || | ||
| 180 | current?.auth?.credentialBinding?.key !== binding.key || | ||
| 181 | current.auth.credentialBinding.owner !== binding.owner | ||
| 182 | ) | ||
| 183 | return; | ||
| 184 | } | ||
| 185 | yield* providerSessions | ||
| 186 | .release({ | ||
| 187 | providerSessionId: session.id, | ||
| 188 | reason: "manual_shutdown", | ||
| 189 | detail: "Provider sign-in changed.", | ||
| 190 | }) | ||
| 191 | .pipe(Effect.tap(() => Effect.sync(() => released.add(session.id)))); | ||
| 192 | }), | ||
| 193 | { discard: true }, | ||
| 194 | ), | ||
| 195 | ), | ||
| 196 | Effect.mapError(() => | ||
| 197 | failure("Could not stop all sessions for this provider. Try again."), | ||
| 198 | ), | ||
| 199 | ), | ||
| 200 | { discard: true }, | ||
| 201 | ); | ||
| 202 | if (binding) { | ||
| 203 | yield* Effect.forEach( | ||
| 204 | (yield* registry.listInstances).filter( | ||
| 205 | (instance) => | ||
| 206 | instance.instanceId !== instanceId && | ||
| 207 | affectedIds.has(instance.instanceId) && | ||
| 208 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 209 | instance.auth.credentialBinding.owner === binding.owner, | ||
| 210 | ), | ||
| 211 | (instance) => instance.auth?.invalidate ?? Effect.void, | ||
| 212 | { discard: true }, | ||
| 213 | ); | ||
| 214 | } | ||
| 215 | }); | ||
| 216 | |||
| 217 | const checkSharedBinding = Effect.fnUntraced(function* ( | ||
| 218 | instanceId: ProviderInstanceId, | ||
| 219 | operation: "start" | "logout", | ||
| 220 | auth: ProviderAuthController, | ||
| 221 | ) { | ||
| 222 | const binding = auth.credentialBinding; | ||
| 223 | if (!binding) return; | ||
| 224 | const instances = yield* registry.listInstances; | ||
| 225 | for (const instance of instances) { | ||
| 226 | if ( | ||
| 227 | instance.instanceId !== instanceId && | ||
| 228 | instance.auth?.credentialBinding?.key === binding.key && | ||
| 229 | instance.auth.credentialBinding.owner === binding.owner && | ||
| 230 | instance.auth.isChangingCredentials && | ||
| 231 | (yield* instance.auth.isChangingCredentials) | ||
| 232 | ) { | ||
| 233 | return yield* new ProviderSetupError({ | ||
| 234 | instanceId, | ||
| 235 | operation, | ||
| 236 | detail: | ||
| 237 | "Another provider instance is changing this shared sign-in. Finish or cancel it first.", | ||
| 238 | }); | ||
| 239 | } | ||
| 240 | } | ||
| 241 | }); | ||
| 242 | |||
| 243 | return ProviderAuthService.of({ | ||
| 244 | reconnectProfile: Effect.fnUntraced(function* (input) { | ||
| 245 | const auth = yield* getController(input.instanceId, "export"); | ||
| 246 | if (!auth.reconnectProfile) | ||
| 247 | return yield* new ProviderSetupError({ | ||
| 248 | instanceId: input.instanceId, | ||
| 249 | operation: "export", | ||
| 250 | detail: "This provider does not support ChatGPT profile transfer.", | ||
| 251 | }); | ||
| 252 | return yield* auth.reconnectProfile(input.methodId); | ||
| 253 | }), | ||
| 254 | importProfile: (input) => | ||
| 255 | credentialChanges.withPermit( | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const auth = yield* getController(input.instanceId, "import"); | ||
| 258 | yield* checkSharedBinding(input.instanceId, "start", auth); | ||
| 259 | if (!auth.importProfile) | ||
| 260 | return yield* new ProviderSetupError({ | ||
| 261 | instanceId: input.instanceId, | ||
| 262 | operation: "import", | ||
| 263 | detail: "This provider does not support ChatGPT profile transfer.", | ||
| 264 | }); | ||
| 265 | return yield* auth.importProfile( | ||
| 266 | input.profile, | ||
| 267 | stopSessions(input.instanceId, auth.credentialBinding), | ||
| 268 | ); | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { | ||
| 272 | return yield* credentialChanges.withPermit( | ||
| 273 | Effect.gen(function* () { | ||
| 274 | const auth = yield* getController(input.instanceId, "start"); | ||
| 275 | yield* checkSharedBinding(input.instanceId, "start", auth); | ||
| 276 | return yield* auth.start( | ||
| 277 | ownerSessionId, | ||
| 278 | stopSessions(input.instanceId, auth.credentialBinding), | ||
| 279 | input.methodId, | ||
| 280 | input.returnUrl, | ||
| 281 | input.callbackMode, | ||
| 282 | ); | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | }), | ||
| 286 | respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { | ||
| 287 | const auth = yield* getController(input.instanceId, "respond"); | ||
| 288 | if (!auth.respond) { | ||
| 289 | return yield* new ProviderSetupError({ | ||
| 290 | instanceId: input.instanceId, | ||
| 291 | operation: "respond", | ||
| 292 | detail: "This provider does not accept this sign-in interaction.", | ||
| 293 | }); | ||
| 294 | } | ||
| 295 | return yield* auth.respond(ownerSessionId, input); | ||
| 296 | }), | ||
| 297 | complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { | ||
| 298 | const auth = yield* getController(input.instanceId, "complete"); | ||
| 299 | return yield* auth.complete(ownerSessionId, input); | ||
| 300 | }), | ||
| 301 | cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) { | ||
| 302 | const auth = yield* getController(input.instanceId, "cancel"); | ||
| 303 | return yield* auth.cancel(ownerSessionId, input.flowId); | ||
| 304 | }), | ||
| 305 | logout: Effect.fn("ProviderAuthService.logout")(function* (input) { | ||
| 306 | return yield* credentialChanges.withPermit( | ||
| 307 | Effect.gen(function* () { | ||
| 308 | const auth = yield* getController(input.instanceId, "logout"); | ||
| 309 | yield* checkSharedBinding(input.instanceId, "logout", auth); | ||
| 310 | return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | ||
| 311 | }), | ||
| 312 | ); | ||
| 313 | }), | ||
| 314 | subscribe: (input, ownerSessionId) => | ||
| 315 | Effect.gen(function* () { | ||
| 316 | const changes = yield* registry.subscribeChanges; | ||
| 317 | const initial = yield* getController(input.instanceId, "subscribe"); | ||
| 318 | return Stream.concat( | ||
| 319 | Stream.succeed(initial), | ||
| 320 | Stream.fromSubscription(changes).pipe( | ||
| 321 | Stream.mapEffect(() => getController(input.instanceId, "subscribe")), | ||
| 322 | ), | ||
| 323 | ).pipe( | ||
| 324 | Stream.changesWith((previous, next) => previous === next), | ||
| 325 | Stream.switchMap((auth) => auth.subscribe(ownerSessionId)), | ||
| 326 | ); | ||
| 327 | }).pipe(Stream.unwrap), | ||
| 328 | tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")( | ||
| 329 | function* (input) { | ||
| 330 | const instance = yield* registry.getInstance(input.instanceId); | ||
| 331 | if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { | ||
| 332 | return false; | ||
| 333 | } | ||
| 334 | return yield* credentialChanges.withPermit( | ||
| 335 | Effect.gen(function* () { | ||
| 336 | const auth = yield* getController(input.instanceId, "logout"); | ||
| 337 | if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; | ||
| 338 | yield* checkSharedBinding(input.instanceId, "logout", auth); | ||
| 339 | yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); | ||
| 340 | return true; | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | }, | ||
| 344 | ), | ||
| 345 | }); | ||
| 346 | }); | ||
| 347 | |||
| 348 | export const layer = Layer.effect(ProviderAuthService, makeProviderAuthService); | ||
apps/server/src/provider/ProviderDriver.ts+2-2| ... | @@ -40,8 +40,8 @@ import type * as Scope from "effect/Scope"; | ... | @@ -40,8 +40,8 @@ import type * as Scope from "effect/Scope"; |
| 40 | import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; | 40 | import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; |
| 41 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; | 41 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; |
| 42 | import type { ProviderDriverError } from "./Errors.ts"; | 42 | import type { ProviderDriverError } from "./Errors.ts"; |
| 43 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; | 43 | import type { ProviderAuthController } from "./ProviderAuthService.ts"; |
| 44 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 44 | import type { ServerProviderShape } from "./ServerProvider.ts"; |
| 45 | 45 | ||
| 46 | /** | 46 | /** |
| 47 | * Static metadata advertised by a driver. Used for default presentation | 47 | * Static metadata advertised by a driver. Used for default presentation |
apps/server/src/provider/ProviderEventLoggers.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | /** | ||
| 2 | * ProviderEventLoggers — single observability service that owns the shared | ||
| 3 | * provider event log store and exposes its two runtime views: | ||
| 4 | * | ||
| 5 | * - `native` — provider-protocol events as the SDK emits them, written | ||
| 6 | * from inside each `<X>Adapter` factory. | ||
| 7 | * - `canonical` — runtime events after `ProviderService` has normalized | ||
| 8 | * them onto `ProviderRuntimeEvent`. | ||
| 9 | * | ||
| 10 | * Why a service tag and not constructor options? | ||
| 11 | * | ||
| 12 | * - Adapters are now constructed *inside* drivers (`<X>Driver.create()`), | ||
| 13 | * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)` | ||
| 14 | * call site where we can hand an `EventNdjsonLogger` in by hand. | ||
| 15 | * - Multiple driver instances per kind (`codex_personal`, `codex_work`) | ||
| 16 | * must share one underlying log store — opening N writers against the | ||
| 17 | * same rotating file would race the rotation logic. Owning the loggers on | ||
| 18 | * a single tag keeps that invariant intact. | ||
| 19 | * - Tests can swap one (or both) loggers with in-memory recorders by | ||
| 20 | * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of | ||
| 21 | * juggling per-Layer option threading. | ||
| 22 | * | ||
| 23 | * Both fields are optional because observability must not prevent startup. | ||
| 24 | * | ||
| 25 | * @module provider/ProviderEventLoggers | ||
| 26 | */ | ||
| 27 | import * as Context from "effect/Context"; | ||
| 28 | import * as Effect from "effect/Effect"; | ||
| 29 | import * as Layer from "effect/Layer"; | ||
| 30 | |||
| 31 | import * as ServerConfig from "../config.ts"; | ||
| 32 | import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; | ||
| 33 | import * as EventNdjsonLogger from "./EventNdjsonLogger.ts"; | ||
| 34 | |||
| 35 | /** | ||
| 36 | * Shared logger pair for native + canonical provider event streams. | ||
| 37 | * | ||
| 38 | * Service value is intentionally a struct of two optional loggers rather | ||
| 39 | * than two parallel tags. Construction site is one place | ||
| 40 | * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the | ||
| 41 | * field they need. | ||
| 42 | */ | ||
| 43 | export class ProviderEventLoggers extends Context.Service< | ||
| 44 | ProviderEventLoggers, | ||
| 45 | { | ||
| 46 | readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 47 | readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined; | ||
| 48 | } | ||
| 49 | >()("t3/provider/ProviderEventLoggers") {} | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Constant value used by tests / boot layers that want to opt out of native | ||
| 53 | * + canonical logging entirely. Keeps the tag non-optional in the type | ||
| 54 | * system while letting the runtime treat absence as a no-op. | ||
| 55 | */ | ||
| 56 | export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { | ||
| 57 | native: undefined, | ||
| 58 | canonical: undefined, | ||
| 59 | }; | ||
| 60 | |||
| 61 | /** | ||
| 62 | * Builds both stream views over one shared store. Setup failures are logged | ||
| 63 | * and downgraded to the no-op service so diagnostics never block startup. | ||
| 64 | * | ||
| 65 | * @public Service construction is part of the canonical Effect module API. | ||
| 66 | */ | ||
| 67 | export const make = Effect.gen(function* () { | ||
| 68 | const { providerEventLogPath } = yield* ServerConfig.ServerConfig; | ||
| 69 | const attribution = yield* ResourceAttribution.ResourceAttribution; | ||
| 70 | const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, { | ||
| 71 | attribution, | ||
| 72 | }).pipe( | ||
| 73 | Effect.catch((error) => | ||
| 74 | Effect.logWarning(error.message, { error }).pipe( | ||
| 75 | Effect.annotateLogs({ scope: "provider-observability" }), | ||
| 76 | Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined), | ||
| 77 | ), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | if (!store) { | ||
| 82 | return ProviderEventLoggers.of(NoOpProviderEventLoggers); | ||
| 83 | } | ||
| 84 | |||
| 85 | yield* Effect.addFinalizer(() => store.close()); | ||
| 86 | return ProviderEventLoggers.of({ | ||
| 87 | native: store.logger("native"), | ||
| 88 | canonical: store.logger("canonical"), | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | |||
| 92 | export const layer = Layer.effect(ProviderEventLoggers, make); | ||
apps/server/src/provider/ProviderInstanceRegistry.test.ts created+781| ... | @@ -0,0 +1,781 @@ | ||
| 1 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 2 | import * as CodexInstallation from "./CodexInstallation.ts"; | ||
| 3 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 4 | /** | ||
| 5 | * Multi-instance validation slices for the live `ProviderInstanceRegistry`. | ||
| 6 | * | ||
| 7 | * Two axes of the driver/registry refactor are exercised here: | ||
| 8 | * | ||
| 9 | * 1. **Same driver, many instances** — the "multi-instance codex slice" | ||
| 10 | * describe block below configures two independent `codex` instances and | ||
| 11 | * asserts each gets its own closures and identity. This is the | ||
| 12 | * multi-codex capability the refactor exists to unlock. | ||
| 13 | * | ||
| 14 | * 2. **Many drivers, one registry** — the "all drivers slice" describe | ||
| 15 | * block below configures one instance of every shipped driver | ||
| 16 | * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single | ||
| 17 | * `ProviderInstanceConfigMap` and asserts the registry boots them all | ||
| 18 | * without cross-contamination. This proves the driver SPI is uniform | ||
| 19 | * across every provider — any driver plugs into the registry through | ||
| 20 | * the same `ProviderDriver` value contract. | ||
| 21 | * | ||
| 22 | * Every instance in these tests is configured with `enabled: false` so the | ||
| 23 | * provider-status checks short-circuit to pending/disabled snapshots | ||
| 24 | * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` | ||
| 25 | * binaries. That keeps the assertions focused on registry routing | ||
| 26 | * behaviour rather than the runtime details of each provider. | ||
| 27 | */ | ||
| 28 | import { describe, expect, it } from "@effect/vitest"; | ||
| 29 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 30 | import { | ||
| 31 | EnvironmentId, | ||
| 32 | type ClaudeSettings, | ||
| 33 | type CodexSettings, | ||
| 34 | type CursorSettings, | ||
| 35 | type GrokSettings, | ||
| 36 | type OpenCodeSettings, | ||
| 37 | ProviderDriverKind, | ||
| 38 | type ProviderInstanceConfigMap, | ||
| 39 | ProviderInstanceId, | ||
| 40 | } from "@t3tools/contracts"; | ||
| 41 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; | ||
| 42 | import * as DateTime from "effect/DateTime"; | ||
| 43 | import * as Effect from "effect/Effect"; | ||
| 44 | import * as FileSystem from "effect/FileSystem"; | ||
| 45 | import * as Layer from "effect/Layer"; | ||
| 46 | import * as Path from "effect/Path"; | ||
| 47 | import * as Stream from "effect/Stream"; | ||
| 48 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 49 | |||
| 50 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 51 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; | ||
| 52 | import * as ServerConfig from "../config.ts"; | ||
| 53 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 54 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 55 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; | ||
| 56 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | ||
| 57 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | ||
| 58 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | ||
| 59 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | ||
| 60 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 61 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | ||
| 62 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | ||
| 63 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 64 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | ||
| 65 | import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; | ||
| 66 | import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; | ||
| 67 | |||
| 68 | const layerTestHttpClient = Layer.succeed( | ||
| 69 | HttpClient.HttpClient, | ||
| 70 | HttpClient.make((request) => | ||
| 71 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 76 | |||
| 77 | const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 78 | reportClientActivity: () => Effect.void, | ||
| 79 | removeRpcClient: () => Effect.void, | ||
| 80 | reportHostPowerState: () => Effect.void, | ||
| 81 | snapshot: Effect.succeed({ | ||
| 82 | hostPower: { | ||
| 83 | source: "unknown", | ||
| 84 | idle: "unknown", | ||
| 85 | idleSeconds: null, | ||
| 86 | locked: "unknown", | ||
| 87 | suspended: false, | ||
| 88 | onBattery: "unknown", | ||
| 89 | lowPowerMode: "unknown", | ||
| 90 | thermalState: "unknown", | ||
| 91 | stale: true, | ||
| 92 | updatedAt: TEST_EPOCH, | ||
| 93 | }, | ||
| 94 | leases: [], | ||
| 95 | activeForegroundLeaseCount: 0, | ||
| 96 | activeScopeKeys: [], | ||
| 97 | shouldRunOpportunisticWork: true, | ||
| 98 | updatedAt: TEST_EPOCH, | ||
| 99 | }), | ||
| 100 | streamChanges: Stream.empty, | ||
| 101 | hasDemand: () => Effect.succeed(true), | ||
| 102 | shouldRunScopeWork: () => Effect.succeed(true), | ||
| 103 | shouldRunOpportunisticWork: Effect.succeed(true), | ||
| 104 | }); | ||
| 105 | |||
| 106 | const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({ | ||
| 107 | enabled: false, | ||
| 108 | binaryPath: "codex", | ||
| 109 | homePath: "", | ||
| 110 | shadowHomePath: "", | ||
| 111 | launchArgs: "", | ||
| 112 | customModels: [], | ||
| 113 | ...overrides, | ||
| 114 | }); | ||
| 115 | |||
| 116 | const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings => ({ | ||
| 117 | enabled: false, | ||
| 118 | binaryPath: "claude", | ||
| 119 | homePath: "", | ||
| 120 | customModels: [], | ||
| 121 | launchArgs: "", | ||
| 122 | autoCompactWindow: "", | ||
| 123 | ...overrides, | ||
| 124 | }); | ||
| 125 | |||
| 126 | const makeCursorConfig = (overrides: Partial<CursorSettings>): CursorSettings => ({ | ||
| 127 | enabled: false, | ||
| 128 | customModels: [], | ||
| 129 | ...overrides, | ||
| 130 | }); | ||
| 131 | |||
| 132 | const makeGrokConfig = (overrides: Partial<GrokSettings>): GrokSettings => ({ | ||
| 133 | enabled: false, | ||
| 134 | binaryPath: "grok", | ||
| 135 | customModels: [], | ||
| 136 | ...overrides, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const makeOpenCodeConfig = (overrides: Partial<OpenCodeSettings>): OpenCodeSettings => ({ | ||
| 140 | enabled: false, | ||
| 141 | binaryPath: "opencode", | ||
| 142 | serverUrl: "", | ||
| 143 | serverPassword: "", | ||
| 144 | customModels: [], | ||
| 145 | ...overrides, | ||
| 146 | }); | ||
| 147 | |||
| 148 | const makeTildeProviderFixtures = Effect.fn( | ||
| 149 | "ProviderInstanceRegistry.test.makeTildeProviderFixtures", | ||
| 150 | )(function* () { | ||
| 151 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 152 | const path = yield* Path.Path; | ||
| 153 | const homePath = expandHomePath("~"); | ||
| 154 | const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 155 | directory: homePath, | ||
| 156 | prefix: ".t3-provider-path-test-", | ||
| 157 | }); | ||
| 158 | const codexPath = path.join(fixtureDir, "codex"); | ||
| 159 | const claudePath = path.join(fixtureDir, "claude"); | ||
| 160 | const claudeHomePath = path.join(fixtureDir, "claude-home"); | ||
| 161 | const codexScriptPath = path.join(fixtureDir, "codex-script.json"); | ||
| 162 | const codexFixtureDir = path.join(import.meta.dirname, "testFixtures"); | ||
| 163 | |||
| 164 | yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); | ||
| 165 | yield* fileSystem.copyFile( | ||
| 166 | path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), | ||
| 167 | path.join(fixtureDir, "codexCollabMockPeer.mjs"), | ||
| 168 | ); | ||
| 169 | yield* fileSystem.copyFile( | ||
| 170 | path.join(codexFixtureDir, "codexMultiAgentWire.json"), | ||
| 171 | path.join(fixtureDir, "codexMultiAgentWire.json"), | ||
| 172 | ); | ||
| 173 | yield* fileSystem.writeFileString( | ||
| 174 | codexScriptPath, | ||
| 175 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. | ||
| 176 | JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), | ||
| 177 | ); | ||
| 178 | yield* fileSystem.chmod(codexPath, 0o755); | ||
| 179 | |||
| 180 | yield* fileSystem.copyFile( | ||
| 181 | yield* path.fromFileUrl( | ||
| 182 | new URL("./testing/ProviderInstanceRegistryLive.fixture.mjs", import.meta.url), | ||
| 183 | ), | ||
| 184 | claudePath, | ||
| 185 | ); | ||
| 186 | yield* fileSystem.chmod(claudePath, 0o755); | ||
| 187 | yield* fileSystem.makeDirectory(claudeHomePath); | ||
| 188 | |||
| 189 | const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; | ||
| 190 | return { | ||
| 191 | codexBinaryPath: asTildePath(codexPath), | ||
| 192 | claudeBinaryPath: asTildePath(claudePath), | ||
| 193 | claudeHomePath, | ||
| 194 | codexScriptPath, | ||
| 195 | }; | ||
| 196 | }); | ||
| 197 | |||
| 198 | describe("ProviderInstanceRegistry — multi-instance codex slice", () => { | ||
| 199 | // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch | ||
| 200 | // directory. `Layer.merge` just unions requirements, so we have to push | ||
| 201 | // `NodeServices.layer` through `Layer.provideMerge` to satisfy that | ||
| 202 | // dependency while still surfacing NodeServices to the test body (the | ||
| 203 | // codex driver's `create` yields `ChildProcessSpawner` directly). | ||
| 204 | const layerBase = ServerConfig.layerTest(process.cwd(), { | ||
| 205 | prefix: "provider-instance-registry-test", | ||
| 206 | }).pipe( | ||
| 207 | Layer.provideMerge(NodeServices.layer), | ||
| 208 | Layer.provideMerge( | ||
| 209 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 210 | managedDirectory: "unused-managed-installation", | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), | ||
| 214 | Layer.provideMerge( | ||
| 215 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 216 | getEnvironmentId: Effect.succeed( | ||
| 217 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | ||
| 218 | ), | ||
| 219 | }), | ||
| 220 | ), | ||
| 221 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 222 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 223 | Layer.provideMerge(layerTestHttpClient), | ||
| 224 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 225 | Layer.provideMerge( | ||
| 226 | Layer.succeed( | ||
| 227 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 228 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 229 | ), | ||
| 230 | ), | ||
| 231 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 232 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 233 | ); | ||
| 234 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( | ||
| 235 | Layer.provideMerge(layerBase), | ||
| 236 | ); | ||
| 237 | |||
| 238 | it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 241 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 242 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 243 | |||
| 244 | const configMap: ProviderInstanceConfigMap = { | ||
| 245 | [personalId]: { | ||
| 246 | driver: codexDriverKind, | ||
| 247 | displayName: "Codex (personal)", | ||
| 248 | enabled: false, | ||
| 249 | config: makeCodexConfig({ | ||
| 250 | binaryPath: "/opt/codex-personal/bin/codex", | ||
| 251 | homePath: "/home/julius/.codex_personal", | ||
| 252 | customModels: ["personal-preview"], | ||
| 253 | }), | ||
| 254 | }, | ||
| 255 | [workId]: { | ||
| 256 | driver: codexDriverKind, | ||
| 257 | displayName: "Codex (work)", | ||
| 258 | enabled: false, | ||
| 259 | config: makeCodexConfig({ | ||
| 260 | binaryPath: "/opt/codex-work/bin/codex", | ||
| 261 | homePath: "/home/julius/.codex", | ||
| 262 | customModels: ["work-preview"], | ||
| 263 | }), | ||
| 264 | }, | ||
| 265 | }; | ||
| 266 | |||
| 267 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({ | ||
| 268 | drivers: [CodexDriver], | ||
| 269 | configMap, | ||
| 270 | }); | ||
| 271 | |||
| 272 | const instances = yield* registry.listInstances; | ||
| 273 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 274 | [personalId, workId].toSorted(), | ||
| 275 | ); | ||
| 276 | expect(instances.every((instance) => instance.driverKind === codexDriverKind)).toBe(true); | ||
| 277 | expect(instances.map((instance) => instance.displayName).toSorted()).toEqual( | ||
| 278 | ["Codex (personal)", "Codex (work)"].toSorted(), | ||
| 279 | ); | ||
| 280 | |||
| 281 | // Each instance must be retrievable by id and carry its *own* closures. | ||
| 282 | const personal = yield* registry.getInstance(personalId); | ||
| 283 | const work = yield* registry.getInstance(workId); | ||
| 284 | expect(personal).toBeDefined(); | ||
| 285 | expect(work).toBeDefined(); | ||
| 286 | expect(personal!.orchestrationAdapter).not.toBe(work!.orchestrationAdapter); | ||
| 287 | expect(personal!.textGeneration).not.toBe(work!.textGeneration); | ||
| 288 | expect(personal!.snapshot).not.toBe(work!.snapshot); | ||
| 289 | |||
| 290 | // Snapshots identify themselves by instanceId + driver — this is | ||
| 291 | // what makes per-instance routing distinguishable downstream. | ||
| 292 | const personalSnapshot = yield* personal!.snapshot.getSnapshot; | ||
| 293 | expect(personalSnapshot.instanceId).toBe(personalId); | ||
| 294 | expect(personalSnapshot.driver).toBe(codexDriverKind); | ||
| 295 | expect(personalSnapshot.enabled).toBe(false); | ||
| 296 | // The layout resolves the configured home through the host Path. | ||
| 297 | const path = yield* Path.Path; | ||
| 298 | expect(personalSnapshot.continuation?.groupKey).toBe( | ||
| 299 | `codex:home:${path.resolve("/home/julius/.codex_personal")}`, | ||
| 300 | ); | ||
| 301 | |||
| 302 | const workSnapshot = yield* work!.snapshot.getSnapshot; | ||
| 303 | expect(workSnapshot.instanceId).toBe(workId); | ||
| 304 | expect(workSnapshot.driver).toBe(codexDriverKind); | ||
| 305 | expect(workSnapshot.enabled).toBe(false); | ||
| 306 | expect(workSnapshot.continuation?.groupKey).toBe( | ||
| 307 | `codex:home:${path.resolve("/home/julius/.codex")}`, | ||
| 308 | ); | ||
| 309 | |||
| 310 | // Nothing goes to the unavailable bucket — both drivers are registered. | ||
| 311 | const unavailable = yield* registry.listUnavailable; | ||
| 312 | expect(unavailable).toEqual([]); | ||
| 313 | }).pipe(Effect.provide(layerTest)), | ||
| 314 | ); | ||
| 315 | |||
| 316 | it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => | ||
| 317 | Effect.gen(function* () { | ||
| 318 | // Old settings files can carry both flags with conflicting values. | ||
| 319 | // The explicit false must win so a user's disable is never undone. | ||
| 320 | const staleId = ProviderInstanceId.make("codex_stale"); | ||
| 321 | const configMap: ProviderInstanceConfigMap = { | ||
| 322 | [staleId]: { | ||
| 323 | driver: ProviderDriverKind.make("codex"), | ||
| 324 | enabled: true, | ||
| 325 | config: makeCodexConfig({ enabled: false }), | ||
| 326 | }, | ||
| 327 | }; | ||
| 328 | |||
| 329 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 330 | drivers: [CodexDriver], | ||
| 331 | configMap, | ||
| 332 | }); | ||
| 333 | |||
| 334 | const instance = yield* registry.getInstance(staleId); | ||
| 335 | expect(instance).toBeDefined(); | ||
| 336 | expect(instance!.enabled).toBe(false); | ||
| 337 | const snapshot = yield* instance!.snapshot.getSnapshot; | ||
| 338 | expect(snapshot.enabled).toBe(false); | ||
| 339 | }).pipe(Effect.provide(layerTest)), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.live("reports Codex's answer when a redemption changed nothing", () => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | if (yield* isHostWindows) return; | ||
| 345 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 346 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 347 | yield* fileSystem.writeFileString( | ||
| 348 | fixtures.codexScriptPath, | ||
| 349 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. | ||
| 350 | JSON.stringify({ | ||
| 351 | rootThreadId: "probe-thread", | ||
| 352 | notifications: [], | ||
| 353 | account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, | ||
| 354 | failRateLimitsRead: true, | ||
| 355 | resetCreditOutcome: "alreadyRedeemed", | ||
| 356 | }), | ||
| 357 | ); | ||
| 358 | const codexId = ProviderInstanceId.make("codex_reset"); | ||
| 359 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 360 | drivers: [CodexDriver], | ||
| 361 | configMap: { | ||
| 362 | [codexId]: { | ||
| 363 | driver: ProviderDriverKind.make("codex"), | ||
| 364 | enabled: true, | ||
| 365 | environment: [ | ||
| 366 | { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, | ||
| 367 | ], | ||
| 368 | config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | }); | ||
| 372 | const codex = yield* registry.getInstance(codexId); | ||
| 373 | expect(codex).toBeDefined(); | ||
| 374 | // The usage read fails, so the re-probe cannot confirm new limits. | ||
| 375 | yield* codex!.snapshot.refresh; | ||
| 376 | expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); | ||
| 377 | }).pipe(Effect.provide(layerTest)), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.live("runs Codex and Claude readiness probes from configured tilde paths", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | if (yield* isHostWindows) return; | ||
| 383 | |||
| 384 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 385 | |||
| 386 | const codexId = ProviderInstanceId.make("codex_tilde"); | ||
| 387 | const claudeId = ProviderInstanceId.make("claude_tilde"); | ||
| 388 | const configMap: ProviderInstanceConfigMap = { | ||
| 389 | [codexId]: { | ||
| 390 | driver: ProviderDriverKind.make("codex"), | ||
| 391 | enabled: true, | ||
| 392 | environment: [ | ||
| 393 | { | ||
| 394 | name: "T3_CODEX_COLLAB_SCRIPT", | ||
| 395 | value: fixtures.codexScriptPath, | ||
| 396 | sensitive: false, | ||
| 397 | }, | ||
| 398 | ], | ||
| 399 | config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), | ||
| 400 | }, | ||
| 401 | [claudeId]: { | ||
| 402 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 403 | enabled: true, | ||
| 404 | config: makeClaudeConfig({ | ||
| 405 | enabled: true, | ||
| 406 | binaryPath: fixtures.claudeBinaryPath, | ||
| 407 | homePath: fixtures.claudeHomePath, | ||
| 408 | }), | ||
| 409 | }, | ||
| 410 | }; | ||
| 411 | |||
| 412 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv | ClaudeDriverEnv>({ | ||
| 413 | drivers: [CodexDriver, ClaudeDriver], | ||
| 414 | configMap, | ||
| 415 | }); | ||
| 416 | const codex = yield* registry.getInstance(codexId); | ||
| 417 | const claude = yield* registry.getInstance(claudeId); | ||
| 418 | expect(codex).toBeDefined(); | ||
| 419 | expect(claude).toBeDefined(); | ||
| 420 | |||
| 421 | const [codexSnapshot, claudeSnapshot] = yield* Effect.all( | ||
| 422 | [codex!.snapshot.refresh, claude!.snapshot.refresh], | ||
| 423 | { concurrency: "unbounded" }, | ||
| 424 | ); | ||
| 425 | expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); | ||
| 426 | expect(claudeSnapshot).toMatchObject({ | ||
| 427 | status: "ready", | ||
| 428 | installed: true, | ||
| 429 | version: "2.1.219", | ||
| 430 | }); | ||
| 431 | }).pipe(Effect.provide(layerTest)), | ||
| 432 | ); | ||
| 433 | |||
| 434 | const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const fs = yield* FileSystem.FileSystem; | ||
| 437 | const path = yield* Path.Path; | ||
| 438 | const fixtures = yield* makeTildeProviderFixtures(); | ||
| 439 | const marker = path.join(fixtures.claudeHomePath, "redeemed"); | ||
| 440 | yield* fs.writeFileString( | ||
| 441 | path.join(fixtures.claudeHomePath, ".credentials.json"), | ||
| 442 | '{"claudeAiOauth":{"accessToken":"fake-token"}}', | ||
| 443 | ); | ||
| 444 | yield* fs.writeFileString( | ||
| 445 | path.join(fixtures.claudeHomePath, ".claude.json"), | ||
| 446 | '{"oauthAccount":{"organizationUuid":"fake-org"}}', | ||
| 447 | ); | ||
| 448 | const client = HttpClient.make((request) => | ||
| 449 | Effect.gen(function* () { | ||
| 450 | if (request.url.endsWith("/api/oauth/usage")) { | ||
| 451 | return HttpClientResponse.fromWeb( | ||
| 452 | request, | ||
| 453 | Response.json({ | ||
| 454 | cedar_ember: { | ||
| 455 | eligible: true, | ||
| 456 | next_grant_id: "grant_a", | ||
| 457 | grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], | ||
| 458 | }, | ||
| 459 | }), | ||
| 460 | ); | ||
| 461 | } | ||
| 462 | if (request.url.endsWith("/reset_rate_limits")) { | ||
| 463 | yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); | ||
| 464 | return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); | ||
| 465 | } | ||
| 466 | return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); | ||
| 467 | }), | ||
| 468 | ); | ||
| 469 | const instanceId = ProviderInstanceId.make("claude_reset"); | ||
| 470 | const { registry } = yield* makeProviderInstanceRegistry({ | ||
| 471 | drivers: [ClaudeDriver], | ||
| 472 | configMap: { | ||
| 473 | [instanceId]: { | ||
| 474 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 475 | enabled: true, | ||
| 476 | environment: [ | ||
| 477 | { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, | ||
| 478 | ...(claim.usageFailsAfterClaim | ||
| 479 | ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] | ||
| 480 | : []), | ||
| 481 | ], | ||
| 482 | config: makeClaudeConfig({ | ||
| 483 | enabled: true, | ||
| 484 | binaryPath: fixtures.claudeBinaryPath, | ||
| 485 | homePath: fixtures.claudeHomePath, | ||
| 486 | }), | ||
| 487 | }, | ||
| 488 | }, | ||
| 489 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); | ||
| 490 | const instance = yield* registry.getInstance(instanceId); | ||
| 491 | expect(instance).toBeDefined(); | ||
| 492 | const before = yield* instance!.snapshot.refresh; | ||
| 493 | expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); | ||
| 494 | expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); | ||
| 495 | const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); | ||
| 496 | return { outcome, after: yield* instance!.snapshot.getSnapshot }; | ||
| 497 | }).pipe( | ||
| 498 | // macOS logins live in the Keychain, where resets are never read. | ||
| 499 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 500 | Effect.provide(layerTest), | ||
| 501 | ); | ||
| 502 | |||
| 503 | it.live("refreshes Claude usage after redeeming a reset", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const { outcome, after } = yield* redeemClaudeReset({ | ||
| 506 | result: "reset", | ||
| 507 | usageFailsAfterClaim: false, | ||
| 508 | }); | ||
| 509 | expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); | ||
| 510 | expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); | ||
| 511 | }), | ||
| 512 | ); | ||
| 513 | |||
| 514 | it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => | ||
| 515 | Effect.gen(function* () { | ||
| 516 | const { outcome } = yield* redeemClaudeReset({ | ||
| 517 | result: "already_used", | ||
| 518 | usageFailsAfterClaim: true, | ||
| 519 | }); | ||
| 520 | expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); | ||
| 521 | }), | ||
| 522 | ); | ||
| 523 | |||
| 524 | it.live( | ||
| 525 | "shadows instances whose driver is not registered in this build without failing boot", | ||
| 526 | () => | ||
| 527 | Effect.gen(function* () { | ||
| 528 | const codexId = ProviderInstanceId.make("codex_main"); | ||
| 529 | const ghostId = ProviderInstanceId.make("ghost_main"); | ||
| 530 | |||
| 531 | const configMap: ProviderInstanceConfigMap = { | ||
| 532 | [codexId]: { | ||
| 533 | driver: ProviderDriverKind.make("codex"), | ||
| 534 | enabled: false, | ||
| 535 | config: makeCodexConfig({}), | ||
| 536 | }, | ||
| 537 | [ghostId]: { | ||
| 538 | driver: ProviderDriverKind.make("ghostDriver"), | ||
| 539 | displayName: "A fork-only driver we don't ship", | ||
| 540 | enabled: false, | ||
| 541 | config: { arbitrary: "payload", preserved: true }, | ||
| 542 | }, | ||
| 543 | }; | ||
| 544 | |||
| 545 | const { registry } = yield* makeProviderInstanceRegistry<CodexDriverEnv>({ | ||
| 546 | drivers: [CodexDriver], | ||
| 547 | configMap, | ||
| 548 | }); | ||
| 549 | |||
| 550 | const instances = yield* registry.listInstances; | ||
| 551 | expect(instances).toHaveLength(1); | ||
| 552 | expect(instances[0]!.instanceId).toBe(codexId); | ||
| 553 | |||
| 554 | const unavailable = yield* registry.listUnavailable; | ||
| 555 | expect(unavailable).toHaveLength(1); | ||
| 556 | const ghost = unavailable[0]!; | ||
| 557 | expect(ghost.instanceId).toBe(ghostId); | ||
| 558 | expect(ghost.driver).toBe("ghostDriver"); | ||
| 559 | expect(ghost.availability).toBe("unavailable"); | ||
| 560 | expect(ghost.unavailableReason).toMatch(/ghostDriver/); | ||
| 561 | }).pipe(Effect.provide(layerTest)), | ||
| 562 | ); | ||
| 563 | }); | ||
| 564 | |||
| 565 | describe("ProviderInstanceRegistry — all drivers slice", () => { | ||
| 566 | // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + | ||
| 567 | // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` | ||
| 568 | // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. | ||
| 569 | // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via | ||
| 570 | // `Layer.provide`, so the only external requirement it still exposes is | ||
| 571 | // `ChildProcessSpawner` — resolved here by piping it through | ||
| 572 | // `provideMerge(NodeServices.layer)`. | ||
| 573 | // | ||
| 574 | // The nested `provideMerge`s read bottom-up: `NodeServices.layer` | ||
| 575 | // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs | ||
| 576 | // surfaced; that merged layer then provides `ServerConfig.layerTest`'s | ||
| 577 | // `FileSystem` dep while keeping everything else surfaced to the test. | ||
| 578 | const layerInfra = OpenCodeRuntime.layer.pipe( | ||
| 579 | Layer.provide(OpenCodeServerLedger.layerTest), | ||
| 580 | Layer.provideMerge(NodeServices.layer), | ||
| 581 | Layer.provideMerge( | ||
| 582 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 583 | managedDirectory: "unused-managed-installation", | ||
| 584 | }), | ||
| 585 | ), | ||
| 586 | Layer.provideMerge( | ||
| 587 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 588 | getEnvironmentId: Effect.succeed( | ||
| 589 | EnvironmentId.make("00000000-0000-4000-8000-000000000001"), | ||
| 590 | ), | ||
| 591 | }), | ||
| 592 | ), | ||
| 593 | ); | ||
| 594 | const layerBase = AntigravityInstallation.AntigravityInstallation.layer.pipe( | ||
| 595 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 596 | Layer.provideMerge( | ||
| 597 | ServerConfig.layerTest(process.cwd(), { | ||
| 598 | prefix: "provider-instance-registry-all-drivers-test", | ||
| 599 | }), | ||
| 600 | ), | ||
| 601 | Layer.provideMerge(layerInfra), | ||
| 602 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 603 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 604 | Layer.provideMerge(layerTestHttpClient), | ||
| 605 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 606 | Layer.provideMerge( | ||
| 607 | Layer.succeed( | ||
| 608 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 609 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 610 | ), | ||
| 611 | ), | ||
| 612 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 613 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 614 | ); | ||
| 615 | const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( | ||
| 616 | Layer.provideMerge(layerBase), | ||
| 617 | ); | ||
| 618 | |||
| 619 | it.live("boots one instance of every shipped driver from a single config map", () => | ||
| 620 | Effect.gen(function* () { | ||
| 621 | const codexId = ProviderInstanceId.make("codex_default"); | ||
| 622 | const claudeId = ProviderInstanceId.make("claude_default"); | ||
| 623 | const cursorId = ProviderInstanceId.make("cursor_default"); | ||
| 624 | const grokId = ProviderInstanceId.make("grok_default"); | ||
| 625 | const openCodeId = ProviderInstanceId.make("opencode_default"); | ||
| 626 | |||
| 627 | const codexDriverKind = ProviderDriverKind.make("codex"); | ||
| 628 | const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); | ||
| 629 | const cursorDriverKind = ProviderDriverKind.make("cursor"); | ||
| 630 | const grokDriverKind = ProviderDriverKind.make("grok"); | ||
| 631 | const openCodeDriverKind = ProviderDriverKind.make("opencode"); | ||
| 632 | |||
| 633 | const configMap: ProviderInstanceConfigMap = { | ||
| 634 | [codexId]: { | ||
| 635 | driver: codexDriverKind, | ||
| 636 | displayName: "Codex", | ||
| 637 | enabled: false, | ||
| 638 | config: makeCodexConfig({ homePath: "/home/julius/.codex" }), | ||
| 639 | }, | ||
| 640 | [claudeId]: { | ||
| 641 | driver: claudeDriverKind, | ||
| 642 | displayName: "Claude", | ||
| 643 | enabled: false, | ||
| 644 | config: makeClaudeConfig({ | ||
| 645 | homePath: "/home/julius/.claude-work", | ||
| 646 | launchArgs: "--verbose", | ||
| 647 | }), | ||
| 648 | }, | ||
| 649 | [cursorId]: { | ||
| 650 | driver: cursorDriverKind, | ||
| 651 | displayName: "Cursor", | ||
| 652 | enabled: false, | ||
| 653 | config: makeCursorConfig({}), | ||
| 654 | }, | ||
| 655 | [grokId]: { | ||
| 656 | driver: grokDriverKind, | ||
| 657 | displayName: "Grok", | ||
| 658 | enabled: false, | ||
| 659 | config: makeGrokConfig({}), | ||
| 660 | }, | ||
| 661 | [openCodeId]: { | ||
| 662 | driver: openCodeDriverKind, | ||
| 663 | displayName: "OpenCode", | ||
| 664 | enabled: false, | ||
| 665 | config: makeOpenCodeConfig({}), | ||
| 666 | }, | ||
| 667 | }; | ||
| 668 | |||
| 669 | const { registry } = yield* makeProviderInstanceRegistry< | ||
| 670 | CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | ||
| 671 | >({ | ||
| 672 | drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], | ||
| 673 | configMap, | ||
| 674 | }); | ||
| 675 | |||
| 676 | // Every configured instance must materialize — none downgraded to a | ||
| 677 | // shadow snapshot, because every driver in the map is registered. | ||
| 678 | const unavailable = yield* registry.listUnavailable; | ||
| 679 | expect(unavailable).toEqual([]); | ||
| 680 | |||
| 681 | const instances = yield* registry.listInstances; | ||
| 682 | expect(instances).toHaveLength(5); | ||
| 683 | expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( | ||
| 684 | [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), | ||
| 685 | ); | ||
| 686 | |||
| 687 | // Instance lookup by id resolves each instance to its own bundle — | ||
| 688 | // this is how rest-of-server routes turn/session calls in the new | ||
| 689 | // model. Each driver's bundle carries its advertised `driverKind`. | ||
| 690 | const codex = yield* registry.getInstance(codexId); | ||
| 691 | const claude = yield* registry.getInstance(claudeId); | ||
| 692 | const cursor = yield* registry.getInstance(cursorId); | ||
| 693 | const grok = yield* registry.getInstance(grokId); | ||
| 694 | const openCode = yield* registry.getInstance(openCodeId); | ||
| 695 | expect(codex?.driverKind).toBe(codexDriverKind); | ||
| 696 | expect(claude?.driverKind).toBe(claudeDriverKind); | ||
| 697 | expect(cursor?.driverKind).toBe(cursorDriverKind); | ||
| 698 | expect(grok?.driverKind).toBe(grokDriverKind); | ||
| 699 | expect(openCode?.driverKind).toBe(openCodeDriverKind); | ||
| 700 | expect(codex?.displayName).toBe("Codex"); | ||
| 701 | expect(claude?.displayName).toBe("Claude"); | ||
| 702 | expect(cursor?.displayName).toBe("Cursor"); | ||
| 703 | expect(grok?.displayName).toBe("Grok"); | ||
| 704 | expect(openCode?.displayName).toBe("OpenCode"); | ||
| 705 | |||
| 706 | // Every instance owns its own set of closures — no sharing across | ||
| 707 | // drivers. `orchestrationAdapter` / `textGeneration` / `snapshot` are all | ||
| 708 | // distinct references even when two instances happen to share a | ||
| 709 | // trait (e.g. Cursor + others all use a stub-or-real | ||
| 710 | // `textGeneration`; they must still be different object values). | ||
| 711 | const adapters = [ | ||
| 712 | codex!.orchestrationAdapter, | ||
| 713 | claude!.orchestrationAdapter, | ||
| 714 | cursor!.orchestrationAdapter, | ||
| 715 | grok!.orchestrationAdapter, | ||
| 716 | openCode!.orchestrationAdapter, | ||
| 717 | ]; | ||
| 718 | expect(new Set(adapters).size).toBe(adapters.length); | ||
| 719 | const textGenerations = [ | ||
| 720 | codex!.textGeneration, | ||
| 721 | claude!.textGeneration, | ||
| 722 | cursor!.textGeneration, | ||
| 723 | grok!.textGeneration, | ||
| 724 | openCode!.textGeneration, | ||
| 725 | ]; | ||
| 726 | expect(new Set(textGenerations).size).toBe(textGenerations.length); | ||
| 727 | const snapshots = [ | ||
| 728 | codex!.snapshot, | ||
| 729 | claude!.snapshot, | ||
| 730 | cursor!.snapshot, | ||
| 731 | grok!.snapshot, | ||
| 732 | openCode!.snapshot, | ||
| 733 | ]; | ||
| 734 | expect(new Set(snapshots).size).toBe(snapshots.length); | ||
| 735 | |||
| 736 | // Snapshots identify themselves by `instanceId` + `driver` so | ||
| 737 | // downstream aggregation in `ProviderRegistry` can tell instances | ||
| 738 | // apart even when two share a driver. With `enabled: false`, the | ||
| 739 | // check short-circuits and we get a disabled/pending snapshot back | ||
| 740 | // — that's enough signal to validate the stamping wrapper without | ||
| 741 | // spawning real binaries. | ||
| 742 | const codexSnapshot = yield* codex!.snapshot.getSnapshot; | ||
| 743 | expect(codexSnapshot.instanceId).toBe(codexId); | ||
| 744 | expect(codexSnapshot.driver).toBe(codexDriverKind); | ||
| 745 | expect(codexSnapshot.enabled).toBe(false); | ||
| 746 | expect(codexSnapshot.continuation?.groupKey).toBe( | ||
| 747 | `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`, | ||
| 748 | ); | ||
| 749 | |||
| 750 | const claudeSnapshot = yield* claude!.snapshot.getSnapshot; | ||
| 751 | expect(claudeSnapshot.instanceId).toBe(claudeId); | ||
| 752 | expect(claudeSnapshot.driver).toBe(claudeDriverKind); | ||
| 753 | expect(claudeSnapshot.enabled).toBe(false); | ||
| 754 | expect(claudeSnapshot.continuation?.groupKey).toBe( | ||
| 755 | `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`, | ||
| 756 | ); | ||
| 757 | |||
| 758 | const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; | ||
| 759 | expect(cursorSnapshot.instanceId).toBe(cursorId); | ||
| 760 | expect(cursorSnapshot.driver).toBe(cursorDriverKind); | ||
| 761 | expect(cursorSnapshot.enabled).toBe(false); | ||
| 762 | expect(cursorSnapshot.continuation?.groupKey).toBe( | ||
| 763 | `${cursorDriverKind}:instance:${cursorId}`, | ||
| 764 | ); | ||
| 765 | |||
| 766 | const grokSnapshot = yield* grok!.snapshot.getSnapshot; | ||
| 767 | expect(grokSnapshot.instanceId).toBe(grokId); | ||
| 768 | expect(grokSnapshot.driver).toBe(grokDriverKind); | ||
| 769 | expect(grokSnapshot.enabled).toBe(false); | ||
| 770 | expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); | ||
| 771 | |||
| 772 | const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; | ||
| 773 | expect(openCodeSnapshot.instanceId).toBe(openCodeId); | ||
| 774 | expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); | ||
| 775 | expect(openCodeSnapshot.enabled).toBe(false); | ||
| 776 | expect(openCodeSnapshot.continuation?.groupKey).toBe( | ||
| 777 | `${openCodeDriverKind}:instance:${openCodeId}`, | ||
| 778 | ); | ||
| 779 | }).pipe(Effect.provide(layerTest)), | ||
| 780 | ); | ||
| 781 | }); | ||
apps/server/src/provider/ProviderInstanceRegistry.ts created+508| ... | @@ -0,0 +1,508 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistry — the single Effect service in the new model. | ||
| 3 | * | ||
| 4 | * Owns a `Map<ProviderInstanceId, ProviderInstance>` produced by running | ||
| 5 | * registered driver factories against `ServerSettings.providerInstances`. | ||
| 6 | * The registry watches settings; when an instance's config changes (or | ||
| 7 | * the entry disappears), the registry tears down the affected instance's | ||
| 8 | * scope and rebuilds — that's the entire hot-reload story. | ||
| 9 | * | ||
| 10 | * What rest-of-server reads from here: | ||
| 11 | * - `getInstance(instanceId)` — for routing turn/session calls. | ||
| 12 | * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. | ||
| 13 | * - `listUnavailable` — `ServerProvider` shadows for instances whose | ||
| 14 | * driver is not registered in this build (rollback / fork tolerance). | ||
| 15 | * - `streamChanges` — coalesced "registry mutated" pings so consumers | ||
| 16 | * can re-pull lists or re-broadcast. | ||
| 17 | * | ||
| 18 | * The live implementation (`makeProviderInstanceRegistry`, `layer`) also | ||
| 19 | * provides its sibling mutator. | ||
| 20 | * | ||
| 21 | * Materializes every entry in a `ProviderInstanceConfigMap`: | ||
| 22 | * | ||
| 23 | * - When the entry's `driver` matches a registered driver, the registry | ||
| 24 | * decodes the opaque `config` envelope through `driver.configSchema` | ||
| 25 | * and calls `driver.create()` inside a fresh child scope. The | ||
| 26 | * resulting `ProviderInstance` is stored keyed by instance id, | ||
| 27 | * alongside its scope so the entry can be torn down independently. | ||
| 28 | * - When the entry's `driver` is unknown to this build (fork, rollback, | ||
| 29 | * in-flight PR branch), the registry emits an `"unavailable"` shadow | ||
| 30 | * `ServerProvider` snapshot instead of failing. This is what makes | ||
| 31 | * downgrades and fork-hopping safe per the | ||
| 32 | * `forward/backward compatibility invariant` in | ||
| 33 | * `packages/contracts/src/providerInstance.ts`. | ||
| 34 | * - When the entry's config fails schema decode, the registry logs and | ||
| 35 | * emits a shadow snapshot with the schema detail — same bucket as an | ||
| 36 | * unknown driver. | ||
| 37 | * | ||
| 38 | * Unlike the pre-Slice-D layer, the registry now holds mutable state | ||
| 39 | * (`Ref`s + `PubSub`) and exposes an internal mutator | ||
| 40 | * (`ProviderInstanceRegistryMutator`) whose `reconcile` method diffs a | ||
| 41 | * fresh config map against the live state, tearing down removed instances | ||
| 42 | * and building new ones without disturbing unaffected instances. | ||
| 43 | * | ||
| 44 | * Every live instance runs inside its own child `Scope`. The registry's | ||
| 45 | * own scope owns all child scopes via finalizers, so closing the registry | ||
| 46 | * tears every instance down in reverse order; closing a single instance | ||
| 47 | * (via `reconcile` removing it) leaves the rest untouched. | ||
| 48 | * | ||
| 49 | * @module provider/ProviderInstanceRegistry | ||
| 50 | */ | ||
| 51 | import { | ||
| 52 | providerInstanceConfigEnabledFlag, | ||
| 53 | ProviderInstanceId, | ||
| 54 | type ProviderInstanceConfig, | ||
| 55 | type ProviderInstanceConfigMap, | ||
| 56 | type ProviderDriverKind, | ||
| 57 | type ServerProvider, | ||
| 58 | } from "@t3tools/contracts"; | ||
| 59 | import * as Context from "effect/Context"; | ||
| 60 | import * as Effect from "effect/Effect"; | ||
| 61 | import * as Equal from "effect/Equal"; | ||
| 62 | import * as Exit from "effect/Exit"; | ||
| 63 | import * as Layer from "effect/Layer"; | ||
| 64 | import * as PubSub from "effect/PubSub"; | ||
| 65 | import * as Ref from "effect/Ref"; | ||
| 66 | import * as Schema from "effect/Schema"; | ||
| 67 | import * as Scope from "effect/Scope"; | ||
| 68 | import * as Stream from "effect/Stream"; | ||
| 69 | |||
| 70 | import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; | ||
| 71 | import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; | ||
| 72 | import type { AnyProviderDriver, ProviderInstance } from "./ProviderDriver.ts"; | ||
| 73 | |||
| 74 | export class ProviderInstanceRegistry extends Context.Service< | ||
| 75 | ProviderInstanceRegistry, | ||
| 76 | { | ||
| 77 | /** | ||
| 78 | * Look up one instance by id. Returns `undefined` (not Option) when the | ||
| 79 | * id is unknown — callers branch on falsy and emit | ||
| 80 | * `ProviderInstanceNotFoundError`. | ||
| 81 | */ | ||
| 82 | readonly getInstance: ( | ||
| 83 | instanceId: ProviderInstanceId, | ||
| 84 | ) => Effect.Effect<ProviderInstance | undefined>; | ||
| 85 | /** | ||
| 86 | * Every available (driver-registered, successfully created) instance, | ||
| 87 | * in stable settings-author order. | ||
| 88 | */ | ||
| 89 | readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>; | ||
| 90 | /** | ||
| 91 | * Wire-shape shadow snapshots for instances whose driver is unknown to | ||
| 92 | * this build (or whose config failed to decode). Suitable for merging | ||
| 93 | * directly into `ProviderRegistry` output. | ||
| 94 | */ | ||
| 95 | readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 96 | /** | ||
| 97 | * Push notification stream emitted whenever the registry's contents | ||
| 98 | * change — instance added, removed, or rebuilt. The payload is `void` | ||
| 99 | * because consumers always want to re-pull `listInstances` / | ||
| 100 | * `listUnavailable` together. | ||
| 101 | * | ||
| 102 | * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the | ||
| 103 | * stream starts running, forking a consumer via | ||
| 104 | * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next | ||
| 105 | * publish — the forked fiber may not have subscribed yet when the | ||
| 106 | * publish lands. Hot-reload consumers that must not miss a publish | ||
| 107 | * should use `subscribeChanges` below instead, which acquires the | ||
| 108 | * subscription synchronously in the caller's fiber before the consumer | ||
| 109 | * loop is forked. | ||
| 110 | */ | ||
| 111 | readonly streamChanges: Stream.Stream<void>; | ||
| 112 | /** | ||
| 113 | * Acquire a subscription to the registry's change channel synchronously | ||
| 114 | * in the caller's fiber. Returns a `PubSub.Subscription<void>` whose | ||
| 115 | * lifetime is scoped to the provided `Scope` (the subscription is | ||
| 116 | * released when the scope closes). Consumers typically `yield*` this | ||
| 117 | * in the same fiber that forks their consumer loop, then drain with | ||
| 118 | * `PubSub.take(subscription)` inside `Effect.forever`. Because the | ||
| 119 | * subscription is registered with the PubSub before this `yield*` | ||
| 120 | * returns, no subsequent publish can land in a gap. | ||
| 121 | * | ||
| 122 | * This exists because the `ProviderInstanceRegistry` publishes on a | ||
| 123 | * PubSub and `Stream.fromPubSub` defers subscription until the stream | ||
| 124 | * starts executing — a consumer that `forkScoped`s the stream | ||
| 125 | * consumption can miss a publish that lands in the narrow window | ||
| 126 | * between "fiber scheduled" and "fiber starts running". | ||
| 127 | */ | ||
| 128 | readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>; | ||
| 129 | } | ||
| 130 | >()("t3/provider/ProviderInstanceRegistry") {} | ||
| 131 | |||
| 132 | /** | ||
| 133 | * Live registry entry: the materialized `ProviderInstance` + the fresh | ||
| 134 | * child scope its `create` effect ran in + the original `entry` envelope | ||
| 135 | * so `reconcile` can cheaply detect "no-op" updates. | ||
| 136 | */ | ||
| 137 | interface LiveEntry { | ||
| 138 | readonly instance: ProviderInstance; | ||
| 139 | readonly scope: Scope.Closeable; | ||
| 140 | readonly entry: ProviderInstanceConfig; | ||
| 141 | } | ||
| 142 | |||
| 143 | /** | ||
| 144 | * Internal state shared between the public registry service and the | ||
| 145 | * mutator service. Both services are thin shells around these refs. | ||
| 146 | */ | ||
| 147 | interface RegistryState { | ||
| 148 | readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>; | ||
| 149 | readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>; | ||
| 150 | readonly changes: PubSub.PubSub<void>; | ||
| 151 | } | ||
| 152 | |||
| 153 | /** | ||
| 154 | * Structural equality on `ProviderInstanceConfig` envelopes. Used by | ||
| 155 | * `reconcile` to skip rebuilds when settings arrive unchanged. Config | ||
| 156 | * payloads are opaque `unknown` at the envelope layer; `Equal.equals` | ||
| 157 | * falls back to structural equality for plain records, which matches how | ||
| 158 | * the schema decode output is constructed. | ||
| 159 | */ | ||
| 160 | const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => | ||
| 161 | Equal.equals(a, b); | ||
| 162 | |||
| 163 | /** | ||
| 164 | * Resolve an entry's enabled state. An explicit false on either the | ||
| 165 | * envelope or the raw config blob wins (most restrictive) — old settings | ||
| 166 | * files can carry both flags with conflicting values, and a user's disable | ||
| 167 | * must never be silently undone. Otherwise the envelope flag wins, then the | ||
| 168 | * decoded config's flag (which carries the driver schema's default for | ||
| 169 | * built-ins and forks alike), then enabled by default. | ||
| 170 | */ | ||
| 171 | const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { | ||
| 172 | const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); | ||
| 173 | if (entry.enabled === false || rawConfigEnabled === false) { | ||
| 174 | return false; | ||
| 175 | } | ||
| 176 | return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; | ||
| 177 | }; | ||
| 178 | |||
| 179 | /** | ||
| 180 | * Build one live entry from a raw config envelope. Returns either a | ||
| 181 | * `LiveEntry` plus undefined unavailable shadow, or a shadow snapshot and | ||
| 182 | * undefined entry — callers dispatch to the appropriate Ref bucket. | ||
| 183 | */ | ||
| 184 | const buildEntry = <R>(input: { | ||
| 185 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 186 | readonly parentScope: Scope.Scope; | ||
| 187 | readonly instanceId: ProviderInstanceId; | ||
| 188 | readonly rawInstanceId: string; | ||
| 189 | readonly entry: ProviderInstanceConfig; | ||
| 190 | }): Effect.Effect< | ||
| 191 | | { readonly kind: "live"; readonly live: LiveEntry } | ||
| 192 | | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, | ||
| 193 | never, | ||
| 194 | R | ||
| 195 | > => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; | ||
| 198 | const driver = driversById.get(entry.driver); | ||
| 199 | if (!driver) { | ||
| 200 | return { | ||
| 201 | kind: "unavailable" as const, | ||
| 202 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 203 | driverKind: entry.driver, | ||
| 204 | instanceId, | ||
| 205 | displayName: entry.displayName, | ||
| 206 | accentColor: entry.accentColor, | ||
| 207 | reason: `Driver '${entry.driver}' is not registered in this build.`, | ||
| 208 | }), | ||
| 209 | }; | ||
| 210 | } | ||
| 211 | |||
| 212 | const decoder = Schema.decodeUnknownEffect(driver.configSchema); | ||
| 213 | const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result); | ||
| 214 | if (decodeResult._tag === "Failure") { | ||
| 215 | const issue = decodeResult.failure; | ||
| 216 | const detail = issue.message ?? String(issue); | ||
| 217 | yield* Effect.logError("Failed to decode provider instance config", { | ||
| 218 | instanceId: rawInstanceId, | ||
| 219 | driver: entry.driver, | ||
| 220 | detail, | ||
| 221 | }); | ||
| 222 | return { | ||
| 223 | kind: "unavailable" as const, | ||
| 224 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 225 | driverKind: entry.driver, | ||
| 226 | instanceId, | ||
| 227 | displayName: entry.displayName, | ||
| 228 | accentColor: entry.accentColor, | ||
| 229 | reason: `Invalid config for instance '${rawInstanceId}': ${detail}`, | ||
| 230 | }), | ||
| 231 | }; | ||
| 232 | } | ||
| 233 | |||
| 234 | const typedConfig = decodeResult.success; | ||
| 235 | const childScope = yield* Scope.make(); | ||
| 236 | // Attach the child scope to the registry's parent scope: if the | ||
| 237 | // registry scope closes, each surviving instance's child scope is | ||
| 238 | // closed through this finalizer. `reconcile` manually closes the | ||
| 239 | // child scope on remove/replace; subsequent close via the parent's | ||
| 240 | // finalizer is a no-op because `Scope.close` is idempotent. | ||
| 241 | yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); | ||
| 242 | |||
| 243 | const createResult = yield* driver | ||
| 244 | .create({ | ||
| 245 | instanceId, | ||
| 246 | displayName: entry.displayName, | ||
| 247 | accentColor: entry.accentColor, | ||
| 248 | environment: entry.environment ?? [], | ||
| 249 | enabled: resolveEntryEnabled(entry, typedConfig), | ||
| 250 | config: typedConfig, | ||
| 251 | }) | ||
| 252 | .pipe(Effect.provideService(Scope.Scope, childScope), Effect.result); | ||
| 253 | if (createResult._tag === "Failure") { | ||
| 254 | yield* Effect.logError("Failed to create provider instance", { | ||
| 255 | instanceId: rawInstanceId, | ||
| 256 | driver: entry.driver, | ||
| 257 | detail: createResult.failure.detail, | ||
| 258 | }); | ||
| 259 | yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore); | ||
| 260 | return { | ||
| 261 | kind: "unavailable" as const, | ||
| 262 | snapshot: yield* buildUnavailableProviderSnapshot({ | ||
| 263 | driverKind: entry.driver, | ||
| 264 | instanceId, | ||
| 265 | displayName: entry.displayName, | ||
| 266 | accentColor: entry.accentColor, | ||
| 267 | reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`, | ||
| 268 | }), | ||
| 269 | }; | ||
| 270 | } | ||
| 271 | |||
| 272 | return { | ||
| 273 | kind: "live" as const, | ||
| 274 | live: { | ||
| 275 | instance: createResult.success, | ||
| 276 | scope: childScope, | ||
| 277 | entry, | ||
| 278 | }, | ||
| 279 | }; | ||
| 280 | }); | ||
| 281 | |||
| 282 | /** | ||
| 283 | * Reconcile-only implementation of the mutator. Exposed to the hydration | ||
| 284 | * layer; never called directly by the rest of the server. | ||
| 285 | */ | ||
| 286 | const makeReconcile = <R>(input: { | ||
| 287 | readonly state: RegistryState; | ||
| 288 | readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>; | ||
| 289 | readonly parentScope: Scope.Scope; | ||
| 290 | }): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => { | ||
| 291 | const { state, driversById, parentScope } = input; | ||
| 292 | return (configMap: ProviderInstanceConfigMap) => | ||
| 293 | Effect.gen(function* () { | ||
| 294 | const previousEntries = yield* Ref.get(state.entries); | ||
| 295 | const previousUnavailable = yield* Ref.get(state.unavailable); | ||
| 296 | const nextRaw = Object.entries(configMap); | ||
| 297 | const nextKeys = new Set<ProviderInstanceId>( | ||
| 298 | nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), | ||
| 299 | ); | ||
| 300 | |||
| 301 | // 1. Close scopes for instances that disappeared or whose config | ||
| 302 | // changed. Do this BEFORE creating replacements so ids map 1-to-1 | ||
| 303 | // to live scopes at all times. | ||
| 304 | const removedIds: Array<ProviderInstanceId> = []; | ||
| 305 | const replacedIds = new Set<ProviderInstanceId>(); | ||
| 306 | for (const [instanceId, live] of previousEntries) { | ||
| 307 | if (!nextKeys.has(instanceId)) { | ||
| 308 | removedIds.push(instanceId); | ||
| 309 | continue; | ||
| 310 | } | ||
| 311 | const nextEntry = configMap[instanceId]; | ||
| 312 | if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { | ||
| 313 | replacedIds.add(instanceId); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | for (const id of [...removedIds, ...replacedIds]) { | ||
| 317 | const live = previousEntries.get(id); | ||
| 318 | if (live) { | ||
| 319 | yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | // 2. Build additions and replacements. Walk `nextRaw` so the final | ||
| 324 | // entry order follows settings-author order. | ||
| 325 | const builtEntries = new Map<ProviderInstanceId, LiveEntry>(); | ||
| 326 | const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>(); | ||
| 327 | let orderChanged = false; | ||
| 328 | const previousOrder = [...previousEntries.keys()]; | ||
| 329 | const nextOrder: Array<ProviderInstanceId> = []; | ||
| 330 | |||
| 331 | for (const [rawInstanceId, entry] of nextRaw) { | ||
| 332 | const instanceId = ProviderInstanceId.make(rawInstanceId); | ||
| 333 | nextOrder.push(instanceId); | ||
| 334 | |||
| 335 | const existing = previousEntries.get(instanceId); | ||
| 336 | if (existing !== undefined && !replacedIds.has(instanceId)) { | ||
| 337 | // No-op update: keep the existing live entry and scope. | ||
| 338 | builtEntries.set(instanceId, existing); | ||
| 339 | continue; | ||
| 340 | } | ||
| 341 | |||
| 342 | const result = yield* buildEntry({ | ||
| 343 | driversById, | ||
| 344 | parentScope, | ||
| 345 | instanceId, | ||
| 346 | rawInstanceId, | ||
| 347 | entry, | ||
| 348 | }); | ||
| 349 | if (result.kind === "live") { | ||
| 350 | builtEntries.set(instanceId, result.live); | ||
| 351 | } else { | ||
| 352 | builtUnavailable.set(instanceId, result.snapshot); | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | if (previousOrder.length === nextOrder.length) { | ||
| 357 | for (let i = 0; i < previousOrder.length; i++) { | ||
| 358 | if (previousOrder[i] !== nextOrder[i]) { | ||
| 359 | orderChanged = true; | ||
| 360 | break; | ||
| 361 | } | ||
| 362 | } | ||
| 363 | } else { | ||
| 364 | orderChanged = true; | ||
| 365 | } | ||
| 366 | |||
| 367 | const entriesChanged = | ||
| 368 | orderChanged || | ||
| 369 | removedIds.length > 0 || | ||
| 370 | replacedIds.size > 0 || | ||
| 371 | builtEntries.size !== previousEntries.size; | ||
| 372 | const unavailableChanged = | ||
| 373 | builtUnavailable.size !== previousUnavailable.size || | ||
| 374 | [...builtUnavailable].some(([id, snapshot]) => { | ||
| 375 | const prev = previousUnavailable.get(id); | ||
| 376 | return prev === undefined || !Equal.equals(prev, snapshot); | ||
| 377 | }) || | ||
| 378 | [...previousUnavailable].some(([id]) => !builtUnavailable.has(id)); | ||
| 379 | |||
| 380 | yield* Ref.set(state.entries, builtEntries); | ||
| 381 | yield* Ref.set(state.unavailable, builtUnavailable); | ||
| 382 | |||
| 383 | if (entriesChanged || unavailableChanged) { | ||
| 384 | yield* PubSub.publish(state.changes, undefined); | ||
| 385 | } | ||
| 386 | }); | ||
| 387 | }; | ||
| 388 | |||
| 389 | /** | ||
| 390 | * Build the registry's runtime state from a concrete configMap. Returns a | ||
| 391 | * record containing: | ||
| 392 | * | ||
| 393 | * - `registry`: the read-only `ProviderInstanceRegistry["Service"]` to expose | ||
| 394 | * under `ProviderInstanceRegistry`. | ||
| 395 | * - `mutator`: the `ProviderInstanceRegistryMutatorShape` to expose | ||
| 396 | * under `ProviderInstanceRegistryMutator`. | ||
| 397 | * - `reconcile`: the raw reconcile function, provided for convenience so | ||
| 398 | * boot-time layers can hydrate an initial map before publishing the | ||
| 399 | * services. | ||
| 400 | * | ||
| 401 | * The scope that this effect runs in owns every per-instance child scope | ||
| 402 | * created during `reconcile`. Closing that scope closes every live | ||
| 403 | * instance. | ||
| 404 | */ | ||
| 405 | export const makeProviderInstanceRegistry = <R>(input: { | ||
| 406 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 407 | readonly configMap: ProviderInstanceConfigMap; | ||
| 408 | }): Effect.Effect< | ||
| 409 | { | ||
| 410 | readonly registry: ProviderInstanceRegistry["Service"]; | ||
| 411 | readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; | ||
| 412 | }, | ||
| 413 | never, | ||
| 414 | R | Scope.Scope | ||
| 415 | > => | ||
| 416 | Effect.gen(function* () { | ||
| 417 | const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>( | ||
| 418 | input.drivers.map((driver) => [driver.driverKind, driver]), | ||
| 419 | ); | ||
| 420 | |||
| 421 | // Capture the enclosing scope so per-instance child scopes can be | ||
| 422 | // attached to it at `reconcile` time. Without this, `reconcile` | ||
| 423 | // called later (e.g. from the hydration layer) would attach child | ||
| 424 | // scopes to the *caller's* scope instead of the registry's. | ||
| 425 | const parentScope = yield* Scope.Scope; | ||
| 426 | |||
| 427 | // Capture the driver R context at construction time so `reconcile` | ||
| 428 | // can be invoked later without re-providing driver dependencies. | ||
| 429 | // The service tag's declared `reconcile: Effect<void>` hides R from | ||
| 430 | // consumers — we materialize that here. | ||
| 431 | const driverContext = yield* Effect.context<R>(); | ||
| 432 | |||
| 433 | const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map()); | ||
| 434 | const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map()); | ||
| 435 | const changes = yield* PubSub.unbounded<void>(); | ||
| 436 | yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); | ||
| 437 | |||
| 438 | const state: RegistryState = { entries, unavailable, changes }; | ||
| 439 | const reconcileWithR = makeReconcile({ state, driversById, parentScope }); | ||
| 440 | const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = | ||
| 441 | (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); | ||
| 442 | |||
| 443 | // Hydrate the initial configMap synchronously so callers can read | ||
| 444 | // `listInstances` immediately after this effect completes. | ||
| 445 | yield* reconcile(input.configMap); | ||
| 446 | |||
| 447 | const registry: ProviderInstanceRegistry["Service"] = { | ||
| 448 | getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), | ||
| 449 | listInstances: Ref.get(entries).pipe( | ||
| 450 | Effect.map( | ||
| 451 | (map) => | ||
| 452 | Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>, | ||
| 453 | ), | ||
| 454 | ), | ||
| 455 | listUnavailable: Ref.get(unavailable).pipe( | ||
| 456 | Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>), | ||
| 457 | ), | ||
| 458 | // Getters: each read constructs a fresh Stream / Effect descriptor | ||
| 459 | // so multiple consumers don't share a single already-started | ||
| 460 | // Channel or subscription. Matches the pattern `ProviderRegistry` | ||
| 461 | // uses for its own `streamChanges`. | ||
| 462 | get streamChanges() { | ||
| 463 | return Stream.fromPubSub(changes); | ||
| 464 | }, | ||
| 465 | // Synchronous subscribe — callers that need to consume changes | ||
| 466 | // from a forked fibre must acquire the subscription in their own | ||
| 467 | // fibre first (via `yield* registry.subscribeChanges`) and only | ||
| 468 | // then fork a consumer loop on `Stream.fromSubscription(...)` / | ||
| 469 | // `PubSub.take(...)`. See the shape docs for the race this avoids. | ||
| 470 | get subscribeChanges() { | ||
| 471 | return PubSub.subscribe(changes); | ||
| 472 | }, | ||
| 473 | }; | ||
| 474 | |||
| 475 | const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { | ||
| 476 | reconcile, | ||
| 477 | }; | ||
| 478 | |||
| 479 | return { registry, mutator }; | ||
| 480 | }); | ||
| 481 | |||
| 482 | /** | ||
| 483 | * Layer variant that also exposes the mutator tag. Consumed by | ||
| 484 | * `ProviderInstanceRegistryHydrationLive` to reconcile on settings | ||
| 485 | * changes. Tests that exercise the mutator directly can pair this Layer | ||
| 486 | * with a test-local `ServerSettingsService`. | ||
| 487 | */ | ||
| 488 | export const layer = <R>(input: { | ||
| 489 | readonly drivers: ReadonlyArray<AnyProviderDriver<R>>; | ||
| 490 | readonly configMap: ProviderInstanceConfigMap; | ||
| 491 | }): Layer.Layer< | ||
| 492 | ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, | ||
| 493 | never, | ||
| 494 | R | ||
| 495 | > => | ||
| 496 | Layer.effectContext( | ||
| 497 | makeProviderInstanceRegistry(input).pipe( | ||
| 498 | Effect.map(({ registry, mutator }) => | ||
| 499 | Context.make(ProviderInstanceRegistry, registry).pipe( | ||
| 500 | Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator), | ||
| 501 | ), | ||
| 502 | ), | ||
| 503 | ), | ||
| 504 | ) as Layer.Layer< | ||
| 505 | ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, | ||
| 506 | never, | ||
| 507 | R | ||
| 508 | >; | ||
apps/server/src/provider/ProviderInstanceRegistryHydration.ts created+192| ... | @@ -0,0 +1,192 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` | ||
| 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. | ||
| 4 | * | ||
| 5 | * The server still reads two shapes: | ||
| 6 | * | ||
| 7 | * 1. `settings.providerInstances` — the new driver-agnostic map the | ||
| 8 | * registry expects. Keyed by `ProviderInstanceId`, values are | ||
| 9 | * `ProviderInstanceConfig` envelopes. | ||
| 10 | * 2. `settings.providers.<kind>` — the legacy single-instance-per-driver | ||
| 11 | * fields (`providers.codex`, `providers.claudeAgent`, …). These are | ||
| 12 | * the source of truth for every deployment that hasn't been migrated | ||
| 13 | * yet to an explicit `providerInstances` entry. | ||
| 14 | * | ||
| 15 | * This module bridges (2) into (1) and wires the resulting map into a | ||
| 16 | * mutable registry. For every built-in driver whose id is not already | ||
| 17 | * present in `providerInstances` (keyed on | ||
| 18 | * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a | ||
| 19 | * routing slug), we synthesize an envelope from the legacy field. The | ||
| 20 | * registry decodes both flavours through the same `configSchema` and ends | ||
| 21 | * up with one uniform `ProviderInstance` per entry. | ||
| 22 | * | ||
| 23 | * Explicit `providerInstances` entries always win — users can already | ||
| 24 | * override the legacy `providers.<kind>` blob by authoring a | ||
| 25 | * `providerInstances.codex` entry with a matching driver, and we don't | ||
| 26 | * want the synthesized envelope to silently stomp their config. | ||
| 27 | * | ||
| 28 | * Hot-reload | ||
| 29 | * ---------- | ||
| 30 | * On layer build we: | ||
| 31 | * 1. Read the current `ServerSettings` once and use it to seed the | ||
| 32 | * registry's initial state via `ProviderInstanceRegistry.layer`. | ||
| 33 | * 2. Fork a daemon fiber (lifetime tied to the layer's scope) that | ||
| 34 | * acquires `ServerSettingsService.subscribeChanges` and calls | ||
| 35 | * `ProviderInstanceRegistryMutator.reconcile` on every emission. | ||
| 36 | * | ||
| 37 | * Failures inside the watcher are logged and swallowed so a single bad | ||
| 38 | * settings emission cannot kill the registry. Unknown drivers and invalid | ||
| 39 | * configs already round-trip through the registry's own "unavailable" | ||
| 40 | * shadow bucket. | ||
| 41 | * | ||
| 42 | * @module provider/ProviderInstanceRegistryHydration | ||
| 43 | */ | ||
| 44 | import { | ||
| 45 | defaultInstanceIdForDriver, | ||
| 46 | type ProviderInstanceConfig, | ||
| 47 | type ProviderInstanceConfigMap, | ||
| 48 | ServerSettings, | ||
| 49 | } from "@t3tools/contracts"; | ||
| 50 | import * as Effect from "effect/Effect"; | ||
| 51 | import * as Layer from "effect/Layer"; | ||
| 52 | import * as Stream from "effect/Stream"; | ||
| 53 | |||
| 54 | import * as Settings from "../serverSettings.ts"; | ||
| 55 | import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; | ||
| 56 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| 57 | import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; | ||
| 58 | import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; | ||
| 59 | import * as AcpRegistrySupport from "./acp/AcpRegistrySupport.ts"; | ||
| 60 | import * as AcpRegistryCatalog from "./AcpRegistryCatalog.ts"; | ||
| 61 | |||
| 62 | type ProviderInstanceRegistryHydrationEnv = | ||
| 63 | | Exclude< | ||
| 64 | BuiltInDriversEnv, | ||
| 65 | | ProviderOrchestrationAdapterInfrastructure.ProviderOrchestrationAdapterInfrastructure | ||
| 66 | | AcpRegistrySupport.AcpRegistryCatalog | ||
| 67 | > | ||
| 68 | | Settings.ServerSettingsService; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. | ||
| 72 | * | ||
| 73 | * Strategy: | ||
| 74 | * 1. Copy all explicit `settings.providerInstances` entries verbatim. | ||
| 75 | * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key | ||
| 76 | * is *not* already in the explicit map, synthesize an entry from the | ||
| 77 | * matching legacy `settings.providers.<kind>` blob. | ||
| 78 | * | ||
| 79 | * The returned map is the input the registry consumes; pure & exported | ||
| 80 | * separately so the hydration logic can be exercised by unit tests | ||
| 81 | * without layering. | ||
| 82 | */ | ||
| 83 | export const deriveProviderInstanceConfigMap = ( | ||
| 84 | settings: ServerSettings, | ||
| 85 | ): ProviderInstanceConfigMap => { | ||
| 86 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; | ||
| 87 | |||
| 88 | for (const driver of BUILT_IN_DRIVERS) { | ||
| 89 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); | ||
| 90 | if (instanceId in merged) { | ||
| 91 | // Explicit `providerInstances` entry for this slot — user-authored | ||
| 92 | // config always wins over the legacy mirror. | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | |||
| 96 | // Only built-in drivers have a legacy mirror; the registry's | ||
| 97 | // `providers` struct is keyed on the same literal slug as | ||
| 98 | // `driverKind`. Access is dynamic (the driver kind is a branded string), | ||
| 99 | // but it's constrained to `keyof settings.providers` by the union of | ||
| 100 | // built-in driver kinds. | ||
| 101 | const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; | ||
| 102 | const legacyConfig = settings.providers[legacyKey]; | ||
| 103 | if (legacyConfig === undefined) { | ||
| 104 | continue; | ||
| 105 | } | ||
| 106 | |||
| 107 | merged[instanceId] = { | ||
| 108 | driver: driver.driverKind, | ||
| 109 | config: legacyConfig, | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | return merged as ProviderInstanceConfigMap; | ||
| 114 | }; | ||
| 115 | |||
| 116 | /** | ||
| 117 | * Layer that consumes `ProviderInstanceRegistryMutator` and forks a | ||
| 118 | * settings-watcher fiber. The fiber's lifetime is tied to the enclosing | ||
| 119 | * layer scope (process lifetime in production), so it is interrupted on | ||
| 120 | * shutdown without leaking. | ||
| 121 | * | ||
| 122 | * Errors inside the watcher are logged and swallowed — the registry's own | ||
| 123 | * "unavailable" bucket already absorbs unknown drivers and invalid | ||
| 124 | * configs, so the only way the watcher could fail is a settings stream | ||
| 125 | * tear-down, which logs and exits cleanly. | ||
| 126 | */ | ||
| 127 | const layerSettingsWatcher = Layer.effectDiscard( | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const mutator = yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; | ||
| 130 | const serverSettings = yield* Settings.ServerSettingsService; | ||
| 131 | const settingsChanges = yield* serverSettings.subscribeChanges; | ||
| 132 | yield* settingsChanges.pipe( | ||
| 133 | Stream.runForEach((next) => | ||
| 134 | mutator | ||
| 135 | .reconcile(deriveProviderInstanceConfigMap(next)) | ||
| 136 | .pipe( | ||
| 137 | Effect.catchCause((cause) => | ||
| 138 | Effect.logError("ProviderInstanceRegistry reconcile failed", cause), | ||
| 139 | ), | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | Effect.forkScoped, | ||
| 143 | ); | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 147 | /** | ||
| 148 | * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in | ||
| 149 | * sync with subsequent `streamChanges` emissions. | ||
| 150 | * | ||
| 151 | * The Layer's two halves: | ||
| 152 | * - `ProviderInstanceRegistry.layer` produces the registry + | ||
| 153 | * mutator from the initial config map. Its scope owns every | ||
| 154 | * per-instance child scope created during reconcile. | ||
| 155 | * - `SettingsWatcherLive` consumes the mutator, acquires its settings | ||
| 156 | * subscription before forking, and runs a daemon fiber in the same scope. | ||
| 157 | * | ||
| 158 | * Composing via `Layer.provideMerge` makes the watcher's deps available | ||
| 159 | * from the mutable layer while still surfacing the registry as an output. | ||
| 160 | * The mutator tag is technically also exposed; only this module imports | ||
| 161 | * it, so the visibility leak is harmless in practice. | ||
| 162 | */ | ||
| 163 | export const layer: Layer.Layer< | ||
| 164 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 165 | never, | ||
| 166 | ProviderInstanceRegistryHydrationEnv | ||
| 167 | > = Layer.unwrap( | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const serverSettings = yield* Settings.ServerSettingsService; | ||
| 170 | const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( | ||
| 171 | Effect.orElseSucceed(() => undefined), | ||
| 172 | ); | ||
| 173 | const initialConfigMap = | ||
| 174 | initialSettings === undefined | ||
| 175 | ? ({} as ProviderInstanceConfigMap) | ||
| 176 | : deriveProviderInstanceConfigMap(initialSettings); | ||
| 177 | |||
| 178 | const layerMutable = ProviderInstanceRegistry.layer({ | ||
| 179 | drivers: BUILT_IN_DRIVERS, | ||
| 180 | configMap: initialConfigMap, | ||
| 181 | }).pipe( | ||
| 182 | Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), | ||
| 183 | Layer.provide(AcpRegistryCatalog.layer), | ||
| 184 | ); | ||
| 185 | |||
| 186 | return layerSettingsWatcher.pipe(Layer.provideMerge(layerMutable)); | ||
| 187 | }), | ||
| 188 | ) as Layer.Layer< | ||
| 189 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 190 | never, | ||
| 191 | ProviderInstanceRegistryHydrationEnv | ||
| 192 | >; | ||
apps/server/src/provider/ProviderInstanceRegistryMutator.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryMutator — internal handle used by the hydration | ||
| 3 | * layer to reconcile the live registry with a fresh | ||
| 4 | * `ProviderInstanceConfigMap`. | ||
| 5 | * | ||
| 6 | * Kept separate from the public `ProviderInstanceRegistry` service tag so | ||
| 7 | * downstream consumers (drivers, reactors, `ProviderService`) can only read | ||
| 8 | * from the registry. Only the hydration layer — which watches | ||
| 9 | * `ServerSettingsService.streamChanges` and applies diffs — imports this | ||
| 10 | * tag. | ||
| 11 | * | ||
| 12 | * The mutator exposes a single entry point, `reconcile(configMap)`, which: | ||
| 13 | * | ||
| 14 | * 1. Diffs the incoming map against the live one keyed by instance id. | ||
| 15 | * 2. Closes the per-instance `Scope` of every removed or replaced entry | ||
| 16 | * (tearing down adapter processes, refresh fibres, temp files) BEFORE | ||
| 17 | * creating the replacement — `reconcile` guarantees "at most one live | ||
| 18 | * instance per id" at all times. | ||
| 19 | * 3. Opens a fresh child `Scope` for every added or replaced entry, runs | ||
| 20 | * the driver's `create`, and stores the resulting `ProviderInstance` | ||
| 21 | * plus its scope. | ||
| 22 | * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at | ||
| 23 | * the end of the batch — consumers re-pull `listInstances` / | ||
| 24 | * `listUnavailable`. | ||
| 25 | * | ||
| 26 | * `reconcile` is idempotent: calling it with an unchanged config map is a | ||
| 27 | * no-op (no scope churn, no pubsub emission). | ||
| 28 | * | ||
| 29 | * @module provider/ProviderInstanceRegistryMutator | ||
| 30 | */ | ||
| 31 | import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; | ||
| 32 | import * as Context from "effect/Context"; | ||
| 33 | import type * as Effect from "effect/Effect"; | ||
| 34 | |||
| 35 | export interface ProviderInstanceRegistryMutatorShape { | ||
| 36 | /** | ||
| 37 | * Bring the live registry in line with the supplied config map. See | ||
| 38 | * module docs for the add / remove / replace semantics. | ||
| 39 | * | ||
| 40 | * The effect never fails: individual driver `create` failures are | ||
| 41 | * captured as "unavailable" shadow snapshots inside the registry, the | ||
| 42 | * same way boot-time failures are handled by | ||
| 43 | * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from | ||
| 44 | * erroring out on a single bad entry. | ||
| 45 | */ | ||
| 46 | readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect<void>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export class ProviderInstanceRegistryMutator extends Context.Service< | ||
| 50 | ProviderInstanceRegistryMutator, | ||
| 51 | ProviderInstanceRegistryMutatorShape | ||
| 52 | >()("t3/provider/ProviderInstanceRegistryMutator") {} | ||
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import * as Layer from "effect/Layer"; | ||
| 2 | |||
| 3 | import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | ||
| 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; | ||
| 5 | import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; | ||
| 6 | import * as IdAllocator from "../orchestration-v2/IdAllocator.ts"; | ||
| 7 | import * as ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; | ||
| 8 | |||
| 9 | export type ProviderOrchestrationAdapterInfrastructure = | ||
| 10 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner | ||
| 11 | | CodexAdapterV2.CodexAppServerClientFactory | ||
| 12 | | CursorAgentSdk.CursorAgentSdkRunner | ||
| 13 | | IdAllocator.IdAllocatorV2; | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Infrastructure shared by the V2 adapters materialized inside provider | ||
| 17 | * instances. `providerContinuationRequestsLayer` must be the same layer | ||
| 18 | * reference the orchestration runtime provides to its continuation worker so | ||
| 19 | * Effect layer memoization yields one shared queue. | ||
| 20 | */ | ||
| 21 | export const layer = Layer.mergeAll( | ||
| 22 | ClaudeAdapterV2.layerQueryRunner, | ||
| 23 | CodexAdapterV2.layerAppServerClientFactory, | ||
| 24 | CursorAgentSdk.layer, | ||
| 25 | IdAllocator.layer, | ||
| 26 | ProviderContinuationRequests.layer, | ||
| 27 | ); | ||
apps/server/src/provider/ProviderRegistry.test.ts created+3376| ... | @@ -0,0 +1,3376 @@ | ||
| 1 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 2 | import * as CodexInstallation from "./CodexInstallation.ts"; | ||
| 3 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { describe, it, assert } from "@effect/vitest"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Deferred from "effect/Deferred"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as PubSub from "effect/PubSub"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Scope from "effect/Scope"; | ||
| 17 | import * as Sink from "effect/Sink"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import * as TestClock from "effect/testing/TestClock"; | ||
| 20 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 21 | import { | ||
| 22 | EnvironmentId, | ||
| 23 | ClaudeSettings, | ||
| 24 | CodexSettings, | ||
| 25 | DEFAULT_SERVER_SETTINGS, | ||
| 26 | ProviderDriverKind, | ||
| 27 | ProviderInstanceId, | ||
| 28 | ServerSettings, | ||
| 29 | type ServerProvider, | ||
| 30 | type ServerProviderSlashCommand, | ||
| 31 | type ServerSettings as ContractServerSettings, | ||
| 32 | } from "@t3tools/contracts"; | ||
| 33 | import * as PlatformError from "effect/PlatformError"; | ||
| 34 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 35 | import { ChildProcessSpawner } from "effect/process"; | ||
| 36 | import { deepMerge } from "@t3tools/shared/Struct"; | ||
| 37 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 38 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 39 | |||
| 40 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | ||
| 41 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | ||
| 42 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 43 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; | ||
| 44 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 45 | import { applyProviderCompatibility } from "./providerCompatibility.ts"; | ||
| 46 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 47 | import * as OpenCodeRuntime from "./opencodeRuntime.ts"; | ||
| 48 | import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; | ||
| 49 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | ||
| 50 | import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; | ||
| 51 | import * as ServerConfig from "../config.ts"; | ||
| 52 | import * as ServerSettingsModule from "../serverSettings.ts"; | ||
| 53 | import { | ||
| 54 | readProviderStatusCache, | ||
| 55 | resolveProviderStatusCachePath, | ||
| 56 | writeProviderStatusCache, | ||
| 57 | } from "./providerStatusCache.ts"; | ||
| 58 | import { COMPACT_SLASH_COMMAND } from "./providerSnapshot.ts"; | ||
| 59 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; | ||
| 60 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| 61 | import * as ProviderRegistry from "./ProviderRegistry.ts"; | ||
| 62 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | ||
| 63 | const decodeServerSettings = Schema.decodeSync(ServerSettings); | ||
| 64 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 65 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); | ||
| 66 | |||
| 67 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | ||
| 68 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | ||
| 69 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | ||
| 70 | const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ | ||
| 71 | enabled: false, | ||
| 72 | }); | ||
| 73 | |||
| 74 | process.env.T3CODE_CURSOR_ENABLED = "1"; | ||
| 75 | |||
| 76 | // ── Test helpers ──────────────────────────────────────────────────── | ||
| 77 | |||
| 78 | const encoder = new TextEncoder(); | ||
| 79 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 80 | const withBundledCompatibility = (snapshot: ServerProvider) => | ||
| 81 | applyProviderCompatibility( | ||
| 82 | snapshot, | ||
| 83 | undefined, | ||
| 84 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 85 | ); | ||
| 86 | |||
| 87 | // Provider metadata checks use a bundled manifest and stubbed HTTP. | ||
| 88 | const layerTestHttpClient = Layer.succeed( | ||
| 89 | HttpClient.HttpClient, | ||
| 90 | HttpClient.make((request) => | ||
| 91 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), | ||
| 92 | ), | ||
| 93 | ).pipe(Layer.provideMerge(ModelManifest.layerTest)); | ||
| 94 | |||
| 95 | const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 96 | reportClientActivity: () => Effect.void, | ||
| 97 | removeRpcClient: () => Effect.void, | ||
| 98 | reportHostPowerState: () => Effect.void, | ||
| 99 | snapshot: Effect.succeed({ | ||
| 100 | hostPower: { | ||
| 101 | source: "unknown", | ||
| 102 | idle: "unknown", | ||
| 103 | idleSeconds: null, | ||
| 104 | locked: "unknown", | ||
| 105 | suspended: false, | ||
| 106 | onBattery: "unknown", | ||
| 107 | lowPowerMode: "unknown", | ||
| 108 | thermalState: "unknown", | ||
| 109 | stale: true, | ||
| 110 | updatedAt: TEST_EPOCH, | ||
| 111 | }, | ||
| 112 | leases: [], | ||
| 113 | activeForegroundLeaseCount: 0, | ||
| 114 | activeScopeKeys: [], | ||
| 115 | shouldRunOpportunisticWork: true, | ||
| 116 | updatedAt: TEST_EPOCH, | ||
| 117 | }), | ||
| 118 | streamChanges: Stream.empty, | ||
| 119 | hasDemand: () => Effect.succeed(true), | ||
| 120 | shouldRunScopeWork: () => Effect.succeed(true), | ||
| 121 | shouldRunOpportunisticWork: Effect.succeed(true), | ||
| 122 | }); | ||
| 123 | |||
| 124 | function selectDescriptor( | ||
| 125 | id: string, | ||
| 126 | label: string, | ||
| 127 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | ||
| 128 | ) { | ||
| 129 | return { | ||
| 130 | id, | ||
| 131 | label, | ||
| 132 | type: "select" as const, | ||
| 133 | options: [...options], | ||
| 134 | ...(options.find((option) => option.isDefault)?.id | ||
| 135 | ? { currentValue: options.find((option) => option.isDefault)?.id } | ||
| 136 | : {}), | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | |||
| 140 | function booleanDescriptor(id: string, label: string) { | ||
| 141 | return { | ||
| 142 | id, | ||
| 143 | label, | ||
| 144 | type: "boolean" as const, | ||
| 145 | }; | ||
| 146 | } | ||
| 147 | |||
| 148 | type TestClaudeCapabilities = { | ||
| 149 | readonly email: string | undefined; | ||
| 150 | readonly subscriptionType: string | undefined; | ||
| 151 | readonly tokenSource: string | undefined; | ||
| 152 | readonly apiProvider: string | undefined; | ||
| 153 | readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>; | ||
| 154 | }; | ||
| 155 | |||
| 156 | function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) { | ||
| 157 | return () => | ||
| 158 | Effect.succeed({ | ||
| 159 | email: undefined, | ||
| 160 | subscriptionType: undefined, | ||
| 161 | tokenSource: undefined, | ||
| 162 | apiProvider: undefined, | ||
| 163 | slashCommands: [], | ||
| 164 | ...overrides, | ||
| 165 | }); | ||
| 166 | } | ||
| 167 | |||
| 168 | const noClaudeCapabilities = () => | ||
| 169 | Effect.sync(() => undefined as TestClaudeCapabilities | undefined); | ||
| 170 | |||
| 171 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | ||
| 172 | return ChildProcessSpawner.makeHandle({ | ||
| 173 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 174 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), | ||
| 175 | isRunning: Effect.succeed(false), | ||
| 176 | kill: () => Effect.void, | ||
| 177 | unref: Effect.succeed(Effect.void), | ||
| 178 | stdin: Sink.drain, | ||
| 179 | stdout: Stream.make(encoder.encode(result.stdout)), | ||
| 180 | stderr: Stream.make(encoder.encode(result.stderr)), | ||
| 181 | all: Stream.empty, | ||
| 182 | getInputFd: () => Sink.drain, | ||
| 183 | getOutputFd: () => Stream.empty, | ||
| 184 | }); | ||
| 185 | } | ||
| 186 | |||
| 187 | function layerMockSpawner( | ||
| 188 | handler: (args: ReadonlyArray<string>) => { | ||
| 189 | stdout: string; | ||
| 190 | stderr: string; | ||
| 191 | code: number; | ||
| 192 | }, | ||
| 193 | ) { | ||
| 194 | return Layer.succeed( | ||
| 195 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 196 | ChildProcessSpawner.make((command) => { | ||
| 197 | const cmd = command as unknown as { args: ReadonlyArray<string> }; | ||
| 198 | return Effect.succeed(mockHandle(handler(cmd.args))); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | function recordingMockSpawnerLayer( | ||
| 204 | handler: (args: ReadonlyArray<string>) => { | ||
| 205 | stdout: string; | ||
| 206 | stderr: string; | ||
| 207 | code: number; | ||
| 208 | }, | ||
| 209 | ) { | ||
| 210 | const commands: Array<{ | ||
| 211 | readonly args: ReadonlyArray<string>; | ||
| 212 | readonly env: NodeJS.ProcessEnv | undefined; | ||
| 213 | }> = []; | ||
| 214 | const layer = Layer.succeed( | ||
| 215 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 216 | ChildProcessSpawner.make((command) => { | ||
| 217 | const cmd = command as unknown as { | ||
| 218 | args: ReadonlyArray<string>; | ||
| 219 | options?: { | ||
| 220 | readonly env?: NodeJS.ProcessEnv; | ||
| 221 | }; | ||
| 222 | }; | ||
| 223 | commands.push({ args: cmd.args, env: cmd.options?.env }); | ||
| 224 | return Effect.succeed(mockHandle(handler(cmd.args))); | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | return { layer, commands }; | ||
| 228 | } | ||
| 229 | |||
| 230 | function layerMockCommandSpawner( | ||
| 231 | handler: ( | ||
| 232 | command: string, | ||
| 233 | args: ReadonlyArray<string>, | ||
| 234 | ) => { stdout: string; stderr: string; code: number }, | ||
| 235 | ) { | ||
| 236 | return Layer.succeed( | ||
| 237 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 238 | ChildProcessSpawner.make((command) => { | ||
| 239 | const cmd = command as unknown as { | ||
| 240 | command: string; | ||
| 241 | args: ReadonlyArray<string>; | ||
| 242 | }; | ||
| 243 | return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | } | ||
| 247 | |||
| 248 | function layerFailingSpawner(description: string) { | ||
| 249 | return Layer.succeed( | ||
| 250 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 251 | ChildProcessSpawner.make(() => | ||
| 252 | Effect.fail( | ||
| 253 | PlatformError.systemError({ | ||
| 254 | _tag: "NotFound", | ||
| 255 | module: "ChildProcess", | ||
| 256 | method: "spawn", | ||
| 257 | description, | ||
| 258 | }), | ||
| 259 | ), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | |||
| 264 | function layerHangingScopedSpawner(killCalls: Ref.Ref<number>) { | ||
| 265 | return Layer.succeed( | ||
| 266 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 267 | ChildProcessSpawner.make(() => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const handle = ChildProcessSpawner.makeHandle({ | ||
| 270 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 271 | exitCode: Effect.never, | ||
| 272 | isRunning: Effect.succeed(true), | ||
| 273 | kill: () => Ref.update(killCalls, (current) => current + 1), | ||
| 274 | unref: Effect.succeed(Effect.void), | ||
| 275 | stdin: Sink.drain, | ||
| 276 | stdout: Stream.never, | ||
| 277 | stderr: Stream.never, | ||
| 278 | all: Stream.never, | ||
| 279 | getInputFd: () => Sink.drain, | ||
| 280 | getOutputFd: () => Stream.empty, | ||
| 281 | }); | ||
| 282 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 283 | return handle; | ||
| 284 | }), | ||
| 285 | ), | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | const codexModelCapabilities = createModelCapabilities({ | ||
| 290 | optionDescriptors: [ | ||
| 291 | selectDescriptor("reasoningEffort", "Reasoning", [ | ||
| 292 | { id: "high", label: "High", isDefault: true }, | ||
| 293 | { id: "low", label: "Low" }, | ||
| 294 | ]), | ||
| 295 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 296 | ], | ||
| 297 | }) satisfies NonNullable<ServerProvider["models"][number]["capabilities"]>; | ||
| 298 | |||
| 299 | function makeCodexProbeSnapshot( | ||
| 300 | input: Partial<CodexAppServerProviderSnapshot> = {}, | ||
| 301 | ): CodexAppServerProviderSnapshot { | ||
| 302 | return { | ||
| 303 | version: "1.0.0", | ||
| 304 | account: { | ||
| 305 | account: { | ||
| 306 | type: "chatgpt", | ||
| 307 | email: "test@example.com", | ||
| 308 | planType: "pro", | ||
| 309 | }, | ||
| 310 | requiresOpenaiAuth: false, | ||
| 311 | }, | ||
| 312 | models: [ | ||
| 313 | { | ||
| 314 | slug: "gpt-live-codex", | ||
| 315 | name: "GPT Live Codex", | ||
| 316 | isCustom: false, | ||
| 317 | capabilities: codexModelCapabilities, | ||
| 318 | }, | ||
| 319 | ], | ||
| 320 | skills: [], | ||
| 321 | ...input, | ||
| 322 | }; | ||
| 323 | } | ||
| 324 | |||
| 325 | function makeMutableServerSettingsService( | ||
| 326 | initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, | ||
| 327 | ) { | ||
| 328 | return Effect.gen(function* () { | ||
| 329 | const settingsRef = yield* Ref.make(initial); | ||
| 330 | const changes = yield* PubSub.unbounded<ContractServerSettings>(); | ||
| 331 | |||
| 332 | return { | ||
| 333 | start: Effect.void, | ||
| 334 | ready: Effect.void, | ||
| 335 | getSettings: Ref.get(settingsRef), | ||
| 336 | updateSettings: (patch) => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const current = yield* Ref.get(settingsRef); | ||
| 339 | const next = applyServerSettingsPatch(current, patch); | ||
| 340 | encodeServerSettings(next); | ||
| 341 | yield* Ref.set(settingsRef, next); | ||
| 342 | yield* PubSub.publish(changes, next); | ||
| 343 | return next; | ||
| 344 | }), | ||
| 345 | updateProviderInstance: (mutation, patch = {}) => | ||
| 346 | Effect.gen(function* () { | ||
| 347 | const current = yield* Ref.get(settingsRef); | ||
| 348 | const next = ServerSettingsModule.applyProviderInstanceMutation( | ||
| 349 | applyServerSettingsPatch(current, patch), | ||
| 350 | mutation, | ||
| 351 | ); | ||
| 352 | encodeServerSettings(next); | ||
| 353 | yield* Ref.set(settingsRef, next); | ||
| 354 | yield* PubSub.publish(changes, next); | ||
| 355 | return next; | ||
| 356 | }), | ||
| 357 | withSettingsSnapshot: (use) => Ref.get(settingsRef).pipe(Effect.flatMap(use)), | ||
| 358 | get streamChanges() { | ||
| 359 | return Stream.fromPubSub(changes); | ||
| 360 | }, | ||
| 361 | get subscribeChanges() { | ||
| 362 | return PubSub.subscribe(changes).pipe( | ||
| 363 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | ||
| 364 | ); | ||
| 365 | }, | ||
| 366 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; | ||
| 367 | }); | ||
| 368 | } | ||
| 369 | |||
| 370 | // The registry writes the status cache and only then publishes the change, so | ||
| 371 | // a subscriber that sees `checkedAt` on the stream knows the file is on disk. | ||
| 372 | // Subscribed before the publish that triggers it; a spin on the file would | ||
| 373 | // race the write and lose on a slow host. | ||
| 374 | const awaitPersistedProvider = ( | ||
| 375 | registry: ProviderRegistry.ProviderRegistry["Service"], | ||
| 376 | checkedAt: string, | ||
| 377 | ) => | ||
| 378 | registry.streamChanges.pipe( | ||
| 379 | Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)), | ||
| 380 | Stream.take(1), | ||
| 381 | Stream.runDrain, | ||
| 382 | Effect.forkScoped, | ||
| 383 | ); | ||
| 384 | |||
| 385 | const layerTestNodeServices = Layer.mergeAll( | ||
| 386 | NodeServices.layer, | ||
| 387 | Layer.mock(CodexInstallation.CodexInstallation)({ | ||
| 388 | managedDirectory: "unused-managed-installation", | ||
| 389 | }), | ||
| 390 | Layer.mock(ServerSecretStore.ServerSecretStore)({}), | ||
| 391 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 392 | getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | |||
| 396 | it.layer( | ||
| 397 | Layer.mergeAll(layerTestNodeServices, ServerSettingsModule.layerTest(), layerTestHttpClient), | ||
| 398 | )("ProviderRegistry", (it) => { | ||
| 399 | describe("checkCodexProviderStatus", () => { | ||
| 400 | it.effect("uses the app-server account and model list for provider status", () => | ||
| 401 | Effect.gen(function* () { | ||
| 402 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 403 | Effect.succeed( | ||
| 404 | makeCodexProbeSnapshot({ | ||
| 405 | skills: [ | ||
| 406 | { | ||
| 407 | name: "github:gh-fix-ci", | ||
| 408 | path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", | ||
| 409 | enabled: true, | ||
| 410 | displayName: "CI Debug", | ||
| 411 | shortDescription: "Debug failing GitHub Actions checks", | ||
| 412 | }, | ||
| 413 | ], | ||
| 414 | }), | ||
| 415 | ), | ||
| 416 | ); | ||
| 417 | assert.strictEqual(status.status, "ready"); | ||
| 418 | assert.strictEqual(status.installed, true); | ||
| 419 | assert.strictEqual(status.version, "1.0.0"); | ||
| 420 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 421 | assert.strictEqual(status.auth.type, "chatgpt"); | ||
| 422 | assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); | ||
| 423 | assert.strictEqual(status.auth.email, "test@example.com"); | ||
| 424 | assert.deepStrictEqual(status.models, [ | ||
| 425 | { | ||
| 426 | slug: "gpt-live-codex", | ||
| 427 | name: "GPT Live Codex", | ||
| 428 | isCustom: false, | ||
| 429 | capabilities: codexModelCapabilities, | ||
| 430 | }, | ||
| 431 | ]); | ||
| 432 | assert.deepStrictEqual(status.skills, [ | ||
| 433 | { | ||
| 434 | name: "github:gh-fix-ci", | ||
| 435 | path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", | ||
| 436 | enabled: true, | ||
| 437 | displayName: "CI Debug", | ||
| 438 | shortDescription: "Debug failing GitHub Actions checks", | ||
| 439 | }, | ||
| 440 | ]); | ||
| 441 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | ||
| 442 | { | ||
| 443 | name: "goal", | ||
| 444 | description: "Set a goal Codex keeps working toward until it is done", | ||
| 445 | input: { hint: "Objective, or pause, resume, clear" }, | ||
| 446 | }, | ||
| 447 | { | ||
| 448 | name: "feedback", | ||
| 449 | description: "Send this thread and Codex logs to OpenAI", | ||
| 450 | input: { hint: "Describe the issue (optional)" }, | ||
| 451 | }, | ||
| 452 | ]); | ||
| 453 | }), | ||
| 454 | ); | ||
| 455 | |||
| 456 | it.effect("passes configured launch args to the Codex provider probe", () => | ||
| 457 | Effect.gen(function* () { | ||
| 458 | let observedLaunchArgs: string | undefined; | ||
| 459 | const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); | ||
| 460 | |||
| 461 | const status = yield* checkCodexProviderStatus(settings, (input) => { | ||
| 462 | observedLaunchArgs = input.launchArgs; | ||
| 463 | return Effect.succeed(makeCodexProbeSnapshot()); | ||
| 464 | }); | ||
| 465 | |||
| 466 | assert.strictEqual(status.status, "ready"); | ||
| 467 | assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); | ||
| 468 | }), | ||
| 469 | ); | ||
| 470 | |||
| 471 | it.effect("returns unauthenticated when app-server requires OpenAI auth", () => | ||
| 472 | Effect.gen(function* () { | ||
| 473 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 474 | Effect.succeed( | ||
| 475 | makeCodexProbeSnapshot({ | ||
| 476 | account: { | ||
| 477 | account: null, | ||
| 478 | requiresOpenaiAuth: true, | ||
| 479 | }, | ||
| 480 | }), | ||
| 481 | ), | ||
| 482 | ); | ||
| 483 | |||
| 484 | assert.strictEqual(status.status, "error"); | ||
| 485 | assert.strictEqual(status.auth.status, "unauthenticated"); | ||
| 486 | assert.strictEqual( | ||
| 487 | status.message, | ||
| 488 | "Codex CLI is not authenticated. Run `codex login` and try again.", | ||
| 489 | ); | ||
| 490 | }), | ||
| 491 | ); | ||
| 492 | |||
| 493 | it.effect("returns ready with unknown auth when app-server does not require OpenAI auth", () => | ||
| 494 | Effect.gen(function* () { | ||
| 495 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 496 | Effect.succeed( | ||
| 497 | makeCodexProbeSnapshot({ | ||
| 498 | account: { | ||
| 499 | account: null, | ||
| 500 | requiresOpenaiAuth: false, | ||
| 501 | }, | ||
| 502 | }), | ||
| 503 | ), | ||
| 504 | ); | ||
| 505 | |||
| 506 | assert.strictEqual(status.status, "ready"); | ||
| 507 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 508 | }), | ||
| 509 | ); | ||
| 510 | |||
| 511 | it.effect("returns an api key label for codex api key auth", () => | ||
| 512 | Effect.gen(function* () { | ||
| 513 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 514 | Effect.succeed( | ||
| 515 | makeCodexProbeSnapshot({ | ||
| 516 | account: { | ||
| 517 | account: { type: "apiKey" }, | ||
| 518 | requiresOpenaiAuth: false, | ||
| 519 | }, | ||
| 520 | }), | ||
| 521 | ), | ||
| 522 | ); | ||
| 523 | |||
| 524 | assert.strictEqual(status.status, "ready"); | ||
| 525 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 526 | assert.strictEqual(status.auth.type, "apiKey"); | ||
| 527 | assert.strictEqual(status.auth.label, "OpenAI API Key"); | ||
| 528 | }), | ||
| 529 | ); | ||
| 530 | |||
| 531 | it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => | ||
| 532 | Effect.gen(function* () { | ||
| 533 | const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => | ||
| 534 | Effect.succeed( | ||
| 535 | makeCodexProbeSnapshot({ | ||
| 536 | account: { | ||
| 537 | account: { type: "amazonBedrock" }, | ||
| 538 | requiresOpenaiAuth: false, | ||
| 539 | }, | ||
| 540 | }), | ||
| 541 | ), | ||
| 542 | ); | ||
| 543 | |||
| 544 | assert.strictEqual(status.status, "ready"); | ||
| 545 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 546 | assert.strictEqual(status.auth.type, "amazonBedrock"); | ||
| 547 | assert.strictEqual(status.auth.label, "Amazon Bedrock"); | ||
| 548 | }), | ||
| 549 | ); | ||
| 550 | |||
| 551 | it.effect.each([ | ||
| 552 | "codex", | ||
| 553 | "/Applications/Custom App.app/Contents/Resources/codex", | ||
| 554 | "C:\\Tools\\codex.exe", | ||
| 555 | ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => | ||
| 556 | Effect.gen(function* () { | ||
| 557 | const settings = { ...defaultCodexSettings, binaryPath }; | ||
| 558 | const status = yield* checkCodexProviderStatus(settings, (input) => { | ||
| 559 | assert.strictEqual(input.binaryPath, binaryPath); | ||
| 560 | return Effect.fail( | ||
| 561 | new CodexErrors.CodexAppServerSpawnError({ | ||
| 562 | command: `${binaryPath} app-server`, | ||
| 563 | cause: new Error("spawn ENOENT"), | ||
| 564 | }), | ||
| 565 | ); | ||
| 566 | }); | ||
| 567 | assert.strictEqual(status.status, "error"); | ||
| 568 | assert.strictEqual(status.installed, false); | ||
| 569 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 570 | assert.include(status.message, binaryPath); | ||
| 571 | assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); | ||
| 572 | assert.strictEqual( | ||
| 573 | status.message?.includes("Installing ChatGPT or Codex desktop"), | ||
| 574 | binaryPath === "codex", | ||
| 575 | ); | ||
| 576 | assert.strictEqual(settings.binaryPath, binaryPath); | ||
| 577 | }), | ||
| 578 | ); | ||
| 579 | |||
| 580 | it.effect("closes the app-server probe scope when provider status times out", () => | ||
| 581 | Effect.gen(function* () { | ||
| 582 | const killCalls = yield* Ref.make(0); | ||
| 583 | const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( | ||
| 584 | Effect.provide(layerHangingScopedSpawner(killCalls)), | ||
| 585 | Effect.forkChild, | ||
| 586 | ); | ||
| 587 | |||
| 588 | yield* Effect.yieldNow; | ||
| 589 | yield* TestClock.adjust("11 seconds"); | ||
| 590 | yield* Effect.yieldNow; | ||
| 591 | |||
| 592 | const status = yield* Fiber.join(statusFiber); | ||
| 593 | assert.strictEqual(status.status, "error"); | ||
| 594 | assert.strictEqual( | ||
| 595 | status.message, | ||
| 596 | "Timed out while checking Codex app-server provider status.", | ||
| 597 | ); | ||
| 598 | assert.strictEqual(yield* Ref.get(killCalls), 1); | ||
| 599 | }), | ||
| 600 | ); | ||
| 601 | }); | ||
| 602 | |||
| 603 | describe("ProviderRegistry.layer", () => { | ||
| 604 | it("stores workspace skills and commands without changing machine metadata", () => { | ||
| 605 | const provider = { | ||
| 606 | instanceId: ProviderInstanceId.make("codex"), | ||
| 607 | driver: ProviderDriverKind.make("codex"), | ||
| 608 | status: "ready", | ||
| 609 | enabled: true, | ||
| 610 | installed: true, | ||
| 611 | auth: { status: "authenticated" }, | ||
| 612 | checkedAt: "2026-03-25T00:00:00.000Z", | ||
| 613 | version: "1.0.0", | ||
| 614 | models: [], | ||
| 615 | slashCommands: [{ name: "global" }], | ||
| 616 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 617 | } satisfies ServerProvider; | ||
| 618 | const scopedSnapshot = { | ||
| 619 | ...provider, | ||
| 620 | checkedAt: "2026-03-25T00:01:00.000Z", | ||
| 621 | slashCommands: [{ name: "project" }], | ||
| 622 | skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }], | ||
| 623 | } satisfies ServerProvider; | ||
| 624 | |||
| 625 | const result = ProviderRegistry.upsertProviderWorkspaceSnapshot( | ||
| 626 | provider, | ||
| 627 | "/project", | ||
| 628 | scopedSnapshot, | ||
| 629 | ); | ||
| 630 | |||
| 631 | assert.deepStrictEqual(result.slashCommands, provider.slashCommands); | ||
| 632 | assert.deepStrictEqual(result.skills, provider.skills); | ||
| 633 | assert.deepStrictEqual(result.workspaceSnapshots, [ | ||
| 634 | { | ||
| 635 | cwd: "/project", | ||
| 636 | checkedAt: scopedSnapshot.checkedAt, | ||
| 637 | slashCommands: scopedSnapshot.slashCommands, | ||
| 638 | skills: scopedSnapshot.skills, | ||
| 639 | }, | ||
| 640 | ]); | ||
| 641 | |||
| 642 | const pendingSnapshot = { | ||
| 643 | ...scopedSnapshot, | ||
| 644 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 645 | slashCommandsPending: true, | ||
| 646 | } satisfies ProviderWorkspaceSnapshot; | ||
| 647 | const partial = ProviderRegistry.upsertProviderWorkspaceSnapshot( | ||
| 648 | result, | ||
| 649 | "/project", | ||
| 650 | pendingSnapshot, | ||
| 651 | ); | ||
| 652 | assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.slashCommands, [{ name: "project" }]); | ||
| 653 | assert.strictEqual(partial.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 654 | const otherProject = ProviderRegistry.upsertProviderWorkspaceSnapshot( | ||
| 655 | partial, | ||
| 656 | "/other-project", | ||
| 657 | pendingSnapshot, | ||
| 658 | ); | ||
| 659 | assert.deepStrictEqual(otherProject.workspaceSnapshots?.[1]?.slashCommands, [ | ||
| 660 | COMPACT_SLASH_COMMAND, | ||
| 661 | ]); | ||
| 662 | const recovered = ProviderRegistry.upsertProviderWorkspaceSnapshot(partial, "/project", { | ||
| 663 | ...scopedSnapshot, | ||
| 664 | slashCommands: [COMPACT_SLASH_COMMAND, { name: "replacement" }], | ||
| 665 | }); | ||
| 666 | assert.deepStrictEqual(recovered.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 667 | COMPACT_SLASH_COMMAND, | ||
| 668 | { name: "replacement" }, | ||
| 669 | ]); | ||
| 670 | assert.strictEqual(recovered.workspaceSnapshots?.[0]?.slashCommandsPending, undefined); | ||
| 671 | }); | ||
| 672 | |||
| 673 | it("preserves previously discovered provider models when a refresh returns none", () => { | ||
| 674 | const previousProvider = { | ||
| 675 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 676 | driver: ProviderDriverKind.make("cursor"), | ||
| 677 | status: "ready", | ||
| 678 | enabled: true, | ||
| 679 | installed: true, | ||
| 680 | auth: { status: "authenticated" }, | ||
| 681 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 682 | version: "2026.04.09-f2b0fcd", | ||
| 683 | models: [ | ||
| 684 | { | ||
| 685 | slug: "claude-opus-4-6", | ||
| 686 | name: "Opus 4.6", | ||
| 687 | isCustom: false, | ||
| 688 | capabilities: createModelCapabilities({ | ||
| 689 | optionDescriptors: [ | ||
| 690 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 691 | { id: "high", label: "High", isDefault: true }, | ||
| 692 | ]), | ||
| 693 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 694 | booleanDescriptor("thinking", "Thinking"), | ||
| 695 | ], | ||
| 696 | }), | ||
| 697 | }, | ||
| 698 | ], | ||
| 699 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 700 | skills: [ | ||
| 701 | { | ||
| 702 | name: "typescript", | ||
| 703 | description: "TypeScript help", | ||
| 704 | path: "/skills/typescript/SKILL.md", | ||
| 705 | enabled: true, | ||
| 706 | }, | ||
| 707 | ], | ||
| 708 | } as const satisfies ServerProvider; | ||
| 709 | const refreshedProvider = { | ||
| 710 | ...previousProvider, | ||
| 711 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 712 | models: [], | ||
| 713 | slashCommands: [], | ||
| 714 | skills: [], | ||
| 715 | } satisfies ServerProvider; | ||
| 716 | |||
| 717 | assert.deepStrictEqual( | ||
| 718 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 719 | [...previousProvider.models], | ||
| 720 | ); | ||
| 721 | assert.deepStrictEqual( | ||
| 722 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | ||
| 723 | [], | ||
| 724 | ); | ||
| 725 | assert.deepStrictEqual( | ||
| 726 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, | ||
| 727 | [], | ||
| 728 | ); | ||
| 729 | }); | ||
| 730 | |||
| 731 | it("drops custom models the refreshed snapshot no longer carries", () => { | ||
| 732 | const previousProvider = { | ||
| 733 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 734 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 735 | status: "ready", | ||
| 736 | enabled: true, | ||
| 737 | installed: true, | ||
| 738 | auth: { status: "authenticated" }, | ||
| 739 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 740 | version: "2.1.0", | ||
| 741 | models: [ | ||
| 742 | { | ||
| 743 | slug: "claude-sonnet-4-6", | ||
| 744 | name: "Sonnet 4.6", | ||
| 745 | isCustom: false, | ||
| 746 | capabilities: null, | ||
| 747 | }, | ||
| 748 | { | ||
| 749 | slug: "removed-custom", | ||
| 750 | name: "removed-custom", | ||
| 751 | isCustom: true, | ||
| 752 | capabilities: null, | ||
| 753 | }, | ||
| 754 | ], | ||
| 755 | slashCommands: [], | ||
| 756 | skills: [], | ||
| 757 | } as const satisfies ServerProvider; | ||
| 758 | const refreshedProvider = { | ||
| 759 | ...previousProvider, | ||
| 760 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 761 | models: [previousProvider.models[0]], | ||
| 762 | } satisfies ServerProvider; | ||
| 763 | |||
| 764 | assert.deepStrictEqual( | ||
| 765 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 766 | [...refreshedProvider.models], | ||
| 767 | ); | ||
| 768 | }); | ||
| 769 | |||
| 770 | it("drops stale ACP Registry models missing from a completed discovery probe", () => { | ||
| 771 | const previousProvider = { | ||
| 772 | instanceId: ProviderInstanceId.make("acpRegistry_codex"), | ||
| 773 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 774 | status: "ready", | ||
| 775 | enabled: true, | ||
| 776 | installed: true, | ||
| 777 | auth: { status: "authenticated" }, | ||
| 778 | checkedAt: "2026-08-13T00:00:00.000Z", | ||
| 779 | version: "1.2.0", | ||
| 780 | models: [ | ||
| 781 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 782 | { | ||
| 783 | slug: "gpt-5.6-sol[low]", | ||
| 784 | name: "GPT-5.6-Sol (low)", | ||
| 785 | isCustom: false, | ||
| 786 | capabilities: null, | ||
| 787 | }, | ||
| 788 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 789 | ], | ||
| 790 | slashCommands: [], | ||
| 791 | skills: [], | ||
| 792 | } as const satisfies ServerProvider; | ||
| 793 | const refreshedProvider = { | ||
| 794 | ...previousProvider, | ||
| 795 | checkedAt: "2026-08-13T00:01:00.000Z", | ||
| 796 | models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], | ||
| 797 | } satisfies ServerProvider; | ||
| 798 | |||
| 799 | assert.deepStrictEqual( | ||
| 800 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 801 | [...refreshedProvider.models], | ||
| 802 | ); | ||
| 803 | }); | ||
| 804 | |||
| 805 | it("retains ACP Registry models while discovery has not completed", () => { | ||
| 806 | const previousProvider = { | ||
| 807 | instanceId: ProviderInstanceId.make("acpRegistry_codex"), | ||
| 808 | driver: ProviderDriverKind.make("acpRegistry"), | ||
| 809 | status: "ready", | ||
| 810 | enabled: true, | ||
| 811 | installed: true, | ||
| 812 | auth: { status: "authenticated" }, | ||
| 813 | checkedAt: "2026-08-13T00:00:00.000Z", | ||
| 814 | version: "1.2.0", | ||
| 815 | models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], | ||
| 816 | slashCommands: [], | ||
| 817 | skills: [], | ||
| 818 | } as const satisfies ServerProvider; | ||
| 819 | const checkingProvider = { | ||
| 820 | ...previousProvider, | ||
| 821 | checkedAt: "2026-08-13T00:01:00.000Z", | ||
| 822 | auth: { status: "unknown" }, | ||
| 823 | models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], | ||
| 824 | } satisfies ServerProvider; | ||
| 825 | const failedProbeProvider = { | ||
| 826 | ...checkingProvider, | ||
| 827 | status: "warning", | ||
| 828 | } satisfies ServerProvider; | ||
| 829 | |||
| 830 | assert.deepStrictEqual( | ||
| 831 | ProviderRegistry.mergeProviderSnapshot(previousProvider, checkingProvider).models, | ||
| 832 | [ | ||
| 833 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 834 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 835 | ], | ||
| 836 | ); | ||
| 837 | assert.deepStrictEqual( | ||
| 838 | ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProbeProvider).models, | ||
| 839 | [ | ||
| 840 | { slug: "default", name: "Default", isCustom: false, capabilities: null }, | ||
| 841 | { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, | ||
| 842 | ], | ||
| 843 | ); | ||
| 844 | }); | ||
| 845 | |||
| 846 | it("drops stale OpenCode models missing from a successful refresh", () => { | ||
| 847 | const previousProvider = { | ||
| 848 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 849 | driver: ProviderDriverKind.make("opencode"), | ||
| 850 | status: "ready", | ||
| 851 | enabled: true, | ||
| 852 | installed: true, | ||
| 853 | auth: { status: "authenticated" }, | ||
| 854 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 855 | version: "1.0.0", | ||
| 856 | models: [ | ||
| 857 | { | ||
| 858 | slug: "github/gpt-5", | ||
| 859 | name: "GPT-5", | ||
| 860 | subProvider: "GitHub", | ||
| 861 | isCustom: false, | ||
| 862 | capabilities: null, | ||
| 863 | }, | ||
| 864 | { | ||
| 865 | slug: "removed-plugin/model", | ||
| 866 | name: "Removed Plugin Model", | ||
| 867 | subProvider: "Removed Plugin", | ||
| 868 | isCustom: false, | ||
| 869 | capabilities: null, | ||
| 870 | }, | ||
| 871 | ], | ||
| 872 | slashCommands: [], | ||
| 873 | skills: [], | ||
| 874 | } as const satisfies ServerProvider; | ||
| 875 | const refreshedProvider = { | ||
| 876 | ...previousProvider, | ||
| 877 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 878 | models: [ | ||
| 879 | { | ||
| 880 | slug: "github/gpt-5", | ||
| 881 | name: "GPT-5", | ||
| 882 | subProvider: "GitHub", | ||
| 883 | isCustom: false, | ||
| 884 | capabilities: null, | ||
| 885 | }, | ||
| 886 | ], | ||
| 887 | } satisfies ServerProvider; | ||
| 888 | |||
| 889 | assert.deepStrictEqual( | ||
| 890 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 891 | [...refreshedProvider.models], | ||
| 892 | ); | ||
| 893 | }); | ||
| 894 | |||
| 895 | it("retains stale OpenCode models when a refresh fails", () => { | ||
| 896 | const previousProvider = { | ||
| 897 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 898 | driver: ProviderDriverKind.make("opencode"), | ||
| 899 | status: "ready", | ||
| 900 | enabled: true, | ||
| 901 | installed: true, | ||
| 902 | auth: { status: "authenticated" }, | ||
| 903 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 904 | version: "1.0.0", | ||
| 905 | models: [ | ||
| 906 | { | ||
| 907 | slug: "github/gpt-5", | ||
| 908 | name: "GPT-5", | ||
| 909 | subProvider: "GitHub", | ||
| 910 | isCustom: false, | ||
| 911 | capabilities: null, | ||
| 912 | }, | ||
| 913 | ], | ||
| 914 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 915 | skills: [ | ||
| 916 | { | ||
| 917 | name: "typescript", | ||
| 918 | description: "TypeScript help", | ||
| 919 | path: "/skills/typescript/SKILL.md", | ||
| 920 | enabled: true, | ||
| 921 | }, | ||
| 922 | ], | ||
| 923 | } as const satisfies ServerProvider; | ||
| 924 | const refreshedProvider = { | ||
| 925 | ...previousProvider, | ||
| 926 | status: "error", | ||
| 927 | auth: { status: "unknown" }, | ||
| 928 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 929 | models: [], | ||
| 930 | message: "Failed to refresh OpenCode models.", | ||
| 931 | } satisfies ServerProvider; | ||
| 932 | |||
| 933 | assert.deepStrictEqual( | ||
| 934 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 935 | [...previousProvider.models], | ||
| 936 | ); | ||
| 937 | assert.deepStrictEqual( | ||
| 938 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, | ||
| 939 | previousProvider.slashCommands, | ||
| 940 | ); | ||
| 941 | assert.deepStrictEqual( | ||
| 942 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).skills, | ||
| 943 | previousProvider.skills, | ||
| 944 | ); | ||
| 945 | }); | ||
| 946 | |||
| 947 | it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { | ||
| 948 | const previousProvider = { | ||
| 949 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 950 | driver: ProviderDriverKind.make("opencode"), | ||
| 951 | status: "ready", | ||
| 952 | enabled: true, | ||
| 953 | installed: true, | ||
| 954 | auth: { status: "authenticated" }, | ||
| 955 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 956 | version: "1.0.0", | ||
| 957 | models: [ | ||
| 958 | { | ||
| 959 | slug: "github/gpt-5", | ||
| 960 | name: "GPT-5", | ||
| 961 | subProvider: "GitHub", | ||
| 962 | isCustom: false, | ||
| 963 | capabilities: null, | ||
| 964 | }, | ||
| 965 | { | ||
| 966 | slug: "removed-plugin/model", | ||
| 967 | name: "Removed Plugin Model", | ||
| 968 | subProvider: "Removed Plugin", | ||
| 969 | isCustom: false, | ||
| 970 | capabilities: null, | ||
| 971 | }, | ||
| 972 | ], | ||
| 973 | slashCommands: [{ name: "review", description: "Review changes" }], | ||
| 974 | skills: [ | ||
| 975 | { | ||
| 976 | name: "typescript", | ||
| 977 | description: "TypeScript help", | ||
| 978 | path: "/skills/typescript/SKILL.md", | ||
| 979 | enabled: true, | ||
| 980 | }, | ||
| 981 | ], | ||
| 982 | } as const satisfies ServerProvider; | ||
| 983 | const pendingProvider = { | ||
| 984 | ...previousProvider, | ||
| 985 | status: "warning", | ||
| 986 | installed: false, | ||
| 987 | auth: { status: "unknown" }, | ||
| 988 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 989 | version: null, | ||
| 990 | models: [], | ||
| 991 | message: "OpenCode provider status has not been checked in this session yet.", | ||
| 992 | } satisfies ServerProvider; | ||
| 993 | const loggedOutProvider = { | ||
| 994 | ...previousProvider, | ||
| 995 | status: "warning", | ||
| 996 | auth: { status: "unknown" }, | ||
| 997 | checkedAt: "2026-07-17T00:02:00.000Z", | ||
| 998 | models: [], | ||
| 999 | slashCommands: [], | ||
| 1000 | skills: [], | ||
| 1001 | message: "OpenCode is available, but it did not report any connected upstream providers.", | ||
| 1002 | } satisfies ServerProvider; | ||
| 1003 | const missingProvider = { | ||
| 1004 | ...previousProvider, | ||
| 1005 | status: "error", | ||
| 1006 | installed: false, | ||
| 1007 | auth: { status: "unknown" }, | ||
| 1008 | checkedAt: "2026-07-17T00:03:00.000Z", | ||
| 1009 | version: null, | ||
| 1010 | models: [], | ||
| 1011 | message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | ||
| 1012 | } satisfies ServerProvider; | ||
| 1013 | const authoritativeProvider = { | ||
| 1014 | ...previousProvider, | ||
| 1015 | checkedAt: "2026-07-17T00:04:00.000Z", | ||
| 1016 | models: [previousProvider.models[0]!], | ||
| 1017 | } satisfies ServerProvider; | ||
| 1018 | const failedProvider = { | ||
| 1019 | ...authoritativeProvider, | ||
| 1020 | status: "error", | ||
| 1021 | auth: { status: "unknown" }, | ||
| 1022 | checkedAt: "2026-07-17T00:05:00.000Z", | ||
| 1023 | models: [], | ||
| 1024 | message: "Failed to refresh OpenCode models.", | ||
| 1025 | } satisfies ServerProvider; | ||
| 1026 | |||
| 1027 | assert.deepStrictEqual( | ||
| 1028 | ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, | ||
| 1029 | [...previousProvider.models], | ||
| 1030 | ); | ||
| 1031 | assert.deepStrictEqual( | ||
| 1032 | ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).models, | ||
| 1033 | [], | ||
| 1034 | ); | ||
| 1035 | assert.deepStrictEqual( | ||
| 1036 | ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, | ||
| 1037 | [], | ||
| 1038 | ); | ||
| 1039 | assert.deepStrictEqual( | ||
| 1040 | ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, | ||
| 1041 | [], | ||
| 1042 | ); | ||
| 1043 | assert.deepStrictEqual( | ||
| 1044 | ProviderRegistry.mergeProviderSnapshot(previousProvider, missingProvider).models, | ||
| 1045 | [], | ||
| 1046 | ); | ||
| 1047 | |||
| 1048 | const afterRemoval = ProviderRegistry.mergeProviderSnapshot( | ||
| 1049 | previousProvider, | ||
| 1050 | authoritativeProvider, | ||
| 1051 | ); | ||
| 1052 | const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, failedProvider); | ||
| 1053 | |||
| 1054 | assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); | ||
| 1055 | }); | ||
| 1056 | |||
| 1057 | describe("Codex model inventories", () => { | ||
| 1058 | const cachedProvider = { | ||
| 1059 | instanceId: ProviderInstanceId.make("codex-personal"), | ||
| 1060 | driver: ProviderDriverKind.make("codex"), | ||
| 1061 | status: "ready", | ||
| 1062 | enabled: true, | ||
| 1063 | installed: true, | ||
| 1064 | auth: { status: "authenticated" }, | ||
| 1065 | checkedAt: "2026-09-04T19:00:00.000Z", | ||
| 1066 | version: "0.153.3", | ||
| 1067 | models: ["vega-alpha", "joule-alpha", "kindle-alpha", "ultima-alpha", "solstice-alpha"].map( | ||
| 1068 | (slug) => ({ slug, name: slug, isCustom: false, capabilities: null }), | ||
| 1069 | ), | ||
| 1070 | slashCommands: [], | ||
| 1071 | skills: [], | ||
| 1072 | } satisfies ServerProvider; | ||
| 1073 | const customModel = { | ||
| 1074 | slug: "custom-model", | ||
| 1075 | name: "Custom model", | ||
| 1076 | isCustom: true, | ||
| 1077 | capabilities: null, | ||
| 1078 | } as const; | ||
| 1079 | const refreshedProvider = { | ||
| 1080 | ...cachedProvider, | ||
| 1081 | checkedAt: "2026-09-04T19:01:00.000Z", | ||
| 1082 | models: [ | ||
| 1083 | { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, | ||
| 1084 | cachedProvider.models[0]!, | ||
| 1085 | customModel, | ||
| 1086 | ], | ||
| 1087 | } satisfies ServerProvider; | ||
| 1088 | const pendingProvider = { | ||
| 1089 | ...cachedProvider, | ||
| 1090 | status: "warning", | ||
| 1091 | installed: false, | ||
| 1092 | auth: { status: "unknown" }, | ||
| 1093 | models: [customModel], | ||
| 1094 | } satisfies ServerProvider; | ||
| 1095 | const failedProvider = { | ||
| 1096 | ...pendingProvider, | ||
| 1097 | checkedAt: "2026-09-04T19:02:00.000Z", | ||
| 1098 | status: "error", | ||
| 1099 | installed: true, | ||
| 1100 | } satisfies ServerProvider; | ||
| 1101 | |||
| 1102 | it("drops retired alpha models after discovery, including without OpenAI authentication", () => { | ||
| 1103 | for (const authStatus of ["authenticated", "unknown"] as const) { | ||
| 1104 | assert.deepStrictEqual( | ||
| 1105 | ProviderRegistry.mergeProviderSnapshot(cachedProvider, { | ||
| 1106 | ...refreshedProvider, | ||
| 1107 | auth: { status: authStatus }, | ||
| 1108 | }).models, | ||
| 1109 | refreshedProvider.models, | ||
| 1110 | ); | ||
| 1111 | } | ||
| 1112 | }); | ||
| 1113 | |||
| 1114 | it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { | ||
| 1115 | for (const provider of [pendingProvider, failedProvider]) { | ||
| 1116 | assert.deepStrictEqual( | ||
| 1117 | ProviderRegistry.mergeProviderSnapshot( | ||
| 1118 | { | ||
| 1119 | ...cachedProvider, | ||
| 1120 | models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], | ||
| 1121 | }, | ||
| 1122 | provider, | ||
| 1123 | ).models, | ||
| 1124 | [customModel, ...cachedProvider.models], | ||
| 1125 | ); | ||
| 1126 | } | ||
| 1127 | }); | ||
| 1128 | |||
| 1129 | it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { | ||
| 1130 | const emptyProvider = { ...refreshedProvider, models: [customModel] }; | ||
| 1131 | const clearedProviders = [ | ||
| 1132 | { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, | ||
| 1133 | { ...emptyProvider, status: "disabled", enabled: false }, | ||
| 1134 | { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, | ||
| 1135 | emptyProvider, | ||
| 1136 | { ...emptyProvider, models: [] }, | ||
| 1137 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1138 | |||
| 1139 | for (const provider of clearedProviders) { | ||
| 1140 | assert.deepStrictEqual( | ||
| 1141 | ProviderRegistry.mergeProviderSnapshot(cachedProvider, provider).models, | ||
| 1142 | provider.models, | ||
| 1143 | ); | ||
| 1144 | } | ||
| 1145 | }); | ||
| 1146 | |||
| 1147 | it.effect("persists removals across failed refreshes and registry restarts", () => | ||
| 1148 | Effect.gen(function* () { | ||
| 1149 | const config = yield* ServerConfig.ServerConfig; | ||
| 1150 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 1151 | cacheDir: config.providerStatusCacheDir, | ||
| 1152 | instanceId: cachedProvider.instanceId, | ||
| 1153 | }); | ||
| 1154 | yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); | ||
| 1155 | const nextProvider = yield* Ref.make<ServerProvider>(refreshedProvider); | ||
| 1156 | const instance = { | ||
| 1157 | instanceId: cachedProvider.instanceId, | ||
| 1158 | driverKind: cachedProvider.driver, | ||
| 1159 | continuationIdentity: { | ||
| 1160 | driverKind: cachedProvider.driver, | ||
| 1161 | continuationKey: "codex:instance:codex-personal", | ||
| 1162 | }, | ||
| 1163 | displayName: undefined, | ||
| 1164 | enabled: true, | ||
| 1165 | snapshot: { | ||
| 1166 | resolveMaintenance: () => | ||
| 1167 | Effect.succeed( | ||
| 1168 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1169 | provider: cachedProvider.driver, | ||
| 1170 | packageName: null, | ||
| 1171 | }), | ||
| 1172 | ), | ||
| 1173 | getSnapshot: Effect.succeed(pendingProvider), | ||
| 1174 | refresh: Ref.get(nextProvider), | ||
| 1175 | streamChanges: Stream.empty, | ||
| 1176 | applyUsageLimits: () => Effect.void, | ||
| 1177 | }, | ||
| 1178 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1179 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1180 | } satisfies ProviderInstance; | ||
| 1181 | const layerInstanceRegistry = Layer.succeed( | ||
| 1182 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1183 | { | ||
| 1184 | getInstance: (id) => | ||
| 1185 | Effect.succeed(id === instance.instanceId ? instance : undefined), | ||
| 1186 | listInstances: Effect.succeed([instance]), | ||
| 1187 | listUnavailable: Effect.succeed([]), | ||
| 1188 | streamChanges: Stream.empty, | ||
| 1189 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1190 | }, | ||
| 1191 | ); | ||
| 1192 | const retainedModels = [ | ||
| 1193 | customModel, | ||
| 1194 | ...refreshedProvider.models.filter((model) => !model.isCustom), | ||
| 1195 | ]; | ||
| 1196 | |||
| 1197 | for (const restarted of [false, true]) { | ||
| 1198 | yield* Effect.gen(function* () { | ||
| 1199 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1200 | const expectedModels = restarted | ||
| 1201 | ? retainedModels | ||
| 1202 | : [customModel, ...cachedProvider.models]; | ||
| 1203 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); | ||
| 1204 | |||
| 1205 | yield* registry.refreshInstance(instance.instanceId); | ||
| 1206 | assert.deepStrictEqual( | ||
| 1207 | (yield* readProviderStatusCache(filePath))?.models, | ||
| 1208 | restarted ? retainedModels : refreshedProvider.models, | ||
| 1209 | ); | ||
| 1210 | |||
| 1211 | yield* Ref.set(nextProvider, failedProvider); | ||
| 1212 | const afterFailure = yield* registry.refreshInstance(instance.instanceId); | ||
| 1213 | assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); | ||
| 1214 | assert.deepStrictEqual( | ||
| 1215 | (yield* readProviderStatusCache(filePath))?.models, | ||
| 1216 | retainedModels, | ||
| 1217 | ); | ||
| 1218 | }).pipe( | ||
| 1219 | Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), | ||
| 1220 | Effect.scoped, | ||
| 1221 | ); | ||
| 1222 | } | ||
| 1223 | }).pipe( | ||
| 1224 | Effect.provide( | ||
| 1225 | ServerConfig.layerTest(process.cwd(), { | ||
| 1226 | prefix: "t3-codex-retired-model-cache-", | ||
| 1227 | }).pipe(Layer.provideMerge(NodeServices.layer)), | ||
| 1228 | ), | ||
| 1229 | ), | ||
| 1230 | ); | ||
| 1231 | }); | ||
| 1232 | |||
| 1233 | describe("Antigravity model inventories", () => { | ||
| 1234 | const previousProvider = { | ||
| 1235 | instanceId: ProviderInstanceId.make("antigravity-personal"), | ||
| 1236 | driver: ProviderDriverKind.make("antigravity"), | ||
| 1237 | status: "ready", | ||
| 1238 | enabled: true, | ||
| 1239 | installed: true, | ||
| 1240 | auth: { status: "authenticated" }, | ||
| 1241 | checkedAt: "2026-09-02T00:00:00.000Z", | ||
| 1242 | version: "0.1.3", | ||
| 1243 | models: [ | ||
| 1244 | { | ||
| 1245 | slug: "gemini-3.1-pro-high", | ||
| 1246 | name: "Gemini 3.1 Pro High", | ||
| 1247 | isCustom: false, | ||
| 1248 | capabilities: null, | ||
| 1249 | }, | ||
| 1250 | { | ||
| 1251 | slug: "gemini-3-flash", | ||
| 1252 | name: "Gemini 3 Flash", | ||
| 1253 | isCustom: false, | ||
| 1254 | capabilities: null, | ||
| 1255 | }, | ||
| 1256 | ], | ||
| 1257 | slashCommands: [], | ||
| 1258 | skills: [], | ||
| 1259 | } as const satisfies ServerProvider; | ||
| 1260 | |||
| 1261 | it("removes unavailable models after a successful refresh", () => { | ||
| 1262 | for (const status of ["ready", "warning"] as const) { | ||
| 1263 | const refreshedProvider = { | ||
| 1264 | ...previousProvider, | ||
| 1265 | status, | ||
| 1266 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1267 | models: [previousProvider.models[1]], | ||
| 1268 | } satisfies ServerProvider; | ||
| 1269 | const afterRefresh = ProviderRegistry.mergeProviderSnapshot( | ||
| 1270 | previousProvider, | ||
| 1271 | refreshedProvider, | ||
| 1272 | ); | ||
| 1273 | |||
| 1274 | assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); | ||
| 1275 | |||
| 1276 | const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRefresh, { | ||
| 1277 | ...refreshedProvider, | ||
| 1278 | status: "error", | ||
| 1279 | auth: { status: "unknown" }, | ||
| 1280 | models: [], | ||
| 1281 | }); | ||
| 1282 | assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); | ||
| 1283 | } | ||
| 1284 | }); | ||
| 1285 | |||
| 1286 | it("keeps cached models during health checks and temporary failures", () => { | ||
| 1287 | for (const installed of [false, true]) { | ||
| 1288 | const pendingProvider = { | ||
| 1289 | ...previousProvider, | ||
| 1290 | status: "warning", | ||
| 1291 | installed, | ||
| 1292 | auth: { status: "unknown" }, | ||
| 1293 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1294 | version: installed ? previousProvider.version : null, | ||
| 1295 | models: [], | ||
| 1296 | } satisfies ServerProvider; | ||
| 1297 | |||
| 1298 | assert.deepStrictEqual( | ||
| 1299 | ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, | ||
| 1300 | previousProvider.models, | ||
| 1301 | ); | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | for (const authStatus of ["unknown", "authenticated"] as const) { | ||
| 1305 | const failedProvider = { | ||
| 1306 | ...previousProvider, | ||
| 1307 | status: "error", | ||
| 1308 | auth: { status: authStatus }, | ||
| 1309 | checkedAt: "2026-09-02T00:02:00.000Z", | ||
| 1310 | models: [], | ||
| 1311 | } satisfies ServerProvider; | ||
| 1312 | |||
| 1313 | assert.deepStrictEqual( | ||
| 1314 | ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, | ||
| 1315 | previousProvider.models, | ||
| 1316 | ); | ||
| 1317 | } | ||
| 1318 | }); | ||
| 1319 | |||
| 1320 | it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { | ||
| 1321 | const emptyProvider = { | ||
| 1322 | ...previousProvider, | ||
| 1323 | checkedAt: "2026-09-02T00:01:00.000Z", | ||
| 1324 | models: [], | ||
| 1325 | } satisfies ServerProvider; | ||
| 1326 | const clearedProviders = [ | ||
| 1327 | { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, | ||
| 1328 | { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, | ||
| 1329 | { ...emptyProvider, status: "disabled", enabled: false }, | ||
| 1330 | { ...emptyProvider, status: "error", enabled: false }, | ||
| 1331 | { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, | ||
| 1332 | emptyProvider, | ||
| 1333 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1334 | |||
| 1335 | for (const provider of clearedProviders) { | ||
| 1336 | const afterRemoval = ProviderRegistry.mergeProviderSnapshot(previousProvider, provider); | ||
| 1337 | assert.deepStrictEqual(afterRemoval.models, []); | ||
| 1338 | |||
| 1339 | const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, { | ||
| 1340 | ...emptyProvider, | ||
| 1341 | status: "error", | ||
| 1342 | auth: { status: "unknown" }, | ||
| 1343 | }); | ||
| 1344 | assert.deepStrictEqual(afterFailure.models, []); | ||
| 1345 | } | ||
| 1346 | }); | ||
| 1347 | }); | ||
| 1348 | |||
| 1349 | describe("Antigravity saved account", () => { | ||
| 1350 | const signedIn = { | ||
| 1351 | instanceId: ProviderInstanceId.make("antigravity-personal"), | ||
| 1352 | driver: ProviderDriverKind.make("antigravity"), | ||
| 1353 | status: "ready", | ||
| 1354 | enabled: true, | ||
| 1355 | installed: true, | ||
| 1356 | auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, | ||
| 1357 | checkedAt: "2026-09-05T00:00:00.000Z", | ||
| 1358 | version: "agy_acp_server_1.1.1", | ||
| 1359 | models: [ | ||
| 1360 | { | ||
| 1361 | slug: "gemini-3.7-flash-high", | ||
| 1362 | name: "Gemini 3.7 Flash", | ||
| 1363 | isCustom: false, | ||
| 1364 | capabilities: null, | ||
| 1365 | }, | ||
| 1366 | ], | ||
| 1367 | slashCommands: [{ name: "plan" }], | ||
| 1368 | skills: [], | ||
| 1369 | } as const satisfies ServerProvider; | ||
| 1370 | const uncheckedMessage = | ||
| 1371 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 1372 | const restartProbe = { | ||
| 1373 | ...signedIn, | ||
| 1374 | status: "warning", | ||
| 1375 | auth: { status: "unknown" }, | ||
| 1376 | checkedAt: "2026-09-05T00:01:00.000Z", | ||
| 1377 | message: uncheckedMessage, | ||
| 1378 | models: [], | ||
| 1379 | } as const satisfies ServerProvider; | ||
| 1380 | |||
| 1381 | it("keeps the saved Google account through restart health checks", () => { | ||
| 1382 | const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, restartProbe); | ||
| 1383 | const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; | ||
| 1384 | assert.deepStrictEqual(merged, { | ||
| 1385 | ...probeWithoutMessage, | ||
| 1386 | status: "ready", | ||
| 1387 | auth: signedIn.auth, | ||
| 1388 | models: signedIn.models, | ||
| 1389 | }); | ||
| 1390 | assert.equal("message" in merged, false); | ||
| 1391 | // The next periodic probe reads the merged snapshot as its previous state. | ||
| 1392 | assert.deepStrictEqual( | ||
| 1393 | ProviderRegistry.mergeProviderSnapshot(merged, restartProbe), | ||
| 1394 | merged, | ||
| 1395 | ); | ||
| 1396 | }); | ||
| 1397 | |||
| 1398 | it("carries the account through the boot probe and a failed probe without hiding them", () => { | ||
| 1399 | const booting = { | ||
| 1400 | ...restartProbe, | ||
| 1401 | installed: false, | ||
| 1402 | version: null, | ||
| 1403 | message: "Checking Antigravity availability.", | ||
| 1404 | } satisfies ServerProvider; | ||
| 1405 | assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, booting), { | ||
| 1406 | ...booting, | ||
| 1407 | auth: signedIn.auth, | ||
| 1408 | models: signedIn.models, | ||
| 1409 | }); | ||
| 1410 | |||
| 1411 | const failed = { | ||
| 1412 | ...restartProbe, | ||
| 1413 | status: "error", | ||
| 1414 | message: "Antigravity did not respond to its local health check within 90 seconds.", | ||
| 1415 | } satisfies ServerProvider; | ||
| 1416 | assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, failed), { | ||
| 1417 | ...failed, | ||
| 1418 | auth: signedIn.auth, | ||
| 1419 | models: signedIn.models, | ||
| 1420 | }); | ||
| 1421 | }); | ||
| 1422 | |||
| 1423 | it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { | ||
| 1424 | const untouched = [ | ||
| 1425 | { ...restartProbe, auth: { status: "unauthenticated" } }, | ||
| 1426 | { ...restartProbe, status: "disabled", enabled: false }, | ||
| 1427 | { ...restartProbe, status: "error", installed: false }, | ||
| 1428 | { ...restartProbe, driver: ProviderDriverKind.make("codex") }, | ||
| 1429 | // The instance was rebuilt with another sign-in method. | ||
| 1430 | { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, | ||
| 1431 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1432 | for (const next of untouched) { | ||
| 1433 | const merged = ProviderRegistry.mergeProviderSnapshot(signedIn, next); | ||
| 1434 | assert.deepStrictEqual(merged.auth, next.auth); | ||
| 1435 | assert.equal(merged.status, next.status); | ||
| 1436 | assert.equal(merged.message, next.message); | ||
| 1437 | } | ||
| 1438 | assert.deepStrictEqual( | ||
| 1439 | ProviderRegistry.mergeProviderSnapshot( | ||
| 1440 | { ...signedIn, auth: { status: "unknown" } }, | ||
| 1441 | restartProbe, | ||
| 1442 | ).auth, | ||
| 1443 | { status: "unknown" }, | ||
| 1444 | ); | ||
| 1445 | assert.equal( | ||
| 1446 | ProviderRegistry.mergeProviderSnapshot( | ||
| 1447 | { ...signedIn, driver: ProviderDriverKind.make("codex") }, | ||
| 1448 | restartProbe, | ||
| 1449 | ).auth.status, | ||
| 1450 | "unknown", | ||
| 1451 | ); | ||
| 1452 | assert.deepStrictEqual( | ||
| 1453 | ProviderRegistry.mergeProviderSnapshot(signedIn, { | ||
| 1454 | ...restartProbe, | ||
| 1455 | auth: { status: "unknown", type: "oauth-personal" }, | ||
| 1456 | }).auth, | ||
| 1457 | signedIn.auth, | ||
| 1458 | ); | ||
| 1459 | }); | ||
| 1460 | }); | ||
| 1461 | |||
| 1462 | it("fills missing capabilities from the previous provider snapshot", () => { | ||
| 1463 | const previousProvider = { | ||
| 1464 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 1465 | driver: ProviderDriverKind.make("cursor"), | ||
| 1466 | status: "ready", | ||
| 1467 | enabled: true, | ||
| 1468 | installed: true, | ||
| 1469 | auth: { status: "authenticated" }, | ||
| 1470 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 1471 | version: "2026.04.09-f2b0fcd", | ||
| 1472 | models: [ | ||
| 1473 | { | ||
| 1474 | slug: "claude-opus-4-6", | ||
| 1475 | name: "Opus 4.6", | ||
| 1476 | isCustom: false, | ||
| 1477 | capabilities: createModelCapabilities({ | ||
| 1478 | optionDescriptors: [ | ||
| 1479 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 1480 | { id: "high", label: "High", isDefault: true }, | ||
| 1481 | ]), | ||
| 1482 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 1483 | booleanDescriptor("thinking", "Thinking"), | ||
| 1484 | ], | ||
| 1485 | }), | ||
| 1486 | }, | ||
| 1487 | ], | ||
| 1488 | slashCommands: [], | ||
| 1489 | skills: [], | ||
| 1490 | } as const satisfies ServerProvider; | ||
| 1491 | const refreshedProvider = { | ||
| 1492 | ...previousProvider, | ||
| 1493 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 1494 | models: [ | ||
| 1495 | { | ||
| 1496 | slug: "claude-opus-4-6", | ||
| 1497 | name: "Opus 4.6", | ||
| 1498 | isCustom: false, | ||
| 1499 | capabilities: createModelCapabilities({ | ||
| 1500 | optionDescriptors: [], | ||
| 1501 | }), | ||
| 1502 | }, | ||
| 1503 | ], | ||
| 1504 | } satisfies ServerProvider; | ||
| 1505 | |||
| 1506 | assert.deepStrictEqual( | ||
| 1507 | ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, | ||
| 1508 | [...previousProvider.models], | ||
| 1509 | ); | ||
| 1510 | }); | ||
| 1511 | |||
| 1512 | it.effect("does not run provider probes during layer construction", () => | ||
| 1513 | Effect.gen(function* () { | ||
| 1514 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 1515 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 1516 | const initialProvider = { | ||
| 1517 | instanceId: codexInstanceId, | ||
| 1518 | driver: codexDriver, | ||
| 1519 | status: "warning", | ||
| 1520 | enabled: true, | ||
| 1521 | installed: false, | ||
| 1522 | auth: { status: "unknown" }, | ||
| 1523 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1524 | version: null, | ||
| 1525 | message: "Checking Codex provider status.", | ||
| 1526 | models: [], | ||
| 1527 | slashCommands: [], | ||
| 1528 | skills: [], | ||
| 1529 | } as const satisfies ServerProvider; | ||
| 1530 | const refreshCalls = yield* Ref.make(0); | ||
| 1531 | const instance = { | ||
| 1532 | instanceId: codexInstanceId, | ||
| 1533 | driverKind: codexDriver, | ||
| 1534 | continuationIdentity: { | ||
| 1535 | driverKind: codexDriver, | ||
| 1536 | continuationKey: "codex:instance:codex", | ||
| 1537 | }, | ||
| 1538 | displayName: undefined, | ||
| 1539 | enabled: true, | ||
| 1540 | snapshot: { | ||
| 1541 | resolveMaintenance: () => | ||
| 1542 | Effect.succeed( | ||
| 1543 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1544 | provider: codexDriver, | ||
| 1545 | packageName: null, | ||
| 1546 | }), | ||
| 1547 | ), | ||
| 1548 | getSnapshot: Effect.succeed(initialProvider), | ||
| 1549 | refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( | ||
| 1550 | Effect.andThen(Effect.never), | ||
| 1551 | ), | ||
| 1552 | streamChanges: Stream.empty, | ||
| 1553 | applyUsageLimits: () => Effect.void, | ||
| 1554 | }, | ||
| 1555 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1556 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1557 | } satisfies ProviderInstance; | ||
| 1558 | const layerInstanceRegistry = Layer.succeed( | ||
| 1559 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1560 | { | ||
| 1561 | getInstance: (instanceId) => | ||
| 1562 | Effect.succeed(instanceId === codexInstanceId ? instance : undefined), | ||
| 1563 | listInstances: Effect.succeed([instance]), | ||
| 1564 | listUnavailable: Effect.succeed([]), | ||
| 1565 | streamChanges: Stream.empty, | ||
| 1566 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1567 | }, | ||
| 1568 | ); | ||
| 1569 | const scope = yield* Scope.make(); | ||
| 1570 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1571 | const runtimeServices = yield* Layer.build( | ||
| 1572 | ProviderRegistry.layer.pipe( | ||
| 1573 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1574 | Layer.provideMerge( | ||
| 1575 | ServerConfig.layerTest(process.cwd(), { | ||
| 1576 | prefix: "t3-provider-registry-background-refresh-", | ||
| 1577 | }), | ||
| 1578 | ), | ||
| 1579 | Layer.provideMerge(NodeServices.layer), | ||
| 1580 | ), | ||
| 1581 | ).pipe(Scope.provide(scope)); | ||
| 1582 | yield* Effect.gen(function* () { | ||
| 1583 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1584 | assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); | ||
| 1585 | assert.strictEqual(yield* Ref.get(refreshCalls), 0); | ||
| 1586 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1587 | }), | ||
| 1588 | ); | ||
| 1589 | |||
| 1590 | it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => | ||
| 1591 | Effect.gen(function* () { | ||
| 1592 | const driver = ProviderDriverKind.make("codex"); | ||
| 1593 | const instanceId = ProviderInstanceId.make("codex"); | ||
| 1594 | const machineProvider = { | ||
| 1595 | instanceId, | ||
| 1596 | driver, | ||
| 1597 | status: "ready", | ||
| 1598 | enabled: true, | ||
| 1599 | installed: true, | ||
| 1600 | auth: { status: "authenticated" }, | ||
| 1601 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1602 | version: "1.0.0", | ||
| 1603 | models: [], | ||
| 1604 | slashCommands: [{ name: "global" }], | ||
| 1605 | skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], | ||
| 1606 | } as const satisfies ServerProvider; | ||
| 1607 | const scopedProvider = { | ||
| 1608 | ...machineProvider, | ||
| 1609 | checkedAt: "2026-06-10T00:01:00.000Z", | ||
| 1610 | slashCommands: [{ name: "project" }], | ||
| 1611 | skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], | ||
| 1612 | } as const satisfies ServerProvider; | ||
| 1613 | const pendingScopedProvider = { | ||
| 1614 | ...scopedProvider, | ||
| 1615 | status: "error", | ||
| 1616 | installed: false, | ||
| 1617 | slashCommands: [], | ||
| 1618 | } as const satisfies ServerProvider; | ||
| 1619 | const snapshotCalls = yield* Ref.make(0); | ||
| 1620 | const scopedResult = yield* Ref.make<ProviderWorkspaceSnapshot>({ | ||
| 1621 | ...scopedProvider, | ||
| 1622 | status: "error", | ||
| 1623 | slashCommands: [COMPACT_SLASH_COMMAND], | ||
| 1624 | slashCommandsPending: true, | ||
| 1625 | }); | ||
| 1626 | const cacheInvalidations = yield* Ref.make(0); | ||
| 1627 | const scanGate = yield* Ref.make<{ | ||
| 1628 | readonly started: Deferred.Deferred<void>; | ||
| 1629 | readonly release: Deferred.Deferred<void>; | ||
| 1630 | } | null>(null); | ||
| 1631 | const returnPendingSnapshot = yield* Ref.make(true); | ||
| 1632 | const probeStarted = yield* Deferred.make<void>(); | ||
| 1633 | const releaseProbe = yield* Deferred.make<void>(); | ||
| 1634 | const makeInstance = ( | ||
| 1635 | provider: ServerProvider, | ||
| 1636 | snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>, | ||
| 1637 | ): ProviderInstance => ({ | ||
| 1638 | instanceId, | ||
| 1639 | driverKind: driver, | ||
| 1640 | continuationIdentity: { | ||
| 1641 | driverKind: driver, | ||
| 1642 | continuationKey: "codex:instance:codex", | ||
| 1643 | }, | ||
| 1644 | displayName: undefined, | ||
| 1645 | enabled: true, | ||
| 1646 | snapshot: { | ||
| 1647 | resolveMaintenance: () => | ||
| 1648 | Effect.succeed( | ||
| 1649 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1650 | provider: driver, | ||
| 1651 | packageName: null, | ||
| 1652 | }), | ||
| 1653 | ), | ||
| 1654 | getSnapshot: Effect.succeed(provider), | ||
| 1655 | refresh: Effect.succeed(provider), | ||
| 1656 | streamChanges: Stream.empty, | ||
| 1657 | applyUsageLimits: () => Effect.void, | ||
| 1658 | }, | ||
| 1659 | snapshotForCwd, | ||
| 1660 | invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), | ||
| 1661 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1662 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1663 | }); | ||
| 1664 | const firstInstance = makeInstance(machineProvider, () => | ||
| 1665 | Effect.gen(function* () { | ||
| 1666 | yield* Ref.update(snapshotCalls, (count) => count + 1); | ||
| 1667 | if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; | ||
| 1668 | yield* Deferred.succeed(probeStarted, undefined); | ||
| 1669 | yield* Deferred.await(releaseProbe); | ||
| 1670 | const result = yield* Ref.get(scopedResult); | ||
| 1671 | const gate = yield* Ref.getAndSet(scanGate, null); | ||
| 1672 | if (gate) { | ||
| 1673 | yield* Deferred.succeed(gate.started, undefined); | ||
| 1674 | yield* Deferred.await(gate.release); | ||
| 1675 | } | ||
| 1676 | return result; | ||
| 1677 | }), | ||
| 1678 | ); | ||
| 1679 | const rebuiltProvider = { | ||
| 1680 | ...machineProvider, | ||
| 1681 | checkedAt: "2026-06-10T00:02:00.000Z", | ||
| 1682 | status: "warning", | ||
| 1683 | installed: false, | ||
| 1684 | auth: { status: "unknown" }, | ||
| 1685 | } satisfies ServerProvider; | ||
| 1686 | const rebuiltInstance = makeInstance(rebuiltProvider, () => | ||
| 1687 | Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), | ||
| 1688 | ); | ||
| 1689 | const registryChanges = yield* PubSub.unbounded<void>(); | ||
| 1690 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]); | ||
| 1691 | const layerInstanceRegistry = Layer.succeed( | ||
| 1692 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1693 | { | ||
| 1694 | getInstance: (requestedId) => | ||
| 1695 | Ref.get(instancesRef).pipe( | ||
| 1696 | Effect.map((instances) => | ||
| 1697 | instances.find((instance) => instance.instanceId === requestedId), | ||
| 1698 | ), | ||
| 1699 | ), | ||
| 1700 | listInstances: Ref.get(instancesRef), | ||
| 1701 | listUnavailable: Effect.succeed([]), | ||
| 1702 | streamChanges: Stream.fromPubSub(registryChanges), | ||
| 1703 | subscribeChanges: PubSub.subscribe(registryChanges), | ||
| 1704 | }, | ||
| 1705 | ); | ||
| 1706 | const scope = yield* Scope.make(); | ||
| 1707 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1708 | const runtimeServices = yield* Layer.build( | ||
| 1709 | ProviderRegistry.layer.pipe( | ||
| 1710 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1711 | Layer.provideMerge( | ||
| 1712 | ServerConfig.layerTest(process.cwd(), { | ||
| 1713 | prefix: "t3-provider-registry-workspace-snapshot-", | ||
| 1714 | }), | ||
| 1715 | ), | ||
| 1716 | Layer.provideMerge(NodeServices.layer), | ||
| 1717 | ), | ||
| 1718 | ).pipe(Scope.provide(scope)); | ||
| 1719 | |||
| 1720 | yield* Effect.gen(function* () { | ||
| 1721 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1722 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1723 | assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); | ||
| 1724 | yield* Ref.set(returnPendingSnapshot, false); | ||
| 1725 | const workspaceUpdate = yield* registry.streamChanges.pipe( | ||
| 1726 | Stream.runHead, | ||
| 1727 | Effect.forkChild, | ||
| 1728 | ); | ||
| 1729 | yield* Effect.yieldNow; | ||
| 1730 | const firstRefresh = yield* registry | ||
| 1731 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1732 | .pipe(Effect.forkChild); | ||
| 1733 | yield* Deferred.await(probeStarted); | ||
| 1734 | const duplicateRefresh = yield* registry | ||
| 1735 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) | ||
| 1736 | .pipe(Effect.forkChild); | ||
| 1737 | yield* Effect.yieldNow; | ||
| 1738 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | ||
| 1739 | yield* Deferred.succeed(releaseProbe, undefined); | ||
| 1740 | yield* Fiber.join(firstRefresh); | ||
| 1741 | yield* Fiber.join(duplicateRefresh); | ||
| 1742 | const published = yield* Fiber.join(workspaceUpdate); | ||
| 1743 | assert.strictEqual(published._tag, "Some"); | ||
| 1744 | const providers = yield* registry.getProviders; | ||
| 1745 | assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); | ||
| 1746 | assert.deepStrictEqual( | ||
| 1747 | providers[0]?.workspaceSnapshots?.[0]?.skills, | ||
| 1748 | scopedProvider.skills, | ||
| 1749 | ); | ||
| 1750 | assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ | ||
| 1751 | COMPACT_SLASH_COMMAND, | ||
| 1752 | ]); | ||
| 1753 | assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); | ||
| 1754 | yield* Ref.set(scopedResult, { | ||
| 1755 | ...scopedProvider, | ||
| 1756 | status: "error", | ||
| 1757 | slashCommandsPending: false, | ||
| 1758 | }); | ||
| 1759 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1760 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1761 | assert.deepStrictEqual( | ||
| 1762 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, | ||
| 1763 | scopedProvider.slashCommands, | ||
| 1764 | ); | ||
| 1765 | assert.strictEqual( | ||
| 1766 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, | ||
| 1767 | undefined, | ||
| 1768 | ); | ||
| 1769 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | ||
| 1770 | assert.strictEqual(yield* Ref.get(snapshotCalls), 3); | ||
| 1771 | const newSkills = [ | ||
| 1772 | ...scopedProvider.skills, | ||
| 1773 | { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, | ||
| 1774 | ]; | ||
| 1775 | yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); | ||
| 1776 | yield* registry.refreshWorkspaceSnapshot({ | ||
| 1777 | instanceId, | ||
| 1778 | cwd: "/workspace", | ||
| 1779 | fresh: true, | ||
| 1780 | }); | ||
| 1781 | assert.strictEqual(yield* Ref.get(snapshotCalls), 4); | ||
| 1782 | assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); | ||
| 1783 | assert.deepStrictEqual( | ||
| 1784 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | ||
| 1785 | [newSkills], | ||
| 1786 | ); | ||
| 1787 | |||
| 1788 | // A slow fresh scan that read older files must not overwrite a | ||
| 1789 | // newer scan that finished first. | ||
| 1790 | const slowStarted = yield* Deferred.make<void>(); | ||
| 1791 | const releaseSlow = yield* Deferred.make<void>(); | ||
| 1792 | yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); | ||
| 1793 | yield* Ref.set(scopedResult, scopedProvider); | ||
| 1794 | const slowScan = yield* registry | ||
| 1795 | .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) | ||
| 1796 | .pipe(Effect.forkChild); | ||
| 1797 | yield* Deferred.await(slowStarted); | ||
| 1798 | const latestSkills = [ | ||
| 1799 | ...newSkills, | ||
| 1800 | { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, | ||
| 1801 | ]; | ||
| 1802 | yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); | ||
| 1803 | yield* registry.refreshWorkspaceSnapshot({ | ||
| 1804 | instanceId, | ||
| 1805 | cwd: "/workspace", | ||
| 1806 | fresh: true, | ||
| 1807 | }); | ||
| 1808 | yield* Deferred.succeed(releaseSlow, undefined); | ||
| 1809 | yield* Fiber.join(slowScan); | ||
| 1810 | assert.deepStrictEqual( | ||
| 1811 | (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), | ||
| 1812 | [latestSkills], | ||
| 1813 | ); | ||
| 1814 | |||
| 1815 | yield* Ref.set(instancesRef, [rebuiltInstance]); | ||
| 1816 | yield* PubSub.publish(registryChanges, undefined); | ||
| 1817 | let rebuilt = yield* registry.getProviders; | ||
| 1818 | for ( | ||
| 1819 | let attempt = 0; | ||
| 1820 | attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; | ||
| 1821 | attempt += 1 | ||
| 1822 | ) { | ||
| 1823 | yield* Effect.yieldNow; | ||
| 1824 | rebuilt = yield* registry.getProviders; | ||
| 1825 | } | ||
| 1826 | assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); | ||
| 1827 | assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); | ||
| 1828 | }).pipe(Effect.provide(runtimeServices)); | ||
| 1829 | }), | ||
| 1830 | ); | ||
| 1831 | |||
| 1832 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => | ||
| 1833 | Effect.gen(function* () { | ||
| 1834 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 1835 | const openCodeDriver = ProviderDriverKind.make("opencode"); | ||
| 1836 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 1837 | const openCodeInstanceId = ProviderInstanceId.make("opencode"); | ||
| 1838 | const codexRefreshCalls = yield* Ref.make(0); | ||
| 1839 | const openCodeRefreshCalls = yield* Ref.make(0); | ||
| 1840 | const codexProvider = { | ||
| 1841 | instanceId: codexInstanceId, | ||
| 1842 | driver: codexDriver, | ||
| 1843 | status: "ready", | ||
| 1844 | enabled: true, | ||
| 1845 | installed: true, | ||
| 1846 | auth: { status: "authenticated" }, | ||
| 1847 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1848 | version: "1.0.0", | ||
| 1849 | models: [], | ||
| 1850 | slashCommands: [], | ||
| 1851 | skills: [], | ||
| 1852 | } as const satisfies ServerProvider; | ||
| 1853 | const failedOpenCodeProvider = { | ||
| 1854 | instanceId: openCodeInstanceId, | ||
| 1855 | driver: openCodeDriver, | ||
| 1856 | status: "error", | ||
| 1857 | enabled: true, | ||
| 1858 | installed: true, | ||
| 1859 | auth: { status: "unknown" }, | ||
| 1860 | checkedAt: "2026-06-10T00:00:00.000Z", | ||
| 1861 | version: "1.0.0", | ||
| 1862 | message: "Failed to refresh OpenCode models.", | ||
| 1863 | models: [], | ||
| 1864 | slashCommands: [], | ||
| 1865 | skills: [], | ||
| 1866 | } as const satisfies ServerProvider; | ||
| 1867 | const recoveredOpenCodeProvider = { | ||
| 1868 | ...failedOpenCodeProvider, | ||
| 1869 | status: "ready", | ||
| 1870 | auth: { status: "authenticated" }, | ||
| 1871 | checkedAt: "2026-06-10T00:01:00.000Z", | ||
| 1872 | message: "One upstream provider connected through OpenCode.", | ||
| 1873 | models: [ | ||
| 1874 | { | ||
| 1875 | slug: "github/gpt-5", | ||
| 1876 | name: "GPT-5", | ||
| 1877 | subProvider: "GitHub", | ||
| 1878 | isCustom: false, | ||
| 1879 | capabilities: null, | ||
| 1880 | }, | ||
| 1881 | ], | ||
| 1882 | } as const satisfies ServerProvider; | ||
| 1883 | const changedCatalogProvider = { | ||
| 1884 | ...recoveredOpenCodeProvider, | ||
| 1885 | checkedAt: "2026-06-10T00:02:00.000Z", | ||
| 1886 | models: [ | ||
| 1887 | { | ||
| 1888 | slug: "anthropic/claude-sonnet-4", | ||
| 1889 | name: "Claude Sonnet 4", | ||
| 1890 | subProvider: "Anthropic", | ||
| 1891 | isCustom: false, | ||
| 1892 | capabilities: null, | ||
| 1893 | }, | ||
| 1894 | ], | ||
| 1895 | } as const satisfies ServerProvider; | ||
| 1896 | const catalogSnapshot = yield* Ref.make<ServerProvider>(recoveredOpenCodeProvider); | ||
| 1897 | const instances = [ | ||
| 1898 | { | ||
| 1899 | instanceId: codexInstanceId, | ||
| 1900 | driverKind: codexDriver, | ||
| 1901 | continuationIdentity: { | ||
| 1902 | driverKind: codexDriver, | ||
| 1903 | continuationKey: "codex:instance:codex", | ||
| 1904 | }, | ||
| 1905 | displayName: undefined, | ||
| 1906 | enabled: true, | ||
| 1907 | snapshot: { | ||
| 1908 | resolveMaintenance: () => | ||
| 1909 | Effect.succeed( | ||
| 1910 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1911 | provider: codexDriver, | ||
| 1912 | packageName: null, | ||
| 1913 | }), | ||
| 1914 | ), | ||
| 1915 | getSnapshot: Effect.succeed(codexProvider), | ||
| 1916 | refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( | ||
| 1917 | Effect.as(codexProvider), | ||
| 1918 | ), | ||
| 1919 | streamChanges: Stream.empty, | ||
| 1920 | applyUsageLimits: () => Effect.void, | ||
| 1921 | }, | ||
| 1922 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1923 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1924 | }, | ||
| 1925 | { | ||
| 1926 | instanceId: openCodeInstanceId, | ||
| 1927 | driverKind: openCodeDriver, | ||
| 1928 | continuationIdentity: { | ||
| 1929 | driverKind: openCodeDriver, | ||
| 1930 | continuationKey: "opencode:instance:opencode", | ||
| 1931 | }, | ||
| 1932 | displayName: undefined, | ||
| 1933 | enabled: true, | ||
| 1934 | snapshot: { | ||
| 1935 | resolveMaintenance: () => | ||
| 1936 | Effect.succeed( | ||
| 1937 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 1938 | provider: openCodeDriver, | ||
| 1939 | packageName: null, | ||
| 1940 | }), | ||
| 1941 | ), | ||
| 1942 | getSnapshot: Effect.succeed(failedOpenCodeProvider), | ||
| 1943 | refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( | ||
| 1944 | Effect.andThen(Ref.get(catalogSnapshot)), | ||
| 1945 | ), | ||
| 1946 | streamChanges: Stream.empty, | ||
| 1947 | applyUsageLimits: () => Effect.void, | ||
| 1948 | }, | ||
| 1949 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 1950 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 1951 | }, | ||
| 1952 | ] satisfies ReadonlyArray<ProviderInstance>; | ||
| 1953 | const layerInstanceRegistry = Layer.succeed( | ||
| 1954 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 1955 | { | ||
| 1956 | getInstance: (instanceId) => | ||
| 1957 | Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), | ||
| 1958 | listInstances: Effect.succeed(instances), | ||
| 1959 | listUnavailable: Effect.succeed([]), | ||
| 1960 | streamChanges: Stream.empty, | ||
| 1961 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | ||
| 1962 | }, | ||
| 1963 | ); | ||
| 1964 | const scope = yield* Scope.make(); | ||
| 1965 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 1966 | const runtimeServices = yield* Layer.build( | ||
| 1967 | ProviderRegistry.layer.pipe( | ||
| 1968 | Layer.provideMerge(layerInstanceRegistry), | ||
| 1969 | Layer.provideMerge( | ||
| 1970 | ServerConfig.layerTest(process.cwd(), { | ||
| 1971 | prefix: "t3-provider-registry-reconnect-refresh-", | ||
| 1972 | }), | ||
| 1973 | ), | ||
| 1974 | Layer.provideMerge(NodeServices.layer), | ||
| 1975 | ), | ||
| 1976 | ).pipe(Scope.provide(scope)); | ||
| 1977 | |||
| 1978 | yield* Effect.gen(function* () { | ||
| 1979 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 1980 | const initialProviders = yield* registry.getProviders; | ||
| 1981 | assert.strictEqual( | ||
| 1982 | initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, | ||
| 1983 | "error", | ||
| 1984 | ); | ||
| 1985 | |||
| 1986 | const recoveredProviders = yield* registry.refresh(); | ||
| 1987 | assert.deepStrictEqual( | ||
| 1988 | recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) | ||
| 1989 | ?.models, | ||
| 1990 | recoveredOpenCodeProvider.models, | ||
| 1991 | ); | ||
| 1992 | assert.deepStrictEqual( | ||
| 1993 | recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), | ||
| 1994 | withBundledCompatibility(codexProvider), | ||
| 1995 | ); | ||
| 1996 | |||
| 1997 | yield* Ref.set(catalogSnapshot, changedCatalogProvider); | ||
| 1998 | const changedProviders = yield* registry.refresh(); | ||
| 1999 | assert.deepStrictEqual( | ||
| 2000 | changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, | ||
| 2001 | changedCatalogProvider.models, | ||
| 2002 | ); | ||
| 2003 | assert.deepStrictEqual( | ||
| 2004 | changedProviders.find((provider) => provider.instanceId === codexInstanceId), | ||
| 2005 | withBundledCompatibility(codexProvider), | ||
| 2006 | ); | ||
| 2007 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2008 | |||
| 2009 | assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); | ||
| 2010 | assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); | ||
| 2011 | }), | ||
| 2012 | ); | ||
| 2013 | |||
| 2014 | it("persists merged provider snapshots for the providers that were refreshed", () => { | ||
| 2015 | const previousProviders = [ | ||
| 2016 | { | ||
| 2017 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 2018 | driver: ProviderDriverKind.make("cursor"), | ||
| 2019 | status: "ready", | ||
| 2020 | enabled: true, | ||
| 2021 | installed: true, | ||
| 2022 | auth: { status: "authenticated" }, | ||
| 2023 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 2024 | version: "2026.04.09-f2b0fcd", | ||
| 2025 | models: [ | ||
| 2026 | { | ||
| 2027 | slug: "claude-opus-4-6", | ||
| 2028 | name: "Opus 4.6", | ||
| 2029 | isCustom: false, | ||
| 2030 | capabilities: createModelCapabilities({ | ||
| 2031 | optionDescriptors: [ | ||
| 2032 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 2033 | { id: "high", label: "High", isDefault: true }, | ||
| 2034 | ]), | ||
| 2035 | booleanDescriptor("fastMode", "Fast Mode"), | ||
| 2036 | booleanDescriptor("thinking", "Thinking"), | ||
| 2037 | ], | ||
| 2038 | }), | ||
| 2039 | }, | ||
| 2040 | ], | ||
| 2041 | slashCommands: [], | ||
| 2042 | skills: [], | ||
| 2043 | }, | ||
| 2044 | { | ||
| 2045 | instanceId: ProviderInstanceId.make("codex"), | ||
| 2046 | driver: ProviderDriverKind.make("codex"), | ||
| 2047 | status: "ready", | ||
| 2048 | enabled: true, | ||
| 2049 | installed: true, | ||
| 2050 | auth: { status: "authenticated" }, | ||
| 2051 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 2052 | version: "1.0.0", | ||
| 2053 | models: [], | ||
| 2054 | slashCommands: [], | ||
| 2055 | skills: [], | ||
| 2056 | }, | ||
| 2057 | ] as const satisfies ReadonlyArray<ServerProvider>; | ||
| 2058 | const refreshedCursor = { | ||
| 2059 | ...previousProviders[0], | ||
| 2060 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 2061 | models: [], | ||
| 2062 | } satisfies ServerProvider; | ||
| 2063 | |||
| 2064 | const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ | ||
| 2065 | refreshedCursor, | ||
| 2066 | ]); | ||
| 2067 | const persistedProviders = ProviderRegistry.selectProvidersByKind( | ||
| 2068 | mergedProviders, | ||
| 2069 | new Set([ProviderDriverKind.make("cursor")]), | ||
| 2070 | ); | ||
| 2071 | |||
| 2072 | assert.deepStrictEqual(persistedProviders, [ | ||
| 2073 | { | ||
| 2074 | ...refreshedCursor, | ||
| 2075 | models: [...previousProviders[0].models], | ||
| 2076 | }, | ||
| 2077 | ]); | ||
| 2078 | }); | ||
| 2079 | |||
| 2080 | it.effect("persists the merged snapshot when a live update has empty models", () => | ||
| 2081 | Effect.gen(function* () { | ||
| 2082 | const cursorDriver = ProviderDriverKind.make("cursor"); | ||
| 2083 | const cursorInstanceId = ProviderInstanceId.make("cursor"); | ||
| 2084 | const initialProvider = { | ||
| 2085 | instanceId: cursorInstanceId, | ||
| 2086 | driver: cursorDriver, | ||
| 2087 | status: "ready", | ||
| 2088 | enabled: true, | ||
| 2089 | installed: true, | ||
| 2090 | auth: { status: "authenticated" }, | ||
| 2091 | checkedAt: "2026-04-14T00:00:00.000Z", | ||
| 2092 | version: "2026.04.09-f2b0fcd", | ||
| 2093 | models: [ | ||
| 2094 | { | ||
| 2095 | slug: "claude-opus-4-6", | ||
| 2096 | name: "Opus 4.6", | ||
| 2097 | isCustom: false, | ||
| 2098 | capabilities: createModelCapabilities({ | ||
| 2099 | optionDescriptors: [ | ||
| 2100 | selectDescriptor("reasoning", "Reasoning", [ | ||
| 2101 | { id: "high", label: "High", isDefault: true }, | ||
| 2102 | ]), | ||
| 2103 | ], | ||
| 2104 | }), | ||
| 2105 | }, | ||
| 2106 | ], | ||
| 2107 | slashCommands: [], | ||
| 2108 | skills: [], | ||
| 2109 | } as const satisfies ServerProvider; | ||
| 2110 | const refreshedProvider = { | ||
| 2111 | ...initialProvider, | ||
| 2112 | checkedAt: "2026-04-14T00:01:00.000Z", | ||
| 2113 | models: [], | ||
| 2114 | } satisfies ServerProvider; | ||
| 2115 | const changes = yield* PubSub.unbounded<ServerProvider>(); | ||
| 2116 | const instance = { | ||
| 2117 | instanceId: cursorInstanceId, | ||
| 2118 | driverKind: cursorDriver, | ||
| 2119 | continuationIdentity: { | ||
| 2120 | driverKind: cursorDriver, | ||
| 2121 | continuationKey: "cursor:instance:cursor", | ||
| 2122 | }, | ||
| 2123 | displayName: undefined, | ||
| 2124 | enabled: true, | ||
| 2125 | snapshot: { | ||
| 2126 | resolveMaintenance: () => | ||
| 2127 | Effect.succeed( | ||
| 2128 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2129 | provider: cursorDriver, | ||
| 2130 | packageName: null, | ||
| 2131 | }), | ||
| 2132 | ), | ||
| 2133 | getSnapshot: Effect.succeed(initialProvider), | ||
| 2134 | refresh: Effect.succeed(refreshedProvider), | ||
| 2135 | streamChanges: Stream.fromPubSub(changes), | ||
| 2136 | applyUsageLimits: () => Effect.void, | ||
| 2137 | }, | ||
| 2138 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2139 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2140 | } satisfies ProviderInstance; | ||
| 2141 | const layerInstanceRegistry = Layer.succeed( | ||
| 2142 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2143 | { | ||
| 2144 | getInstance: (instanceId) => | ||
| 2145 | Effect.succeed(instanceId === cursorInstanceId ? instance : undefined), | ||
| 2146 | listInstances: Effect.succeed([instance]), | ||
| 2147 | listUnavailable: Effect.succeed([]), | ||
| 2148 | streamChanges: Stream.empty, | ||
| 2149 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2150 | PubSub.subscribe(pubsub), | ||
| 2151 | ), | ||
| 2152 | }, | ||
| 2153 | ); | ||
| 2154 | const scope = yield* Scope.make(); | ||
| 2155 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2156 | const runtimeServices = yield* Layer.build( | ||
| 2157 | ProviderRegistry.layer.pipe( | ||
| 2158 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2159 | Layer.provideMerge( | ||
| 2160 | ServerConfig.layerTest(process.cwd(), { | ||
| 2161 | prefix: "t3-provider-registry-merged-persist-", | ||
| 2162 | }), | ||
| 2163 | ), | ||
| 2164 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2165 | Layer.provideMerge(NodeServices.layer), | ||
| 2166 | ), | ||
| 2167 | ).pipe(Scope.provide(scope)); | ||
| 2168 | |||
| 2169 | yield* Effect.gen(function* () { | ||
| 2170 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2171 | const config = yield* ServerConfig.ServerConfig; | ||
| 2172 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 2173 | cacheDir: config.providerStatusCacheDir, | ||
| 2174 | instanceId: cursorInstanceId, | ||
| 2175 | }); | ||
| 2176 | |||
| 2177 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ | ||
| 2178 | ...initialProvider.models, | ||
| 2179 | ]); | ||
| 2180 | const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); | ||
| 2181 | yield* PubSub.publish(changes, refreshedProvider); | ||
| 2182 | yield* Fiber.join(persisted); | ||
| 2183 | const cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2184 | |||
| 2185 | assert.deepStrictEqual( | ||
| 2186 | cachedProvider, | ||
| 2187 | withBundledCompatibility({ | ||
| 2188 | ...refreshedProvider, | ||
| 2189 | models: [...initialProvider.models], | ||
| 2190 | }), | ||
| 2191 | ); | ||
| 2192 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2193 | }), | ||
| 2194 | ); | ||
| 2195 | |||
| 2196 | it.effect( | ||
| 2197 | "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", | ||
| 2198 | () => | ||
| 2199 | Effect.gen(function* () { | ||
| 2200 | const openCodeDriver = ProviderDriverKind.make("opencode"); | ||
| 2201 | const openCodeInstanceId = ProviderInstanceId.make("opencode"); | ||
| 2202 | const initialProvider = { | ||
| 2203 | instanceId: openCodeInstanceId, | ||
| 2204 | driver: openCodeDriver, | ||
| 2205 | status: "ready", | ||
| 2206 | enabled: true, | ||
| 2207 | installed: true, | ||
| 2208 | auth: { status: "authenticated" }, | ||
| 2209 | checkedAt: "2026-07-17T00:00:00.000Z", | ||
| 2210 | version: "1.0.0", | ||
| 2211 | models: [ | ||
| 2212 | { | ||
| 2213 | slug: "github/gpt-5", | ||
| 2214 | name: "GPT-5", | ||
| 2215 | subProvider: "GitHub", | ||
| 2216 | isCustom: false, | ||
| 2217 | capabilities: null, | ||
| 2218 | }, | ||
| 2219 | { | ||
| 2220 | slug: "removed-plugin/model", | ||
| 2221 | name: "Removed Plugin Model", | ||
| 2222 | subProvider: "Removed Plugin", | ||
| 2223 | isCustom: false, | ||
| 2224 | capabilities: null, | ||
| 2225 | }, | ||
| 2226 | ], | ||
| 2227 | slashCommands: [], | ||
| 2228 | skills: [], | ||
| 2229 | } as const satisfies ServerProvider; | ||
| 2230 | const authoritativeProvider = { | ||
| 2231 | ...initialProvider, | ||
| 2232 | checkedAt: "2026-07-17T00:01:00.000Z", | ||
| 2233 | models: [initialProvider.models[0]!], | ||
| 2234 | } satisfies ServerProvider; | ||
| 2235 | const failedProvider = { | ||
| 2236 | ...authoritativeProvider, | ||
| 2237 | status: "error", | ||
| 2238 | auth: { status: "unknown" }, | ||
| 2239 | checkedAt: "2026-07-17T00:02:00.000Z", | ||
| 2240 | models: [], | ||
| 2241 | message: "Failed to refresh OpenCode models.", | ||
| 2242 | } satisfies ServerProvider; | ||
| 2243 | const changes = yield* PubSub.unbounded<ServerProvider>(); | ||
| 2244 | const instance = { | ||
| 2245 | instanceId: openCodeInstanceId, | ||
| 2246 | driverKind: openCodeDriver, | ||
| 2247 | continuationIdentity: { | ||
| 2248 | driverKind: openCodeDriver, | ||
| 2249 | continuationKey: "opencode:instance:opencode", | ||
| 2250 | }, | ||
| 2251 | displayName: undefined, | ||
| 2252 | enabled: true, | ||
| 2253 | snapshot: { | ||
| 2254 | resolveMaintenance: () => | ||
| 2255 | Effect.succeed( | ||
| 2256 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2257 | provider: openCodeDriver, | ||
| 2258 | packageName: null, | ||
| 2259 | }), | ||
| 2260 | ), | ||
| 2261 | getSnapshot: Effect.succeed(initialProvider), | ||
| 2262 | refresh: Effect.succeed(authoritativeProvider), | ||
| 2263 | streamChanges: Stream.fromPubSub(changes), | ||
| 2264 | applyUsageLimits: () => Effect.void, | ||
| 2265 | }, | ||
| 2266 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2267 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2268 | } satisfies ProviderInstance; | ||
| 2269 | const layerInstanceRegistry = Layer.succeed( | ||
| 2270 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2271 | { | ||
| 2272 | getInstance: (instanceId) => | ||
| 2273 | Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), | ||
| 2274 | listInstances: Effect.succeed([instance]), | ||
| 2275 | listUnavailable: Effect.succeed([]), | ||
| 2276 | streamChanges: Stream.empty, | ||
| 2277 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2278 | PubSub.subscribe(pubsub), | ||
| 2279 | ), | ||
| 2280 | }, | ||
| 2281 | ); | ||
| 2282 | const scope = yield* Scope.make(); | ||
| 2283 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2284 | const runtimeServices = yield* Layer.build( | ||
| 2285 | ProviderRegistry.layer.pipe( | ||
| 2286 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2287 | Layer.provideMerge( | ||
| 2288 | ServerConfig.layerTest(process.cwd(), { | ||
| 2289 | prefix: "t3-provider-registry-opencode-authoritative-persist-", | ||
| 2290 | }), | ||
| 2291 | ), | ||
| 2292 | Layer.provideMerge(NodeServices.layer), | ||
| 2293 | ), | ||
| 2294 | ).pipe(Scope.provide(scope)); | ||
| 2295 | |||
| 2296 | yield* Effect.gen(function* () { | ||
| 2297 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2298 | const config = yield* ServerConfig.ServerConfig; | ||
| 2299 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 2300 | cacheDir: config.providerStatusCacheDir, | ||
| 2301 | instanceId: openCodeInstanceId, | ||
| 2302 | }); | ||
| 2303 | |||
| 2304 | const authoritativePersisted = yield* awaitPersistedProvider( | ||
| 2305 | registry, | ||
| 2306 | authoritativeProvider.checkedAt, | ||
| 2307 | ); | ||
| 2308 | yield* PubSub.publish(changes, authoritativeProvider); | ||
| 2309 | yield* Fiber.join(authoritativePersisted); | ||
| 2310 | let cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2311 | |||
| 2312 | assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); | ||
| 2313 | |||
| 2314 | const failedPersisted = yield* awaitPersistedProvider( | ||
| 2315 | registry, | ||
| 2316 | failedProvider.checkedAt, | ||
| 2317 | ); | ||
| 2318 | yield* PubSub.publish(changes, failedProvider); | ||
| 2319 | yield* Fiber.join(failedPersisted); | ||
| 2320 | cachedProvider = yield* readProviderStatusCache(filePath); | ||
| 2321 | |||
| 2322 | assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); | ||
| 2323 | assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ | ||
| 2324 | authoritativeProvider.models[0]!, | ||
| 2325 | ]); | ||
| 2326 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2327 | }), | ||
| 2328 | ); | ||
| 2329 | |||
| 2330 | it.effect("returns the cached provider list when a manual refresh fails", () => | ||
| 2331 | Effect.gen(function* () { | ||
| 2332 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 2333 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 2334 | const cachedProvider = { | ||
| 2335 | instanceId: codexInstanceId, | ||
| 2336 | driver: codexDriver, | ||
| 2337 | status: "ready", | ||
| 2338 | enabled: true, | ||
| 2339 | installed: true, | ||
| 2340 | auth: { status: "authenticated" }, | ||
| 2341 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 2342 | version: "1.0.0", | ||
| 2343 | models: [], | ||
| 2344 | slashCommands: [], | ||
| 2345 | skills: [], | ||
| 2346 | } as const satisfies ServerProvider; | ||
| 2347 | const instance = { | ||
| 2348 | instanceId: codexInstanceId, | ||
| 2349 | driverKind: codexDriver, | ||
| 2350 | continuationIdentity: { | ||
| 2351 | driverKind: codexDriver, | ||
| 2352 | continuationKey: "codex:instance:codex", | ||
| 2353 | }, | ||
| 2354 | displayName: undefined, | ||
| 2355 | enabled: true, | ||
| 2356 | snapshot: { | ||
| 2357 | resolveMaintenance: () => | ||
| 2358 | Effect.succeed( | ||
| 2359 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2360 | provider: codexDriver, | ||
| 2361 | packageName: null, | ||
| 2362 | }), | ||
| 2363 | ), | ||
| 2364 | getSnapshot: Effect.succeed(cachedProvider), | ||
| 2365 | refresh: Effect.die(new Error("simulated refresh failure")), | ||
| 2366 | streamChanges: Stream.empty, | ||
| 2367 | applyUsageLimits: () => Effect.void, | ||
| 2368 | }, | ||
| 2369 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2370 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2371 | } satisfies ProviderInstance; | ||
| 2372 | const layerInstanceRegistry = Layer.succeed( | ||
| 2373 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2374 | { | ||
| 2375 | getInstance: (instanceId) => | ||
| 2376 | Effect.succeed(instanceId === codexInstanceId ? instance : undefined), | ||
| 2377 | listInstances: Effect.succeed([instance]), | ||
| 2378 | listUnavailable: Effect.succeed([]), | ||
| 2379 | streamChanges: Stream.empty, | ||
| 2380 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 2381 | PubSub.subscribe(pubsub), | ||
| 2382 | ), | ||
| 2383 | }, | ||
| 2384 | ); | ||
| 2385 | const scope = yield* Scope.make(); | ||
| 2386 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2387 | const runtimeServices = yield* Layer.build( | ||
| 2388 | ProviderRegistry.layer.pipe( | ||
| 2389 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2390 | Layer.provideMerge( | ||
| 2391 | ServerConfig.layerTest(process.cwd(), { | ||
| 2392 | prefix: "t3-provider-registry-refresh-failure-", | ||
| 2393 | }), | ||
| 2394 | ), | ||
| 2395 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2396 | Layer.provideMerge(NodeServices.layer), | ||
| 2397 | ), | ||
| 2398 | ).pipe(Scope.provide(scope)); | ||
| 2399 | |||
| 2400 | yield* Effect.gen(function* () { | ||
| 2401 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2402 | |||
| 2403 | assert.deepStrictEqual(yield* registry.getProviders, [ | ||
| 2404 | withBundledCompatibility(cachedProvider), | ||
| 2405 | ]); | ||
| 2406 | assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ | ||
| 2407 | withBundledCompatibility(cachedProvider), | ||
| 2408 | ]); | ||
| 2409 | assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ | ||
| 2410 | withBundledCompatibility(cachedProvider), | ||
| 2411 | ]); | ||
| 2412 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2413 | }), | ||
| 2414 | ); | ||
| 2415 | |||
| 2416 | it.effect("keeps consuming registry changes after one sync fails", () => | ||
| 2417 | Effect.gen(function* () { | ||
| 2418 | const codexDriver = ProviderDriverKind.make("codex"); | ||
| 2419 | const codexInstanceId = ProviderInstanceId.make("codex"); | ||
| 2420 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); | ||
| 2421 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); | ||
| 2422 | const codexProvider = { | ||
| 2423 | instanceId: codexInstanceId, | ||
| 2424 | driver: codexDriver, | ||
| 2425 | status: "ready", | ||
| 2426 | enabled: true, | ||
| 2427 | installed: true, | ||
| 2428 | auth: { status: "authenticated" }, | ||
| 2429 | checkedAt: "2026-04-29T10:00:00.000Z", | ||
| 2430 | version: "1.0.0", | ||
| 2431 | models: [], | ||
| 2432 | slashCommands: [], | ||
| 2433 | skills: [], | ||
| 2434 | } as const satisfies ServerProvider; | ||
| 2435 | const claudeProvider = { | ||
| 2436 | instanceId: claudeInstanceId, | ||
| 2437 | driver: claudeDriver, | ||
| 2438 | status: "ready", | ||
| 2439 | enabled: true, | ||
| 2440 | installed: true, | ||
| 2441 | auth: { status: "authenticated" }, | ||
| 2442 | checkedAt: "2026-04-29T10:01:00.000Z", | ||
| 2443 | version: "1.0.0", | ||
| 2444 | models: [], | ||
| 2445 | slashCommands: [], | ||
| 2446 | skills: [], | ||
| 2447 | } as const satisfies ServerProvider; | ||
| 2448 | const makeInstance = (provider: ServerProvider): ProviderInstance => ({ | ||
| 2449 | instanceId: provider.instanceId, | ||
| 2450 | driverKind: provider.driver, | ||
| 2451 | continuationIdentity: { | ||
| 2452 | driverKind: provider.driver, | ||
| 2453 | continuationKey: `${provider.driver}:instance:${provider.instanceId}`, | ||
| 2454 | }, | ||
| 2455 | displayName: undefined, | ||
| 2456 | enabled: true, | ||
| 2457 | snapshot: { | ||
| 2458 | resolveMaintenance: () => | ||
| 2459 | Effect.succeed( | ||
| 2460 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 2461 | provider: provider.driver, | ||
| 2462 | packageName: null, | ||
| 2463 | }), | ||
| 2464 | ), | ||
| 2465 | getSnapshot: Effect.succeed(provider), | ||
| 2466 | refresh: Effect.succeed(provider), | ||
| 2467 | streamChanges: Stream.empty, | ||
| 2468 | applyUsageLimits: () => Effect.void, | ||
| 2469 | }, | ||
| 2470 | orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], | ||
| 2471 | textGeneration: {} as ProviderInstance["textGeneration"], | ||
| 2472 | }); | ||
| 2473 | const codexInstance = makeInstance(codexProvider); | ||
| 2474 | const claudeInstance = makeInstance(claudeProvider); | ||
| 2475 | const changes = yield* PubSub.unbounded<void>(); | ||
| 2476 | const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([codexInstance]); | ||
| 2477 | const failNextList = yield* Ref.make(false); | ||
| 2478 | const wait = () => Effect.yieldNow; | ||
| 2479 | const layerInstanceRegistry = Layer.succeed( | ||
| 2480 | ProviderInstanceRegistry.ProviderInstanceRegistry, | ||
| 2481 | { | ||
| 2482 | getInstance: (instanceId) => | ||
| 2483 | Ref.get(instancesRef).pipe( | ||
| 2484 | Effect.map((instances) => | ||
| 2485 | instances.find((instance) => instance.instanceId === instanceId), | ||
| 2486 | ), | ||
| 2487 | ), | ||
| 2488 | listInstances: Effect.gen(function* () { | ||
| 2489 | const shouldFail = yield* Ref.get(failNextList); | ||
| 2490 | if (shouldFail) { | ||
| 2491 | yield* Ref.set(failNextList, false); | ||
| 2492 | return yield* Effect.die(new Error("simulated registry list failure")); | ||
| 2493 | } | ||
| 2494 | return yield* Ref.get(instancesRef); | ||
| 2495 | }), | ||
| 2496 | listUnavailable: Effect.succeed([]), | ||
| 2497 | streamChanges: Stream.fromPubSub(changes), | ||
| 2498 | subscribeChanges: PubSub.subscribe(changes), | ||
| 2499 | }, | ||
| 2500 | ); | ||
| 2501 | const scope = yield* Scope.make(); | ||
| 2502 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2503 | const runtimeServices = yield* Layer.build( | ||
| 2504 | ProviderRegistry.layer.pipe( | ||
| 2505 | Layer.provideMerge(layerInstanceRegistry), | ||
| 2506 | Layer.provideMerge( | ||
| 2507 | ServerConfig.layerTest(process.cwd(), { | ||
| 2508 | prefix: "t3-provider-registry-sync-failure-", | ||
| 2509 | }), | ||
| 2510 | ), | ||
| 2511 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2512 | Layer.provideMerge(NodeServices.layer), | ||
| 2513 | ), | ||
| 2514 | ).pipe(Scope.provide(scope)); | ||
| 2515 | |||
| 2516 | yield* Effect.gen(function* () { | ||
| 2517 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2518 | assert.deepStrictEqual(yield* registry.getProviders, [ | ||
| 2519 | withBundledCompatibility(codexProvider), | ||
| 2520 | ]); | ||
| 2521 | |||
| 2522 | yield* Ref.set(failNextList, true); | ||
| 2523 | yield* PubSub.publish(changes, undefined); | ||
| 2524 | |||
| 2525 | yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); | ||
| 2526 | yield* PubSub.publish(changes, undefined); | ||
| 2527 | |||
| 2528 | let providers = yield* registry.getProviders; | ||
| 2529 | for ( | ||
| 2530 | let attempt = 0; | ||
| 2531 | attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); | ||
| 2532 | attempt += 1 | ||
| 2533 | ) { | ||
| 2534 | yield* wait(); | ||
| 2535 | providers = yield* registry.getProviders; | ||
| 2536 | } | ||
| 2537 | |||
| 2538 | assert.deepStrictEqual( | ||
| 2539 | providers.map((provider) => provider.instanceId).toSorted(), | ||
| 2540 | [codexInstanceId, claudeInstanceId].toSorted(), | ||
| 2541 | ); | ||
| 2542 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2543 | }), | ||
| 2544 | ); | ||
| 2545 | |||
| 2546 | // This test intentionally avoids `mockCommandSpawnerLayer` so the real | ||
| 2547 | // `probeCodexAppServerProvider` path runs — including the full | ||
| 2548 | // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. | ||
| 2549 | // We point `binaryPath` at a name that cannot exist on any machine so | ||
| 2550 | // the real `ChildProcessSpawner` deterministically returns ENOENT; the | ||
| 2551 | // probe wraps that as `CodexAppServerSpawnError` and | ||
| 2552 | // `checkCodexProviderStatus` turns it into the user-visible "not | ||
| 2553 | // installed" error snapshot. If the aggregator's `syncLiveSources` | ||
| 2554 | // breaks — the `codex_personal`-never-probes bug we are guarding | ||
| 2555 | // against — that snapshot never lands in `getProviders` and the | ||
| 2556 | // assertions below fail. | ||
| 2557 | it.effect("propagates real Codex probe failures to the aggregator at boot", () => | ||
| 2558 | Effect.gen(function* () { | ||
| 2559 | const missingBinary = `t3code_codex_missing_`; | ||
| 2560 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2561 | decodeServerSettings( | ||
| 2562 | deepMerge(encodedDefaultServerSettings, { | ||
| 2563 | providers: { | ||
| 2564 | // Disable every built-in probe that would otherwise spawn | ||
| 2565 | // on the CI host. `enabled: false` short-circuits each | ||
| 2566 | // driver's probe *before* it touches the spawner, so the | ||
| 2567 | // test environment stays isolated from the dev | ||
| 2568 | // machine's PATH. | ||
| 2569 | codex: { enabled: false }, | ||
| 2570 | claudeAgent: { enabled: false }, | ||
| 2571 | cursor: { enabled: false }, | ||
| 2572 | grok: { enabled: false }, | ||
| 2573 | opencode: { enabled: false }, | ||
| 2574 | }, | ||
| 2575 | // `providerInstances` keys are branded `ProviderInstanceId`; | ||
| 2576 | // the branded index signature rejects plain string literals | ||
| 2577 | // at the TS level even though the runtime schema happily | ||
| 2578 | // accepts + decodes them. Cast the patch to `unknown` so | ||
| 2579 | // the `Schema.decodeSync` below does the real validation. | ||
| 2580 | providerInstances: { | ||
| 2581 | // Matches the shape the user had in `.t3/dev/settings.json` | ||
| 2582 | // when the bug was reported: a custom enabled Codex instance | ||
| 2583 | // pointing at a binary the server has to actually spawn. | ||
| 2584 | codex_personal: { | ||
| 2585 | driver: "codex", | ||
| 2586 | displayName: "Codex Personal", | ||
| 2587 | enabled: true, | ||
| 2588 | config: { | ||
| 2589 | binaryPath: missingBinary, | ||
| 2590 | homePath: `/tmp/${missingBinary}_home`, | ||
| 2591 | }, | ||
| 2592 | }, | ||
| 2593 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 2594 | }), | ||
| 2595 | ), | ||
| 2596 | ); | ||
| 2597 | const scope = yield* Scope.make(); | ||
| 2598 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2599 | const layerProviderRegistry = ProviderRegistry.layer.pipe( | ||
| 2600 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2601 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2602 | Layer.provideMerge( | ||
| 2603 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2604 | ), | ||
| 2605 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2606 | Layer.provideMerge( | ||
| 2607 | ServerConfig.layerTest(process.cwd(), { | ||
| 2608 | prefix: "t3-provider-registry-", | ||
| 2609 | }), | ||
| 2610 | ), | ||
| 2611 | Layer.provideMerge(layerTestHttpClient), | ||
| 2612 | Layer.provideMerge( | ||
| 2613 | Layer.succeed( | ||
| 2614 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2615 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2616 | ), | ||
| 2617 | ), | ||
| 2618 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2619 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2620 | Layer.provideMerge( | ||
| 2621 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2622 | ), | ||
| 2623 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2624 | // NO spawner mock — `ChildProcessSpawner` is supplied by the | ||
| 2625 | // outer `NodeServices.layer` on `it.layer(...)` and will | ||
| 2626 | // genuinely spawn a subprocess. The missing-binary ENOENT is | ||
| 2627 | // what exercises the same failure mode as a misconfigured | ||
| 2628 | // production `binaryPath`. | ||
| 2629 | ); | ||
| 2630 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2631 | Scope.provide(scope), | ||
| 2632 | ); | ||
| 2633 | |||
| 2634 | yield* Effect.gen(function* () { | ||
| 2635 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2636 | let providers = yield* registry.getProviders; | ||
| 2637 | for ( | ||
| 2638 | let attempts = 0; | ||
| 2639 | attempts < 50 && | ||
| 2640 | providers.find((provider) => provider.instanceId === "codex_personal")?.status !== | ||
| 2641 | "error"; | ||
| 2642 | attempts += 1 | ||
| 2643 | ) { | ||
| 2644 | yield* Effect.yieldNow; | ||
| 2645 | providers = yield* registry.getProviders; | ||
| 2646 | } | ||
| 2647 | const codexPersonal = providers.find( | ||
| 2648 | (provider) => provider.instanceId === "codex_personal", | ||
| 2649 | ); | ||
| 2650 | assert.notStrictEqual( | ||
| 2651 | codexPersonal, | ||
| 2652 | undefined, | ||
| 2653 | `Expected the aggregator to know about codex_personal; instead saw: ${providers | ||
| 2654 | .map((provider) => provider.instanceId) | ||
| 2655 | .join(", ")}`, | ||
| 2656 | ); | ||
| 2657 | assert.strictEqual( | ||
| 2658 | codexPersonal?.status, | ||
| 2659 | "error", | ||
| 2660 | "Real Codex probe against a missing binary should surface as 'error' in the aggregator", | ||
| 2661 | ); | ||
| 2662 | assert.strictEqual(codexPersonal?.installed, false); | ||
| 2663 | assert.include(codexPersonal?.message, missingBinary); | ||
| 2664 | assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); | ||
| 2665 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2666 | }), | ||
| 2667 | ); | ||
| 2668 | |||
| 2669 | // A binary path change must rebuild Codex and publish its new probe result. | ||
| 2670 | it.effect("re-probes when settings change the codex binaryPath", () => | ||
| 2671 | Effect.gen(function* () { | ||
| 2672 | const firstMissing = `t3code_codex_first_`; | ||
| 2673 | const secondMissing = `t3code_codex_second_`; | ||
| 2674 | const spawnedCommands: Array<string> = []; | ||
| 2675 | const secondProbeStarted = yield* Deferred.make<void>(); | ||
| 2676 | const releaseSecondProbe = yield* Deferred.make<void>(); | ||
| 2677 | const allowLazySettingsStream = yield* Deferred.make<void>(); | ||
| 2678 | const mutableServerSettings = yield* makeMutableServerSettingsService( | ||
| 2679 | decodeServerSettings( | ||
| 2680 | deepMerge(encodedDefaultServerSettings, { | ||
| 2681 | providers: { | ||
| 2682 | codex: { enabled: true, binaryPath: firstMissing }, | ||
| 2683 | claudeAgent: { enabled: false }, | ||
| 2684 | cursor: { enabled: false }, | ||
| 2685 | grok: { enabled: false }, | ||
| 2686 | opencode: { enabled: false }, | ||
| 2687 | }, | ||
| 2688 | }), | ||
| 2689 | ), | ||
| 2690 | ); | ||
| 2691 | const serverSettings = { | ||
| 2692 | ...mutableServerSettings, | ||
| 2693 | streamChanges: Stream.unwrap( | ||
| 2694 | Deferred.await(allowLazySettingsStream).pipe( | ||
| 2695 | Effect.as(mutableServerSettings.streamChanges), | ||
| 2696 | ), | ||
| 2697 | ), | ||
| 2698 | } satisfies ServerSettingsModule.ServerSettingsService["Service"]; | ||
| 2699 | const scope = yield* Scope.make(); | ||
| 2700 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2701 | const layerProviderRegistry = ProviderRegistry.layer.pipe( | ||
| 2702 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2703 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2704 | Layer.provideMerge( | ||
| 2705 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2706 | ), | ||
| 2707 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2708 | Layer.provideMerge( | ||
| 2709 | ServerConfig.layerTest(process.cwd(), { | ||
| 2710 | prefix: "t3-provider-registry-", | ||
| 2711 | }), | ||
| 2712 | ), | ||
| 2713 | Layer.provideMerge(layerTestHttpClient), | ||
| 2714 | Layer.provideMerge( | ||
| 2715 | Layer.succeed( | ||
| 2716 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2717 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2718 | ), | ||
| 2719 | ), | ||
| 2720 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2721 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2722 | Layer.provideMerge( | ||
| 2723 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2724 | ), | ||
| 2725 | Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => | ||
| 2726 | ChildProcessSpawner.make((command) => { | ||
| 2727 | if (command._tag !== "StandardCommand") return spawner.spawn(command); | ||
| 2728 | spawnedCommands.push(command.command); | ||
| 2729 | const beforeSpawn = | ||
| 2730 | command.command === secondMissing | ||
| 2731 | ? Deferred.succeed(secondProbeStarted, undefined).pipe( | ||
| 2732 | Effect.andThen(Deferred.await(releaseSecondProbe)), | ||
| 2733 | ) | ||
| 2734 | : Effect.void; | ||
| 2735 | return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); | ||
| 2736 | }), | ||
| 2737 | ), | ||
| 2738 | Layer.provideMerge(NodeServices.layer), | ||
| 2739 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2740 | ); | ||
| 2741 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2742 | Scope.provide(scope), | ||
| 2743 | ); | ||
| 2744 | |||
| 2745 | yield* Effect.gen(function* () { | ||
| 2746 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2747 | const codexSnapshots = registry.streamChanges.pipe( | ||
| 2748 | Stream.map((providers) => | ||
| 2749 | providers.find((provider) => provider.instanceId === "codex"), | ||
| 2750 | ), | ||
| 2751 | Stream.filter((provider): provider is ServerProvider => provider !== undefined), | ||
| 2752 | ); | ||
| 2753 | const firstError = yield* Stream.toPull( | ||
| 2754 | codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), | ||
| 2755 | ); | ||
| 2756 | const currentCodex = (yield* registry.getProviders).find( | ||
| 2757 | (provider) => provider.instanceId === "codex", | ||
| 2758 | ); | ||
| 2759 | const initialCodex = | ||
| 2760 | currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0]; | ||
| 2761 | assert.strictEqual(initialCodex?.status, "error"); | ||
| 2762 | assert.strictEqual(initialCodex?.installed, false); | ||
| 2763 | assert.deepStrictEqual(spawnedCommands, [firstMissing]); | ||
| 2764 | |||
| 2765 | const pendingRebuild = yield* Stream.toPull( | ||
| 2766 | codexSnapshots.pipe( | ||
| 2767 | Stream.filter((provider) => provider.status === "warning" && !provider.installed), | ||
| 2768 | ), | ||
| 2769 | ); | ||
| 2770 | yield* serverSettings.updateSettings({ | ||
| 2771 | providers: { | ||
| 2772 | codex: { enabled: true, binaryPath: secondMissing }, | ||
| 2773 | }, | ||
| 2774 | }); | ||
| 2775 | // Start the lazy stream only after publishing. A watcher that did | ||
| 2776 | // not subscribe before forking has already lost this update. | ||
| 2777 | yield* Deferred.succeed(allowLazySettingsStream, undefined); | ||
| 2778 | |||
| 2779 | // Hold the second probe until the aggregator sees the rebuilt | ||
| 2780 | // instance. Its next error must come from the new executable. | ||
| 2781 | yield* Deferred.await(secondProbeStarted); | ||
| 2782 | yield* pendingRebuild; | ||
| 2783 | const rebuiltError = yield* Stream.toPull( | ||
| 2784 | codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), | ||
| 2785 | ); | ||
| 2786 | yield* Deferred.succeed(releaseSecondProbe, undefined); | ||
| 2787 | const [reprobedCodex] = yield* rebuiltError; | ||
| 2788 | assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); | ||
| 2789 | assert.strictEqual(reprobedCodex?.status, "error"); | ||
| 2790 | assert.strictEqual(reprobedCodex?.installed, false); | ||
| 2791 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2792 | }), | ||
| 2793 | ); | ||
| 2794 | |||
| 2795 | it.effect("includes unavailable instance snapshots in getProviders", () => | ||
| 2796 | Effect.gen(function* () { | ||
| 2797 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2798 | decodeServerSettings( | ||
| 2799 | deepMerge(encodedDefaultServerSettings, { | ||
| 2800 | providers: { | ||
| 2801 | codex: { enabled: false }, | ||
| 2802 | claudeAgent: { enabled: false }, | ||
| 2803 | cursor: { enabled: false }, | ||
| 2804 | grok: { enabled: false }, | ||
| 2805 | opencode: { enabled: false }, | ||
| 2806 | }, | ||
| 2807 | providerInstances: { | ||
| 2808 | ghost_main: { | ||
| 2809 | driver: "ghostDriver", | ||
| 2810 | displayName: "A fork-only driver we don't ship", | ||
| 2811 | enabled: false, | ||
| 2812 | config: { arbitrary: "payload" }, | ||
| 2813 | }, | ||
| 2814 | } as unknown as ContractServerSettings["providerInstances"], | ||
| 2815 | }), | ||
| 2816 | ), | ||
| 2817 | ); | ||
| 2818 | const scope = yield* Scope.make(); | ||
| 2819 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2820 | const layerProviderRegistry = ProviderRegistry.layer.pipe( | ||
| 2821 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2822 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2823 | Layer.provideMerge( | ||
| 2824 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2825 | ), | ||
| 2826 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2827 | Layer.provideMerge( | ||
| 2828 | ServerConfig.layerTest(process.cwd(), { | ||
| 2829 | prefix: "t3-provider-registry-", | ||
| 2830 | }), | ||
| 2831 | ), | ||
| 2832 | Layer.provideMerge(layerTestHttpClient), | ||
| 2833 | Layer.provideMerge( | ||
| 2834 | Layer.succeed( | ||
| 2835 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2836 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2837 | ), | ||
| 2838 | ), | ||
| 2839 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2840 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2841 | Layer.provideMerge( | ||
| 2842 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2843 | ), | ||
| 2844 | Layer.provideMerge(NodeServices.layer), | ||
| 2845 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2846 | ); | ||
| 2847 | const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( | ||
| 2848 | Scope.provide(scope), | ||
| 2849 | ); | ||
| 2850 | |||
| 2851 | yield* Effect.gen(function* () { | ||
| 2852 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2853 | const providers = yield* registry.getProviders; | ||
| 2854 | const ghost = providers.find((provider) => provider.instanceId === "ghost_main"); | ||
| 2855 | |||
| 2856 | assert.notStrictEqual(ghost, undefined); | ||
| 2857 | assert.strictEqual(ghost?.driver, "ghostDriver"); | ||
| 2858 | assert.strictEqual(ghost?.availability, "unavailable"); | ||
| 2859 | assert.match(ghost?.unavailableReason ?? "", /ghostDriver/); | ||
| 2860 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2861 | }), | ||
| 2862 | ); | ||
| 2863 | |||
| 2864 | it.effect( | ||
| 2865 | "keeps Cursor disabled and skips provider probing when settings use their defaults", | ||
| 2866 | () => | ||
| 2867 | Effect.gen(function* () { | ||
| 2868 | const serverSettings = yield* makeMutableServerSettingsService( | ||
| 2869 | decodeServerSettings( | ||
| 2870 | deepMerge(encodedDefaultServerSettings, { | ||
| 2871 | providers: { | ||
| 2872 | codex: { | ||
| 2873 | enabled: false, | ||
| 2874 | }, | ||
| 2875 | grok: { | ||
| 2876 | enabled: false, | ||
| 2877 | }, | ||
| 2878 | }, | ||
| 2879 | }), | ||
| 2880 | ), | ||
| 2881 | ); | ||
| 2882 | let cursorSpawned = false; | ||
| 2883 | const scope = yield* Scope.make(); | ||
| 2884 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | ||
| 2885 | const layerProviderRegistry = ProviderRegistry.layer.pipe( | ||
| 2886 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | ||
| 2887 | Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), | ||
| 2888 | Layer.provideMerge( | ||
| 2889 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2890 | ), | ||
| 2891 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 2892 | Layer.provideMerge( | ||
| 2893 | ServerConfig.layerTest(process.cwd(), { | ||
| 2894 | prefix: "t3-provider-registry-", | ||
| 2895 | }), | ||
| 2896 | ), | ||
| 2897 | Layer.provideMerge(layerTestHttpClient), | ||
| 2898 | Layer.provideMerge( | ||
| 2899 | Layer.succeed( | ||
| 2900 | ProviderEventLoggers.ProviderEventLoggers, | ||
| 2901 | ProviderEventLoggers.NoOpProviderEventLoggers, | ||
| 2902 | ), | ||
| 2903 | ), | ||
| 2904 | Layer.provideMerge(ModelManifest.layerTest), | ||
| 2905 | Layer.provideMerge(ResetCreditCoordinator.layerTest), | ||
| 2906 | Layer.provideMerge( | ||
| 2907 | OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), | ||
| 2908 | ), | ||
| 2909 | Layer.provideMerge(layerBackgroundPolicyAlwaysRun), | ||
| 2910 | Layer.provideMerge( | ||
| 2911 | layerMockCommandSpawner((command, args) => { | ||
| 2912 | if (command === "cursor-agent") { | ||
| 2913 | cursorSpawned = true; | ||
| 2914 | } | ||
| 2915 | const joined = args.join(" "); | ||
| 2916 | if (joined === "--version") { | ||
| 2917 | return { | ||
| 2918 | stdout: `${command} 1.0.0\n`, | ||
| 2919 | stderr: "", | ||
| 2920 | code: 0, | ||
| 2921 | }; | ||
| 2922 | } | ||
| 2923 | if (joined === "auth status") { | ||
| 2924 | return { | ||
| 2925 | stdout: '{"authenticated":true}\n', | ||
| 2926 | stderr: "", | ||
| 2927 | code: 0, | ||
| 2928 | }; | ||
| 2929 | } | ||
| 2930 | throw new Error(`Unexpected args: ${command} ${joined}`); | ||
| 2931 | }), | ||
| 2932 | ), | ||
| 2933 | ); | ||
| 2934 | const runtimeServices = yield* Layer.build( | ||
| 2935 | Layer.mergeAll( | ||
| 2936 | Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), | ||
| 2937 | layerProviderRegistry, | ||
| 2938 | ), | ||
| 2939 | ).pipe(Scope.provide(scope)); | ||
| 2940 | |||
| 2941 | yield* Effect.gen(function* () { | ||
| 2942 | const registry = yield* ProviderRegistry.ProviderRegistry; | ||
| 2943 | const providers = yield* registry.getProviders; | ||
| 2944 | const cursorProvider = providers.find( | ||
| 2945 | (provider) => provider.instanceId === ProviderInstanceId.make("cursor"), | ||
| 2946 | ); | ||
| 2947 | |||
| 2948 | assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ | ||
| 2949 | "antigravity", | ||
| 2950 | "claudeAgent", | ||
| 2951 | "codex", | ||
| 2952 | "cursor", | ||
| 2953 | "grok", | ||
| 2954 | "opencode", | ||
| 2955 | "pi", | ||
| 2956 | ]); | ||
| 2957 | assert.strictEqual(cursorProvider?.enabled, false); | ||
| 2958 | assert.strictEqual(cursorProvider?.status, "disabled"); | ||
| 2959 | assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); | ||
| 2960 | assert.strictEqual(cursorSpawned, false); | ||
| 2961 | }).pipe(Effect.provide(runtimeServices)); | ||
| 2962 | }), | ||
| 2963 | ); | ||
| 2964 | |||
| 2965 | it.effect("skips codex probes entirely when the provider is disabled", () => | ||
| 2966 | Effect.gen(function* () { | ||
| 2967 | const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( | ||
| 2968 | Effect.provide(layerFailingSpawner("spawn codex ENOENT")), | ||
| 2969 | ); | ||
| 2970 | assert.strictEqual(status.enabled, false); | ||
| 2971 | assert.strictEqual(status.status, "disabled"); | ||
| 2972 | assert.strictEqual(status.installed, false); | ||
| 2973 | assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); | ||
| 2974 | }), | ||
| 2975 | ); | ||
| 2976 | }); | ||
| 2977 | |||
| 2978 | // ── checkClaudeProviderStatus tests ────────────────────────── | ||
| 2979 | |||
| 2980 | describe("checkClaudeProviderStatus", () => { | ||
| 2981 | it.effect("returns ready when claude is installed and authenticated", () => | ||
| 2982 | Effect.gen(function* () { | ||
| 2983 | const status = yield* checkClaudeProviderStatus( | ||
| 2984 | defaultClaudeSettings, | ||
| 2985 | claudeCapabilities(), | ||
| 2986 | ); | ||
| 2987 | assert.strictEqual(status.status, "ready"); | ||
| 2988 | assert.strictEqual(status.installed, true); | ||
| 2989 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 2990 | }).pipe( | ||
| 2991 | Effect.provide( | ||
| 2992 | layerMockSpawner((args) => { | ||
| 2993 | const joined = args.join(" "); | ||
| 2994 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 2995 | if (joined === "auth status") | ||
| 2996 | return { | ||
| 2997 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 2998 | stderr: "", | ||
| 2999 | code: 0, | ||
| 3000 | }; | ||
| 3001 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3002 | }), | ||
| 3003 | ), | ||
| 3004 | ), | ||
| 3005 | ); | ||
| 3006 | |||
| 3007 | it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => | ||
| 3008 | Effect.gen(function* () { | ||
| 3009 | // Bedrock authenticates via external AWS credentials, so the SDK init | ||
| 3010 | // reports only `apiProvider` with no subscription or token. | ||
| 3011 | const status = yield* checkClaudeProviderStatus( | ||
| 3012 | defaultClaudeSettings, | ||
| 3013 | claudeCapabilities({ apiProvider: "bedrock" }), | ||
| 3014 | ); | ||
| 3015 | assert.strictEqual(status.status, "ready"); | ||
| 3016 | assert.strictEqual(status.installed, true); | ||
| 3017 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3018 | assert.strictEqual(status.auth.type, "bedrock"); | ||
| 3019 | assert.strictEqual(status.auth.label, "Amazon Bedrock"); | ||
| 3020 | }).pipe( | ||
| 3021 | Effect.provide( | ||
| 3022 | layerMockSpawner((args) => { | ||
| 3023 | const joined = args.join(" "); | ||
| 3024 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3025 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3026 | }), | ||
| 3027 | ), | ||
| 3028 | ), | ||
| 3029 | ); | ||
| 3030 | |||
| 3031 | it.effect("returns a display label for claude subscription types", () => | ||
| 3032 | Effect.gen(function* () { | ||
| 3033 | const status = yield* checkClaudeProviderStatus( | ||
| 3034 | defaultClaudeSettings, | ||
| 3035 | claudeCapabilities({ subscriptionType: "maxplan" }), | ||
| 3036 | ); | ||
| 3037 | assert.strictEqual(status.status, "ready"); | ||
| 3038 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3039 | assert.strictEqual(status.auth.type, "maxplan"); | ||
| 3040 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3041 | }).pipe( | ||
| 3042 | Effect.provide( | ||
| 3043 | layerMockSpawner((args) => { | ||
| 3044 | const joined = args.join(" "); | ||
| 3045 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3046 | if (joined === "auth status") | ||
| 3047 | return { | ||
| 3048 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3049 | stderr: "", | ||
| 3050 | code: 0, | ||
| 3051 | }; | ||
| 3052 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3053 | }), | ||
| 3054 | ), | ||
| 3055 | ), | ||
| 3056 | ); | ||
| 3057 | |||
| 3058 | it.effect("reads banked resets only for subscription logins", () => | ||
| 3059 | Effect.gen(function* () { | ||
| 3060 | const check = (overrides: Partial<TestClaudeCapabilities>) => | ||
| 3061 | checkClaudeProviderStatus( | ||
| 3062 | defaultClaudeSettings, | ||
| 3063 | () => | ||
| 3064 | Effect.succeed({ | ||
| 3065 | email: undefined, | ||
| 3066 | subscriptionType: undefined, | ||
| 3067 | tokenSource: undefined, | ||
| 3068 | apiProvider: undefined, | ||
| 3069 | slashCommands: [], | ||
| 3070 | usage: { rate_limits_available: true, rate_limits: {} }, | ||
| 3071 | ...overrides, | ||
| 3072 | }), | ||
| 3073 | undefined, | ||
| 3074 | undefined, | ||
| 3075 | undefined, | ||
| 3076 | undefined, | ||
| 3077 | () => Effect.succeed({ availableCount: 2 }), | ||
| 3078 | ); | ||
| 3079 | const subscription = yield* check({ subscriptionType: "max" }); | ||
| 3080 | const bedrock = yield* check({ apiProvider: "bedrock" }); | ||
| 3081 | assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); | ||
| 3082 | assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); | ||
| 3083 | }).pipe( | ||
| 3084 | Effect.provide( | ||
| 3085 | layerMockSpawner((args) => { | ||
| 3086 | const joined = args.join(" "); | ||
| 3087 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3088 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3089 | }), | ||
| 3090 | ), | ||
| 3091 | ), | ||
| 3092 | ); | ||
| 3093 | |||
| 3094 | it.effect("does not duplicate Claude in full subscription labels", () => | ||
| 3095 | Effect.gen(function* () { | ||
| 3096 | const status = yield* checkClaudeProviderStatus( | ||
| 3097 | defaultClaudeSettings, | ||
| 3098 | claudeCapabilities({ | ||
| 3099 | subscriptionType: "Claude Max Subscription", | ||
| 3100 | }), | ||
| 3101 | ); | ||
| 3102 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3103 | assert.strictEqual(status.auth.type, "Claude Max Subscription"); | ||
| 3104 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3105 | }).pipe( | ||
| 3106 | Effect.provide( | ||
| 3107 | layerMockSpawner((args) => { | ||
| 3108 | const joined = args.join(" "); | ||
| 3109 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3110 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3111 | }), | ||
| 3112 | ), | ||
| 3113 | ), | ||
| 3114 | ); | ||
| 3115 | |||
| 3116 | it.effect("does not duplicate Claude in provider-prefixed subscription names", () => | ||
| 3117 | Effect.gen(function* () { | ||
| 3118 | const status = yield* checkClaudeProviderStatus( | ||
| 3119 | defaultClaudeSettings, | ||
| 3120 | claudeCapabilities({ | ||
| 3121 | subscriptionType: "Claude Max", | ||
| 3122 | }), | ||
| 3123 | ); | ||
| 3124 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3125 | assert.strictEqual(status.auth.type, "Claude Max"); | ||
| 3126 | assert.strictEqual(status.auth.label, "Claude Max Subscription"); | ||
| 3127 | }).pipe( | ||
| 3128 | Effect.provide( | ||
| 3129 | layerMockSpawner((args) => { | ||
| 3130 | const joined = args.join(" "); | ||
| 3131 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3132 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3133 | }), | ||
| 3134 | ), | ||
| 3135 | ), | ||
| 3136 | ); | ||
| 3137 | |||
| 3138 | it.effect("returns claude auth email from initialization result", () => | ||
| 3139 | Effect.gen(function* () { | ||
| 3140 | const status = yield* checkClaudeProviderStatus( | ||
| 3141 | defaultClaudeSettings, | ||
| 3142 | claudeCapabilities({ email: "claude@example.com" }), | ||
| 3143 | ); | ||
| 3144 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3145 | assert.strictEqual(status.auth.email, "claude@example.com"); | ||
| 3146 | }).pipe( | ||
| 3147 | Effect.provide( | ||
| 3148 | layerMockSpawner((args) => { | ||
| 3149 | const joined = args.join(" "); | ||
| 3150 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3151 | if (joined === "auth status") | ||
| 3152 | return { | ||
| 3153 | stdout: | ||
| 3154 | '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', | ||
| 3155 | stderr: "", | ||
| 3156 | code: 0, | ||
| 3157 | }; | ||
| 3158 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3159 | }), | ||
| 3160 | ), | ||
| 3161 | ), | ||
| 3162 | ); | ||
| 3163 | |||
| 3164 | it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { | ||
| 3165 | const claudeConfigDir = "/tmp/t3code-claude-home"; | ||
| 3166 | const recorded = recordingMockSpawnerLayer((args) => { | ||
| 3167 | const joined = args.join(" "); | ||
| 3168 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3169 | if (joined === "auth status") | ||
| 3170 | return { | ||
| 3171 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3172 | stderr: "", | ||
| 3173 | code: 0, | ||
| 3174 | }; | ||
| 3175 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3176 | }); | ||
| 3177 | |||
| 3178 | return Effect.gen(function* () { | ||
| 3179 | const status = yield* checkClaudeProviderStatus( | ||
| 3180 | { | ||
| 3181 | ...defaultClaudeSettings, | ||
| 3182 | homePath: claudeConfigDir, | ||
| 3183 | }, | ||
| 3184 | claudeCapabilities(), | ||
| 3185 | ); | ||
| 3186 | assert.strictEqual(status.status, "ready"); | ||
| 3187 | // The home is resolved through the host Path before it reaches the env. | ||
| 3188 | assert.deepStrictEqual( | ||
| 3189 | recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), | ||
| 3190 | [(yield* Path.Path).resolve(claudeConfigDir)], | ||
| 3191 | ); | ||
| 3192 | }).pipe(Effect.provide(recorded.layer)); | ||
| 3193 | }); | ||
| 3194 | |||
| 3195 | it.effect("includes probed claude slash commands in the provider snapshot", () => | ||
| 3196 | Effect.gen(function* () { | ||
| 3197 | const status = yield* checkClaudeProviderStatus( | ||
| 3198 | defaultClaudeSettings, | ||
| 3199 | claudeCapabilities({ | ||
| 3200 | subscriptionType: "maxplan", | ||
| 3201 | slashCommands: [ | ||
| 3202 | { | ||
| 3203 | name: "review", | ||
| 3204 | description: "Review a pull request", | ||
| 3205 | input: { hint: "pr-or-branch" }, | ||
| 3206 | }, | ||
| 3207 | ], | ||
| 3208 | }), | ||
| 3209 | ); | ||
| 3210 | |||
| 3211 | assert.deepStrictEqual(status.slashCommands.slice(1), [ | ||
| 3212 | { | ||
| 3213 | name: "review", | ||
| 3214 | description: "Review a pull request", | ||
| 3215 | input: { hint: "pr-or-branch" }, | ||
| 3216 | }, | ||
| 3217 | ]); | ||
| 3218 | }).pipe( | ||
| 3219 | Effect.provide( | ||
| 3220 | layerMockSpawner((args) => { | ||
| 3221 | const joined = args.join(" "); | ||
| 3222 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3223 | if (joined === "auth status") | ||
| 3224 | return { | ||
| 3225 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3226 | stderr: "", | ||
| 3227 | code: 0, | ||
| 3228 | }; | ||
| 3229 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3230 | }), | ||
| 3231 | ), | ||
| 3232 | ), | ||
| 3233 | ); | ||
| 3234 | |||
| 3235 | it.effect("deduplicates probed claude slash commands by name", () => | ||
| 3236 | Effect.gen(function* () { | ||
| 3237 | const status = yield* checkClaudeProviderStatus( | ||
| 3238 | defaultClaudeSettings, | ||
| 3239 | claudeCapabilities({ | ||
| 3240 | subscriptionType: "maxplan", | ||
| 3241 | slashCommands: [ | ||
| 3242 | { | ||
| 3243 | name: "ui", | ||
| 3244 | description: "Explore and refine UI", | ||
| 3245 | }, | ||
| 3246 | { | ||
| 3247 | name: "ui", | ||
| 3248 | input: { hint: "component-or-screen" }, | ||
| 3249 | }, | ||
| 3250 | ], | ||
| 3251 | }), | ||
| 3252 | ); | ||
| 3253 | |||
| 3254 | assert.deepStrictEqual(status.slashCommands, [ | ||
| 3255 | COMPACT_SLASH_COMMAND, | ||
| 3256 | { | ||
| 3257 | name: "ui", | ||
| 3258 | description: "Explore and refine UI", | ||
| 3259 | input: { hint: "component-or-screen" }, | ||
| 3260 | }, | ||
| 3261 | ]); | ||
| 3262 | }).pipe( | ||
| 3263 | Effect.provide( | ||
| 3264 | layerMockSpawner((args) => { | ||
| 3265 | const joined = args.join(" "); | ||
| 3266 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3267 | if (joined === "auth status") | ||
| 3268 | return { | ||
| 3269 | stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', | ||
| 3270 | stderr: "", | ||
| 3271 | code: 0, | ||
| 3272 | }; | ||
| 3273 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3274 | }), | ||
| 3275 | ), | ||
| 3276 | ), | ||
| 3277 | ); | ||
| 3278 | |||
| 3279 | it.effect("returns an api key label for claude api key auth", () => | ||
| 3280 | Effect.gen(function* () { | ||
| 3281 | const status = yield* checkClaudeProviderStatus( | ||
| 3282 | defaultClaudeSettings, | ||
| 3283 | claudeCapabilities({ tokenSource: "ANTHROPIC_AUTH_TOKEN" }), | ||
| 3284 | ); | ||
| 3285 | assert.strictEqual(status.status, "ready"); | ||
| 3286 | assert.strictEqual(status.auth.status, "authenticated"); | ||
| 3287 | assert.strictEqual(status.auth.type, "apiKey"); | ||
| 3288 | assert.strictEqual(status.auth.label, "Claude API Key"); | ||
| 3289 | }).pipe( | ||
| 3290 | Effect.provide( | ||
| 3291 | layerMockSpawner((args) => { | ||
| 3292 | const joined = args.join(" "); | ||
| 3293 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3294 | if (joined === "auth status") | ||
| 3295 | return { | ||
| 3296 | stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', | ||
| 3297 | stderr: "", | ||
| 3298 | code: 0, | ||
| 3299 | }; | ||
| 3300 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3301 | }), | ||
| 3302 | ), | ||
| 3303 | ), | ||
| 3304 | ); | ||
| 3305 | |||
| 3306 | it.effect("returns unavailable when claude is missing", () => | ||
| 3307 | Effect.gen(function* () { | ||
| 3308 | const status = yield* checkClaudeProviderStatus( | ||
| 3309 | defaultClaudeSettings, | ||
| 3310 | claudeCapabilities(), | ||
| 3311 | ); | ||
| 3312 | assert.strictEqual(status.status, "error"); | ||
| 3313 | assert.strictEqual(status.installed, false); | ||
| 3314 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 3315 | assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); | ||
| 3316 | }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), | ||
| 3317 | ); | ||
| 3318 | |||
| 3319 | it.effect("returns error when version check fails with non-zero exit code", () => { | ||
| 3320 | const secretStderr = "Something went wrong: secret-token-value"; | ||
| 3321 | return Effect.gen(function* () { | ||
| 3322 | const status = yield* checkClaudeProviderStatus( | ||
| 3323 | defaultClaudeSettings, | ||
| 3324 | claudeCapabilities(), | ||
| 3325 | ); | ||
| 3326 | assert.strictEqual(status.status, "error"); | ||
| 3327 | assert.strictEqual(status.installed, true); | ||
| 3328 | assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); | ||
| 3329 | assert.ok(!(status.message ?? "").includes(secretStderr)); | ||
| 3330 | }).pipe( | ||
| 3331 | Effect.provide( | ||
| 3332 | layerMockSpawner((args) => { | ||
| 3333 | const joined = args.join(" "); | ||
| 3334 | if (joined === "--version") | ||
| 3335 | return { | ||
| 3336 | stdout: "", | ||
| 3337 | stderr: secretStderr, | ||
| 3338 | code: 1, | ||
| 3339 | }; | ||
| 3340 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3341 | }), | ||
| 3342 | ), | ||
| 3343 | ); | ||
| 3344 | }); | ||
| 3345 | |||
| 3346 | it.effect("returns warning when the Claude initialization result is unavailable", () => | ||
| 3347 | Effect.gen(function* () { | ||
| 3348 | const status = yield* checkClaudeProviderStatus( | ||
| 3349 | defaultClaudeSettings, | ||
| 3350 | noClaudeCapabilities, | ||
| 3351 | ); | ||
| 3352 | assert.strictEqual(status.status, "warning"); | ||
| 3353 | assert.strictEqual(status.installed, true); | ||
| 3354 | assert.strictEqual(status.auth.status, "unknown"); | ||
| 3355 | assert.strictEqual( | ||
| 3356 | status.message, | ||
| 3357 | "Could not verify Claude authentication status from initialization result.", | ||
| 3358 | ); | ||
| 3359 | }).pipe( | ||
| 3360 | Effect.provide( | ||
| 3361 | layerMockSpawner((args) => { | ||
| 3362 | const joined = args.join(" "); | ||
| 3363 | if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; | ||
| 3364 | if (joined === "auth status") | ||
| 3365 | return { | ||
| 3366 | stdout: '{"loggedIn":false}\n', | ||
| 3367 | stderr: "", | ||
| 3368 | code: 1, | ||
| 3369 | }; | ||
| 3370 | throw new Error(`Unexpected args: ${joined}`); | ||
| 3371 | }), | ||
| 3372 | ), | ||
| 3373 | ), | ||
| 3374 | ); | ||
| 3375 | }); | ||
| 3376 | }); | ||
apps/server/src/provider/ProviderRegistry.ts created+1086| ... | @@ -0,0 +1,1086 @@ | ||
| 1 | /** | ||
| 2 | * ProviderRegistry - Provider snapshot service. | ||
| 3 | * | ||
| 4 | * Owns provider install/auth/version/model snapshots and exposes the latest | ||
| 5 | * provider state to transport layers. | ||
| 6 | * | ||
| 7 | * The live layer aggregates per-instance snapshot streams into a | ||
| 8 | * single materialized list. | ||
| 9 | * | ||
| 10 | * Historically this Layer composed four per-kind Live Layers | ||
| 11 | * (`CodexProviderLive`, `ClaudeProviderLive`, …) that each exposed a | ||
| 12 | * `ServerProviderShape`. Those Lives were deleted during the driver / | ||
| 13 | * instance refactor — every driver now carries its `snapshot: ServerProviderShape` | ||
| 14 | * bundled onto the `ProviderInstance` the registry produces. | ||
| 15 | * | ||
| 16 | * Each configured instance (including multi-instance setups like | ||
| 17 | * `codex_personal` + `codex_work`) contributes one `ProviderSnapshotSource`, | ||
| 18 | * keyed by `instanceId`. Instances whose driver is unavailable or whose | ||
| 19 | * config failed to decode are merged from `instanceRegistry.listUnavailable` | ||
| 20 | * as shadow snapshots so the UI can render their exact unavailable reason. | ||
| 21 | * | ||
| 22 | * Cache paths on disk are now keyed by `instanceId`. Because | ||
| 23 | * `defaultInstanceIdForDriver(kind) === kind` for built-in kinds, existing | ||
| 24 | * `<kind>.json` files remain the on-disk location for that driver's default | ||
| 25 | * instance. Identity-less legacy cache contents are ignored and replaced by | ||
| 26 | * the first live refresh. | ||
| 27 | * | ||
| 28 | * @module ProviderRegistry | ||
| 29 | */ | ||
| 30 | import { | ||
| 31 | defaultInstanceIdForDriver, | ||
| 32 | ProviderDriverKind, | ||
| 33 | type ProviderInstanceId, | ||
| 34 | type ServerProvider, | ||
| 35 | type ServerProviderUpdateState, | ||
| 36 | } from "@t3tools/contracts"; | ||
| 37 | import * as Cause from "effect/Cause"; | ||
| 38 | import * as Context from "effect/Context"; | ||
| 39 | import * as Effect from "effect/Effect"; | ||
| 40 | import * as Equal from "effect/Equal"; | ||
| 41 | import * as FileSystem from "effect/FileSystem"; | ||
| 42 | import * as Layer from "effect/Layer"; | ||
| 43 | import * as Path from "effect/Path"; | ||
| 44 | import * as PubSub from "effect/PubSub"; | ||
| 45 | import * as Ref from "effect/Ref"; | ||
| 46 | import * as Stream from "effect/Stream"; | ||
| 47 | import * as Semaphore from "effect/Semaphore"; | ||
| 48 | |||
| 49 | import * as ModelManifest from "./ModelManifest.ts"; | ||
| 50 | import { applyProviderCompatibility } from "./providerCompatibility.ts"; | ||
| 51 | import * as ServerConfig from "../config.ts"; | ||
| 52 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; | ||
| 53 | import { | ||
| 54 | hydrateCachedProvider, | ||
| 55 | isCachedProviderCorrelated, | ||
| 56 | orderProviderSnapshots, | ||
| 57 | readProviderStatusCache, | ||
| 58 | resolveProviderStatusCachePath, | ||
| 59 | writeProviderStatusCache, | ||
| 60 | } from "./providerStatusCache.ts"; | ||
| 61 | import type { ProviderInstance, ProviderWorkspaceSnapshot } from "./ProviderDriver.ts"; | ||
| 62 | import { | ||
| 63 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 64 | type ProviderMaintenanceCapabilities, | ||
| 65 | } from "./providerMaintenance.ts"; | ||
| 66 | import type { ProviderSnapshotSource } from "./builtInProviderCatalog.ts"; | ||
| 67 | |||
| 68 | export type ProviderMaintenanceActionKind = "update"; | ||
| 69 | |||
| 70 | export class ProviderRegistry extends Context.Service< | ||
| 71 | ProviderRegistry, | ||
| 72 | { | ||
| 73 | /** | ||
| 74 | * Read the latest provider snapshots for every configured instance. | ||
| 75 | * Multiple snapshots may share the same `provider` kind (multiple | ||
| 76 | * instances of the same driver) and disambiguate via `instanceId`. | ||
| 77 | */ | ||
| 78 | readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 79 | |||
| 80 | /** | ||
| 81 | * Refresh all providers, or the default instance of the specified | ||
| 82 | * kind when supplied. | ||
| 83 | * | ||
| 84 | * Retained for back-compat with legacy call sites (WS refresh RPC, | ||
| 85 | * orchestration metrics). New code should prefer `refreshInstance`. | ||
| 86 | * | ||
| 87 | * @deprecated prefer `refreshInstance` for new call sites. | ||
| 88 | */ | ||
| 89 | readonly refresh: ( | ||
| 90 | provider?: ProviderDriverKind, | ||
| 91 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 92 | |||
| 93 | /** | ||
| 94 | * Refresh the specific configured instance. Returns the updated snapshot | ||
| 95 | * list. When the instance id is unknown the call resolves with the | ||
| 96 | * currently cached list (no error) — matching the legacy `refresh` shim | ||
| 97 | * behaviour so transport layers don't have to special-case unknowns. | ||
| 98 | */ | ||
| 99 | readonly refreshInstance: ( | ||
| 100 | instanceId: ProviderInstanceId, | ||
| 101 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 102 | |||
| 103 | /** | ||
| 104 | * Fill the skills and slash commands snapshot for one cwd. A cwd that | ||
| 105 | * already has a snapshot is left alone unless `fresh` is set. A fresh scan | ||
| 106 | * also refreshes the instance's machine snapshot and drops other | ||
| 107 | * instances' snapshots for the cwd. | ||
| 108 | */ | ||
| 109 | readonly refreshWorkspaceSnapshot: (input: { | ||
| 110 | readonly instanceId: ProviderInstanceId; | ||
| 111 | readonly cwd: string; | ||
| 112 | readonly fresh?: boolean; | ||
| 113 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 114 | |||
| 115 | /** | ||
| 116 | * Resolve the maintenance capabilities owned by one live provider instance. | ||
| 117 | * Falls back to manual-only capabilities when the instance is not live. | ||
| 118 | * `fresh` re-derives ownership from the executable instead of the cache. | ||
| 119 | */ | ||
| 120 | readonly getProviderMaintenanceCapabilitiesForInstance: ( | ||
| 121 | instanceId: ProviderInstanceId, | ||
| 122 | provider: ProviderDriverKind, | ||
| 123 | options?: { readonly fresh?: boolean }, | ||
| 124 | ) => Effect.Effect<ProviderMaintenanceCapabilities>; | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Apply volatile maintenance-action state to one configured instance. | ||
| 128 | * This state is never persisted to disk. Today only update actions are | ||
| 129 | * projected onto `ServerProvider.updateState`; install/auth actions can | ||
| 130 | * extend this action map without adding driver-scoped APIs. | ||
| 131 | */ | ||
| 132 | readonly setProviderMaintenanceActionState: (input: { | ||
| 133 | readonly instanceId: ProviderInstanceId; | ||
| 134 | readonly action: ProviderMaintenanceActionKind; | ||
| 135 | readonly state: ServerProviderUpdateState | null; | ||
| 136 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 137 | |||
| 138 | /** | ||
| 139 | * Stream of provider snapshot updates — one emission per aggregated | ||
| 140 | * change. The array contains the full current state. | ||
| 141 | */ | ||
| 142 | readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>; | ||
| 143 | } | ||
| 144 | >()("t3/provider/ProviderRegistry") {} | ||
| 145 | |||
| 146 | const loadProviders = ( | ||
| 147 | providerSources: ReadonlyArray<ProviderSnapshotSource>, | ||
| 148 | ): Effect.Effect<ReadonlyArray<ServerProvider>> => | ||
| 149 | Effect.forEach( | ||
| 150 | providerSources, | ||
| 151 | (providerSource) => | ||
| 152 | providerSource.getSnapshot.pipe( | ||
| 153 | Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), | ||
| 154 | ), | ||
| 155 | { | ||
| 156 | concurrency: "unbounded", | ||
| 157 | }, | ||
| 158 | ); | ||
| 159 | |||
| 160 | const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) => | ||
| 161 | makeManualOnlyProviderMaintenanceCapabilities({ | ||
| 162 | provider, | ||
| 163 | packageName: null, | ||
| 164 | }); | ||
| 165 | |||
| 166 | const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => | ||
| 167 | (model.capabilities?.optionDescriptors?.length ?? 0) > 0; | ||
| 168 | |||
| 169 | const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; | ||
| 170 | |||
| 171 | function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider { | ||
| 172 | return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd) | ||
| 173 | ? { | ||
| 174 | ...provider, | ||
| 175 | workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd), | ||
| 176 | } | ||
| 177 | : provider; | ||
| 178 | } | ||
| 179 | |||
| 180 | export function upsertProviderWorkspaceSnapshot( | ||
| 181 | provider: ServerProvider, | ||
| 182 | cwd: string, | ||
| 183 | scopedSnapshot: ProviderWorkspaceSnapshot, | ||
| 184 | ): ServerProvider { | ||
| 185 | const workspaceSnapshot = { | ||
| 186 | cwd, | ||
| 187 | checkedAt: scopedSnapshot.checkedAt, | ||
| 188 | slashCommands: scopedSnapshot.slashCommandsPending | ||
| 189 | ? (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd)?.slashCommands ?? | ||
| 190 | scopedSnapshot.slashCommands) | ||
| 191 | : scopedSnapshot.slashCommands, | ||
| 192 | ...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}), | ||
| 193 | skills: scopedSnapshot.skills, | ||
| 194 | } satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number]; | ||
| 195 | return { | ||
| 196 | ...provider, | ||
| 197 | workspaceSnapshots: [ | ||
| 198 | ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), | ||
| 199 | workspaceSnapshot, | ||
| 200 | ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER), | ||
| 201 | }; | ||
| 202 | } | ||
| 203 | |||
| 204 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { | ||
| 205 | if (provider.driver === ProviderDriverKind.make("acpRegistry")) { | ||
| 206 | // ACP Registry discovery probes return the agent's complete inventory, so | ||
| 207 | // a completed probe (ready and authenticated) replaces the model list — | ||
| 208 | // otherwise agents that rename or collapse models leave stale entries | ||
| 209 | // pinned forever through the snapshot cache. Readiness-only and failed | ||
| 210 | // probe snapshots only know the "default" placeholder and stay partial. | ||
| 211 | return !( | ||
| 212 | provider.installed && | ||
| 213 | provider.status === "ready" && | ||
| 214 | provider.auth.status === "authenticated" | ||
| 215 | ); | ||
| 216 | } | ||
| 217 | |||
| 218 | const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); | ||
| 219 | const isCodex = provider.driver === ProviderDriverKind.make("codex"); | ||
| 220 | if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { | ||
| 221 | return true; | ||
| 222 | } | ||
| 223 | |||
| 224 | if ( | ||
| 225 | (isAntigravity || isCodex) && | ||
| 226 | (!provider.enabled || provider.auth.status === "unauthenticated") | ||
| 227 | ) { | ||
| 228 | return false; | ||
| 229 | } | ||
| 230 | |||
| 231 | // Successful discovery replaces these inventories so cached retired models disappear. | ||
| 232 | // Antigravity's local health check does not authenticate or discover models. | ||
| 233 | const isPendingAntigravityAuthentication = | ||
| 234 | isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; | ||
| 235 | const isPendingInitialProbe = | ||
| 236 | provider.enabled && !provider.installed && provider.status === "warning"; | ||
| 237 | const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; | ||
| 238 | return ( | ||
| 239 | isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail | ||
| 240 | ); | ||
| 241 | }; | ||
| 242 | |||
| 243 | const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => | ||
| 244 | provider.driver === ProviderDriverKind.make("opencode") && | ||
| 245 | shouldRetainMissingProviderModels(provider); | ||
| 246 | |||
| 247 | const mergeProviderModels = ( | ||
| 248 | provider: ServerProvider, | ||
| 249 | previousModels: ReadonlyArray<ServerProvider["models"][number]>, | ||
| 250 | nextModels: ReadonlyArray<ServerProvider["models"][number]>, | ||
| 251 | ): ReadonlyArray<ServerProvider["models"][number]> => { | ||
| 252 | const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); | ||
| 253 | // Custom rows are derived from settings and every snapshot carries the full | ||
| 254 | // current list, so a custom model missing from `nextModels` was removed by | ||
| 255 | // the user and must not be resurrected from the previous snapshot. | ||
| 256 | const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); | ||
| 257 | |||
| 258 | if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { | ||
| 259 | return retainablePreviousModels; | ||
| 260 | } | ||
| 261 | |||
| 262 | const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); | ||
| 263 | const mergedModels = nextModels.map((model) => { | ||
| 264 | const previousModel = previousBySlug.get(model.slug); | ||
| 265 | if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { | ||
| 266 | return model; | ||
| 267 | } | ||
| 268 | return { | ||
| 269 | ...model, | ||
| 270 | capabilities: previousModel.capabilities, | ||
| 271 | }; | ||
| 272 | }); | ||
| 273 | const nextSlugs = new Set(nextModels.map((model) => model.slug)); | ||
| 274 | return shouldRetainMissingModels | ||
| 275 | ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] | ||
| 276 | : mergedModels; | ||
| 277 | }; | ||
| 278 | |||
| 279 | /** | ||
| 280 | * Antigravity's health check only initializes the agent, so after a server | ||
| 281 | * restart it reports the account as unchecked. The saved Google login still | ||
| 282 | * works, and the previous snapshot proves it. Carry that account state until | ||
| 283 | * a session, refresh, or sign-out reports something new. A confirmed missing | ||
| 284 | * installation, sign-out, disabled instance, or a changed sign-in method is | ||
| 285 | * never overridden. | ||
| 286 | */ | ||
| 287 | const carrySavedAntigravityAccount = ( | ||
| 288 | previousProvider: ServerProvider, | ||
| 289 | nextProvider: ServerProvider, | ||
| 290 | ): Pick<ServerProvider, "auth" | "status"> | undefined => { | ||
| 291 | const antigravity = ProviderDriverKind.make("antigravity"); | ||
| 292 | if ( | ||
| 293 | nextProvider.driver !== antigravity || | ||
| 294 | previousProvider.driver !== antigravity || | ||
| 295 | !nextProvider.enabled || | ||
| 296 | nextProvider.auth.status !== "unknown" || | ||
| 297 | previousProvider.auth.status !== "authenticated" || | ||
| 298 | (nextProvider.auth.type !== undefined && | ||
| 299 | nextProvider.auth.type !== previousProvider.auth.type) || | ||
| 300 | (!nextProvider.installed && nextProvider.status !== "warning") | ||
| 301 | ) { | ||
| 302 | return undefined; | ||
| 303 | } | ||
| 304 | // The pending boot probe (`installed: false`, warning) and a failed probe | ||
| 305 | // keep their own status; only a passed health check reads as ready. | ||
| 306 | const status = | ||
| 307 | nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; | ||
| 308 | return { auth: previousProvider.auth, status }; | ||
| 309 | }; | ||
| 310 | |||
| 311 | export const mergeProviderSnapshot = ( | ||
| 312 | previousProvider: ServerProvider | undefined, | ||
| 313 | nextProvider: ServerProvider, | ||
| 314 | ): ServerProvider => { | ||
| 315 | if (!previousProvider) { | ||
| 316 | return nextProvider; | ||
| 317 | } | ||
| 318 | const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); | ||
| 319 | // "Google account access is not checked yet" describes the probe, not the | ||
| 320 | // account; it must not outlive the state it explained. | ||
| 321 | const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; | ||
| 322 | return { | ||
| 323 | ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), | ||
| 324 | ...savedAccount, | ||
| 325 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), | ||
| 326 | ...(nextProvider.workspaceSnapshots !== undefined | ||
| 327 | ? { workspaceSnapshots: nextProvider.workspaceSnapshots } | ||
| 328 | : previousProvider.workspaceSnapshots !== undefined | ||
| 329 | ? { workspaceSnapshots: previousProvider.workspaceSnapshots } | ||
| 330 | : {}), | ||
| 331 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) | ||
| 332 | ? { | ||
| 333 | slashCommands: | ||
| 334 | nextProvider.slashCommands.length === 0 | ||
| 335 | ? previousProvider.slashCommands | ||
| 336 | : nextProvider.slashCommands, | ||
| 337 | skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, | ||
| 338 | } | ||
| 339 | : {}), | ||
| 340 | }; | ||
| 341 | }; | ||
| 342 | |||
| 343 | export const mergeProviderSnapshots = ( | ||
| 344 | previousProviders: ReadonlyArray<ServerProvider>, | ||
| 345 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 346 | ): ReadonlyArray<ServerProvider> => { | ||
| 347 | const mergedProviders = new Map( | ||
| 348 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | ||
| 349 | ); | ||
| 350 | |||
| 351 | for (const provider of nextProviders) { | ||
| 352 | mergedProviders.set( | ||
| 353 | snapshotInstanceKey(provider), | ||
| 354 | mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), | ||
| 355 | ); | ||
| 356 | } | ||
| 357 | |||
| 358 | return orderProviderSnapshots([...mergedProviders.values()]); | ||
| 359 | }; | ||
| 360 | |||
| 361 | export const selectProvidersByKind = ( | ||
| 362 | providers: ReadonlyArray<ServerProvider>, | ||
| 363 | providerKinds: ReadonlySet<ProviderDriverKind>, | ||
| 364 | ): ReadonlyArray<ServerProvider> => | ||
| 365 | providers.filter((provider) => providerKinds.has(provider.driver)); | ||
| 366 | |||
| 367 | const haveProvidersChanged = ( | ||
| 368 | previousProviders: ReadonlyArray<ServerProvider>, | ||
| 369 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 370 | ): boolean => !Equal.equals(previousProviders, nextProviders); | ||
| 371 | |||
| 372 | const correlateSnapshotWithSource = ( | ||
| 373 | source: ProviderSnapshotSource, | ||
| 374 | snapshot: ServerProvider, | ||
| 375 | ): Effect.Effect<ServerProvider> => { | ||
| 376 | if (snapshot.instanceId !== source.instanceId) { | ||
| 377 | return Effect.die( | ||
| 378 | new Error( | ||
| 379 | `Provider snapshot instance mismatch: source '${source.instanceId}' emitted '${snapshot.instanceId}'.`, | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | } | ||
| 383 | if (snapshot.driver !== source.driverKind) { | ||
| 384 | return Effect.die( | ||
| 385 | new Error( | ||
| 386 | `Provider snapshot driver mismatch for instance '${source.instanceId}': source '${source.driverKind}' emitted '${snapshot.driver}'.`, | ||
| 387 | ), | ||
| 388 | ); | ||
| 389 | } | ||
| 390 | return Effect.succeed(snapshot); | ||
| 391 | }; | ||
| 392 | |||
| 393 | /** | ||
| 394 | * Key a snapshot for aggregation and persistence. Snapshot sources | ||
| 395 | * must be correlated by instance id before reaching this map; missing | ||
| 396 | * identities are defects, not runtime routing fallbacks. | ||
| 397 | */ | ||
| 398 | const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { | ||
| 399 | return provider.instanceId; | ||
| 400 | }; | ||
| 401 | |||
| 402 | // Project a live `ProviderInstance` into the aggregator's consumption | ||
| 403 | // shape. Each call re-captures the instance's `snapshot` closures, so | ||
| 404 | // after `ProviderInstanceRegistry` rebuilds an instance (e.g. because | ||
| 405 | // its settings changed), a fresh source rides the new PubSub instead | ||
| 406 | // of a closed one. | ||
| 407 | const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ | ||
| 408 | instanceId: instance.instanceId, | ||
| 409 | driverKind: instance.driverKind, | ||
| 410 | getSnapshot: instance.snapshot.getSnapshot, | ||
| 411 | refresh: instance.snapshot.refresh, | ||
| 412 | streamChanges: instance.snapshot.streamChanges, | ||
| 413 | }); | ||
| 414 | |||
| 415 | export const layer = Layer.effect( | ||
| 416 | ProviderRegistry, | ||
| 417 | Effect.gen(function* () { | ||
| 418 | const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; | ||
| 419 | const manifestService = yield* ModelManifest.ModelManifest; | ||
| 420 | const serviceScope = yield* Effect.scope; | ||
| 421 | const config = yield* ServerConfig.ServerConfig; | ||
| 422 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 423 | const path = yield* Path.Path; | ||
| 424 | |||
| 425 | // Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for | ||
| 426 | // coalesced updates across every instance. | ||
| 427 | const changesPubSub = yield* Effect.acquireRelease( | ||
| 428 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), | ||
| 429 | PubSub.shutdown, | ||
| 430 | ); | ||
| 431 | |||
| 432 | // Boot-only: hydrate `providersRef` from the on-disk per-instance | ||
| 433 | // cache so the UI has something to render during the first refresh. | ||
| 434 | // Instances added post-boot skip this path; their first entry in | ||
| 435 | // `providersRef` comes from the reactive `syncLiveSources` pass | ||
| 436 | // below. | ||
| 437 | const bootInstances = yield* instanceRegistry.listInstances; | ||
| 438 | const bootSources = bootInstances.map(buildSnapshotSource); | ||
| 439 | const fallbackProviders = yield* loadProviders(bootSources); | ||
| 440 | const fallbackByInstance = new Map<ProviderInstanceId, ServerProvider>(); | ||
| 441 | for (let index = 0; index < fallbackProviders.length; index++) { | ||
| 442 | const provider = fallbackProviders[index]; | ||
| 443 | const source = bootSources[index]; | ||
| 444 | if (provider === undefined || source === undefined) { | ||
| 445 | continue; | ||
| 446 | } | ||
| 447 | fallbackByInstance.set(source.instanceId, provider); | ||
| 448 | } | ||
| 449 | |||
| 450 | const cachedProviders = yield* Effect.forEach( | ||
| 451 | bootSources, | ||
| 452 | (source) => | ||
| 453 | Effect.gen(function* () { | ||
| 454 | // One cache file per configured instance. For the default | ||
| 455 | // instance of a built-in kind the path equals `<kind>.json` — | ||
| 456 | // identical to the legacy filename. We still require the cache | ||
| 457 | // payload to carry matching instance id + driver kind; old | ||
| 458 | // identity-less payloads are discarded and the awaited refresh | ||
| 459 | // below repopulates the cache. | ||
| 460 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 461 | cacheDir: config.providerStatusCacheDir, | ||
| 462 | instanceId: source.instanceId, | ||
| 463 | }).pipe(Effect.provideService(Path.Path, path)); | ||
| 464 | const fallbackProvider = fallbackByInstance.get(source.instanceId); | ||
| 465 | if (fallbackProvider === undefined) { | ||
| 466 | return undefined; | ||
| 467 | } | ||
| 468 | return yield* readProviderStatusCache(filePath).pipe( | ||
| 469 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 470 | Effect.flatMap((cachedProvider) => { | ||
| 471 | if (cachedProvider === undefined) { | ||
| 472 | return Effect.void.pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 473 | } | ||
| 474 | const correlation = { | ||
| 475 | cachedProvider, | ||
| 476 | fallbackProvider, | ||
| 477 | } as const; | ||
| 478 | if (!isCachedProviderCorrelated(correlation)) { | ||
| 479 | return Effect.logWarning("provider status cache identity mismatch, ignoring", { | ||
| 480 | path: filePath, | ||
| 481 | instanceId: source.instanceId, | ||
| 482 | cachedInstanceId: cachedProvider.instanceId ?? null, | ||
| 483 | driver: source.driverKind, | ||
| 484 | cachedDriver: cachedProvider.driver ?? null, | ||
| 485 | }).pipe(Effect.as(undefined as ServerProvider | undefined)); | ||
| 486 | } | ||
| 487 | return Effect.succeed(hydrateCachedProvider(correlation)); | ||
| 488 | }), | ||
| 489 | ); | ||
| 490 | }), | ||
| 491 | { concurrency: "unbounded" }, | ||
| 492 | ).pipe( | ||
| 493 | Effect.map((providers) => | ||
| 494 | orderProviderSnapshots( | ||
| 495 | providers.filter((provider): provider is ServerProvider => provider !== undefined), | ||
| 496 | ), | ||
| 497 | ), | ||
| 498 | ); | ||
| 499 | const initialManifest = yield* manifestService.current; | ||
| 500 | const classifyCompatibility = ( | ||
| 501 | provider: ServerProvider, | ||
| 502 | manifest: ModelManifest.ModelManifestData, | ||
| 503 | ) => | ||
| 504 | applyProviderCompatibility( | ||
| 505 | provider, | ||
| 506 | manifest.compatibility, | ||
| 507 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | ||
| 508 | ); | ||
| 509 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | ||
| 510 | cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), | ||
| 511 | ); | ||
| 512 | const workspaceRefreshesRef = yield* Ref.make< | ||
| 513 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> | ||
| 514 | >(new Map()); | ||
| 515 | const maintenanceActionStatesRef = yield* Ref.make< | ||
| 516 | ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }> | ||
| 517 | >(new Map()); | ||
| 518 | |||
| 519 | // Live-source registry — the dynamic counterpart to the boot-time | ||
| 520 | // `bootSources`. Keyed by `instanceId`; the stored `ProviderInstance` | ||
| 521 | // reference is used for identity equality so "no-op" reconciles | ||
| 522 | // (settings unchanged) skip re-subscribing + re-probing. | ||
| 523 | const liveSubsRef = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstance>>( | ||
| 524 | new Map(), | ||
| 525 | ); | ||
| 526 | // Serialize `syncLiveSources` so a rapid burst of reconciles doesn't | ||
| 527 | // interleave two passes clobbering each other's fiber bookkeeping. | ||
| 528 | const syncSemaphore = yield* Semaphore.make(1); | ||
| 529 | |||
| 530 | const getLiveSources: Effect.Effect<ReadonlyArray<ProviderSnapshotSource>> = Ref.get( | ||
| 531 | liveSubsRef, | ||
| 532 | ).pipe(Effect.map((map) => Array.from(map.values(), buildSnapshotSource))); | ||
| 533 | |||
| 534 | const persistProvider = (provider: ServerProvider) => | ||
| 535 | Effect.gen(function* () { | ||
| 536 | // Persist every instance — the file name is the instance id, so | ||
| 537 | // multi-instance setups (e.g. `codex_personal`, `codex_work`) each | ||
| 538 | // get their own cache. We resolve the path fresh so snapshots | ||
| 539 | // produced by newly-added instances post-boot still land on disk | ||
| 540 | // without the aggregator holding a stale `cachePathByInstance` | ||
| 541 | // entry. | ||
| 542 | const key = snapshotInstanceKey(provider); | ||
| 543 | const filePath = yield* resolveProviderStatusCachePath({ | ||
| 544 | cacheDir: config.providerStatusCacheDir, | ||
| 545 | instanceId: key, | ||
| 546 | }).pipe(Effect.provideService(Path.Path, path)); | ||
| 547 | const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; | ||
| 548 | yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( | ||
| 549 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 550 | Effect.provideService(Path.Path, path), | ||
| 551 | Effect.tapError(Effect.logError), | ||
| 552 | Effect.ignore, | ||
| 553 | ); | ||
| 554 | }); | ||
| 555 | |||
| 556 | const applyProviderUpdateState = Effect.fn("applyProviderUpdateState")(function* ( | ||
| 557 | provider: ServerProvider, | ||
| 558 | ) { | ||
| 559 | const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef); | ||
| 560 | const updateState = maintenanceActionStates.get(provider.instanceId)?.update; | ||
| 561 | if (!updateState) { | ||
| 562 | const { updateState: _updateState, ...providerWithoutUpdateState } = provider; | ||
| 563 | return providerWithoutUpdateState; | ||
| 564 | } | ||
| 565 | return { | ||
| 566 | ...provider, | ||
| 567 | updateState, | ||
| 568 | }; | ||
| 569 | }); | ||
| 570 | |||
| 571 | const upsertProviders = Effect.fn("upsertProviders")(function* ( | ||
| 572 | nextProviders: ReadonlyArray<ServerProvider>, | ||
| 573 | options?: { | ||
| 574 | readonly publish?: boolean; | ||
| 575 | readonly persist?: boolean; | ||
| 576 | readonly replace?: boolean; | ||
| 577 | }, | ||
| 578 | ) { | ||
| 579 | const manifest = yield* manifestService.current; | ||
| 580 | const nextProvidersWithUpdateState = yield* Effect.forEach( | ||
| 581 | nextProviders, | ||
| 582 | applyProviderUpdateState, | ||
| 583 | { | ||
| 584 | concurrency: "unbounded", | ||
| 585 | }, | ||
| 586 | ); | ||
| 587 | const [previousProviders, providers, providersToPersist] = yield* Ref.modify( | ||
| 588 | providersRef, | ||
| 589 | (previousProviders) => { | ||
| 590 | const mergedProviders = new Map( | ||
| 591 | previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | ||
| 592 | ); | ||
| 593 | const updatedKeys = new Set<ProviderInstanceId>(); | ||
| 594 | |||
| 595 | for (const provider of nextProvidersWithUpdateState) { | ||
| 596 | const key = snapshotInstanceKey(provider); | ||
| 597 | updatedKeys.add(key); | ||
| 598 | mergedProviders.set( | ||
| 599 | key, | ||
| 600 | options?.replace === true | ||
| 601 | ? provider | ||
| 602 | : mergeProviderSnapshot(mergedProviders.get(key), provider), | ||
| 603 | ); | ||
| 604 | } | ||
| 605 | |||
| 606 | const providers = orderProviderSnapshots( | ||
| 607 | [...mergedProviders.values()].map((provider) => | ||
| 608 | classifyCompatibility(provider, manifest), | ||
| 609 | ), | ||
| 610 | ); | ||
| 611 | const providersToPersist = providers.filter((provider) => | ||
| 612 | updatedKeys.has(snapshotInstanceKey(provider)), | ||
| 613 | ); | ||
| 614 | return [[previousProviders, providers, providersToPersist] as const, providers]; | ||
| 615 | }, | ||
| 616 | ); | ||
| 617 | |||
| 618 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 619 | if (options?.persist !== false) { | ||
| 620 | yield* Effect.forEach(providersToPersist, persistProvider, { | ||
| 621 | concurrency: "unbounded", | ||
| 622 | discard: true, | ||
| 623 | }); | ||
| 624 | } | ||
| 625 | if (options?.publish !== false) { | ||
| 626 | yield* PubSub.publish(changesPubSub, providers); | ||
| 627 | } | ||
| 628 | } | ||
| 629 | |||
| 630 | return providers; | ||
| 631 | }); | ||
| 632 | |||
| 633 | const compatibilityRefreshRunning = yield* Ref.make(false); | ||
| 634 | const syncProvider = Effect.fn("syncProvider")(function* ( | ||
| 635 | provider: ServerProvider, | ||
| 636 | options?: { | ||
| 637 | readonly publish?: boolean; | ||
| 638 | }, | ||
| 639 | ) { | ||
| 640 | const providers = yield* upsertProviders([provider], options); | ||
| 641 | // Reclassify the current read model after fetching. Never republish the | ||
| 642 | // probe captured before the fetch: a newer health result may have landed. | ||
| 643 | if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { | ||
| 644 | yield* manifestService.refresh.pipe( | ||
| 645 | Effect.andThen(upsertProviders([], { persist: false })), | ||
| 646 | Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), | ||
| 647 | Effect.forkIn(serviceScope), | ||
| 648 | ); | ||
| 649 | } | ||
| 650 | return providers; | ||
| 651 | }); | ||
| 652 | |||
| 653 | const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( | ||
| 654 | function* (input: { | ||
| 655 | readonly instanceId: ProviderInstanceId; | ||
| 656 | readonly action: "update"; | ||
| 657 | readonly state: ServerProviderUpdateState | null; | ||
| 658 | }) { | ||
| 659 | yield* Ref.update(maintenanceActionStatesRef, (previous) => { | ||
| 660 | const previousActions = previous.get(input.instanceId); | ||
| 661 | const nextActions = { ...previousActions }; | ||
| 662 | if (input.state === null || input.state.status === "idle") { | ||
| 663 | delete nextActions[input.action]; | ||
| 664 | } else { | ||
| 665 | nextActions[input.action] = input.state; | ||
| 666 | } | ||
| 667 | |||
| 668 | const next = new Map(previous); | ||
| 669 | if (Object.keys(nextActions).length === 0) { | ||
| 670 | next.delete(input.instanceId); | ||
| 671 | } else { | ||
| 672 | next.set(input.instanceId, nextActions); | ||
| 673 | } | ||
| 674 | return next; | ||
| 675 | }); | ||
| 676 | |||
| 677 | const existingProviders = yield* Ref.get(providersRef); | ||
| 678 | const matchingProvider = existingProviders.find( | ||
| 679 | (candidate) => candidate.instanceId === input.instanceId, | ||
| 680 | ); | ||
| 681 | if (!matchingProvider) { | ||
| 682 | return existingProviders; | ||
| 683 | } | ||
| 684 | |||
| 685 | const nextProvider = yield* applyProviderUpdateState(matchingProvider); | ||
| 686 | return yield* upsertProviders([nextProvider], { | ||
| 687 | persist: false, | ||
| 688 | }); | ||
| 689 | }, | ||
| 690 | ); | ||
| 691 | |||
| 692 | const refreshOneSource = Effect.fn("refreshOneSource")(function* ( | ||
| 693 | providerSource: ProviderSnapshotSource, | ||
| 694 | ) { | ||
| 695 | return yield* providerSource.refresh.pipe( | ||
| 696 | Effect.flatMap((nextProvider) => | ||
| 697 | correlateSnapshotWithSource(providerSource, nextProvider).pipe( | ||
| 698 | Effect.flatMap(syncProvider), | ||
| 699 | ), | ||
| 700 | ), | ||
| 701 | ); | ||
| 702 | }); | ||
| 703 | |||
| 704 | const refreshAll = Effect.fn("refreshAll")(function* () { | ||
| 705 | const sources = yield* getLiveSources; | ||
| 706 | return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { | ||
| 707 | concurrency: "unbounded", | ||
| 708 | discard: true, | ||
| 709 | }).pipe(Effect.andThen(Ref.get(providersRef))); | ||
| 710 | }); | ||
| 711 | |||
| 712 | const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { | ||
| 713 | if (provider === undefined) { | ||
| 714 | return yield* refreshAll(); | ||
| 715 | } | ||
| 716 | // Kind-scoped refreshes target the default instance for that driver. | ||
| 717 | const defaultInstanceId = defaultInstanceIdForDriver(provider); | ||
| 718 | const sources = yield* getLiveSources; | ||
| 719 | const providerSource = sources.find( | ||
| 720 | (candidate) => candidate.instanceId === defaultInstanceId, | ||
| 721 | ); | ||
| 722 | if (!providerSource) { | ||
| 723 | return yield* Ref.get(providersRef); | ||
| 724 | } | ||
| 725 | return yield* refreshOneSource(providerSource); | ||
| 726 | }); | ||
| 727 | |||
| 728 | const refreshInstance = Effect.fn("refreshInstance")(function* ( | ||
| 729 | instanceId: ProviderInstanceId, | ||
| 730 | ) { | ||
| 731 | const sources = yield* getLiveSources; | ||
| 732 | const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); | ||
| 733 | if (!providerSource) { | ||
| 734 | return yield* Ref.get(providersRef); | ||
| 735 | } | ||
| 736 | return yield* refreshOneSource(providerSource); | ||
| 737 | }); | ||
| 738 | |||
| 739 | const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( | ||
| 740 | "getProviderMaintenanceCapabilitiesForInstance", | ||
| 741 | )(function* ( | ||
| 742 | instanceId: ProviderInstanceId, | ||
| 743 | provider: ProviderDriverKind, | ||
| 744 | options?: { readonly fresh?: boolean }, | ||
| 745 | ) { | ||
| 746 | // Read the instance registry, not `liveSubsRef`: the latter trails | ||
| 747 | // reconciliation, and an update must never run a retired instance's | ||
| 748 | // command against a freshly configured executable. | ||
| 749 | const instance = yield* instanceRegistry.getInstance(instanceId); | ||
| 750 | if (!instance || instance.driverKind !== provider) { | ||
| 751 | return makeManualProviderMaintenanceCapabilities(provider); | ||
| 752 | } | ||
| 753 | return yield* instance.snapshot.resolveMaintenance(options); | ||
| 754 | }); | ||
| 755 | |||
| 756 | /** | ||
| 757 | * Diff the aggregator's live-source set against the current | ||
| 758 | * `ProviderInstanceRegistry` and: | ||
| 759 | * - subscribe to each newly-added or rebuilt instance's | ||
| 760 | * `streamChanges` (so periodic + enrichment refreshes land in | ||
| 761 | * `providersRef`); | ||
| 762 | * - read each newly-added/rebuilt instance's current snapshot after | ||
| 763 | * subscribing, closing the race with its independently-running | ||
| 764 | * background startup probe; | ||
| 765 | * - prune `providersRef` of instances that no longer exist. | ||
| 766 | * | ||
| 767 | * Provider refreshes are owned by each managed provider and never run | ||
| 768 | * on this layer's construction path. Consumers see cached or pending | ||
| 769 | * snapshots immediately, then receive live probe results through the | ||
| 770 | * already-attached change stream. | ||
| 771 | * | ||
| 772 | * Per-instance subscription fibers are not tracked explicitly. When | ||
| 773 | * a rebuilt instance's old child scope closes, its PubSub shuts | ||
| 774 | * down and our `Stream.runForEach` fiber exits naturally. | ||
| 775 | */ | ||
| 776 | const syncLiveSources = syncSemaphore.withPermits(1)( | ||
| 777 | Effect.gen(function* () { | ||
| 778 | const instances = yield* instanceRegistry.listInstances; | ||
| 779 | const unavailableProviders = yield* instanceRegistry.listUnavailable; | ||
| 780 | const nextByInstance = new Map<ProviderInstanceId, ProviderInstance>( | ||
| 781 | instances.map((instance) => [instance.instanceId, instance] as const), | ||
| 782 | ); | ||
| 783 | const knownInstanceIds = new Set<ProviderInstanceId>(nextByInstance.keys()); | ||
| 784 | for (const provider of unavailableProviders) { | ||
| 785 | knownInstanceIds.add(snapshotInstanceKey(provider)); | ||
| 786 | } | ||
| 787 | const previousSubs = yield* Ref.get(liveSubsRef); | ||
| 788 | |||
| 789 | // Carry over subscriptions for instances whose identity is | ||
| 790 | // unchanged (reconcile treated them as no-op). Instances that | ||
| 791 | // disappeared, or were rebuilt with a different reference, | ||
| 792 | // fall through to the "newly-added" branch below. | ||
| 793 | const carriedOver = new Map<ProviderInstanceId, ProviderInstance>(); | ||
| 794 | for (const [instanceId, previousInstance] of previousSubs) { | ||
| 795 | const nextInstance = nextByInstance.get(instanceId); | ||
| 796 | if (nextInstance !== undefined && nextInstance === previousInstance) { | ||
| 797 | carriedOver.set(instanceId, previousInstance); | ||
| 798 | } | ||
| 799 | } | ||
| 800 | |||
| 801 | // Collect new/rebuilt instances in `nextByInstance` insertion | ||
| 802 | // order (which preserves settings-author order). | ||
| 803 | const newlyAdded: Array<readonly [ProviderInstanceId, ProviderInstance]> = []; | ||
| 804 | for (const [instanceId, instance] of nextByInstance) { | ||
| 805 | if (carriedOver.has(instanceId)) { | ||
| 806 | continue; | ||
| 807 | } | ||
| 808 | newlyAdded.push([instanceId, instance] as const); | ||
| 809 | } | ||
| 810 | |||
| 811 | const rebuiltInstanceIds = new Set( | ||
| 812 | newlyAdded | ||
| 813 | .map(([instanceId]) => instanceId) | ||
| 814 | .filter((instanceId) => previousSubs.has(instanceId)), | ||
| 815 | ); | ||
| 816 | if (rebuiltInstanceIds.size > 0) { | ||
| 817 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 818 | providersRef, | ||
| 819 | (previousProviders) => { | ||
| 820 | const providers = previousProviders.map((provider) => { | ||
| 821 | if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; | ||
| 822 | const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; | ||
| 823 | return machineSnapshot; | ||
| 824 | }); | ||
| 825 | return [[previousProviders, providers] as const, providers]; | ||
| 826 | }, | ||
| 827 | ); | ||
| 828 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 829 | yield* PubSub.publish(changesPubSub, providers); | ||
| 830 | } | ||
| 831 | } | ||
| 832 | |||
| 833 | // Fork long-lived subscriptions to each new/rebuilt instance's | ||
| 834 | // change stream before reading its current snapshot. If the | ||
| 835 | // driver's own initial probe finishes during this sync, either | ||
| 836 | // the current read or the active subscriber observes the result. | ||
| 837 | for (const [, instance] of newlyAdded) { | ||
| 838 | const source = buildSnapshotSource(instance); | ||
| 839 | yield* Stream.runForEach(source.streamChanges, (provider) => | ||
| 840 | correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), | ||
| 841 | ).pipe(Effect.forkScoped); | ||
| 842 | } | ||
| 843 | yield* Effect.yieldNow; | ||
| 844 | |||
| 845 | // Snapshot current state without starting a probe. Managed providers | ||
| 846 | // launch their startup refresh independently, so this closes the | ||
| 847 | // subscription race without putting external work on the registry | ||
| 848 | // or HTTP server construction path. | ||
| 849 | yield* Effect.forEach( | ||
| 850 | newlyAdded, | ||
| 851 | ([, instance]) => | ||
| 852 | Effect.gen(function* () { | ||
| 853 | const source = buildSnapshotSource(instance); | ||
| 854 | const provider = yield* source.getSnapshot; | ||
| 855 | yield* correlateSnapshotWithSource(source, provider).pipe( | ||
| 856 | Effect.flatMap(syncProvider), | ||
| 857 | ); | ||
| 858 | }).pipe(Effect.ignoreCause({ log: true })), | ||
| 859 | { concurrency: "unbounded", discard: true }, | ||
| 860 | ); | ||
| 861 | yield* upsertProviders(unavailableProviders, { | ||
| 862 | persist: false, | ||
| 863 | replace: true, | ||
| 864 | }); | ||
| 865 | |||
| 866 | const nextSubs = new Map(carriedOver); | ||
| 867 | for (const [instanceId, instance] of newlyAdded) { | ||
| 868 | nextSubs.set(instanceId, instance); | ||
| 869 | } | ||
| 870 | yield* Ref.set(liveSubsRef, nextSubs); | ||
| 871 | |||
| 872 | // Drop aggregator state for instances that have disappeared — | ||
| 873 | // otherwise the UI would keep rendering ghosts. | ||
| 874 | const [previousProviders, providers] = yield* Ref.modify( | ||
| 875 | providersRef, | ||
| 876 | (previousProviders) => { | ||
| 877 | const providers = orderProviderSnapshots( | ||
| 878 | previousProviders.filter((provider) => | ||
| 879 | knownInstanceIds.has(snapshotInstanceKey(provider)), | ||
| 880 | ), | ||
| 881 | ); | ||
| 882 | return [[previousProviders, providers] as const, providers]; | ||
| 883 | }, | ||
| 884 | ); | ||
| 885 | if (haveProvidersChanged(previousProviders, providers)) { | ||
| 886 | yield* PubSub.publish(changesPubSub, providers); | ||
| 887 | } | ||
| 888 | yield* Ref.update(maintenanceActionStatesRef, (previous) => { | ||
| 889 | const next = new Map(previous); | ||
| 890 | for (const instanceId of previous.keys()) { | ||
| 891 | if (!knownInstanceIds.has(instanceId)) { | ||
| 892 | next.delete(instanceId); | ||
| 893 | } | ||
| 894 | } | ||
| 895 | return next; | ||
| 896 | }); | ||
| 897 | }), | ||
| 898 | ); | ||
| 899 | const syncLiveSourcesAndContinue = syncLiveSources.pipe( | ||
| 900 | Effect.catchCause((cause) => { | ||
| 901 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 902 | return Effect.interrupt; | ||
| 903 | } | ||
| 904 | return Effect.logError( | ||
| 905 | "provider registry instance sync failed; keeping subscription alive", | ||
| 906 | { | ||
| 907 | cause: Cause.pretty(cause), | ||
| 908 | }, | ||
| 909 | ); | ||
| 910 | }), | ||
| 911 | ); | ||
| 912 | |||
| 913 | // Seed `providersRef` with the boot-time fallback snapshots so | ||
| 914 | // consumers calling `getProviders` immediately after layer build see | ||
| 915 | // a populated list — even before the first `syncLiveSources` refresh | ||
| 916 | // resolves. Cached snapshots (already in `providersRef`) merge with | ||
| 917 | // these via `upsertProviders` so on-disk state wins where present | ||
| 918 | // and pending fallbacks fill the gaps. | ||
| 919 | yield* upsertProviders(fallbackProviders, { publish: false }); | ||
| 920 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 921 | // `subscribeChanges` acquires the dequeue synchronously in this | ||
| 922 | // fibre; the subscription is active the instant this `yield*` | ||
| 923 | // returns. Forking the consumer loop later cannot lose a publish | ||
| 924 | // because no publish can reach a not-yet-subscribed dequeue. | ||
| 925 | // | ||
| 926 | // (Contrast with the pre-fix code that did | ||
| 927 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 928 | // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, | ||
| 929 | // and `forkScoped` only schedules the fibre — so a reconcile that | ||
| 930 | // published between "fibre scheduled" and "fibre starts running" | ||
| 931 | // was dropped, which made any settings change that replaced an | ||
| 932 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 933 | // Subscribe to registry mutations BEFORE running the initial sync. | ||
| 934 | // `subscribeChanges` acquires the `PubSub.Subscription` synchronously | ||
| 935 | // in this fibre; the subscription is registered with the PubSub the | ||
| 936 | // instant this `yield*` returns, so any subsequent publish is | ||
| 937 | // buffered in the subscription regardless of when the consumer | ||
| 938 | // fibre below actually starts running. | ||
| 939 | // | ||
| 940 | // (Contrast with the pre-fix code that did | ||
| 941 | // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. | ||
| 942 | // `instanceRegistry.streamChanges` is `Stream.fromPubSub(changes)`, | ||
| 943 | // which defers `PubSub.subscribe` to stream start. `forkScoped` only | ||
| 944 | // schedules the consumer fibre — so a reconcile that published | ||
| 945 | // between "fibre scheduled" and "fibre starts running + subscribes" | ||
| 946 | // was dropped, which made any settings change that replaced an | ||
| 947 | // instance never propagate to the aggregator's `providersRef`.) | ||
| 948 | const instanceChanges = yield* instanceRegistry.subscribeChanges; | ||
| 949 | // Initial sync attaches subscriptions and snapshots current state for | ||
| 950 | // every instance present at boot. Provider probes are already running in | ||
| 951 | // their managed background fibers and never block this layer. | ||
| 952 | yield* syncLiveSources; | ||
| 953 | // React to registry mutations — instance added / removed / rebuilt. | ||
| 954 | // `Stream.fromSubscription` builds a stream over the pre-acquired | ||
| 955 | // subscription rather than subscribing on stream start, which is | ||
| 956 | // what closes the race. | ||
| 957 | yield* Stream.runForEach( | ||
| 958 | Stream.fromSubscription(instanceChanges), | ||
| 959 | () => syncLiveSourcesAndContinue, | ||
| 960 | ).pipe(Effect.forkScoped); | ||
| 961 | |||
| 962 | const recoverRefreshFailure = Effect.fn("recoverRefreshFailure")(function* ( | ||
| 963 | cause: Cause.Cause<unknown>, | ||
| 964 | ) { | ||
| 965 | if (Cause.hasInterruptsOnly(cause)) { | ||
| 966 | return yield* Effect.interrupt; | ||
| 967 | } | ||
| 968 | yield* Effect.logError("provider registry refresh failed; preserving cached providers", { | ||
| 969 | cause: Cause.pretty(cause), | ||
| 970 | }); | ||
| 971 | return yield* Ref.get(providersRef); | ||
| 972 | }); | ||
| 973 | |||
| 974 | const updateProviders = ( | ||
| 975 | update: (providers: ReadonlyArray<ServerProvider>) => ReadonlyArray<ServerProvider>, | ||
| 976 | ) => | ||
| 977 | Ref.modify(providersRef, (currentProviders) => { | ||
| 978 | const nextProviders = update(currentProviders); | ||
| 979 | return [[currentProviders, nextProviders] as const, nextProviders]; | ||
| 980 | }).pipe( | ||
| 981 | Effect.tap(([previousProviders, nextProviders]) => | ||
| 982 | haveProvidersChanged(previousProviders, nextProviders) | ||
| 983 | ? PubSub.publish(changesPubSub, nextProviders) | ||
| 984 | : Effect.void, | ||
| 985 | ), | ||
| 986 | Effect.map(([, nextProviders]) => nextProviders), | ||
| 987 | ); | ||
| 988 | |||
| 989 | const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { | ||
| 990 | readonly instanceId: ProviderInstanceId; | ||
| 991 | readonly cwd: string; | ||
| 992 | readonly fresh?: boolean; | ||
| 993 | }) { | ||
| 994 | // Fresh scans drop other instances' snapshots for this cwd first, so a | ||
| 995 | // composer on one of them scans again on next use, even when this | ||
| 996 | // instance is gone or cannot be scanned. | ||
| 997 | if (input.fresh) { | ||
| 998 | yield* updateProviders((providers) => | ||
| 999 | providers.map((candidate) => | ||
| 1000 | candidate.instanceId === input.instanceId | ||
| 1001 | ? candidate | ||
| 1002 | : dropProviderWorkspaceSnapshot(candidate, input.cwd), | ||
| 1003 | ), | ||
| 1004 | ); | ||
| 1005 | } | ||
| 1006 | const providers = yield* Ref.get(providersRef); | ||
| 1007 | const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); | ||
| 1008 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => | ||
| 1009 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); | ||
| 1010 | const scannedFrom = workspaceSnapshotOf(provider); | ||
| 1011 | if ( | ||
| 1012 | !provider || | ||
| 1013 | !provider.enabled || | ||
| 1014 | (!input.fresh && scannedFrom && !scannedFrom.slashCommandsPending) | ||
| 1015 | ) { | ||
| 1016 | return providers; | ||
| 1017 | } | ||
| 1018 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | ||
| 1019 | if (!instance?.snapshotForCwd) return providers; | ||
| 1020 | const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { | ||
| 1021 | const current = refreshes.get(instance); | ||
| 1022 | if (current?.has(input.cwd)) return [false, refreshes] as const; | ||
| 1023 | const next = new Map(refreshes); | ||
| 1024 | next.set(instance, new Set(current).add(input.cwd)); | ||
| 1025 | return [true, next] as const; | ||
| 1026 | }); | ||
| 1027 | // A fresh scan never joins a running one, which may predate the change. | ||
| 1028 | if (!claimed && !input.fresh) return yield* Ref.get(providersRef); | ||
| 1029 | // Fresh scans also re-read the machine snapshot after invalidating caches. | ||
| 1030 | const refreshMachineSnapshot = input.fresh | ||
| 1031 | ? (instance.invalidateCaches ?? Effect.void).pipe( | ||
| 1032 | Effect.andThen(refreshInstance(input.instanceId)), | ||
| 1033 | ) | ||
| 1034 | : Effect.void; | ||
| 1035 | return yield* refreshMachineSnapshot.pipe( | ||
| 1036 | Effect.andThen(instance.snapshotForCwd(input.cwd)), | ||
| 1037 | Effect.flatMap((scopedSnapshot) => | ||
| 1038 | scopedSnapshot.status === "error" && scopedSnapshot.slashCommandsPending === undefined | ||
| 1039 | ? Ref.get(providersRef) | ||
| 1040 | : instanceRegistry.getInstance(input.instanceId).pipe( | ||
| 1041 | Effect.flatMap((currentInstance) => { | ||
| 1042 | if (currentInstance !== instance) return Ref.get(providersRef); | ||
| 1043 | // Write only if the cwd's snapshot did not change during the | ||
| 1044 | // scan. A session event or another scan that landed first is newer. | ||
| 1045 | return updateProviders((currentProviders) => | ||
| 1046 | currentProviders.map((candidate) => | ||
| 1047 | candidate.instanceId === input.instanceId && | ||
| 1048 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) | ||
| 1049 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | ||
| 1050 | : candidate, | ||
| 1051 | ), | ||
| 1052 | ); | ||
| 1053 | }), | ||
| 1054 | ), | ||
| 1055 | ), | ||
| 1056 | Effect.ensuring( | ||
| 1057 | claimed | ||
| 1058 | ? Ref.update(workspaceRefreshesRef, (refreshes) => { | ||
| 1059 | const next = new Map(refreshes); | ||
| 1060 | const current = new Set(next.get(instance)); | ||
| 1061 | current.delete(input.cwd); | ||
| 1062 | if (current.size) next.set(instance, current); | ||
| 1063 | else next.delete(instance); | ||
| 1064 | return next; | ||
| 1065 | }) | ||
| 1066 | : Effect.void, | ||
| 1067 | ), | ||
| 1068 | ); | ||
| 1069 | }); | ||
| 1070 | |||
| 1071 | return { | ||
| 1072 | getProviders: Ref.get(providersRef), | ||
| 1073 | refresh: (provider?: ProviderDriverKind) => | ||
| 1074 | refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 1075 | refreshInstance: (instanceId: ProviderInstanceId) => | ||
| 1076 | refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 1077 | refreshWorkspaceSnapshot: (input) => | ||
| 1078 | refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), | ||
| 1079 | getProviderMaintenanceCapabilitiesForInstance, | ||
| 1080 | setProviderMaintenanceActionState, | ||
| 1081 | get streamChanges() { | ||
| 1082 | return Stream.fromPubSub(changesPubSub); | ||
| 1083 | }, | ||
| 1084 | } satisfies ProviderRegistry["Service"]; | ||
| 1085 | }), | ||
| 1086 | ); | ||
apps/server/src/provider/ProviderUsageLimitsIngestion.ts created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | /** | ||
| 2 | * V2 drivers fold live account usage events directly into their managed | ||
| 3 | * snapshots. Retained as an empty layer for the shared server composition. | ||
| 4 | */ | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | |||
| 7 | export const layer = Layer.empty; | ||
apps/server/src/provider/ServerProvider.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import type * as Effect from "effect/Effect"; | ||
| 3 | import type * as Stream from "effect/Stream"; | ||
| 4 | import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | ||
| 5 | |||
| 6 | export interface ServerProviderShape { | ||
| 7 | /** | ||
| 8 | * Ownership-derived update capabilities. Cached between reads; pass | ||
| 9 | * `{ fresh: true }` before executing an update so it never trusts a | ||
| 10 | * resolution older than the click. | ||
| 11 | */ | ||
| 12 | readonly resolveMaintenance: (options?: { | ||
| 13 | readonly fresh?: boolean; | ||
| 14 | }) => Effect.Effect<ProviderMaintenanceCapabilities>; | ||
| 15 | readonly getSnapshot: Effect.Effect<ServerProvider>; | ||
| 16 | readonly refresh: Effect.Effect<ServerProvider>; | ||
| 17 | readonly streamChanges: Stream.Stream<ServerProvider>; | ||
| 18 | /** | ||
| 19 | * Fold a runtime rate-limit update into the published snapshot without | ||
| 20 | * waiting for the next status probe. Sparse: windows merge by id and an | ||
| 21 | * update with no usable window leaves the snapshot untouched. | ||
| 22 | */ | ||
| 23 | readonly applyUsageLimits: ( | ||
| 24 | update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, | ||
| 25 | ) => Effect.Effect<void>; | ||
| 26 | } | ||
apps/server/src/provider/Services/ProviderAuthService.ts deleted-104| ... | @@ -1,104 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | ChatGptReconnectProfile, | ||
| 3 | ChatGptTransferredProfile, | ||
| 4 | ProviderAuthRespondInput, | ||
| 5 | ProviderAuthStartInput, | ||
| 6 | ProviderAuthState, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ProviderSetupError, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Context from "effect/Context"; | ||
| 11 | import type * as Effect from "effect/Effect"; | ||
| 12 | import type * as Stream from "effect/Stream"; | ||
| 13 | import type * as Scope from "effect/Scope"; | ||
| 14 | |||
| 15 | export interface ProviderAuthController { | ||
| 16 | /** Equal keys mean these instances share credentials on this environment. */ | ||
| 17 | readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; | ||
| 18 | readonly reconnectProfile?: ( | ||
| 19 | methodId: string, | ||
| 20 | ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>; | ||
| 21 | readonly importProfile?: ( | ||
| 22 | profile: ChatGptTransferredProfile, | ||
| 23 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 24 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 25 | readonly adoptCredentials?: ( | ||
| 26 | update: Effect.Effect<void, ProviderSetupError>, | ||
| 27 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 28 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 29 | readonly isChangingCredentials?: Effect.Effect<boolean>; | ||
| 30 | readonly invalidate?: Effect.Effect<void>; | ||
| 31 | readonly refreshMethods?: Effect.Effect<void>; | ||
| 32 | readonly withAccess?: <A, E, R>( | ||
| 33 | task: Effect.Effect<A, E, R>, | ||
| 34 | ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>; | ||
| 35 | readonly start: ( | ||
| 36 | ownerSessionId: string, | ||
| 37 | stopSessions?: Effect.Effect<void, ProviderSetupError>, | ||
| 38 | methodId?: string, | ||
| 39 | returnUrl?: string, | ||
| 40 | callbackMode?: "server" | "client", | ||
| 41 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 42 | readonly complete: ( | ||
| 43 | ownerSessionId: string, | ||
| 44 | input: { readonly flowId: string; readonly callbackUrl: string }, | ||
| 45 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 46 | readonly cancel: ( | ||
| 47 | ownerSessionId: string, | ||
| 48 | flowId: string, | ||
| 49 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 50 | readonly respond?: ( | ||
| 51 | ownerSessionId: string, | ||
| 52 | input: ProviderAuthRespondInput, | ||
| 53 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 54 | /** The controller closes process admission before it stops routed sessions. */ | ||
| 55 | readonly logout: ( | ||
| 56 | stopSessions: Effect.Effect<void, ProviderSetupError>, | ||
| 57 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 58 | readonly subscribe: (ownerSessionId: string) => Stream.Stream<ProviderAuthState>; | ||
| 59 | readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean; | ||
| 60 | } | ||
| 61 | |||
| 62 | interface ProviderAuthTarget { | ||
| 63 | readonly instanceId: ProviderInstanceId; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface ProviderAuthServiceShape { | ||
| 67 | readonly reconnectProfile: ( | ||
| 68 | input: ProviderAuthTarget & { methodId: string }, | ||
| 69 | ) => Effect.Effect<ChatGptReconnectProfile | null, ProviderSetupError>; | ||
| 70 | readonly importProfile: ( | ||
| 71 | input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, | ||
| 72 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 73 | readonly start: ( | ||
| 74 | input: ProviderAuthStartInput, | ||
| 75 | ownerSessionId: string, | ||
| 76 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 77 | readonly complete: ( | ||
| 78 | input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, | ||
| 79 | ownerSessionId: string, | ||
| 80 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 81 | readonly respond: ( | ||
| 82 | input: ProviderAuthRespondInput, | ||
| 83 | ownerSessionId: string, | ||
| 84 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 85 | readonly cancel: ( | ||
| 86 | input: ProviderAuthTarget & { readonly flowId: string }, | ||
| 87 | ownerSessionId: string, | ||
| 88 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 89 | readonly logout: ( | ||
| 90 | input: ProviderAuthTarget, | ||
| 91 | ) => Effect.Effect<ProviderAuthState, ProviderSetupError>; | ||
| 92 | readonly subscribe: ( | ||
| 93 | input: ProviderAuthTarget, | ||
| 94 | ownerSessionId: string, | ||
| 95 | ) => Stream.Stream<ProviderAuthState, ProviderSetupError>; | ||
| 96 | readonly tryHandlePromptCommand: ( | ||
| 97 | input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean }, | ||
| 98 | ) => Effect.Effect<boolean, ProviderSetupError>; | ||
| 99 | } | ||
| 100 | |||
| 101 | export class ProviderAuthService extends Context.Service< | ||
| 102 | ProviderAuthService, | ||
| 103 | ProviderAuthServiceShape | ||
| 104 | >()("t3/provider/Services/ProviderAuthService") {} | ||
apps/server/src/provider/Services/ProviderInstanceRegistry.ts deleted-87| ... | @@ -1,87 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistry — the single Effect service in the new model. | ||
| 3 | * | ||
| 4 | * Owns a `Map<ProviderInstanceId, ProviderInstance>` produced by running | ||
| 5 | * registered driver factories against `ServerSettings.providerInstances`. | ||
| 6 | * The registry watches settings; when an instance's config changes (or | ||
| 7 | * the entry disappears), the registry tears down the affected instance's | ||
| 8 | * scope and rebuilds — that's the entire hot-reload story. | ||
| 9 | * | ||
| 10 | * What rest-of-server reads from here: | ||
| 11 | * - `getInstance(instanceId)` — for routing turn/session calls. | ||
| 12 | * - `listInstances` — for snapshot aggregation in `ProviderRegistry`. | ||
| 13 | * - `listUnavailable` — `ServerProvider` shadows for instances whose | ||
| 14 | * driver is not registered in this build (rollback / fork tolerance). | ||
| 15 | * - `streamChanges` — coalesced "registry mutated" pings so consumers | ||
| 16 | * can re-pull lists or re-broadcast. | ||
| 17 | * | ||
| 18 | * @module provider/Services/ProviderInstanceRegistry | ||
| 19 | */ | ||
| 20 | import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; | ||
| 21 | import * as Context from "effect/Context"; | ||
| 22 | import type * as Effect from "effect/Effect"; | ||
| 23 | import type * as PubSub from "effect/PubSub"; | ||
| 24 | import type * as Scope from "effect/Scope"; | ||
| 25 | import type * as Stream from "effect/Stream"; | ||
| 26 | |||
| 27 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 28 | |||
| 29 | export interface ProviderInstanceRegistryShape { | ||
| 30 | /** | ||
| 31 | * Look up one instance by id. Returns `undefined` (not Option) when the | ||
| 32 | * id is unknown — callers branch on falsy and emit | ||
| 33 | * `ProviderInstanceNotFoundError`. | ||
| 34 | */ | ||
| 35 | readonly getInstance: ( | ||
| 36 | instanceId: ProviderInstanceId, | ||
| 37 | ) => Effect.Effect<ProviderInstance | undefined>; | ||
| 38 | /** | ||
| 39 | * Every available (driver-registered, successfully created) instance, | ||
| 40 | * in stable settings-author order. | ||
| 41 | */ | ||
| 42 | readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>; | ||
| 43 | /** | ||
| 44 | * Wire-shape shadow snapshots for instances whose driver is unknown to | ||
| 45 | * this build (or whose config failed to decode). Suitable for merging | ||
| 46 | * directly into `ProviderRegistry` output. | ||
| 47 | */ | ||
| 48 | readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 49 | /** | ||
| 50 | * Push notification stream emitted whenever the registry's contents | ||
| 51 | * change — instance added, removed, or rebuilt. The payload is `void` | ||
| 52 | * because consumers always want to re-pull `listInstances` / | ||
| 53 | * `listUnavailable` together. | ||
| 54 | * | ||
| 55 | * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the | ||
| 56 | * stream starts running, forking a consumer via | ||
| 57 | * `Stream.runForEach(...).pipe(Effect.forkScoped)` races the next | ||
| 58 | * publish — the forked fiber may not have subscribed yet when the | ||
| 59 | * publish lands. Hot-reload consumers that must not miss a publish | ||
| 60 | * should use `subscribeChanges` below instead, which acquires the | ||
| 61 | * subscription synchronously in the caller's fiber before the consumer | ||
| 62 | * loop is forked. | ||
| 63 | */ | ||
| 64 | readonly streamChanges: Stream.Stream<void>; | ||
| 65 | /** | ||
| 66 | * Acquire a subscription to the registry's change channel synchronously | ||
| 67 | * in the caller's fiber. Returns a `PubSub.Subscription<void>` whose | ||
| 68 | * lifetime is scoped to the provided `Scope` (the subscription is | ||
| 69 | * released when the scope closes). Consumers typically `yield*` this | ||
| 70 | * in the same fiber that forks their consumer loop, then drain with | ||
| 71 | * `PubSub.take(subscription)` inside `Effect.forever`. Because the | ||
| 72 | * subscription is registered with the PubSub before this `yield*` | ||
| 73 | * returns, no subsequent publish can land in a gap. | ||
| 74 | * | ||
| 75 | * This exists because the `ProviderInstanceRegistry` publishes on a | ||
| 76 | * PubSub and `Stream.fromPubSub` defers subscription until the stream | ||
| 77 | * starts executing — a consumer that `forkScoped`s the stream | ||
| 78 | * consumption can miss a publish that lands in the narrow window | ||
| 79 | * between "fiber scheduled" and "fiber starts running". | ||
| 80 | */ | ||
| 81 | readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>; | ||
| 82 | } | ||
| 83 | |||
| 84 | export class ProviderInstanceRegistry extends Context.Service< | ||
| 85 | ProviderInstanceRegistry, | ||
| 86 | ProviderInstanceRegistryShape | ||
| 87 | >()("t3/provider/Services/ProviderInstanceRegistry") {} | ||
apps/server/src/provider/Services/ProviderInstanceRegistryMutator.ts deleted-52| ... | @@ -1,52 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderInstanceRegistryMutator — internal handle used by the hydration | ||
| 3 | * layer to reconcile the live registry with a fresh | ||
| 4 | * `ProviderInstanceConfigMap`. | ||
| 5 | * | ||
| 6 | * Kept separate from the public `ProviderInstanceRegistry` service tag so | ||
| 7 | * downstream consumers (drivers, reactors, `ProviderService`) can only read | ||
| 8 | * from the registry. Only the hydration layer — which watches | ||
| 9 | * `ServerSettingsService.streamChanges` and applies diffs — imports this | ||
| 10 | * tag. | ||
| 11 | * | ||
| 12 | * The mutator exposes a single entry point, `reconcile(configMap)`, which: | ||
| 13 | * | ||
| 14 | * 1. Diffs the incoming map against the live one keyed by instance id. | ||
| 15 | * 2. Closes the per-instance `Scope` of every removed or replaced entry | ||
| 16 | * (tearing down adapter processes, refresh fibres, temp files) BEFORE | ||
| 17 | * creating the replacement — `reconcile` guarantees "at most one live | ||
| 18 | * instance per id" at all times. | ||
| 19 | * 3. Opens a fresh child `Scope` for every added or replaced entry, runs | ||
| 20 | * the driver's `create`, and stores the resulting `ProviderInstance` | ||
| 21 | * plus its scope. | ||
| 22 | * 4. Publishes one `void` tick on the registry's `streamChanges` PubSub at | ||
| 23 | * the end of the batch — consumers re-pull `listInstances` / | ||
| 24 | * `listUnavailable`. | ||
| 25 | * | ||
| 26 | * `reconcile` is idempotent: calling it with an unchanged config map is a | ||
| 27 | * no-op (no scope churn, no pubsub emission). | ||
| 28 | * | ||
| 29 | * @module provider/Services/ProviderInstanceRegistryMutator | ||
| 30 | */ | ||
| 31 | import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; | ||
| 32 | import * as Context from "effect/Context"; | ||
| 33 | import type * as Effect from "effect/Effect"; | ||
| 34 | |||
| 35 | export interface ProviderInstanceRegistryMutatorShape { | ||
| 36 | /** | ||
| 37 | * Bring the live registry in line with the supplied config map. See | ||
| 38 | * module docs for the add / remove / replace semantics. | ||
| 39 | * | ||
| 40 | * The effect never fails: individual driver `create` failures are | ||
| 41 | * captured as "unavailable" shadow snapshots inside the registry, the | ||
| 42 | * same way boot-time failures are handled by | ||
| 43 | * `makeProviderInstanceRegistry`. This keeps settings-watcher loops from | ||
| 44 | * erroring out on a single bad entry. | ||
| 45 | */ | ||
| 46 | readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect<void>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export class ProviderInstanceRegistryMutator extends Context.Service< | ||
| 50 | ProviderInstanceRegistryMutator, | ||
| 51 | ProviderInstanceRegistryMutatorShape | ||
| 52 | >()("t3/provider/Services/ProviderInstanceRegistryMutator") {} | ||
apps/server/src/provider/Services/ProviderRegistry.ts deleted-95| ... | @@ -1,95 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ProviderRegistry - Provider snapshot service. | ||
| 3 | * | ||
| 4 | * Owns provider install/auth/version/model snapshots and exposes the latest | ||
| 5 | * provider state to transport layers. | ||
| 6 | * | ||
| 7 | * @module ProviderRegistry | ||
| 8 | */ | ||
| 9 | import type { | ||
| 10 | ProviderInstanceId, | ||
| 11 | ProviderDriverKind, | ||
| 12 | ServerProvider, | ||
| 13 | ServerProviderUpdateState, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as Context from "effect/Context"; | ||
| 16 | import type * as Effect from "effect/Effect"; | ||
| 17 | import type * as Stream from "effect/Stream"; | ||
| 18 | import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 19 | |||
| 20 | export type ProviderMaintenanceActionKind = "update"; | ||
| 21 | |||
| 22 | export interface ProviderRegistryShape { | ||
| 23 | /** | ||
| 24 | * Read the latest provider snapshots for every configured instance. | ||
| 25 | * Multiple snapshots may share the same `provider` kind (multiple | ||
| 26 | * instances of the same driver) and disambiguate via `instanceId`. | ||
| 27 | */ | ||
| 28 | readonly getProviders: Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Refresh all providers, or the default instance of the specified | ||
| 32 | * kind when supplied. | ||
| 33 | * | ||
| 34 | * Retained for back-compat with legacy call sites (WS refresh RPC, | ||
| 35 | * orchestration metrics). New code should prefer `refreshInstance`. | ||
| 36 | * | ||
| 37 | * @deprecated prefer `refreshInstance` for new call sites. | ||
| 38 | */ | ||
| 39 | readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 40 | |||
| 41 | /** | ||
| 42 | * Refresh the specific configured instance. Returns the updated snapshot | ||
| 43 | * list. When the instance id is unknown the call resolves with the | ||
| 44 | * currently cached list (no error) — matching the legacy `refresh` shim | ||
| 45 | * behaviour so transport layers don't have to special-case unknowns. | ||
| 46 | */ | ||
| 47 | readonly refreshInstance: ( | ||
| 48 | instanceId: ProviderInstanceId, | ||
| 49 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 50 | |||
| 51 | /** | ||
| 52 | * Fill the skills and slash commands snapshot for one cwd. A cwd that | ||
| 53 | * already has a snapshot is left alone unless `fresh` is set. A fresh scan | ||
| 54 | * also refreshes the instance's machine snapshot and drops other | ||
| 55 | * instances' snapshots for the cwd. | ||
| 56 | */ | ||
| 57 | readonly refreshWorkspaceSnapshot: (input: { | ||
| 58 | readonly instanceId: ProviderInstanceId; | ||
| 59 | readonly cwd: string; | ||
| 60 | readonly fresh?: boolean; | ||
| 61 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 62 | |||
| 63 | /** | ||
| 64 | * Resolve the maintenance capabilities owned by one live provider instance. | ||
| 65 | * Falls back to manual-only capabilities when the instance is not live. | ||
| 66 | * `fresh` re-derives ownership from the executable instead of the cache. | ||
| 67 | */ | ||
| 68 | readonly getProviderMaintenanceCapabilitiesForInstance: ( | ||
| 69 | instanceId: ProviderInstanceId, | ||
| 70 | provider: ProviderDriverKind, | ||
| 71 | options?: { readonly fresh?: boolean }, | ||
| 72 | ) => Effect.Effect<ProviderMaintenanceCapabilities>; | ||
| 73 | |||
| 74 | /** | ||
| 75 | * Apply volatile maintenance-action state to one configured instance. | ||
| 76 | * This state is never persisted to disk. Today only update actions are | ||
| 77 | * projected onto `ServerProvider.updateState`; install/auth actions can | ||
| 78 | * extend this action map without adding driver-scoped APIs. | ||
| 79 | */ | ||
| 80 | readonly setProviderMaintenanceActionState: (input: { | ||
| 81 | readonly instanceId: ProviderInstanceId; | ||
| 82 | readonly action: ProviderMaintenanceActionKind; | ||
| 83 | readonly state: ServerProviderUpdateState | null; | ||
| 84 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | ||
| 85 | |||
| 86 | /** | ||
| 87 | * Stream of provider snapshot updates — one emission per aggregated | ||
| 88 | * change. The array contains the full current state. | ||
| 89 | */ | ||
| 90 | readonly streamChanges: Stream.Stream<ReadonlyArray<ServerProvider>>; | ||
| 91 | } | ||
| 92 | |||
| 93 | export class ProviderRegistry extends Context.Service<ProviderRegistry, ProviderRegistryShape>()( | ||
| 94 | "t3/provider/Services/ProviderRegistry", | ||
| 95 | ) {} | ||
apps/server/src/provider/Services/ServerProvider.ts deleted-26| ... | @@ -1,26 +0,0 @@ | ||
| 1 | import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import type * as Effect from "effect/Effect"; | ||
| 3 | import type * as Stream from "effect/Stream"; | ||
| 4 | import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | ||
| 5 | |||
| 6 | export interface ServerProviderShape { | ||
| 7 | /** | ||
| 8 | * Ownership-derived update capabilities. Cached between reads; pass | ||
| 9 | * `{ fresh: true }` before executing an update so it never trusts a | ||
| 10 | * resolution older than the click. | ||
| 11 | */ | ||
| 12 | readonly resolveMaintenance: (options?: { | ||
| 13 | readonly fresh?: boolean; | ||
| 14 | }) => Effect.Effect<ProviderMaintenanceCapabilities>; | ||
| 15 | readonly getSnapshot: Effect.Effect<ServerProvider>; | ||
| 16 | readonly refresh: Effect.Effect<ServerProvider>; | ||
| 17 | readonly streamChanges: Stream.Stream<ServerProvider>; | ||
| 18 | /** | ||
| 19 | * Fold a runtime rate-limit update into the published snapshot without | ||
| 20 | * waiting for the next status probe. Sparse: windows merge by id and an | ||
| 21 | * update with no usable window leaves the snapshot untouched. | ||
| 22 | */ | ||
| 23 | readonly applyUsageLimits: ( | ||
| 24 | update: ProviderUsageLimitsUpdate & { readonly checkedAt: string }, | ||
| 25 | ) => Effect.Effect<void>; | ||
| 26 | } | ||
apps/server/src/provider/acp/AcpNativeLogging.test.ts+1-1| ... | @@ -8,7 +8,7 @@ import * as Logger from "effect/Logger"; | ... | @@ -8,7 +8,7 @@ import * as Logger from "effect/Logger"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as AcpErrors from "effect-acp/errors"; | 9 | import * as AcpErrors from "effect-acp/errors"; |
| 10 | 10 | ||
| 11 | import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; | 11 | import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; |
| 12 | import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; | 12 | import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; |
| 13 | 13 | ||
| 14 | const nodeServicesIt = it.layer(NodeServices.layer); | 14 | const nodeServicesIt = it.layer(NodeServices.layer); |
apps/server/src/provider/acp/AcpNativeLogging.ts+1-1| ... | @@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -6,7 +6,7 @@ import * as DateTime from "effect/DateTime"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 7 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 8 | 8 | ||
| 9 | import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; | 9 | import type { EventNdjsonLogger } from "../EventNdjsonLogger.ts"; |
| 10 | import { | 10 | import { |
| 11 | structuralProtocolMethod, | 11 | structuralProtocolMethod, |
| 12 | summarizeNativeProtocolPayload, | 12 | summarizeNativeProtocolPayload, |
apps/server/src/provider/builtInProviderCatalog.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; | 1 | import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; |
| 2 | import type * as Stream from "effect/Stream"; | 2 | import type * as Stream from "effect/Stream"; |
| 3 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 3 | import type { ServerProviderShape } from "./ServerProvider.ts"; |
| 4 | 4 | ||
| 5 | export type ProviderSnapshotSource = { | 5 | export type ProviderSnapshotSource = { |
| 6 | /** | 6 | /** |
apps/server/src/provider/claudeResetCredits.test.ts created+259| ... | @@ -0,0 +1,259 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it as effectIt } from "@effect/vitest"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import * as TestClock from "effect/testing/TestClock"; | ||
| 11 | import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; | ||
| 12 | import { describe, expect, it } from "vite-plus/test"; | ||
| 13 | |||
| 14 | import * as ClaudeResetCredits from "./claudeResetCredits.ts"; | ||
| 15 | |||
| 16 | const NOW = Date.parse("2026-09-22T12:00:00.000Z"); | ||
| 17 | const grant = (overrides: Record<string, unknown>) => ({ | ||
| 18 | id: "grant_a", | ||
| 19 | resets_left: 1, | ||
| 20 | usable_now: true, | ||
| 21 | ...overrides, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const writeLogin = Effect.gen(function* () { | ||
| 25 | const fs = yield* FileSystem.FileSystem; | ||
| 26 | const path = yield* Path.Path; | ||
| 27 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 28 | yield* fs.writeFileString( | ||
| 29 | path.join(directory, ".credentials.json"), | ||
| 30 | '{"claudeAiOauth":{"accessToken":"oauth-token"}}', | ||
| 31 | ); | ||
| 32 | const accountConfigPath = path.join(directory, ".claude.json"); | ||
| 33 | yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); | ||
| 34 | return { configDir: directory, accountConfigPath }; | ||
| 35 | }); | ||
| 36 | |||
| 37 | const respond = (status: number, body: unknown) => | ||
| 38 | HttpClient.make((request) => | ||
| 39 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), | ||
| 40 | ); | ||
| 41 | const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); | ||
| 42 | |||
| 43 | describe("claudeResetCreditsToContract", () => { | ||
| 44 | it("counts live grants and pins the next usable one", () => { | ||
| 45 | expect( | ||
| 46 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 47 | { | ||
| 48 | eligible: true, | ||
| 49 | next_grant_id: "grant_a", | ||
| 50 | grants: [ | ||
| 51 | grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), | ||
| 52 | grant({ id: "paused", paused: true }), | ||
| 53 | grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), | ||
| 54 | grant({ id: "garbled", ends_at: "not a date" }), | ||
| 55 | grant({ id: "date_only", ends_at: "2026-10-01" }), | ||
| 56 | grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), | ||
| 57 | grant({ id: "empty", ends_at: "" }), | ||
| 58 | grant({ id: "Not Valid" }), | ||
| 59 | grant({ id: "grant_b", resets_left: 3, usable_now: false }), | ||
| 60 | ], | ||
| 61 | }, | ||
| 62 | NOW, | ||
| 63 | ), | ||
| 64 | ).toEqual({ | ||
| 65 | availableCount: 2, | ||
| 66 | nextCreditId: "grant_a", | ||
| 67 | nextExpiresAt: "2026-10-01T00:00:00.000Z", | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("offers nothing to redeem without a usable next grant or an eligible account", () => { | ||
| 72 | expect( | ||
| 73 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 74 | { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, | ||
| 75 | NOW, | ||
| 76 | ), | ||
| 77 | ).toEqual({ availableCount: 0 }); | ||
| 78 | expect( | ||
| 79 | ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), | ||
| 80 | ).toEqual({ | ||
| 81 | availableCount: 0, | ||
| 82 | }); | ||
| 83 | expect( | ||
| 84 | ClaudeResetCredits.claudeResetCreditsToContract( | ||
| 85 | { eligible: false, grants: [grant({})] }, | ||
| 86 | NOW, | ||
| 87 | ), | ||
| 88 | ).toBeUndefined(); | ||
| 89 | expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { | ||
| 94 | it.effect("reads the grants with the CLI's request", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const { configDir } = yield* writeLogin; | ||
| 97 | const client = HttpClient.make((request) => { | ||
| 98 | expect(request.method).toBe("GET"); | ||
| 99 | expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); | ||
| 100 | expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); | ||
| 101 | expect(request.headers.authorization).toBe("Bearer oauth-token"); | ||
| 102 | expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); | ||
| 103 | expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); | ||
| 104 | return Effect.succeed( | ||
| 105 | HttpClientResponse.fromWeb( | ||
| 106 | request, | ||
| 107 | Response.json({ | ||
| 108 | cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | }); | ||
| 113 | const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 114 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 115 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 116 | ); | ||
| 117 | expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("reads nothing from keychain logins or failed requests", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const { configDir } = yield* writeLogin; | ||
| 124 | const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 125 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 126 | Effect.provideService(HttpClient.HttpClient, refuseRequests), | ||
| 127 | ); | ||
| 128 | const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( | ||
| 129 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 130 | Effect.provideService(HttpClient.HttpClient, respond(429, {})), | ||
| 131 | ); | ||
| 132 | expect([darwin, limited]).toEqual([undefined, undefined]); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | }); | ||
| 136 | |||
| 137 | const ClaimBody = Schema.fromJsonString( | ||
| 138 | Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), | ||
| 139 | ); | ||
| 140 | const decodeClaimBody = Schema.decodeEffect(ClaimBody); | ||
| 141 | |||
| 142 | const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => | ||
| 143 | Effect.gen(function* () { | ||
| 144 | const login = yield* writeLogin; | ||
| 145 | return yield* ClaudeResetCredits.consumeClaudeResetCredit({ | ||
| 146 | ...login, | ||
| 147 | version: "2.1.0", | ||
| 148 | ...ids, | ||
| 149 | }).pipe( | ||
| 150 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 151 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 152 | Effect.result, | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 156 | effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { | ||
| 157 | it.effect("claims the grant for the organization", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const client = HttpClient.make((request) => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | expect(request.method).toBe("POST"); | ||
| 162 | expect(request.url).toBe( | ||
| 163 | "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", | ||
| 164 | ); | ||
| 165 | expect(request.headers.authorization).toBe("Bearer oauth-token"); | ||
| 166 | const body = | ||
| 167 | request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; | ||
| 168 | expect(yield* decodeClaimBody(body)).toEqual({ | ||
| 169 | program: "cedar_ember", | ||
| 170 | grant_id: "grant_a", | ||
| 171 | request_id: "r-1", | ||
| 172 | }); | ||
| 173 | return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); | ||
| 174 | }).pipe(Effect.orDie), | ||
| 175 | ); | ||
| 176 | expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | it.effect("maps each answer to an outcome or a failure", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | for (const [result, outcome] of [ | ||
| 183 | ["not_limited", "nothingToReset"], | ||
| 184 | ["already_used", "alreadyRedeemed"], | ||
| 185 | ["ineligible", "noCredit"], | ||
| 186 | ] as const) { | ||
| 187 | expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); | ||
| 188 | } | ||
| 189 | for (const client of [ | ||
| 190 | respond(200, { result: "cooldown" }), | ||
| 191 | respond(429, {}), | ||
| 192 | respond(401, {}), | ||
| 193 | ]) { | ||
| 194 | const result = yield* consume(client); | ||
| 195 | expect(result).toMatchObject({ _tag: "Failure" }); | ||
| 196 | // Claude answered, so a retry must be a new claim. | ||
| 197 | if (result._tag === "Failure") { | ||
| 198 | expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); | ||
| 199 | } | ||
| 200 | } | ||
| 201 | // No answer, or Claude could not confirm the claim: a retry is the same claim. | ||
| 202 | for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { | ||
| 203 | const unanswered = yield* consume(client); | ||
| 204 | expect(unanswered).toMatchObject({ _tag: "Failure" }); | ||
| 205 | if (unanswered._tag === "Failure") { | ||
| 206 | expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( | ||
| 207 | false, | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | } | ||
| 211 | }), | ||
| 212 | ); | ||
| 213 | |||
| 214 | it.effect("times out a stalled claim body", () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const login = yield* writeLogin; | ||
| 217 | const readingBody = yield* Deferred.make<void>(); | ||
| 218 | const client = HttpClient.make((request) => { | ||
| 219 | const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); | ||
| 220 | Object.defineProperty(response, "json", { | ||
| 221 | value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), | ||
| 222 | }); | ||
| 223 | return Effect.succeed(response); | ||
| 224 | }); | ||
| 225 | const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ | ||
| 226 | ...login, | ||
| 227 | version: "2.1.0", | ||
| 228 | grantId: "grant_a", | ||
| 229 | requestId: "r-1", | ||
| 230 | }).pipe( | ||
| 231 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 232 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 233 | Effect.result, | ||
| 234 | Effect.forkChild, | ||
| 235 | ); | ||
| 236 | yield* Deferred.await(readingBody); | ||
| 237 | yield* TestClock.adjust("26 seconds"); | ||
| 238 | expect(yield* Fiber.join(claim)).toMatchObject({ | ||
| 239 | _tag: "Failure", | ||
| 240 | failure: { | ||
| 241 | _tag: "ClaudeResetCreditError", | ||
| 242 | reason: "requestFailed", | ||
| 243 | cause: { _tag: "TimeoutError" }, | ||
| 244 | }, | ||
| 245 | }); | ||
| 246 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 247 | ); | ||
| 248 | |||
| 249 | it.effect("refuses malformed ids without sending anything", () => | ||
| 250 | Effect.gen(function* () { | ||
| 251 | for (const ids of [ | ||
| 252 | { grantId: "Bad Grant", requestId: "r-1" }, | ||
| 253 | { grantId: "grant_a", requestId: "has space" }, | ||
| 254 | ]) { | ||
| 255 | expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); | ||
| 256 | } | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | }); | ||
apps/server/src/provider/claudeResetCredits.ts created+270| ... | @@ -0,0 +1,270 @@ | ||
| 1 | /** | ||
| 2 | * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the | ||
| 3 | * grants from the OAuth usage endpoint and claims one against the | ||
| 4 | * organization; this module does the same with the credentials the CLI keeps | ||
| 5 | * in its config directory. macOS keeps them in the keychain, so there the | ||
| 6 | * feature is not offered. | ||
| 7 | * | ||
| 8 | * @module provider/claudeResetCredits | ||
| 9 | */ | ||
| 10 | import * as NodeOS from "node:os"; | ||
| 11 | import type { | ||
| 12 | ProviderConsumeResetCreditOutcome, | ||
| 13 | ServerProviderResetCredits, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as DateTime from "effect/DateTime"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Path from "effect/Path"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 23 | |||
| 24 | const API_BASE = "https://api.anthropic.com"; | ||
| 25 | const PROGRAM = "cedar_ember"; | ||
| 26 | const GRANT_ID = /^[a-z0-9_-]{1,40}$/; | ||
| 27 | const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; | ||
| 28 | const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; | ||
| 29 | |||
| 30 | const Credentials = Schema.Struct({ | ||
| 31 | claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), | ||
| 32 | }); | ||
| 33 | const Config = Schema.Struct({ | ||
| 34 | oauthAccount: Schema.optional( | ||
| 35 | Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), | ||
| 36 | ), | ||
| 37 | }); | ||
| 38 | const Grant = Schema.Struct({ | ||
| 39 | id: Schema.String.check(Schema.isPattern(GRANT_ID)), | ||
| 40 | resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | ||
| 41 | ends_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 42 | paused: Schema.optional(Schema.Boolean), | ||
| 43 | usable_now: Schema.optional(Schema.Boolean), | ||
| 44 | }); | ||
| 45 | const decodeGrant = Schema.decodeUnknownOption(Grant); | ||
| 46 | const CedarEmber = Schema.Struct({ | ||
| 47 | eligible: Schema.Boolean, | ||
| 48 | grants: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 49 | next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 50 | }); | ||
| 51 | const UsageResponse = Schema.Struct({ | ||
| 52 | cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), | ||
| 53 | }); | ||
| 54 | const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); | ||
| 55 | const ClaimResponse = Schema.Struct({ | ||
| 56 | result: Schema.Literals([ | ||
| 57 | "reset", | ||
| 58 | "already_used", | ||
| 59 | "not_limited", | ||
| 60 | "cooldown", | ||
| 61 | "ineligible", | ||
| 62 | "unavailable", | ||
| 63 | ]), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const RESET_CREDIT_FAILURES = { | ||
| 67 | malformedCredit: "Claude returned a malformed reset credit.", | ||
| 68 | loginUnreadable: "Claude could not read its login.", | ||
| 69 | accountUnreadable: "Claude could not read its account.", | ||
| 70 | signedOut: "Sign in to Claude again to redeem resets.", | ||
| 71 | rateLimited: "Claude is rate limiting resets. Try again soon.", | ||
| 72 | coolingDown: "Claude resets are cooling down. Try again later.", | ||
| 73 | unconfirmed: | ||
| 74 | "Claude could not confirm the reset. If you are still limited in a moment, try again.", | ||
| 75 | requestFailed: "Claude could not redeem the reset.", | ||
| 76 | } as const; | ||
| 77 | |||
| 78 | class ClaudeResetCreditError extends Schema.TaggedError<ClaudeResetCreditError>()( | ||
| 79 | "ClaudeResetCreditError", | ||
| 80 | { | ||
| 81 | reason: Schema.Literals( | ||
| 82 | Object.keys(RESET_CREDIT_FAILURES) as Array<keyof typeof RESET_CREDIT_FAILURES>, | ||
| 83 | ), | ||
| 84 | cause: Schema.optional(Schema.Defect()), | ||
| 85 | }, | ||
| 86 | ) { | ||
| 87 | override get message(): string { | ||
| 88 | return RESET_CREDIT_FAILURES[this.reason]; | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); | ||
| 93 | |||
| 94 | /** | ||
| 95 | * Every reset failure except `requestFailed` and `unconfirmed` is final: | ||
| 96 | * Claude answered, or nothing was sent. An unanswered or unconfirmed claim | ||
| 97 | * retries with the same request id. | ||
| 98 | */ | ||
| 99 | export const isSettledClaudeResetCreditFailure = (error: unknown) => | ||
| 100 | isClaudeResetCreditError(error) && | ||
| 101 | error.reason !== "requestFailed" && | ||
| 102 | error.reason !== "unconfirmed"; | ||
| 103 | |||
| 104 | /** Rejects unparseable and calendar-invalid timestamps such as February 30. */ | ||
| 105 | const isFutureTimestamp = (value: string, nowMs: number) => { | ||
| 106 | if (!COMPLETE_TIMESTAMP.test(value)) return false; | ||
| 107 | const [year, month, day] = value.slice(0, 10).split("-").map(Number); | ||
| 108 | return ( | ||
| 109 | Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) | ||
| 110 | ); | ||
| 111 | }; | ||
| 112 | |||
| 113 | /** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ | ||
| 114 | export function claudeResetCreditsToContract( | ||
| 115 | block: unknown, | ||
| 116 | nowMs: number, | ||
| 117 | ): ServerProviderResetCredits | undefined { | ||
| 118 | const parsed = decodeCedarEmber(block); | ||
| 119 | if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; | ||
| 120 | const live = (parsed.value.grants ?? []) | ||
| 121 | .flatMap((raw) => Option.toArray(decodeGrant(raw))) | ||
| 122 | .filter( | ||
| 123 | (grant) => | ||
| 124 | !grant.paused && | ||
| 125 | grant.usable_now && | ||
| 126 | (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), | ||
| 127 | ); | ||
| 128 | const next = live.find((grant) => grant.id === parsed.value.next_grant_id); | ||
| 129 | const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); | ||
| 130 | return { | ||
| 131 | availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, | ||
| 132 | ...(Option.isSome(nextExpiresAt) | ||
| 133 | ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } | ||
| 134 | : {}), | ||
| 135 | ...(next ? { nextCreditId: next.id } : {}), | ||
| 136 | }; | ||
| 137 | } | ||
| 138 | |||
| 139 | const readJson = <S extends Schema.Top>(schema: S, file: string) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | const fs = yield* FileSystem.FileSystem; | ||
| 142 | return yield* fs.readFileString(file).pipe( | ||
| 143 | Effect.catchTags({ | ||
| 144 | PlatformError: (error) => | ||
| 145 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 146 | }), | ||
| 147 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), | ||
| 148 | ); | ||
| 149 | }); | ||
| 150 | |||
| 151 | const readAccessToken = (configDir: string) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | if ((yield* HostProcessPlatform) === "darwin") return undefined; | ||
| 154 | const path = yield* Path.Path; | ||
| 155 | const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); | ||
| 156 | return credentials.claudeAiOauth?.accessToken?.trim() || undefined; | ||
| 157 | }); | ||
| 158 | |||
| 159 | const withClaudeHeaders = (token: string, version: string) => | ||
| 160 | HttpClientRequest.setHeaders({ | ||
| 161 | authorization: `Bearer ${token}`, | ||
| 162 | "anthropic-beta": "oauth-2025-04-20", | ||
| 163 | "user-agent": `claude-cli/${version} (external, cli)`, | ||
| 164 | }); | ||
| 165 | |||
| 166 | /** | ||
| 167 | * Reads the banked resets for the login in `configDir`. Any failure reads as | ||
| 168 | * "no resets" so the usage bars never break on this optional extra. | ||
| 169 | */ | ||
| 170 | export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( | ||
| 171 | function* (configDir: string, version: string) { | ||
| 172 | const token = yield* readAccessToken(configDir); | ||
| 173 | if (!token) return undefined; | ||
| 174 | const client = yield* HttpClient.HttpClient; | ||
| 175 | const response = yield* client.execute( | ||
| 176 | HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { | ||
| 177 | urlParams: { cedar_ember: "1", skip_spend: "1" }, | ||
| 178 | }).pipe(withClaudeHeaders(token, version)), | ||
| 179 | ); | ||
| 180 | const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( | ||
| 181 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 182 | ); | ||
| 183 | return claudeResetCreditsToContract( | ||
| 184 | body.cedar_ember, | ||
| 185 | DateTime.toEpochMillis(yield* DateTime.now), | ||
| 186 | ); | ||
| 187 | }, | ||
| 188 | Effect.timeout("10 seconds"), | ||
| 189 | Effect.orElseSucceed(() => undefined), | ||
| 190 | ); | ||
| 191 | |||
| 192 | /** The CLI keeps the account record beside its settings, or in the home directory by default. */ | ||
| 193 | export const claudeAccountConfigPath = (configDir: string | undefined) => | ||
| 194 | Effect.map(Path.Path, (path) => | ||
| 195 | configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), | ||
| 196 | ); | ||
| 197 | |||
| 198 | const CLAIM_OUTCOMES = { | ||
| 199 | reset: "reset", | ||
| 200 | not_limited: "nothingToReset", | ||
| 201 | already_used: "alreadyRedeemed", | ||
| 202 | ineligible: "noCredit", | ||
| 203 | } as const satisfies Record<string, ProviderConsumeResetCreditOutcome>; | ||
| 204 | |||
| 205 | /** | ||
| 206 | * Claims `grantId`. `requestId` is the idempotency key: a retry with the same | ||
| 207 | * id is the same claim. Ids are checked before anything is sent. | ||
| 208 | */ | ||
| 209 | export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { | ||
| 210 | readonly configDir: string; | ||
| 211 | readonly accountConfigPath: string; | ||
| 212 | readonly version: string; | ||
| 213 | readonly grantId: string; | ||
| 214 | readonly requestId: string; | ||
| 215 | }) { | ||
| 216 | if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { | ||
| 217 | return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); | ||
| 218 | } | ||
| 219 | const token = yield* readAccessToken(input.configDir).pipe( | ||
| 220 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), | ||
| 221 | ); | ||
| 222 | const config = yield* readJson(Config, input.accountConfigPath).pipe( | ||
| 223 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), | ||
| 224 | ); | ||
| 225 | const organization = config.oauthAccount?.organizationUuid?.trim(); | ||
| 226 | if (!token || !organization) { | ||
| 227 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); | ||
| 228 | } | ||
| 229 | const client = yield* HttpClient.HttpClient; | ||
| 230 | const response = yield* client | ||
| 231 | .execute( | ||
| 232 | HttpClientRequest.post( | ||
| 233 | new URL( | ||
| 234 | `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, | ||
| 235 | API_BASE, | ||
| 236 | ), | ||
| 237 | ).pipe( | ||
| 238 | withClaudeHeaders(token, input.version), | ||
| 239 | HttpClientRequest.bodyJsonUnsafe({ | ||
| 240 | program: PROGRAM, | ||
| 241 | grant_id: input.grantId, | ||
| 242 | request_id: input.requestId, | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ) | ||
| 246 | .pipe( | ||
| 247 | Effect.timeout("25 seconds"), | ||
| 248 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), | ||
| 249 | ); | ||
| 250 | if (response.status === 429) { | ||
| 251 | return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); | ||
| 252 | } | ||
| 253 | if (response.status === 401 || response.status === 403) { | ||
| 254 | return yield* new ClaudeResetCreditError({ reason: "signedOut" }); | ||
| 255 | } | ||
| 256 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 257 | Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), | ||
| 258 | Effect.timeout("25 seconds"), | ||
| 259 | Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), | ||
| 260 | ); | ||
| 261 | if (body.result === "cooldown") { | ||
| 262 | return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); | ||
| 263 | } | ||
| 264 | // Claude could not say whether the claim landed, so, like the CLI, keep the | ||
| 265 | // request id and let the retry ask about the same claim. | ||
| 266 | if (body.result === "unavailable") { | ||
| 267 | return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); | ||
| 268 | } | ||
| 269 | return CLAIM_OUTCOMES[body.result]; | ||
| 270 | }); | ||
apps/server/src/provider/claudeUsageLimits.test.ts created+180| ... | @@ -0,0 +1,180 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { claudeRateLimitEventToUpdate, claudeUsageResponseToLimits } from "./claudeUsageLimits.ts"; | ||
| 4 | |||
| 5 | const checkedAt = "2026-07-18T10:00:00.000Z"; | ||
| 6 | const noNames = { overageIncluded: undefined } as const; | ||
| 7 | |||
| 8 | describe("claudeUsageResponseToLimits", () => { | ||
| 9 | it("maps the session, weekly, and model-scoped weekly windows", () => { | ||
| 10 | expect( | ||
| 11 | claudeUsageResponseToLimits({ | ||
| 12 | checkedAt, | ||
| 13 | response: { | ||
| 14 | rate_limits_available: true, | ||
| 15 | rate_limits: { | ||
| 16 | five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, | ||
| 17 | seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, | ||
| 18 | seven_day_opus: { utilization: 3, resets_at: null }, | ||
| 19 | // Newer CLIs add this on top of the typed keys; the pinned SDK | ||
| 20 | // typings do not know it yet. | ||
| 21 | ...({ | ||
| 22 | model_scoped: [ | ||
| 23 | { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, | ||
| 24 | { display_name: "Ghost", utilization: null, resets_at: null }, | ||
| 25 | ], | ||
| 26 | } as object), | ||
| 27 | extra_usage: { | ||
| 28 | is_enabled: false, | ||
| 29 | monthly_limit: null, | ||
| 30 | used_credits: null, | ||
| 31 | utilization: null, | ||
| 32 | }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | names: { overageIncluded: "Fable" }, | ||
| 38 | limits: { | ||
| 39 | checkedAt, | ||
| 40 | windows: [ | ||
| 41 | { | ||
| 42 | id: "five_hour", | ||
| 43 | kind: "session", | ||
| 44 | label: "Session", | ||
| 45 | usedPercent: 54, | ||
| 46 | windowDurationMins: 300, | ||
| 47 | resetsAt: "2026-07-18T14:39:00.000Z", | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | id: "seven_day", | ||
| 51 | kind: "weekly", | ||
| 52 | label: "Weekly", | ||
| 53 | usedPercent: 18.4, | ||
| 54 | windowDurationMins: 10080, | ||
| 55 | resetsAt: "2026-07-24T08:59:00.000Z", | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | id: "seven_day_fable", | ||
| 59 | kind: "weekly", | ||
| 60 | label: "Weekly · Fable", | ||
| 61 | usedPercent: 73, | ||
| 62 | windowDurationMins: 10080, | ||
| 63 | resetsAt: "2026-07-24T08:59:00.000Z", | ||
| 64 | }, | ||
| 65 | ], | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("names the overage-included bucket only from a scoped entry that drew a row", () => { | ||
| 71 | expect( | ||
| 72 | claudeUsageResponseToLimits({ | ||
| 73 | checkedAt, | ||
| 74 | response: { | ||
| 75 | rate_limits_available: true, | ||
| 76 | rate_limits: { | ||
| 77 | ...({ | ||
| 78 | model_scoped: [ | ||
| 79 | { display_name: "Ghost", utilization: null, resets_at: null }, | ||
| 80 | { display_name: "Fable", utilization: 5, resets_at: null }, | ||
| 81 | ], | ||
| 82 | } as object), | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | }).names, | ||
| 86 | ).toEqual({ overageIncluded: "Fable" }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("reports API key and Bedrock accounts as unsupported", () => { | ||
| 90 | expect( | ||
| 91 | claudeUsageResponseToLimits({ | ||
| 92 | checkedAt, | ||
| 93 | response: { rate_limits_available: false, rate_limits: null }, | ||
| 94 | }).limits, | ||
| 95 | ).toEqual({ checkedAt, windows: [], unavailable: { reason: "unsupported" } }); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("skips a window the endpoint reports without a utilization", () => { | ||
| 99 | expect( | ||
| 100 | claudeUsageResponseToLimits({ | ||
| 101 | checkedAt, | ||
| 102 | response: { | ||
| 103 | rate_limits_available: true, | ||
| 104 | rate_limits: { | ||
| 105 | five_hour: { utilization: null, resets_at: null }, | ||
| 106 | seven_day: { utilization: 250, resets_at: null }, | ||
| 107 | }, | ||
| 108 | }, | ||
| 109 | }).limits.windows, | ||
| 110 | ).toEqual([ | ||
| 111 | { | ||
| 112 | id: "seven_day", | ||
| 113 | kind: "weekly", | ||
| 114 | label: "Weekly", | ||
| 115 | usedPercent: 100, | ||
| 116 | windowDurationMins: 10080, | ||
| 117 | }, | ||
| 118 | ]); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("claudeRateLimitEventToUpdate", () => { | ||
| 123 | it("scales the 0–1 utilization and epoch-second reset onto the probe's window id", () => { | ||
| 124 | expect( | ||
| 125 | claudeRateLimitEventToUpdate( | ||
| 126 | { | ||
| 127 | status: "allowed_warning", | ||
| 128 | rateLimitType: "seven_day", | ||
| 129 | utilization: 0.85, | ||
| 130 | resetsAt: 1_784_000_000, | ||
| 131 | }, | ||
| 132 | noNames, | ||
| 133 | ), | ||
| 134 | ).toEqual({ | ||
| 135 | windows: [ | ||
| 136 | { | ||
| 137 | id: "seven_day", | ||
| 138 | kind: "weekly", | ||
| 139 | label: "Weekly", | ||
| 140 | usedPercent: 85, | ||
| 141 | windowDurationMins: 10080, | ||
| 142 | resetsAt: "2026-07-14T03:33:20.000Z", | ||
| 143 | }, | ||
| 144 | ], | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | it("lands the streamed overage-included bucket on the row the probe named", () => { | ||
| 149 | const event = { | ||
| 150 | status: "allowed", | ||
| 151 | rateLimitType: "seven_day_overage_included" as never, | ||
| 152 | utilization: 0.4, | ||
| 153 | } as const; | ||
| 154 | // No probe has named the bucket yet: guessing would open a stray row. | ||
| 155 | expect(claudeRateLimitEventToUpdate(event, noNames)).toBeUndefined(); | ||
| 156 | expect(claudeRateLimitEventToUpdate(event, { overageIncluded: "Fable" })).toEqual({ | ||
| 157 | windows: [ | ||
| 158 | { | ||
| 159 | id: "seven_day_fable", | ||
| 160 | kind: "weekly", | ||
| 161 | label: "Weekly · Fable", | ||
| 162 | usedPercent: 40, | ||
| 163 | windowDurationMins: 10080, | ||
| 164 | }, | ||
| 165 | ], | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | |||
| 169 | it("ignores windows the page does not render and events without a utilization", () => { | ||
| 170 | expect( | ||
| 171 | claudeRateLimitEventToUpdate( | ||
| 172 | { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, | ||
| 173 | noNames, | ||
| 174 | ), | ||
| 175 | ).toBeUndefined(); | ||
| 176 | expect( | ||
| 177 | claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), | ||
| 178 | ).toBeUndefined(); | ||
| 179 | }); | ||
| 180 | }); | ||
apps/server/src/provider/claudeUsageLimits.ts created+199| ... | @@ -0,0 +1,199 @@ | ||
| 1 | /** | ||
| 2 | * Claude Code subscription usage. Both sources produce windows with the same | ||
| 3 | * ids so a turn-driven `rate_limit_event` lands on the row the SDK's | ||
| 4 | * `get_usage` read established: | ||
| 5 | * | ||
| 6 | * - `get_usage` (on demand, during the capabilities probe) reports every | ||
| 7 | * window at once as 0–100 percentages with ISO reset times. | ||
| 8 | * - `rate_limit_event` (streamed during a turn) names one window at a time | ||
| 9 | * with a 0–1 utilization fraction and an epoch-seconds reset. | ||
| 10 | * | ||
| 11 | * @module provider/claudeUsageLimits | ||
| 12 | */ | ||
| 13 | import type { SDKControlGetUsageResponse, SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; | ||
| 14 | import type { | ||
| 15 | ProviderUsageLimitsUpdate, | ||
| 16 | ServerProviderUsageLimits, | ||
| 17 | ServerProviderUsageWindow, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import * as DateTime from "effect/DateTime"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Ref from "effect/Ref"; | ||
| 23 | |||
| 24 | import { | ||
| 25 | clampPercent, | ||
| 26 | makeUnavailableUsageLimits, | ||
| 27 | makeUsageLimits, | ||
| 28 | } from "./providerUsageLimits.ts"; | ||
| 29 | |||
| 30 | const SESSION_MINS = 5 * 60; | ||
| 31 | const WEEK_MINS = 7 * 24 * 60; | ||
| 32 | |||
| 33 | /** | ||
| 34 | * The account-wide windows, keyed by the SDK's `rateLimitType`. Model-scoped | ||
| 35 | * weeklies are additive on top of these: the CLI reports them under | ||
| 36 | * `rate_limits.model_scoped[]` on `get_usage` and streams the overage-included | ||
| 37 | * model bucket (Fable today) as `seven_day_overage_included`. | ||
| 38 | */ | ||
| 39 | const WINDOWS: Readonly< | ||
| 40 | Record<string, Pick<ServerProviderUsageWindow, "kind" | "label" | "windowDurationMins">> | ||
| 41 | > = { | ||
| 42 | five_hour: { kind: "session", label: "Session", windowDurationMins: SESSION_MINS }, | ||
| 43 | seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, | ||
| 44 | }; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * The streamed event names the overage-included bucket by type | ||
| 48 | * (`seven_day_overage_included`), while `get_usage` names it by the model's | ||
| 49 | * `display_name`. Which model that is changes over time, so the probe records | ||
| 50 | * the name it saw and the event mapper reuses it; the mid-turn update then | ||
| 51 | * lands on the row the probe drew instead of opening a second one. | ||
| 52 | */ | ||
| 53 | const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; | ||
| 54 | |||
| 55 | export interface ClaudeScopedLimitNames { | ||
| 56 | readonly overageIncluded: string | undefined; | ||
| 57 | } | ||
| 58 | |||
| 59 | export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({ | ||
| 60 | overageIncluded: undefined, | ||
| 61 | }); | ||
| 62 | |||
| 63 | function scopedWindowId(displayName: string): string { | ||
| 64 | return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`; | ||
| 65 | } | ||
| 66 | |||
| 67 | function scopedWindow( | ||
| 68 | displayName: string, | ||
| 69 | usedPercent: number, | ||
| 70 | resetsAt: string | undefined, | ||
| 71 | ): ServerProviderUsageWindow { | ||
| 72 | return { | ||
| 73 | id: scopedWindowId(displayName), | ||
| 74 | kind: "weekly", | ||
| 75 | label: `Weekly · ${displayName}`, | ||
| 76 | windowDurationMins: WEEK_MINS, | ||
| 77 | usedPercent: clampPercent(usedPercent), | ||
| 78 | ...(resetsAt ? { resetsAt } : {}), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | /** | ||
| 83 | * `model_scoped` shipped in the CLI after the SDK typings we pin, so it is | ||
| 84 | * read structurally until the `.d.ts` catches up. | ||
| 85 | */ | ||
| 86 | interface ModelScopedWindow { | ||
| 87 | readonly display_name: string; | ||
| 88 | readonly utilization: number | null; | ||
| 89 | readonly resets_at: string | null; | ||
| 90 | } | ||
| 91 | |||
| 92 | function readModelScoped(rateLimits: object): ReadonlyArray<ModelScopedWindow> { | ||
| 93 | const raw = (rateLimits as { readonly model_scoped?: unknown }).model_scoped; | ||
| 94 | if (!Array.isArray(raw)) return []; | ||
| 95 | return raw.filter( | ||
| 96 | (entry): entry is ModelScopedWindow => | ||
| 97 | typeof entry === "object" && | ||
| 98 | entry !== null && | ||
| 99 | typeof (entry as ModelScopedWindow).display_name === "string", | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | function isoFromEpochSeconds(value: number | undefined): string | undefined { | ||
| 104 | if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; | ||
| 105 | const dt = DateTime.make(value * 1000); | ||
| 106 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 107 | } | ||
| 108 | |||
| 109 | function isoFromString(value: string | null | undefined): string | undefined { | ||
| 110 | if (!value) return undefined; | ||
| 111 | const dt = DateTime.make(value); | ||
| 112 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 113 | } | ||
| 114 | |||
| 115 | function makeWindow( | ||
| 116 | id: keyof typeof WINDOWS & string, | ||
| 117 | usedPercent: number, | ||
| 118 | resetsAt: string | undefined, | ||
| 119 | ): ServerProviderUsageWindow { | ||
| 120 | const window = WINDOWS[id]!; | ||
| 121 | return { | ||
| 122 | id, | ||
| 123 | ...window, | ||
| 124 | usedPercent: clampPercent(usedPercent), | ||
| 125 | ...(resetsAt ? { resetsAt } : {}), | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | /** | ||
| 130 | * Utilization is a 0–1 fraction on the streamed event. An overage-included | ||
| 131 | * event before any probe has named the bucket is dropped: guessing a name | ||
| 132 | * would draw a row the next probe cannot reconcile. | ||
| 133 | */ | ||
| 134 | export function claudeRateLimitEventToUpdate( | ||
| 135 | info: SDKRateLimitInfo, | ||
| 136 | names: ClaudeScopedLimitNames, | ||
| 137 | ): ProviderUsageLimitsUpdate | undefined { | ||
| 138 | const type: string | undefined = info.rateLimitType; | ||
| 139 | if (!type || typeof info.utilization !== "number") { | ||
| 140 | return undefined; | ||
| 141 | } | ||
| 142 | const usedPercent = info.utilization * 100; | ||
| 143 | const resetsAt = isoFromEpochSeconds(info.resetsAt); | ||
| 144 | if (type in WINDOWS) { | ||
| 145 | return { windows: [makeWindow(type, usedPercent, resetsAt)] }; | ||
| 146 | } | ||
| 147 | if (type === OVERAGE_INCLUDED_EVENT_TYPE && names.overageIncluded) { | ||
| 148 | return { windows: [scopedWindow(names.overageIncluded, usedPercent, resetsAt)] }; | ||
| 149 | } | ||
| 150 | return undefined; | ||
| 151 | } | ||
| 152 | |||
| 153 | /** | ||
| 154 | * Percentages on the `get_usage` response are already 0–100. Also yields the | ||
| 155 | * scoped-bucket names the response carried, for the event mapper to reuse. | ||
| 156 | */ | ||
| 157 | export function claudeUsageResponseToLimits(input: { | ||
| 158 | readonly response: Pick<SDKControlGetUsageResponse, "rate_limits_available" | "rate_limits">; | ||
| 159 | readonly checkedAt: string; | ||
| 160 | }): { readonly limits: ServerProviderUsageLimits; readonly names: ClaudeScopedLimitNames } { | ||
| 161 | const { response, checkedAt } = input; | ||
| 162 | if (!response.rate_limits_available || !response.rate_limits) { | ||
| 163 | return { | ||
| 164 | limits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }), | ||
| 165 | names: { overageIncluded: undefined }, | ||
| 166 | }; | ||
| 167 | } | ||
| 168 | const windows: ServerProviderUsageWindow[] = []; | ||
| 169 | for (const id of Object.keys(WINDOWS)) { | ||
| 170 | const window = response.rate_limits[id as "five_hour" | "seven_day"]; | ||
| 171 | if (!window || typeof window.utilization !== "number") continue; | ||
| 172 | windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); | ||
| 173 | } | ||
| 174 | // The CLI filters `model_scoped` to the overage-included allowlist, which | ||
| 175 | // today holds one model; the first entry is the one the event refers to. | ||
| 176 | let overageIncluded: string | undefined; | ||
| 177 | for (const entry of readModelScoped(response.rate_limits)) { | ||
| 178 | if (typeof entry.utilization !== "number") continue; | ||
| 179 | windows.push( | ||
| 180 | scopedWindow(entry.display_name, entry.utilization, isoFromString(entry.resets_at)), | ||
| 181 | ); | ||
| 182 | // Only a bucket that drew a row may receive events; naming one that was | ||
| 183 | // skipped would let a mid-turn event open a row the probe never showed. | ||
| 184 | overageIncluded ??= entry.display_name; | ||
| 185 | } | ||
| 186 | return { | ||
| 187 | limits: makeUsageLimits({ checkedAt, windows }), | ||
| 188 | names: { overageIncluded }, | ||
| 189 | }; | ||
| 190 | } | ||
| 191 | |||
| 192 | /** Probe-side helper: map the response and remember the scoped names for events. */ | ||
| 193 | export const recordClaudeUsageResponse = ( | ||
| 194 | namesRef: Ref.Ref<ClaudeScopedLimitNames>, | ||
| 195 | input: Parameters<typeof claudeUsageResponseToLimits>[0], | ||
| 196 | ): Effect.Effect<ServerProviderUsageLimits> => { | ||
| 197 | const { limits, names } = claudeUsageResponseToLimits(input); | ||
| 198 | return Ref.set(namesRef, names).pipe(Effect.as(limits)); | ||
| 199 | }; | ||
apps/server/src/provider/codexLaunchArgs.test.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import * as NodeAssert from "node:assert/strict"; | ||
| 2 | |||
| 3 | import { describe, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | codexAppServerArgs, | ||
| 7 | codexExecLaunchArgs, | ||
| 8 | resolveCodexLaunchArgs, | ||
| 9 | } from "./codexLaunchArgs.ts"; | ||
| 10 | |||
| 11 | describe("resolveCodexLaunchArgs", () => { | ||
| 12 | it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { | ||
| 13 | NodeAssert.equal( | ||
| 14 | resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), | ||
| 15 | "--enable foo", | ||
| 16 | ); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { | ||
| 20 | NodeAssert.equal( | ||
| 21 | resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), | ||
| 22 | "--strict-config", | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("ignores whitespace-only environment values", () => { | ||
| 27 | NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("codexAppServerArgs", () => { | ||
| 32 | it("returns the app-server command for empty launch args", () => { | ||
| 33 | NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("appends parsed launch args after app-server", () => { | ||
| 37 | NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ | ||
| 38 | "app-server", | ||
| 39 | "--strict-config", | ||
| 40 | "--enable", | ||
| 41 | "foo", | ||
| 42 | ]); | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | describe("codexExecLaunchArgs", () => { | ||
| 47 | it("keeps shared codex flags and omits app-server-only flags", () => { | ||
| 48 | NodeAssert.deepStrictEqual( | ||
| 49 | codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), | ||
| 50 | ["--strict-config", "--enable", "foo", "--config", "model=gpt 5"], | ||
| 51 | ); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("does not pair value-taking flags with adjacent flags", () => { | ||
| 55 | NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ | ||
| 56 | "--strict-config", | ||
| 57 | ]); | ||
| 58 | }); | ||
| 59 | }); | ||
apps/server/src/provider/codexLaunchArgs.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; | ||
| 2 | |||
| 3 | const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; | ||
| 4 | |||
| 5 | export const resolveCodexLaunchArgs = ( | ||
| 6 | launchArgs?: string, | ||
| 7 | environment: NodeJS.ProcessEnv = process.env, | ||
| 8 | ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; | ||
| 9 | |||
| 10 | const codexLaunchArgv = (launchArgs?: string): ReadonlyArray<string> => tokenizeCliArgs(launchArgs); | ||
| 11 | |||
| 12 | export const codexAppServerArgs = (launchArgs?: string) => [ | ||
| 13 | "app-server", | ||
| 14 | ...codexLaunchArgv(launchArgs), | ||
| 15 | ]; | ||
| 16 | |||
| 17 | export const codexExecLaunchArgs = (launchArgs?: string) => { | ||
| 18 | const args = codexLaunchArgv(launchArgs); | ||
| 19 | const execArgs: Array<string> = []; | ||
| 20 | |||
| 21 | for (let index = 0; index < args.length; index++) { | ||
| 22 | const arg = args[index]; | ||
| 23 | if (arg === undefined) continue; | ||
| 24 | |||
| 25 | if (arg === "--strict-config" || arg.startsWith("--config=") || arg.startsWith("-c=")) { | ||
| 26 | execArgs.push(arg); | ||
| 27 | } else if (arg === "--config" || arg === "-c" || arg === "--enable" || arg === "--disable") { | ||
| 28 | const value = args[index + 1]; | ||
| 29 | if (value !== undefined && !value.startsWith("-")) { | ||
| 30 | execArgs.push(arg, value); | ||
| 31 | index++; | ||
| 32 | } | ||
| 33 | } else if (arg.startsWith("--enable=") || arg.startsWith("--disable=")) { | ||
| 34 | execArgs.push(arg); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | return execArgs; | ||
| 39 | }; | ||
apps/server/src/provider/codexUsageLimits.test.ts created+316| ... | @@ -0,0 +1,316 @@ | ||
| 1 | import * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | codexRateLimitsFailureMessage, | ||
| 6 | codexRateLimitsToLimits, | ||
| 7 | codexRateLimitsToUpdate, | ||
| 8 | codexResetCreditsToContract, | ||
| 9 | codexUsageLimitMessage, | ||
| 10 | mergeCodexRateLimits, | ||
| 11 | } from "./codexUsageLimits.ts"; | ||
| 12 | |||
| 13 | const checkedAt = "2026-07-18T10:00:00.000Z"; | ||
| 14 | |||
| 15 | describe("codexRateLimitsToLimits", () => { | ||
| 16 | it("maps primary and secondary onto the session and weekly windows", () => { | ||
| 17 | expect( | ||
| 18 | codexRateLimitsToLimits({ | ||
| 19 | checkedAt, | ||
| 20 | snapshot: { | ||
| 21 | planType: "plus", | ||
| 22 | primary: { usedPercent: 12, resetsAt: 1_784_000_000, windowDurationMins: 300 }, | ||
| 23 | secondary: { usedPercent: 47, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, | ||
| 24 | }, | ||
| 25 | }), | ||
| 26 | ).toEqual({ | ||
| 27 | checkedAt, | ||
| 28 | windows: [ | ||
| 29 | { | ||
| 30 | id: "primary", | ||
| 31 | kind: "session", | ||
| 32 | label: "Session", | ||
| 33 | usedPercent: 12, | ||
| 34 | windowDurationMins: 300, | ||
| 35 | resetsAt: "2026-07-14T03:33:20.000Z", | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | id: "secondary", | ||
| 39 | kind: "weekly", | ||
| 40 | label: "Weekly", | ||
| 41 | usedPercent: 47, | ||
| 42 | windowDurationMins: 10080, | ||
| 43 | resetsAt: "2026-07-19T22:26:40.000Z", | ||
| 44 | }, | ||
| 45 | ], | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("treats a lone duration-less primary as monthly on Free and Go", () => { | ||
| 50 | expect( | ||
| 51 | codexRateLimitsToLimits({ | ||
| 52 | checkedAt, | ||
| 53 | snapshot: { planType: "free", primary: { usedPercent: 80, resetsAt: null } }, | ||
| 54 | }).windows, | ||
| 55 | ).toEqual([ | ||
| 56 | { | ||
| 57 | id: "primary", | ||
| 58 | kind: "monthly", | ||
| 59 | label: "Monthly", | ||
| 60 | usedPercent: 80, | ||
| 61 | windowDurationMins: 43_200, | ||
| 62 | }, | ||
| 63 | ]); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("selects the main Codex allowance and leaves Spark out", () => { | ||
| 67 | const spark = { | ||
| 68 | limitId: "codex_bengalfox", | ||
| 69 | primary: { usedPercent: 0, windowDurationMins: 300 }, | ||
| 70 | secondary: { usedPercent: 90, windowDurationMins: 10080 }, | ||
| 71 | }; | ||
| 72 | expect( | ||
| 73 | codexRateLimitsToLimits({ | ||
| 74 | checkedAt, | ||
| 75 | snapshot: spark, | ||
| 76 | rateLimitsByLimitId: { | ||
| 77 | codex_bengalfox: spark, | ||
| 78 | codex: { secondary: { usedPercent: 42, windowDurationMins: 10080 } }, | ||
| 79 | }, | ||
| 80 | }).windows, | ||
| 81 | ).toEqual([ | ||
| 82 | { | ||
| 83 | id: "secondary", | ||
| 84 | kind: "weekly", | ||
| 85 | label: "Weekly", | ||
| 86 | usedPercent: 42, | ||
| 87 | windowDurationMins: 10080, | ||
| 88 | }, | ||
| 89 | ]); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it.each([undefined, null, {}])( | ||
| 93 | "supports legacy reads with no bucket map: %j", | ||
| 94 | (rateLimitsByLimitId) => { | ||
| 95 | const snapshot = { primary: { usedPercent: 12, windowDurationMins: 300 } }; | ||
| 96 | expect(codexRateLimitsToLimits({ checkedAt, snapshot, rateLimitsByLimitId })).toEqual( | ||
| 97 | codexRateLimitsToLimits({ checkedAt, snapshot }), | ||
| 98 | ); | ||
| 99 | }, | ||
| 100 | ); | ||
| 101 | |||
| 102 | it("does not show a model-specific legacy snapshot as the main allowance", () => { | ||
| 103 | expect( | ||
| 104 | codexRateLimitsToLimits({ | ||
| 105 | checkedAt, | ||
| 106 | snapshot: { | ||
| 107 | limitId: "codex_bengalfox", | ||
| 108 | secondary: { usedPercent: 90 }, | ||
| 109 | }, | ||
| 110 | }).windows, | ||
| 111 | ).toEqual([]); | ||
| 112 | }); | ||
| 113 | }); | ||
| 114 | |||
| 115 | describe("codexRateLimitsToUpdate", () => { | ||
| 116 | it("carries only the windows the notification names", () => { | ||
| 117 | expect( | ||
| 118 | codexRateLimitsToUpdate({ | ||
| 119 | secondary: { usedPercent: 51, windowDurationMins: 10080 }, | ||
| 120 | }), | ||
| 121 | ).toEqual({ | ||
| 122 | windows: [ | ||
| 123 | { | ||
| 124 | id: "secondary", | ||
| 125 | kind: "weekly", | ||
| 126 | label: "Weekly", | ||
| 127 | usedPercent: 51, | ||
| 128 | windowDurationMins: 10080, | ||
| 129 | }, | ||
| 130 | ], | ||
| 131 | }); | ||
| 132 | expect(codexRateLimitsToUpdate({ planType: "plus" })).toBeUndefined(); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("ignores Spark notifications so they cannot overwrite the main allowance", () => { | ||
| 136 | expect( | ||
| 137 | codexRateLimitsToUpdate({ | ||
| 138 | limitId: "codex_bengalfox", | ||
| 139 | primary: { usedPercent: 0, windowDurationMins: 300 }, | ||
| 140 | secondary: { usedPercent: 90, windowDurationMins: 10080 }, | ||
| 141 | }), | ||
| 142 | ).toBeUndefined(); | ||
| 143 | expect( | ||
| 144 | codexRateLimitsToUpdate({ | ||
| 145 | limitId: "codex", | ||
| 146 | secondary: { usedPercent: 42, windowDurationMins: 10080 }, | ||
| 147 | })?.windows, | ||
| 148 | ).toEqual([ | ||
| 149 | { | ||
| 150 | id: "secondary", | ||
| 151 | kind: "weekly", | ||
| 152 | label: "Weekly", | ||
| 153 | usedPercent: 42, | ||
| 154 | windowDurationMins: 10080, | ||
| 155 | }, | ||
| 156 | ]); | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | describe("codexRateLimitsFailureMessage", () => { | ||
| 161 | it("keeps the JSON-RPC code and nothing else from a request failure", () => { | ||
| 162 | expect( | ||
| 163 | codexRateLimitsFailureMessage( | ||
| 164 | new CodexErrors.CodexAppServerRequestError({ | ||
| 165 | code: -32603, | ||
| 166 | errorMessage: | ||
| 167 | "failed to fetch codex rate limits: GET https://chatgpt.com/backend-api/wham/usage failed: 401 Unauthorized", | ||
| 168 | }), | ||
| 169 | ), | ||
| 170 | ).toBe("Codex could not read usage (JSON-RPC -32603)."); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("phrases a dead process differently from a bad answer", () => { | ||
| 174 | expect( | ||
| 175 | codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), | ||
| 176 | ).toBe("Codex exited before it could report usage."); | ||
| 177 | }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | describe("codexResetCreditsToContract", () => { | ||
| 181 | it("counts available credits and reports the soonest expiry", () => { | ||
| 182 | expect( | ||
| 183 | codexResetCreditsToContract({ | ||
| 184 | availableCount: 2, | ||
| 185 | credits: [ | ||
| 186 | { status: "available", expiresAt: 1_784_500_000 }, | ||
| 187 | { status: "redeemed", expiresAt: 1_700_000_000 }, | ||
| 188 | { status: "available", expiresAt: 1_784_000_000 }, | ||
| 189 | ], | ||
| 190 | }), | ||
| 191 | ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); | ||
| 192 | expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); | ||
| 193 | expect(codexResetCreditsToContract(null)).toBeUndefined(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | it("rides along on the probe's limits", () => { | ||
| 197 | expect( | ||
| 198 | codexRateLimitsToLimits({ | ||
| 199 | checkedAt, | ||
| 200 | snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, | ||
| 201 | resetCredits: { availableCount: 1 }, | ||
| 202 | }).resetCredits, | ||
| 203 | ).toEqual({ availableCount: 1 }); | ||
| 204 | }); | ||
| 205 | }); | ||
| 206 | |||
| 207 | describe("codexUsageLimitMessage", () => { | ||
| 208 | const at = "2026-01-01T00:00:00.000Z"; | ||
| 209 | const atSeconds = Date.parse(at) / 1000; | ||
| 210 | |||
| 211 | it("names the exhausted window and the workspace's missing credits", () => { | ||
| 212 | expect( | ||
| 213 | codexUsageLimitMessage( | ||
| 214 | { | ||
| 215 | limitId: "codex", | ||
| 216 | rateLimitReachedType: "workspace_owner_credits_depleted", | ||
| 217 | primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, | ||
| 218 | secondary: { | ||
| 219 | usedPercent: 100, | ||
| 220 | resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, | ||
| 221 | windowDurationMins: 10_080, | ||
| 222 | }, | ||
| 223 | }, | ||
| 224 | at, | ||
| 225 | ), | ||
| 226 | ).toBe( | ||
| 227 | "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.", | ||
| 228 | ); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("points a reached spend cap at the workspace owner", () => { | ||
| 232 | expect( | ||
| 233 | codexUsageLimitMessage( | ||
| 234 | { | ||
| 235 | limitId: "codex", | ||
| 236 | rateLimitReachedType: "workspace_member_usage_limit_reached", | ||
| 237 | primary: { | ||
| 238 | usedPercent: 100, | ||
| 239 | resetsAt: atSeconds + 3 * 3_600 + 20 * 60, | ||
| 240 | windowDurationMins: 300, | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | at, | ||
| 244 | ), | ||
| 245 | ).toBe( | ||
| 246 | "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.", | ||
| 247 | ); | ||
| 248 | }); | ||
| 249 | |||
| 250 | it("names no window when credits run out without one", () => { | ||
| 251 | expect( | ||
| 252 | codexUsageLimitMessage( | ||
| 253 | { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, | ||
| 254 | at, | ||
| 255 | ), | ||
| 256 | ).toBe( | ||
| 257 | "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.", | ||
| 258 | ); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("says only what it knows without a snapshot", () => { | ||
| 262 | expect(codexUsageLimitMessage(undefined, at)).toBe( | ||
| 263 | "Codex usage limit reached. Send the message again once the limit resets.", | ||
| 264 | ); | ||
| 265 | }); | ||
| 266 | }); | ||
| 267 | |||
| 268 | describe("mergeCodexRateLimits", () => { | ||
| 269 | it("keeps windows an update does not carry", () => { | ||
| 270 | const merged = mergeCodexRateLimits( | ||
| 271 | { | ||
| 272 | limitId: "codex", | ||
| 273 | planType: "business", | ||
| 274 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 275 | }, | ||
| 276 | { rateLimitReachedType: "rate_limit_reached" }, | ||
| 277 | ); | ||
| 278 | |||
| 279 | expect(merged).toEqual({ | ||
| 280 | limitId: "codex", | ||
| 281 | planType: "business", | ||
| 282 | rateLimitReachedType: "rate_limit_reached", | ||
| 283 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 284 | }); | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { | ||
| 288 | const main = { | ||
| 289 | limitId: "codex", | ||
| 290 | primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 291 | }; | ||
| 292 | expect( | ||
| 293 | mergeCodexRateLimits(main, { | ||
| 294 | limitId: "spark", | ||
| 295 | primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, | ||
| 296 | }), | ||
| 297 | ).toBe(main); | ||
| 298 | }); | ||
| 299 | }); | ||
| 300 | |||
| 301 | import { codexUsageLimitResetAt } from "./codexUsageLimits.ts"; | ||
| 302 | |||
| 303 | describe("codexUsageLimitResetAt", () => { | ||
| 304 | it("waits for every exhausted window and never invents an unknown reset", () => { | ||
| 305 | expect( | ||
| 306 | codexUsageLimitResetAt({ | ||
| 307 | primary: { usedPercent: 100, resetsAt: 2000000000 }, | ||
| 308 | secondary: { usedPercent: 100, resetsAt: 2000100000 }, | ||
| 309 | }), | ||
| 310 | ).toBe("2033-05-19T07:20:00.000Z"); | ||
| 311 | expect(codexUsageLimitResetAt({ primary: { usedPercent: 100 } })).toBeNull(); | ||
| 312 | expect( | ||
| 313 | codexUsageLimitResetAt({ primary: { usedPercent: 50, resetsAt: 2000000000 } }), | ||
| 314 | ).toBeNull(); | ||
| 315 | }); | ||
| 316 | }); | ||
apps/server/src/provider/codexUsageLimits.ts created+250| ... | @@ -0,0 +1,250 @@ | ||
| 1 | /** | ||
| 2 | * Codex subscription usage. The `account/rateLimits/read` response and the | ||
| 3 | * `account/rateLimits/updated` notification carry the same snapshot shape, so | ||
| 4 | * one mapper serves the status probe and the turn-driven update; both emit | ||
| 5 | * windows with the same ids so they merge onto the same rows. | ||
| 6 | * | ||
| 7 | * @module provider/codexUsageLimits | ||
| 8 | */ | ||
| 9 | import type { | ||
| 10 | ProviderUsageLimitsUpdate, | ||
| 11 | ServerProviderResetCredits, | ||
| 12 | ServerProviderUsageLimits, | ||
| 13 | ServerProviderUsageWindow, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import type * as CodexErrors from "effect-codex-app-server/errors"; | ||
| 18 | |||
| 19 | import { clampPercent, makeUsageLimits } from "./providerUsageLimits.ts"; | ||
| 20 | |||
| 21 | interface CodexRateLimitWindow { | ||
| 22 | readonly usedPercent: number; | ||
| 23 | readonly resetsAt?: number | null; | ||
| 24 | readonly windowDurationMins?: number | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | /** Structural view of the generated `RateLimitSnapshot`; both messages satisfy it. */ | ||
| 28 | export interface CodexRateLimitSnapshot { | ||
| 29 | readonly limitId?: string | null; | ||
| 30 | readonly planType?: string | null; | ||
| 31 | readonly rateLimitReachedType?: string | null; | ||
| 32 | readonly primary?: CodexRateLimitWindow | null; | ||
| 33 | readonly secondary?: CodexRateLimitWindow | null; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** Structural view of the read response's `rateLimitResetCredits`. */ | ||
| 37 | export interface CodexResetCreditsSummary { | ||
| 38 | readonly availableCount: number; | ||
| 39 | readonly credits?: ReadonlyArray<{ | ||
| 40 | readonly status: string; | ||
| 41 | readonly expiresAt?: number | null; | ||
| 42 | }> | null; | ||
| 43 | } | ||
| 44 | |||
| 45 | const SESSION_MINS = 5 * 60; | ||
| 46 | const WEEK_MINS = 7 * 24 * 60; | ||
| 47 | const MONTH_MINS = 30 * 24 * 60; | ||
| 48 | |||
| 49 | function isoFromEpochSeconds(value: number | null | undefined): string | undefined { | ||
| 50 | if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; | ||
| 51 | const dt = DateTime.make(value * 1000); | ||
| 52 | return Option.isSome(dt) ? DateTime.formatIso(dt.value) : undefined; | ||
| 53 | } | ||
| 54 | |||
| 55 | function kindForDuration(mins: number): ServerProviderUsageWindow["kind"] { | ||
| 56 | if (mins >= MONTH_MINS) return "monthly"; | ||
| 57 | if (mins >= WEEK_MINS) return "weekly"; | ||
| 58 | return "session"; | ||
| 59 | } | ||
| 60 | |||
| 61 | function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { | ||
| 62 | return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; | ||
| 63 | } | ||
| 64 | |||
| 65 | /** | ||
| 66 | * `primary` / `secondary` are positions, not durations. Codex usually sends | ||
| 67 | * `windowDurationMins`; when it does not, paid plans expose the 5-hour and | ||
| 68 | * weekly pair and Free/Go expose one monthly allowance. | ||
| 69 | */ | ||
| 70 | function codexRateLimitsToWindows( | ||
| 71 | snapshot: CodexRateLimitSnapshot, | ||
| 72 | ): ReadonlyArray<ServerProviderUsageWindow> { | ||
| 73 | // Show the main allowance only. Model-specific notifications (such as Spark) | ||
| 74 | // must not replace its primary/secondary rows. Older CLIs omit the limit id. | ||
| 75 | if (snapshot.limitId && snapshot.limitId !== "codex") return []; | ||
| 76 | const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; | ||
| 77 | const positions = [ | ||
| 78 | ["primary", snapshot.primary, isMonthlyPlan ? MONTH_MINS : SESSION_MINS], | ||
| 79 | ["secondary", snapshot.secondary, WEEK_MINS], | ||
| 80 | ] as const; | ||
| 81 | const windows: ServerProviderUsageWindow[] = []; | ||
| 82 | for (const [id, window, fallbackMins] of positions) { | ||
| 83 | if (!window || !Number.isFinite(window.usedPercent)) continue; | ||
| 84 | const windowDurationMins = | ||
| 85 | typeof window.windowDurationMins === "number" ? window.windowDurationMins : fallbackMins; | ||
| 86 | const kind = kindForDuration(windowDurationMins); | ||
| 87 | const resetsAt = isoFromEpochSeconds(window.resetsAt); | ||
| 88 | windows.push({ | ||
| 89 | id, | ||
| 90 | kind, | ||
| 91 | label: labelForKind(kind), | ||
| 92 | usedPercent: clampPercent(window.usedPercent), | ||
| 93 | windowDurationMins, | ||
| 94 | ...(resetsAt ? { resetsAt } : {}), | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | return windows; | ||
| 98 | } | ||
| 99 | |||
| 100 | export function codexResetCreditsToContract( | ||
| 101 | summary: CodexResetCreditsSummary | null | undefined, | ||
| 102 | ): ServerProviderResetCredits | undefined { | ||
| 103 | if (!summary) return undefined; | ||
| 104 | const expiries = (summary.credits ?? []) | ||
| 105 | .filter((credit) => credit.status === "available") | ||
| 106 | .map((credit) => credit.expiresAt) | ||
| 107 | .filter((value): value is number => typeof value === "number"); | ||
| 108 | const nextExpiresAt = | ||
| 109 | expiries.length > 0 ? isoFromEpochSeconds(Math.min(...expiries)) : undefined; | ||
| 110 | return { | ||
| 111 | availableCount: Math.max(0, summary.availableCount), | ||
| 112 | ...(nextExpiresAt ? { nextExpiresAt } : {}), | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function codexRateLimitsToLimits(input: { | ||
| 117 | readonly snapshot: CodexRateLimitSnapshot; | ||
| 118 | readonly rateLimitsByLimitId?: | ||
| 119 | | Readonly<Record<string, CodexRateLimitSnapshot>> | ||
| 120 | | null | ||
| 121 | | undefined; | ||
| 122 | readonly resetCredits?: CodexResetCreditsSummary | null | undefined; | ||
| 123 | readonly checkedAt: string; | ||
| 124 | }): ServerProviderUsageLimits { | ||
| 125 | const resetCredits = codexResetCreditsToContract(input.resetCredits); | ||
| 126 | // Select the main bucket explicitly; the legacy snapshot can name another limit. | ||
| 127 | const windows = codexRateLimitsToWindows(input.rateLimitsByLimitId?.codex ?? input.snapshot); | ||
| 128 | return { | ||
| 129 | ...makeUsageLimits({ | ||
| 130 | checkedAt: input.checkedAt, | ||
| 131 | windows, | ||
| 132 | }), | ||
| 133 | ...(resetCredits ? { resetCredits } : {}), | ||
| 134 | }; | ||
| 135 | } | ||
| 136 | |||
| 137 | export function codexRateLimitsToUpdate( | ||
| 138 | snapshot: CodexRateLimitSnapshot, | ||
| 139 | ): ProviderUsageLimitsUpdate | undefined { | ||
| 140 | const windows = codexRateLimitsToWindows(snapshot); | ||
| 141 | return windows.length > 0 ? { windows } : undefined; | ||
| 142 | } | ||
| 143 | |||
| 144 | /** | ||
| 145 | * A bounded, client-safe reason for a failed `account/rateLimits/read`. The | ||
| 146 | * raw error is for the log; only the category and, for a JSON-RPC failure, | ||
| 147 | * the code reach the Limits view. | ||
| 148 | */ | ||
| 149 | export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerError): string { | ||
| 150 | switch (error._tag) { | ||
| 151 | case "CodexAppServerRequestError": | ||
| 152 | return `Codex could not read usage (JSON-RPC ${error.code}).`; | ||
| 153 | case "CodexAppServerSpawnError": | ||
| 154 | return "Codex could not be started to read usage."; | ||
| 155 | case "CodexAppServerProcessExitedError": | ||
| 156 | return "Codex exited before it could report usage."; | ||
| 157 | default: | ||
| 158 | return "Codex did not answer the usage request."; | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | /** | ||
| 163 | * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a | ||
| 164 | * field the update omits keeps the value observed earlier in the session, so a | ||
| 165 | * later notification that only names the limit it reached must not drop the | ||
| 166 | * windows an earlier one carried. | ||
| 167 | */ | ||
| 168 | export function mergeCodexRateLimits( | ||
| 169 | previous: CodexRateLimitSnapshot | undefined, | ||
| 170 | update: CodexRateLimitSnapshot, | ||
| 171 | ): CodexRateLimitSnapshot | undefined { | ||
| 172 | // Model-specific snapshots (such as Spark) describe a different allowance | ||
| 173 | // and must not overwrite the main one, the same rule the usage rows apply. | ||
| 174 | if (update.limitId && update.limitId !== "codex") return previous; | ||
| 175 | if (!previous) return update; | ||
| 176 | return { | ||
| 177 | ...previous, | ||
| 178 | ...(update.limitId !== undefined ? { limitId: update.limitId } : {}), | ||
| 179 | ...(update.planType !== undefined ? { planType: update.planType } : {}), | ||
| 180 | ...(update.rateLimitReachedType !== undefined | ||
| 181 | ? { rateLimitReachedType: update.rateLimitReachedType } | ||
| 182 | : {}), | ||
| 183 | ...(update.primary !== undefined ? { primary: update.primary } : {}), | ||
| 184 | ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | /** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */ | ||
| 189 | function formatCodexUsageLimitWait(waitMs: number): string { | ||
| 190 | const totalMinutes = Math.ceil(waitMs / 60_000); | ||
| 191 | const days = Math.floor(totalMinutes / (24 * 60)); | ||
| 192 | const hours = Math.floor((totalMinutes % (24 * 60)) / 60); | ||
| 193 | const minutes = totalMinutes % 60; | ||
| 194 | if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; | ||
| 195 | if (hours === 0) return `${totalMinutes}m`; | ||
| 196 | return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; | ||
| 197 | } | ||
| 198 | |||
| 199 | function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { | ||
| 200 | switch (rateLimitReachedType) { | ||
| 201 | case "workspace_owner_credits_depleted": | ||
| 202 | case "workspace_member_credits_depleted": | ||
| 203 | 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."; | ||
| 204 | case "workspace_owner_usage_limit_reached": | ||
| 205 | case "workspace_member_usage_limit_reached": | ||
| 206 | return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; | ||
| 207 | default: | ||
| 208 | return " Send the message again once the limit resets."; | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | /** | ||
| 213 | * The message a usage-limit stop shows instead of the provider sentence, which | ||
| 214 | * on a Business workspace blames credits for a window that simply ran out. The | ||
| 215 | * window named is the exhausted one that has yet to reset, latest first; `atIso` | ||
| 216 | * is the stopping event's timestamp, not the wall clock. | ||
| 217 | */ | ||
| 218 | export function codexUsageLimitMessage( | ||
| 219 | snapshot: CodexRateLimitSnapshot | undefined, | ||
| 220 | atIso: string, | ||
| 221 | ): string { | ||
| 222 | const atMs = Date.parse(atIso); | ||
| 223 | const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; | ||
| 224 | let reset = ""; | ||
| 225 | let latestResetMs = Number.NEGATIVE_INFINITY; | ||
| 226 | for (const window of windows) { | ||
| 227 | if (window.usedPercent < 100 || !window.resetsAt) continue; | ||
| 228 | const resetMs = Date.parse(window.resetsAt); | ||
| 229 | if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; | ||
| 230 | latestResetMs = resetMs; | ||
| 231 | reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; | ||
| 232 | } | ||
| 233 | return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; | ||
| 234 | } | ||
| 235 | |||
| 236 | /** All exhausted windows must reset before a continuation can run. */ | ||
| 237 | export function codexUsageLimitResetAt( | ||
| 238 | snapshot: CodexRateLimitSnapshot | undefined, | ||
| 239 | ): string | null { | ||
| 240 | if (!snapshot) return null; | ||
| 241 | const windows = codexRateLimitsToWindows(snapshot).filter((window) => window.usedPercent >= 100); | ||
| 242 | if (windows.length === 0 || windows.some((window) => !window.resetsAt)) return null; | ||
| 243 | return windows.reduce<string | null>( | ||
| 244 | (latest, window) => | ||
| 245 | latest === null || Date.parse(window.resetsAt!) > Date.parse(latest) | ||
| 246 | ? window.resetsAt! | ||
| 247 | : latest, | ||
| 248 | null, | ||
| 249 | ); | ||
| 250 | } | ||
apps/server/src/provider/cursorUsageLimits.test.ts created+264| ... | @@ -0,0 +1,264 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { NodeServices } from "@effect/platform-node"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | ||
| 8 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; | ||
| 9 | |||
| 10 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | ||
| 11 | effect.pipe(Effect.provide(NodeServices.layer)); | ||
| 12 | |||
| 13 | describe("Cursor usage limits", () => { | ||
| 14 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 15 | |||
| 16 | it("uses the advertised percentages and billing-cycle reset", () => { | ||
| 17 | const limits = cursorUsageResponseToLimits( | ||
| 18 | { | ||
| 19 | billingCycleEnd: "1789876386000", | ||
| 20 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, | ||
| 21 | }, | ||
| 22 | checkedAt, | ||
| 23 | ); | ||
| 24 | expect(limits.windows).toEqual( | ||
| 25 | expect.arrayContaining([ | ||
| 26 | { | ||
| 27 | id: "totalPercentUsed", | ||
| 28 | kind: "monthly", | ||
| 29 | label: "Overall", | ||
| 30 | usedPercent: 72.4, | ||
| 31 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | id: "autoPercentUsed", | ||
| 35 | kind: "monthly", | ||
| 36 | label: "Cursor Models", | ||
| 37 | usedPercent: 69.5, | ||
| 38 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | id: "apiPercentUsed", | ||
| 42 | kind: "monthly", | ||
| 43 | label: "Other Models", | ||
| 44 | usedPercent: 100, | ||
| 45 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 46 | }, | ||
| 47 | ]), | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("does not invent unused allowance for absent buckets", () => { | ||
| 52 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( | ||
| 53 | "unsupported", | ||
| 54 | ); | ||
| 55 | expect( | ||
| 56 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, | ||
| 57 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); | ||
| 58 | expect( | ||
| 59 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, | ||
| 60 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | yield* withNodeServices( | ||
| 66 | Effect.gen(function* () { | ||
| 67 | const fs = yield* FileSystem.FileSystem; | ||
| 68 | const path = yield* Path.Path; | ||
| 69 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 70 | yield* fs.makeDirectory(path.join(directory, "cursor")); | ||
| 71 | yield* fs.writeFileString( | ||
| 72 | path.join(directory, "cursor", "auth.json"), | ||
| 73 | '{"accessToken":"instance-token"}', | ||
| 74 | ); | ||
| 75 | const client = HttpClient.make((request) => { | ||
| 76 | expect(request.url).toBe( | ||
| 77 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", | ||
| 78 | ); | ||
| 79 | expect(request.method).toBe("POST"); | ||
| 80 | expect(request.headers.authorization).toBe("Bearer instance-token"); | ||
| 81 | expect(request.headers["connect-protocol-version"]).toBe("1"); | ||
| 82 | return Effect.succeed( | ||
| 83 | HttpClientResponse.fromWeb( | ||
| 84 | request, | ||
| 85 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), | ||
| 86 | ), | ||
| 87 | ); | ||
| 88 | }); | ||
| 89 | yield* fs.makeDirectory(path.join(directory, ".cursor")); | ||
| 90 | yield* fs.writeFileString( | ||
| 91 | path.join(directory, ".cursor", "auth.json"), | ||
| 92 | '{"accessToken":"instance-token"}', | ||
| 93 | ); | ||
| 94 | for (const platform of ["linux", "darwin"] as const) { | ||
| 95 | const limits = yield* readCursorUsageLimits( | ||
| 96 | { apiEndpoint: "https://cursor.example/" }, | ||
| 97 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, | ||
| 98 | ).pipe( | ||
| 99 | Effect.provideService(HostProcessPlatform, platform), | ||
| 100 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 101 | ); | ||
| 102 | expect(limits.windows[0]?.usedPercent).toBe(42); | ||
| 103 | } | ||
| 104 | }).pipe(Effect.scoped), | ||
| 105 | ); | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 109 | it.effect( | ||
| 110 | "never reads stale files for keychain or memory logins, but accepts an explicit auth token", | ||
| 111 | () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | for (const platform of ["linux", "darwin"] as const) { | ||
| 114 | for (const token of [undefined, "explicit-token"]) { | ||
| 115 | const limits = yield* withNodeServices( | ||
| 116 | readCursorUsageLimits( | ||
| 117 | { apiEndpoint: "" }, | ||
| 118 | { | ||
| 119 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", | ||
| 120 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), | ||
| 121 | }, | ||
| 122 | false, | ||
| 123 | async () => { | ||
| 124 | throw new Error("must not read Keychain before opt-in"); | ||
| 125 | }, | ||
| 126 | ).pipe( | ||
| 127 | Effect.provideService(HostProcessPlatform, platform), | ||
| 128 | Effect.provideService( | ||
| 129 | FileSystem.FileSystem, | ||
| 130 | FileSystem.makeNoop({ | ||
| 131 | readFileString: () => Effect.die("must not read an unrelated credential file"), | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | Effect.provideService( | ||
| 135 | HttpClient.HttpClient, | ||
| 136 | HttpClient.make((request) => { | ||
| 137 | expect(token).toBe("explicit-token"); | ||
| 138 | expect(request.headers.authorization).toBe("Bearer explicit-token"); | ||
| 139 | return Effect.succeed( | ||
| 140 | HttpClientResponse.fromWeb( | ||
| 141 | request, | ||
| 142 | Response.json({ planUsage: { totalPercentUsed: 10 } }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); | ||
| 150 | else expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 151 | } | ||
| 152 | } | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("reports failed requests without exposing credentials or response bodies", () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const limits = yield* withNodeServices( | ||
| 159 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( | ||
| 160 | Effect.provideService( | ||
| 161 | HttpClient.HttpClient, | ||
| 162 | HttpClient.make((request) => | ||
| 163 | Effect.succeed( | ||
| 164 | HttpClientResponse.fromWeb( | ||
| 165 | request, | ||
| 166 | new Response("private response", { status: 401 }), | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | expect(limits.unavailable).toEqual({ | ||
| 174 | reason: "probeFailed", | ||
| 175 | message: "Cursor could not read usage limits.", | ||
| 176 | }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | it.effect("does not use a stored login for an explicit API key", () => | ||
| 181 | Effect.gen(function* () { | ||
| 182 | const limits = yield* withNodeServices( | ||
| 183 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( | ||
| 184 | Effect.provideService( | ||
| 185 | HttpClient.HttpClient, | ||
| 186 | HttpClient.make(() => Effect.die("must not request usage")), | ||
| 187 | ), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 191 | }), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const limits = yield* withNodeServices( | ||
| 197 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( | ||
| 198 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 199 | Effect.provideService( | ||
| 200 | FileSystem.FileSystem, | ||
| 201 | FileSystem.makeNoop({ | ||
| 202 | readFileString: () => Effect.die("must not read a stale credential file"), | ||
| 203 | }), | ||
| 204 | ), | ||
| 205 | Effect.provideService( | ||
| 206 | HttpClient.HttpClient, | ||
| 207 | HttpClient.make((request) => { | ||
| 208 | expect(request.headers.authorization).toBe("Bearer keychain-token"); | ||
| 209 | return Effect.succeed( | ||
| 210 | HttpClientResponse.fromWeb( | ||
| 211 | request, | ||
| 212 | Response.json({ planUsage: { totalPercentUsed: 42 } }), | ||
| 213 | ), | ||
| 214 | ); | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | expect(limits.windows[0]?.usedPercent).toBe(42); | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | |||
| 223 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const limits = yield* withNodeServices( | ||
| 226 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { | ||
| 227 | throw new Error("Keychain initialization failed"); | ||
| 228 | }).pipe( | ||
| 229 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 230 | Effect.provideService( | ||
| 231 | HttpClient.HttpClient, | ||
| 232 | HttpClient.make(() => Effect.die("must not request limits without a login")), | ||
| 233 | ), | ||
| 234 | ), | ||
| 235 | ); | ||
| 236 | expect(limits.unavailable?.reason).toBe("probeFailed"); | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | |||
| 240 | it.effect("does not read Keychain or send its token to a custom endpoint", () => | ||
| 241 | Effect.gen(function* () { | ||
| 242 | for (const [apiEndpoint, environment] of [ | ||
| 243 | ["http://localhost:3000", {}], | ||
| 244 | ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], | ||
| 245 | ["https://cursor-proxy.example", {}], | ||
| 246 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], | ||
| 247 | ] as const) { | ||
| 248 | const limits = yield* withNodeServices( | ||
| 249 | readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { | ||
| 250 | throw new Error("must not read Keychain for a custom endpoint"); | ||
| 251 | }).pipe( | ||
| 252 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 253 | Effect.provideService( | ||
| 254 | HttpClient.HttpClient, | ||
| 255 | HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | ); | ||
| 259 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 260 | expect(limits.unavailable?.message).toContain("default Cursor endpoint"); | ||
| 261 | } | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | }); | ||
apps/server/src/provider/cursorUsageLimits.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 12 | import { | ||
| 13 | clampPercent, | ||
| 14 | makeUnavailableUsageLimits, | ||
| 15 | makeUsageLimits, | ||
| 16 | } from "./providerUsageLimits.ts"; | ||
| 17 | import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; | ||
| 18 | |||
| 19 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | ||
| 20 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; | ||
| 21 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | ||
| 22 | const CursorUsageResponse = Schema.Struct({ | ||
| 23 | billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), | ||
| 24 | planUsage: Schema.optional( | ||
| 25 | Schema.Struct({ | ||
| 26 | totalPercentUsed: Schema.optional(Schema.Number), | ||
| 27 | autoPercentUsed: Schema.optional(Schema.Number), | ||
| 28 | apiPercentUsed: Schema.optional(Schema.Number), | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | }); | ||
| 32 | |||
| 33 | /** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ | ||
| 34 | export function cursorUsageResponseToLimits( | ||
| 35 | response: typeof CursorUsageResponse.Type, | ||
| 36 | checkedAt: string, | ||
| 37 | ) { | ||
| 38 | const reset = DateTime.make(Number(response.billingCycleEnd)); | ||
| 39 | const resetsAt = | ||
| 40 | Number(response.billingCycleEnd) > 0 && Option.isSome(reset) | ||
| 41 | ? DateTime.formatIso(reset.value) | ||
| 42 | : undefined; | ||
| 43 | const windows: ServerProviderUsageWindow[] = []; | ||
| 44 | if (response.planUsage) { | ||
| 45 | for (const { id, label } of CURSOR_USAGE_WINDOWS) { | ||
| 46 | const usedPercent = response.planUsage[id]; | ||
| 47 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; | ||
| 48 | windows.push({ | ||
| 49 | id, | ||
| 50 | kind: "monthly", | ||
| 51 | label, | ||
| 52 | usedPercent: clampPercent(usedPercent), | ||
| 53 | ...(resetsAt ? { resetsAt } : {}), | ||
| 54 | }); | ||
| 55 | } | ||
| 56 | } | ||
| 57 | return windows.length > 0 | ||
| 58 | ? makeUsageLimits({ checkedAt, windows }) | ||
| 59 | : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 60 | } | ||
| 61 | |||
| 62 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | ||
| 63 | settings: Pick<CursorSettings, "apiEndpoint">, | ||
| 64 | environment: NodeJS.ProcessEnv = process.env, | ||
| 65 | allowKeychain = false, | ||
| 66 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | ||
| 67 | ) { | ||
| 68 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 69 | return yield* Effect.gen(function* () { | ||
| 70 | const fs = yield* FileSystem.FileSystem; | ||
| 71 | const path = yield* Path.Path; | ||
| 72 | const platform = yield* HostProcessPlatform; | ||
| 73 | const endpoint = ( | ||
| 74 | settings.apiEndpoint?.trim() || | ||
| 75 | environment.CURSOR_API_ENDPOINT?.trim() || | ||
| 76 | DEFAULT_CURSOR_API_ENDPOINT | ||
| 77 | ).replace(/\/$/, ""); | ||
| 78 | let token = environment.CURSOR_AUTH_TOKEN?.trim(); | ||
| 79 | // An explicit API key can name a different account from the stored login. | ||
| 80 | if (!token && environment.CURSOR_API_KEY?.trim()) { | ||
| 81 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 82 | } | ||
| 83 | const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | ||
| 84 | if (!token && credentialStore === "memory") { | ||
| 85 | return makeUnavailableUsageLimits({ | ||
| 86 | checkedAt, | ||
| 87 | reason: "unsupported", | ||
| 88 | message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | if (!token && platform === "darwin" && credentialStore !== "file") { | ||
| 92 | if (!allowKeychain) { | ||
| 93 | return makeUnavailableUsageLimits({ | ||
| 94 | checkedAt, | ||
| 95 | reason: "unsupported", | ||
| 96 | message: "Enable Cursor account usage in T3 Code to read its Keychain login.", | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { | ||
| 100 | return makeUnavailableUsageLimits({ | ||
| 101 | checkedAt, | ||
| 102 | reason: "unsupported", | ||
| 103 | message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", | ||
| 104 | }); | ||
| 105 | } | ||
| 106 | token = (yield* Effect.tryPromise(keychainToken))?.trim(); | ||
| 107 | } else if (!token) { | ||
| 108 | const home = | ||
| 109 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | ||
| 110 | const directory = | ||
| 111 | platform === "win32" | ||
| 112 | ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | ||
| 113 | : platform === "darwin" | ||
| 114 | ? path.join(home, ".cursor") | ||
| 115 | : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | ||
| 116 | const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | ||
| 117 | Effect.catchTags({ | ||
| 118 | PlatformError: (error) => | ||
| 119 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 120 | }), | ||
| 121 | Effect.flatMap(decodeCredentials), | ||
| 122 | ); | ||
| 123 | token = credentials.accessToken?.trim(); | ||
| 124 | } | ||
| 125 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 126 | const client = yield* HttpClient.HttpClient; | ||
| 127 | const response = yield* client.execute( | ||
| 128 | HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( | ||
| 129 | HttpClientRequest.bearerToken(token), | ||
| 130 | HttpClientRequest.setHeaders({ | ||
| 131 | "connect-protocol-version": "1", | ||
| 132 | "x-cursor-client-type": "cli", | ||
| 133 | }), | ||
| 134 | HttpClientRequest.bodyJsonUnsafe({}), | ||
| 135 | ), | ||
| 136 | ); | ||
| 137 | const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( | ||
| 138 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 139 | ); | ||
| 140 | return cursorUsageResponseToLimits(body, checkedAt); | ||
| 141 | }).pipe( | ||
| 142 | Effect.timeout("10 seconds"), | ||
| 143 | Effect.orElseSucceed(() => | ||
| 144 | makeUnavailableUsageLimits({ | ||
| 145 | checkedAt, | ||
| 146 | reason: "probeFailed", | ||
| 147 | message: "Cursor could not read usage limits.", | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | }); | ||
apps/server/src/provider/grokUsageLimits.ts created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 10 | import { | ||
| 11 | clampPercent, | ||
| 12 | makeUnavailableUsageLimits, | ||
| 13 | makeUsageLimits, | ||
| 14 | } from "./providerUsageLimits.ts"; | ||
| 15 | |||
| 16 | const GrokCredentials = Schema.Record( | ||
| 17 | Schema.String, | ||
| 18 | Schema.Struct({ | ||
| 19 | key: Schema.optional(Schema.String), | ||
| 20 | auth_mode: Schema.optional(Schema.String), | ||
| 21 | email: Schema.optional(Schema.String), | ||
| 22 | }), | ||
| 23 | ); | ||
| 24 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); | ||
| 25 | const GrokUsageResponse = Schema.Struct({ | ||
| 26 | config: Schema.optional( | ||
| 27 | Schema.Struct({ | ||
| 28 | creditUsagePercent: Schema.optional(Schema.Number), | ||
| 29 | currentPeriod: Schema.optional( | ||
| 30 | Schema.Struct({ | ||
| 31 | type: Schema.optional(Schema.String), | ||
| 32 | end: Schema.optional(Schema.String), | ||
| 33 | }), | ||
| 34 | ), | ||
| 35 | }), | ||
| 36 | ), | ||
| 37 | }); | ||
| 38 | |||
| 39 | export function grokUsageResponseToLimits( | ||
| 40 | response: typeof GrokUsageResponse.Type, | ||
| 41 | checkedAt: string, | ||
| 42 | ) { | ||
| 43 | const usedPercent = response.config?.creditUsagePercent; | ||
| 44 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) { | ||
| 45 | // A billing read that succeeded but carries no percentage is an account | ||
| 46 | // with nothing metered yet, not one that can never report: xAI omits the | ||
| 47 | // field entirely (rather than sending 0) until usage registers, then fills | ||
| 48 | // it in. Calling that `unsupported` would strand the account — the Limits | ||
| 49 | // view drops unsupported entries and deliberately mutes their notice, so a | ||
| 50 | // freshly signed-in Grok account would vanish with no explanation until it | ||
| 51 | // happened to be used, and `applyUsageLimitsUpdate` would refuse the | ||
| 52 | // mid-turn windows that could have recovered it. | ||
| 53 | return makeUsageLimits({ checkedAt, windows: [] }); | ||
| 54 | } | ||
| 55 | const period = response.config?.currentPeriod; | ||
| 56 | const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); | ||
| 57 | const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; | ||
| 58 | const reset = period?.end ? DateTime.make(period.end) : Option.none(); | ||
| 59 | const window: ServerProviderUsageWindow = { | ||
| 60 | id: "subscription", | ||
| 61 | kind, | ||
| 62 | label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", | ||
| 63 | usedPercent: clampPercent(usedPercent), | ||
| 64 | ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), | ||
| 65 | }; | ||
| 66 | return makeUsageLimits({ checkedAt, windows: [window] }); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * The grok.com login the CLI uses by default, or undefined when the CLI is | ||
| 71 | * configured to pick another account, endpoint, or an API key. | ||
| 72 | */ | ||
| 73 | const readGrokCredential = Effect.fn("readGrokCredential")(function* ( | ||
| 74 | environment: NodeJS.ProcessEnv, | ||
| 75 | ) { | ||
| 76 | // T3's ACP adapter explicitly selects API-key auth when this variable is set. | ||
| 77 | if (environment.XAI_API_KEY?.trim()) return undefined; | ||
| 78 | // Alternate auth deployments can select another scope or account from the same file. | ||
| 79 | if ( | ||
| 80 | [ | ||
| 81 | "GROK_OIDC_ISSUER", | ||
| 82 | "GROK_OIDC_CLIENT_ID", | ||
| 83 | "GROK_OAUTH2_ISSUER", | ||
| 84 | "GROK_OAUTH2_CLIENT_ID", | ||
| 85 | "GROK_OAUTH2_PRINCIPAL_TYPE", | ||
| 86 | "GROK_OAUTH2_PRINCIPAL_ID", | ||
| 87 | "GROK_AUTH_PROVIDER_COMMAND", | ||
| 88 | "GROK_LOCAL_AUTH", | ||
| 89 | "GROK_CLI_CHAT_PROXY_BASE_URL", | ||
| 90 | "GROK_MODELS_BASE_URL", | ||
| 91 | "GROK_CONFIG", | ||
| 92 | "GROK_CONFIG_PATH", | ||
| 93 | ].some((name) => environment[name]?.trim()) | ||
| 94 | ) { | ||
| 95 | return undefined; | ||
| 96 | } | ||
| 97 | const fs = yield* FileSystem.FileSystem; | ||
| 98 | const path = yield* Path.Path; | ||
| 99 | const home = | ||
| 100 | environment.GROK_HOME?.trim() || | ||
| 101 | path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); | ||
| 102 | for (const configPath of [ | ||
| 103 | path.join(home, "config.toml"), | ||
| 104 | path.join(home, "managed_config.toml"), | ||
| 105 | path.join(home, "requirements.toml"), | ||
| 106 | "/etc/grok/managed_config.toml", | ||
| 107 | "/etc/grok/requirements.toml", | ||
| 108 | ]) { | ||
| 109 | const config = yield* fs.readFileString(configPath).pipe( | ||
| 110 | Effect.catchTags({ | ||
| 111 | PlatformError: (error) => | ||
| 112 | error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. | ||
| 116 | if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { | ||
| 117 | return undefined; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | const contents = | ||
| 121 | environment.GROK_AUTH?.trim() || | ||
| 122 | (yield* fs.readFileString(path.join(home, "auth.json")).pipe( | ||
| 123 | Effect.catchTags({ | ||
| 124 | PlatformError: (error) => | ||
| 125 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 126 | }), | ||
| 127 | )); | ||
| 128 | const credentials = yield* decodeCredentials(contents); | ||
| 129 | // Never pick an arbitrary account from other deployments stored in the same file. | ||
| 130 | const credential = | ||
| 131 | credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? | ||
| 132 | credentials["https://accounts.x.ai/sign-in"]; | ||
| 133 | return credential?.auth_mode === "api_key" ? undefined : credential; | ||
| 134 | }); | ||
| 135 | |||
| 136 | /** | ||
| 137 | * Reads the default grok.com login once and reports its usage limits along with | ||
| 138 | * its email, so the email always names the account whose quota was read. | ||
| 139 | */ | ||
| 140 | export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( | ||
| 141 | environment: NodeJS.ProcessEnv = process.env, | ||
| 142 | ) { | ||
| 143 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 144 | const probeFailed = makeUnavailableUsageLimits({ | ||
| 145 | checkedAt, | ||
| 146 | reason: "probeFailed", | ||
| 147 | message: "Grok could not read usage limits.", | ||
| 148 | }); | ||
| 149 | const credential = yield* Effect.option( | ||
| 150 | readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), | ||
| 151 | ); | ||
| 152 | if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; | ||
| 153 | const email = credential.value?.email?.trim() || undefined; | ||
| 154 | const token = credential.value?.key?.trim(); | ||
| 155 | if (!token) { | ||
| 156 | return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; | ||
| 157 | } | ||
| 158 | // A failed quota request still knows which account it asked about. | ||
| 159 | const usageLimits = yield* Effect.gen(function* () { | ||
| 160 | const client = yield* HttpClient.HttpClient; | ||
| 161 | const response = yield* client.execute( | ||
| 162 | HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( | ||
| 163 | HttpClientRequest.bearerToken(token), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( | ||
| 167 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 168 | ); | ||
| 169 | return grokUsageResponseToLimits(body, checkedAt); | ||
| 170 | }).pipe( | ||
| 171 | Effect.timeout("10 seconds"), | ||
| 172 | Effect.orElseSucceed(() => probeFailed), | ||
| 173 | ); | ||
| 174 | return { email, usageLimits }; | ||
| 175 | }); | ||
apps/server/src/provider/makeManagedServerProvider.ts+1-1| ... | @@ -18,7 +18,7 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -18,7 +18,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 18 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 18 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 19 | import * as ServerSettings from "../serverSettings.ts"; | 19 | import * as ServerSettings from "../serverSettings.ts"; |
| 20 | import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; | 20 | import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./providerUsageLimits.ts"; |
| 21 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 21 | import type { ServerProviderShape } from "./ServerProvider.ts"; |
| 22 | 22 | ||
| 23 | interface ProviderSnapshotState { | 23 | interface ProviderSnapshotState { |
| 24 | readonly snapshot: ServerProvider; | 24 | readonly snapshot: ServerProvider; |
apps/server/src/provider/openCodeUsageLimits.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | clampPercent, | ||
| 15 | makeUnavailableUsageLimits, | ||
| 16 | makeUsageLimits, | ||
| 17 | } from "./providerUsageLimits.ts"; | ||
| 18 | |||
| 19 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); | ||
| 20 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); | ||
| 21 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); | ||
| 22 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); | ||
| 23 | const UsageWindow = Schema.Struct({ | ||
| 24 | percent: Schema.Finite, | ||
| 25 | resetsAt: Schema.DateTimeUtcFromString, | ||
| 26 | }); | ||
| 27 | const UsageResponse = Schema.Struct({ | ||
| 28 | usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), | ||
| 29 | }); | ||
| 30 | |||
| 31 | /** External OpenCode servers own their credentials; never read the host's account for them. */ | ||
| 32 | export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { | ||
| 33 | readonly enabled: boolean; | ||
| 34 | readonly serverUrl: string; | ||
| 35 | readonly environment: NodeJS.ProcessEnv; | ||
| 36 | }) { | ||
| 37 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 38 | const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 39 | if (!input.enabled || input.serverUrl.trim()) return unsupported; | ||
| 40 | |||
| 41 | return yield* Effect.gen(function* () { | ||
| 42 | const fs = yield* FileSystem.FileSystem; | ||
| 43 | const path = yield* Path.Path; | ||
| 44 | const env = input.environment; | ||
| 45 | const dataHome = | ||
| 46 | env.XDG_DATA_HOME || | ||
| 47 | path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); | ||
| 48 | const authPath = path.join(dataHome, "opencode", "auth.json"); | ||
| 49 | const contents = | ||
| 50 | env.OPENCODE_AUTH_CONTENT || | ||
| 51 | (yield* fs.readFileString(authPath).pipe( | ||
| 52 | Effect.catchTags({ | ||
| 53 | PlatformError: (error) => | ||
| 54 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 55 | }), | ||
| 56 | )); | ||
| 57 | const auth = yield* decodeAuthFile(contents); | ||
| 58 | const apiAuth = decodeApiAuth(auth["opencode-go"]); | ||
| 59 | // OpenCode overlays stored API credentials after environment credentials. | ||
| 60 | const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); | ||
| 61 | if (!apiKey) return unsupported; | ||
| 62 | |||
| 63 | const client = yield* HttpClient.HttpClient; | ||
| 64 | const response = yield* client.execute( | ||
| 65 | HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( | ||
| 66 | HttpClientRequest.bearerToken(apiKey), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | // A valid Zen key can exist without a Go subscription. | ||
| 70 | if (response.status === 403) return unsupported; | ||
| 71 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 72 | Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), | ||
| 73 | ); | ||
| 74 | const windows: ServerProviderUsageWindow[] = [ | ||
| 75 | { | ||
| 76 | id: "go_rolling", | ||
| 77 | kind: "session", | ||
| 78 | label: "Go · Session", | ||
| 79 | windowDurationMins: 5 * 60, | ||
| 80 | usedPercent: clampPercent(body.usage.rolling.percent), | ||
| 81 | resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | id: "go_weekly", | ||
| 85 | kind: "weekly", | ||
| 86 | label: "Go · Weekly", | ||
| 87 | windowDurationMins: 7 * 24 * 60, | ||
| 88 | usedPercent: clampPercent(body.usage.weekly.percent), | ||
| 89 | resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), | ||
| 90 | }, | ||
| 91 | { | ||
| 92 | id: "go_monthly", | ||
| 93 | kind: "monthly", | ||
| 94 | label: "Go · Monthly", | ||
| 95 | usedPercent: clampPercent(body.usage.monthly.percent), | ||
| 96 | resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), | ||
| 97 | }, | ||
| 98 | ]; | ||
| 99 | return { | ||
| 100 | ...makeUsageLimits({ checkedAt, windows }), | ||
| 101 | // Go's usage response has no account ID. An unkeyed hash matches across | ||
| 102 | // environments without a shared secret. It permits offline guesses, but | ||
| 103 | // Go keys are randomly generated. | ||
| 104 | credentialFingerprint: NodeCrypto.createHash("sha256") | ||
| 105 | .update("opencode-go\0") | ||
| 106 | .update(apiKey) | ||
| 107 | .digest("hex"), | ||
| 108 | }; | ||
| 109 | }).pipe( | ||
| 110 | Effect.timeout("5 seconds"), | ||
| 111 | Effect.orElseSucceed(() => | ||
| 112 | makeUnavailableUsageLimits({ | ||
| 113 | checkedAt, | ||
| 114 | reason: "probeFailed", | ||
| 115 | message: "OpenCode Go could not read usage.", | ||
| 116 | }), | ||
| 117 | ), | ||
| 118 | ); | ||
| 119 | }); | ||
apps/server/src/provider/piThinkingCapabilities.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { thinkingCapabilitiesForPiModel } from "./piThinkingCapabilities.ts"; | ||
| 4 | |||
| 5 | describe("thinkingCapabilitiesForPiModel", () => { | ||
| 6 | it("gives every choice its real level id and tags Pi's default", () => { | ||
| 7 | const capabilities = thinkingCapabilitiesForPiModel( | ||
| 8 | { | ||
| 9 | reasoning: true, | ||
| 10 | thinkingLevelMap: { off: null, xhigh: "extra_high", max: null }, | ||
| 11 | }, | ||
| 12 | "xhigh", | ||
| 13 | ); | ||
| 14 | const descriptors = capabilities.optionDescriptors ?? []; | ||
| 15 | const thinking = descriptors[0]; | ||
| 16 | assert.equal(thinking?.id, "thinking"); | ||
| 17 | assert.equal(thinking?.type, "select"); | ||
| 18 | if (thinking?.type !== "select") return; | ||
| 19 | assert.deepEqual( | ||
| 20 | thinking.options.map((option) => [option.id, option.label, option.isDefault === true]), | ||
| 21 | [ | ||
| 22 | ["minimal", "Minimal", false], | ||
| 23 | ["low", "Low", false], | ||
| 24 | ["medium", "Medium", false], | ||
| 25 | ["high", "High", false], | ||
| 26 | ["xhigh", "Extra High", true], | ||
| 27 | ], | ||
| 28 | ); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("clamps Pi's default to each model's supported levels", () => { | ||
| 32 | const capabilities = thinkingCapabilitiesForPiModel( | ||
| 33 | { | ||
| 34 | reasoning: true, | ||
| 35 | thinkingLevelMap: { xhigh: "extra_high", max: null }, | ||
| 36 | }, | ||
| 37 | "max", | ||
| 38 | ); | ||
| 39 | const thinking = capabilities.optionDescriptors?.[0]; | ||
| 40 | assert.equal(thinking?.type, "select"); | ||
| 41 | if (thinking?.type !== "select") return; | ||
| 42 | assert.deepInclude(thinking.options, { | ||
| 43 | id: "xhigh", | ||
| 44 | label: "Extra High", | ||
| 45 | isDefault: true, | ||
| 46 | }); | ||
| 47 | assert.notInclude( | ||
| 48 | thinking.options.map((option) => option.id), | ||
| 49 | "max", | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/server/src/provider/piThinkingCapabilities.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import { type ModelCapabilities, type ProviderOptionChoice } from "@t3tools/contracts"; | ||
| 2 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 3 | import * as Predicate from "effect/Predicate"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Pi's full thinking ladder. Extra High (`xhigh`) and Max are opt-in per | ||
| 7 | * model via `thinkingLevelMap`; advertising them globally makes | ||
| 8 | * `set_thinking_level` fail on models that lack them. | ||
| 9 | */ | ||
| 10 | const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; | ||
| 11 | |||
| 12 | export type PiThinkingLevel = (typeof PI_THINKING_LEVELS)[number]; | ||
| 13 | |||
| 14 | const PI_THINKING_LEVEL_LABELS: Record<PiThinkingLevel, string> = { | ||
| 15 | off: "Off", | ||
| 16 | minimal: "Minimal", | ||
| 17 | low: "Low", | ||
| 18 | medium: "Medium", | ||
| 19 | high: "High", | ||
| 20 | xhigh: "Extra High", | ||
| 21 | max: "Max", | ||
| 22 | }; | ||
| 23 | |||
| 24 | export const EMPTY_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 25 | optionDescriptors: [], | ||
| 26 | }); | ||
| 27 | |||
| 28 | export function thinkingCapabilitiesForPiModel( | ||
| 29 | model: unknown, | ||
| 30 | defaultThinkingLevel: unknown, | ||
| 31 | ): ModelCapabilities { | ||
| 32 | const levels = supportedPiThinkingLevelsFromModel(model); | ||
| 33 | if (levels.length === 0) return EMPTY_PI_MODEL_CAPABILITIES; | ||
| 34 | const defaultLevel = clampPiThinkingLevel(defaultThinkingLevel, levels); | ||
| 35 | return createModelCapabilities({ | ||
| 36 | optionDescriptors: [ | ||
| 37 | { | ||
| 38 | id: "thinking", | ||
| 39 | label: "Thinking", | ||
| 40 | type: "select", | ||
| 41 | options: levels.map((level): ProviderOptionChoice => ({ | ||
| 42 | id: level, | ||
| 43 | label: PI_THINKING_LEVEL_LABELS[level], | ||
| 44 | ...(level === defaultLevel ? { isDefault: true } : {}), | ||
| 45 | })), | ||
| 46 | }, | ||
| 47 | ], | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | |||
| 51 | /** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ | ||
| 52 | function clampPiThinkingLevel( | ||
| 53 | input: unknown, | ||
| 54 | availableLevels: ReadonlyArray<PiThinkingLevel>, | ||
| 55 | ): PiThinkingLevel | undefined { | ||
| 56 | if (typeof input !== "string") return undefined; | ||
| 57 | const requestedIndex = PI_THINKING_LEVELS.findIndex((level) => level === input); | ||
| 58 | if (requestedIndex === -1) return undefined; | ||
| 59 | const exact = availableLevels.find((level) => level === input); | ||
| 60 | if (exact !== undefined) return exact; | ||
| 61 | for (let index = requestedIndex + 1; index < PI_THINKING_LEVELS.length; index += 1) { | ||
| 62 | const higher = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | ||
| 63 | if (higher !== undefined) return higher; | ||
| 64 | } | ||
| 65 | for (let index = requestedIndex - 1; index >= 0; index -= 1) { | ||
| 66 | const lower = availableLevels.find((level) => level === PI_THINKING_LEVELS[index]); | ||
| 67 | if (lower !== undefined) return lower; | ||
| 68 | } | ||
| 69 | return availableLevels[0]; | ||
| 70 | } | ||
| 71 | |||
| 72 | /** | ||
| 73 | * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. | ||
| 74 | * | ||
| 75 | * A reasoning model always exposes off through high unless a map entry is | ||
| 76 | * `null`. Extra High and Max appear only when the map has a non-null entry. | ||
| 77 | */ | ||
| 78 | function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray<PiThinkingLevel> { | ||
| 79 | if (recordField(model, "reasoning") !== true) return []; | ||
| 80 | const thinkingLevelMap = thinkingLevelMapFromModel(model); | ||
| 81 | return PI_THINKING_LEVELS.filter((level) => { | ||
| 82 | const mapped = thinkingLevelMap?.[level]; | ||
| 83 | if (mapped === null) return false; | ||
| 84 | if (level === "xhigh" || level === "max") return mapped !== undefined; | ||
| 85 | return true; | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | function thinkingLevelMapFromModel(model: unknown): Record<string, unknown> | undefined { | ||
| 90 | const value = recordField(model, "thinkingLevelMap"); | ||
| 91 | if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; | ||
| 92 | return value as Record<string, unknown>; | ||
| 93 | } | ||
| 94 | |||
| 95 | function recordField(input: unknown, key: string): unknown { | ||
| 96 | return Predicate.isObject(input) ? input[key] : undefined; | ||
| 97 | } | ||
apps/server/src/provider/providerCompatibility.test.ts+3-4| ... | @@ -10,9 +10,8 @@ import * as Ref from "effect/Ref"; | ... | @@ -10,9 +10,8 @@ import * as Ref from "effect/Ref"; |
| 10 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as ServerConfig from "../config.ts"; | 11 | import * as ServerConfig from "../config.ts"; |
| 12 | import * as ModelManifest from "./ModelManifest.ts"; | 12 | import * as ModelManifest from "./ModelManifest.ts"; |
| 13 | import * as ProviderRegistryLayer from "./Layers/ProviderRegistry.ts"; | 13 | import * as ProviderRegistry from "./ProviderRegistry.ts"; |
| 14 | import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; | 14 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; |
| 15 | import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; | ||
| 16 | import type { ProviderInstance } from "./ProviderDriver.ts"; | 15 | import type { ProviderInstance } from "./ProviderDriver.ts"; |
| 17 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | 16 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; |
| 18 | import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; | 17 | 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 | ... | @@ -362,7 +361,7 @@ it.effect("a remote policy refresh preserves a newer health result on the regist |
| 362 | assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); | 361 | assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); |
| 363 | assert.strictEqual(updated?.status, "error"); | 362 | assert.strictEqual(updated?.status, "error"); |
| 364 | assert.strictEqual(updated?.message, "Authentication failed"); | 363 | assert.strictEqual(updated?.message, "Authentication failed"); |
| 365 | }).pipe(Effect.provide(ProviderRegistryLayer.layer.pipe(Layer.provide(layerDependencies)))); | 364 | }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); |
| 366 | }).pipe(Effect.scoped), | 365 | }).pipe(Effect.scoped), |
| 367 | ); | 366 | ); |
| 368 | 367 |
apps/server/src/provider/providerInstallation.test.ts+2-2| ... | @@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts"; | ... | @@ -17,8 +17,8 @@ import * as CodexInstallation from "./CodexInstallation.ts"; |
| 17 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; | 17 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; |
| 18 | import type { ProviderInstance } from "./ProviderDriver.ts"; | 18 | import type { ProviderInstance } from "./ProviderDriver.ts"; |
| 19 | import { makeProviderInstallation } from "./providerInstallation.ts"; | 19 | import { makeProviderInstallation } from "./providerInstallation.ts"; |
| 20 | import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; | 20 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; |
| 21 | import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; | 21 | import * as ProviderRegistry from "./ProviderRegistry.ts"; |
| 22 | 22 | ||
| 23 | const instanceId = ProviderInstanceId.make("antigravity"); | 23 | const instanceId = ProviderInstanceId.make("antigravity"); |
| 24 | const driver = ProviderDriverKind.make("antigravity"); | 24 | const driver = ProviderDriverKind.make("antigravity"); |
apps/server/src/provider/providerInstallation.ts+3-3| ... | @@ -15,9 +15,9 @@ import * as Stream from "effect/Stream"; | ... | @@ -15,9 +15,9 @@ import * as Stream from "effect/Stream"; |
| 15 | import * as CodexInstallation from "./CodexInstallation.ts"; | 15 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 16 | import * as ServerSettings from "../serverSettings.ts"; | 16 | import * as ServerSettings from "../serverSettings.ts"; |
| 17 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; | 17 | import * as AntigravityInstallation from "./AntigravityInstallation.ts"; |
| 18 | import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; | 18 | import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; |
| 19 | import * as ProviderInstanceRegistry from "./Services/ProviderInstanceRegistry.ts"; | 19 | import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; |
| 20 | import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; | 20 | import * as ProviderRegistry from "./ProviderRegistry.ts"; |
| 21 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; | 21 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; |
| 22 | 22 | ||
| 23 | const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); | 23 | const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); |
apps/server/src/provider/providerMaintenanceRunner.test.ts+3-3| ... | @@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process"; | ... | @@ -20,7 +20,7 @@ import { ChildProcessSpawner } from "effect/process"; |
| 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 20 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 22 | 22 | ||
| 23 | import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; | 23 | import * as ProviderRegistry from "./ProviderRegistry.ts"; |
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | 24 | import * as ModelManifest from "./ModelManifest.ts"; |
| 25 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; | 25 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| 26 | import { | 26 | import { |
| ... | @@ -190,7 +190,7 @@ function makeRegistry( | ... | @@ -190,7 +190,7 @@ function makeRegistry( |
| 190 | ); | 190 | ); |
| 191 | }); | 191 | }); |
| 192 | 192 | ||
| 193 | const registry: ProviderRegistry.ProviderRegistryShape = { | 193 | const registry: ProviderRegistry.ProviderRegistry["Service"] = { |
| 194 | getProviders: Ref.get(providersRef), | 194 | getProviders: Ref.get(providersRef), |
| 195 | refresh: () => Ref.get(providersRef), | 195 | refresh: () => Ref.get(providersRef), |
| 196 | refreshInstance: () => Ref.get(providersRef), | 196 | refreshInstance: () => Ref.get(providersRef), |
| ... | @@ -210,7 +210,7 @@ function makeRegistry( | ... | @@ -210,7 +210,7 @@ function makeRegistry( |
| 210 | } | 210 | } |
| 211 | 211 | ||
| 212 | const makeTestRunner = ( | 212 | const makeTestRunner = ( |
| 213 | registry: ProviderRegistry.ProviderRegistryShape, | 213 | registry: ProviderRegistry.ProviderRegistry["Service"], |
| 214 | // Generic updater fixtures use synthetic versions. Keep their compatibility | 214 | // Generic updater fixtures use synthetic versions. Keep their compatibility |
| 215 | // unknown so real harness minimums do not bypass the command under test. | 215 | // unknown so real harness minimums do not bypass the command under test. |
| 216 | manifest: ModelManifest.ModelManifestData = { | 216 | manifest: ModelManifest.ModelManifestData = { |
apps/server/src/provider/providerMaintenanceRunner.ts+1-1| ... | @@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ... | @@ -23,7 +23,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 23 | 23 | ||
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | 24 | import * as ModelManifest from "./ModelManifest.ts"; |
| 25 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; | 25 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; |
| 26 | import * as ProviderRegistry from "./Services/ProviderRegistry.ts"; | 26 | import * as ProviderRegistry from "./ProviderRegistry.ts"; |
| 27 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; | 27 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; |
| 28 | import { | 28 | import { |
| 29 | enrichProviderSnapshotWithVersionAdvisory, | 29 | enrichProviderSnapshotWithVersionAdvisory, |
apps/server/src/provider/resetCreditCoordinator.test.ts created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 6 | |||
| 7 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; | ||
| 8 | |||
| 9 | describe("ResetCreditCoordinator", () => { | ||
| 10 | it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => | ||
| 11 | Effect.gen(function* () { | ||
| 12 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 13 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 14 | const attempts = yield* Ref.make(0); | ||
| 15 | const consume = (key: string) => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | yield* Ref.update(keys, (seen) => [...seen, key]); | ||
| 18 | const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); | ||
| 19 | if (attempt === 1) return yield* Effect.fail("timed out" as const); | ||
| 20 | return "reset" as const; | ||
| 21 | }); | ||
| 22 | |||
| 23 | const first = yield* redeem("acct", consume).pipe(Effect.result); | ||
| 24 | assert.isTrue(first._tag === "Failure"); | ||
| 25 | const second = yield* redeem("acct", consume); | ||
| 26 | assert.strictEqual(second, "reset"); | ||
| 27 | // A fresh redemption after success must be a fresh attempt. | ||
| 28 | yield* redeem("acct", consume); | ||
| 29 | |||
| 30 | const seen = yield* Ref.get(keys); | ||
| 31 | assert.strictEqual(seen.length, 3); | ||
| 32 | assert.strictEqual(seen[0], seen[1]); | ||
| 33 | assert.notStrictEqual(seen[1], seen[2]); | ||
| 34 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 35 | ); | ||
| 36 | |||
| 37 | it.effect("starts a fresh attempt after a settled failure", () => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 40 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 41 | const consume = (key: string) => | ||
| 42 | Ref.update(keys, (seen) => [...seen, key]).pipe( | ||
| 43 | Effect.andThen(Effect.fail("cooldown" as const)), | ||
| 44 | ); | ||
| 45 | const isSettled = (error: "cooldown") => error === "cooldown"; | ||
| 46 | |||
| 47 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); | ||
| 48 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); | ||
| 49 | |||
| 50 | const seen = yield* Ref.get(keys); | ||
| 51 | assert.strictEqual(seen.length, 2); | ||
| 52 | assert.notStrictEqual(seen[0], seen[1]); | ||
| 53 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("serialises concurrent redemptions on the same account, not per caller", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 59 | const release = yield* Deferred.make<void>(); | ||
| 60 | const inFlight = yield* Ref.make(0); | ||
| 61 | const peak = yield* Ref.make(0); | ||
| 62 | const consume = () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); | ||
| 65 | yield* Ref.update(peak, (p) => Math.max(p, now)); | ||
| 66 | yield* Deferred.await(release); | ||
| 67 | yield* Ref.update(inFlight, (n) => n - 1); | ||
| 68 | return "reset" as const; | ||
| 69 | }); | ||
| 70 | |||
| 71 | // Two instances of the same account redeem at once. | ||
| 72 | const a = yield* redeem("acct", consume).pipe(Effect.forkChild); | ||
| 73 | const b = yield* redeem("acct", consume).pipe(Effect.forkChild); | ||
| 74 | yield* Effect.yieldNow; | ||
| 75 | yield* Deferred.succeed(release, undefined); | ||
| 76 | yield* Fiber.join(a); | ||
| 77 | yield* Fiber.join(b); | ||
| 78 | |||
| 79 | assert.strictEqual(yield* Ref.get(peak), 1); | ||
| 80 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 81 | ); | ||
| 82 | |||
| 83 | it.effect("keeps different accounts independent", () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; | ||
| 86 | const release = yield* Deferred.make<void>(); | ||
| 87 | const peak = yield* Ref.make(0); | ||
| 88 | const inFlight = yield* Ref.make(0); | ||
| 89 | const consume = () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); | ||
| 92 | yield* Ref.update(peak, (p) => Math.max(p, now)); | ||
| 93 | yield* Deferred.await(release); | ||
| 94 | return "reset" as const; | ||
| 95 | }); | ||
| 96 | const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); | ||
| 97 | const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); | ||
| 98 | yield* Effect.yieldNow; | ||
| 99 | yield* Deferred.succeed(release, undefined); | ||
| 100 | yield* Fiber.join(a); | ||
| 101 | yield* Fiber.join(b); | ||
| 102 | assert.strictEqual(yield* Ref.get(peak), 2); | ||
| 103 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), | ||
| 104 | ); | ||
| 105 | }); | ||
apps/server/src/provider/resetCreditCoordinator.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | /** | ||
| 2 | * Redeeming a reset credit is an account-level action: instances that share | ||
| 3 | * the directory holding a provider's login share the credit, so their | ||
| 4 | * redemptions must serialise on that directory, not the instance. This | ||
| 5 | * service keeps one lock and one pending idempotency key per account key so | ||
| 6 | * overlapping confirmations from any instance queue rather than spending two | ||
| 7 | * credits, and a retry after a timeout re-sends the same attempt. | ||
| 8 | * | ||
| 9 | * @module provider/resetCreditCoordinator | ||
| 10 | */ | ||
| 11 | import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import type * as PlatformError from "effect/PlatformError"; | ||
| 17 | import * as Ref from "effect/Ref"; | ||
| 18 | import * as Semaphore from "effect/Semaphore"; | ||
| 19 | |||
| 20 | interface AccountRedemptionState { | ||
| 21 | readonly lock: Semaphore.Semaphore; | ||
| 22 | readonly pendingKey: Ref.Ref<string | null>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export class ResetCreditCoordinator extends Context.Service< | ||
| 26 | ResetCreditCoordinator, | ||
| 27 | { | ||
| 28 | /** | ||
| 29 | * Run `consume` under the account's lock with a stable idempotency key. | ||
| 30 | * The key is cleared when the provider reports an outcome, or when | ||
| 31 | * `isSettled` says a failure was a final answer (such as a cooldown). | ||
| 32 | * Any other failure (timeout included) keeps it so the next attempt is | ||
| 33 | * the same attempt. | ||
| 34 | */ | ||
| 35 | readonly redeem: <E, R>( | ||
| 36 | accountKey: string, | ||
| 37 | consume: (idempotencyKey: string) => Effect.Effect<ProviderConsumeResetCreditOutcome, E, R>, | ||
| 38 | isSettled?: (error: E) => boolean, | ||
| 39 | ) => Effect.Effect<ProviderConsumeResetCreditOutcome, E | PlatformError.PlatformError, R>; | ||
| 40 | } | ||
| 41 | >()("t3/provider/resetCreditCoordinator") {} | ||
| 42 | |||
| 43 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 44 | export const make = Effect.gen(function* () { | ||
| 45 | const crypto = yield* Crypto.Crypto; | ||
| 46 | const statesRef = yield* Ref.make<ReadonlyMap<string, AccountRedemptionState>>(new Map()); | ||
| 47 | |||
| 48 | // Get-or-create through one Ref.modify so two first redemptions for the | ||
| 49 | // same account cannot each install their own lock. | ||
| 50 | const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { | ||
| 51 | const existing = (yield* Ref.get(statesRef)).get(accountKey); | ||
| 52 | if (existing) return existing; | ||
| 53 | const candidate = { | ||
| 54 | lock: yield* Semaphore.make(1), | ||
| 55 | pendingKey: yield* Ref.make<string | null>(null), | ||
| 56 | }; | ||
| 57 | return yield* Ref.modify(statesRef, (states) => { | ||
| 58 | const current = states.get(accountKey); | ||
| 59 | if (current) return [current, states] as const; | ||
| 60 | const next = new Map(states); | ||
| 61 | next.set(accountKey, candidate); | ||
| 62 | return [candidate, next] as const; | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const state = yield* stateFor(accountKey); | ||
| 69 | return yield* state.lock.withPermits(1)( | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const existing = yield* Ref.get(state.pendingKey); | ||
| 72 | const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); | ||
| 73 | yield* Ref.set(state.pendingKey, idempotencyKey); | ||
| 74 | const outcome = yield* consume(idempotencyKey).pipe( | ||
| 75 | Effect.tapError((error) => | ||
| 76 | isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, | ||
| 77 | ), | ||
| 78 | ); | ||
| 79 | yield* Ref.set(state.pendingKey, null); | ||
| 80 | return outcome; | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | }); | ||
| 84 | |||
| 85 | return { redeem } satisfies ResetCreditCoordinator["Service"]; | ||
| 86 | }); | ||
| 87 | |||
| 88 | export const layer = Layer.effect(ResetCreditCoordinator, make); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Self-contained for tests: a counter-backed Crypto so keys are deterministic | ||
| 92 | * and distinct without the platform layer. | ||
| 93 | */ | ||
| 94 | export const layerTest = Layer.effect( | ||
| 95 | ResetCreditCoordinator, | ||
| 96 | Effect.gen(function* () { | ||
| 97 | let counter = 0; | ||
| 98 | return yield* make.pipe( | ||
| 99 | Effect.provideService( | ||
| 100 | Crypto.Crypto, | ||
| 101 | Crypto.make({ | ||
| 102 | randomBytes: (size) => { | ||
| 103 | counter += 1; | ||
| 104 | return new Uint8Array(size).fill(counter); | ||
| 105 | }, | ||
| 106 | digest: (_algorithm, data) => Effect.succeed(data), | ||
| 107 | }), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | }), | ||
| 111 | ); | ||
apps/server/src/provider/testUtils/providerRegistryMock.ts+2-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; | 1 | import * as ProviderRegistry from "../ProviderRegistry.ts"; |
| 2 | import type { ServerProvider } from "@t3tools/contracts"; | 2 | import type { ServerProvider } from "@t3tools/contracts"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte | ... | @@ -7,7 +7,7 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte |
| 7 | 7 | ||
| 8 | const makeProviderRegistryMock = ( | 8 | const makeProviderRegistryMock = ( |
| 9 | providers: ReadonlyArray<ServerProvider> = [], | 9 | providers: ReadonlyArray<ServerProvider> = [], |
| 10 | ): ProviderRegistry.ProviderRegistryShape => ({ | 10 | ): ProviderRegistry.ProviderRegistry["Service"] => ({ |
| 11 | getProviders: Effect.succeed(providers), | 11 | getProviders: Effect.succeed(providers), |
| 12 | refresh: () => Effect.succeed(providers), | 12 | refresh: () => Effect.succeed(providers), |
| 13 | refreshInstance: () => Effect.succeed(providers), | 13 | refreshInstance: () => Effect.succeed(providers), |
apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeReadline from "node:readline"; | ||
| 4 | const args = process.argv.slice(2); | ||
| 5 | const mcpConfigIndex = args.indexOf("--mcp-config"); | ||
| 6 | const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; | ||
| 7 | let mcpConfig; | ||
| 8 | if (rawMcpConfig) { | ||
| 9 | const contents = NodeFS.existsSync(rawMcpConfig) | ||
| 10 | ? NodeFS.readFileSync(rawMcpConfig, "utf8") | ||
| 11 | : rawMcpConfig; | ||
| 12 | try { | ||
| 13 | mcpConfig = JSON.parse(contents); | ||
| 14 | } catch { | ||
| 15 | mcpConfig = contents; | ||
| 16 | } | ||
| 17 | } | ||
| 18 | NodeFS.writeFileSync( | ||
| 19 | process.env.T3_PROBE_INVOCATION_PATH, | ||
| 20 | JSON.stringify({ | ||
| 21 | args, | ||
| 22 | cwd: process.cwd(), | ||
| 23 | connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, | ||
| 24 | mcpConfig, | ||
| 25 | }), | ||
| 26 | ); | ||
| 27 | const lines = NodeReadline.createInterface({ input: process.stdin }); | ||
| 28 | lines.on("line", (line) => { | ||
| 29 | const message = JSON.parse(line); | ||
| 30 | if (message.type !== "control_request") return; | ||
| 31 | const reply = (response) => | ||
| 32 | process.stdout.write( | ||
| 33 | JSON.stringify({ | ||
| 34 | type: "control_response", | ||
| 35 | response: { subtype: "success", request_id: message.request_id, response }, | ||
| 36 | }) + "\n", | ||
| 37 | ); | ||
| 38 | if (message.request?.subtype === "initialize") { | ||
| 39 | reply({ | ||
| 40 | commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], | ||
| 41 | agents: [], | ||
| 42 | output_style: "default", | ||
| 43 | available_output_styles: ["default"], | ||
| 44 | models: [], | ||
| 45 | account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 46 | }); | ||
| 47 | } | ||
| 48 | // The probe follows initialize with get_usage on the same process. | ||
| 49 | if (message.request?.subtype === "get_usage") { | ||
| 50 | reply({ | ||
| 51 | session: {}, | ||
| 52 | subscription_type: "pro", | ||
| 53 | rate_limits_available: true, | ||
| 54 | rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, | ||
| 55 | behaviors: null, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | }); | ||
| 59 | // Stay alive for follow-up control requests, but never outlive the | ||
| 60 | // parent: the probe aborts the SDK without awaiting the child, so an | ||
| 61 | // unconditional interval would strand this process until reboot. | ||
| 62 | const keepAlive = setInterval(() => {}, 1_000); | ||
| 63 | lines.on("close", () => { | ||
| 64 | clearInterval(keepAlive); | ||
| 65 | process.exit(0); | ||
| 66 | }); | ||
apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodeReadline from "node:readline"; | ||
| 4 | if (process.argv.includes("--version")) { | ||
| 5 | process.stdout.write("claude 2.1.219\n"); | ||
| 6 | process.exit(0); | ||
| 7 | } | ||
| 8 | const lines = NodeReadline.createInterface({ input: process.stdin }); | ||
| 9 | lines.on("line", (line) => { | ||
| 10 | const message = JSON.parse(line); | ||
| 11 | if (message.type !== "control_request") return; | ||
| 12 | if (message.request?.subtype === "get_usage") { | ||
| 13 | const marker = process.env.T3_CLAUDE_RESET_MARKER; | ||
| 14 | if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { | ||
| 15 | process.stdout.write( | ||
| 16 | JSON.stringify({ | ||
| 17 | type: "control_response", | ||
| 18 | response: { subtype: "error", request_id: message.request_id, error: "usage failed" }, | ||
| 19 | }) + "\n", | ||
| 20 | ); | ||
| 21 | return; | ||
| 22 | } | ||
| 23 | process.stdout.write( | ||
| 24 | JSON.stringify({ | ||
| 25 | type: "control_response", | ||
| 26 | response: { | ||
| 27 | subtype: "success", | ||
| 28 | request_id: message.request_id, | ||
| 29 | response: { | ||
| 30 | session: {}, | ||
| 31 | subscription_type: "pro", | ||
| 32 | rate_limits_available: true, | ||
| 33 | rate_limits: { | ||
| 34 | five_hour: { | ||
| 35 | utilization: marker && NodeFS.existsSync(marker) ? 0 : 100, | ||
| 36 | resets_at: null, | ||
| 37 | }, | ||
| 38 | }, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | }) + "\n", | ||
| 42 | ); | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | if (message.request?.subtype !== "initialize") return; | ||
| 46 | process.stdout.write( | ||
| 47 | JSON.stringify({ | ||
| 48 | type: "control_response", | ||
| 49 | response: { | ||
| 50 | subtype: "success", | ||
| 51 | request_id: message.request_id, | ||
| 52 | response: { | ||
| 53 | commands: [], | ||
| 54 | agents: [], | ||
| 55 | models: [], | ||
| 56 | output_style: "default", | ||
| 57 | available_output_styles: ["default"], | ||
| 58 | account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, | ||
| 59 | }, | ||
| 60 | }, | ||
| 61 | }) + "\n", | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | // Stay alive for follow-up control requests, but never outlive the | ||
| 65 | // parent: the probe aborts the SDK without awaiting the child, so an | ||
| 66 | // unconditional interval would strand this process until reboot. | ||
| 67 | const keepAlive = setInterval(() => {}, 1_000); | ||
| 68 | lines.on("close", () => { | ||
| 69 | clearInterval(keepAlive); | ||
| 70 | process.exit(0); | ||
| 71 | }); | ||
apps/server/src/pullRequest/PullRequestService.test.ts+1-1| ... | @@ -25,7 +25,7 @@ import * as ServerSettingsService from "../serverSettings.ts"; | ... | @@ -25,7 +25,7 @@ import * as ServerSettingsService from "../serverSettings.ts"; |
| 25 | import { PullRequestOperationError } from "@t3tools/contracts"; | 25 | import { PullRequestOperationError } from "@t3tools/contracts"; |
| 26 | 26 | ||
| 27 | import * as ProjectService from "../project/ProjectService.ts"; | 27 | import * as ProjectService from "../project/ProjectService.ts"; |
| 28 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 28 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 29 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; | 29 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| 30 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; | 30 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 31 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 31 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
apps/server/src/pullRequest/linkedThreads.test.ts+1-1| ... | @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SqlClient from "effect/sql/SqlClient"; | 4 | import * as SqlClient from "effect/sql/SqlClient"; |
| 5 | 5 | ||
| 6 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 6 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 7 | import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; | 7 | import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; |
| 8 | 8 | ||
| 9 | const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 9 | const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts+1-1| ... | @@ -11,7 +11,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -11,7 +11,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 11 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | 11 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; |
| 12 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 12 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 13 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 13 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 14 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 14 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 15 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; | 15 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; |
| 16 | 16 | ||
| 17 | const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); | 17 | const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); |
apps/server/src/scheduledTasks/ScheduledTaskService.test.ts+1-1| ... | @@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -18,7 +18,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 18 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | 18 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; |
| 19 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 19 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 20 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 20 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 21 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 21 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 22 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; |
| 23 | 23 | ||
| 24 | const isScheduledTaskError = Schema.is(ScheduledTaskError); | 24 | const isScheduledTaskError = Schema.is(ScheduledTaskError); |
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+1-1| ... | @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -17,7 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 17 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | 17 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; |
| 18 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 18 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 19 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 19 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 20 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 20 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 21 | import * as Scheduler from "../scheduling/Scheduler.ts"; | 21 | import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 22 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; | 22 | import * as ScheduledTaskService from "./ScheduledTaskService.ts"; |
| 23 | 23 |
apps/server/src/scheduling/Scheduler.integration.test.ts+1-1| ... | @@ -22,7 +22,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; | ... | @@ -22,7 +22,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 22 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; | 22 | import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; |
| 23 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | 23 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 24 | import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; | 24 | import * as UsageLimitRecoveryWorker from "../orchestration-v2/UsageLimitRecoveryWorker.ts"; |
| 25 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 25 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 26 | import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; | 26 | import * as ScheduledTasks from "../scheduledTasks/ScheduledTaskService.ts"; |
| 27 | import * as SecretRequests from "../secrets/SecretRequests.ts"; | 27 | import * as SecretRequests from "../secrets/SecretRequests.ts"; |
| 28 | import * as ServerSettings from "../serverSettings.ts"; | 28 | import * as ServerSettings from "../serverSettings.ts"; |
apps/server/src/server.ts+11-12| ... | @@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; | ... | @@ -35,17 +35,17 @@ import * as NodePtyAdapter from "./terminal/NodePtyAdapter.ts"; |
| 35 | import * as PullRequestHttp from "./pullRequest/http.ts"; | 35 | import * as PullRequestHttp from "./pullRequest/http.ts"; |
| 36 | import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; | 36 | import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; |
| 37 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; | 37 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| 38 | import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts"; | 38 | import * as SqlitePersistence from "./persistence/Sqlite.ts"; |
| 39 | import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; | 39 | import * as PullRequestFilesViewed from "./persistence/PullRequestFilesViewed.ts"; |
| 40 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 40 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 41 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | 41 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; |
| 42 | import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; | 42 | import * as ProviderEventIngestor from "./orchestration-v2/ProviderEventIngestor.ts"; |
| 43 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 43 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 44 | import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; | 44 | import * as ResetCreditCoordinator from "./provider/resetCreditCoordinator.ts"; |
| 45 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 45 | import * as ProviderEventLoggers from "./provider/ProviderEventLoggers.ts"; |
| 46 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; | 46 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; |
| 47 | import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; | 47 | import * as OpenCodeServerLedger from "./provider/OpenCodeServerLedger.ts"; |
| 48 | import * as AcpRegistryCatalog from "./provider/Layers/AcpRegistryCatalog.ts"; | 48 | import * as AcpRegistryCatalog from "./provider/AcpRegistryCatalog.ts"; |
| 49 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | 49 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 50 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; | 50 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 51 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; | 51 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| ... | @@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | ... | @@ -54,7 +54,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 54 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 54 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 55 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; | 55 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; |
| 56 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 56 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 57 | import * as ProviderInstanceRegistryHydration from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 57 | import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; |
| 58 | import * as TerminalManager from "./terminal/Manager.ts"; | 58 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 59 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; | 59 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 60 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; | 60 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| ... | @@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts"; | ... | @@ -70,16 +70,15 @@ import * as Keybindings from "./keybindings.ts"; |
| 70 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 70 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 71 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | 71 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; |
| 72 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | 72 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 73 | import * as ProviderRegistryLayer from "./provider/Layers/ProviderRegistry.ts"; | ||
| 74 | import * as ServerSettings from "./serverSettings.ts"; | 73 | import * as ServerSettings from "./serverSettings.ts"; |
| 75 | import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; | 74 | import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; |
| 76 | import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; | 75 | import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; |
| 77 | import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; | 76 | import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts"; |
| 78 | import * as CodexInstallation from "./provider/CodexInstallation.ts"; | 77 | import * as CodexInstallation from "./provider/CodexInstallation.ts"; |
| 79 | import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; | 78 | import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; |
| 80 | import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts"; | 79 | import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts"; |
| 81 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 80 | import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; |
| 82 | import * as ProviderUsageLimitsIngestion from "./provider/Layers/ProviderUsageLimitsIngestion.ts"; | 81 | import * as ProviderUsageLimitsIngestion from "./provider/ProviderUsageLimitsIngestion.ts"; |
| 83 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; | 82 | import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; |
| 84 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; | 83 | import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; |
| 85 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; | 84 | import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; |
| ... | @@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; | ... | @@ -101,7 +100,7 @@ import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; |
| 101 | import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; | 100 | import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; |
| 102 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 101 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 103 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; | 102 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| 104 | import * as Observability from "./observability/Layers/Observability.ts"; | 103 | import * as Observability from "./observability/Observability.ts"; |
| 105 | import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; | 104 | import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; |
| 106 | import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; | 105 | import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; |
| 107 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | 106 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| ... | @@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -569,7 +568,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 569 | Layer.provideMerge( | 568 | Layer.provideMerge( |
| 570 | Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), | 569 | Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer, UsageLimitSources.layer), |
| 571 | ), | 570 | ), |
| 572 | Layer.provideMerge(ProviderRegistryLayer.layer), | 571 | Layer.provideMerge(ProviderRegistry.layer), |
| 573 | // The instance registry is the new routing keystone — text generation, | 572 | // The instance registry is the new routing keystone — text generation, |
| 574 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` | 573 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` |
| 575 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; | 574 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; |
| ... | @@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( | ... | @@ -601,7 +600,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 601 | Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), | 600 | Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), |
| 602 | ), | 601 | ), |
| 603 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old | 602 | // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old |
| 604 | // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but | 603 | // `ProviderRegistry.layer` pulled `OpenCodeRuntimeLive` in for itself, but |
| 605 | // the rewritten registry reads snapshots off the instance registry and | 604 | // the rewritten registry reads snapshots off the instance registry and |
| 606 | // no longer transitively provides it. Exposing it at the runtime level | 605 | // no longer transitively provides it. Exposing it at the runtime level |
| 607 | // keeps a single Live for all opencode consumers. | 606 | // keeps a single Live for all opencode consumers. |
apps/server/src/serverSettings.test.ts+1-1| ... | @@ -27,7 +27,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -27,7 +27,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 27 | import * as SqlClient from "effect/sql/SqlClient"; | 27 | import * as SqlClient from "effect/sql/SqlClient"; |
| 28 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 28 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 29 | import * as ServerConfig from "./config.ts"; | 29 | import * as ServerConfig from "./config.ts"; |
| 30 | import * as SqlitePersistence from "./persistence/Layers/Sqlite.ts"; | 30 | import * as SqlitePersistence from "./persistence/Sqlite.ts"; |
| 31 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | 31 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 32 | import * as ServerSettingsModule from "./serverSettings.ts"; | 32 | import * as ServerSettingsModule from "./serverSettings.ts"; |
| 33 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; | 33 | import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; |
apps/server/src/terminal/Manager.test.ts+1-1| ... | @@ -37,7 +37,7 @@ import { expect } from "vite-plus/test"; | ... | @@ -37,7 +37,7 @@ import { expect } from "vite-plus/test"; |
| 37 | 37 | ||
| 38 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 38 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 39 | import * as ServerConfig from "../config.ts"; | 39 | import * as ServerConfig from "../config.ts"; |
| 40 | import * as SqlitePersistence from "../persistence/Layers/Sqlite.ts"; | 40 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 41 | import * as ProcessRunner from "../processRunner.ts"; | 41 | import * as ProcessRunner from "../processRunner.ts"; |
| 42 | import * as ServerSettings from "../serverSettings.ts"; | 42 | import * as ServerSettings from "../serverSettings.ts"; |
| 43 | import * as TerminalManager from "./Manager.ts"; | 43 | import * as TerminalManager from "./Manager.ts"; |
apps/server/src/terminal/Manager.ts+1-1| ... | @@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -65,7 +65,7 @@ import * as ServerConfig from "../config.ts"; |
| 65 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; | 65 | import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; |
| 66 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | 66 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; |
| 67 | import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; | 67 | import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; |
| 68 | import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; | 68 | import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 69 | import * as ServerSettings from "../serverSettings.ts"; | 69 | import * as ServerSettings from "../serverSettings.ts"; |
| 70 | import { | 70 | import { |
| 71 | increment, | 71 | increment, |
apps/server/src/testUtils/shardWeights.json+4-4| ... | @@ -31,16 +31,16 @@ | ... | @@ -31,16 +31,16 @@ |
| 31 | "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4, | 31 | "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 25.4, |
| 32 | "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7, | 32 | "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 3.7, |
| 33 | "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3, | 33 | "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 4.3, |
| 34 | "src/persistence/Layers/OrchestrationEventStore.test.ts": 1.9, | 34 | "src/persistence/OrchestrationEventStore.test.ts": 1.9, |
| 35 | "src/persistence/Layers/Sqlite.test.ts": 0.5, | 35 | "src/persistence/Sqlite.test.ts": 0.5, |
| 36 | "src/process/externalLauncher.test.ts": 2.3, | 36 | "src/process/externalLauncher.test.ts": 2.3, |
| 37 | "src/project/AgentSessionScanner.test.ts": 4.2, | 37 | "src/project/AgentSessionScanner.test.ts": 4.2, |
| 38 | "src/project/ManagedProjectFolders.test.ts": 0.5, | 38 | "src/project/ManagedProjectFolders.test.ts": 0.5, |
| 39 | "src/provider/CodexChatGptAuth.test.ts": 5.4, | 39 | "src/provider/CodexChatGptAuth.test.ts": 5.4, |
| 40 | "src/provider/Drivers/AntigravityDriver.test.ts": 2.3, | 40 | "src/provider/Drivers/AntigravityDriver.test.ts": 2.3, |
| 41 | "src/provider/Layers/GrokProvider.test.ts": 0.9, | 41 | "src/provider/GrokProvider.test.ts": 0.9, |
| 42 | "src/provider/Layers/ProviderInstanceRegistryLive.test.ts": 0.5, | ||
| 43 | "src/provider/OpenCodeServerLedger.test.ts": 1.7, | 42 | "src/provider/OpenCodeServerLedger.test.ts": 1.7, |
| 43 | "src/provider/ProviderInstanceRegistry.test.ts": 0.5, | ||
| 44 | "src/provider/acp/AcpClientTerminals.test.ts": 1.3, | 44 | "src/provider/acp/AcpClientTerminals.test.ts": 1.3, |
| 45 | "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2, | 45 | "src/provider/acp/AcpJsonRpcConnection.test.ts": 10.2, |
| 46 | "src/provider/acp/AcpRegistryProbe.test.ts": 1, | 46 | "src/provider/acp/AcpRegistryProbe.test.ts": 1, |
apps/server/src/testkit/RpcInitialMemory.fixture.mjs+1-1| ... | @@ -17,7 +17,7 @@ const [Prefix, Ws, Threads, Events] = await Promise.all([ | ... | @@ -17,7 +17,7 @@ const [Prefix, Ws, Threads, Events] = await Promise.all([ |
| 17 | app("rpcInitialItems"), | 17 | app("rpcInitialItems"), |
| 18 | app("ws"), | 18 | app("ws"), |
| 19 | app("orchestration-v2/ThreadManagementService"), | 19 | app("orchestration-v2/ThreadManagementService"), |
| 20 | app("persistence/Services/OrchestrationEventStore"), | 20 | app("persistence/OrchestrationEventStore"), |
| 21 | ]); | 21 | ]); |
| 22 | const refs = []; | 22 | const refs = []; |
| 23 | const fibers = []; | 23 | const fibers = []; |
apps/server/src/textGeneration/CodexTextGeneration.ts+1-1| ... | @@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ... | @@ -20,7 +20,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 20 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | 20 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 21 | import * as ServerConfig from "../config.ts"; | 21 | import * as ServerConfig from "../config.ts"; |
| 22 | import { expandHomePath } from "../pathExpansion.ts"; | 22 | import { expandHomePath } from "../pathExpansion.ts"; |
| 23 | import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; | 23 | import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts"; |
| 24 | import * as TextGeneration from "./TextGeneration.ts"; | 24 | import * as TextGeneration from "./TextGeneration.ts"; |
| 25 | import { | 25 | import { |
| 26 | buildBranchNamePrompt, | 26 | buildBranchNamePrompt, |
apps/server/src/textGeneration/TextGeneration.test.ts+1-1| ... | @@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts"; | ... | @@ -9,7 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | 10 | ||
| 11 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 11 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 12 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 12 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 13 | import * as TextGeneration from "./TextGeneration.ts"; | 13 | import * as TextGeneration from "./TextGeneration.ts"; |
| 14 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 14 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 15 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
apps/server/src/textGeneration/TextGeneration.ts+1-1| ... | @@ -9,7 +9,7 @@ import type { | ... | @@ -9,7 +9,7 @@ import type { |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | 10 | import { TextGenerationError } from "@t3tools/contracts"; |
| 11 | 11 | ||
| 12 | import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; | 12 | import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; |
| 13 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | 13 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; |
| 14 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 14 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 15 | import * as ThreadTitleLinks from "./ThreadTitleLinks.ts"; | 15 | import * as ThreadTitleLinks from "./ThreadTitleLinks.ts"; |
apps/server/src/usage/cliproxyApi.ts+3-3| ... | @@ -12,9 +12,9 @@ import * as Effect from "effect/Effect"; | ... | @@ -12,9 +12,9 @@ import * as Effect from "effect/Effect"; |
| 12 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 13 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | 13 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 14 | 14 | ||
| 15 | import { codexPlanLabel } from "../provider/Layers/CodexProvider.ts"; | 15 | import { codexPlanLabel } from "../provider/CodexProvider.ts"; |
| 16 | import { codexRateLimitsToLimits } from "../provider/Layers/codexUsageLimits.ts"; | 16 | import { codexRateLimitsToLimits } from "../provider/codexUsageLimits.ts"; |
| 17 | import { claudeUsageResponseToLimits } from "../provider/Layers/claudeUsageLimits.ts"; | 17 | import { claudeUsageResponseToLimits } from "../provider/claudeUsageLimits.ts"; |
| 18 | import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts"; | 18 | import { makeUnavailableUsageLimits } from "../provider/providerUsageLimits.ts"; |
| 19 | 19 | ||
| 20 | const AuthFile = Schema.Struct({ | 20 | const AuthFile = Schema.Struct({ |
apps/server/src/ws.ts+4-4| ... | @@ -158,21 +158,21 @@ import { | ... | @@ -158,21 +158,21 @@ import { |
| 158 | } from "./orchestration-v2/WireProjection.ts"; | 158 | } from "./orchestration-v2/WireProjection.ts"; |
| 159 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; | 159 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; |
| 160 | import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; | 160 | import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; |
| 161 | import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; | 161 | import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; |
| 162 | import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; | 162 | import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; |
| 163 | import { | 163 | import { |
| 164 | observeRpcEffect, | 164 | observeRpcEffect, |
| 165 | observeRpcStream, | 165 | observeRpcStream, |
| 166 | observeRpcStreamEffect, | 166 | observeRpcStreamEffect, |
| 167 | } from "./observability/RpcInstrumentation.ts"; | 167 | } from "./observability/RpcInstrumentation.ts"; |
| 168 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 168 | import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; |
| 169 | import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; | 169 | import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; |
| 170 | import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; | 170 | import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; |
| 171 | import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; | 171 | import * as AcpRegistryRuntimeCoordinator from "./provider/acp/AcpRegistryRuntimeCoordinator.ts"; |
| 172 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 172 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 173 | import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; | 173 | import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; |
| 174 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 174 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 175 | import * as ProviderAuthService from "./provider/Services/ProviderAuthService.ts"; | 175 | import * as ProviderAuthService from "./provider/ProviderAuthService.ts"; |
| 176 | import { makeProviderInstallation } from "./provider/providerInstallation.ts"; | 176 | import { makeProviderInstallation } from "./provider/providerInstallation.ts"; |
| 177 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | 177 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 178 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 178 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
docs/internals/providers.md+1-1| ... | @@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r | ... | @@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r |
| 52 | ## Setup must not happen as a health-check side effect | 52 | ## Setup must not happen as a health-check side effect |
| 53 | 53 | ||
| 54 | Opening a provider session can start MCP servers, run hooks, or launch a login browser. | 54 | Opening a provider session can start MCP servers, run hooks, or launch a login browser. |
| 55 | [Grok probes](../../apps/server/src/provider/Layers/GrokProvider.ts) avoid authentication and | 55 | [Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and |
| 56 | session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for | 56 | session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for |
| 57 | explicit setup or model refresh; background checks use initialization only. | 57 | explicit setup or model refresh; background checks use initialization only. |
| 58 | 58 |
docs/operations/observability.md+1-1| ... | @@ -552,7 +552,7 @@ const program = doWork().pipe( | ... | @@ -552,7 +552,7 @@ const program = doWork().pipe( |
| 552 | 552 | ||
| 553 | ### Runtime Wiring | 553 | ### Runtime Wiring |
| 554 | 554 | ||
| 555 | The server observability layer is assembled in `apps/server/src/observability/Layers/Observability.ts`. | 555 | The server observability layer is assembled in `apps/server/src/observability/Observability.ts`. |
| 556 | 556 | ||
| 557 | It provides: | 557 | It provides: |
| 558 | 558 |