authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-22 13:58:32-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log32942a5f20246ad6a0bd8f0e72e73c084676d45b
tree1ddb7e7b12b7ea3a700f28a75e8cb8cf8d6d3c45
parent150b3782b942e3b35899e134536efc0e4bbd51ac

more effect: provider service layers


8 files changed, 1441 insertions(+), 0 deletions(-)

apps/server/src/provider/Layers/CodexAdapter.test.ts created+202
...@@ -0,0 +1,202 @@
1import type {
2 ProviderApprovalDecision,
3 ProviderEvent,
4 ProviderSendTurnInput,
5 ProviderSession,
6 ProviderSessionStartInput,
7 ProviderTurnStartResult,
8} from "@t3tools/contracts";
9import { afterAll, assert, it, vi } from "@effect/vitest";
10import { assertFailure } from "@effect/vitest/utils";
11
12import { Effect } from "effect";
13
14import { CodexAppServerManager } from "../../codexAppServerManager.ts";
15import { ProviderAdapterValidationError } from "../Errors.ts";
16import { CodexAdapter } from "../Services/CodexAdapter.ts";
17import { makeCodexAdapterLive } from "./CodexAdapter.ts";
18
19class 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
109const validationManager = new FakeCodexManager();
110const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager }));
111
112validationLayer("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
135const sessionErrorManager = new FakeCodexManager();
136sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
137 throw new Error("Unknown session: sess-missing");
138});
139const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager }));
140
141sessionErrorLayer("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
169const lifecycleManager = new FakeCodexManager();
170const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager }));
171
172lifecycleLayer("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
200afterAll(() => {
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 */
9import type { ProviderEvent } from "@t3tools/contracts";
10import { Effect, Layer } from "effect";
11
12import {
13 ProviderAdapterProcessError,
14 ProviderAdapterRequestError,
15 ProviderAdapterSessionClosedError,
16 ProviderAdapterSessionNotFoundError,
17 ProviderAdapterValidationError,
18 type ProviderAdapterError,
19} from "../Errors.ts";
20import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
21import { CodexAppServerManager } from "../../codexAppServerManager.ts";
22
23const PROVIDER = "codex" as const;
24
25export interface CodexAdapterLiveOptions {
26 readonly manager?: CodexAppServerManager;
27 readonly makeManager?: () => CodexAppServerManager;
28}
29
30function 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
37function 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
59function 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
72const 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
209export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter());
210
211export 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 @@
1import type { ProviderKind } from "@t3tools/contracts";
2import { it, assert, vi } from "@effect/vitest";
3import { assertFailure } from "@effect/vitest/utils";
4
5import { Effect, Layer } from "effect";
6
7import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
8import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
9import { makeProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
10import { ProviderUnsupportedError } from "../Errors.ts";
11
12const 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
27const layer = it.layer(
28 makeProviderAdapterRegistryLive().pipe(
29 Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)),
30 ),
31);
32
33layer("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 */
10import type { ProviderKind } from "@t3tools/contracts";
11import { Effect, Layer } from "effect";
12
13import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
14import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
15import {
16 ProviderAdapterRegistry,
17 type ProviderAdapterRegistryShape,
18} from "../Services/ProviderAdapterRegistry.ts";
19import { CodexAdapter } from "../Services/CodexAdapter.ts";
20
21export interface ProviderAdapterRegistryLiveOptions {
22 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
23}
24
25const 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
50export const ProviderAdapterRegistryLive = Layer.effect(
51 ProviderAdapterRegistry,
52 makeProviderAdapterRegistry(),
53);
54
55export 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 @@
1import 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";
15import { providerSessionStartInputSchema } from "@t3tools/contracts";
16import { it, assert, vi } from "@effect/vitest";
17import { assertFailure } from "@effect/vitest/utils";
18
19import { Effect, Layer } from "effect";
20
21import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";
22import {
23 ProviderAdapterSessionNotFoundError,
24 ProviderSessionNotFoundError,
25 ProviderValidationError,
26 ProviderUnsupportedError,
27 type ProviderAdapterError,
28} from "../Errors.ts";
29import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
30import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
31import { ProviderService } from "../Services/ProviderService.ts";
32import { ProviderServiceLive } from "./ProviderService.ts";
33import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
34
35function 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
177function 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
245function 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
275const routing = makeProviderServiceLayer();
276routing.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
349const fanout = makeProviderServiceLayer();
350fanout.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
396const validation = makeProviderServiceLayer();
397validation.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 */
13import { randomUUID } from "node:crypto";
14
15import type { ProviderEvent } from "@t3tools/contracts";
16import {
17 providerGetCheckpointDiffInputSchema,
18 providerInterruptTurnInputSchema,
19 providerListCheckpointsInputSchema,
20 providerRespondToRequestInputSchema,
21 providerRevertToCheckpointInputSchema,
22 providerSendTurnInputSchema,
23 providerSessionStartInputSchema,
24 providerStopSessionInputSchema,
25} from "@t3tools/contracts";
26import { Effect, Layer } from "effect";
27
28import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";
29import { ProviderValidationError } from "../Errors.ts";
30import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
31import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
32import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
33
34function 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
41function asString(value: unknown): string | undefined {
42 return typeof value === "string" ? value : undefined;
43}
44
45function readTurnCompletionStatus(event: ProviderEvent): string | undefined {
46 const payload = asObject(event.payload);
47 const turn = asObject(payload?.turn);
48 return asString(turn?.status);
49}
50
51function 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
68function 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
80const 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
339export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService);
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts created+60
...@@ -0,0 +1,60 @@
1import { it, assert } from "@effect/vitest";
2import { assertFailure, assertSome } from "@effect/vitest/utils";
3import { Effect } from "effect";
4
5import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
6import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
7import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
8
9const layer = it.layer(ProviderSessionDirectoryLive);
10
11layer("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 */
10import { Effect, Layer, Option, Ref } from "effect";
11
12import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
13import {
14 ProviderSessionDirectory,
15 type ProviderSessionBinding,
16 type ProviderSessionDirectoryShape,
17} from "../Services/ProviderSessionDirectory.ts";
18
19const 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
90export const ProviderSessionDirectoryLive = Layer.effect(
91 ProviderSessionDirectory,
92 makeProviderSessionDirectory,
93);