| author | |
| committer | |
| log | 32942a5f20246ad6a0bd8f0e72e73c084676d45b |
| tree | 1ddb7e7b12b7ea3a700f28a75e8cb8cf8d6d3c45 |
| parent | 150b3782b942e3b35899e134536efc0e4bbd51ac |
8 files changed, 1441 insertions(+), 0 deletions(-)
apps/server/src/provider/Layers/CodexAdapter.test.ts created+202| ... | ... | @@ -0,0 +1,202 @@ |
| 1 | import type { | |
| 2 | ProviderApprovalDecision, | |
| 3 | ProviderEvent, | |
| 4 | ProviderSendTurnInput, | |
| 5 | ProviderSession, | |
| 6 | ProviderSessionStartInput, | |
| 7 | ProviderTurnStartResult, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { afterAll, assert, it, vi } from "@effect/vitest"; | |
| 10 | import { assertFailure } from "@effect/vitest/utils"; | |
| 11 | ||
| 12 | import { Effect } from "effect"; | |
| 13 | ||
| 14 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | |
| 15 | import { ProviderAdapterValidationError } from "../Errors.ts"; | |
| 16 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | |
| 17 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; | |
| 18 | ||
| 19 | class FakeCodexManager extends CodexAppServerManager { | |
| 20 | public startSessionImpl = vi.fn( | |
| 21 | async (input: ProviderSessionStartInput): Promise<ProviderSession> => { | |
| 22 | const now = new Date().toISOString(); | |
| 23 | return { | |
| 24 | sessionId: "sess-1", | |
| 25 | provider: "codex", | |
| 26 | status: "ready", | |
| 27 | threadId: "thread-1", | |
| 28 | cwd: input.cwd, | |
| 29 | createdAt: now, | |
| 30 | updatedAt: now, | |
| 31 | }; | |
| 32 | }, | |
| 33 | ); | |
| 34 | ||
| 35 | public sendTurnImpl = vi.fn( | |
| 36 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ | |
| 37 | threadId: "thread-1", | |
| 38 | turnId: "turn-1", | |
| 39 | }), | |
| 40 | ); | |
| 41 | ||
| 42 | public interruptTurnImpl = vi.fn( | |
| 43 | async (_sessionId: string, _turnId?: string): Promise<void> => undefined, | |
| 44 | ); | |
| 45 | ||
| 46 | public readThreadImpl = vi.fn(async (_sessionId: string) => ({ | |
| 47 | threadId: "thread-1", | |
| 48 | turns: [], | |
| 49 | })); | |
| 50 | ||
| 51 | public rollbackThreadImpl = vi.fn(async (_sessionId: string, _numTurns: number) => ({ | |
| 52 | threadId: "thread-1", | |
| 53 | turns: [], | |
| 54 | })); | |
| 55 | ||
| 56 | public respondToRequestImpl = vi.fn( | |
| 57 | async ( | |
| 58 | _sessionId: string, | |
| 59 | _requestId: string, | |
| 60 | _decision: ProviderApprovalDecision, | |
| 61 | ): Promise<void> => undefined, | |
| 62 | ); | |
| 63 | ||
| 64 | public stopAllImpl = vi.fn(() => undefined); | |
| 65 | ||
| 66 | override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { | |
| 67 | return this.startSessionImpl(input); | |
| 68 | } | |
| 69 | ||
| 70 | override sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 71 | return this.sendTurnImpl(input); | |
| 72 | } | |
| 73 | ||
| 74 | override interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 75 | return this.interruptTurnImpl(sessionId, turnId); | |
| 76 | } | |
| 77 | ||
| 78 | override readThread(sessionId: string) { | |
| 79 | return this.readThreadImpl(sessionId); | |
| 80 | } | |
| 81 | ||
| 82 | override rollbackThread(sessionId: string, numTurns: number) { | |
| 83 | return this.rollbackThreadImpl(sessionId, numTurns); | |
| 84 | } | |
| 85 | ||
| 86 | override respondToRequest( | |
| 87 | sessionId: string, | |
| 88 | requestId: string, | |
| 89 | decision: ProviderApprovalDecision, | |
| 90 | ): Promise<void> { | |
| 91 | return this.respondToRequestImpl(sessionId, requestId, decision); | |
| 92 | } | |
| 93 | ||
| 94 | override stopSession(_sessionId: string): void {} | |
| 95 | ||
| 96 | override listSessions(): ProviderSession[] { | |
| 97 | return []; | |
| 98 | } | |
| 99 | ||
| 100 | override hasSession(_sessionId: string): boolean { | |
| 101 | return false; | |
| 102 | } | |
| 103 | ||
| 104 | override stopAll(): void { | |
| 105 | this.stopAllImpl(); | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | const validationManager = new FakeCodexManager(); | |
| 110 | const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager })); | |
| 111 | ||
| 112 | validationLayer("CodexAdapterLive validation", (it) => { | |
| 113 | it.effect("returns validation error for non-codex provider on startSession", () => | |
| 114 | Effect.gen(function* () { | |
| 115 | const adapter = yield* CodexAdapter; | |
| 116 | const result = yield* adapter | |
| 117 | .startSession({ | |
| 118 | provider: "claudeCode", | |
| 119 | }) | |
| 120 | .pipe(Effect.result); | |
| 121 | ||
| 122 | assertFailure( | |
| 123 | result, | |
| 124 | new ProviderAdapterValidationError({ | |
| 125 | provider: "codex", | |
| 126 | operation: "startSession", | |
| 127 | issue: "Expected provider 'codex' but received 'claudeCode'.", | |
| 128 | }), | |
| 129 | ); | |
| 130 | assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); | |
| 131 | }), | |
| 132 | ); | |
| 133 | }); | |
| 134 | ||
| 135 | const sessionErrorManager = new FakeCodexManager(); | |
| 136 | sessionErrorManager.sendTurnImpl.mockImplementation(async () => { | |
| 137 | throw new Error("Unknown session: sess-missing"); | |
| 138 | }); | |
| 139 | const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager })); | |
| 140 | ||
| 141 | sessionErrorLayer("CodexAdapterLive session errors", (it) => { | |
| 142 | it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | const adapter = yield* CodexAdapter; | |
| 145 | const result = yield* adapter | |
| 146 | .sendTurn({ | |
| 147 | sessionId: "sess-missing", | |
| 148 | input: "hello", | |
| 149 | attachments: [], | |
| 150 | }) | |
| 151 | .pipe(Effect.result); | |
| 152 | ||
| 153 | assert.equal(result._tag, "Failure"); | |
| 154 | if (result._tag !== "Failure") { | |
| 155 | return; | |
| 156 | } | |
| 157 | ||
| 158 | assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); | |
| 159 | if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { | |
| 160 | return; | |
| 161 | } | |
| 162 | assert.equal(result.failure.provider, "codex"); | |
| 163 | assert.equal(result.failure.sessionId, "sess-missing"); | |
| 164 | assert.instanceOf(result.failure.cause, Error); | |
| 165 | }), | |
| 166 | ); | |
| 167 | }); | |
| 168 | ||
| 169 | const lifecycleManager = new FakeCodexManager(); | |
| 170 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); | |
| 171 | ||
| 172 | lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | |
| 173 | it.effect("subscribes and unsubscribes from manager events", () => | |
| 174 | Effect.gen(function* () { | |
| 175 | const adapter = yield* CodexAdapter; | |
| 176 | const onEvent = vi.fn((_event: ProviderEvent) => undefined); | |
| 177 | const unsubscribe = yield* adapter.subscribeToEvents(onEvent); | |
| 178 | ||
| 179 | const event: ProviderEvent = { | |
| 180 | id: "evt-1", | |
| 181 | kind: "notification", | |
| 182 | provider: "codex", | |
| 183 | sessionId: "sess-1", | |
| 184 | createdAt: new Date().toISOString(), | |
| 185 | method: "turn/started", | |
| 186 | }; | |
| 187 | ||
| 188 | lifecycleManager.emit("event", event); | |
| 189 | assert.equal(onEvent.mock.calls.length, 1); | |
| 190 | assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/started"); | |
| 191 | ||
| 192 | unsubscribe(); | |
| 193 | lifecycleManager.emit("event", event); | |
| 194 | assert.equal(onEvent.mock.calls.length, 1); | |
| 195 | assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 0); | |
| 196 | }), | |
| 197 | ); | |
| 198 | }); | |
| 199 | ||
| 200 | afterAll(() => { | |
| 201 | assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1); | |
| 202 | }); |
apps/server/src/provider/Layers/CodexAdapter.ts created+213| ... | ... | @@ -0,0 +1,213 @@ |
| 1 | /** | |
| 2 | * CodexAdapterLive - Scoped live implementation for the Codex provider adapter. | |
| 3 | * | |
| 4 | * Wraps `CodexAppServerManager` behind the `CodexAdapter` service contract and | |
| 5 | * maps manager failures into the shared `ProviderAdapterError` algebra. | |
| 6 | * | |
| 7 | * @module CodexAdapterLive | |
| 8 | */ | |
| 9 | import type { ProviderEvent } from "@t3tools/contracts"; | |
| 10 | import { Effect, Layer } from "effect"; | |
| 11 | ||
| 12 | import { | |
| 13 | ProviderAdapterProcessError, | |
| 14 | ProviderAdapterRequestError, | |
| 15 | ProviderAdapterSessionClosedError, | |
| 16 | ProviderAdapterSessionNotFoundError, | |
| 17 | ProviderAdapterValidationError, | |
| 18 | type ProviderAdapterError, | |
| 19 | } from "../Errors.ts"; | |
| 20 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; | |
| 21 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | |
| 22 | ||
| 23 | const PROVIDER = "codex" as const; | |
| 24 | ||
| 25 | export interface CodexAdapterLiveOptions { | |
| 26 | readonly manager?: CodexAppServerManager; | |
| 27 | readonly makeManager?: () => CodexAppServerManager; | |
| 28 | } | |
| 29 | ||
| 30 | function toMessage(cause: unknown, fallback: string): string { | |
| 31 | if (cause instanceof Error && cause.message.length > 0) { | |
| 32 | return cause.message; | |
| 33 | } | |
| 34 | return fallback; | |
| 35 | } | |
| 36 | ||
| 37 | function toSessionError( | |
| 38 | sessionId: string, | |
| 39 | cause: unknown, | |
| 40 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { | |
| 41 | const normalized = toMessage(cause, "").toLowerCase(); | |
| 42 | if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { | |
| 43 | return new ProviderAdapterSessionNotFoundError({ | |
| 44 | provider: PROVIDER, | |
| 45 | sessionId, | |
| 46 | cause, | |
| 47 | }); | |
| 48 | } | |
| 49 | if (normalized.includes("session is closed")) { | |
| 50 | return new ProviderAdapterSessionClosedError({ | |
| 51 | provider: PROVIDER, | |
| 52 | sessionId, | |
| 53 | cause, | |
| 54 | }); | |
| 55 | } | |
| 56 | return undefined; | |
| 57 | } | |
| 58 | ||
| 59 | function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError { | |
| 60 | const sessionError = toSessionError(sessionId, cause); | |
| 61 | if (sessionError) { | |
| 62 | return sessionError; | |
| 63 | } | |
| 64 | return new ProviderAdapterRequestError({ | |
| 65 | provider: PROVIDER, | |
| 66 | method, | |
| 67 | detail: toMessage(cause, `${method} failed`), | |
| 68 | cause, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | |
| 73 | Effect.gen(function* () { | |
| 74 | const manager = yield* Effect.acquireRelease( | |
| 75 | Effect.sync(() => { | |
| 76 | if (options?.manager) { | |
| 77 | return options.manager; | |
| 78 | } | |
| 79 | if (options?.makeManager) { | |
| 80 | return options.makeManager(); | |
| 81 | } | |
| 82 | return new CodexAppServerManager(); | |
| 83 | }), | |
| 84 | (manager) => | |
| 85 | Effect.sync(() => { | |
| 86 | try { | |
| 87 | manager.stopAll(); | |
| 88 | } catch { | |
| 89 | // Finalizers should never fail and block shutdown. | |
| 90 | } | |
| 91 | }), | |
| 92 | ); | |
| 93 | ||
| 94 | const startSession: CodexAdapterShape["startSession"] = (input) => { | |
| 95 | if (input.provider !== undefined && input.provider !== PROVIDER) { | |
| 96 | return Effect.fail( | |
| 97 | new ProviderAdapterValidationError({ | |
| 98 | provider: PROVIDER, | |
| 99 | operation: "startSession", | |
| 100 | issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, | |
| 101 | }), | |
| 102 | ); | |
| 103 | } | |
| 104 | ||
| 105 | return Effect.tryPromise({ | |
| 106 | try: () => manager.startSession(input), | |
| 107 | catch: (cause) => | |
| 108 | new ProviderAdapterProcessError({ | |
| 109 | provider: PROVIDER, | |
| 110 | sessionId: "pending", | |
| 111 | detail: toMessage(cause, "Failed to start Codex adapter session."), | |
| 112 | cause, | |
| 113 | }), | |
| 114 | }); | |
| 115 | }; | |
| 116 | ||
| 117 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => | |
| 118 | Effect.tryPromise({ | |
| 119 | try: () => manager.sendTurn(input), | |
| 120 | catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), | |
| 121 | }); | |
| 122 | ||
| 123 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => | |
| 124 | Effect.tryPromise({ | |
| 125 | try: () => manager.interruptTurn(sessionId, turnId), | |
| 126 | catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), | |
| 127 | }); | |
| 128 | ||
| 129 | const readThread: CodexAdapterShape["readThread"] = (sessionId) => | |
| 130 | Effect.tryPromise({ | |
| 131 | try: () => manager.readThread(sessionId), | |
| 132 | catch: (cause) => toRequestError(sessionId, "thread/read", cause), | |
| 133 | }); | |
| 134 | ||
| 135 | const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => { | |
| 136 | if (!Number.isInteger(numTurns) || numTurns < 1) { | |
| 137 | return Effect.fail( | |
| 138 | new ProviderAdapterValidationError({ | |
| 139 | provider: PROVIDER, | |
| 140 | operation: "rollbackThread", | |
| 141 | issue: "numTurns must be an integer >= 1.", | |
| 142 | }), | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | return Effect.tryPromise({ | |
| 147 | try: () => manager.rollbackThread(sessionId, numTurns), | |
| 148 | catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), | |
| 149 | }); | |
| 150 | }; | |
| 151 | ||
| 152 | const respondToRequest: CodexAdapterShape["respondToRequest"] = ( | |
| 153 | sessionId, | |
| 154 | requestId, | |
| 155 | decision, | |
| 156 | ) => | |
| 157 | Effect.tryPromise({ | |
| 158 | try: () => manager.respondToRequest(sessionId, requestId, decision), | |
| 159 | catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), | |
| 160 | }); | |
| 161 | ||
| 162 | const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => | |
| 163 | Effect.sync(() => { | |
| 164 | manager.stopSession(sessionId); | |
| 165 | }); | |
| 166 | ||
| 167 | const listSessions: CodexAdapterShape["listSessions"] = () => | |
| 168 | Effect.sync(() => manager.listSessions()); | |
| 169 | ||
| 170 | const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => | |
| 171 | Effect.sync(() => manager.hasSession(sessionId)); | |
| 172 | ||
| 173 | const stopAll: CodexAdapterShape["stopAll"] = () => | |
| 174 | Effect.sync(() => { | |
| 175 | manager.stopAll(); | |
| 176 | }); | |
| 177 | ||
| 178 | const subscribeToEvents: CodexAdapterShape["subscribeToEvents"] = (callback) => | |
| 179 | Effect.sync(() => { | |
| 180 | const listener = (event: ProviderEvent) => { | |
| 181 | try { | |
| 182 | callback(event); | |
| 183 | } catch { | |
| 184 | // Adapter subscribers must not destabilize provider event processing. | |
| 185 | } | |
| 186 | }; | |
| 187 | manager.on("event", listener); | |
| 188 | return () => { | |
| 189 | manager.off("event", listener); | |
| 190 | }; | |
| 191 | }); | |
| 192 | ||
| 193 | return { | |
| 194 | provider: PROVIDER, | |
| 195 | startSession, | |
| 196 | sendTurn, | |
| 197 | interruptTurn, | |
| 198 | readThread, | |
| 199 | rollbackThread, | |
| 200 | respondToRequest, | |
| 201 | stopSession, | |
| 202 | listSessions, | |
| 203 | hasSession, | |
| 204 | stopAll, | |
| 205 | subscribeToEvents, | |
| 206 | } satisfies CodexAdapterShape; | |
| 207 | }); | |
| 208 | ||
| 209 | export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); | |
| 210 | ||
| 211 | export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { | |
| 212 | return Layer.effect(CodexAdapter, makeCodexAdapter(options)); | |
| 213 | } |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 2 | import { it, assert, vi } from "@effect/vitest"; | |
| 3 | import { assertFailure } from "@effect/vitest/utils"; | |
| 4 | ||
| 5 | import { Effect, Layer } from "effect"; | |
| 6 | ||
| 7 | import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; | |
| 8 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 9 | import { makeProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | |
| 10 | import { ProviderUnsupportedError } from "../Errors.ts"; | |
| 11 | ||
| 12 | const fakeCodexAdapter: CodexAdapterShape = { | |
| 13 | provider: "codex", | |
| 14 | startSession: vi.fn(), | |
| 15 | sendTurn: vi.fn(), | |
| 16 | interruptTurn: vi.fn(), | |
| 17 | respondToRequest: vi.fn(), | |
| 18 | stopSession: vi.fn(), | |
| 19 | listSessions: vi.fn(), | |
| 20 | hasSession: vi.fn(), | |
| 21 | readThread: vi.fn(), | |
| 22 | rollbackThread: vi.fn(), | |
| 23 | stopAll: vi.fn(), | |
| 24 | subscribeToEvents: vi.fn(), | |
| 25 | }; | |
| 26 | ||
| 27 | const layer = it.layer( | |
| 28 | makeProviderAdapterRegistryLive().pipe( | |
| 29 | Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)), | |
| 30 | ), | |
| 31 | ); | |
| 32 | ||
| 33 | layer("ProviderAdapterRegistryLive", (it) => { | |
| 34 | it("resolves a registered provider adapter", () => | |
| 35 | Effect.gen(function* () { | |
| 36 | const registry = yield* ProviderAdapterRegistry; | |
| 37 | const adapter = yield* registry.getByProvider("codex"); | |
| 38 | assert.equal(adapter, fakeCodexAdapter); | |
| 39 | ||
| 40 | const providers = yield* registry.listProviders(); | |
| 41 | assert.deepEqual(providers, ["codex"]); | |
| 42 | })); | |
| 43 | ||
| 44 | it("fails with ProviderUnsupportedError for unknown providers", () => | |
| 45 | Effect.gen(function* () { | |
| 46 | const registry = yield* ProviderAdapterRegistry; | |
| 47 | const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); | |
| 48 | assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); | |
| 49 | })); | |
| 50 | }); |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | /** | |
| 2 | * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. | |
| 3 | * | |
| 4 | * Binds provider kinds (codex/claudeCode/...) to concrete adapter services. | |
| 5 | * This layer only performs adapter lookup; it does not route session-scoped | |
| 6 | * calls or own provider lifecycle workflows. | |
| 7 | * | |
| 8 | * @module ProviderAdapterRegistryLive | |
| 9 | */ | |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 11 | import { Effect, Layer } from "effect"; | |
| 12 | ||
| 13 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; | |
| 14 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 15 | import { | |
| 16 | ProviderAdapterRegistry, | |
| 17 | type ProviderAdapterRegistryShape, | |
| 18 | } from "../Services/ProviderAdapterRegistry.ts"; | |
| 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | |
| 20 | ||
| 21 | export interface ProviderAdapterRegistryLiveOptions { | |
| 22 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; | |
| 23 | } | |
| 24 | ||
| 25 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => | |
| 26 | Effect.gen(function* () { | |
| 27 | const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; | |
| 28 | const byProvider = new Map<ProviderKind, ProviderAdapterShape<ProviderAdapterError>>(); | |
| 29 | for (const adapter of adapters) { | |
| 30 | byProvider.set(adapter.provider, adapter); | |
| 31 | } | |
| 32 | ||
| 33 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { | |
| 34 | const adapter = byProvider.get(provider); | |
| 35 | if (!adapter) { | |
| 36 | return Effect.fail(new ProviderUnsupportedError({ provider })); | |
| 37 | } | |
| 38 | return Effect.succeed(adapter); | |
| 39 | }; | |
| 40 | ||
| 41 | const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => | |
| 42 | Effect.sync(() => Array.from(byProvider.keys())); | |
| 43 | ||
| 44 | return { | |
| 45 | getByProvider, | |
| 46 | listProviders, | |
| 47 | } satisfies ProviderAdapterRegistryShape; | |
| 48 | }); | |
| 49 | ||
| 50 | export const ProviderAdapterRegistryLive = Layer.effect( | |
| 51 | ProviderAdapterRegistry, | |
| 52 | makeProviderAdapterRegistry(), | |
| 53 | ); | |
| 54 | ||
| 55 | export function makeProviderAdapterRegistryLive(options?: ProviderAdapterRegistryLiveOptions) { | |
| 56 | return Layer.effect(ProviderAdapterRegistry, makeProviderAdapterRegistry(options)); | |
| 57 | } |
apps/server/src/provider/Layers/ProviderService.test.ts created+427| ... | ... | @@ -0,0 +1,427 @@ |
| 1 | import type { | |
| 2 | ProviderApprovalDecision, | |
| 3 | ProviderEvent, | |
| 4 | ProviderGetCheckpointDiffInput, | |
| 5 | ProviderGetCheckpointDiffResult, | |
| 6 | ProviderListCheckpointsInput, | |
| 7 | ProviderListCheckpointsResult, | |
| 8 | ProviderRevertToCheckpointInput, | |
| 9 | ProviderRevertToCheckpointResult, | |
| 10 | ProviderSendTurnInput, | |
| 11 | ProviderSession, | |
| 12 | ProviderSessionStartInput, | |
| 13 | ProviderTurnStartResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { providerSessionStartInputSchema } from "@t3tools/contracts"; | |
| 16 | import { it, assert, vi } from "@effect/vitest"; | |
| 17 | import { assertFailure } from "@effect/vitest/utils"; | |
| 18 | ||
| 19 | import { Effect, Layer } from "effect"; | |
| 20 | ||
| 21 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; | |
| 22 | import { | |
| 23 | ProviderAdapterSessionNotFoundError, | |
| 24 | ProviderSessionNotFoundError, | |
| 25 | ProviderValidationError, | |
| 26 | ProviderUnsupportedError, | |
| 27 | type ProviderAdapterError, | |
| 28 | } from "../Errors.ts"; | |
| 29 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | |
| 30 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 31 | import { ProviderService } from "../Services/ProviderService.ts"; | |
| 32 | import { ProviderServiceLive } from "./ProviderService.ts"; | |
| 33 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | |
| 34 | ||
| 35 | function makeFakeCodexAdapter() { | |
| 36 | const sessions = new Map<string, ProviderSession>(); | |
| 37 | const eventListeners = new Set<(event: ProviderEvent) => void>(); | |
| 38 | let nextSession = 1; | |
| 39 | ||
| 40 | const startSession = vi.fn((input: ProviderSessionStartInput) => | |
| 41 | Effect.sync(() => { | |
| 42 | const now = new Date().toISOString(); | |
| 43 | const session: ProviderSession = { | |
| 44 | sessionId: `sess-${nextSession}`, | |
| 45 | provider: "codex", | |
| 46 | status: "ready", | |
| 47 | threadId: `thread-${nextSession}`, | |
| 48 | cwd: input.cwd ?? process.cwd(), | |
| 49 | createdAt: now, | |
| 50 | updatedAt: now, | |
| 51 | }; | |
| 52 | nextSession += 1; | |
| 53 | sessions.set(session.sessionId, session); | |
| 54 | return session; | |
| 55 | }), | |
| 56 | ); | |
| 57 | ||
| 58 | const sendTurn = vi.fn( | |
| 59 | ( | |
| 60 | input: ProviderSendTurnInput, | |
| 61 | ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> => { | |
| 62 | if (!sessions.has(input.sessionId)) { | |
| 63 | return Effect.fail( | |
| 64 | new ProviderAdapterSessionNotFoundError({ | |
| 65 | provider: "codex", | |
| 66 | sessionId: input.sessionId, | |
| 67 | }), | |
| 68 | ); | |
| 69 | } | |
| 70 | ||
| 71 | return Effect.succeed({ | |
| 72 | threadId: "thread-1", | |
| 73 | turnId: "turn-1", | |
| 74 | }); | |
| 75 | }, | |
| 76 | ); | |
| 77 | ||
| 78 | const interruptTurn = vi.fn( | |
| 79 | (_sessionId: string, _turnId?: string): Effect.Effect<void, ProviderAdapterError> => | |
| 80 | Effect.void, | |
| 81 | ); | |
| 82 | ||
| 83 | const respondToRequest = vi.fn( | |
| 84 | ( | |
| 85 | _sessionId: string, | |
| 86 | _requestId: string, | |
| 87 | _decision: ProviderApprovalDecision, | |
| 88 | ): Effect.Effect<void, ProviderAdapterError> => Effect.void, | |
| 89 | ); | |
| 90 | ||
| 91 | const stopSession = vi.fn( | |
| 92 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | |
| 93 | Effect.sync(() => { | |
| 94 | sessions.delete(sessionId); | |
| 95 | }), | |
| 96 | ); | |
| 97 | ||
| 98 | const listSessions = vi.fn( | |
| 99 | (): Effect.Effect<ReadonlyArray<ProviderSession>> => | |
| 100 | Effect.sync(() => Array.from(sessions.values())), | |
| 101 | ); | |
| 102 | ||
| 103 | const hasSession = vi.fn( | |
| 104 | (sessionId: string): Effect.Effect<boolean> => Effect.succeed(sessions.has(sessionId)), | |
| 105 | ); | |
| 106 | ||
| 107 | const readThread = vi.fn( | |
| 108 | ( | |
| 109 | _sessionId: string, | |
| 110 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | |
| 111 | Effect.succeed({ threadId: "thread-1", turns: [] }), | |
| 112 | ); | |
| 113 | ||
| 114 | const rollbackThread = vi.fn( | |
| 115 | ( | |
| 116 | _sessionId: string, | |
| 117 | _numTurns: number, | |
| 118 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | |
| 119 | Effect.succeed({ threadId: "thread-1", turns: [] }), | |
| 120 | ); | |
| 121 | ||
| 122 | const stopAll = vi.fn( | |
| 123 | (): Effect.Effect<void, ProviderAdapterError> => | |
| 124 | Effect.sync(() => { | |
| 125 | sessions.clear(); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | const subscribeToEvents = vi.fn( | |
| 130 | (callback: (event: ProviderEvent) => void): Effect.Effect<() => void, ProviderAdapterError> => | |
| 131 | Effect.sync(() => { | |
| 132 | eventListeners.add(callback); | |
| 133 | return () => { | |
| 134 | eventListeners.delete(callback); | |
| 135 | }; | |
| 136 | }), | |
| 137 | ); | |
| 138 | ||
| 139 | const adapter: ProviderAdapterShape<ProviderAdapterError> = { | |
| 140 | provider: "codex", | |
| 141 | startSession, | |
| 142 | sendTurn, | |
| 143 | interruptTurn, | |
| 144 | respondToRequest, | |
| 145 | stopSession, | |
| 146 | listSessions, | |
| 147 | hasSession, | |
| 148 | readThread, | |
| 149 | rollbackThread, | |
| 150 | stopAll, | |
| 151 | subscribeToEvents, | |
| 152 | }; | |
| 153 | ||
| 154 | const emit = (event: ProviderEvent): void => { | |
| 155 | for (const listener of eventListeners) { | |
| 156 | listener(event); | |
| 157 | } | |
| 158 | }; | |
| 159 | ||
| 160 | return { | |
| 161 | adapter, | |
| 162 | emit, | |
| 163 | startSession, | |
| 164 | sendTurn, | |
| 165 | interruptTurn, | |
| 166 | respondToRequest, | |
| 167 | stopSession, | |
| 168 | listSessions, | |
| 169 | hasSession, | |
| 170 | readThread, | |
| 171 | rollbackThread, | |
| 172 | stopAll, | |
| 173 | subscribeToEvents, | |
| 174 | }; | |
| 175 | } | |
| 176 | ||
| 177 | function makeCheckpointServiceDouble() { | |
| 178 | const initializeForSession = vi.fn( | |
| 179 | (_: { providerSessionId: string; cwd: string }) => Effect.void, | |
| 180 | ); | |
| 181 | const captureCurrentTurn = vi.fn( | |
| 182 | (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void, | |
| 183 | ); | |
| 184 | const listCheckpoints = vi.fn( | |
| 185 | (_input: ProviderListCheckpointsInput): Effect.Effect<ProviderListCheckpointsResult> => | |
| 186 | Effect.succeed({ | |
| 187 | threadId: "thread-1", | |
| 188 | checkpoints: [ | |
| 189 | { | |
| 190 | id: "root", | |
| 191 | turnCount: 0, | |
| 192 | messageCount: 0, | |
| 193 | label: "Start of conversation", | |
| 194 | isCurrent: true, | |
| 195 | }, | |
| 196 | ], | |
| 197 | }), | |
| 198 | ); | |
| 199 | const getCheckpointDiff = vi.fn( | |
| 200 | (input: ProviderGetCheckpointDiffInput): Effect.Effect<ProviderGetCheckpointDiffResult> => | |
| 201 | Effect.succeed({ | |
| 202 | threadId: "thread-1", | |
| 203 | fromTurnCount: input.fromTurnCount, | |
| 204 | toTurnCount: input.toTurnCount, | |
| 205 | diff: "diff --git a/a.ts b/a.ts", | |
| 206 | }), | |
| 207 | ); | |
| 208 | const revertToCheckpoint = vi.fn( | |
| 209 | (input: ProviderRevertToCheckpointInput): Effect.Effect<ProviderRevertToCheckpointResult> => | |
| 210 | Effect.succeed({ | |
| 211 | threadId: "thread-1", | |
| 212 | turnCount: input.turnCount, | |
| 213 | messageCount: 0, | |
| 214 | rolledBackTurns: 0, | |
| 215 | checkpoints: [ | |
| 216 | { | |
| 217 | id: "root", | |
| 218 | turnCount: 0, | |
| 219 | messageCount: 0, | |
| 220 | label: "Start of conversation", | |
| 221 | isCurrent: input.turnCount === 0, | |
| 222 | }, | |
| 223 | ], | |
| 224 | }), | |
| 225 | ); | |
| 226 | ||
| 227 | const service: typeof CheckpointService.Service = { | |
| 228 | initializeForSession, | |
| 229 | captureCurrentTurn, | |
| 230 | listCheckpoints, | |
| 231 | getCheckpointDiff, | |
| 232 | revertToCheckpoint, | |
| 233 | }; | |
| 234 | ||
| 235 | return { | |
| 236 | service, | |
| 237 | initializeForSession, | |
| 238 | captureCurrentTurn, | |
| 239 | listCheckpoints, | |
| 240 | getCheckpointDiff, | |
| 241 | revertToCheckpoint, | |
| 242 | }; | |
| 243 | } | |
| 244 | ||
| 245 | function makeProviderServiceLayer() { | |
| 246 | const codex = makeFakeCodexAdapter(); | |
| 247 | const checkpoint = makeCheckpointServiceDouble(); | |
| 248 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 249 | getByProvider: (provider) => | |
| 250 | provider === "codex" | |
| 251 | ? Effect.succeed(codex.adapter) | |
| 252 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 253 | listProviders: () => Effect.succeed(["codex"]), | |
| 254 | }; | |
| 255 | ||
| 256 | const layer = it.layer( | |
| 257 | ProviderServiceLive.pipe( | |
| 258 | Layer.provide( | |
| 259 | Layer.mergeAll( | |
| 260 | Layer.succeed(ProviderAdapterRegistry, registry), | |
| 261 | ProviderSessionDirectoryLive, | |
| 262 | Layer.succeed(CheckpointService, checkpoint.service), | |
| 263 | ), | |
| 264 | ), | |
| 265 | ), | |
| 266 | ); | |
| 267 | ||
| 268 | return { | |
| 269 | codex, | |
| 270 | checkpoint, | |
| 271 | layer, | |
| 272 | }; | |
| 273 | } | |
| 274 | ||
| 275 | const routing = makeProviderServiceLayer(); | |
| 276 | routing.layer("ProviderServiceLive routing", (it) => { | |
| 277 | it.effect("routes provider operations and delegates checkpoint workflows", () => | |
| 278 | Effect.gen(function* () { | |
| 279 | const provider = yield* ProviderService; | |
| 280 | ||
| 281 | const session = yield* provider.startSession({ | |
| 282 | provider: "codex", | |
| 283 | cwd: "/tmp/project", | |
| 284 | }); | |
| 285 | assert.equal(session.provider, "codex"); | |
| 286 | assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [ | |
| 287 | [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }], | |
| 288 | ]); | |
| 289 | ||
| 290 | const sessions = yield* provider.listSessions(); | |
| 291 | assert.equal(sessions.length, 1); | |
| 292 | ||
| 293 | yield* provider.sendTurn({ | |
| 294 | sessionId: session.sessionId, | |
| 295 | input: "hello", | |
| 296 | attachments: [], | |
| 297 | }); | |
| 298 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); | |
| 299 | ||
| 300 | yield* provider.interruptTurn({ sessionId: session.sessionId }); | |
| 301 | assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]); | |
| 302 | ||
| 303 | yield* provider.respondToRequest({ | |
| 304 | sessionId: session.sessionId, | |
| 305 | requestId: "req-1", | |
| 306 | decision: "accept", | |
| 307 | }); | |
| 308 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ | |
| 309 | [session.sessionId, "req-1", "accept"], | |
| 310 | ]); | |
| 311 | ||
| 312 | yield* provider.listCheckpoints({ sessionId: session.sessionId }); | |
| 313 | yield* provider.getCheckpointDiff({ | |
| 314 | sessionId: session.sessionId, | |
| 315 | fromTurnCount: 0, | |
| 316 | toTurnCount: 0, | |
| 317 | }); | |
| 318 | yield* provider.revertToCheckpoint({ | |
| 319 | sessionId: session.sessionId, | |
| 320 | turnCount: 0, | |
| 321 | }); | |
| 322 | ||
| 323 | assert.deepEqual(routing.checkpoint.listCheckpoints.mock.calls, [ | |
| 324 | [{ sessionId: session.sessionId }], | |
| 325 | ]); | |
| 326 | assert.deepEqual(routing.checkpoint.getCheckpointDiff.mock.calls, [ | |
| 327 | [{ sessionId: session.sessionId, fromTurnCount: 0, toTurnCount: 0 }], | |
| 328 | ]); | |
| 329 | assert.deepEqual(routing.checkpoint.revertToCheckpoint.mock.calls, [ | |
| 330 | [{ sessionId: session.sessionId, turnCount: 0 }], | |
| 331 | ]); | |
| 332 | ||
| 333 | yield* provider.stopSession({ sessionId: session.sessionId }); | |
| 334 | const sendAfterStop = yield* Effect.result( | |
| 335 | provider.sendTurn({ | |
| 336 | sessionId: session.sessionId, | |
| 337 | input: "after-stop", | |
| 338 | attachments: [], | |
| 339 | }), | |
| 340 | ); | |
| 341 | assertFailure( | |
| 342 | sendAfterStop, | |
| 343 | new ProviderSessionNotFoundError({ sessionId: session.sessionId }), | |
| 344 | ); | |
| 345 | }), | |
| 346 | ); | |
| 347 | }); | |
| 348 | ||
| 349 | const fanout = makeProviderServiceLayer(); | |
| 350 | fanout.layer("ProviderServiceLive fanout", (it) => { | |
| 351 | it.effect("fans out adapter events and captures turn completion checkpoints", () => | |
| 352 | Effect.gen(function* () { | |
| 353 | const provider = yield* ProviderService; | |
| 354 | const session = yield* provider.startSession({ | |
| 355 | provider: "codex", | |
| 356 | }); | |
| 357 | ||
| 358 | const onEvent = vi.fn((_event: ProviderEvent) => undefined); | |
| 359 | const unsubscribe = yield* provider.subscribeToEvents(onEvent); | |
| 360 | ||
| 361 | const completedEvent: ProviderEvent = { | |
| 362 | id: "evt-1", | |
| 363 | kind: "notification", | |
| 364 | provider: "codex", | |
| 365 | sessionId: session.sessionId, | |
| 366 | createdAt: new Date().toISOString(), | |
| 367 | method: "turn/completed", | |
| 368 | threadId: "thread-1", | |
| 369 | turnId: "turn-1", | |
| 370 | payload: { | |
| 371 | turn: { | |
| 372 | status: "completed", | |
| 373 | }, | |
| 374 | }, | |
| 375 | }; | |
| 376 | ||
| 377 | fanout.codex.emit(completedEvent); | |
| 378 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | |
| 379 | ||
| 380 | assert.equal(onEvent.mock.calls.length, 1); | |
| 381 | assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/completed"); | |
| 382 | assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ | |
| 383 | [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], | |
| 384 | ]); | |
| 385 | ||
| 386 | unsubscribe(); | |
| 387 | fanout.codex.emit({ | |
| 388 | ...completedEvent, | |
| 389 | id: "evt-2", | |
| 390 | }); | |
| 391 | assert.equal(onEvent.mock.calls.length, 1); | |
| 392 | }), | |
| 393 | ); | |
| 394 | }); | |
| 395 | ||
| 396 | const validation = makeProviderServiceLayer(); | |
| 397 | validation.layer("ProviderServiceLive validation", (it) => { | |
| 398 | it.effect("returns ProviderValidationError for invalid input payloads", () => | |
| 399 | Effect.gen(function* () { | |
| 400 | const provider = yield* ProviderService; | |
| 401 | ||
| 402 | const parse = providerSessionStartInputSchema.safeParse({ | |
| 403 | provider: "invalid-provider", | |
| 404 | }); | |
| 405 | assert.equal(parse.success, false); | |
| 406 | if (parse.success) { | |
| 407 | return; | |
| 408 | } | |
| 409 | ||
| 410 | const failure = yield* Effect.result( | |
| 411 | provider.startSession({ | |
| 412 | provider: "invalid-provider", | |
| 413 | } as never), | |
| 414 | ); | |
| 415 | ||
| 416 | const cause = (parse.error as { cause?: unknown }).cause; | |
| 417 | assertFailure( | |
| 418 | failure, | |
| 419 | new ProviderValidationError({ | |
| 420 | operation: "ProviderService.startSession", | |
| 421 | issue: parse.error.message, | |
| 422 | ...(cause !== undefined ? { cause } : {}), | |
| 423 | }), | |
| 424 | ); | |
| 425 | }), | |
| 426 | ); | |
| 427 | }); |
apps/server/src/provider/Layers/ProviderService.ts created+339| ... | ... | @@ -0,0 +1,339 @@ |
| 1 | /** | |
| 2 | * ProviderServiceLive - Cross-provider orchestration layer. | |
| 3 | * | |
| 4 | * Routes validated transport/API calls to provider adapters through | |
| 5 | * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a | |
| 6 | * unified provider event stream for subscribers. | |
| 7 | * | |
| 8 | * It does not implement provider protocol details (adapter concern) and does | |
| 9 | * not implement checkpoint persistence mechanics (checkpointing concern). | |
| 10 | * | |
| 11 | * @module ProviderServiceLive | |
| 12 | */ | |
| 13 | import { randomUUID } from "node:crypto"; | |
| 14 | ||
| 15 | import type { ProviderEvent } from "@t3tools/contracts"; | |
| 16 | import { | |
| 17 | providerGetCheckpointDiffInputSchema, | |
| 18 | providerInterruptTurnInputSchema, | |
| 19 | providerListCheckpointsInputSchema, | |
| 20 | providerRespondToRequestInputSchema, | |
| 21 | providerRevertToCheckpointInputSchema, | |
| 22 | providerSendTurnInputSchema, | |
| 23 | providerSessionStartInputSchema, | |
| 24 | providerStopSessionInputSchema, | |
| 25 | } from "@t3tools/contracts"; | |
| 26 | import { Effect, Layer } from "effect"; | |
| 27 | ||
| 28 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; | |
| 29 | import { ProviderValidationError } from "../Errors.ts"; | |
| 30 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | |
| 31 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; | |
| 32 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 33 | ||
| 34 | function asObject(value: unknown): Record<string, unknown> | undefined { | |
| 35 | if (!value || typeof value !== "object") { | |
| 36 | return undefined; | |
| 37 | } | |
| 38 | return value as Record<string, unknown>; | |
| 39 | } | |
| 40 | ||
| 41 | function asString(value: unknown): string | undefined { | |
| 42 | return typeof value === "string" ? value : undefined; | |
| 43 | } | |
| 44 | ||
| 45 | function readTurnCompletionStatus(event: ProviderEvent): string | undefined { | |
| 46 | const payload = asObject(event.payload); | |
| 47 | const turn = asObject(payload?.turn); | |
| 48 | return asString(turn?.status); | |
| 49 | } | |
| 50 | ||
| 51 | function toCheckpointCaptureErrorEvent( | |
| 52 | event: ProviderEvent, | |
| 53 | error: { readonly message: string }, | |
| 54 | ): ProviderEvent { | |
| 55 | return { | |
| 56 | id: randomUUID(), | |
| 57 | kind: "error", | |
| 58 | provider: event.provider, | |
| 59 | sessionId: event.sessionId, | |
| 60 | createdAt: new Date().toISOString(), | |
| 61 | method: "checkpoint/captureError", | |
| 62 | message: error.message, | |
| 63 | ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), | |
| 64 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | function toValidationError( | |
| 69 | operation: string, | |
| 70 | issue: string, | |
| 71 | cause?: unknown, | |
| 72 | ): ProviderValidationError { | |
| 73 | return new ProviderValidationError({ | |
| 74 | operation, | |
| 75 | issue, | |
| 76 | ...(cause !== undefined ? { cause } : {}), | |
| 77 | }); | |
| 78 | } | |
| 79 | ||
| 80 | const makeProviderService = Effect.gen(function* () { | |
| 81 | const registry = yield* ProviderAdapterRegistry; | |
| 82 | const directory = yield* ProviderSessionDirectory; | |
| 83 | const checkpointService = yield* CheckpointService; | |
| 84 | ||
| 85 | const eventSubscribers = new Set<(event: ProviderEvent) => void>(); | |
| 86 | const notifySubscribers = (event: ProviderEvent): void => { | |
| 87 | for (const callback of eventSubscribers) { | |
| 88 | try { | |
| 89 | callback(event); | |
| 90 | } catch { | |
| 91 | // Subscriber callbacks must not destabilize provider event fanout. | |
| 92 | } | |
| 93 | } | |
| 94 | }; | |
| 95 | ||
| 96 | const providers = yield* registry.listProviders(); | |
| 97 | const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); | |
| 98 | ||
| 99 | yield* Effect.acquireRelease( | |
| 100 | Effect.forEach(adapters, (adapter) => | |
| 101 | adapter.subscribeToEvents((event) => { | |
| 102 | notifySubscribers(event); | |
| 103 | if (event.kind !== "notification" || event.method !== "turn/completed") { | |
| 104 | return; | |
| 105 | } | |
| 106 | ||
| 107 | const status = readTurnCompletionStatus(event); | |
| 108 | const captureInput = { | |
| 109 | providerSessionId: event.sessionId, | |
| 110 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | |
| 111 | ...(status !== undefined ? { status } : {}), | |
| 112 | }; | |
| 113 | ||
| 114 | Effect.runFork( | |
| 115 | checkpointService.captureCurrentTurn(captureInput).pipe( | |
| 116 | Effect.catch((error) => | |
| 117 | Effect.sync(() => { | |
| 118 | notifySubscribers(toCheckpointCaptureErrorEvent(event, error)); | |
| 119 | }), | |
| 120 | ), | |
| 121 | ), | |
| 122 | ); | |
| 123 | }), | |
| 124 | ), | |
| 125 | (unsubscribeAll) => | |
| 126 | Effect.forEach(unsubscribeAll, (unsubscribe) => | |
| 127 | Effect.sync(() => { | |
| 128 | unsubscribe(); | |
| 129 | }), | |
| 130 | ).pipe(Effect.asVoid), | |
| 131 | ); | |
| 132 | ||
| 133 | const adapterForSession = (sessionId: string) => | |
| 134 | directory | |
| 135 | .getProvider(sessionId) | |
| 136 | .pipe(Effect.flatMap((provider) => registry.getByProvider(provider))); | |
| 137 | ||
| 138 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => | |
| 139 | Effect.gen(function* () { | |
| 140 | const parsed = providerSessionStartInputSchema.safeParse(rawInput); | |
| 141 | if (!parsed.success) { | |
| 142 | return yield* Effect.fail( | |
| 143 | toValidationError( | |
| 144 | "ProviderService.startSession", | |
| 145 | parsed.error.message, | |
| 146 | parsed.error.cause, | |
| 147 | ), | |
| 148 | ); | |
| 149 | } | |
| 150 | ||
| 151 | const input = parsed.data; | |
| 152 | const adapter = yield* registry.getByProvider(input.provider); | |
| 153 | const session = yield* adapter.startSession(input); | |
| 154 | ||
| 155 | if (session.provider !== adapter.provider) { | |
| 156 | return yield* Effect.fail( | |
| 157 | toValidationError( | |
| 158 | "ProviderService.startSession", | |
| 159 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, | |
| 160 | ), | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 164 | yield* directory.upsert({ | |
| 165 | sessionId: session.sessionId, | |
| 166 | provider: session.provider, | |
| 167 | ...(session.threadId !== undefined ? { threadId: session.threadId } : {}), | |
| 168 | }); | |
| 169 | ||
| 170 | const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); | |
| 171 | yield* checkpointService.initializeForSession({ | |
| 172 | providerSessionId: session.sessionId, | |
| 173 | cwd: checkpointCwd, | |
| 174 | }); | |
| 175 | ||
| 176 | return session; | |
| 177 | }); | |
| 178 | ||
| 179 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => | |
| 180 | Effect.gen(function* () { | |
| 181 | const parsed = providerSendTurnInputSchema.safeParse(rawInput); | |
| 182 | if (!parsed.success) { | |
| 183 | return yield* Effect.fail( | |
| 184 | toValidationError("ProviderService.sendTurn", parsed.error.message, parsed.error.cause), | |
| 185 | ); | |
| 186 | } | |
| 187 | ||
| 188 | const input = parsed.data; | |
| 189 | const adapter = yield* adapterForSession(input.sessionId); | |
| 190 | return yield* adapter.sendTurn(input); | |
| 191 | }); | |
| 192 | ||
| 193 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => | |
| 194 | Effect.gen(function* () { | |
| 195 | const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); | |
| 196 | if (!parsed.success) { | |
| 197 | return yield* Effect.fail( | |
| 198 | toValidationError( | |
| 199 | "ProviderService.interruptTurn", | |
| 200 | parsed.error.message, | |
| 201 | parsed.error.cause, | |
| 202 | ), | |
| 203 | ); | |
| 204 | } | |
| 205 | ||
| 206 | const input = parsed.data; | |
| 207 | const adapter = yield* adapterForSession(input.sessionId); | |
| 208 | yield* adapter.interruptTurn(input.sessionId, input.turnId); | |
| 209 | }); | |
| 210 | ||
| 211 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => | |
| 212 | Effect.gen(function* () { | |
| 213 | const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); | |
| 214 | if (!parsed.success) { | |
| 215 | return yield* Effect.fail( | |
| 216 | toValidationError( | |
| 217 | "ProviderService.respondToRequest", | |
| 218 | parsed.error.message, | |
| 219 | parsed.error.cause, | |
| 220 | ), | |
| 221 | ); | |
| 222 | } | |
| 223 | ||
| 224 | const input = parsed.data; | |
| 225 | const adapter = yield* adapterForSession(input.sessionId); | |
| 226 | yield* adapter.respondToRequest(input.sessionId, input.requestId, input.decision); | |
| 227 | }); | |
| 228 | ||
| 229 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => | |
| 230 | Effect.gen(function* () { | |
| 231 | const parsed = providerStopSessionInputSchema.safeParse(rawInput); | |
| 232 | if (!parsed.success) { | |
| 233 | return yield* Effect.fail( | |
| 234 | toValidationError( | |
| 235 | "ProviderService.stopSession", | |
| 236 | parsed.error.message, | |
| 237 | parsed.error.cause, | |
| 238 | ), | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | const input = parsed.data; | |
| 243 | const adapter = yield* adapterForSession(input.sessionId); | |
| 244 | yield* adapter.stopSession(input.sessionId); | |
| 245 | yield* directory.remove(input.sessionId); | |
| 246 | }); | |
| 247 | ||
| 248 | const listSessions: ProviderServiceShape["listSessions"] = () => | |
| 249 | Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( | |
| 250 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | |
| 251 | ); | |
| 252 | ||
| 253 | const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => | |
| 254 | Effect.gen(function* () { | |
| 255 | const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); | |
| 256 | if (!parsed.success) { | |
| 257 | return yield* Effect.fail( | |
| 258 | toValidationError( | |
| 259 | "ProviderService.listCheckpoints", | |
| 260 | parsed.error.message, | |
| 261 | parsed.error.cause, | |
| 262 | ), | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | const input = parsed.data; | |
| 267 | yield* directory.getProvider(input.sessionId); | |
| 268 | return yield* checkpointService.listCheckpoints(input); | |
| 269 | }); | |
| 270 | ||
| 271 | const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => | |
| 272 | Effect.gen(function* () { | |
| 273 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); | |
| 274 | if (!parsed.success) { | |
| 275 | return yield* Effect.fail( | |
| 276 | toValidationError( | |
| 277 | "ProviderService.getCheckpointDiff", | |
| 278 | parsed.error.message, | |
| 279 | parsed.error.cause, | |
| 280 | ), | |
| 281 | ); | |
| 282 | } | |
| 283 | ||
| 284 | const input = parsed.data; | |
| 285 | yield* directory.getProvider(input.sessionId); | |
| 286 | return yield* checkpointService.getCheckpointDiff(input); | |
| 287 | }); | |
| 288 | ||
| 289 | const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => | |
| 290 | Effect.gen(function* () { | |
| 291 | const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); | |
| 292 | if (!parsed.success) { | |
| 293 | return yield* Effect.fail( | |
| 294 | toValidationError( | |
| 295 | "ProviderService.revertToCheckpoint", | |
| 296 | parsed.error.message, | |
| 297 | parsed.error.cause, | |
| 298 | ), | |
| 299 | ); | |
| 300 | } | |
| 301 | ||
| 302 | const input = parsed.data; | |
| 303 | yield* directory.getProvider(input.sessionId); | |
| 304 | return yield* checkpointService.revertToCheckpoint(input); | |
| 305 | }); | |
| 306 | ||
| 307 | const stopAll: ProviderServiceShape["stopAll"] = () => | |
| 308 | Effect.gen(function* () { | |
| 309 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); | |
| 310 | const sessionIds = yield* directory.listSessionIds(); | |
| 311 | yield* Effect.forEach(sessionIds, (sessionId) => directory.remove(sessionId)).pipe( | |
| 312 | Effect.asVoid, | |
| 313 | ); | |
| 314 | }); | |
| 315 | ||
| 316 | const subscribeToEvents: ProviderServiceShape["subscribeToEvents"] = (callback) => | |
| 317 | Effect.sync(() => { | |
| 318 | eventSubscribers.add(callback); | |
| 319 | return () => { | |
| 320 | eventSubscribers.delete(callback); | |
| 321 | }; | |
| 322 | }); | |
| 323 | ||
| 324 | return { | |
| 325 | startSession, | |
| 326 | sendTurn, | |
| 327 | interruptTurn, | |
| 328 | respondToRequest, | |
| 329 | stopSession, | |
| 330 | listSessions, | |
| 331 | listCheckpoints, | |
| 332 | getCheckpointDiff, | |
| 333 | revertToCheckpoint, | |
| 334 | stopAll, | |
| 335 | subscribeToEvents, | |
| 336 | } satisfies ProviderServiceShape; | |
| 337 | }); | |
| 338 | ||
| 339 | export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService); |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import { it, assert } from "@effect/vitest"; | |
| 2 | import { assertFailure, assertSome } from "@effect/vitest/utils"; | |
| 3 | import { Effect } from "effect"; | |
| 4 | ||
| 5 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 6 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 7 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(ProviderSessionDirectoryLive); | |
| 10 | ||
| 11 | layer("ProviderSessionDirectoryLive", (it) => { | |
| 12 | it("upserts, reads, and removes session bindings", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const directory = yield* ProviderSessionDirectory; | |
| 15 | ||
| 16 | yield* directory.upsert({ | |
| 17 | sessionId: "sess-1", | |
| 18 | provider: "codex", | |
| 19 | threadId: "thread-1", | |
| 20 | }); | |
| 21 | ||
| 22 | const provider = yield* directory.getProvider("sess-1"); | |
| 23 | assert.equal(provider, "codex"); | |
| 24 | const threadId = yield* directory.getThreadId("sess-1"); | |
| 25 | assertSome(threadId, "thread-1"); | |
| 26 | ||
| 27 | yield* directory.upsert({ | |
| 28 | sessionId: "sess-1", | |
| 29 | provider: "codex", | |
| 30 | threadId: "thread-2", | |
| 31 | }); | |
| 32 | const updatedThreadId = yield* directory.getThreadId("sess-1"); | |
| 33 | assertSome(updatedThreadId, "thread-2"); | |
| 34 | ||
| 35 | const sessionIds = yield* directory.listSessionIds(); | |
| 36 | assert.deepEqual(sessionIds, ["sess-1"]); | |
| 37 | ||
| 38 | yield* directory.remove("sess-1"); | |
| 39 | const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result); | |
| 40 | assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); | |
| 41 | })); | |
| 42 | ||
| 43 | it("fails upsert for empty session id", () => | |
| 44 | Effect.gen(function* () { | |
| 45 | const directory = yield* ProviderSessionDirectory; | |
| 46 | const result = yield* Effect.result( | |
| 47 | directory.upsert({ | |
| 48 | sessionId: " ", | |
| 49 | provider: "codex", | |
| 50 | }), | |
| 51 | ); | |
| 52 | assertFailure( | |
| 53 | result, | |
| 54 | new ProviderValidationError({ | |
| 55 | operation: "ProviderSessionDirectory.upsert", | |
| 56 | issue: "sessionId must be a non-empty string.", | |
| 57 | }), | |
| 58 | ); | |
| 59 | })); | |
| 60 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | /** | |
| 2 | * ProviderSessionDirectoryLive - In-memory session ownership index layer. | |
| 3 | * | |
| 4 | * Tracks provider ownership and optional thread id by `sessionId` so | |
| 5 | * `ProviderService` can route session-scoped API calls to the right adapter. | |
| 6 | * This layer stores metadata only and does not call provider adapters directly. | |
| 7 | * | |
| 8 | * @module ProviderSessionDirectoryLive | |
| 9 | */ | |
| 10 | import { Effect, Layer, Option, Ref } from "effect"; | |
| 11 | ||
| 12 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | |
| 13 | import { | |
| 14 | ProviderSessionDirectory, | |
| 15 | type ProviderSessionBinding, | |
| 16 | type ProviderSessionDirectoryShape, | |
| 17 | } from "../Services/ProviderSessionDirectory.ts"; | |
| 18 | ||
| 19 | const makeProviderSessionDirectory = Effect.gen(function* () { | |
| 20 | const bindingsRef = yield* Ref.make(new Map<string, ProviderSessionBinding>()); | |
| 21 | ||
| 22 | const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => { | |
| 23 | const sessionId = binding.sessionId.trim(); | |
| 24 | if (sessionId.length === 0) { | |
| 25 | return Effect.fail( | |
| 26 | new ProviderValidationError({ | |
| 27 | operation: "ProviderSessionDirectory.upsert", | |
| 28 | issue: "sessionId must be a non-empty string.", | |
| 29 | }), | |
| 30 | ); | |
| 31 | } | |
| 32 | ||
| 33 | const normalized: ProviderSessionBinding = { | |
| 34 | sessionId, | |
| 35 | provider: binding.provider, | |
| 36 | ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), | |
| 37 | }; | |
| 38 | ||
| 39 | return Ref.update(bindingsRef, (current) => { | |
| 40 | const next = new Map(current); | |
| 41 | next.set(sessionId, normalized); | |
| 42 | return next; | |
| 43 | }); | |
| 44 | }; | |
| 45 | ||
| 46 | const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => | |
| 47 | Ref.get(bindingsRef).pipe( | |
| 48 | Effect.flatMap((bindings) => { | |
| 49 | const binding = bindings.get(sessionId); | |
| 50 | if (!binding) { | |
| 51 | return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); | |
| 52 | } | |
| 53 | return Effect.succeed(binding.provider); | |
| 54 | }), | |
| 55 | ); | |
| 56 | ||
| 57 | const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => | |
| 58 | Ref.get(bindingsRef).pipe( | |
| 59 | Effect.flatMap((bindings) => { | |
| 60 | const binding = bindings.get(sessionId); | |
| 61 | if (!binding) { | |
| 62 | return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); | |
| 63 | } | |
| 64 | return Effect.succeed(Option.fromNullishOr(binding.threadId)); | |
| 65 | }), | |
| 66 | ); | |
| 67 | ||
| 68 | const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => | |
| 69 | Ref.update(bindingsRef, (current) => { | |
| 70 | if (!current.has(sessionId)) { | |
| 71 | return current; | |
| 72 | } | |
| 73 | const next = new Map(current); | |
| 74 | next.delete(sessionId); | |
| 75 | return next; | |
| 76 | }); | |
| 77 | ||
| 78 | const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => | |
| 79 | Ref.get(bindingsRef).pipe(Effect.map((bindings) => Array.from(bindings.keys()))); | |
| 80 | ||
| 81 | return { | |
| 82 | upsert, | |
| 83 | getProvider, | |
| 84 | getThreadId, | |
| 85 | remove, | |
| 86 | listSessionIds, | |
| 87 | } satisfies ProviderSessionDirectoryShape; | |
| 88 | }); | |
| 89 | ||
| 90 | export const ProviderSessionDirectoryLive = Layer.effect( | |
| 91 | ProviderSessionDirectory, | |
| 92 | makeProviderSessionDirectory, | |
| 93 | ); |