authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-23 14:32:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log7307079d4063a5c546a4c277dcc3b6594b41a846
tree5aaf1103261db502f6b8f0136afb54dd65989ff6
parent23861cf3002a7b15a30968de6edd80ebefdefa86

spec


30 files changed, 1901 insertions(+), 808 deletions(-)

.plans/15-effect-server.md+10-1
...@@ -1 +1,10 @@...@@ -1 +1,10 @@
1Rewrite `createServer` and `index.ts` to be Effect native.
\ No newline at end of file
1Rewrite `createServer` and `index.ts` to be Effect native.
2
3Maybe use `effect/unstable/Socket` for the web socket server
4- https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/unstable/socket/SocketServer.ts
5- https://github.com/Effect-TS/effect-smol/blob/main/packages/platform-node/test/NodeSocket.test.ts
6
7- Migrate remaining runtime code to Effect
8 - `gitManager` -> `src/git`
9 - `terminalManager` -> `src/terminal` (Manager + PTY)
10 - ...
\ No newline at end of file
.vscode/settings.json+2-1
...@@ -4,5 +4,6 @@...@@ -4,5 +4,6 @@
4 "editor.codeActionsOnSave": {4 "editor.codeActionsOnSave": {
5 "source.fixAll.oxc": "always"5 "source.fixAll.oxc": "always"
6 },6 },
7 "oxc.unusedDisableDirectives": "warn"7 "oxc.unusedDisableDirectives": "warn",
8 "typescript.tsdk": "node_modules/typescript/lib"
8}9}
AGENTS.md+2
...@@ -41,3 +41,5 @@ Docs:...@@ -41,3 +41,5 @@ Docs:
41- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor41- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
4242
43Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.43Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.
44
45<!-- Dummy edit marker for workflow testing. -->
SPEC.md created+569
...@@ -0,0 +1,569 @@
1# Orchestration Interface Spec (Required State)
2
3Status: Required target architecture (not a description of current behavior).
4
5## 1. Identifier Schema (Branded)
6
7All identifiers are branded and never plain `string` in schema definitions.
8
9```ts
10const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"));
11const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
12const CommandId = Schema.String.pipe(Schema.brand("CommandId"));
13const EventId = Schema.String.pipe(Schema.brand("EventId"));
14const MessageId = Schema.String.pipe(Schema.brand("MessageId"));
15const TurnId = Schema.String.pipe(Schema.brand("TurnId"));
16
17const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId"));
18const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId"));
19const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId"));
20const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId"));
21const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId"));
22
23const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef"));
24```
25
26Rules:
27
28- `ThreadId` is app/orchestration thread identity.
29- `ProviderThreadId` is provider runtime thread identity.
30- They are never assigned to each other.
31- `ProviderSessionId` is internal server/provider routing identity; clients do not send it.
32- use e.g. `ThreadId.make(randomUUID())` to create new entity ids at the source (e.g. client create thread id and send to server, provider adapter creates the provider thread id from the codex app server etc)
33
34## 2. Client Commands (Domain Commands)
35
36All commands are sent through one RPC: `orchestration.dispatchCommand`.
37
38Output for every command: `DispatchResultSchema = { sequence: number }`.
39
40### 2.1 Client-Dispatchable Commands
41
421. `project.create`
43
44- Input:
45 - `commandId: CommandId`
46 - `projectId: ProjectId`
47 - `title: string`
48 - `workspaceRoot: string`
49 - `defaultModel?: string`
50 - `createdAt: IsoDateTime`
51- Output: `DispatchResult`
52
532. `project.meta.update`
54
55- Input:
56 - `commandId: CommandId`
57 - `projectId: ProjectId`
58 - optional `{ title, workspaceRoot, defaultModel }`
59- Output: `DispatchResult`
60
613. `project.delete`
62
63- Input: `commandId`, `projectId`
64- Output: `DispatchResult`
65
664. `thread.create`
67
68- Input:
69 - `commandId: CommandId`
70 - `threadId: ThreadId`
71 - `projectId: ProjectId`
72 - `title: string`
73 - `model: string`
74 - `branch: string | null`
75 - `worktreePath: string | null`
76 - `createdAt: IsoDateTime`
77- Output: `DispatchResult`
78
795. `thread.delete`
80
81- Input: `commandId`, `threadId`
82- Output: `DispatchResult`
83
846. `thread.meta.update`
85
86- Input: `commandId`, `threadId`, optional `{ title, model, branch, worktreePath }`
87- Output: `DispatchResult`
88
897. `thread.turn.start`
90
91- Input:
92 - `commandId`
93 - `threadId`
94 - `message`:
95 - `messageId: MessageId`
96 - `role: "user"`
97 - `text: string`
98 - `attachments: ChatAttachment[]` (schema-shared)
99 - `model?: string`
100 - `effort?: string`
101 - `createdAt`
102- Output: `DispatchResult`
103
1048. `thread.turn.interrupt`
105
106- Input: `commandId`, `threadId`, optional `turnId: TurnId`, `createdAt`
107- Output: `DispatchResult`
108
1099. `thread.approval.respond`
110
111- Input:
112 - `commandId`
113 - `threadId`
114 - `requestId: ApprovalRequestId`
115 - `decision: "accept" | "acceptForSession" | "decline" | "cancel"`
116 - `createdAt`
117- Output: `DispatchResult`
118
11910. `thread.checkpoint.revert`
120
121- Input: `commandId`, `threadId`, `turnCount: number`, `createdAt`
122- Output: `DispatchResult`
123
12411. `thread.session.stop`
125
126- Input: `commandId`, `threadId`, `createdAt`
127- Output: `DispatchResult`
128
129### 2.2 Internal-Only Commands (Not Client Dispatchable)
130
1311. `thread.session.set`
132
133- Server-owned projection update for session lifecycle.
134
1352. `thread.message.assistant.delta`
136
137- Server-owned incremental assistant content append.
138
1393. `thread.message.assistant.complete`
140
141- Server-owned assistant message completion marker.
142
1434. `thread.turn.diff.complete`
144
145- Server-owned checkpoint diff summary write.
146
1475. `thread.activity.append`
148
149- Server-owned activity feed append.
150
151## 3. Client RPC (Required)
152
153These are the required client-facing RPC methods.
154
1551. `orchestration.getSnapshot`
156
157- Input: `{}`
158- Output: `OrchestrationReadModel`
159
1602. `orchestration.dispatchCommand`
161
162- Input: `ClientOrchestrationCommand` (`Schema.Union` of all client-dispatchable commands)
163- Output: `DispatchResult`
164
1653. `orchestration.getTurnDiff`
166
167- Input:
168 - `threadId: ThreadId`
169 - `fromTurnCount: number`
170 - `toTurnCount: number`
171- Output:
172 - `threadId: ThreadId`
173 - `fromTurnCount: number`
174 - `toTurnCount: number`
175 - `diff: string`
176
1774. `orchestration.replayEvents` (optional operational/debug API)
178
179- Input: `{ fromSequenceExclusive: number }`
180- Output: `OrchestrationEvent[]`
181
182### 3.1 RPC To Remove
183
184Remove from client boundary:
185
186- `providers.startSession`
187- `providers.sendTurn`
188- `providers.interruptTurn`
189- `providers.respondToRequest`
190- `providers.stopSession`
191- `providers.listCheckpoints`
192- `providers.getCheckpointDiff`
193- `providers.revertToCheckpoint`
194
195Rationale:
196
197- Session/turn/checkpoint control belongs to orchestrator command path.
198- Snapshot already includes checkpoint summaries (`checkpoints`); only full textual diff needs explicit query RPC.
199
200## 4. Server Orchestrator Command <-> Event Schemas
201
202Each command deterministically yields one or more domain events that are persisted to the event store.
203
2041. `project.create` -> `project.created`
2052. `project.meta.update` -> `project.meta-updated`
2063. `project.delete` -> `project.deleted`
2074. `thread.create` -> `thread.created`
2085. `thread.delete` -> `thread.deleted`
2096. `thread.meta.update` -> `thread.meta-updated`
2107. `thread.turn.start` ->
211
212- `thread.message-sent` (user message append)
213- `thread.turn-start-requested`
214- then (async side effect success) `thread.session-set` + provider-driven message/turn events
215
2168. `thread.turn.interrupt` -> `thread.turn-interrupt-requested`
2179. `thread.approval.respond` -> `thread.approval-response-requested`
21810. `thread.checkpoint.revert` -> `thread.checkpoint-revert-requested` then `thread.reverted`
21911. `thread.session.stop` -> `thread.session-stop-requested` then `thread.session-set`
220
221Internal command/event path:
222
223- `thread.session.set` -> `thread.session-set`
224- `thread.message.assistant.delta` -> `thread.message-sent` (assistant streaming)
225- `thread.message.assistant.complete` -> `thread.message-sent` (streaming false)
226- `thread.turn.diff.complete` -> `thread.turn-diff-completed`
227- `thread.activity.append` -> `thread.activity-appended`
228
229## 5. Orchestration Events <-> Generic Provider Events <-> Raw Provider Events
230
231Required mapping pipeline:
232
233`Raw Provider Event (adapter-specific)`
234-> `GenericProviderRuntimeEvent` (provider-agnostic schema)
235-> `Internal Orchestration Command`
236-> `Orchestration Domain Event`
237
238### 5.1 Mapping Table for Codex Adapter
239
2401. Raw `thread/started`
241
242- Generic: `thread.started`
243- Internal command: `thread.session.set` (attach `ProviderThreadId`, status `ready|running`)
244- Domain event: `thread.session-set`
245
2462. Raw `turn/started`
247
248- Generic: `turn.started`
249- Internal command: `thread.session.set` (status `running`, active turn)
250- Domain event: `thread.session-set`
251
2523. Raw `item/agentMessage/delta`
253
254- Generic: `message.delta`
255- Internal command: `thread.message.assistant.delta`
256- Domain event: `thread.message-sent` (assistant, streaming)
257
2584. Raw `item/completed` (agent message)
259
260- Generic: `message.completed`
261- Internal command: `thread.message.assistant.complete`
262- Domain event: `thread.message-sent` (assistant, completed)
263
2645. Raw `turn/completed`
265
266- Generic: `turn.completed`
267- Internal commands:
268 - `thread.session.set` (ready/error)
269 - `thread.activity.append` (optional summary)
270- Domain events: `thread.session-set`, `thread.activity-appended`
271
2726. Raw approval request
273
274- Generic: `approval.requested`
275- Internal command: `thread.activity.append` (typed `requestId`, `requestKind`)
276- Domain event: `thread.activity-appended`
277
2787. Raw approval decision ack
279
280- Generic: `approval.resolved`
281- Internal command: `thread.activity.append`
282- Domain event: `thread.activity-appended`
283
2848. Raw tool start/complete
285
286- Generic: `tool.started` / `tool.completed`
287- Internal command: `thread.activity.append`
288- Domain event: `thread.activity-appended`
289
2909. Checkpoint capture (server-produced)
291
292- Generic: `checkpoint.captured`
293- Internal command: `thread.turn.diff.complete`
294- Domain event: `thread.turn-diff-completed`
295
29610. Raw/runtime error
297
298- Generic: `runtime.error`
299- Internal commands:
300 - `thread.session.set` (status `error`)
301 - `thread.activity.append` (`tone=error`)
302- Domain events: `thread.session-set`, `thread.activity-appended`
303
304## 6. Schema Section (Boundary + Reuse)
305
306### 6.1 Shared Schemas (contracts package)
307
308Expose to both web and server:
309
310- `OrchestrationReadModelSchema`
311- `ClientOrchestrationCommandSchema`
312- `OrchestrationEventSchema`
313- `OrchestrationRpcSchemas` (`getSnapshot`, `dispatchCommand`, `getTurnDiff`, optional replay)
314- `GenericProviderRuntimeEventSchema` (used for diagnostics/ingestion tests)
315- Shared message/activity/checkpoint summary schemas used in snapshot
316
317### 6.2 Server-Internal Schemas
318
319Not exposed to web:
320
321- `InternalOrchestrationCommandSchema`
322- Adapter raw-event schemas and provider-specific payload schemas
323- Provider session directory records and persistence-only row schemas
324- Git service cache/query schemas (non-orchestration read model)
325
326### 6.3 Reuse Requirements
327
328- Snapshot checkpoint summary schema is source of truth for checkpoint list UI.
329- `getTurnDiff` response reuses `ThreadId`/turn count brands and textual `diff` only.
330- Activity schemas are reused for approval/tool/runtime annotations; no separate UI-only structure.
331- Session schema must include both `threadId: ThreadId` and optional `providerThreadId: ProviderThreadId` (explicitly distinct).
332
333## 7. Persistence Model (Required)
334
335This section defines required persisted tables, the canonical persisted event envelope, and required projected/read tables.
336
337### 7.1 Write-Side Persisted Tables
338
3391. `orchestration_events` (append-only event store)
340
341- `sequence: number` (global monotonic sequence, primary key)
342- `eventId: EventId` (unique)
343- `aggregateKind: "project" | "thread"`
344- `streamId: ProjectId | ThreadId` (aggregate stream id)
345- `streamVersion: number` (per-aggregate monotonic version)
346- `eventType: OrchestrationEventType`
347- `occurredAt: IsoDateTime`
348- `commandId: CommandId | null`
349- `causationEventId: EventId | null`
350- `correlationId: CommandId | null`
351- `actorKind: "client" | "server" | "provider"`
352- `payload: OrchestrationEventPayload` (type-validated by `eventType`)
353- `metadata: OrchestrationEventMetadata`
354
3552. `orchestration_command_receipts` (idempotency + ack replay)
356
357- `commandId: CommandId` (primary key)
358- `aggregateKind: "project" | "thread"`
359- `aggregateId: ProjectId | ThreadId`
360- `acceptedAt: IsoDateTime`
361- `resultSequence: number`
362- `status: "accepted" | "rejected"`
363- `error: string | null`
364
3653. `checkpoint_diff_blobs` (large plaintext diffs; separate from summaries)
366
367- `threadId: ThreadId`
368- `fromTurnCount: number`
369- `toTurnCount: number`
370- `diff: string`
371- `createdAt: IsoDateTime`
372- unique key: `(threadId, fromTurnCount, toTurnCount)`
373
3744. `provider_session_runtime` (server-internal adapter resume state)
375
376- `providerSessionId: ProviderSessionId` (primary key)
377- `threadId: ThreadId`
378- `providerName: string`
379- `adapterKey: string`
380- `providerThreadId: ProviderThreadId | null`
381- `status: "starting" | "running" | "stopped" | "error"`
382- `lastSeenAt: IsoDateTime`
383- `resumeCursor: JsonValue | null` (adapter-specific opaque state)
384- `runtimePayload: JsonValue | null` (adapter-specific opaque state)
385
386### 7.2 Canonical Persisted Event Schema
387
388`OrchestrationPersistedEventSchema` (full envelope):
389
390```ts
391type OrchestrationPersistedEvent = {
392 sequence: number;
393 eventId: EventId;
394 aggregateKind: "project" | "thread";
395 streamId: ProjectId | ThreadId;
396 streamVersion: number;
397 eventType: OrchestrationEventType;
398 occurredAt: IsoDateTime;
399 commandId: CommandId | null;
400 causationEventId: EventId | null;
401 correlationId: CommandId | null;
402 actorKind: "client" | "server" | "provider";
403 payload: OrchestrationEventPayload;
404 metadata: {
405 providerSessionId?: ProviderSessionId;
406 providerThreadId?: ProviderThreadId;
407 providerTurnId?: ProviderTurnId;
408 providerItemId?: ProviderItemId;
409 adapterKey?: string;
410 requestId?: ApprovalRequestId;
411 ingestedAt?: IsoDateTime;
412 };
413};
414```
415
416Rules:
417
418- `payload` must be schema-discriminated by `eventType`.
419- provider identifiers only appear in `metadata` and provider-specific payload sub-shapes; they do not replace `ThreadId`.
420- `streamVersion` is concurrency guard for aggregate writes.
421
422### 7.3 Required Projected Tables (Read Models)
423
4241. `projection_projects`
425
426- `projectId: ProjectId` (primary key)
427- `title: string`
428- `workspaceRoot: string`
429- `defaultModel: string | null`
430- `createdAt: IsoDateTime`
431- `updatedAt: IsoDateTime`
432- `deletedAt: IsoDateTime | null`
433
4342. `projection_threads`
435
436- `threadId: ThreadId` (primary key)
437- `projectId: ProjectId`
438- `title: string`
439- `model: string`
440- `branch: string | null`
441- `worktreePath: string | null`
442- `latestTurnId: TurnId | null`
443- `createdAt: IsoDateTime`
444- `updatedAt: IsoDateTime`
445- `deletedAt: IsoDateTime | null`
446
4473. `projection_thread_messages`
448
449- `messageId: MessageId` (primary key)
450- `threadId: ThreadId`
451- `turnId: TurnId | null`
452- `role: "user" | "assistant" | "system"`
453- `text: string`
454- `isStreaming: boolean`
455- `createdAt: IsoDateTime`
456- `updatedAt: IsoDateTime`
457
4584. `projection_thread_activities`
459
460- `activityId: EventId` (primary key; derived from source event)
461- `threadId: ThreadId`
462- `turnId: TurnId | null`
463- `tone: "info" | "tool" | "approval" | "error"`
464- `kind: string`
465- `summary: string`
466- `payload: JsonValue`
467- `createdAt: IsoDateTime`
468
4695. `projection_thread_sessions`
470
471- `threadId: ThreadId` (primary key)
472- `status: "idle" | "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"`
473- `providerName: string | null`
474- `providerSessionId: ProviderSessionId | null`
475- `providerThreadId: ProviderThreadId | null`
476- `activeTurnId: TurnId | null`
477- `lastError: string | null`
478- `updatedAt: IsoDateTime`
479
4806. `projection_thread_turns`
481
482- `turnId: TurnId` (primary key)
483- `threadId: ThreadId`
484- `turnCount: number`
485- `status: "running" | "completed" | "interrupted" | "error"`
486- `userMessageId: MessageId | null`
487- `assistantMessageId: MessageId | null`
488- `startedAt: IsoDateTime`
489- `completedAt: IsoDateTime | null`
490
4917. `projection_checkpoints`
492
493- `threadId: ThreadId`
494- `turnId: TurnId`
495- `checkpointTurnCount: number`
496- `checkpointRef: CheckpointRef`
497- `status: "ready" | "missing" | "error"`
498- `files: JsonArray` (typed as file diff summary schema)
499- `assistantMessageId: MessageId | null`
500- `completedAt: IsoDateTime`
501- unique key: `(threadId, checkpointTurnCount)`
502
5038. `projection_pending_approvals`
504
505- `requestId: ApprovalRequestId` (primary key)
506- `threadId: ThreadId`
507- `turnId: TurnId | null`
508- `status: "pending" | "resolved"`
509- `decision: "accept" | "acceptForSession" | "decline" | "cancel" | null`
510- `createdAt: IsoDateTime`
511- `resolvedAt: IsoDateTime | null`
512
5139. `projection_state`
514
515- `projector: string` (primary key; e.g. `threads`, `messages`, `sessions`)
516- `lastAppliedSequence: number`
517- `updatedAt: IsoDateTime`
518
519Projection consistency rules:
520
521- Every projector applies read-model row updates and `projection_state.lastAppliedSequence` in the same database transaction.
522- Optional debug field on projection rows: `lastEventSequence: number` (not required for correctness).
523
524### 7.4 Snapshot and RPC Requirements
525
5261. `orchestration.getSnapshot` is fully served from projection tables and returns `snapshotSequence: number`.
5272. Snapshot must include `projects[]` from `projection_projects`.
5283. Thread snapshot must include `checkpoints[]` from `projection_checkpoints`:
529- `turnId: TurnId`
530- `completedAt: IsoDateTime`
531- `status`
532- `files[]` (`path`, `kind`, `additions`, `deletions`)
533- `checkpointRef: CheckpointRef`
534- `assistantMessageId?: MessageId`
535- `checkpointTurnCount: number`
5364. Client does not require `listCheckpoints` RPC:
537- checkpoint list comes from snapshot projections
538- full diff text comes from `orchestration.getTurnDiff` backed by `checkpoint_diff_blobs`
5395. Provider session identity is not a client routing key:
540- client acts on `ThreadId`
541- server resolves provider session internally via `projection_thread_sessions`
5426. `snapshotSequence` is derived from `projection_state`:
543- if snapshot depends on multiple projectors, use the minimum `lastAppliedSequence` across those projectors.
5447. Event subscription handoff contract:
545- client performs `getSnapshot` and reads `snapshotSequence`
546- client subscribes/replays with `fromSequenceExclusive = snapshotSequence`
547- server guarantees no gap between snapshot visibility and subsequent event stream from that sequence.
548
549### 7.5 External Derived State (Non-Orchestration)
550
5511. Current git/worktree state is not projected from orchestration events.
5522. If persisted, it belongs in a separate git cache/read model (for example `git_state_cache`) owned by a git service.
5533. Orchestration may embed git metadata only when captured as a domain fact (for example checkpoint metadata at turn completion).
5544. Any RPC for current git status should be outside orchestration RPC (for example `git.getThreadState`).
555
556### 7.6 Existing Repository Placement
557
5581. `ProjectsRepository`
559- Fits as projection/query access on top of `projection_projects` plus orchestration command dispatch for writes.
560- Must not bypass command->event append path for mutations.
561
5622. `CheckpointsRepository`
563- Fits as projection/query access on top of `projection_checkpoints` plus `checkpoint_diff_blobs` (or git-service on-demand diff implementation).
564- Represents the same concept as prior `turn_diff_summary`; canonical naming is `checkpoint`.
565- Must not be an independent source of truth outside orchestration events.
566
5673. `ProviderSessionsRepository`
568- Fits as server-internal runtime persistence on top of `provider_session_runtime`, with domain-visible state projected into `projection_thread_sessions`.
569- Not part of client RPC/domain aggregate boundary.
apps/server/integration/TestProviderAdapter.integration.ts+10-14
...@@ -70,13 +70,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -70,13 +70,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
70 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>70 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
71 Effect.gen(function* () {71 Effect.gen(function* () {
72 if (input.provider !== undefined && input.provider !== PROVIDER) {72 if (input.provider !== undefined && input.provider !== PROVIDER) {
73 return yield* Effect.fail(73 return yield* new ProviderAdapterValidationError({
74 new ProviderAdapterValidationError({74 provider: PROVIDER,
75 provider: PROVIDER,75 operation: "startSession",
76 operation: "startSession",76 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
77 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,77 });
78 }),
79 );
80 }78 }
8179
82 sessionCount += 1;80 sessionCount += 1;
...@@ -121,13 +119,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -121,13 +119,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
121119
122 const response = state.queuedResponses.shift();120 const response = state.queuedResponses.shift();
123 if (!response) {121 if (!response) {
124 return yield* Effect.fail(122 return yield* new ProviderAdapterValidationError({
125 new ProviderAdapterValidationError({123 provider: PROVIDER,
126 provider: PROVIDER,124 operation: "sendTurn",
127 operation: "sendTurn",125 issue: `No queued turn response for session ${input.sessionId}.`,
128 issue: `No queued turn response for session ${input.sessionId}.`,126 });
129 }),
130 );
131 }127 }
132128
133 const assistantDeltas: string[] = [];129 const assistantDeltas: string[] = [];
apps/server/package.json+3
...@@ -13,11 +13,13 @@...@@ -13,11 +13,13 @@
13 "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts",13 "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts",
14 "build": "tsdown && node scripts/bundle-client.ts",14 "build": "tsdown && node scripts/bundle-client.ts",
15 "start": "node dist/index.mjs",15 "start": "node dist/index.mjs",
16 "prepare": "effect-language-service patch",
16 "typecheck": "tsc --noEmit",17 "typecheck": "tsc --noEmit",
17 "test": "vitest run"18 "test": "vitest run"
18 },19 },
19 "dependencies": {20 "dependencies": {
20 "@effect/platform-node": "4.0.0-beta.10",21 "@effect/platform-node": "4.0.0-beta.10",
22 "@effect/sql-sqlite-bun": "4.0.0-beta.10",
21 "@pierre/diffs": "^1.1.0-beta.16",23 "@pierre/diffs": "^1.1.0-beta.16",
22 "effect": "4.0.0-beta.10",24 "effect": "4.0.0-beta.10",
23 "node-pty": "^1.1.0",25 "node-pty": "^1.1.0",
...@@ -25,6 +27,7 @@...@@ -25,6 +27,7 @@
25 "ws": "^8.18.0"27 "ws": "^8.18.0"
26 },28 },
27 "devDependencies": {29 "devDependencies": {
30 "@effect/language-service": "0.75.1",
28 "@effect/vitest": "4.0.0-beta.10",31 "@effect/vitest": "4.0.0-beta.10",
29 "@t3tools/contracts": "workspace:*",32 "@t3tools/contracts": "workspace:*",
30 "@t3tools/web": "workspace:*",33 "@t3tools/web": "workspace:*",
apps/server/src/checkpointing/Layers/CheckpointService.test.ts+136-28
...@@ -60,41 +60,56 @@ const makeGitRepository = Effect.gen(function* () {...@@ -60,41 +60,56 @@ const makeGitRepository = Effect.gen(function* () {
60class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> {60class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> {
61 readonly provider = "codex" as const;61 readonly provider = "codex" as const;
6262
63 private active = true;
64 private readThreadFailure: ProviderAdapterError | undefined;63 private readThreadFailure: ProviderAdapterError | undefined;
65 private snapshot: ProviderThreadSnapshot;64 private readonly sessions = new Map<string, ProviderSession>();
66 private readonly session: ProviderSession;65 private readonly snapshots = new Map<string, ProviderThreadSnapshot>();
6766
68 constructor(67 constructor(
69 readonly sessionId: string,68 readonly sessionId: string,
70 readonly cwd: string,69 readonly cwd: string,
71 turns: ReadonlyArray<ProviderThreadTurnSnapshot>,70 turns: ReadonlyArray<ProviderThreadTurnSnapshot>,
72 ) {71 ) {
73 const now = new Date().toISOString();72 this.addSession({
74 this.session = {
75 sessionId,73 sessionId,
74 threadId: "thread-1",
75 turns,
76 });
77 }
78
79 addSession(input: {
80 readonly sessionId: string;
81 readonly threadId: string;
82 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
83 }): void {
84 const now = new Date().toISOString();
85 this.sessions.set(input.sessionId, {
86 sessionId: input.sessionId,
76 provider: "codex",87 provider: "codex",
77 status: "ready",88 status: "ready",
78 threadId: "thread-1",89 threadId: input.threadId,
79 cwd,90 cwd: this.cwd,
80 createdAt: now,91 createdAt: now,
81 updatedAt: now,92 updatedAt: now,
82 };93 });
83 this.snapshot = {94 this.snapshots.set(input.sessionId, {
84 threadId: this.session.threadId ?? "thread-1",95 threadId: input.threadId,
85 turns,96 turns: input.turns,
86 };97 });
87 }98 }
8899
89 setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>): void {100 setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>, sessionId = this.sessionId): void {
90 this.snapshot = {101 const snapshot = this.snapshots.get(sessionId);
91 threadId: this.snapshot.threadId,102 if (!snapshot) {
103 return;
104 }
105 this.snapshots.set(sessionId, {
106 threadId: snapshot.threadId,
92 turns,107 turns,
93 };108 });
94 }109 }
95110
96 getTurns(): ReadonlyArray<ProviderThreadTurnSnapshot> {111 getTurns(sessionId = this.sessionId): ReadonlyArray<ProviderThreadTurnSnapshot> {
97 return this.snapshot.turns;112 return this.snapshots.get(sessionId)?.turns ?? [];
98 }113 }
99114
100 setReadThreadFailure(error: ProviderAdapterError | undefined): void {115 setReadThreadFailure(error: ProviderAdapterError | undefined): void {
...@@ -136,16 +151,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr...@@ -136,16 +151,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr
136151
137 readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>152 readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>
138 Effect.sync(() => {153 Effect.sync(() => {
139 if (sessionId === this.sessionId) {154 this.sessions.delete(sessionId);
140 this.active = false;155 this.snapshots.delete(sessionId);
141 }
142 });156 });
143157
144 readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> =>158 readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> =>
145 Effect.sync(() => (this.active ? [this.session] : []));159 Effect.sync(() => Array.from(this.sessions.values()));
146160
147 readonly hasSession = (sessionId: string): Effect.Effect<boolean> =>161 readonly hasSession = (sessionId: string): Effect.Effect<boolean> =>
148 Effect.succeed(this.active && sessionId === this.sessionId);162 Effect.succeed(this.sessions.has(sessionId));
149163
150 readonly readThread = (164 readonly readThread = (
151 sessionId: string,165 sessionId: string,
...@@ -155,7 +169,18 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr...@@ -155,7 +169,18 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr
155 hasSession169 hasSession
156 ? this.readThreadFailure170 ? this.readThreadFailure
157 ? Effect.fail(this.readThreadFailure)171 ? Effect.fail(this.readThreadFailure)
158 : Effect.succeed(this.snapshot)172 : (() => {
173 const snapshot = this.snapshots.get(sessionId);
174 if (!snapshot) {
175 return Effect.fail(
176 new ProviderAdapterSessionNotFoundError({
177 provider: this.provider,
178 sessionId,
179 }),
180 );
181 }
182 return Effect.succeed(snapshot);
183 })()
159 : Effect.fail(184 : Effect.fail(
160 new ProviderAdapterSessionNotFoundError({185 new ProviderAdapterSessionNotFoundError({
161 provider: this.provider,186 provider: this.provider,
...@@ -184,14 +209,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr...@@ -184,14 +209,15 @@ class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterErr
184 threadId: snapshot.threadId,209 threadId: snapshot.threadId,
185 turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns),210 turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns),
186 } satisfies ProviderThreadSnapshot;211 } satisfies ProviderThreadSnapshot;
187 this.snapshot = next;212 this.snapshots.set(sessionId, next);
188 return Effect.succeed(next);213 return Effect.succeed(next);
189 }),214 }),
190 );215 );
191216
192 readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> =>217 readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> =>
193 Effect.sync(() => {218 Effect.sync(() => {
194 this.active = false;219 this.sessions.clear();
220 this.snapshots.clear();
195 });221 });
196222
197 readonly streamEvents = Stream.empty;223 readonly streamEvents = Stream.empty;
...@@ -315,6 +341,88 @@ it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", ()...@@ -315,6 +341,88 @@ it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", ()
315 }).pipe(Effect.provide(NodeServices.layer)),341 }).pipe(Effect.provide(NodeServices.layer)),
316);342);
317343
344it.effect(
345 "CheckpointServiceLive reuses checkpoint history after session churn on the same thread",
346 () =>
347 Effect.gen(function* () {
348 const firstTurn: ProviderThreadTurnSnapshot = {
349 id: "turn-1",
350 items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }],
351 };
352 const secondTurn: ProviderThreadTurnSnapshot = {
353 id: "turn-2",
354 items: [{ type: "agentMessage", text: "second" }],
355 };
356
357 const fixture = yield* makeFixture([]);
358
359 yield* Effect.gen(function* () {
360 const fs = yield* FileSystem.FileSystem;
361 const path = yield* Path.Path;
362 const service = yield* CheckpointService;
363 const directory = yield* ProviderSessionDirectory;
364
365 yield* directory.upsert({
366 sessionId: fixture.sessionId,
367 provider: "codex",
368 threadId: "thread-1",
369 });
370 yield* service.initializeForSession({
371 providerSessionId: fixture.sessionId,
372 cwd: fixture.cwd,
373 });
374
375 yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n");
376 fixture.adapter.setTurns([firstTurn]);
377 yield* service.captureCurrentTurn({
378 providerSessionId: fixture.sessionId,
379 });
380
381 const resumedSessionId = "sess-2";
382 fixture.adapter.addSession({
383 sessionId: resumedSessionId,
384 threadId: "thread-1",
385 turns: [firstTurn, secondTurn],
386 });
387 yield* directory.upsert({
388 sessionId: resumedSessionId,
389 provider: "codex",
390 threadId: "thread-1",
391 });
392
393 yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n");
394 yield* service.captureCurrentTurn({
395 providerSessionId: resumedSessionId,
396 });
397
398 const listed = yield* service.listCheckpoints({ sessionId: resumedSessionId });
399 assert.equal(listed.threadId, "thread-1");
400 assert.equal(listed.checkpoints.length, 3);
401 assert.equal(listed.checkpoints[2]?.id, "turn-2");
402 assert.equal(listed.checkpoints[2]?.isCurrent, true);
403
404 const diff = yield* service.getCheckpointDiff({
405 sessionId: resumedSessionId,
406 fromTurnCount: 1,
407 toTurnCount: 2,
408 });
409 assert.equal(diff.threadId, "thread-1");
410 assert.equal(diff.diff.includes("-v2"), true);
411 assert.equal(diff.diff.includes("+v3"), true);
412
413 const reverted = yield* service.revertToCheckpoint({
414 sessionId: resumedSessionId,
415 turnCount: 1,
416 });
417 const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md"));
418 assert.equal(afterRevert, "v2\n");
419 assert.equal(reverted.turnCount, 1);
420 assert.equal(reverted.rolledBackTurns, 1);
421 assert.equal(fixture.adapter.getTurns(resumedSessionId).length, 1);
422 }).pipe(Effect.provide(fixture.layer));
423 }).pipe(Effect.provide(NodeServices.layer)),
424);
425
318it.effect(426it.effect(
319 "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions",427 "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions",
320 () =>428 () =>
...@@ -374,7 +482,7 @@ it.effect(...@@ -374,7 +482,7 @@ it.effect(
374 });482 });
375483
376 const checkpoint = yield* checkpointRepository.getCheckpoint({484 const checkpoint = yield* checkpointRepository.getCheckpoint({
377 providerSessionId: fixture.sessionId,485 threadId: "thread-1",
378 turnCount: 1,486 turnCount: 1,
379 });487 });
380 if (checkpoint._tag !== "Some") {488 if (checkpoint._tag !== "Some") {
...@@ -429,7 +537,7 @@ it.effect("CheckpointServiceLive initializes root checkpoints without requiring...@@ -429,7 +537,7 @@ it.effect("CheckpointServiceLive initializes root checkpoints without requiring
429 });537 });
430538
431 const root = yield* checkpointRepository.getCheckpoint({539 const root = yield* checkpointRepository.getCheckpoint({
432 providerSessionId: fixture.sessionId,540 threadId: "thread-1",
433 turnCount: 0,541 turnCount: 0,
434 });542 });
435 assert.equal(root._tag, "Some");543 assert.equal(root._tag, "Some");
apps/server/src/checkpointing/Layers/CheckpointService.ts+129-103
...@@ -481,11 +481,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -481,11 +481,7 @@ const makeCheckpointService = Effect.gen(function* () {
481 ): Effect.Effect<string, CheckpointServiceError> =>481 ): Effect.Effect<string, CheckpointServiceError> =>
482 directory.getThreadId(sessionId).pipe(482 directory.getThreadId(sessionId).pipe(
483 Effect.mapError((cause) =>483 Effect.mapError((cause) =>
484 mapDirectoryLookupError(484 mapDirectoryLookupError(sessionId, `${operation}:getThreadId`, cause),
485 sessionId,
486 `${operation}:getThreadId`,
487 cause,
488 ),
489 ),485 ),
490 Effect.flatMap((threadId) =>486 Effect.flatMap((threadId) =>
491 Option.match(threadId, {487 Option.match(threadId, {
...@@ -495,10 +491,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -495,10 +491,7 @@ const makeCheckpointService = Effect.gen(function* () {
495 return Effect.succeed(normalizedThreadId);491 return Effect.succeed(normalizedThreadId);
496 }492 }
497 return Effect.fail(493 return Effect.fail(
498 invariantError(494 invariantError(operation, `Resolved empty thread id for session ${sessionId}.`),
499 operation,
500 `Resolved empty thread id for session ${sessionId}.`,
501 ),
502 );495 );
503 },496 },
504 onNone: () =>497 onNone: () =>
...@@ -512,6 +505,30 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -512,6 +505,30 @@ const makeCheckpointService = Effect.gen(function* () {
512 ),505 ),
513 );506 );
514507
508 const ensureThreadMatchesSnapshot = (
509 resolvedThreadId: string,
510 snapshot: ProviderThreadSnapshot,
511 operation: string,
512 ): Effect.Effect<void, CheckpointInvariantError> => {
513 const snapshotThreadId = snapshot.threadId.trim();
514 if (snapshotThreadId.length === 0) {
515 return Effect.fail(
516 invariantError(operation, "Provider thread snapshot is missing threadId."),
517 );
518 }
519
520 if (snapshotThreadId !== resolvedThreadId) {
521 return Effect.fail(
522 invariantError(
523 operation,
524 `Resolved thread ${resolvedThreadId} does not match provider snapshot thread ${snapshotThreadId}.`,
525 ),
526 );
527 }
528
529 return Effect.void;
530 };
531
515 const rollbackThreadSnapshot = (532 const rollbackThreadSnapshot = (
516 sessionId: string,533 sessionId: string,
517 adapter: ProviderAdapterShape<ProviderAdapterError>,534 adapter: ProviderAdapterShape<ProviderAdapterError>,
...@@ -601,29 +618,30 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -601,29 +618,30 @@ const makeCheckpointService = Effect.gen(function* () {
601 };618 };
602619
603 const ensureRootCheckpointMetadata = (620 const ensureRootCheckpointMetadata = (
604 providerSessionId: string,621 threadId: string,
605 operation: string,622 operation: string,
606 ): Effect.Effect<void, CheckpointServiceError> =>623 ): Effect.Effect<void, CheckpointServiceError> =>
607 checkpointRepository.getCheckpoint({ providerSessionId, turnCount: 0 }).pipe(624 checkpointRepository
608 Effect.flatMap((rootEntry) =>625 .getCheckpoint({ threadId, turnCount: 0 })
609 Option.isSome(rootEntry)626 .pipe(
610 ? Effect.void627 Effect.flatMap((rootEntry) =>
611 : Effect.fail(628 Option.isSome(rootEntry)
612 invariantError(629 ? Effect.void
613 operation,630 : Effect.fail(
614 `Missing root checkpoint metadata for session ${providerSessionId}.`,631 invariantError(
632 operation,
633 `Missing root checkpoint metadata for thread ${threadId}.`,
634 ),
615 ),635 ),
616 ),636 ),
617 ),637 );
618 );
619638
620 const getCheckpointRefOrFail = (639 const getCheckpointRefOrFail = (
621 providerSessionId: string,
622 threadId: string,640 threadId: string,
623 turnCount: number,641 turnCount: number,
624 operation: string,642 operation: string,
625 ): Effect.Effect<string, CheckpointServiceError> =>643 ): Effect.Effect<string, CheckpointServiceError> =>
626 checkpointRepository.getCheckpoint({ providerSessionId, turnCount }).pipe(644 checkpointRepository.getCheckpoint({ threadId, turnCount }).pipe(
627 Effect.mapError((error): CheckpointServiceError => error),645 Effect.mapError((error): CheckpointServiceError => error),
628 Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => {646 Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => {
629 if (Option.isNone(checkpoint)) {647 if (Option.isNone(checkpoint)) {
...@@ -716,17 +734,15 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -716,17 +734,15 @@ const makeCheckpointService = Effect.gen(function* () {
716 }734 }
717735
718 const existingRoot = yield* checkpointRepository.getCheckpoint({736 const existingRoot = yield* checkpointRepository.getCheckpoint({
719 providerSessionId: input.providerSessionId,737 threadId,
720 turnCount: 0,738 turnCount: 0,
721 });739 });
722 if (Option.isSome(existingRoot)) {740 if (Option.isSome(existingRoot)) {
723 const existingRef = existingRoot.value.checkpointRef.trim();741 const existingRef = existingRoot.value.checkpointRef.trim();
724 if (existingRef.length === 0) {742 if (existingRef.length === 0) {
725 return yield* Effect.fail(743 return yield* invariantError(
726 invariantError(744 "CheckpointService.initializeForSession",
727 "CheckpointService.initializeForSession",745 "Existing root checkpoint metadata is missing checkpointRef.",
728 "Existing root checkpoint metadata is missing checkpointRef.",
729 ),
730 );746 );
731 }747 }
732 const exists = yield* store.hasCheckpointRef({748 const exists = yield* store.hasCheckpointRef({
...@@ -734,11 +750,9 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -734,11 +750,9 @@ const makeCheckpointService = Effect.gen(function* () {
734 checkpointRef: existingRef,750 checkpointRef: existingRef,
735 });751 });
736 if (!exists) {752 if (!exists) {
737 return yield* Effect.fail(753 return yield* invariantError(
738 invariantError(754 "CheckpointService.initializeForSession",
739 "CheckpointService.initializeForSession",755 "Root checkpoint metadata exists but filesystem ref is missing.",
740 "Root checkpoint metadata exists but filesystem ref is missing.",
741 ),
742 );756 );
743 }757 }
744 yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd);758 yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd);
...@@ -770,6 +784,15 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -770,6 +784,15 @@ const makeCheckpointService = Effect.gen(function* () {
770 adapter,784 adapter,
771 "CheckpointService.captureCurrentTurn:readThread",785 "CheckpointService.captureCurrentTurn:readThread",
772 );786 );
787 const threadId = yield* resolveThreadIdForSession(
788 input.providerSessionId,
789 "CheckpointService.captureCurrentTurn",
790 );
791 yield* ensureThreadMatchesSnapshot(
792 threadId,
793 snapshot,
794 "CheckpointService.captureCurrentTurn",
795 );
773 if (snapshot.turns.length === 0) {796 if (snapshot.turns.length === 0) {
774 return;797 return;
775 }798 }
...@@ -784,23 +807,18 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -784,23 +807,18 @@ const makeCheckpointService = Effect.gen(function* () {
784 "CheckpointService.captureCurrentTurn",807 "CheckpointService.captureCurrentTurn",
785 );808 );
786809
787 yield* ensureRootCheckpointMetadata(810 yield* ensureRootCheckpointMetadata(threadId, "CheckpointService.captureCurrentTurn");
788 input.providerSessionId,
789 "CheckpointService.captureCurrentTurn",
790 );
791811
792 const existing = yield* checkpointRepository.getCheckpoint({812 const existing = yield* checkpointRepository.getCheckpoint({
793 providerSessionId: input.providerSessionId,813 threadId,
794 turnCount: checkpoint.turnCount,814 turnCount: checkpoint.turnCount,
795 });815 });
796 if (Option.isSome(existing)) {816 if (Option.isSome(existing)) {
797 const checkpointRef = existing.value.checkpointRef.trim();817 const checkpointRef = existing.value.checkpointRef.trim();
798 if (checkpointRef.length === 0) {818 if (checkpointRef.length === 0) {
799 return yield* Effect.fail(819 return yield* invariantError(
800 invariantError(820 "CheckpointService.captureCurrentTurn",
801 "CheckpointService.captureCurrentTurn",821 `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`,
802 `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`,
803 ),
804 );822 );
805 }823 }
806 const exists = yield* store.hasCheckpointRef({824 const exists = yield* store.hasCheckpointRef({
...@@ -808,11 +826,9 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -808,11 +826,9 @@ const makeCheckpointService = Effect.gen(function* () {
808 checkpointRef,826 checkpointRef,
809 });827 });
810 if (!exists) {828 if (!exists) {
811 return yield* Effect.fail(829 return yield* invariantError(
812 invariantError(830 "CheckpointService.captureCurrentTurn",
813 "CheckpointService.captureCurrentTurn",831 `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`,
814 `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`,
815 ),
816 );832 );
817 }833 }
818 return;834 return;
...@@ -821,7 +837,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -821,7 +837,7 @@ const makeCheckpointService = Effect.gen(function* () {
821 yield* captureAndPersistCheckpoint({837 yield* captureAndPersistCheckpoint({
822 providerSessionId: input.providerSessionId,838 providerSessionId: input.providerSessionId,
823 cwd: resolution.checkpointCwd.value,839 cwd: resolution.checkpointCwd.value,
824 threadId: snapshot.threadId,840 threadId,
825 checkpoint,841 checkpoint,
826 });842 });
827 }),843 }),
...@@ -842,31 +858,36 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -842,31 +858,36 @@ const makeCheckpointService = Effect.gen(function* () {
842 adapter,858 adapter,
843 "CheckpointService.listCheckpoints:readThread",859 "CheckpointService.listCheckpoints:readThread",
844 );860 );
861 const threadId = yield* resolveThreadIdForSession(
862 input.sessionId,
863 "CheckpointService.listCheckpoints",
864 );
865 yield* ensureThreadMatchesSnapshot(
866 threadId,
867 snapshot,
868 "CheckpointService.listCheckpoints",
869 );
845870
846 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);871 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
847 if (Option.isNone(resolution.checkpointCwd)) {872 if (Option.isNone(resolution.checkpointCwd)) {
848 return yield* Effect.fail(873 return yield* new CheckpointRepositoryError({
849 new CheckpointRepositoryError({874 cwd: resolution.candidateCwd,
850 cwd: resolution.candidateCwd,875 detail: "Filesystem checkpoints are unavailable for this session.",
851 detail: "Filesystem checkpoints are unavailable for this session.",876 });
852 }),
853 );
854 }877 }
855878
856 const checkpoints = yield* checkpointRepository.listCheckpoints({879 const checkpoints = yield* checkpointRepository.listCheckpoints({
857 providerSessionId: input.sessionId,880 threadId,
858 });881 });
859 if (checkpoints.length === 0) {882 if (checkpoints.length === 0) {
860 return yield* Effect.fail(883 return yield* invariantError(
861 invariantError(884 "CheckpointService.listCheckpoints",
862 "CheckpointService.listCheckpoints",885 `Missing checkpoint metadata for active session ${input.sessionId}.`,
863 `Missing checkpoint metadata for active session ${input.sessionId}.`,
864 ),
865 );886 );
866 }887 }
867888
868 return {889 return {
869 threadId: snapshot.threadId,890 threadId,
870 checkpoints: Array.from(checkpoints),891 checkpoints: Array.from(checkpoints),
871 };892 };
872 }),893 }),
...@@ -887,35 +908,38 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -887,35 +908,38 @@ const makeCheckpointService = Effect.gen(function* () {
887 adapter,908 adapter,
888 "CheckpointService.getCheckpointDiff:readThread",909 "CheckpointService.getCheckpointDiff:readThread",
889 );910 );
911 const threadId = yield* resolveThreadIdForSession(
912 input.sessionId,
913 "CheckpointService.getCheckpointDiff",
914 );
915 yield* ensureThreadMatchesSnapshot(
916 threadId,
917 snapshot,
918 "CheckpointService.getCheckpointDiff",
919 );
890920
891 if (input.toTurnCount > snapshot.turns.length) {921 if (input.toTurnCount > snapshot.turns.length) {
892 return yield* Effect.fail(922 return yield* validationError(
893 validationError(923 "CheckpointService.getCheckpointDiff",
894 "CheckpointService.getCheckpointDiff",924 `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
895 `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
896 ),
897 );925 );
898 }926 }
899927
900 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);928 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
901 if (Option.isNone(resolution.checkpointCwd)) {929 if (Option.isNone(resolution.checkpointCwd)) {
902 return yield* Effect.fail(930 return yield* new CheckpointRepositoryError({
903 new CheckpointRepositoryError({931 cwd: resolution.candidateCwd,
904 cwd: resolution.candidateCwd,932 detail: "Filesystem checkpoints are unavailable for this session.",
905 detail: "Filesystem checkpoints are unavailable for this session.",933 });
906 }),
907 );
908 }934 }
909935
910 const fromCheckpointRef = yield* getCheckpointRefOrFail(936 const fromCheckpointRef = yield* getCheckpointRefOrFail(
911 input.sessionId,937 threadId,
912 snapshot.threadId,
913 input.fromTurnCount,938 input.fromTurnCount,
914 "CheckpointService.getCheckpointDiff:fromCheckpoint",939 "CheckpointService.getCheckpointDiff:fromCheckpoint",
915 );940 );
916 const toCheckpointRef = yield* getCheckpointRefOrFail(941 const toCheckpointRef = yield* getCheckpointRefOrFail(
917 input.sessionId,942 threadId,
918 snapshot.threadId,
919 input.toTurnCount,943 input.toTurnCount,
920 "CheckpointService.getCheckpointDiff:toCheckpoint",944 "CheckpointService.getCheckpointDiff:toCheckpoint",
921 );945 );
...@@ -928,7 +952,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -928,7 +952,7 @@ const makeCheckpointService = Effect.gen(function* () {
928 });952 });
929953
930 return {954 return {
931 threadId: snapshot.threadId,955 threadId,
932 fromTurnCount: input.fromTurnCount,956 fromTurnCount: input.fromTurnCount,
933 toTurnCount: input.toTurnCount,957 toTurnCount: input.toTurnCount,
934 diff,958 diff,
...@@ -951,31 +975,35 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -951,31 +975,35 @@ const makeCheckpointService = Effect.gen(function* () {
951 adapter,975 adapter,
952 "CheckpointService.revertToCheckpoint:readThread",976 "CheckpointService.revertToCheckpoint:readThread",
953 );977 );
978 const threadId = yield* resolveThreadIdForSession(
979 input.sessionId,
980 "CheckpointService.revertToCheckpoint",
981 );
982 yield* ensureThreadMatchesSnapshot(
983 threadId,
984 beforeSnapshot,
985 "CheckpointService.revertToCheckpoint",
986 );
954 const currentTurnCount = beforeSnapshot.turns.length;987 const currentTurnCount = beforeSnapshot.turns.length;
955988
956 if (input.turnCount > currentTurnCount) {989 if (input.turnCount > currentTurnCount) {
957 return yield* Effect.fail(990 return yield* validationError(
958 validationError(991 "CheckpointService.revertToCheckpoint",
959 "CheckpointService.revertToCheckpoint",992 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
960 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
961 ),
962 );993 );
963 }994 }
964995
965 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);996 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
966 if (Option.isNone(resolution.checkpointCwd)) {997 if (Option.isNone(resolution.checkpointCwd)) {
967 return yield* Effect.fail(998 return yield* new CheckpointRepositoryError({
968 new CheckpointRepositoryError({999 cwd: resolution.candidateCwd,
969 cwd: resolution.candidateCwd,1000 detail: "Filesystem checkpoints are unavailable for this session.",
970 detail: "Filesystem checkpoints are unavailable for this session.",1001 });
971 }),
972 );
973 }1002 }
9741003
975 const checkpointCwd = resolution.checkpointCwd.value;1004 const checkpointCwd = resolution.checkpointCwd.value;
976 const checkpointRef = yield* getCheckpointRefOrFail(1005 const checkpointRef = yield* getCheckpointRefOrFail(
977 input.sessionId,1006 threadId,
978 beforeSnapshot.threadId,
979 input.turnCount,1007 input.turnCount,
980 "CheckpointService.revertToCheckpoint:checkpointLookup",1008 "CheckpointService.revertToCheckpoint:checkpointLookup",
981 );1009 );
...@@ -986,13 +1014,11 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -986,13 +1014,11 @@ const makeCheckpointService = Effect.gen(function* () {
986 fallbackToHead: input.turnCount === 0,1014 fallbackToHead: input.turnCount === 0,
987 });1015 });
988 if (!restored) {1016 if (!restored) {
989 return yield* Effect.fail(1017 return yield* new CheckpointUnavailableError({
990 new CheckpointUnavailableError({1018 threadId,
991 threadId: beforeSnapshot.threadId,1019 turnCount: input.turnCount,
992 turnCount: input.turnCount,1020 detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${threadId}.`,
993 detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,1021 });
994 }),
995 );
996 }1022 }
9971023
998 const requestedRollbackTurns = currentTurnCount - input.turnCount;1024 const requestedRollbackTurns = currentTurnCount - input.turnCount;
...@@ -1015,7 +1041,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -1015,7 +1041,7 @@ const makeCheckpointService = Effect.gen(function* () {
1015 staleTurnCounts,1041 staleTurnCounts,
1016 (turnCount) =>1042 (turnCount) =>
1017 checkpointRepository.getCheckpoint({1043 checkpointRepository.getCheckpoint({
1018 providerSessionId: input.sessionId,1044 threadId,
1019 turnCount,1045 turnCount,
1020 }),1046 }),
1021 { discard: false },1047 { discard: false },
...@@ -1034,11 +1060,11 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -1034,11 +1060,11 @@ const makeCheckpointService = Effect.gen(function* () {
1034 checkpointRefs: staleCheckpointRefs,1060 checkpointRefs: staleCheckpointRefs,
1035 });1061 });
1036 yield* checkpointRepository.deleteAfterTurn({1062 yield* checkpointRepository.deleteAfterTurn({
1037 providerSessionId: input.sessionId,1063 threadId,
1038 maxTurnCount: input.turnCount,1064 maxTurnCount: input.turnCount,
1039 });1065 });
1040 const checkpoints = yield* checkpointRepository.listCheckpoints({1066 const checkpoints = yield* checkpointRepository.listCheckpoints({
1041 providerSessionId: input.sessionId,1067 threadId,
1042 });1068 });
1043 const currentCheckpoint =1069 const currentCheckpoint =
1044 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??1070 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
...@@ -1047,7 +1073,7 @@ const makeCheckpointService = Effect.gen(function* () {...@@ -1047,7 +1073,7 @@ const makeCheckpointService = Effect.gen(function* () {
1047 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);1073 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);
10481074
1049 return {1075 return {
1050 threadId: afterSnapshot.threadId,1076 threadId,
1051 turnCount: currentCheckpoint?.turnCount ?? 0,1077 turnCount: currentCheckpoint?.turnCount ?? 0,
1052 messageCount: currentCheckpoint?.messageCount ?? 0,1078 messageCount: currentCheckpoint?.messageCount ?? 0,
1053 rolledBackTurns,1079 rolledBackTurns,
apps/server/src/index.ts+2
...@@ -11,6 +11,8 @@ import { createServer } from "./wsServer";...@@ -11,6 +11,8 @@ import { createServer } from "./wsServer";
11fixPath();11fixPath();
1212
13const DEFAULT_PORT = 3773;13const DEFAULT_PORT = 3773;
14// Dummy comment for a no-op change request.
15// Additional dummy comment requested in chat.
14const cwd = process.cwd();16const cwd = process.cwd();
15const logger = createLogger("server");17const logger = createLogger("server");
1618
apps/server/src/persistence/Layers/Checkpoints.test.ts+101-68
...@@ -11,72 +11,105 @@ function makeCheckpointRepositoryLayer() {...@@ -11,72 +11,105 @@ function makeCheckpointRepositoryLayer() {
1111
12it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () =>12it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () =>
13 Effect.gen(function* () {13 Effect.gen(function* () {
14 yield* Effect.gen(function* () {14 const repository = yield* CheckpointRepository;
15 const repository = yield* CheckpointRepository;15
1616 yield* repository.upsertCheckpoint({
17 yield* repository.upsertCheckpoint({17 providerSessionId: "sess-1",
18 providerSessionId: "sess-1",18 threadId: "thread-1",
19 threadId: "thread-1",19 checkpointId: "root",
20 checkpointId: "root",20 checkpointRef: "refs/t3/checkpoints/thread-1/turn/0",
21 checkpointRef: "refs/t3/checkpoints/thread-1/turn/0",21 turnCount: 0,
22 turnCount: 0,22 messageCount: 0,
23 messageCount: 0,23 label: "Start of conversation",
24 label: "Start of conversation",24 createdAt: new Date().toISOString(),
25 createdAt: new Date().toISOString(),25 });
26 });26
2727 yield* repository.upsertCheckpoint({
28 yield* repository.upsertCheckpoint({28 providerSessionId: "sess-1",
29 providerSessionId: "sess-1",29 threadId: "thread-1",
30 threadId: "thread-1",30 checkpointId: "turn-1",
31 checkpointId: "turn-1",31 checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",
32 checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",32 turnCount: 1,
33 turnCount: 1,33 messageCount: 2,
34 messageCount: 2,34 label: "Turn 1",
35 label: "Turn 1",35 preview: "hello",
36 preview: "hello",36 createdAt: new Date().toISOString(),
37 createdAt: new Date().toISOString(),37 });
38 });38
3939 yield* repository.upsertCheckpoint({
40 yield* repository.upsertCheckpoint({40 providerSessionId: "sess-1",
41 providerSessionId: "sess-1",41 threadId: "thread-1",
42 threadId: "thread-1",42 checkpointId: "turn-2",
43 checkpointId: "turn-2",43 checkpointRef: "refs/t3/checkpoints/thread-1/turn/2",
44 checkpointRef: "refs/t3/checkpoints/thread-1/turn/2",44 turnCount: 2,
45 turnCount: 2,45 messageCount: 4,
46 messageCount: 4,46 label: "Turn 2",
47 label: "Turn 2",47 preview: "done",
48 preview: "done",48 createdAt: new Date().toISOString(),
49 createdAt: new Date().toISOString(),49 });
50 });50
5151 const checkpoints = yield* repository.listCheckpoints({ threadId: "thread-1" });
52 const checkpoints = yield* repository.listCheckpoints({ providerSessionId: "sess-1" });52 assert.equal(checkpoints.length, 3);
53 assert.equal(checkpoints.length, 3);53 assert.equal(checkpoints[0]?.id, "root");
54 assert.equal(checkpoints[0]?.id, "root");54 assert.equal(checkpoints[0]?.isCurrent, false);
55 assert.equal(checkpoints[0]?.isCurrent, false);55 assert.equal(checkpoints[2]?.id, "turn-2");
56 assert.equal(checkpoints[2]?.id, "turn-2");56 assert.equal(checkpoints[2]?.isCurrent, true);
57 assert.equal(checkpoints[2]?.isCurrent, true);57
5858 const one = yield* repository.getCheckpoint({ threadId: "thread-1", turnCount: 1 });
59 const one = yield* repository.getCheckpoint({ providerSessionId: "sess-1", turnCount: 1 });59 assert.equal(Option.isSome(one), true);
60 assert.equal(Option.isSome(one), true);60 if (Option.isSome(one)) {
61 if (Option.isSome(one)) {61 assert.equal(one.value.id, "turn-1");
62 assert.equal(one.value.id, "turn-1");62 assert.equal(one.value.isCurrent, false);
63 assert.equal(one.value.isCurrent, false);63 assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1");
64 assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1");64 }
65 }65
6666 yield* repository.upsertCheckpoint({
67 yield* repository.deleteAfterTurn({67 providerSessionId: "sess-2",
68 providerSessionId: "sess-1",68 threadId: "thread-1",
69 maxTurnCount: 1,69 checkpointId: "turn-3",
70 });70 checkpointRef: "refs/t3/checkpoints/thread-1/turn/3",
7171 turnCount: 3,
72 const pruned = yield* repository.listCheckpoints({ providerSessionId: "sess-1" });72 messageCount: 6,
73 assert.equal(pruned.length, 2);73 label: "Turn 3",
74 assert.equal(pruned[1]?.turnCount, 1);74 preview: "new session",
75 assert.equal(pruned[1]?.isCurrent, true);75 createdAt: new Date().toISOString(),
7676 });
77 yield* repository.deleteAllForSession({ providerSessionId: "sess-1" });77
78 const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" });78 const resumed = yield* repository.listCheckpoints({ threadId: "thread-1" });
79 assert.equal(empty.length, 0);79 assert.equal(resumed.length, 4);
80 }).pipe(Effect.provide(makeCheckpointRepositoryLayer()));80 assert.equal(resumed[3]?.id, "turn-3");
81 }),81 assert.equal(resumed[3]?.isCurrent, true);
82
83 yield* repository.upsertCheckpoint({
84 providerSessionId: "sess-3",
85 threadId: "thread-2",
86 checkpointId: "root",
87 checkpointRef: "refs/t3/checkpoints/thread-2/turn/0",
88 turnCount: 0,
89 messageCount: 0,
90 label: "Start of conversation",
91 createdAt: new Date().toISOString(),
92 });
93
94 const otherThread = yield* repository.listCheckpoints({ threadId: "thread-2" });
95 assert.equal(otherThread.length, 1);
96 assert.equal(otherThread[0]?.id, "root");
97
98 yield* repository.deleteAfterTurn({
99 threadId: "thread-1",
100 maxTurnCount: 1,
101 });
102
103 const pruned = yield* repository.listCheckpoints({ threadId: "thread-1" });
104 assert.equal(pruned.length, 2);
105 assert.equal(pruned[1]?.turnCount, 1);
106 assert.equal(pruned[1]?.isCurrent, true);
107
108 yield* repository.deleteAllForThread({ threadId: "thread-1" });
109 const empty = yield* repository.listCheckpoints({ threadId: "thread-1" });
110 assert.equal(empty.length, 0);
111
112 const stillPresent = yield* repository.listCheckpoints({ threadId: "thread-2" });
113 assert.equal(stillPresent.length, 1);
114 }).pipe(Effect.provide(makeCheckpointRepositoryLayer())),
82);115);
apps/server/src/persistence/Layers/Checkpoints.ts+29-28
...@@ -29,7 +29,7 @@ import {...@@ -29,7 +29,7 @@ import {
29const NonEmptyString = Schema.NonEmptyString;29const NonEmptyString = Schema.NonEmptyString;
3030
31const CheckpointRowSchema = Schema.Struct({31const CheckpointRowSchema = Schema.Struct({
32 providerSessionId: Schema.String,32 providerSessionId: Schema.NullOr(Schema.String),
33 threadId: Schema.String,33 threadId: Schema.String,
34 checkpointId: Schema.String,34 checkpointId: Schema.String,
35 checkpointRef: Schema.String,35 checkpointRef: Schema.String,
...@@ -40,12 +40,12 @@ const CheckpointRowSchema = Schema.Struct({...@@ -40,12 +40,12 @@ const CheckpointRowSchema = Schema.Struct({
40 createdAt: Schema.String,40 createdAt: Schema.String,
41});41});
4242
43const SessionRequestSchema = Schema.Struct({43const ThreadRequestSchema = Schema.Struct({
44 providerSessionId: NonEmptyString,44 threadId: NonEmptyString,
45});45});
4646
47const UpsertRequestSchema = Schema.Struct({47const UpsertRequestSchema = Schema.Struct({
48 providerSessionId: NonEmptyString,48 providerSessionId: Schema.NullOr(NonEmptyString),
49 threadId: NonEmptyString,49 threadId: NonEmptyString,
50 checkpointId: NonEmptyString,50 checkpointId: NonEmptyString,
51 checkpointRef: NonEmptyString,51 checkpointRef: NonEmptyString,
...@@ -57,12 +57,12 @@ const UpsertRequestSchema = Schema.Struct({...@@ -57,12 +57,12 @@ const UpsertRequestSchema = Schema.Struct({
57});57});
5858
59const GetCheckpointRequestSchema = Schema.Struct({59const GetCheckpointRequestSchema = Schema.Struct({
60 providerSessionId: NonEmptyString,60 threadId: NonEmptyString,
61 turnCount: Schema.Int,61 turnCount: Schema.Int,
62});62});
6363
64const DeleteAfterTurnRequestSchema = Schema.Struct({64const DeleteAfterTurnRequestSchema = Schema.Struct({
65 providerSessionId: NonEmptyString,65 threadId: NonEmptyString,
66 maxTurnCount: Schema.Int,66 maxTurnCount: Schema.Int,
67});67});
6868
...@@ -176,8 +176,9 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -176,8 +176,9 @@ const makeCheckpointRepository = Effect.gen(function* () {
176 ${request.preview},176 ${request.preview},
177 ${request.createdAt}177 ${request.createdAt}
178 )178 )
179 ON CONFLICT (provider_session_id, turn_count)179 ON CONFLICT (thread_id, turn_count)
180 DO UPDATE SET180 DO UPDATE SET
181 provider_session_id = excluded.provider_session_id,
181 thread_id = excluded.thread_id,182 thread_id = excluded.thread_id,
182 checkpoint_id = excluded.checkpoint_id,183 checkpoint_id = excluded.checkpoint_id,
183 checkpoint_ref = excluded.checkpoint_ref,184 checkpoint_ref = excluded.checkpoint_ref,
...@@ -199,9 +200,9 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -199,9 +200,9 @@ const makeCheckpointRepository = Effect.gen(function* () {
199 });200 });
200201
201 const listCheckpointRows = SqlSchema.findAll({202 const listCheckpointRows = SqlSchema.findAll({
202 Request: SessionRequestSchema,203 Request: ThreadRequestSchema,
203 Result: CheckpointRowSchema,204 Result: CheckpointRowSchema,
204 execute: ({ providerSessionId }) =>205 execute: ({ threadId }) =>
205 sql`206 sql`
206 SELECT207 SELECT
207 provider_session_id AS "providerSessionId",208 provider_session_id AS "providerSessionId",
...@@ -214,15 +215,15 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -214,15 +215,15 @@ const makeCheckpointRepository = Effect.gen(function* () {
214 preview,215 preview,
215 created_at AS "createdAt"216 created_at AS "createdAt"
216 FROM provider_checkpoints217 FROM provider_checkpoints
217 WHERE provider_session_id = ${providerSessionId}218 WHERE thread_id = ${threadId}
218 ORDER BY turn_count ASC219 ORDER BY turn_count ASC
219 `,220 `,
220 });221 });
221222
222 const listForSession = (223 const listForThread = (
223 providerSessionId: string,224 threadId: string,
224 ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> =>225 ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> =>
225 listCheckpointRows({ providerSessionId }).pipe(226 listCheckpointRows({ threadId }).pipe(
226 Effect.mapError((cause) =>227 Effect.mapError((cause) =>
227 toPersistenceError("CheckpointRepository.listCheckpoints:query", cause),228 toPersistenceError("CheckpointRepository.listCheckpoints:query", cause),
228 ),229 ),
...@@ -238,7 +239,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -238,7 +239,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
238 const parsed = yield* decodeInput(239 const parsed = yield* decodeInput(
239 UpsertRequestSchema,240 UpsertRequestSchema,
240 {241 {
241 providerSessionId: input.providerSessionId.trim(),242 providerSessionId: input.providerSessionId?.trim() || null,
242 threadId: input.threadId.trim(),243 threadId: input.threadId.trim(),
243 checkpointId: input.checkpointId.trim(),244 checkpointId: input.checkpointId.trim(),
244 checkpointRef: input.checkpointRef.trim(),245 checkpointRef: input.checkpointRef.trim(),
...@@ -264,14 +265,14 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -264,14 +265,14 @@ const makeCheckpointRepository = Effect.gen(function* () {
264 const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) =>265 const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) =>
265 Effect.gen(function* () {266 Effect.gen(function* () {
266 const parsed = yield* decodeInput(267 const parsed = yield* decodeInput(
267 SessionRequestSchema,268 ThreadRequestSchema,
268 {269 {
269 providerSessionId: input.providerSessionId.trim(),270 threadId: input.threadId.trim(),
270 },271 },
271 "CheckpointRepository.listCheckpoints",272 "CheckpointRepository.listCheckpoints",
272 );273 );
273274
274 const entries = yield* listForSession(parsed.providerSessionId);275 const entries = yield* listForThread(parsed.threadId);
275 return entries.map(toProviderCheckpoint);276 return entries.map(toProviderCheckpoint);
276 });277 });
277278
...@@ -280,7 +281,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -280,7 +281,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
280 const parsed = yield* decodeInput(281 const parsed = yield* decodeInput(
281 GetCheckpointRequestSchema,282 GetCheckpointRequestSchema,
282 {283 {
283 providerSessionId: input.providerSessionId.trim(),284 threadId: input.threadId.trim(),
284 turnCount: input.turnCount,285 turnCount: input.turnCount,
285 },286 },
286 "CheckpointRepository.getCheckpoint",287 "CheckpointRepository.getCheckpoint",
...@@ -291,7 +292,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -291,7 +292,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
291 parsed.turnCount,292 parsed.turnCount,
292 );293 );
293294
294 const entries = yield* listForSession(parsed.providerSessionId);295 const entries = yield* listForThread(parsed.threadId);
295 const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount);296 const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount);
296 return Option.fromNullishOr(checkpoint);297 return Option.fromNullishOr(checkpoint);
297 });298 });
...@@ -301,7 +302,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -301,7 +302,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
301 const parsed = yield* decodeInput(302 const parsed = yield* decodeInput(
302 DeleteAfterTurnRequestSchema,303 DeleteAfterTurnRequestSchema,
303 {304 {
304 providerSessionId: input.providerSessionId.trim(),305 threadId: input.threadId.trim(),
305 maxTurnCount: input.maxTurnCount,306 maxTurnCount: input.maxTurnCount,
306 },307 },
307 "CheckpointRepository.deleteAfterTurn",308 "CheckpointRepository.deleteAfterTurn",
...@@ -314,7 +315,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -314,7 +315,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
314315
315 yield* sql`316 yield* sql`
316 DELETE FROM provider_checkpoints317 DELETE FROM provider_checkpoints
317 WHERE provider_session_id = ${parsed.providerSessionId}318 WHERE thread_id = ${parsed.threadId}
318 AND turn_count > ${parsed.maxTurnCount}319 AND turn_count > ${parsed.maxTurnCount}
319 `.pipe(320 `.pipe(
320 Effect.mapError((cause) =>321 Effect.mapError((cause) =>
...@@ -323,22 +324,22 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -323,22 +324,22 @@ const makeCheckpointRepository = Effect.gen(function* () {
323 );324 );
324 });325 });
325326
326 const deleteAllForSession: CheckpointRepositoryShape["deleteAllForSession"] = (input) =>327 const deleteAllForThread: CheckpointRepositoryShape["deleteAllForThread"] = (input) =>
327 Effect.gen(function* () {328 Effect.gen(function* () {
328 const parsed = yield* decodeInput(329 const parsed = yield* decodeInput(
329 SessionRequestSchema,330 ThreadRequestSchema,
330 {331 {
331 providerSessionId: input.providerSessionId.trim(),332 threadId: input.threadId.trim(),
332 },333 },
333 "CheckpointRepository.deleteAllForSession",334 "CheckpointRepository.deleteAllForThread",
334 );335 );
335336
336 yield* sql`337 yield* sql`
337 DELETE FROM provider_checkpoints338 DELETE FROM provider_checkpoints
338 WHERE provider_session_id = ${parsed.providerSessionId}339 WHERE thread_id = ${parsed.threadId}
339 `.pipe(340 `.pipe(
340 Effect.mapError((cause) =>341 Effect.mapError((cause) =>
341 toPersistenceError("CheckpointRepository.deleteAllForSession:query", cause),342 toPersistenceError("CheckpointRepository.deleteAllForThread:query", cause),
342 ),343 ),
343 );344 );
344 });345 });
...@@ -348,7 +349,7 @@ const makeCheckpointRepository = Effect.gen(function* () {...@@ -348,7 +349,7 @@ const makeCheckpointRepository = Effect.gen(function* () {
348 listCheckpoints,349 listCheckpoints,
349 getCheckpoint,350 getCheckpoint,
350 deleteAfterTurn,351 deleteAfterTurn,
351 deleteAllForSession,352 deleteAllForThread,
352 } satisfies CheckpointRepositoryShape;353 } satisfies CheckpointRepositoryShape;
353});354});
354355
apps/server/src/persistence/Layers/Projects.ts+1-1
...@@ -333,7 +333,7 @@ const makeRepository = Effect.gen(function* () {...@@ -333,7 +333,7 @@ const makeRepository = Effect.gen(function* () {
333 const input = yield* decodeAddInput(rawInput);333 const input = yield* decodeAddInput(rawInput);
334 const normalizedCwd = normalizeCwd(input.cwd);334 const normalizedCwd = normalizeCwd(input.cwd);
335 if (!isDirectory(normalizedCwd)) {335 if (!isDirectory(normalizedCwd)) {
336 return yield* Effect.fail(new ProjectPathMissingError({ cwd: normalizedCwd }));336 return yield* new ProjectPathMissingError({ cwd: normalizedCwd });
337 }337 }
338338
339 const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(339 const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(
apps/server/src/persistence/Layers/Sqlite.ts+33-4
...@@ -1,11 +1,34 @@...@@ -1,11 +1,34 @@
1import { Effect, Layer, FileSystem, Path } from "effect";1import { Effect, Layer, FileSystem, Path } from "effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
23
3import { runMigrations } from "../Migrations.ts";4import { runMigrations } from "../Migrations.ts";
4import * as SqliteClient from "../SqliteClient.ts";5
6type RuntimeSqliteLayerConfig = {
7 readonly filename: string;
8 readonly disableWAL?: boolean | undefined;
9};
10
11type Loader = {
12 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>;
13};
14const defaultSqliteClientLoaders = {
15 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
16 node: () => import("../NodeSqliteClient.ts"),
17} satisfies Record<string, () => Promise<Loader>>;
18
19const makeRuntimeSqliteLayer = (
20 config: RuntimeSqliteLayerConfig,
21): Layer.Layer<SqlClient.SqlClient> =>
22 Effect.gen(function* () {
23 const runtime = process.versions.bun !== undefined ? "bun" : "node";
24 const loader = defaultSqliteClientLoaders[runtime];
25 const clientModule = yield* Effect.promise<Loader>(loader);
26 return clientModule.layer(config);
27 }).pipe(Layer.unwrap);
528
6const setup = Layer.effectDiscard(29const setup = Layer.effectDiscard(
7 Effect.gen(function* () {30 Effect.gen(function* () {
8 const sql = yield* SqliteClient.SqliteClient;31 const sql = yield* SqlClient.SqlClient;
9 yield* sql`PRAGMA journal_mode = WAL;`;32 yield* sql`PRAGMA journal_mode = WAL;`;
10 yield* sql`PRAGMA foreign_keys = ON;`;33 yield* sql`PRAGMA foreign_keys = ON;`;
11 yield* runMigrations;34 yield* runMigrations;
...@@ -18,7 +41,13 @@ export const makeSqlitePersistenceLive = (dbPath: string) =>...@@ -18,7 +41,13 @@ export const makeSqlitePersistenceLive = (dbPath: string) =>
18 const path = yield* Path.Path;41 const path = yield* Path.Path;
19 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });42 yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
2043
21 return Layer.provideMerge(setup, SqliteClient.layer({ filename: dbPath }));44 return Layer.provideMerge(setup, makeRuntimeSqliteLayer({ filename: dbPath }));
22 }).pipe(Layer.unwrap);45 }).pipe(Layer.unwrap);
2346
24export const SqlitePersistenceMemory = Layer.provideMerge(setup, SqliteClient.layerMemory());47export const SqlitePersistenceMemory = Layer.provideMerge(
48 setup,
49 makeRuntimeSqliteLayer({
50 filename: ":memory:",
51 disableWAL: true,
52 }),
53);
apps/server/src/persistence/Migrations/003_Checkpoints.ts+9-4
...@@ -6,7 +6,7 @@ export default Effect.gen(function* () {...@@ -6,7 +6,7 @@ export default Effect.gen(function* () {
66
7 yield* sql`7 yield* sql`
8 CREATE TABLE IF NOT EXISTS provider_checkpoints (8 CREATE TABLE IF NOT EXISTS provider_checkpoints (
9 provider_session_id TEXT NOT NULL,9 provider_session_id TEXT,
10 thread_id TEXT NOT NULL,10 thread_id TEXT NOT NULL,
11 checkpoint_id TEXT NOT NULL,11 checkpoint_id TEXT NOT NULL,
12 checkpoint_ref TEXT NOT NULL,12 checkpoint_ref TEXT NOT NULL,
...@@ -15,12 +15,17 @@ export default Effect.gen(function* () {...@@ -15,12 +15,17 @@ export default Effect.gen(function* () {
15 label TEXT NOT NULL,15 label TEXT NOT NULL,
16 preview TEXT,16 preview TEXT,
17 created_at TEXT NOT NULL,17 created_at TEXT NOT NULL,
18 PRIMARY KEY (provider_session_id, turn_count)18 PRIMARY KEY (thread_id, turn_count)
19 )19 )
20 `;20 `;
2121
22 yield* sql`22 yield* sql`
23 CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session_turn23 CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_thread_turn
24 ON provider_checkpoints(provider_session_id, turn_count ASC)24 ON provider_checkpoints(thread_id, turn_count ASC)
25 `;
26
27 yield* sql`
28 CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session
29 ON provider_checkpoints(provider_session_id)
25 `;30 `;
26});31});
apps/server/src/persistence/NodeSqliteClient.test.ts created+26
...@@ -0,0 +1,26 @@
1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import * as SqliteClient from "./NodeSqliteClient.ts";
6
7it.effect("runs prepared queries and returns positional values", () =>
8 Effect.gen(function* () {
9 const sql = yield* SqlClient.SqlClient;
10
11 yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
12 yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;
13
14 const rows = yield* sql<{ readonly id: number; readonly name: string }>`
15 SELECT id, name FROM entries ORDER BY id
16 `;
17 assert.equal(rows.length, 2);
18 assert.equal(rows[0]?.name, "alpha");
19 assert.equal(rows[1]?.name, "beta");
20
21 const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
22 assert.equal(values.length, 2);
23 assert.equal(values[0]?.[1], "alpha");
24 assert.equal(values[1]?.[1], "beta");
25 }).pipe(Effect.provide(SqliteClient.layerMemory())),
26);
apps/server/src/persistence/NodeSqliteClient.ts created+242
...@@ -0,0 +1,242 @@
1/**
2 * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite`
3 * bindings instead of `better-sqlite3`.
4 */
5import { DatabaseSync, type StatementSync } from "node:sqlite";
6
7import * as Cache from "effect/Cache";
8import * as Config from "effect/Config";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import { identity } from "effect/Function";
13import * as Layer from "effect/Layer";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as ServiceMap from "effect/ServiceMap";
17import * as Stream from "effect/Stream";
18import * as Reactivity from "effect/unstable/reactivity/Reactivity";
19import * as Client from "effect/unstable/sql/SqlClient";
20import type { Connection } from "effect/unstable/sql/SqlConnection";
21import { SqlError } from "effect/unstable/sql/SqlError";
22import * as Statement from "effect/unstable/sql/Statement";
23
24const ATTR_DB_SYSTEM_NAME = "db.system.name";
25
26export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
27
28export type TypeId = "~local/sqlite-node/SqliteClient";
29
30export const SqliteClient = ServiceMap.Service<Client.SqlClient>("@local/sqlite-node/SqliteClient");
31
32export interface SqliteClientConfig {
33 readonly filename: string;
34 readonly readonly?: boolean | undefined;
35 readonly allowExtension?: boolean | undefined;
36 readonly prepareCacheSize?: number | undefined;
37 readonly prepareCacheTTL?: Duration.Input | undefined;
38 readonly disableWAL?: boolean | undefined;
39 readonly spanAttributes?: Record<string, unknown> | undefined;
40 readonly transformResultNames?: ((str: string) => string) | undefined;
41 readonly transformQueryNames?: ((str: string) => string) | undefined;
42}
43
44export interface SqliteMemoryClientConfig extends Omit<
45 SqliteClientConfig,
46 "filename" | "readonly"
47> {}
48
49const makeWithDatabase = (
50 options: SqliteClientConfig,
51 openDatabase: () => DatabaseSync,
52): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
53 Effect.gen(function* () {
54 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
55 const transformRows = options.transformResultNames
56 ? Statement.defaultTransforms(options.transformResultNames).array
57 : undefined;
58
59 const makeConnection = Effect.gen(function* () {
60 const scope = yield* Effect.scope;
61 const db = openDatabase();
62 yield* Scope.addFinalizer(
63 scope,
64 Effect.sync(() => db.close()),
65 );
66
67 if (options.disableWAL !== true) {
68 db.exec("PRAGMA journal_mode = WAL");
69 }
70
71 const statementReaderCache = new WeakMap<StatementSync, boolean>();
72 const hasRows = (statement: StatementSync): boolean => {
73 const cached = statementReaderCache.get(statement);
74 if (cached !== undefined) {
75 return cached;
76 }
77 const value = statement.columns().length > 0;
78 statementReaderCache.set(statement, value);
79 return value;
80 };
81
82 const prepareCache = yield* Cache.make({
83 capacity: options.prepareCacheSize ?? 200,
84 timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),
85 lookup: (sql: string) =>
86 Effect.try({
87 try: () => db.prepare(sql),
88 catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }),
89 }),
90 });
91
92 const runStatement = (
93 statement: StatementSync,
94 params: ReadonlyArray<unknown>,
95 raw: boolean,
96 ) =>
97 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
98 statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers)));
99 try {
100 if (hasRows(statement)) {
101 return Effect.succeed(statement.all(...(params as any)));
102 }
103 const result = statement.run(...(params as any));
104 return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []);
105 } catch (cause) {
106 return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" }));
107 }
108 });
109
110 const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) =>
111 Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw));
112
113 const runValues = (sql: string, params: ReadonlyArray<unknown>) =>
114 Effect.acquireUseRelease(
115 Cache.get(prepareCache, sql),
116 (statement) =>
117 Effect.try({
118 try: () => {
119 if (hasRows(statement)) {
120 statement.setReturnArrays(true);
121 // Safe to cast to array after we've setReturnArrays(true)
122 return statement.all(...(params as any)) as unknown as ReadonlyArray<
123 ReadonlyArray<unknown>
124 >;
125 }
126 statement.run(...(params as any));
127 return [];
128 },
129 catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }),
130 }),
131 (statement) =>
132 Effect.sync(() => {
133 if (hasRows(statement)) {
134 statement.setReturnArrays(false);
135 }
136 }),
137 );
138
139 return identity<Connection>({
140 execute(sql, params, rowTransform) {
141 return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params);
142 },
143 executeRaw(sql, params) {
144 return run(sql, params, true);
145 },
146 executeValues(sql, params) {
147 return runValues(sql, params);
148 },
149 executeUnprepared(sql, params, rowTransform) {
150 const effect = runStatement(db.prepare(sql), params ?? [], false);
151 return rowTransform ? Effect.map(effect, rowTransform) : effect;
152 },
153 executeStream(_sql, _params) {
154 return Stream.die("executeStream not implemented");
155 },
156 });
157 });
158
159 const semaphore = yield* Semaphore.make(1);
160 const connection = yield* makeConnection;
161
162 const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
163 const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
164 const fiber = Fiber.getCurrent()!;
165 const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope);
166 return Effect.as(
167 Effect.tap(restore(semaphore.take(1)), () =>
168 Scope.addFinalizer(scope, semaphore.release(1)),
169 ),
170 connection,
171 );
172 });
173
174 return yield* Client.make({
175 acquirer,
176 compiler,
177 transactionAcquirer,
178 spanAttributes: [
179 ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
180 [ATTR_DB_SYSTEM_NAME, "sqlite"],
181 ],
182 transformRows,
183 });
184 });
185
186const make = (
187 options: SqliteClientConfig,
188): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
189 makeWithDatabase(
190 options,
191 () =>
192 new DatabaseSync(options.filename, {
193 readOnly: options.readonly ?? false,
194 allowExtension: options.allowExtension ?? false,
195 }),
196 );
197
198const makeMemory = (
199 config: SqliteMemoryClientConfig = {},
200): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
201 makeWithDatabase(
202 {
203 ...config,
204 filename: ":memory:",
205 readonly: false,
206 disableWAL: config.disableWAL ?? true,
207 },
208 () => {
209 const database = new DatabaseSync(":memory:", {
210 allowExtension: config.allowExtension ?? false,
211 });
212 return database;
213 },
214 );
215
216export const layerConfig = (
217 config: Config.Wrap<SqliteClientConfig>,
218): Layer.Layer<Client.SqlClient, Config.ConfigError> =>
219 Layer.effectServices(
220 Config.unwrap(config)
221 .asEffect()
222 .pipe(
223 Effect.flatMap(make),
224 Effect.map((client) =>
225 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
226 ),
227 ),
228 ).pipe(Layer.provide(Reactivity.layer));
229
230export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> =>
231 Layer.effectServices(
232 Effect.map(make(config), (client) =>
233 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
234 ),
235 ).pipe(Layer.provide(Reactivity.layer));
236
237export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> =>
238 Layer.effectServices(
239 Effect.map(makeMemory(config), (client) =>
240 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
241 ),
242 ).pipe(Layer.provide(Reactivity.layer));
apps/server/src/persistence/Services/Checkpoints.ts+13-13
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2 * CheckpointRepository - Repository interface for checkpoint metadata persistence.2 * CheckpointRepository - Repository interface for checkpoint metadata persistence.
3 *3 *
4 * Stores and queries checkpoint metadata and checkpoint Git refs keyed by4 * Stores and queries checkpoint metadata and checkpoint Git refs keyed by
5 * provider session + turn count. It does not read/write Git refs itself and it5 * thread + turn count. It does not read/write Git refs itself and it
6 * does not coordinate provider rollback workflows.6 * does not coordinate provider rollback workflows.
7 *7 *
8 * @module CheckpointRepository8 * @module CheckpointRepository
...@@ -14,7 +14,7 @@ import type { Effect } from "effect";...@@ -14,7 +14,7 @@ import type { Effect } from "effect";
14import type { CheckpointRepositoryError } from "../Errors.ts";14import type { CheckpointRepositoryError } from "../Errors.ts";
1515
16export interface UpsertCheckpointInput {16export interface UpsertCheckpointInput {
17 readonly providerSessionId: string;17 readonly providerSessionId?: string;
18 readonly threadId: string;18 readonly threadId: string;
19 readonly checkpointId: string;19 readonly checkpointId: string;
20 readonly checkpointRef: string;20 readonly checkpointRef: string;
...@@ -31,22 +31,22 @@ export interface CheckpointRepositoryEntry extends ProviderCheckpoint {...@@ -31,22 +31,22 @@ export interface CheckpointRepositoryEntry extends ProviderCheckpoint {
31 readonly createdAt: string;31 readonly createdAt: string;
32}32}
3333
34export interface ListSessionCheckpointsInput {34export interface ListThreadCheckpointsInput {
35 readonly providerSessionId: string;35 readonly threadId: string;
36}36}
3737
38export interface GetCheckpointInput {38export interface GetCheckpointInput {
39 readonly providerSessionId: string;39 readonly threadId: string;
40 readonly turnCount: number;40 readonly turnCount: number;
41}41}
4242
43export interface DeleteAfterTurnInput {43export interface DeleteAfterTurnInput {
44 readonly providerSessionId: string;44 readonly threadId: string;
45 readonly maxTurnCount: number;45 readonly maxTurnCount: number;
46}46}
4747
48export interface DeleteAllForSessionInput {48export interface DeleteAllForThreadInput {
49 readonly providerSessionId: string;49 readonly threadId: string;
50}50}
5151
52export interface CheckpointRepositoryShape {52export interface CheckpointRepositoryShape {
...@@ -58,10 +58,10 @@ export interface CheckpointRepositoryShape {...@@ -58,10 +58,10 @@ export interface CheckpointRepositoryShape {
58 ) => Effect.Effect<void, CheckpointRepositoryError>;58 ) => Effect.Effect<void, CheckpointRepositoryError>;
5959
60 /**60 /**
61 * List user-facing checkpoints for a provider session.61 * List user-facing checkpoints for a thread.
62 */62 */
63 readonly listCheckpoints: (63 readonly listCheckpoints: (
64 input: ListSessionCheckpointsInput,64 input: ListThreadCheckpointsInput,
65 ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>;65 ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>;
6666
67 /**67 /**
...@@ -79,10 +79,10 @@ export interface CheckpointRepositoryShape {...@@ -79,10 +79,10 @@ export interface CheckpointRepositoryShape {
79 ) => Effect.Effect<void, CheckpointRepositoryError>;79 ) => Effect.Effect<void, CheckpointRepositoryError>;
8080
81 /**81 /**
82 * Delete all checkpoint metadata for a provider session.82 * Delete all checkpoint metadata for a thread.
83 */83 */
84 readonly deleteAllForSession: (84 readonly deleteAllForThread: (
85 input: DeleteAllForSessionInput,85 input: DeleteAllForThreadInput,
86 ) => Effect.Effect<void, CheckpointRepositoryError>;86 ) => Effect.Effect<void, CheckpointRepositoryError>;
87}87}
8888
apps/server/src/persistence/SqliteClient.test.ts deleted-26
...@@ -1,26 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import * as SqliteClient from "./SqliteClient.ts";
6
7it.effect("runs prepared queries and returns positional values", () =>
8 Effect.gen(function* () {
9 const sql = yield* SqlClient.SqlClient;
10
11 yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
12 yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;
13
14 const rows = yield* sql<{ readonly id: number; readonly name: string }>`
15 SELECT id, name FROM entries ORDER BY id
16 `;
17 assert.equal(rows.length, 2);
18 assert.equal(rows[0]?.name, "alpha");
19 assert.equal(rows[1]?.name, "beta");
20
21 const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
22 assert.equal(values.length, 2);
23 assert.equal(values[0]?.[1], "alpha");
24 assert.equal(values[1]?.[1], "beta");
25 }).pipe(Effect.provide(SqliteClient.layerMemory())),
26);
apps/server/src/persistence/SqliteClient.ts deleted-242
...@@ -1,242 +0,0 @@
1/**
2 * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite`
3 * bindings instead of `better-sqlite3`.
4 */
5import { DatabaseSync, type StatementSync } from "node:sqlite";
6
7import * as Cache from "effect/Cache";
8import * as Config from "effect/Config";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import { identity } from "effect/Function";
13import * as Layer from "effect/Layer";
14import * as Scope from "effect/Scope";
15import * as Semaphore from "effect/Semaphore";
16import * as ServiceMap from "effect/ServiceMap";
17import * as Stream from "effect/Stream";
18import * as Reactivity from "effect/unstable/reactivity/Reactivity";
19import * as Client from "effect/unstable/sql/SqlClient";
20import type { Connection } from "effect/unstable/sql/SqlConnection";
21import { SqlError } from "effect/unstable/sql/SqlError";
22import * as Statement from "effect/unstable/sql/Statement";
23
24const ATTR_DB_SYSTEM_NAME = "db.system.name";
25
26export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
27
28export type TypeId = "~local/sqlite-node/SqliteClient";
29
30export const SqliteClient = ServiceMap.Service<Client.SqlClient>("@local/sqlite-node/SqliteClient");
31
32export interface SqliteClientConfig {
33 readonly filename: string;
34 readonly readonly?: boolean | undefined;
35 readonly allowExtension?: boolean | undefined;
36 readonly prepareCacheSize?: number | undefined;
37 readonly prepareCacheTTL?: Duration.Input | undefined;
38 readonly disableWAL?: boolean | undefined;
39 readonly spanAttributes?: Record<string, unknown> | undefined;
40 readonly transformResultNames?: ((str: string) => string) | undefined;
41 readonly transformQueryNames?: ((str: string) => string) | undefined;
42}
43
44export interface SqliteMemoryClientConfig extends Omit<
45 SqliteClientConfig,
46 "filename" | "readonly"
47> {}
48
49const makeWithDatabase = (
50 options: SqliteClientConfig,
51 openDatabase: () => DatabaseSync,
52): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
53 Effect.gen(function* () {
54 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
55 const transformRows = options.transformResultNames
56 ? Statement.defaultTransforms(options.transformResultNames).array
57 : undefined;
58
59 const makeConnection = Effect.gen(function* () {
60 const scope = yield* Effect.scope;
61 const db = openDatabase();
62 yield* Scope.addFinalizer(
63 scope,
64 Effect.sync(() => db.close()),
65 );
66
67 if (options.disableWAL !== true) {
68 db.exec("PRAGMA journal_mode = WAL");
69 }
70
71 const statementReaderCache = new WeakMap<StatementSync, boolean>();
72 const hasRows = (statement: StatementSync): boolean => {
73 const cached = statementReaderCache.get(statement);
74 if (cached !== undefined) {
75 return cached;
76 }
77 const value = statement.columns().length > 0;
78 statementReaderCache.set(statement, value);
79 return value;
80 };
81
82 const prepareCache = yield* Cache.make({
83 capacity: options.prepareCacheSize ?? 200,
84 timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),
85 lookup: (sql: string) =>
86 Effect.try({
87 try: () => db.prepare(sql),
88 catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }),
89 }),
90 });
91
92 const runStatement = (
93 statement: StatementSync,
94 params: ReadonlyArray<unknown>,
95 raw: boolean,
96 ) =>
97 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
98 statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers)));
99 try {
100 if (hasRows(statement)) {
101 return Effect.succeed(statement.all(...(params as any)));
102 }
103 const result = statement.run(...(params as any));
104 return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []);
105 } catch (cause) {
106 return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" }));
107 }
108 });
109
110 const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) =>
111 Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw));
112
113 const runValues = (sql: string, params: ReadonlyArray<unknown>) =>
114 Effect.acquireUseRelease(
115 Cache.get(prepareCache, sql),
116 (statement) =>
117 Effect.try({
118 try: () => {
119 if (hasRows(statement)) {
120 statement.setReturnArrays(true);
121 // Safe to cast to array after we've setReturnArrays(true)
122 return statement.all(...(params as any)) as unknown as ReadonlyArray<
123 ReadonlyArray<unknown>
124 >;
125 }
126 statement.run(...(params as any));
127 return [];
128 },
129 catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }),
130 }),
131 (statement) =>
132 Effect.sync(() => {
133 if (hasRows(statement)) {
134 statement.setReturnArrays(false);
135 }
136 }),
137 );
138
139 return identity<Connection>({
140 execute(sql, params, rowTransform) {
141 return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params);
142 },
143 executeRaw(sql, params) {
144 return run(sql, params, true);
145 },
146 executeValues(sql, params) {
147 return runValues(sql, params);
148 },
149 executeUnprepared(sql, params, rowTransform) {
150 const effect = runStatement(db.prepare(sql), params ?? [], false);
151 return rowTransform ? Effect.map(effect, rowTransform) : effect;
152 },
153 executeStream(_sql, _params) {
154 return Stream.die("executeStream not implemented");
155 },
156 });
157 });
158
159 const semaphore = yield* Semaphore.make(1);
160 const connection = yield* makeConnection;
161
162 const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
163 const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
164 const fiber = Fiber.getCurrent()!;
165 const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope);
166 return Effect.as(
167 Effect.tap(restore(semaphore.take(1)), () =>
168 Scope.addFinalizer(scope, semaphore.release(1)),
169 ),
170 connection,
171 );
172 });
173
174 return yield* Client.make({
175 acquirer,
176 compiler,
177 transactionAcquirer,
178 spanAttributes: [
179 ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
180 [ATTR_DB_SYSTEM_NAME, "sqlite"],
181 ],
182 transformRows,
183 });
184 });
185
186const make = (
187 options: SqliteClientConfig,
188): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
189 makeWithDatabase(
190 options,
191 () =>
192 new DatabaseSync(options.filename, {
193 readOnly: options.readonly ?? false,
194 allowExtension: options.allowExtension ?? false,
195 }),
196 );
197
198const makeMemory = (
199 config: SqliteMemoryClientConfig = {},
200): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
201 makeWithDatabase(
202 {
203 ...config,
204 filename: ":memory:",
205 readonly: false,
206 disableWAL: config.disableWAL ?? true,
207 },
208 () => {
209 const database = new DatabaseSync(":memory:", {
210 allowExtension: config.allowExtension ?? false,
211 });
212 return database;
213 },
214 );
215
216export const layerConfig = (
217 config: Config.Wrap<SqliteClientConfig>,
218): Layer.Layer<Client.SqlClient, Config.ConfigError> =>
219 Layer.effectServices(
220 Config.unwrap(config)
221 .asEffect()
222 .pipe(
223 Effect.flatMap(make),
224 Effect.map((client) =>
225 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
226 ),
227 ),
228 ).pipe(Layer.provide(Reactivity.layer));
229
230export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> =>
231 Layer.effectServices(
232 Effect.map(make(config), (client) =>
233 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
234 ),
235 ).pipe(Layer.provide(Reactivity.layer));
236
237export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> =>
238 Layer.effectServices(
239 Effect.map(makeMemory(config), (client) =>
240 ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)),
241 ),
242 ).pipe(Layer.provide(Reactivity.layer));
apps/server/src/provider/Layers/ProviderService.test.ts+141-3
...@@ -55,7 +55,7 @@ function makeFakeCodexAdapter() {...@@ -55,7 +55,7 @@ function makeFakeCodexAdapter() {
55 sessionId: `sess-${nextSession}`,55 sessionId: `sess-${nextSession}`,
56 provider: "codex",56 provider: "codex",
57 status: "ready",57 status: "ready",
58 threadId: `thread-${nextSession}`,58 threadId: input.resumeThreadId ?? `thread-${nextSession}`,
59 cwd: input.cwd ?? process.cwd(),59 cwd: input.cwd ?? process.cwd(),
60 createdAt: now,60 createdAt: now,
61 updatedAt: now,61 updatedAt: now,
...@@ -281,7 +281,7 @@ function makeProviderServiceLayer() {...@@ -281,7 +281,7 @@ function makeProviderServiceLayer() {
281}281}
282282
283const routing = makeProviderServiceLayer();283const routing = makeProviderServiceLayer();
284it.effect("ProviderServiceLive reconciles stale persisted sessions on startup", () =>284it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>
285 Effect.gen(function* () {285 Effect.gen(function* () {
286 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-"));286 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-"));
287 const dbPath = path.join(tempDir, "orchestration.sqlite");287 const dbPath = path.join(tempDir, "orchestration.sqlite");
...@@ -322,7 +322,100 @@ it.effect("ProviderServiceLive reconciles stale persisted sessions on startup",...@@ -322,7 +322,100 @@ it.effect("ProviderServiceLive reconciles stale persisted sessions on startup",
322 yield* ProviderService;322 yield* ProviderService;
323 }).pipe(Effect.provide(providerLayer));323 }).pipe(Effect.provide(providerLayer));
324324
325 assert.deepEqual(checkpoint.releaseSession.mock.calls, [[{ providerSessionId: "sess-stale" }]]);325 assert.deepEqual(checkpoint.releaseSession.mock.calls, []);
326 const persistedProvider = yield* Effect.gen(function* () {
327 const directory = yield* ProviderSessionDirectory;
328 return yield* directory.getProvider("sess-stale");
329 }).pipe(Effect.provide(directoryLayer));
330 assert.equal(persistedProvider, "codex");
331
332 fs.rmSync(tempDir, { recursive: true, force: true });
333 }).pipe(Effect.provide(NodeServices.layer)),
334);
335
336it.effect("ProviderServiceLive restores checkpoint RPC routing after restart using persisted thread mapping", () =>
337 Effect.gen(function* () {
338 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-"));
339 const dbPath = path.join(tempDir, "orchestration.sqlite");
340 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
341
342 const checkpoint = makeCheckpointServiceDouble();
343 const firstCodex = makeFakeCodexAdapter();
344 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
345 getByProvider: (provider) =>
346 provider === "codex"
347 ? Effect.succeed(firstCodex.adapter)
348 : Effect.fail(new ProviderUnsupportedError({ provider })),
349 listProviders: () => Effect.succeed(["codex"]),
350 };
351
352 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
353 Layer.provide(ProviderSessionRepositoryLive),
354 Layer.provide(persistenceLayer),
355 );
356 const firstProviderLayer = ProviderServiceLive.pipe(
357 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
358 Layer.provide(firstDirectoryLayer),
359 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
360 );
361
362 const startedSession = yield* Effect.gen(function* () {
363 const provider = yield* ProviderService;
364 return yield* provider.startSession({
365 provider: "codex",
366 cwd: "/tmp/project",
367 });
368 }).pipe(Effect.provide(firstProviderLayer));
369
370 yield* Effect.gen(function* () {
371 const provider = yield* ProviderService;
372 yield* provider.stopAll();
373 }).pipe(Effect.provide(firstProviderLayer));
374
375 const secondCodex = makeFakeCodexAdapter();
376 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
377 getByProvider: (provider) =>
378 provider === "codex"
379 ? Effect.succeed(secondCodex.adapter)
380 : Effect.fail(new ProviderUnsupportedError({ provider })),
381 listProviders: () => Effect.succeed(["codex"]),
382 };
383 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
384 Layer.provide(ProviderSessionRepositoryLive),
385 Layer.provide(persistenceLayer),
386 );
387 const secondProviderLayer = ProviderServiceLive.pipe(
388 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
389 Layer.provide(secondDirectoryLayer),
390 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
391 );
392
393 checkpoint.getCheckpointDiff.mockClear();
394 secondCodex.startSession.mockClear();
395
396 yield* Effect.gen(function* () {
397 const provider = yield* ProviderService;
398 yield* provider.getCheckpointDiff({
399 sessionId: startedSession.sessionId,
400 fromTurnCount: 0,
401 toTurnCount: 0,
402 });
403 }).pipe(Effect.provide(secondProviderLayer));
404
405 assert.deepEqual(secondCodex.startSession.mock.calls, [
406 [
407 {
408 provider: "codex",
409 resumeThreadId: startedSession.threadId,
410 },
411 ],
412 ]);
413 assert.equal(checkpoint.getCheckpointDiff.mock.calls.length, 1);
414 const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0];
415 assert.equal(typeof recoveredDiffInput?.sessionId, "string");
416 assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true);
417 assert.equal(recoveredDiffInput?.fromTurnCount, 0);
418 assert.equal(recoveredDiffInput?.toTurnCount, 0);
326419
327 fs.rmSync(tempDir, { recursive: true, force: true });420 fs.rmSync(tempDir, { recursive: true, force: true });
328 }).pipe(Effect.provide(NodeServices.layer)),421 }).pipe(Effect.provide(NodeServices.layer)),
...@@ -403,6 +496,51 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -403,6 +496,51 @@ routing.layer("ProviderServiceLive routing", (it) => {
403 }),496 }),
404 );497 );
405498
499 it.effect("recovers stale persisted sessions for checkpoint RPCs by resuming thread identity", () =>
500 Effect.gen(function* () {
501 const provider = yield* ProviderService;
502
503 const initial = yield* provider.startSession({
504 provider: "codex",
505 cwd: "/tmp/project",
506 });
507 yield* routing.codex.stopSession(initial.sessionId);
508
509 routing.checkpoint.getCheckpointDiff.mockClear();
510 routing.checkpoint.revertToCheckpoint.mockClear();
511 routing.codex.startSession.mockClear();
512
513 yield* provider.getCheckpointDiff({
514 sessionId: initial.sessionId,
515 fromTurnCount: 0,
516 toTurnCount: 0,
517 });
518 yield* provider.revertToCheckpoint({
519 sessionId: initial.sessionId,
520 turnCount: 0,
521 });
522
523 assert.deepEqual(routing.codex.startSession.mock.calls, [
524 [
525 {
526 provider: "codex",
527 resumeThreadId: initial.threadId,
528 },
529 ],
530 ]);
531 assert.equal(routing.checkpoint.getCheckpointDiff.mock.calls.length, 1);
532 assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1);
533
534 const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0];
535 const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0];
536 assert.equal(diffInput?.fromTurnCount, 0);
537 assert.equal(diffInput?.toTurnCount, 0);
538 assert.equal(diffInput?.sessionId === initial.sessionId, false);
539 assert.equal(revertInput?.sessionId, diffInput?.sessionId);
540 assert.equal(revertInput?.turnCount, 0);
541 }),
542 );
543
406 it.effect("releases checkpoint session state when stopping all sessions", () =>544 it.effect("releases checkpoint session state when stopping all sessions", () =>
407 Effect.gen(function* () {545 Effect.gen(function* () {
408 const provider = yield* ProviderService;546 const provider = yield* ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+413-179
...@@ -23,8 +23,9 @@ import {...@@ -23,8 +23,9 @@ import {
23 providerStopSessionInputSchema,23 providerStopSessionInputSchema,
24 type ProviderRuntimeEvent,24 type ProviderRuntimeEvent,
25 type ProviderRuntimeTurnCompletedEvent,25 type ProviderRuntimeTurnCompletedEvent,
26 type ProviderSession,
26} from "@t3tools/contracts";27} from "@t3tools/contracts";
27import { Effect, Layer, PubSub, Queue, Stream } from "effect";28import { Effect, Layer, Option, PubSub, Queue, Ref, Stream } from "effect";
2829
29import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";30import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";
30import { ProviderValidationError } from "../Errors.ts";31import { ProviderValidationError } from "../Errors.ts";
...@@ -97,25 +98,100 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -97,25 +98,100 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
9798
98 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();99 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
99 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();100 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
101 const routedSessionAliasesRef = yield* Ref.make<Map<string, string>>(new Map());
102
103 const canonicalizeRuntimeEventSession = (
104 event: ProviderRuntimeEvent,
105 ): Effect.Effect<ProviderRuntimeEvent> =>
106 Ref.get(routedSessionAliasesRef).pipe(
107 Effect.map((aliases) => {
108 for (const [staleSessionId, liveSessionId] of aliases) {
109 if (liveSessionId === event.sessionId) {
110 return {
111 ...event,
112 sessionId: staleSessionId,
113 } satisfies ProviderRuntimeEvent;
114 }
115 }
116 return event;
117 }),
118 );
100119
101 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>120 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
102 Effect.sync(() => {121 canonicalizeRuntimeEventSession(event).pipe(
103 canonicalEventLogger?.write({122 Effect.tap((canonicalEvent) =>
104 observedAt: new Date().toISOString(),123 Effect.sync(() => {
105 event,124 canonicalEventLogger?.write({
106 });125 observedAt: new Date().toISOString(),
107 }).pipe(126 event: canonicalEvent,
108 Effect.flatMap(() => PubSub.publish(runtimeEventPubSub, event)),127 });
128 }),
129 ),
130 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
109 Effect.asVoid,131 Effect.asVoid,
110 );132 );
111133
112 const providers = yield* registry.listProviders();134 const upsertSessionBinding = (
113 const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider));135 session: ProviderSession,
136 operation: string,
137 ) =>
138 Effect.gen(function* () {
139 const threadId = session.threadId?.trim();
140 if (!threadId) {
141 return yield* toValidationError(
142 operation,
143 `Provider '${session.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`,
144 );
145 }
146
147 yield* directory.upsert({
148 sessionId: session.sessionId,
149 provider: session.provider,
150 threadId,
151 });
114152
115 const staleSessionIds = yield* directory.reconcileWithAdapters(adapters);153 return threadId;
116 yield* Effect.forEach(staleSessionIds, (sessionId) =>154 });
117 checkpointService.releaseSession({ providerSessionId: sessionId }),155
118 ).pipe(Effect.asVoid);156 const clearAliasKey = (staleSessionId: string) =>
157 Ref.update(routedSessionAliasesRef, (current) => {
158 if (!current.has(staleSessionId)) {
159 return current;
160 }
161 const next = new Map(current);
162 next.delete(staleSessionId);
163 return next;
164 });
165
166 const clearAliasesReferencing = (sessionId: string) =>
167 Ref.update(routedSessionAliasesRef, (current) => {
168 let changed = false;
169 const next = new Map<string, string>();
170 for (const [key, value] of current) {
171 if (key === sessionId || value === sessionId) {
172 changed = true;
173 continue;
174 }
175 next.set(key, value);
176 }
177 return changed ? next : current;
178 });
179
180 const setAlias = (staleSessionId: string, liveSessionId: string) =>
181 Ref.update(routedSessionAliasesRef, (current) => {
182 const existing = current.get(staleSessionId);
183 if (existing === liveSessionId) {
184 return current;
185 }
186 const next = new Map(current);
187 next.set(staleSessionId, liveSessionId);
188 return next;
189 });
190
191 const providers = yield* registry.listProviders();
192 const adapters = yield* Effect.forEach(providers, (provider) =>
193 registry.getByProvider(provider),
194 );
119195
120 const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> =>196 const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> =>
121 checkpointService197 checkpointService
...@@ -166,203 +242,361 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -166,203 +242,361 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
166 ).pipe(Effect.forkScoped),242 ).pipe(Effect.forkScoped),
167 ).pipe(Effect.asVoid);243 ).pipe(Effect.asVoid);
168244
169 const adapterForSession = (sessionId: string) =>245 const recoverSessionForThread = (input: {
170 directory246 readonly staleSessionId: string;
171 .getProvider(sessionId)247 readonly provider: ProviderSession["provider"];
172 .pipe(Effect.flatMap((provider) => registry.getByProvider(provider)));248 readonly threadId: string;
173249 readonly operation: string;
174 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>250 }) =>
175 Effect.gen(function* () {251 Effect.gen(function* () {
176 const parsed = providerSessionStartInputSchema.safeParse(rawInput);252 const adapter = yield* registry.getByProvider(input.provider);
177 if (!parsed.success) {253 const activeSessions = yield* adapter.listSessions();
178 return yield* Effect.fail(254 const existing = activeSessions.find(
179 toValidationError(255 (session) => session.threadId?.trim() === input.threadId,
256 );
257 if (existing) {
258 const existingThreadId = yield* upsertSessionBinding(
259 existing,
260 `${input.operation}:upsertExistingSession`,
261 );
262 yield* directory.upsert({
263 sessionId: input.staleSessionId,
264 provider: existing.provider,
265 threadId: existingThreadId,
266 });
267 if (existing.sessionId !== input.staleSessionId) {
268 yield* setAlias(input.staleSessionId, existing.sessionId);
269 } else {
270 yield* clearAliasKey(input.staleSessionId);
271 }
272 return {
273 adapter,
274 sessionId: existing.sessionId,
275 } as const;
276 }
277
278 const resumed = yield* adapter.startSession({
279 provider: input.provider,
280 resumeThreadId: input.threadId,
281 });
282 if (resumed.provider !== adapter.provider) {
283 return yield* toValidationError(
284 input.operation,
285 `Adapter/provider mismatch while recovering stale session '${input.staleSessionId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`,
286 );
287 }
288
289 const resumedThreadId = yield* upsertSessionBinding(
290 resumed,
291 `${input.operation}:upsertRecoveredSession`,
292 );
293 if (resumedThreadId !== input.threadId) {
294 return yield* toValidationError(
295 input.operation,
296 `Recovered session thread '${resumedThreadId}' does not match expected thread '${input.threadId}'.`,
297 );
298 }
299
300 yield* directory.upsert({
301 sessionId: input.staleSessionId,
302 provider: resumed.provider,
303 threadId: resumedThreadId,
304 });
305
306 const checkpointCwd = resumed.cwd ?? process.cwd();
307 yield* checkpointService.initializeForSession({
308 providerSessionId: resumed.sessionId,
309 cwd: checkpointCwd,
310 });
311
312 if (resumed.sessionId !== input.staleSessionId) {
313 yield* setAlias(input.staleSessionId, resumed.sessionId);
314 } else {
315 yield* clearAliasKey(input.staleSessionId);
316 }
317
318 return {
319 adapter,
320 sessionId: resumed.sessionId,
321 } as const;
322 });
323
324 const resolveRoutableSession = (input: {
325 readonly sessionId: string;
326 readonly operation: string;
327 readonly allowRecovery: boolean;
328 }) =>
329 Effect.gen(function* () {
330 const provider = yield* directory.getProvider(input.sessionId);
331 const adapter = yield* registry.getByProvider(provider);
332
333 const hasRequestedSession = yield* adapter.hasSession(input.sessionId);
334 if (hasRequestedSession) {
335 yield* clearAliasKey(input.sessionId);
336 return {
337 adapter,
338 sessionId: input.sessionId,
339 isActive: true,
340 } as const;
341 }
342
343 const alias = yield* Ref.get(routedSessionAliasesRef).pipe(
344 Effect.map((aliases) => aliases.get(input.sessionId)),
345 );
346 if (alias) {
347 const aliasIsActive = yield* adapter.hasSession(alias);
348 if (aliasIsActive) {
349 return {
350 adapter,
351 sessionId: alias,
352 isActive: true,
353 } as const;
354 }
355 yield* clearAliasKey(input.sessionId);
356 }
357
358 if (!input.allowRecovery) {
359 return {
360 adapter,
361 sessionId: input.sessionId,
362 isActive: false,
363 } as const;
364 }
365
366 const threadIdOption = yield* directory.getThreadId(input.sessionId);
367 const threadId = Option.getOrUndefined(threadIdOption)?.trim();
368 if (!threadId) {
369 return yield* toValidationError(
370 input.operation,
371 `Cannot recover stale session '${input.sessionId}' because no thread id is persisted.`,
372 );
373 }
374
375 const recovered = yield* recoverSessionForThread({
376 staleSessionId: input.sessionId,
377 provider,
378 threadId,
379 operation: input.operation,
380 });
381
382 return {
383 adapter: recovered.adapter,
384 sessionId: recovered.sessionId,
385 isActive: true,
386 } as const;
387 });
388
389 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>
390 Effect.gen(function* () {
391 const parsed = providerSessionStartInputSchema.safeParse(rawInput);
392 if (!parsed.success) {
393 return yield* toValidationError(
180 "ProviderService.startSession",394 "ProviderService.startSession",
181 parsed.error.message,395 parsed.error.message,
182 parsed.error.cause,396 parsed.error.cause,
183 ),397 );
184 );398 }
185 }
186399
187 const input = parsed.data;400 const input = parsed.data;
188 const adapter = yield* registry.getByProvider(input.provider);401 const adapter = yield* registry.getByProvider(input.provider);
189 const session = yield* adapter.startSession(input);402 const session = yield* adapter.startSession(input);
190403
191 if (session.provider !== adapter.provider) {404 if (session.provider !== adapter.provider) {
192 return yield* Effect.fail(405 return yield* toValidationError(
193 toValidationError(
194 "ProviderService.startSession",406 "ProviderService.startSession",
195 `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`,407 `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`,
196 ),408 );
197 );409 }
198 }
199410
200 const threadId = session.threadId?.trim();411 yield* upsertSessionBinding(session, "ProviderService.startSession");
201 if (!threadId) {
202 return yield* Effect.fail(
203 toValidationError(
204 "ProviderService.startSession",
205 `Provider '${adapter.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`,
206 ),
207 );
208 }
209412
210 yield* directory.upsert({413 const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd();
211 sessionId: session.sessionId,414 yield* checkpointService.initializeForSession({
212 provider: session.provider,415 providerSessionId: session.sessionId,
213 threadId,416 cwd: checkpointCwd,
214 });417 });
215418
216 const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd();419 return session;
217 yield* checkpointService.initializeForSession({
218 providerSessionId: session.sessionId,
219 cwd: checkpointCwd,
220 });420 });
221421
222 return session;422 const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) =>
223 });423 Effect.gen(function* () {
424 const parsed = providerSendTurnInputSchema.safeParse(rawInput);
425 if (!parsed.success) {
426 return yield* toValidationError(
427 "ProviderService.sendTurn",
428 parsed.error.message,
429 parsed.error.cause,
430 );
431 }
432
433 const input = parsed.data;
434 const routed = yield* resolveRoutableSession({
435 sessionId: input.sessionId,
436 operation: "ProviderService.sendTurn",
437 allowRecovery: true,
438 });
439 return yield* routed.adapter.sendTurn({
440 ...input,
441 sessionId: routed.sessionId,
442 });
443 });
224444
225 const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) =>445 const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) =>
226 Effect.gen(function* () {446 Effect.gen(function* () {
227 const parsed = providerSendTurnInputSchema.safeParse(rawInput);447 const parsed = providerInterruptTurnInputSchema.safeParse(rawInput);
228 if (!parsed.success) {448 if (!parsed.success) {
229 return yield* Effect.fail(449 return yield* toValidationError(
230 toValidationError("ProviderService.sendTurn", parsed.error.message, parsed.error.cause),
231 );
232 }
233
234 const input = parsed.data;
235 const adapter = yield* adapterForSession(input.sessionId);
236 return yield* adapter.sendTurn(input);
237 });
238
239 const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) =>
240 Effect.gen(function* () {
241 const parsed = providerInterruptTurnInputSchema.safeParse(rawInput);
242 if (!parsed.success) {
243 return yield* Effect.fail(
244 toValidationError(
245 "ProviderService.interruptTurn",450 "ProviderService.interruptTurn",
246 parsed.error.message,451 parsed.error.message,
247 parsed.error.cause,452 parsed.error.cause,
248 ),453 );
249 );454 }
250 }455
251456 const input = parsed.data;
252 const input = parsed.data;457 const routed = yield* resolveRoutableSession({
253 const adapter = yield* adapterForSession(input.sessionId);458 sessionId: input.sessionId,
254 yield* adapter.interruptTurn(input.sessionId, input.turnId);459 operation: "ProviderService.interruptTurn",
255 });460 allowRecovery: true,
256461 });
257 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>462 yield* routed.adapter.interruptTurn(routed.sessionId, input.turnId);
258 Effect.gen(function* () {463 });
259 const parsed = providerRespondToRequestInputSchema.safeParse(rawInput);464
260 if (!parsed.success) {465 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
261 return yield* Effect.fail(466 Effect.gen(function* () {
262 toValidationError(467 const parsed = providerRespondToRequestInputSchema.safeParse(rawInput);
468 if (!parsed.success) {
469 return yield* toValidationError(
263 "ProviderService.respondToRequest",470 "ProviderService.respondToRequest",
264 parsed.error.message,471 parsed.error.message,
265 parsed.error.cause,472 parsed.error.cause,
266 ),473 );
267 );474 }
268 }475
269476 const input = parsed.data;
270 const input = parsed.data;477 const routed = yield* resolveRoutableSession({
271 const adapter = yield* adapterForSession(input.sessionId);478 sessionId: input.sessionId,
272 yield* adapter.respondToRequest(input.sessionId, input.requestId, input.decision);479 operation: "ProviderService.respondToRequest",
273 });480 allowRecovery: true,
274481 });
275 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>482 yield* routed.adapter.respondToRequest(routed.sessionId, input.requestId, input.decision);
276 Effect.gen(function* () {483 });
277 const parsed = providerStopSessionInputSchema.safeParse(rawInput);484
278 if (!parsed.success) {485 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
279 return yield* Effect.fail(486 Effect.gen(function* () {
280 toValidationError(487 const parsed = providerStopSessionInputSchema.safeParse(rawInput);
488 if (!parsed.success) {
489 return yield* toValidationError(
281 "ProviderService.stopSession",490 "ProviderService.stopSession",
282 parsed.error.message,491 parsed.error.message,
283 parsed.error.cause,492 parsed.error.cause,
284 ),493 );
285 );494 }
286 }495
287496 const input = parsed.data;
288 const input = parsed.data;497 const routed = yield* resolveRoutableSession({
289 const adapter = yield* adapterForSession(input.sessionId);498 sessionId: input.sessionId,
290 yield* adapter.stopSession(input.sessionId);499 operation: "ProviderService.stopSession",
291 yield* checkpointService.releaseSession({ providerSessionId: input.sessionId });500 allowRecovery: false,
292 yield* directory.remove(input.sessionId);501 });
293 });502 if (routed.isActive) {
294503 yield* routed.adapter.stopSession(routed.sessionId);
295 const listSessions: ProviderServiceShape["listSessions"] = () =>504 }
296 Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe(505 if (routed.sessionId !== input.sessionId) {
297 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),506 yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId });
298 );507 yield* directory.remove(routed.sessionId);
508 yield* clearAliasesReferencing(routed.sessionId);
509 }
510 yield* checkpointService.releaseSession({ providerSessionId: input.sessionId });
511 yield* directory.remove(input.sessionId);
512 yield* clearAliasesReferencing(input.sessionId);
513 });
514
515 const listSessions: ProviderServiceShape["listSessions"] = () =>
516 Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe(
517 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
518 );
299519
300 const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) =>520 const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) =>
301 Effect.gen(function* () {521 Effect.gen(function* () {
302 const parsed = providerListCheckpointsInputSchema.safeParse(rawInput);522 const parsed = providerListCheckpointsInputSchema.safeParse(rawInput);
303 if (!parsed.success) {523 if (!parsed.success) {
304 return yield* Effect.fail(524 return yield* toValidationError(
305 toValidationError(
306 "ProviderService.listCheckpoints",525 "ProviderService.listCheckpoints",
307 parsed.error.message,526 parsed.error.message,
308 parsed.error.cause,527 parsed.error.cause,
309 ),528 );
310 );529 }
311 }530
312531 const input = parsed.data;
313 const input = parsed.data;532 const routed = yield* resolveRoutableSession({
314 yield* directory.getProvider(input.sessionId);533 sessionId: input.sessionId,
315 return yield* checkpointService.listCheckpoints(input);534 operation: "ProviderService.listCheckpoints",
316 });535 allowRecovery: true,
317536 });
318 const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) =>537 return yield* checkpointService.listCheckpoints({
319 Effect.gen(function* () {538 ...input,
320 const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput);539 sessionId: routed.sessionId,
321 if (!parsed.success) {540 });
322 return yield* Effect.fail(541 });
323 toValidationError(542
543 const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) =>
544 Effect.gen(function* () {
545 const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput);
546 if (!parsed.success) {
547 return yield* toValidationError(
324 "ProviderService.getCheckpointDiff",548 "ProviderService.getCheckpointDiff",
325 parsed.error.message,549 parsed.error.message,
326 parsed.error.cause,550 parsed.error.cause,
327 ),551 );
328 );552 }
329 }553
330554 const input = parsed.data;
331 const input = parsed.data;555 const routed = yield* resolveRoutableSession({
332 yield* directory.getProvider(input.sessionId);556 sessionId: input.sessionId,
333 return yield* checkpointService.getCheckpointDiff(input);557 operation: "ProviderService.getCheckpointDiff",
334 });558 allowRecovery: true,
335559 });
336 const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) =>560 return yield* checkpointService.getCheckpointDiff({
337 Effect.gen(function* () {561 ...input,
338 const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput);562 sessionId: routed.sessionId,
339 if (!parsed.success) {563 });
340 return yield* Effect.fail(564 });
341 toValidationError(565
566 const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) =>
567 Effect.gen(function* () {
568 const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput);
569 if (!parsed.success) {
570 return yield* toValidationError(
342 "ProviderService.revertToCheckpoint",571 "ProviderService.revertToCheckpoint",
343 parsed.error.message,572 parsed.error.message,
344 parsed.error.cause,573 parsed.error.cause,
345 ),574 );
346 );575 }
347 }576
348577 const input = parsed.data;
349 const input = parsed.data;578 const routed = yield* resolveRoutableSession({
350 yield* directory.getProvider(input.sessionId);579 sessionId: input.sessionId,
351 return yield* checkpointService.revertToCheckpoint(input);580 operation: "ProviderService.revertToCheckpoint",
352 });581 allowRecovery: true,
353582 });
354 const stopAll: ProviderServiceShape["stopAll"] = () =>583 return yield* checkpointService.revertToCheckpoint({
355 Effect.gen(function* () {584 ...input,
356 const sessionIds = yield* directory.listSessionIds();585 sessionId: routed.sessionId,
357 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);586 });
358 yield* Effect.forEach(587 });
359 sessionIds,588
360 (sessionId) =>589 const stopAll: ProviderServiceShape["stopAll"] = () =>
361 checkpointService.releaseSession({ providerSessionId: sessionId }).pipe(590 Effect.gen(function* () {
362 Effect.flatMap(() => directory.remove(sessionId)),591 const sessionIds = yield* directory.listSessionIds();
363 ),592 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
364 ).pipe(Effect.asVoid);593 // Keep persisted session bindings so stale sessions can be resumed after
365 });594 // process restart via resumeThreadId.
595 yield* Effect.forEach(sessionIds, (sessionId) =>
596 checkpointService.releaseSession({ providerSessionId: sessionId }),
597 ).pipe(Effect.asVoid);
598 yield* Ref.set(routedSessionAliasesRef, new Map());
599 });
366600
367 return {601 return {
368 startSession,602 startSession,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-45
...@@ -5,13 +5,12 @@ import path from "node:path";...@@ -5,13 +5,12 @@ import path from "node:path";
5import { NodeServices } from "@effect/platform-node";5import { NodeServices } from "@effect/platform-node";
6import { it, assert } from "@effect/vitest";6import { it, assert } from "@effect/vitest";
7import { assertFailure, assertSome } from "@effect/vitest/utils";7import { assertFailure, assertSome } from "@effect/vitest/utils";
8import { Effect, Layer, Stream } from "effect";8import { Effect, Layer } from "effect";
9import * as SqlClient from "effect/unstable/sql/SqlClient";9import * as SqlClient from "effect/unstable/sql/SqlClient";
1010
11import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";11import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
12import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts";12import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts";
13import { ProviderSessionNotFoundError, ProviderValidationError, type ProviderAdapterError } from "../Errors.ts";13import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
14import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
15import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";14import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
16import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";15import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
1716
...@@ -22,24 +21,6 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli...@@ -22,24 +21,6 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli
22 );21 );
23}22}
2423
25function makeAdapter(activeSessionIds: ReadonlySet<string>): ProviderAdapterShape<ProviderAdapterError> {
26 const unsupported = () => Effect.die(new Error("unsupported test operation")) as never;
27 return {
28 provider: "codex",
29 startSession: () => unsupported(),
30 sendTurn: () => unsupported(),
31 interruptTurn: () => unsupported(),
32 respondToRequest: () => unsupported(),
33 stopSession: () => unsupported(),
34 listSessions: () => Effect.succeed([]),
35 hasSession: (sessionId) => Effect.succeed(activeSessionIds.has(sessionId)),
36 readThread: () => unsupported(),
37 rollbackThread: () => unsupported(),
38 stopAll: () => Effect.void,
39 streamEvents: Stream.empty,
40 };
41}
42
43const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));24const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));
4425
45layer("ProviderSessionDirectoryLive", (it) => {26layer("ProviderSessionDirectoryLive", (it) => {
...@@ -118,28 +99,4 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -118,28 +99,4 @@ layer("ProviderSessionDirectoryLive", (it) => {
11899
119 fs.rmSync(tempDir, { recursive: true, force: true });100 fs.rmSync(tempDir, { recursive: true, force: true });
120 }).pipe(Effect.provide(NodeServices.layer)));101 }).pipe(Effect.provide(NodeServices.layer)));
121
122 it("reconciles stale sessions and keeps active mappings", () =>
123 Effect.gen(function* () {
124 const directory = yield* ProviderSessionDirectory;
125
126 yield* directory.upsert({
127 sessionId: "sess-active",
128 provider: "codex",
129 threadId: "thread-active",
130 });
131 yield* directory.upsert({
132 sessionId: "sess-stale",
133 provider: "codex",
134 threadId: "thread-stale",
135 });
136
137 const pruned = yield* directory.reconcileWithAdapters([
138 makeAdapter(new Set(["sess-active"])),
139 ]);
140 assert.deepEqual(pruned, ["sess-stale"]);
141
142 const remaining = yield* directory.listSessionIds();
143 assert.deepEqual(remaining, ["sess-active"]);
144 }));
145});102});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts-33
...@@ -109,45 +109,12 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -109,45 +109,12 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
109 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>109 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>
110 listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId)));110 listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId)));
111111
112 const reconcileWithAdapters: ProviderSessionDirectoryShape["reconcileWithAdapters"] = (
113 adapters,
114 ) =>
115 Effect.gen(function* () {
116 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
117 const bindings = yield* listBindings();
118 const staleSessionIds: string[] = [];
119
120 for (const binding of bindings) {
121 const adapter = byProvider.get(binding.provider);
122 if (!adapter) {
123 staleSessionIds.push(binding.sessionId);
124 continue;
125 }
126
127 const hasSession = yield* adapter.hasSession(binding.sessionId);
128 if (!hasSession) {
129 staleSessionIds.push(binding.sessionId);
130 }
131 }
132
133 if (staleSessionIds.length === 0) {
134 return [] as ReadonlyArray<string>;
135 }
136
137 yield* Effect.forEach(staleSessionIds, (sessionId) => persistDelete(sessionId)).pipe(
138 Effect.asVoid,
139 );
140
141 return staleSessionIds as ReadonlyArray<string>;
142 });
143
144 return {112 return {
145 upsert,113 upsert,
146 getProvider,114 getProvider,
147 getThreadId,115 getThreadId,
148 remove,116 remove,
149 listSessionIds,117 listSessionIds,
150 reconcileWithAdapters,
151 } satisfies ProviderSessionDirectoryShape;118 } satisfies ProviderSessionDirectoryShape;
152});119});
153120
apps/server/src/provider/Services/ProviderAdapterRegistry.ts+2
...@@ -35,3 +35,5 @@ export class ProviderAdapterRegistry extends ServiceMap.Service<...@@ -35,3 +35,5 @@ export class ProviderAdapterRegistry extends ServiceMap.Service<
35 ProviderAdapterRegistry,35 ProviderAdapterRegistry,
36 ProviderAdapterRegistryShape36 ProviderAdapterRegistryShape
37>()("provider/ProviderAdapterRegistry") {}37>()("provider/ProviderAdapterRegistry") {}
38
39// Dummy comment for workflow testing.
apps/server/src/provider/Services/ProviderService.ts+4-1
...@@ -95,7 +95,10 @@ export interface ProviderServiceShape {...@@ -95,7 +95,10 @@ export interface ProviderServiceShape {
95 ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>;95 ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>;
9696
97 /**97 /**
98 * Stop all provider sessions.98 * Stop all active provider sessions.
99 *
100 * Persisted session bindings remain so stale sessions can be resumed after
101 * process restart.
99 */102 */
100 readonly stopAll: () => Effect.Effect<void, ProviderServiceError>;103 readonly stopAll: () => Effect.Effect<void, ProviderServiceError>;
101104
apps/server/src/provider/Services/ProviderSessionDirectory.ts-13
...@@ -12,12 +12,10 @@ import { Option, ServiceMap } from "effect";...@@ -12,12 +12,10 @@ import { Option, ServiceMap } from "effect";
12import type { Effect } from "effect";12import type { Effect } from "effect";
1313
14import type {14import type {
15 ProviderAdapterError,
16 ProviderSessionDirectoryPersistenceError,15 ProviderSessionDirectoryPersistenceError,
17 ProviderSessionNotFoundError,16 ProviderSessionNotFoundError,
18 ProviderValidationError,17 ProviderValidationError,
19} from "../Errors.ts";18} from "../Errors.ts";
20import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
2119
22export interface ProviderSessionBinding {20export interface ProviderSessionBinding {
23 readonly sessionId: string;21 readonly sessionId: string;
...@@ -70,17 +68,6 @@ export interface ProviderSessionDirectoryShape {...@@ -70,17 +68,6 @@ export interface ProviderSessionDirectoryShape {
70 ProviderSessionDirectoryPersistenceError68 ProviderSessionDirectoryPersistenceError
71 >;69 >;
7270
73 /**
74 * Remove stale persisted sessions that are no longer active in adapters.
75 *
76 * Returns pruned session ids.
77 */
78 readonly reconcileWithAdapters: (
79 adapters: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>,
80 ) => Effect.Effect<
81 ReadonlyArray<string>,
82 ProviderSessionDirectoryPersistenceError | ProviderAdapterError
83 >;
84}71}
8572
86/**73/**
apps/server/tsconfig.json+6-1
...@@ -5,7 +5,12 @@...@@ -5,7 +5,12 @@
5 "types": ["node"],5 "types": ["node"],
6 "lib": ["ES2023", "esnext.disposable"],6 "lib": ["ES2023", "esnext.disposable"],
7 "noEmit": true,7 "noEmit": true,
8 "allowImportingTsExtensions": true8 "allowImportingTsExtensions": true,
9 "plugins": [
10 {
11 "name": "@effect/language-service"
12 }
13 ]
9 },14 },
10 "include": ["src", "integration", "tsdown.config.ts", "scripts"]15 "include": ["src", "integration", "tsdown.config.ts", "scripts"]
11}16}
bun.lock+7
...@@ -35,6 +35,7 @@...@@ -35,6 +35,7 @@
35 },35 },
36 "dependencies": {36 "dependencies": {
37 "@effect/platform-node": "4.0.0-beta.10",37 "@effect/platform-node": "4.0.0-beta.10",
38 "@effect/sql-sqlite-bun": "4.0.0-beta.10",
38 "@pierre/diffs": "^1.1.0-beta.16",39 "@pierre/diffs": "^1.1.0-beta.16",
39 "effect": "4.0.0-beta.10",40 "effect": "4.0.0-beta.10",
40 "node-pty": "^1.1.0",41 "node-pty": "^1.1.0",
...@@ -42,6 +43,7 @@...@@ -42,6 +43,7 @@
42 "ws": "^8.18.0",43 "ws": "^8.18.0",
43 },44 },
44 "devDependencies": {45 "devDependencies": {
46 "@effect/language-service": "0.75.1",
45 "@effect/vitest": "4.0.0-beta.10",47 "@effect/vitest": "4.0.0-beta.10",
46 "@t3tools/contracts": "workspace:*",48 "@t3tools/contracts": "workspace:*",
47 "@t3tools/web": "workspace:*",49 "@t3tools/web": "workspace:*",
...@@ -96,6 +98,7 @@...@@ -96,6 +98,7 @@
96 "zod": "^3.24.1",98 "zod": "^3.24.1",
97 },99 },
98 "devDependencies": {100 "devDependencies": {
101 "@effect/language-service": "0.75.1",
99 "tsdown": "^0.20.3",102 "tsdown": "^0.20.3",
100 "typescript": "^5.7.3",103 "typescript": "^5.7.3",
101 },104 },
...@@ -150,10 +153,14 @@...@@ -150,10 +153,14 @@
150153
151 "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="],154 "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="],
152155
156 "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="],
157
153 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.10", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.10", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10", "ioredis": "^5.7.0" } }, "sha512-ikZ/uyos5okYAHE+wWOwZkHgriFUTY3aY8Kdqbj/ro8xw0z9YXWN4fbyQAZUWkBcieDKS36N+QfHRMPG4fEWRA=="],158 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.10", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.10", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10", "ioredis": "^5.7.0" } }, "sha512-ikZ/uyos5okYAHE+wWOwZkHgriFUTY3aY8Kdqbj/ro8xw0z9YXWN4fbyQAZUWkBcieDKS36N+QfHRMPG4fEWRA=="],
154159
155 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="],160 "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="],
156161
162 "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-LY1jNu2uqGYsJCVjVU73nzcZ+kAYBDsG7O8x5ddmkIXj10RmnIhjYMStWBa0NZOFg4Hsl+CadxtsDy3edaOVeA=="],
163
157 "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="],164 "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="],
158165
159 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],166 "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
packages/contracts/package.json+2
...@@ -19,6 +19,7 @@...@@ -19,6 +19,7 @@
19 "scripts": {19 "scripts": {
20 "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean",20 "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean",
21 "build": "tsdown src/index.ts --format esm,cjs --dts --clean",21 "build": "tsdown src/index.ts --format esm,cjs --dts --clean",
22 "prepare": "effect-language-service patch",
22 "typecheck": "tsc --noEmit",23 "typecheck": "tsc --noEmit",
23 "test": "vitest run"24 "test": "vitest run"
24 },25 },
...@@ -27,6 +28,7 @@...@@ -27,6 +28,7 @@
27 "zod": "^3.24.1"28 "zod": "^3.24.1"
28 },29 },
29 "devDependencies": {30 "devDependencies": {
31 "@effect/language-service": "0.75.1",
30 "tsdown": "^0.20.3",32 "tsdown": "^0.20.3",
31 "typescript": "^5.7.3"33 "typescript": "^5.7.3"
32 }34 }
packages/contracts/tsconfig.json+7
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1{1{
2 "extends": "../../tsconfig.base.json",2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service"
7 }
8 ]
9 },
3 "include": ["src"]10 "include": ["src"]
4}11}