authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 02:08:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log25d20ab7928c6aed5f613f4c3319394635869250
treeb0054e64c06aae2f971c8c88b975febdb24b81d7
parent7307079d4063a5c546a4c277dcc3b6594b41a846

rewrite


131 files changed, 12229 insertions(+), 9902 deletions(-)

.plans/spec-1-1-cutover-plan.md created+201
......@@ -0,0 +1,201 @@
1# Spec 1:1 Cutover Plan
2
3Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft.
4
5Execution mode for this plan:
6- Hard cutover only. Existing DB and migration history are disposable.
7- Intermediate steps are allowed to break runtime, tests, typecheck, and lint.
8- We optimize for small, reviewable work units, not continuous app operability.
9- Only the final gate requires everything to run cleanly.
10
11## 1. Freeze SPEC contract as source of truth
12Work units:
13- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`.
14- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants.
15- Add app-level requirements per row: writer path, reader path, and owning module.
16- Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`.
17- Identify any ambiguous spec lines and record a concrete interpretation in the matrix.
18
19Deliverables:
20- Complete matrix file with no unclassified rows.
21- Single source checklist used by all later steps.
22
23Breakage allowed:
24- No code changes required yet.
25
26Exit criteria:
27- Every requirement in `7.1`-`7.4` has exactly one matrix row.
28
29## 2. Hard cutover migrations (replace current migration set)
30Work units:
31- Delete the current legacy migration files and rewrite migration loader ordering.
32- Create `001_orchestration_events.ts` with full envelope columns and required event indexes.
33- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes.
34- Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`.
35- Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes.
36- Create `005_projections.ts` with all projection tables:
37 - `projection_projects`
38 - `projection_threads`
39 - `projection_thread_messages`
40 - `projection_thread_activities`
41 - `projection_thread_sessions`
42 - `projection_thread_turns`
43 - `projection_checkpoints`
44 - `projection_pending_approvals`
45 - `projection_state`
46- Add all required indexes/constraints in `005_projections.ts`.
47- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated.
48
49Deliverables:
50- New 5-file migration chain.
51- Updated migration loader references only new migrations.
52
53Breakage allowed:
54- Repositories/services can be temporarily broken due to removed old tables.
55
56Exit criteria:
57- Fresh DB initializes with only canonical tables plus migration bookkeeping.
58
59## 3. Align persistence row/request schemas to DB 1:1
60Work units:
61- Define row schemas for each canonical table (contracts or persistence layer module).
62- Define request schemas for every insert/update/query operation touching canonical tables.
63- Remove or deprecate row/request schemas tied to deleted legacy tables.
64- Normalize enum and null semantics to match contracts exactly.
65- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms).
66
67Deliverables:
68- Canonical row/request schemas committed.
69- Zero references to legacy row schemas in active code paths.
70
71Breakage allowed:
72- Runtime can still fail while query layers are being rewired.
73
74Exit criteria:
75- Every canonical table used in code has a typed row schema and typed request schema.
76
77## 4. Rewrite event store for full persisted envelope
78Work units:
79- Refactor append path to write full envelope fields:
80 - `event_id`, `aggregate_kind`, `stream_id`, `stream_version`, `event_type`, `occurred_at`, `command_id`, `causation_event_id`, `correlation_id`, `actor_kind`, `payload_json`, `metadata_json`
81- Implement stream version assignment/checking per aggregate stream.
82- Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently.
83- Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor).
84- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`).
85
86Deliverables:
87- Event store append/replay fully aligned with canonical envelope.
88
89Breakage allowed:
90- Command dispatch flow can be partially broken until receipts/projectors are updated.
91
92Exit criteria:
93- Event store no longer depends on legacy event table shape.
94
95## 5. Add command receipt idempotency
96Work units:
97- Introduce persistence access layer for `orchestration_command_receipts`.
98- In command dispatch flow, check existing receipt by `commandId` before append.
99- On first execution, persist accepted receipt with `resultSequence`.
100- On domain rejection, persist rejected receipt with error payload.
101- On duplicate command, return prior result from receipt without re-appending event.
102- Ensure receipt write and event append ordering is deterministic.
103
104Deliverables:
105- Dispatch path with idempotency behavior wired through receipts.
106
107Breakage allowed:
108- Snapshot/read model may still be inconsistent until projectors are fully wired.
109
110Exit criteria:
111- Duplicate command IDs no longer create duplicate events.
112
113## 6. Build DB-backed projection pipeline
114Work units:
115- Create projector runner that consumes events and applies table-specific projections.
116- Implement projector handlers for each projection table.
117- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction.
118- Define projector names used in `projection_state` and make them stable constants.
119- Add replay bootstrap from event store to bring projections up to latest sequence on startup.
120- Add safe resume logic from projector `last_applied_sequence`.
121
122Deliverables:
123- Persistent projector pipeline writing all `projection_*` tables.
124
125Breakage allowed:
126- Web/API layer may still read old in-memory model until step 7.
127
128Exit criteria:
129- Events drive projection rows in DB; projection state advances transactionally.
130
131## 7. Move RPC reads to projections and diff blobs
132Work units:
133- Implement snapshot query service reading only projection tables.
134- Build thread hydration from projection rows: messages, activities, checkpoints, session.
135- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`.
136- Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only.
137- Remove or bypass in-memory snapshot construction for RPC responses.
138- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`.
139
140Deliverables:
141- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store.
142
143Breakage allowed:
144- Provider runtime persistence may still be partially legacy until step 8.
145
146Exit criteria:
147- No orchestration read RPC depends on legacy tables or in-memory-only state.
148
149## 8. Migrate provider runtime persistence to canonical table
150Work units:
151- Create repository/service for `provider_session_runtime`.
152- Update adapter/session manager to persist runtime/resume cursor in new table.
153- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`.
154- Remove writes to legacy provider session tables.
155- Verify restart/resume path reads runtime state from canonical table only.
156
157Deliverables:
158- Provider runtime state entirely backed by `provider_session_runtime`.
159
160Breakage allowed:
161- Some legacy interfaces may still exist but should be disconnected.
162
163Exit criteria:
164- Runtime restore no longer reads/writes legacy provider session persistence.
165
166## 9. Remove old cruft aggressively
167Work units:
168- Delete legacy repositories/services that map to removed tables.
169- Remove dead migration imports and obsolete persistence service interfaces.
170- Remove compatibility code paths that translate legacy row shapes.
171- Remove unused contracts/types linked to deprecated persistence model.
172- Update internal docs/comments to reference canonical projection/event model only.
173
174Deliverables:
175- Legacy persistence and translation layers removed from active codebase.
176
177Breakage allowed:
178- Temporary compile failures acceptable while deletion/refactor is in progress.
179
180Exit criteria:
181- No production code path references deleted legacy tables/services.
182
183## 10. Final verification gate (first point where green is required)
184Work units:
185- Add migration tests that assert canonical tables, columns, constraints, and indexes.
186- Add event store tests for envelope persistence, metadata, actor kind, and replay.
187- Add receipt idempotency tests for accept/reject/duplicate paths.
188- Add projector tests for transactional row updates + `projection_state` updates.
189- Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics.
190- Add turn diff tests verifying `checkpoint_diff_blobs` source of truth.
191- Add provider runtime tests for persist + restart + resume behavior.
192- Run project lint/typecheck/tests and fix failures.
193
194Deliverables:
195- Green checks with canonical schema + persistence model in place.
196
197Breakage allowed:
198- None at end of step.
199
200Exit criteria:
201- SPEC `7.1`-`7.4` requirements satisfied and validated by tests.
.plans/spec-contract-matrix.md created+401
......@@ -0,0 +1,401 @@
1# SPEC Contract Matrix (Sections 7.1-7.4)
2
3Status legend:
4- `required`: requirement acknowledged, no current implementation claim yet.
5- `implemented`: requirement currently satisfied in code + schema.
6- `to-replace`: partial/misaligned implementation exists and must be replaced.
7- `delete`: current path actively conflicts with SPEC and should be removed.
8
9## 7.1 Write-Side Persisted Tables
10
11### W1
12- Spec ref: `7.1.1 orchestration_events`
13- Requirement: append-only event store with canonical envelope columns.
14- SQL contract:
15 - `sequence INTEGER PRIMARY KEY` (global monotonic)
16 - `event_id TEXT UNIQUE NOT NULL`
17 - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')`
18 - `stream_id TEXT NOT NULL`
19 - `stream_version INTEGER NOT NULL`
20 - `event_type TEXT NOT NULL`
21 - `occurred_at TEXT NOT NULL`
22 - `command_id TEXT NULL`
23 - `causation_event_id TEXT NULL`
24 - `correlation_id TEXT NULL`
25 - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')`
26 - `payload_json TEXT NOT NULL`
27 - `metadata_json TEXT NOT NULL`
28- Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts`
29- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts`
30- Owner module: `apps/server/src/persistence` (event store + migrations)
31- Status: `to-replace`
32- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`.
33
34### W2
35- Spec ref: `7.1.2 orchestration_command_receipts`
36- Requirement: command idempotency + ack replay receipts table.
37- SQL contract:
38 - `command_id TEXT PRIMARY KEY`
39 - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')`
40 - `aggregate_id TEXT NOT NULL`
41 - `accepted_at TEXT NOT NULL`
42 - `result_sequence INTEGER NOT NULL`
43 - `status TEXT NOT NULL CHECK IN ('accepted','rejected')`
44 - `error TEXT NULL`
45- Current writer path: none
46- Current reader path: none
47- Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence`
48- Status: `to-replace`
49- Notes: missing table and missing idempotency flow.
50
51### W3
52- Spec ref: `7.1.3 checkpoint_diff_blobs`
53- Requirement: store large plaintext diffs separate from checkpoint summaries.
54- SQL contract:
55 - `thread_id TEXT NOT NULL`
56 - `from_turn_count INTEGER NOT NULL`
57 - `to_turn_count INTEGER NOT NULL`
58 - `diff TEXT NOT NULL`
59 - `created_at TEXT NOT NULL`
60 - `UNIQUE(thread_id, from_turn_count, to_turn_count)`
61- Current writer path: none
62- Current reader path: none
63- Owner module: `apps/server/src/persistence` + turn diff query service
64- Status: `to-replace`
65- Notes: no canonical diff blob table yet.
66
67### W4
68- Spec ref: `7.1.4 provider_session_runtime`
69- Requirement: server-internal provider runtime/resume state.
70- SQL contract:
71 - `provider_session_id TEXT PRIMARY KEY`
72 - `thread_id TEXT NOT NULL`
73 - `provider_name TEXT NOT NULL`
74 - `adapter_key TEXT NOT NULL`
75 - `provider_thread_id TEXT NULL`
76 - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')`
77 - `last_seen_at TEXT NOT NULL`
78 - `resume_cursor_json TEXT NULL`
79 - `runtime_payload_json TEXT NULL`
80- Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`)
81- Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`)
82- Owner module: provider runtime manager + persistence runtime repository
83- Status: `to-replace`
84- Notes: existing `provider_sessions` schema is incompatible and too small.
85
86## 7.2 Canonical Persisted Event Schema
87
88### E1
89- Spec ref: `7.2 OrchestrationPersistedEventSchema`
90- Requirement: full typed persisted event envelope in shared contracts.
91- SQL contract: envelope fields in W1 must map 1:1 to contracts schema.
92- Current writer path: contracts defined in `packages/contracts/src/orchestration.ts`
93- Current reader path: used by persistence decode boundaries (partial)
94- Owner module: `packages/contracts`
95- Status: `implemented`
96- Notes: contract schema exists; DB + store mapping still incomplete.
97
98### E2
99- Spec ref: `7.2 Rules/payload discriminated by eventType`
100- Requirement: `payload` validation keyed by `eventType`.
101- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected.
102- Current writer path: `packages/contracts/src/orchestration.ts`
103- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path
104- Owner module: contracts + event store
105- Status: `to-replace`
106- Notes: decode is present but DB does not persist full envelope columns.
107
108### E3
109- Spec ref: `7.2 Rules/provider ids scope`
110- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement.
111- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id.
112- Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty)
113- Current reader path: projector/event consumers
114- Owner module: decider + provider ingestion + event store
115- Status: `to-replace`
116- Notes: metadata plumbing is incomplete in persistence path.
117
118### E4
119- Spec ref: `7.2 Rules/streamVersion concurrency guard`
120- Requirement: stream version monotonic per aggregate stream; enforced on write.
121- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream.
122- Current writer path: none
123- Current reader path: none
124- Owner module: event store append logic + DB constraints
125- Status: `to-replace`
126- Notes: no stream version assignment/checking today.
127
128## 7.3 Required Projected Tables (Read Models)
129
130### P1
131- Spec ref: `7.3.1 projection_projects`
132- Requirement: persisted project projection table.
133- SQL contract:
134 - `project_id TEXT PRIMARY KEY`
135 - `title TEXT NOT NULL`
136 - `workspace_root TEXT NOT NULL`
137 - `default_model TEXT NULL`
138 - `created_at TEXT NOT NULL`
139 - `updated_at TEXT NOT NULL`
140 - `deleted_at TEXT NULL`
141- Current writer path: none (in-memory projector only)
142- Current reader path: none (snapshot not DB-projected)
143- Owner module: projector pipeline + snapshot query
144- Status: `to-replace`
145- Notes: legacy `projects` table is separate concept and should be removed from orchestration model.
146
147### P2
148- Spec ref: `7.3.2 projection_threads`
149- Requirement: persisted thread projection table.
150- SQL contract:
151 - `thread_id TEXT PRIMARY KEY`
152 - `project_id TEXT NOT NULL`
153 - `title TEXT NOT NULL`
154 - `model TEXT NOT NULL`
155 - `branch TEXT NULL`
156 - `worktree_path TEXT NULL`
157 - `latest_turn_id TEXT NULL`
158 - `created_at TEXT NOT NULL`
159 - `updated_at TEXT NOT NULL`
160 - `deleted_at TEXT NULL`
161- Current writer path: none (in-memory projector only)
162- Current reader path: none (snapshot not DB-projected)
163- Owner module: projector pipeline + snapshot query
164- Status: `to-replace`
165- Notes: missing table and projector writes.
166
167### P3
168- Spec ref: `7.3.3 projection_thread_messages`
169- Requirement: persisted thread message projection table.
170- SQL contract:
171 - `message_id TEXT PRIMARY KEY`
172 - `thread_id TEXT NOT NULL`
173 - `turn_id TEXT NULL`
174 - `role TEXT NOT NULL CHECK IN ('user','assistant','system')`
175 - `text TEXT NOT NULL`
176 - `is_streaming INTEGER/BOOLEAN NOT NULL`
177 - `created_at TEXT NOT NULL`
178 - `updated_at TEXT NOT NULL`
179- Current writer path: none (in-memory projector only)
180- Current reader path: none (snapshot not DB-projected)
181- Owner module: projector pipeline + snapshot query
182- Status: `to-replace`
183- Notes: missing table and message projection writes.
184
185### P4
186- Spec ref: `7.3.4 projection_thread_activities`
187- Requirement: persisted thread activity projection table.
188- SQL contract:
189 - `activity_id TEXT PRIMARY KEY`
190 - `thread_id TEXT NOT NULL`
191 - `turn_id TEXT NULL`
192 - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')`
193 - `kind TEXT NOT NULL`
194 - `summary TEXT NOT NULL`
195 - `payload_json TEXT NOT NULL`
196 - `created_at TEXT NOT NULL`
197- Current writer path: none (in-memory projector only)
198- Current reader path: none (snapshot not DB-projected)
199- Owner module: projector pipeline + snapshot query
200- Status: `to-replace`
201- Notes: no canonical activity projection persistence.
202
203### P5
204- Spec ref: `7.3.5 projection_thread_sessions`
205- Requirement: persisted thread session projection table.
206- SQL contract:
207 - `thread_id TEXT PRIMARY KEY`
208 - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')`
209 - `provider_name TEXT NULL`
210 - `provider_session_id TEXT NULL`
211 - `provider_thread_id TEXT NULL`
212 - `active_turn_id TEXT NULL`
213 - `last_error TEXT NULL`
214 - `updated_at TEXT NOT NULL`
215- Current writer path: none (in-memory projector only)
216- Current reader path: none (snapshot not DB-projected)
217- Owner module: projector pipeline + snapshot query
218- Status: `to-replace`
219- Notes: current provider session table is not this domain projection.
220
221### P6
222- Spec ref: `7.3.6 projection_thread_turns`
223- Requirement: persisted thread turn projection table.
224- SQL contract:
225 - `turn_id TEXT PRIMARY KEY`
226 - `thread_id TEXT NOT NULL`
227 - `turn_count INTEGER NOT NULL`
228 - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')`
229 - `user_message_id TEXT NULL`
230 - `assistant_message_id TEXT NULL`
231 - `started_at TEXT NOT NULL`
232 - `completed_at TEXT NULL`
233- Current writer path: none
234- Current reader path: none
235- Owner module: projector pipeline + session/turn query helpers
236- Status: `to-replace`
237- Notes: missing table and projection logic.
238
239### P7
240- Spec ref: `7.3.7 projection_checkpoints`
241- Requirement: persisted checkpoint summary projection table.
242- SQL contract:
243 - `thread_id TEXT NOT NULL`
244 - `turn_id TEXT NOT NULL`
245 - `checkpoint_turn_count INTEGER NOT NULL`
246 - `checkpoint_ref TEXT NOT NULL`
247 - `status TEXT NOT NULL CHECK IN ('ready','missing','error')`
248 - `files_json TEXT NOT NULL`
249 - `assistant_message_id TEXT NULL`
250 - `completed_at TEXT NOT NULL`
251 - `UNIQUE(thread_id, checkpoint_turn_count)`
252- Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts`
253- Current reader path: legacy checkpoint repository
254- Owner module: projector pipeline + checkpoint query layer
255- Status: `to-replace`
256- Notes: current table semantics do not match canonical checkpoint projection schema.
257
258### P8
259- Spec ref: `7.3.8 projection_pending_approvals`
260- Requirement: persisted pending-approval projection table.
261- SQL contract:
262 - `request_id TEXT PRIMARY KEY`
263 - `thread_id TEXT NOT NULL`
264 - `turn_id TEXT NULL`
265 - `status TEXT NOT NULL CHECK IN ('pending','resolved')`
266 - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')`
267 - `created_at TEXT NOT NULL`
268 - `resolved_at TEXT NULL`
269- Current writer path: none
270- Current reader path: none
271- Owner module: projector pipeline + approval query layer
272- Status: `to-replace`
273- Notes: missing table and projection logic.
274
275### P9
276- Spec ref: `7.3.9 projection_state`
277- Requirement: projector progress tracking table.
278- SQL contract:
279 - `projector TEXT PRIMARY KEY`
280 - `last_applied_sequence INTEGER NOT NULL`
281 - `updated_at TEXT NOT NULL`
282- Current writer path: none
283- Current reader path: none
284- Owner module: projector runner/checkpointing
285- Status: `to-replace`
286- Notes: missing table and projector bookkeeping.
287
288### P10
289- Spec ref: `7.3 Projection consistency rules`
290- Requirement: projector row updates and `projection_state` update must be atomic per event.
291- SQL contract: per-projector transaction boundary covering both projection write and state update.
292- Current writer path: none (in-memory projector has no SQL transaction)
293- Current reader path: none
294- Owner module: projector runner
295- Status: `to-replace`
296- Notes: requires transactional projection executor.
297
298### P11
299- Spec ref: `7.3 Optional debug field`
300- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness.
301- SQL contract: optional; not required in baseline schema.
302- Current writer path: none
303- Current reader path: none
304- Owner module: projector runner
305- Status: `required`
306- Notes: interpretation: exclude from first cutover unless debugging requires it.
307
308## 7.4 Snapshot and RPC Requirements
309
310### R1
311- Spec ref: `7.4.1`
312- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`.
313- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`.
314- Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts`
315- Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel`
316- Owner module: snapshot query service + ws RPC handler
317- Status: `delete`
318- Notes: current in-memory read model path must be removed for SPEC compliance.
319
320### R2
321- Spec ref: `7.4.2`
322- Requirement: snapshot `projects[]` source is `projection_projects`.
323- SQL contract: `projects` collection assembled from `projection_projects` rows.
324- Current writer path: none
325- Current reader path: in-memory thread/project arrays
326- Owner module: snapshot query service
327- Status: `to-replace`
328- Notes: no DB project projection reader exists yet.
329
330### R3
331- Spec ref: `7.4.3`
332- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields.
333- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`.
334- Current writer path: legacy checkpoint repo data model
335- Current reader path: in-memory checkpoints from orchestration events
336- Owner module: snapshot query service + checkpoint projector
337- Status: `to-replace`
338- Notes: canonical projection table and reader not implemented.
339
340### R4
341- Spec ref: `7.4.4`
342- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs.
343- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only.
344- Current writer path: none for diff blobs
345- Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete
346- Owner module: ws RPC handler + diff query service
347- Status: `to-replace`
348- Notes: current checkpoint repository is not canonical source.
349
350### R5
351- Spec ref: `7.4.5`
352- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`.
353- SQL contract: session lookup by `thread_id` from projection table.
354- Current writer path: mixed provider/session handling paths
355- Current reader path: legacy provider session persistence lookups
356- Owner module: provider dispatch/session resolution
357- Status: `to-replace`
358- Notes: remove provider-session-as-routing-key behavior.
359
360### R6
361- Spec ref: `7.4.6`
362- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors.
363- SQL contract: `MIN(last_applied_sequence)` across required projector keys.
364- Current writer path: none
365- Current reader path: currently from in-memory event projection sequence
366- Owner module: snapshot query service
367- Status: `to-replace`
368- Notes: must move from in-memory sequence to DB projection-state semantics.
369
370### R7
371- Spec ref: `7.4.7`
372- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence).
373- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start.
374- Current writer path: event stream via `OrchestrationEventStore.readFromSequence`
375- Current reader path: ws replay flow in `apps/server/src/wsServer.ts`
376- Owner module: ws RPC + event stream handoff layer
377- Status: `to-replace`
378- Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent).
379
380## Ambiguous/Interpretation Decisions (tracked upfront)
381
382### A1
383- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming.
384- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code.
385
386### A2
387- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload.
388- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries.
389
390### A3
391- Topic: `snapshotSequence` dependency set for min-sequence computation.
392- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`).
393
394### A4
395- Topic: no-gap handoff mechanism in `7.4.7`.
396- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`.
397
398## Checklist Completeness Statement
399- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`.
400- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`.
401- Unclassified rows: `0`.
SPEC.md+2-1
......@@ -55,7 +55,7 @@ Output for every command: `DispatchResultSchema = { sequence: number }`.
5555- Input:
5656 - `commandId: CommandId`
5757 - `projectId: ProjectId`
58 - optional `{ title, workspaceRoot, defaultModel }`
58 - optional `{ title, workspaceRoot, defaultModel, scripts }`
5959- Output: `DispatchResult`
6060
61613. `project.delete`
......@@ -427,6 +427,7 @@ Rules:
427427- `title: string`
428428- `workspaceRoot: string`
429429- `defaultModel: string | null`
430- `scripts: ProjectScript[]`
430431- `createdAt: IsoDateTime`
431432- `updatedAt: IsoDateTime`
432433- `deletedAt: IsoDateTime | null`
apps/server/integration/providerService.integration.test.ts+24-116
......@@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node";
33import { it, assert } from "@effect/vitest";
44import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
55
6import { runGit as runGitProcess } from "../src/git/Process.ts";
7import { CheckpointServiceLive } from "../src/checkpointing/Layers/CheckpointService.ts";
8import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
96import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
107import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
118import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
12import { ProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
9import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
1310import {
1411 ProviderService,
1512 type ProviderServiceShape,
1613} from "../src/provider/Services/ProviderService.ts";
1714import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
18import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts";
19import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts";
15import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
2016
2117import {
2218 makeTestProviderAdapterHarness,
......@@ -29,34 +25,11 @@ import {
2925 codexTurnTextFixture,
3026} from "./fixtures/providerRuntime.ts";
3127
32const runGit = (cwd: string, args: ReadonlyArray<string>, allowNonZeroExit = false) =>
33 runGitProcess({
34 operation: "providerService.integration.git",
35 cwd,
36 args,
37 allowNonZeroExit,
38 }).pipe(Effect.provide(NodeServices.layer));
39
40const gitRefExists = (cwd: string, ref: string) =>
41 runGit(cwd, ["show-ref", "--verify", "--quiet", ref], true).pipe(
42 Effect.map((result) => result.code === 0),
43 );
44
45const checkpointRefForTurn = (threadId: string, turnCount: number) =>
46 `refs/t3/checkpoints/${Buffer.from(threadId, "utf8").toString("base64url")}/turn/${turnCount}`;
47
48const makeGitRepository = Effect.gen(function* () {
28const makeWorkspaceDirectory = Effect.gen(function* () {
4929 const fs = yield* FileSystem.FileSystem;
5030 const pathService = yield* Path.Path;
5131 const cwd = yield* fs.makeTempDirectory();
52
53 yield* runGit(cwd, ["init", "--initial-branch=main"]);
54 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
55 yield* runGit(cwd, ["config", "user.name", "Test User"]);
5632 yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n");
57 yield* runGit(cwd, ["add", "."]);
58 yield* runGit(cwd, ["commit", "-m", "Initial"]);
59
6033 return cwd;
6134}).pipe(Effect.provide(NodeServices.layer));
6235
......@@ -67,7 +40,7 @@ interface IntegrationFixture {
6740}
6841
6942const makeIntegrationFixture = Effect.gen(function* () {
70 const cwd = yield* makeGitRepository;
43 const cwd = yield* makeWorkspaceDirectory;
7144 const harness = yield* makeTestProviderAdapterHarness;
7245
7346 const registry: typeof ProviderAdapterRegistry.Service = {
......@@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () {
7952 };
8053
8154 const directoryLayer = ProviderSessionDirectoryLive.pipe(
82 Layer.provide(ProviderSessionRepositoryLive),
55 Layer.provide(ProviderSessionRuntimeRepositoryLive),
8356 );
8457
8558 const shared = Layer.mergeAll(
8659 directoryLayer,
8760 Layer.succeed(ProviderAdapterRegistry, registry),
88 Layer.provide(CheckpointStoreLive, NodeServices.layer),
89 CheckpointRepositoryLive,
9061 ).pipe(Layer.provide(SqlitePersistenceMemory));
9162
92 const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared));
93
94 const layer = ProviderServiceLive.pipe(
95 Layer.provide(Layer.mergeAll(shared, checkpointLayer)),
96 Layer.merge(NodeServices.layer),
97 );
63 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
9864
9965 return {
10066 cwd,
......@@ -135,7 +101,7 @@ const runTurn = (input: {
135101
136102 return yield* collectEventsDuring(
137103 input.provider.streamEvents,
138 input.response.events.length + 1,
104 input.response.events.length,
139105 input.provider.sendTurn({
140106 sessionId: input.sessionId,
141107 input: input.userText,
......@@ -144,7 +110,7 @@ const runTurn = (input: {
144110 );
145111 });
146112
147it.effect("replays typed runtime fixture and emits checkpoint.captured", () =>
113it.effect("replays typed runtime fixture events", () =>
148114 Effect.gen(function* () {
149115 const fixture = yield* makeIntegrationFixture;
150116
......@@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () =>
154120 provider: "codex",
155121 cwd: fixture.cwd,
156122 });
157 const threadId = session.threadId ?? "";
158 assert.equal(threadId.length > 0, true);
123 assert.equal((session.threadId ?? "").length > 0, true);
159124
160125 const observedEvents = yield* runTurn({
161126 provider,
162127 harness: fixture.harness,
163128 sessionId: session.sessionId,
164129 userText: "hello",
165 response: { events: codexTurnTextFixture },
130 response: { events: codexTurnTextFixture },
166131 });
167132
168133 assert.deepEqual(
169134 observedEvents.map((event) => event.type),
170 [...codexTurnTextFixture.map((event) => event.type), "checkpoint.captured"],
135 codexTurnTextFixture.map((event) => event.type),
171136 );
172
173 const checkpoints = yield* provider.listCheckpoints({ sessionId: session.sessionId });
174 assert.deepEqual(
175 checkpoints.checkpoints.map((checkpoint) => checkpoint.turnCount),
176 [0, 1],
177 );
178
179 const diff = yield* provider.getCheckpointDiff({
180 sessionId: session.sessionId,
181 fromTurnCount: 0,
182 toTurnCount: 1,
183 });
184 assert.equal(diff.diff.trim(), "");
185
186 const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1));
187 assert.equal(turnOneRefExists, true);
188137 }).pipe(Effect.provide(fixture.layer));
189 }),
138 }).pipe(Effect.provide(NodeServices.layer)),
190139);
191140
192it.effect("captures file-changing fixture turn and produces a non-empty diff", () =>
141it.effect("replays file-changing fixture turn events", () =>
193142 Effect.gen(function* () {
194143 const fixture = yield* makeIntegrationFixture;
195144 const { join } = yield* Path.Path;
......@@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (
201150 provider: "codex",
202151 cwd: fixture.cwd,
203152 });
204 const threadId = session.threadId ?? "";
205 assert.equal(threadId.length > 0, true);
153 assert.equal((session.threadId ?? "").length > 0, true);
206154
207155 const observedEvents = yield* runTurn({
208156 provider,
......@@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (
218166
219167 assert.deepEqual(
220168 observedEvents.map((event) => event.type),
221 [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"],
169 codexTurnToolFixture.map((event) => event.type),
222170 );
223
224 const diff = yield* provider.getCheckpointDiff({
225 sessionId: session.sessionId,
226 fromTurnCount: 0,
227 toTurnCount: 1,
228 });
229 assert.equal(diff.diff.includes("README.md"), true);
230
231 const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1));
232 assert.equal(turnOneRefExists, true);
233171 }).pipe(Effect.provide(fixture.layer));
234172 }).pipe(Effect.provide(NodeServices.layer)),
235173);
236174
237it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =>
175it.effect("runs multi-turn tool/approval flow", () =>
238176 Effect.gen(function* () {
239177 const fixture = yield* makeIntegrationFixture;
240178 const { join } = yield* Path.Path;
......@@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
246184 provider: "codex",
247185 cwd: fixture.cwd,
248186 });
249 const threadId = session.threadId ?? "";
250 assert.equal(threadId.length > 0, true);
187 assert.equal((session.threadId ?? "").length > 0, true);
251188
252189 const firstTurnEvents = yield* runTurn({
253190 provider,
......@@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
262199 });
263200 assert.deepEqual(
264201 firstTurnEvents.map((event) => event.type),
265 [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"],
202 codexTurnToolFixture.map((event) => event.type),
266203 );
267204
268205 const secondTurnEvents = yield* runTurn({
......@@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
278215 });
279216 assert.deepEqual(
280217 secondTurnEvents.map((event) => event.type),
281 [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"],
282 );
283
284 const turnStateBeforeRevert = yield* provider.listCheckpoints({
285 sessionId: session.sessionId,
286 });
287 assert.deepEqual(
288 turnStateBeforeRevert.checkpoints.map((checkpoint) => checkpoint.turnCount),
289 [0, 1, 2],
218 codexTurnApprovalFixture.map((event) => event.type),
290219 );
291 const current =
292 turnStateBeforeRevert.checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
293 turnStateBeforeRevert.checkpoints[turnStateBeforeRevert.checkpoints.length - 1];
294 assert.equal(current?.turnCount, 2);
295220 }).pipe(Effect.provide(fixture.layer));
296221 }).pipe(Effect.provide(NodeServices.layer)),
297222);
298223
299it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>
224it.effect("rolls back provider conversation state only", () =>
300225 Effect.gen(function* () {
301226 const fixture = yield* makeIntegrationFixture;
302227 const { join } = yield* Path.Path;
......@@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>
308233 provider: "codex",
309234 cwd: fixture.cwd,
310235 });
311 const threadId = session.threadId ?? "";
312 assert.equal(threadId.length > 0, true);
236 assert.equal((session.threadId ?? "").length > 0, true);
313237
314238 yield* runTurn({
315239 provider,
......@@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>
335259 },
336260 });
337261
338 const turnTwoRef = checkpointRefForTurn(threadId, 2);
339 const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef);
340 assert.equal(turnTwoRefBefore, true);
341
342 const revert = yield* provider.revertToCheckpoint({
262 yield* provider.rollbackConversation({
343263 sessionId: session.sessionId,
344 turnCount: 1,
264 numTurns: 1,
345265 });
346 assert.equal(revert.rolledBackTurns, 1);
347266
348267 const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId);
349268 assert.deepEqual(rollbackCalls, [1]);
350269
351270 const readme = yield* readFileString(join(fixture.cwd, "README.md"));
352 assert.equal(readme, "v2\n");
353
354 const afterRevertCheckpoints = yield* provider.listCheckpoints({
355 sessionId: session.sessionId,
356 });
357 assert.deepEqual(
358 afterRevertCheckpoints.checkpoints.map((checkpoint) => checkpoint.turnCount),
359 [0, 1],
360 );
361
362 const turnTwoRefAfter = yield* gitRefExists(fixture.cwd, turnTwoRef);
363 assert.equal(turnTwoRefAfter, false);
271 assert.equal(readme, "v3\n");
364272 }).pipe(Effect.provide(fixture.layer));
365273 }).pipe(Effect.provide(NodeServices.layer)),
366274);
apps/server/src/checkpointing/Errors.ts+5-54
......@@ -1,39 +1,6 @@
11import { Schema } from "effect";
2import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts";
3
4/**
5 * CheckpointValidationError - Invalid checkpoint API input.
6 */
7export class CheckpointValidationError extends Schema.TaggedErrorClass<CheckpointValidationError>()(
8 "CheckpointValidationError",
9 {
10 operation: Schema.String,
11 issue: Schema.String,
12 cause: Schema.optional(Schema.Defect),
13 },
14) {
15 override get message(): string {
16 return `Checkpoint validation failed in ${this.operation}: ${this.issue}`;
17 }
18}
19
20/**
21 * CheckpointGitCommandError - Git command execution failed in checkpoint store.
22 */
23export class CheckpointGitCommandError extends Schema.TaggedErrorClass<CheckpointGitCommandError>()(
24 "CheckpointGitCommandError",
25 {
26 operation: Schema.String,
27 command: Schema.String,
28 cwd: Schema.String,
29 detail: Schema.String,
30 cause: Schema.optional(Schema.Defect),
31 },
32) {
33 override get message(): string {
34 return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
35 }
36}
2import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
3import { GitCommandError } from "../git/Errors.ts";
374
385/**
396 * CheckpointUnavailableError - Expected checkpoint does not exist.
......@@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin
6835 }
6936}
7037
71/**
72 * CheckpointServiceValidationError - Invalid application-level checkpoint request.
73 */
74export class CheckpointServiceValidationError extends Schema.TaggedErrorClass<CheckpointServiceValidationError>()(
75 "CheckpointServiceValidationError",
76 {
77 operation: Schema.String,
78 issue: Schema.String,
79 cause: Schema.optional(Schema.Defect),
80 },
81) {
82 override get message(): string {
83 return `Checkpoint service validation failed in ${this.operation}: ${this.issue}`;
84 }
85}
86
8738/**
8839 * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow.
8940 */
......@@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint
11667}
11768
11869export type CheckpointStoreError =
119 | CheckpointGitCommandError
70 | GitCommandError
71 | CheckpointInvariantError
12072 | CheckpointUnavailableError
12173 | CheckpointRepositoryError;
12274
12375export type CheckpointServiceError =
12476 | CheckpointStoreError
125 | CheckpointMetadataRepositoryError
126 | CheckpointServiceValidationError
77 | ProjectionRepositoryError
12778 | CheckpointSessionNotFoundError
12879 | CheckpointInvariantError;
apps/server/src/checkpointing/Layers/CheckpointService.test.ts deleted-549
......@@ -1,549 +0,0 @@
1import type {
2 ProviderApprovalDecision,
3 ProviderKind,
4 ProviderSendTurnInput,
5 ProviderSession,
6 ProviderSessionStartInput,
7 ProviderTurnStartResult,
8} from "@t3tools/contracts";
9import { NodeServices } from "@effect/platform-node";
10import { it, assert } from "@effect/vitest";
11import { Effect, FileSystem, Layer, Path, Stream } from "effect";
12
13import { runGit as runGitProcess } from "../../git/Process.ts";
14import { CheckpointServiceLive } from "./CheckpointService.ts";
15import { CheckpointStoreLive } from "./CheckpointStore.ts";
16import { CheckpointService } from "../Services/CheckpointService.ts";
17import { CheckpointStore } from "../Services/CheckpointStore.ts";
18import {
19 ProviderAdapterSessionNotFoundError,
20 ProviderAdapterValidationError,
21 ProviderUnsupportedError,
22 type ProviderAdapterError,
23} from "../../provider/Errors.ts";
24import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts";
25import type {
26 ProviderAdapterShape,
27 ProviderThreadSnapshot,
28 ProviderThreadTurnSnapshot,
29} from "../../provider/Services/ProviderAdapter.ts";
30import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts";
31import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts";
32import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
33import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts";
34import { CheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts";
35import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts";
36
37const runGit = (cwd: string, args: readonly string[]) =>
38 runGitProcess({
39 operation: "CheckpointService.test.git",
40 cwd,
41 args,
42 }).pipe(
43 Effect.map((result) => result.stdout.trim()),
44 Effect.provide(NodeServices.layer),
45 );
46
47const makeGitRepository = Effect.gen(function* () {
48 const fs = yield* FileSystem.FileSystem;
49 const path = yield* Path.Path;
50 const cwd = yield* fs.makeTempDirectory();
51 yield* runGit(cwd, ["init", "--initial-branch=main"]);
52 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
53 yield* runGit(cwd, ["config", "user.name", "Test User"]);
54 yield* fs.writeFileString(path.join(cwd, "README.md"), "v1\n");
55 yield* runGit(cwd, ["add", "."]);
56 yield* runGit(cwd, ["commit", "-m", "Initial"]);
57 return cwd;
58});
59
60class InMemoryProviderAdapter implements ProviderAdapterShape<ProviderAdapterError> {
61 readonly provider = "codex" as const;
62
63 private readThreadFailure: ProviderAdapterError | undefined;
64 private readonly sessions = new Map<string, ProviderSession>();
65 private readonly snapshots = new Map<string, ProviderThreadSnapshot>();
66
67 constructor(
68 readonly sessionId: string,
69 readonly cwd: string,
70 turns: ReadonlyArray<ProviderThreadTurnSnapshot>,
71 ) {
72 this.addSession({
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,
87 provider: "codex",
88 status: "ready",
89 threadId: input.threadId,
90 cwd: this.cwd,
91 createdAt: now,
92 updatedAt: now,
93 });
94 this.snapshots.set(input.sessionId, {
95 threadId: input.threadId,
96 turns: input.turns,
97 });
98 }
99
100 setTurns(turns: ReadonlyArray<ProviderThreadTurnSnapshot>, sessionId = this.sessionId): void {
101 const snapshot = this.snapshots.get(sessionId);
102 if (!snapshot) {
103 return;
104 }
105 this.snapshots.set(sessionId, {
106 threadId: snapshot.threadId,
107 turns,
108 });
109 }
110
111 getTurns(sessionId = this.sessionId): ReadonlyArray<ProviderThreadTurnSnapshot> {
112 return this.snapshots.get(sessionId)?.turns ?? [];
113 }
114
115 setReadThreadFailure(error: ProviderAdapterError | undefined): void {
116 this.readThreadFailure = error;
117 }
118
119 readonly startSession = (
120 _input: ProviderSessionStartInput,
121 ): Effect.Effect<ProviderSession, ProviderAdapterError> =>
122 Effect.fail(
123 new ProviderAdapterValidationError({
124 provider: this.provider,
125 operation: "startSession",
126 issue: "not implemented in test adapter",
127 }),
128 );
129
130 readonly sendTurn = (
131 _input: ProviderSendTurnInput,
132 ): Effect.Effect<ProviderTurnStartResult, ProviderAdapterError> =>
133 Effect.fail(
134 new ProviderAdapterValidationError({
135 provider: this.provider,
136 operation: "sendTurn",
137 issue: "not implemented in test adapter",
138 }),
139 );
140
141 readonly interruptTurn = (
142 _sessionId: string,
143 _turnId?: string,
144 ): Effect.Effect<void, ProviderAdapterError> => Effect.void;
145
146 readonly respondToRequest = (
147 _sessionId: string,
148 _requestId: string,
149 _decision: ProviderApprovalDecision,
150 ): Effect.Effect<void, ProviderAdapterError> => Effect.void;
151
152 readonly stopSession = (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>
153 Effect.sync(() => {
154 this.sessions.delete(sessionId);
155 this.snapshots.delete(sessionId);
156 });
157
158 readonly listSessions = (): Effect.Effect<ReadonlyArray<ProviderSession>> =>
159 Effect.sync(() => Array.from(this.sessions.values()));
160
161 readonly hasSession = (sessionId: string): Effect.Effect<boolean> =>
162 Effect.succeed(this.sessions.has(sessionId));
163
164 readonly readThread = (
165 sessionId: string,
166 ): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> =>
167 this.hasSession(sessionId).pipe(
168 Effect.flatMap((hasSession) =>
169 hasSession
170 ? this.readThreadFailure
171 ? Effect.fail(this.readThreadFailure)
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 })()
184 : Effect.fail(
185 new ProviderAdapterSessionNotFoundError({
186 provider: this.provider,
187 sessionId,
188 }),
189 ),
190 ),
191 );
192
193 readonly rollbackThread = (
194 sessionId: string,
195 numTurns: number,
196 ): Effect.Effect<ProviderThreadSnapshot, ProviderAdapterError> =>
197 this.readThread(sessionId).pipe(
198 Effect.flatMap((snapshot) => {
199 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > snapshot.turns.length) {
200 return Effect.fail(
201 new ProviderAdapterValidationError({
202 provider: this.provider,
203 operation: "rollbackThread",
204 issue: "invalid rollback input",
205 }),
206 );
207 }
208 const next = {
209 threadId: snapshot.threadId,
210 turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns),
211 } satisfies ProviderThreadSnapshot;
212 this.snapshots.set(sessionId, next);
213 return Effect.succeed(next);
214 }),
215 );
216
217 readonly stopAll = (): Effect.Effect<void, ProviderAdapterError> =>
218 Effect.sync(() => {
219 this.sessions.clear();
220 this.snapshots.clear();
221 });
222
223 readonly streamEvents = Stream.empty;
224}
225
226function makeFixture(turns: ReadonlyArray<ProviderThreadTurnSnapshot>) {
227 return Effect.gen(function* () {
228 const cwd = yield* makeGitRepository;
229
230 const sessionId = "sess-1";
231 const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns);
232
233 const registry: typeof ProviderAdapterRegistry.Service = {
234 getByProvider: (provider: ProviderKind) =>
235 provider === "codex"
236 ? Effect.succeed(adapter)
237 : Effect.fail(new ProviderUnsupportedError({ provider })),
238 listProviders: () => Effect.succeed(["codex"]),
239 };
240
241 const directoryLayer = ProviderSessionDirectoryLive.pipe(
242 Layer.provide(ProviderSessionRepositoryLive),
243 );
244
245 const dependencies = Layer.mergeAll(
246 Layer.provide(CheckpointStoreLive, NodeServices.layer),
247 CheckpointRepositoryLive,
248 Layer.succeed(ProviderAdapterRegistry, registry),
249 directoryLayer,
250 NodeServices.layer,
251 ).pipe(Layer.provide(SqlitePersistenceMemory));
252
253 const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies));
254
255 return {
256 layer,
257 adapter,
258 sessionId,
259 cwd,
260 };
261 });
262}
263
264it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () =>
265 Effect.gen(function* () {
266 const firstTurn: ProviderThreadTurnSnapshot = {
267 id: "turn-1",
268 items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }],
269 };
270 const secondTurn: ProviderThreadTurnSnapshot = {
271 id: "turn-2",
272 items: [{ type: "agentMessage", text: "second" }],
273 };
274
275 const fixture = yield* makeFixture([]);
276
277 yield* Effect.gen(function* () {
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280
281 const service = yield* CheckpointService;
282 const directory = yield* ProviderSessionDirectory;
283
284 yield* directory.upsert({
285 sessionId: fixture.sessionId,
286 provider: "codex",
287 threadId: "thread-1",
288 });
289
290 yield* service.initializeForSession({
291 providerSessionId: fixture.sessionId,
292 cwd: fixture.cwd,
293 });
294
295 yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n");
296 fixture.adapter.setTurns([firstTurn]);
297
298 yield* service.captureCurrentTurn({
299 providerSessionId: fixture.sessionId,
300 });
301
302 const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId });
303 assert.equal(listed.checkpoints.length, 2);
304 assert.equal(listed.checkpoints[1]?.id, "turn-1");
305 assert.equal(listed.checkpoints[1]?.isCurrent, true);
306
307 const diff = yield* service.getCheckpointDiff({
308 sessionId: fixture.sessionId,
309 fromTurnCount: 0,
310 toTurnCount: 1,
311 });
312 assert.equal(diff.threadId, "thread-1");
313 assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true);
314 assert.equal(diff.diff.includes("-v1"), true);
315 assert.equal(diff.diff.includes("+v2"), true);
316
317 yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n");
318 fixture.adapter.setTurns([firstTurn, secondTurn]);
319
320 yield* service.captureCurrentTurn({
321 providerSessionId: fixture.sessionId,
322 });
323
324 const beforeRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md"));
325 assert.equal(beforeRevert, "v3\n");
326
327 const reverted = yield* service.revertToCheckpoint({
328 sessionId: fixture.sessionId,
329 turnCount: 1,
330 });
331
332 const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md"));
333 assert.equal(afterRevert, "v2\n");
334 assert.equal(reverted.turnCount, 1);
335 assert.equal(reverted.rolledBackTurns, 1);
336 assert.equal(reverted.checkpoints.length, 2);
337 assert.equal(reverted.checkpoints[1]?.id, "turn-1");
338 assert.equal(reverted.checkpoints[1]?.isCurrent, true);
339 assert.equal(fixture.adapter.getTurns().length, 1);
340 }).pipe(Effect.provide(fixture.layer));
341 }).pipe(Effect.provide(NodeServices.layer)),
342);
343
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
426it.effect(
427 "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions",
428 () =>
429 Effect.gen(function* () {
430 const fixture = yield* makeFixture([]);
431
432 yield* Effect.gen(function* () {
433 const service = yield* CheckpointService;
434 const result = yield* service
435 .listCheckpoints({
436 sessionId: "missing",
437 })
438 .pipe(Effect.result);
439
440 assert.equal(result._tag, "Failure");
441 if (result._tag === "Failure") {
442 assert.equal(result.failure._tag, "CheckpointSessionNotFoundError");
443 }
444 }).pipe(Effect.provide(fixture.layer));
445 }).pipe(Effect.provide(NodeServices.layer)),
446);
447
448it.effect(
449 "CheckpointServiceLive fails with CheckpointUnavailableError when a checkpoint ref is missing",
450 () =>
451 Effect.gen(function* () {
452 const firstTurn: ProviderThreadTurnSnapshot = {
453 id: "turn-1",
454 items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }],
455 };
456
457 const fixture = yield* makeFixture([]);
458
459 yield* Effect.gen(function* () {
460 const fs = yield* FileSystem.FileSystem;
461 const path = yield* Path.Path;
462 const service = yield* CheckpointService;
463 const store = yield* CheckpointStore;
464 const checkpointRepository = yield* CheckpointRepository;
465 const directory = yield* ProviderSessionDirectory;
466
467 yield* directory.upsert({
468 sessionId: fixture.sessionId,
469 provider: "codex",
470 threadId: "thread-1",
471 });
472
473 yield* service.initializeForSession({
474 providerSessionId: fixture.sessionId,
475 cwd: fixture.cwd,
476 });
477
478 yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n");
479 fixture.adapter.setTurns([firstTurn]);
480 yield* service.captureCurrentTurn({
481 providerSessionId: fixture.sessionId,
482 });
483
484 const checkpoint = yield* checkpointRepository.getCheckpoint({
485 threadId: "thread-1",
486 turnCount: 1,
487 });
488 if (checkpoint._tag !== "Some") {
489 assert.fail("Expected turn-1 checkpoint row to exist.");
490 }
491
492 yield* store.deleteCheckpointRefs({
493 cwd: fixture.cwd,
494 checkpointRefs: [checkpoint.value.checkpointRef],
495 });
496
497 const result = yield* service
498 .revertToCheckpoint({
499 sessionId: fixture.sessionId,
500 turnCount: 1,
501 })
502 .pipe(Effect.result);
503
504 assert.equal(result._tag, "Failure");
505 if (result._tag === "Failure") {
506 assert.equal(result.failure._tag, "CheckpointUnavailableError");
507 }
508 }).pipe(Effect.provide(fixture.layer));
509 }).pipe(Effect.provide(NodeServices.layer)),
510);
511
512it.effect("CheckpointServiceLive initializes root checkpoints without requiring thread/read", () =>
513 Effect.gen(function* () {
514 const fixture = yield* makeFixture([]);
515
516 yield* Effect.gen(function* () {
517 const service = yield* CheckpointService;
518 const checkpointRepository = yield* CheckpointRepository;
519 const directory = yield* ProviderSessionDirectory;
520
521 yield* directory.upsert({
522 sessionId: fixture.sessionId,
523 provider: "codex",
524 threadId: "thread-1",
525 });
526 fixture.adapter.setReadThreadFailure(
527 new ProviderAdapterValidationError({
528 provider: "codex",
529 operation: "readThread",
530 issue: "thread/read is temporarily unavailable",
531 }),
532 );
533
534 yield* service.initializeForSession({
535 providerSessionId: fixture.sessionId,
536 cwd: fixture.cwd,
537 });
538
539 const root = yield* checkpointRepository.getCheckpoint({
540 threadId: "thread-1",
541 turnCount: 0,
542 });
543 assert.equal(root._tag, "Some");
544 if (root._tag === "Some") {
545 assert.equal(root.value.threadId, "thread-1");
546 }
547 }).pipe(Effect.provide(fixture.layer));
548 }).pipe(Effect.provide(NodeServices.layer)),
549);
apps/server/src/checkpointing/Layers/CheckpointService.ts deleted-1108
......@@ -1,1108 +0,0 @@
1/**
2 * CheckpointServiceLive - Checkpoint workflow orchestration layer.
3 *
4 * Coordinates provider thread snapshots, filesystem checkpoint refs
5 * (`CheckpointStore`), and checkpoint metadata persistence
6 * (`CheckpointRepository`) for initialize/capture/list/diff/revert workflows.
7 *
8 * This layer owns checkpoint workflow rules and invariants. It does not execute
9 * provider protocol details directly (adapter concern), and it does not perform
10 * raw SQL or Git operations directly (catalog/store concerns).
11 *
12 * @module CheckpointServiceLive
13 */
14import type {
15 ProviderCheckpoint,
16 ProviderGetCheckpointDiffInput,
17 ProviderListCheckpointsInput,
18 ProviderRevertToCheckpointInput,
19} from "@t3tools/contracts";
20import {
21 providerGetCheckpointDiffInputSchema,
22 providerListCheckpointsInputSchema,
23 providerRevertToCheckpointInputSchema,
24} from "@t3tools/contracts";
25import { Effect, Layer, Option, Ref, Semaphore } from "effect";
26
27import {
28 CheckpointInvariantError,
29 CheckpointRepositoryError,
30 CheckpointServiceValidationError,
31 CheckpointSessionNotFoundError,
32 CheckpointUnavailableError,
33 type CheckpointServiceError,
34} from "../Errors.ts";
35import {
36 CheckpointService,
37 type CaptureCurrentTurnInput,
38 type CheckpointServiceShape,
39 type InitializeCheckpointSessionInput,
40} from "../Services/CheckpointService.ts";
41import { CheckpointStore } from "../Services/CheckpointStore.ts";
42import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts";
43import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts";
44import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts";
45import type {
46 ProviderAdapterShape,
47 ProviderThreadSnapshot,
48 ProviderThreadTurnSnapshot,
49} from "../../provider/Services/ProviderAdapter.ts";
50import type { ProviderAdapterError } from "../../provider/Errors.ts";
51
52interface CheckpointCwdResolution {
53 readonly candidateCwd: string;
54 readonly checkpointCwd: Option.Option<string>;
55}
56
57const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
58
59function checkpointRefThreadSegment(threadId: string): string {
60 return Buffer.from(threadId, "utf8").toString("base64url");
61}
62
63function checkpointRefForThreadTurn(threadId: string, turnCount: number): string {
64 return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`;
65}
66
67function asObject(value: unknown): Record<string, unknown> | undefined {
68 if (!value || typeof value !== "object") {
69 return undefined;
70 }
71 return value as Record<string, unknown>;
72}
73
74function asString(value: unknown): string | undefined {
75 return typeof value === "string" ? value : undefined;
76}
77
78function asArray(value: unknown): unknown[] {
79 return Array.isArray(value) ? value : [];
80}
81
82function trimToPreview(value: string): string {
83 const normalized = value.replace(/\s+/g, " ").trim();
84 if (normalized.length <= 120) {
85 return normalized;
86 }
87 return `${normalized.slice(0, 117)}...`;
88}
89
90function summarizeUserMessageContent(content: unknown[]): string | undefined {
91 const segments: string[] = [];
92
93 for (const part of content) {
94 const record = asObject(part);
95 const type = asString(record?.type);
96 if (!type) {
97 continue;
98 }
99
100 if (type === "text") {
101 const text = asString(record?.text);
102 if (text && text.trim().length > 0) {
103 segments.push(text.trim());
104 }
105 continue;
106 }
107
108 if (type === "image") {
109 segments.push("[Image attachment]");
110 continue;
111 }
112
113 if (type === "localImage") {
114 segments.push("[Local image attachment]");
115 }
116 }
117
118 if (segments.length === 0) {
119 return undefined;
120 }
121 return trimToPreview(segments.join(" "));
122}
123
124function summarizeTurn(turn: ProviderThreadTurnSnapshot): {
125 readonly messageCountDelta: number;
126 readonly preview?: string;
127} {
128 let messageCountDelta = 0;
129 let preview: string | undefined;
130
131 for (const item of turn.items) {
132 const record = asObject(item);
133 const type = asString(record?.type);
134 if (!type) {
135 continue;
136 }
137
138 if (type === "userMessage") {
139 messageCountDelta += 1;
140 if (!preview) {
141 const content = asArray(record?.content);
142 preview = summarizeUserMessageContent(content);
143 }
144 continue;
145 }
146
147 if (type === "agentMessage") {
148 messageCountDelta += 1;
149 if (!preview) {
150 const text = asString(record?.text);
151 if (text && text.trim().length > 0) {
152 preview = trimToPreview(text);
153 }
154 }
155 }
156 }
157
158 return {
159 messageCountDelta,
160 ...(preview ? { preview } : {}),
161 };
162}
163
164function buildCheckpoints(turns: ReadonlyArray<ProviderThreadTurnSnapshot>): ProviderCheckpoint[] {
165 const checkpoints: ProviderCheckpoint[] = [];
166 let messageCount = 0;
167 const isEmpty = turns.length === 0;
168
169 checkpoints.push({
170 id: "root",
171 turnCount: 0,
172 messageCount: 0,
173 label: "Start of conversation",
174 isCurrent: isEmpty,
175 });
176
177 for (let index = 0; index < turns.length; index += 1) {
178 const turn = turns[index];
179 if (!turn) {
180 continue;
181 }
182
183 const turnSummary = summarizeTurn(turn);
184 messageCount += turnSummary.messageCountDelta;
185 checkpoints.push({
186 id: turn.id,
187 turnCount: index + 1,
188 messageCount,
189 label: `Turn ${index + 1}`,
190 ...(turnSummary.preview ? { preview: turnSummary.preview } : {}),
191 isCurrent: index === turns.length - 1,
192 });
193 }
194
195 return checkpoints;
196}
197
198function rootCheckpoint(): ProviderCheckpoint {
199 return {
200 id: "root",
201 turnCount: 0,
202 messageCount: 0,
203 label: "Start of conversation",
204 isCurrent: true,
205 };
206}
207
208function validationError(
209 operation: string,
210 issue: string,
211 cause?: unknown,
212): CheckpointServiceValidationError {
213 return new CheckpointServiceValidationError({
214 operation,
215 issue,
216 ...(cause !== undefined ? { cause } : {}),
217 });
218}
219
220function invariantError(
221 operation: string,
222 detail: string,
223 cause?: unknown,
224): CheckpointInvariantError {
225 return new CheckpointInvariantError({
226 operation,
227 detail,
228 ...(cause !== undefined ? { cause } : {}),
229 });
230}
231
232function normalizeNonEmpty(
233 value: string,
234 operation: string,
235 field: string,
236): Effect.Effect<string, CheckpointServiceValidationError> {
237 const normalized = value.trim();
238 if (normalized.length === 0) {
239 return Effect.fail(validationError(operation, `${field} must be a non-empty string.`));
240 }
241 return Effect.succeed(normalized);
242}
243
244function decodeInitializeInput(
245 input: InitializeCheckpointSessionInput,
246): Effect.Effect<InitializeCheckpointSessionInput, CheckpointServiceValidationError> {
247 return Effect.gen(function* () {
248 const operation = "CheckpointService.initializeForSession";
249 const providerSessionId = yield* normalizeNonEmpty(
250 input.providerSessionId,
251 operation,
252 "providerSessionId",
253 );
254 const cwd = yield* normalizeNonEmpty(input.cwd, operation, "cwd");
255 return {
256 providerSessionId,
257 cwd,
258 } satisfies InitializeCheckpointSessionInput;
259 });
260}
261
262function decodeCaptureInput(
263 input: CaptureCurrentTurnInput,
264): Effect.Effect<CaptureCurrentTurnInput, CheckpointServiceValidationError> {
265 return Effect.gen(function* () {
266 const operation = "CheckpointService.captureCurrentTurn";
267 const providerSessionId = yield* normalizeNonEmpty(
268 input.providerSessionId,
269 operation,
270 "providerSessionId",
271 );
272 const turnId = input.turnId?.trim();
273 const status = input.status?.trim();
274
275 return {
276 providerSessionId,
277 ...(turnId ? { turnId } : {}),
278 ...(status ? { status } : {}),
279 } satisfies CaptureCurrentTurnInput;
280 });
281}
282
283function decodeListInput(
284 input: ProviderListCheckpointsInput,
285): Effect.Effect<ProviderListCheckpointsInput, CheckpointServiceValidationError> {
286 const parsed = providerListCheckpointsInputSchema.safeParse(input);
287 if (!parsed.success) {
288 return Effect.fail(
289 validationError(
290 "CheckpointService.listCheckpoints",
291 parsed.error.message,
292 parsed.error.cause,
293 ),
294 );
295 }
296 return Effect.succeed(parsed.data);
297}
298
299function decodeDiffInput(
300 input: ProviderGetCheckpointDiffInput,
301): Effect.Effect<ProviderGetCheckpointDiffInput, CheckpointServiceValidationError> {
302 const parsed = providerGetCheckpointDiffInputSchema.safeParse(input);
303 if (!parsed.success) {
304 return Effect.fail(
305 validationError(
306 "CheckpointService.getCheckpointDiff",
307 parsed.error.message,
308 parsed.error.cause,
309 ),
310 );
311 }
312 return Effect.succeed(parsed.data);
313}
314
315function decodeRevertInput(
316 input: ProviderRevertToCheckpointInput,
317): Effect.Effect<ProviderRevertToCheckpointInput, CheckpointServiceValidationError> {
318 const parsed = providerRevertToCheckpointInputSchema.safeParse(input);
319 if (!parsed.success) {
320 return Effect.fail(
321 validationError(
322 "CheckpointService.revertToCheckpoint",
323 parsed.error.message,
324 parsed.error.cause,
325 ),
326 );
327 }
328 return Effect.succeed(parsed.data);
329}
330
331const makeCheckpointService = Effect.gen(function* () {
332 const store = yield* CheckpointStore;
333 const checkpointRepository = yield* CheckpointRepository;
334 const registry = yield* ProviderAdapterRegistry;
335 const directory = yield* ProviderSessionDirectory;
336
337 const sessionCheckpointCwdsRef = yield* Ref.make<Map<string, string>>(new Map<string, string>());
338 const sessionSemaphoresRef = yield* Ref.make<Map<string, Semaphore.Semaphore>>(
339 new Map<string, Semaphore.Semaphore>(),
340 );
341
342 const getSessionSemaphore = (sessionId: string) =>
343 Ref.modify(sessionSemaphoresRef, (current) => {
344 const existing = current.get(sessionId);
345 if (existing) {
346 return [existing, current] as const;
347 }
348
349 const next = new Map(current);
350 const semaphore = Semaphore.makeUnsafe(1);
351 next.set(sessionId, semaphore);
352 return [semaphore, next] as const;
353 });
354
355 const withSessionLock = <A>(
356 sessionId: string,
357 effect: Effect.Effect<A, CheckpointServiceError>,
358 ) =>
359 getSessionSemaphore(sessionId).pipe(
360 Effect.flatMap((semaphore) => semaphore.withPermits(1)(effect)),
361 );
362
363 const setTrackedCheckpointCwd = (sessionId: string, cwd: string) =>
364 Ref.update(sessionCheckpointCwdsRef, (current) => {
365 const next = new Map(current);
366 next.set(sessionId, cwd);
367 return next;
368 });
369
370 const clearTrackedCheckpointCwd = (sessionId: string) =>
371 Ref.update(sessionCheckpointCwdsRef, (current) => {
372 if (!current.has(sessionId)) {
373 return current;
374 }
375 const next = new Map(current);
376 next.delete(sessionId);
377 return next;
378 });
379
380 const clearSessionSemaphore = (sessionId: string) =>
381 Ref.update(sessionSemaphoresRef, (current) => {
382 if (!current.has(sessionId)) {
383 return current;
384 }
385 const next = new Map(current);
386 next.delete(sessionId);
387 return next;
388 });
389
390 const getTrackedCheckpointCwd = (sessionId: string) =>
391 Ref.get(sessionCheckpointCwdsRef).pipe(
392 Effect.map((current) => Option.fromNullishOr(current.get(sessionId))),
393 );
394
395 const mapDirectoryLookupError = (
396 sessionId: string,
397 operation: string,
398 cause: unknown,
399 ): CheckpointSessionNotFoundError | CheckpointInvariantError => {
400 if (
401 typeof cause === "object" &&
402 cause !== null &&
403 "_tag" in cause &&
404 (cause as { _tag?: string })._tag === "ProviderSessionNotFoundError"
405 ) {
406 return new CheckpointSessionNotFoundError({
407 sessionId,
408 cause,
409 });
410 }
411
412 return invariantError(
413 operation,
414 `Failed to resolve provider session metadata for session ${sessionId}.`,
415 cause,
416 );
417 };
418
419 const resolveAdapterForSession = (
420 sessionId: string,
421 ): Effect.Effect<ProviderAdapterShape<ProviderAdapterError>, CheckpointServiceError> =>
422 directory.getProvider(sessionId).pipe(
423 Effect.mapError((cause) =>
424 mapDirectoryLookupError(
425 sessionId,
426 "CheckpointService.resolveAdapterForSession:getProvider",
427 cause,
428 ),
429 ),
430 Effect.flatMap((provider) =>
431 registry
432 .getByProvider(provider)
433 .pipe(
434 Effect.mapError((cause) =>
435 invariantError(
436 "CheckpointService.resolveAdapterForSession",
437 `Provider '${provider}' is not registered for session ${sessionId}.`,
438 cause,
439 ),
440 ),
441 ),
442 ),
443 );
444
445 const ensureSessionIsActive = (
446 sessionId: string,
447 adapter: ProviderAdapterShape<ProviderAdapterError>,
448 ): Effect.Effect<void, CheckpointSessionNotFoundError> =>
449 adapter.hasSession(sessionId).pipe(
450 Effect.flatMap((hasSession) =>
451 hasSession
452 ? Effect.void
453 : Effect.fail(
454 new CheckpointSessionNotFoundError({
455 sessionId,
456 }),
457 ),
458 ),
459 );
460
461 const readThreadSnapshot = (
462 sessionId: string,
463 adapter: ProviderAdapterShape<ProviderAdapterError>,
464 operation: string,
465 ): Effect.Effect<ProviderThreadSnapshot, CheckpointInvariantError> =>
466 adapter
467 .readThread(sessionId)
468 .pipe(
469 Effect.mapError((cause) =>
470 invariantError(
471 operation,
472 `Failed to read thread snapshot for session ${sessionId}.`,
473 cause,
474 ),
475 ),
476 );
477
478 const resolveThreadIdForSession = (
479 sessionId: string,
480 operation: string,
481 ): Effect.Effect<string, CheckpointServiceError> =>
482 directory.getThreadId(sessionId).pipe(
483 Effect.mapError((cause) =>
484 mapDirectoryLookupError(sessionId, `${operation}:getThreadId`, cause),
485 ),
486 Effect.flatMap((threadId) =>
487 Option.match(threadId, {
488 onSome: (value) => {
489 const normalizedThreadId = value.trim();
490 if (normalizedThreadId.length > 0) {
491 return Effect.succeed(normalizedThreadId);
492 }
493 return Effect.fail(
494 invariantError(operation, `Resolved empty thread id for session ${sessionId}.`),
495 );
496 },
497 onNone: () =>
498 Effect.fail(
499 invariantError(
500 operation,
501 `Missing thread id in session directory for session ${sessionId}.`,
502 ),
503 ),
504 }),
505 ),
506 );
507
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
532 const rollbackThreadSnapshot = (
533 sessionId: string,
534 adapter: ProviderAdapterShape<ProviderAdapterError>,
535 numTurns: number,
536 operation: string,
537 ): Effect.Effect<ProviderThreadSnapshot, CheckpointInvariantError> =>
538 adapter
539 .rollbackThread(sessionId, numTurns)
540 .pipe(
541 Effect.mapError((cause) =>
542 invariantError(operation, `Failed to rollback thread for session ${sessionId}.`, cause),
543 ),
544 );
545
546 const persistCheckpointMetadata = (input: {
547 readonly providerSessionId: string;
548 readonly threadId: string;
549 readonly checkpoint: ProviderCheckpoint;
550 }): Effect.Effect<void, CheckpointServiceError> =>
551 checkpointRepository.upsertCheckpoint({
552 providerSessionId: input.providerSessionId,
553 threadId: input.threadId,
554 checkpointId: input.checkpoint.id,
555 checkpointRef: checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount),
556 turnCount: input.checkpoint.turnCount,
557 messageCount: input.checkpoint.messageCount,
558 label: input.checkpoint.label,
559 ...(input.checkpoint.preview !== undefined ? { preview: input.checkpoint.preview } : {}),
560 createdAt: new Date().toISOString(),
561 });
562
563 const captureAndPersistCheckpoint = (input: {
564 readonly providerSessionId: string;
565 readonly cwd: string;
566 readonly threadId: string;
567 readonly checkpoint: ProviderCheckpoint;
568 }): Effect.Effect<void, CheckpointServiceError> => {
569 const checkpointRef = checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount);
570 return Effect.gen(function* () {
571 yield* store.captureCheckpoint({
572 cwd: input.cwd,
573 checkpointRef,
574 });
575
576 yield* persistCheckpointMetadata({
577 providerSessionId: input.providerSessionId,
578 threadId: input.threadId,
579 checkpoint: input.checkpoint,
580 }).pipe(
581 Effect.catch((error) =>
582 store
583 .deleteCheckpointRefs({
584 cwd: input.cwd,
585 checkpointRefs: [checkpointRef],
586 })
587 .pipe(
588 Effect.catch(() => Effect.void),
589 Effect.flatMap(() => Effect.fail(error)),
590 ),
591 ),
592 );
593 });
594 };
595
596 const latestCheckpointFromSnapshot = (
597 snapshot: ProviderThreadSnapshot,
598 operation: string,
599 ): Effect.Effect<ProviderCheckpoint, CheckpointInvariantError> => {
600 if (snapshot.turns.length === 0) {
601 return Effect.fail(
602 invariantError(operation, "Cannot derive latest checkpoint from an empty thread snapshot."),
603 );
604 }
605
606 const checkpoints = buildCheckpoints(snapshot.turns);
607 const latest = checkpoints[checkpoints.length - 1];
608 if (!latest || latest.turnCount !== snapshot.turns.length) {
609 return Effect.fail(
610 invariantError(
611 operation,
612 `Unable to derive checkpoint metadata for turn ${snapshot.turns.length}.`,
613 ),
614 );
615 }
616
617 return Effect.succeed(latest);
618 };
619
620 const ensureRootCheckpointMetadata = (
621 threadId: string,
622 operation: string,
623 ): Effect.Effect<void, CheckpointServiceError> =>
624 checkpointRepository
625 .getCheckpoint({ threadId, turnCount: 0 })
626 .pipe(
627 Effect.flatMap((rootEntry) =>
628 Option.isSome(rootEntry)
629 ? Effect.void
630 : Effect.fail(
631 invariantError(
632 operation,
633 `Missing root checkpoint metadata for thread ${threadId}.`,
634 ),
635 ),
636 ),
637 );
638
639 const getCheckpointRefOrFail = (
640 threadId: string,
641 turnCount: number,
642 operation: string,
643 ): Effect.Effect<string, CheckpointServiceError> =>
644 checkpointRepository.getCheckpoint({ threadId, turnCount }).pipe(
645 Effect.mapError((error): CheckpointServiceError => error),
646 Effect.flatMap((checkpoint): Effect.Effect<string, CheckpointServiceError> => {
647 if (Option.isNone(checkpoint)) {
648 return Effect.fail(
649 new CheckpointUnavailableError({
650 threadId,
651 turnCount,
652 detail: `Filesystem checkpoint is unavailable for turn ${turnCount} in thread ${threadId}.`,
653 }),
654 );
655 }
656
657 const checkpointRef = checkpoint.value.checkpointRef.trim();
658 if (checkpointRef.length === 0) {
659 return Effect.fail(
660 invariantError(
661 operation,
662 `Checkpoint metadata for turn ${turnCount} is missing checkpointRef.`,
663 ),
664 );
665 }
666
667 return Effect.succeed(checkpointRef);
668 }),
669 );
670
671 const resolveCandidateCwd = (
672 sessionId: string,
673 adapter: ProviderAdapterShape<ProviderAdapterError>,
674 ): Effect.Effect<string> =>
675 adapter.listSessions().pipe(
676 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.cwd),
677 Effect.map((cwd) => {
678 const candidate = cwd?.trim() || process.cwd();
679 if (candidate.length > 0) {
680 return candidate;
681 }
682 return process.cwd();
683 }),
684 );
685
686 const resolveCheckpointCwd = (
687 sessionId: string,
688 adapter: ProviderAdapterShape<ProviderAdapterError>,
689 ): Effect.Effect<CheckpointCwdResolution, CheckpointServiceError> =>
690 Effect.gen(function* () {
691 const tracked = yield* getTrackedCheckpointCwd(sessionId);
692 if (Option.isSome(tracked)) {
693 return {
694 candidateCwd: tracked.value,
695 checkpointCwd: tracked,
696 } satisfies CheckpointCwdResolution;
697 }
698
699 const candidateCwd = yield* resolveCandidateCwd(sessionId, adapter);
700 const supportsGit = yield* store.isGitRepository(candidateCwd);
701 if (!supportsGit) {
702 yield* clearTrackedCheckpointCwd(sessionId);
703 return {
704 candidateCwd,
705 checkpointCwd: Option.none(),
706 } satisfies CheckpointCwdResolution;
707 }
708
709 yield* setTrackedCheckpointCwd(sessionId, candidateCwd);
710
711 return {
712 candidateCwd,
713 checkpointCwd: Option.some(candidateCwd),
714 } satisfies CheckpointCwdResolution;
715 });
716
717 const initializeForSession: CheckpointServiceShape["initializeForSession"] = (rawInput) =>
718 decodeInitializeInput(rawInput).pipe(
719 Effect.flatMap((input) =>
720 withSessionLock(
721 input.providerSessionId,
722 Effect.gen(function* () {
723 const adapter = yield* resolveAdapterForSession(input.providerSessionId);
724 yield* ensureSessionIsActive(input.providerSessionId, adapter);
725 const threadId = yield* resolveThreadIdForSession(
726 input.providerSessionId,
727 "CheckpointService.initializeForSession",
728 );
729
730 const supportsGit = yield* store.isGitRepository(input.cwd);
731 if (!supportsGit) {
732 yield* clearTrackedCheckpointCwd(input.providerSessionId);
733 return;
734 }
735
736 const existingRoot = yield* checkpointRepository.getCheckpoint({
737 threadId,
738 turnCount: 0,
739 });
740 if (Option.isSome(existingRoot)) {
741 const existingRef = existingRoot.value.checkpointRef.trim();
742 if (existingRef.length === 0) {
743 return yield* invariantError(
744 "CheckpointService.initializeForSession",
745 "Existing root checkpoint metadata is missing checkpointRef.",
746 );
747 }
748 const exists = yield* store.hasCheckpointRef({
749 cwd: input.cwd,
750 checkpointRef: existingRef,
751 });
752 if (!exists) {
753 return yield* invariantError(
754 "CheckpointService.initializeForSession",
755 "Root checkpoint metadata exists but filesystem ref is missing.",
756 );
757 }
758 yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd);
759 return;
760 }
761
762 yield* captureAndPersistCheckpoint({
763 providerSessionId: input.providerSessionId,
764 cwd: input.cwd,
765 threadId,
766 checkpoint: rootCheckpoint(),
767 });
768 yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd);
769 }),
770 ),
771 ),
772 );
773
774 const captureCurrentTurn: CheckpointServiceShape["captureCurrentTurn"] = (rawInput) =>
775 decodeCaptureInput(rawInput).pipe(
776 Effect.flatMap((input) =>
777 withSessionLock(
778 input.providerSessionId,
779 Effect.gen(function* () {
780 const adapter = yield* resolveAdapterForSession(input.providerSessionId);
781 yield* ensureSessionIsActive(input.providerSessionId, adapter);
782 const snapshot = yield* readThreadSnapshot(
783 input.providerSessionId,
784 adapter,
785 "CheckpointService.captureCurrentTurn:readThread",
786 );
787 const threadId = yield* resolveThreadIdForSession(
788 input.providerSessionId,
789 "CheckpointService.captureCurrentTurn",
790 );
791 yield* ensureThreadMatchesSnapshot(
792 threadId,
793 snapshot,
794 "CheckpointService.captureCurrentTurn",
795 );
796 if (snapshot.turns.length === 0) {
797 return;
798 }
799
800 const resolution = yield* resolveCheckpointCwd(input.providerSessionId, adapter);
801 if (Option.isNone(resolution.checkpointCwd)) {
802 return;
803 }
804
805 const checkpoint = yield* latestCheckpointFromSnapshot(
806 snapshot,
807 "CheckpointService.captureCurrentTurn",
808 );
809
810 yield* ensureRootCheckpointMetadata(threadId, "CheckpointService.captureCurrentTurn");
811
812 const existing = yield* checkpointRepository.getCheckpoint({
813 threadId,
814 turnCount: checkpoint.turnCount,
815 });
816 if (Option.isSome(existing)) {
817 const checkpointRef = existing.value.checkpointRef.trim();
818 if (checkpointRef.length === 0) {
819 return yield* invariantError(
820 "CheckpointService.captureCurrentTurn",
821 `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`,
822 );
823 }
824 const exists = yield* store.hasCheckpointRef({
825 cwd: resolution.checkpointCwd.value,
826 checkpointRef,
827 });
828 if (!exists) {
829 return yield* invariantError(
830 "CheckpointService.captureCurrentTurn",
831 `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`,
832 );
833 }
834 return;
835 }
836
837 yield* captureAndPersistCheckpoint({
838 providerSessionId: input.providerSessionId,
839 cwd: resolution.checkpointCwd.value,
840 threadId,
841 checkpoint,
842 });
843 }),
844 ),
845 ),
846 );
847
848 const listCheckpoints: CheckpointServiceShape["listCheckpoints"] = (rawInput) =>
849 decodeListInput(rawInput).pipe(
850 Effect.flatMap((input) =>
851 withSessionLock(
852 input.sessionId,
853 Effect.gen(function* () {
854 const adapter = yield* resolveAdapterForSession(input.sessionId);
855 yield* ensureSessionIsActive(input.sessionId, adapter);
856 const snapshot = yield* readThreadSnapshot(
857 input.sessionId,
858 adapter,
859 "CheckpointService.listCheckpoints:readThread",
860 );
861 const threadId = yield* resolveThreadIdForSession(
862 input.sessionId,
863 "CheckpointService.listCheckpoints",
864 );
865 yield* ensureThreadMatchesSnapshot(
866 threadId,
867 snapshot,
868 "CheckpointService.listCheckpoints",
869 );
870
871 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
872 if (Option.isNone(resolution.checkpointCwd)) {
873 return yield* new CheckpointRepositoryError({
874 cwd: resolution.candidateCwd,
875 detail: "Filesystem checkpoints are unavailable for this session.",
876 });
877 }
878
879 const checkpoints = yield* checkpointRepository.listCheckpoints({
880 threadId,
881 });
882 if (checkpoints.length === 0) {
883 return yield* invariantError(
884 "CheckpointService.listCheckpoints",
885 `Missing checkpoint metadata for active session ${input.sessionId}.`,
886 );
887 }
888
889 return {
890 threadId,
891 checkpoints: Array.from(checkpoints),
892 };
893 }),
894 ),
895 ),
896 );
897
898 const getCheckpointDiff: CheckpointServiceShape["getCheckpointDiff"] = (rawInput) =>
899 decodeDiffInput(rawInput).pipe(
900 Effect.flatMap((input) =>
901 withSessionLock(
902 input.sessionId,
903 Effect.gen(function* () {
904 const adapter = yield* resolveAdapterForSession(input.sessionId);
905 yield* ensureSessionIsActive(input.sessionId, adapter);
906 const snapshot = yield* readThreadSnapshot(
907 input.sessionId,
908 adapter,
909 "CheckpointService.getCheckpointDiff:readThread",
910 );
911 const threadId = yield* resolveThreadIdForSession(
912 input.sessionId,
913 "CheckpointService.getCheckpointDiff",
914 );
915 yield* ensureThreadMatchesSnapshot(
916 threadId,
917 snapshot,
918 "CheckpointService.getCheckpointDiff",
919 );
920
921 if (input.toTurnCount > snapshot.turns.length) {
922 return yield* validationError(
923 "CheckpointService.getCheckpointDiff",
924 `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
925 );
926 }
927
928 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
929 if (Option.isNone(resolution.checkpointCwd)) {
930 return yield* new CheckpointRepositoryError({
931 cwd: resolution.candidateCwd,
932 detail: "Filesystem checkpoints are unavailable for this session.",
933 });
934 }
935
936 const fromCheckpointRef = yield* getCheckpointRefOrFail(
937 threadId,
938 input.fromTurnCount,
939 "CheckpointService.getCheckpointDiff:fromCheckpoint",
940 );
941 const toCheckpointRef = yield* getCheckpointRefOrFail(
942 threadId,
943 input.toTurnCount,
944 "CheckpointService.getCheckpointDiff:toCheckpoint",
945 );
946
947 const diff = yield* store.diffCheckpoints({
948 cwd: resolution.checkpointCwd.value,
949 fromCheckpointRef,
950 toCheckpointRef,
951 fallbackFromToHead: input.fromTurnCount === 0,
952 });
953
954 return {
955 threadId,
956 fromTurnCount: input.fromTurnCount,
957 toTurnCount: input.toTurnCount,
958 diff,
959 };
960 }),
961 ),
962 ),
963 );
964
965 const revertToCheckpoint: CheckpointServiceShape["revertToCheckpoint"] = (rawInput) =>
966 decodeRevertInput(rawInput).pipe(
967 Effect.flatMap((input) =>
968 withSessionLock(
969 input.sessionId,
970 Effect.gen(function* () {
971 const adapter = yield* resolveAdapterForSession(input.sessionId);
972 yield* ensureSessionIsActive(input.sessionId, adapter);
973 const beforeSnapshot = yield* readThreadSnapshot(
974 input.sessionId,
975 adapter,
976 "CheckpointService.revertToCheckpoint:readThread",
977 );
978 const threadId = yield* resolveThreadIdForSession(
979 input.sessionId,
980 "CheckpointService.revertToCheckpoint",
981 );
982 yield* ensureThreadMatchesSnapshot(
983 threadId,
984 beforeSnapshot,
985 "CheckpointService.revertToCheckpoint",
986 );
987 const currentTurnCount = beforeSnapshot.turns.length;
988
989 if (input.turnCount > currentTurnCount) {
990 return yield* validationError(
991 "CheckpointService.revertToCheckpoint",
992 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
993 );
994 }
995
996 const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter);
997 if (Option.isNone(resolution.checkpointCwd)) {
998 return yield* new CheckpointRepositoryError({
999 cwd: resolution.candidateCwd,
1000 detail: "Filesystem checkpoints are unavailable for this session.",
1001 });
1002 }
1003
1004 const checkpointCwd = resolution.checkpointCwd.value;
1005 const checkpointRef = yield* getCheckpointRefOrFail(
1006 threadId,
1007 input.turnCount,
1008 "CheckpointService.revertToCheckpoint:checkpointLookup",
1009 );
1010
1011 const restored = yield* store.restoreCheckpoint({
1012 cwd: checkpointCwd,
1013 checkpointRef,
1014 fallbackToHead: input.turnCount === 0,
1015 });
1016 if (!restored) {
1017 return yield* new CheckpointUnavailableError({
1018 threadId,
1019 turnCount: input.turnCount,
1020 detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${threadId}.`,
1021 });
1022 }
1023
1024 const requestedRollbackTurns = currentTurnCount - input.turnCount;
1025 const afterSnapshot =
1026 requestedRollbackTurns > 0
1027 ? yield* rollbackThreadSnapshot(
1028 input.sessionId,
1029 adapter,
1030 requestedRollbackTurns,
1031 "CheckpointService.revertToCheckpoint:rollbackThread",
1032 )
1033 : beforeSnapshot;
1034
1035 const staleTurnCounts: number[] = [];
1036 for (let turn = input.turnCount + 1; turn <= currentTurnCount; turn += 1) {
1037 staleTurnCounts.push(turn);
1038 }
1039
1040 const staleCheckpointRefs = yield* Effect.forEach(
1041 staleTurnCounts,
1042 (turnCount) =>
1043 checkpointRepository.getCheckpoint({
1044 threadId,
1045 turnCount,
1046 }),
1047 { discard: false },
1048 ).pipe(
1049 Effect.map((entries) =>
1050 entries
1051 .flatMap((entry) =>
1052 Option.isSome(entry) ? [entry.value.checkpointRef.trim()] : [],
1053 )
1054 .filter((ref) => ref.length > 0),
1055 ),
1056 );
1057
1058 yield* store.deleteCheckpointRefs({
1059 cwd: checkpointCwd,
1060 checkpointRefs: staleCheckpointRefs,
1061 });
1062 yield* checkpointRepository.deleteAfterTurn({
1063 threadId,
1064 maxTurnCount: input.turnCount,
1065 });
1066 const checkpoints = yield* checkpointRepository.listCheckpoints({
1067 threadId,
1068 });
1069 const currentCheckpoint =
1070 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
1071 checkpoints[checkpoints.length - 1] ??
1072 checkpoints[0];
1073 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);
1074
1075 return {
1076 threadId,
1077 turnCount: currentCheckpoint?.turnCount ?? 0,
1078 messageCount: currentCheckpoint?.messageCount ?? 0,
1079 rolledBackTurns,
1080 checkpoints: Array.from(checkpoints),
1081 };
1082 }),
1083 ),
1084 ),
1085 );
1086
1087 const releaseSession: CheckpointServiceShape["releaseSession"] = (input) =>
1088 Effect.gen(function* () {
1089 const providerSessionId = yield* normalizeNonEmpty(
1090 input.providerSessionId,
1091 "CheckpointService.releaseSession",
1092 "providerSessionId",
1093 );
1094 yield* clearTrackedCheckpointCwd(providerSessionId);
1095 yield* clearSessionSemaphore(providerSessionId);
1096 });
1097
1098 return {
1099 initializeForSession,
1100 captureCurrentTurn,
1101 listCheckpoints,
1102 getCheckpointDiff,
1103 revertToCheckpoint,
1104 releaseSession,
1105 } satisfies CheckpointServiceShape;
1106});
1107
1108export const CheckpointServiceLive = Layer.effect(CheckpointService, makeCheckpointService);
apps/server/src/checkpointing/Layers/CheckpointStore.ts+117-243
......@@ -9,162 +9,83 @@
99 *
1010 * @module CheckpointStoreLive
1111 */
12import { mkdtemp, rm } from "node:fs/promises";
1312import { randomUUID } from "node:crypto";
14import os from "node:os";
15import path from "node:path";
16
17import { Effect, Layer } from "effect";
18import { ChildProcessSpawner } from "effect/unstable/process";
19
20import {
21 CheckpointGitCommandError,
22 CheckpointRepositoryError,
23 CheckpointUnavailableError,
24 type CheckpointStoreError,
25} from "../Errors.ts";
13
14import { Effect, Layer, FileSystem, Path } from "effect";
15
16import { CheckpointInvariantError } from "../Errors.ts";
2617import { GitCommandError } from "../../git/Errors.ts";
27import { runGit, RunGitInput } from "../../git/Process.ts";
18import { GitServiceLive } from "../../git/Layer.ts";
19import { GitService } from "../../git/Service.ts";
2820import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
29
30function causeDetail(cause: unknown, fallback: string): string {
31 if (cause instanceof GitCommandError) {
32 return cause.detail;
33 }
34 if (cause instanceof Error && cause.message.length > 0) {
35 return cause.message;
36 }
37 return fallback;
38}
39
40function mapFilesystemFailure(
41 operation: string,
42 cwd: string,
43 cause: unknown,
44): CheckpointStoreError {
45 const detail = causeDetail(cause, `${operation} failed.`);
46
47 const unavailable =
48 /^Filesystem checkpoint is unavailable for turn (\d+) in thread (.+)\.?$/i.exec(detail);
49 if (unavailable) {
50 const [, rawTurnCount, threadId] = unavailable;
51 const turnCount = Number.parseInt(rawTurnCount ?? "", 10);
52 if (threadId && Number.isInteger(turnCount) && turnCount >= 0) {
53 return new CheckpointUnavailableError({
54 threadId,
55 turnCount,
56 detail,
57 cause,
58 });
59 }
60 }
61
62 const lowered = detail.toLowerCase();
63 if (lowered.includes("not a git repository")) {
64 return new CheckpointRepositoryError({
65 cwd,
66 detail,
67 cause,
68 });
69 }
70
71 return new CheckpointGitCommandError({
72 operation,
73 command: cause instanceof GitCommandError ? cause.command : "git",
74 cwd,
75 detail,
76 cause,
77 });
78}
79
80function resolveHeadCommit(
81 cwd: string,
82): Effect.Effect<string | null, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> {
83 return runGit({
84 operation: "CheckpointStore.resolveHeadCommit",
85 cwd,
86 args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
87 allowNonZeroExit: true,
88 }).pipe(
89 Effect.map((result) => {
90 if (result.code !== 0) {
91 return null;
92 }
93 const commit = result.stdout.trim();
94 return commit.length > 0 ? commit : null;
95 }),
96 );
97}
98
99function hasHeadCommit(
100 cwd: string,
101): Effect.Effect<boolean, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> {
102 return runGit({
103 operation: "CheckpointStore.hasHeadCommit",
104 cwd,
105 args: ["rev-parse", "--verify", "HEAD"],
106 allowNonZeroExit: true,
107 }).pipe(Effect.map((result) => result.code === 0));
108}
109
110function resolveCheckpointCommit(
111 cwd: string,
112 checkpointRef: string,
113): Effect.Effect<string | null, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> {
114 return runGit({
115 operation: "CheckpointStore.resolveCheckpointCommit",
116 cwd,
117 args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`],
118 allowNonZeroExit: true,
119 }).pipe(
120 Effect.map((result) => {
121 if (result.code !== 0) {
122 return null;
123 }
124 const commit = result.stdout.trim();
125 return commit.length > 0 ? commit : null;
126 }),
127 );
128}
129
130function removeDirectory(pathname: string): Effect.Effect<void, never> {
131 return Effect.tryPromise({
132 try: () => rm(pathname, { recursive: true, force: true }),
133 catch: (cause) => cause,
134 }).pipe(Effect.orDie);
135}
21import { CheckpointRef } from "@t3tools/contracts";
13622
13723const makeCheckpointStore = Effect.gen(function* () {
138 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
139 const runGitWithSpawner = (input: RunGitInput) =>
140 runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
141 const resolveHeadCommitWithSpawner = (cwd: string) =>
142 resolveHeadCommit(cwd).pipe(
143 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
144 );
145 const hasHeadCommitWithSpawner = (cwd: string) =>
146 hasHeadCommit(cwd).pipe(
147 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
148 );
149 const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) =>
150 resolveCheckpointCommit(cwd, checkpointRef).pipe(
151 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
152 );
24 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 const git = yield* GitService;
27
28 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
29 git
30 .execute({
31 operation: "CheckpointStore.resolveHeadCommit",
32 cwd,
33 args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
34 allowNonZeroExit: true,
35 })
36 .pipe(
37 Effect.map((result) => {
38 if (result.code !== 0) {
39 return null;
40 }
41 const commit = result.stdout.trim();
42 return commit.length > 0 ? commit : null;
43 }),
44 );
15345
154 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>
155 Effect.gen(function* () {
156 const normalizedCwd = cwd.trim();
46 const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
47 git
48 .execute({
49 operation: "CheckpointStore.hasHeadCommit",
50 cwd,
51 args: ["rev-parse", "--verify", "HEAD"],
52 allowNonZeroExit: true,
53 })
54 .pipe(Effect.map((result) => result.code === 0));
55
56 const resolveCheckpointCommit = (
57 cwd: string,
58 checkpointRef: CheckpointRef,
59 ): Effect.Effect<string | null, GitCommandError> =>
60 git
61 .execute({
62 operation: "CheckpointStore.resolveCheckpointCommit",
63 cwd,
64 args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`],
65 allowNonZeroExit: true,
66 })
67 .pipe(
68 Effect.map((result) => {
69 if (result.code !== 0) {
70 return null;
71 }
72 const commit = result.stdout.trim();
73 return commit.length > 0 ? commit : null;
74 }),
75 );
15776
158 return yield* runGitWithSpawner({
77 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>
78 git
79 .execute({
15980 operation: "CheckpointStore.isGitRepository",
160 cwd: normalizedCwd,
81 cwd: cwd.trim(),
16182 args: ["rev-parse", "--is-inside-work-tree"],
16283 allowNonZeroExit: true,
163 }).pipe(
84 })
85 .pipe(
16486 Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"),
16587 Effect.catch(() => Effect.succeed(false)),
16688 );
167 });
16889
16990 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>
17091 Effect.gen(function* () {
......@@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () {
17394 const checkpointRef = input.checkpointRef.trim();
17495
17596 yield* Effect.acquireUseRelease(
176 Effect.tryPromise({
177 try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")),
178 catch: (cause) => cause,
179 }),
97 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
18098 (tempDir) =>
18199 Effect.gen(function* () {
182100 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
......@@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () {
184102 ...process.env,
185103 GIT_INDEX_FILE: tempIndexPath,
186104 GIT_AUTHOR_NAME: "T3 Code",
187 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",
105 GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
188106 GIT_COMMITTER_NAME: "T3 Code",
189 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",
107 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
190108 };
191109
192 const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd);
110 const headExists = yield* hasHeadCommit(normalizedCwd);
193111 if (headExists) {
194 yield* runGitWithSpawner({
112 yield* git.execute({
195113 operation,
196114 cwd: normalizedCwd,
197115 args: ["read-tree", "HEAD"],
......@@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {
199117 });
200118 }
201119
202 yield* runGitWithSpawner({
120 yield* git.execute({
203121 operation,
204122 cwd: normalizedCwd,
205123 args: ["add", "-A", "--", "."],
206124 env: commitEnv,
207125 });
208126
209 const writeTreeResult = yield* runGitWithSpawner({
127 const writeTreeResult = yield* git.execute({
210128 operation,
211129 cwd: normalizedCwd,
212130 args: ["write-tree"],
......@@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () {
215133 const treeOid = writeTreeResult.stdout.trim();
216134 if (treeOid.length === 0) {
217135 return yield* Effect.fail(
218 new CheckpointGitCommandError({
136 new GitCommandError({
219137 operation,
220138 command: "git write-tree",
221139 cwd: normalizedCwd,
......@@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () {
225143 }
226144
227145 const message = `t3 checkpoint ref=${checkpointRef}`;
228 const commitTreeResult = yield* runGitWithSpawner({
146 const commitTreeResult = yield* git.execute({
229147 operation,
230148 cwd: normalizedCwd,
231149 args: ["commit-tree", treeOid, "-m", message],
......@@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () {
234152 const commitOid = commitTreeResult.stdout.trim();
235153 if (commitOid.length === 0) {
236154 return yield* Effect.fail(
237 new CheckpointGitCommandError({
155 new GitCommandError({
238156 operation,
239157 command: "git commit-tree",
240158 cwd: normalizedCwd,
......@@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () {
243161 );
244162 }
245163
246 yield* runGitWithSpawner({
164 yield* git.execute({
247165 operation,
248166 cwd: normalizedCwd,
249167 args: ["update-ref", checkpointRef, commitOid],
250168 });
251169 }),
252 removeDirectory,
253 ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
170 (tempDir) => fs.remove(tempDir, { recursive: true }),
171 ).pipe(
172 Effect.catchTags({
173 PlatformError: (error) =>
174 Effect.fail(
175 new CheckpointInvariantError({
176 operation: "CheckpointStore.captureCheckpoint",
177 detail: "Failed to capture checkpoint.",
178 cause: error,
179 }),
180 ),
181 }),
182 );
254183 });
255184
256185 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
257186 Effect.gen(function* () {
258 const operation = "CheckpointStore.hasCheckpointRef";
259 const normalizedCwd = input.cwd.trim();
260 const checkpointRef = input.checkpointRef.trim();
261
262 return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe(
187 return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
263188 Effect.map((commit) => commit !== null),
264 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
265 );
266 });
267
268 const ensureRootCheckpoint: CheckpointStoreShape["ensureRootCheckpoint"] = (input) =>
269 Effect.gen(function* () {
270 const operation = "CheckpointStore.ensureRootCheckpoint";
271 const normalizedCwd = input.cwd.trim();
272 const checkpointRef = input.checkpointRef.trim();
273
274 const existing = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe(
275 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
276189 );
277 if (existing) {
278 return true;
279 }
280
281 const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe(
282 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
283 );
284 if (!headCommit) {
285 return false;
286 }
287
288 yield* runGitWithSpawner({
289 operation,
290 cwd: normalizedCwd,
291 args: ["update-ref", checkpointRef, headCommit],
292 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
293 return true;
294190 });
295191
296192 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>
297193 Effect.gen(function* () {
298194 const operation = "CheckpointStore.restoreCheckpoint";
299 const normalizedCwd = input.cwd.trim();
300 const checkpointRef = input.checkpointRef.trim();
301195
302 let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe(
303 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
304 );
196 let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
305197
306198 if (!commitOid && input.fallbackToHead === true) {
307 commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe(
308 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
309 );
199 commitOid = yield* resolveHeadCommit(input.cwd);
310200 }
311201
312202 if (!commitOid) {
313203 return false;
314204 }
315205
316 yield* runGitWithSpawner({
206 yield* git.execute({
317207 operation,
318 cwd: normalizedCwd,
208 cwd: input.cwd,
319209 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
320 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
321 yield* runGitWithSpawner({
210 });
211 yield* git.execute({
322212 operation,
323 cwd: normalizedCwd,
213 cwd: input.cwd,
324214 args: ["clean", "-fd", "--", "."],
325 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
215 });
326216
327 const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe(
328 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
329 );
217 const headExists = yield* hasHeadCommit(input.cwd);
330218 if (headExists) {
331 yield* runGitWithSpawner({
219 yield* git.execute({
332220 operation,
333 cwd: normalizedCwd,
221 cwd: input.cwd,
334222 args: ["reset", "--quiet", "--", "."],
335 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
223 });
336224 }
337225
338226 return true;
......@@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () {
341229 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) =>
342230 Effect.gen(function* () {
343231 const operation = "CheckpointStore.diffCheckpoints";
344 const normalizedCwd = input.cwd.trim();
345 const fromCheckpointRef = input.fromCheckpointRef.trim();
346 const toCheckpointRef = input.toCheckpointRef.trim();
347
348 let fromCommitOid = yield* resolveCheckpointCommitWithSpawner(
349 normalizedCwd,
350 fromCheckpointRef,
351 ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
352 const toCommitOid = yield* resolveCheckpointCommitWithSpawner(
353 normalizedCwd,
354 toCheckpointRef,
355 ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
232
233 let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);
234 const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef);
356235
357236 if (!fromCommitOid && input.fallbackFromToHead === true) {
358 const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe(
359 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
360 );
237 const headCommit = yield* resolveHeadCommit(input.cwd);
361238 if (headCommit) {
362239 fromCommitOid = headCommit;
363240 }
......@@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () {
365242
366243 if (!fromCommitOid || !toCommitOid) {
367244 return yield* Effect.fail(
368 new CheckpointGitCommandError({
245 new GitCommandError({
369246 operation,
370247 command: "git diff",
371 cwd: normalizedCwd,
248 cwd: input.cwd,
372249 detail: "Checkpoint ref is unavailable for diff operation.",
373250 }),
374251 );
375252 }
376253
377 const result = yield* runGitWithSpawner({
254 const result = yield* git.execute({
378255 operation,
379 cwd: normalizedCwd,
256 cwd: input.cwd,
380257 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
381 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));
258 });
382259
383260 return result.stdout;
384261 });
......@@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () {
386263 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) =>
387264 Effect.gen(function* () {
388265 const operation = "CheckpointStore.deleteCheckpointRefs";
389 const normalizedCwd = input.cwd.trim();
390 const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim());
391266
392267 yield* Effect.forEach(
393 checkpointRefs,
268 input.checkpointRefs,
394269 (checkpointRef) =>
395 runGitWithSpawner({
270 git.execute({
396271 operation,
397 cwd: normalizedCwd,
272 cwd: input.cwd,
398273 args: ["update-ref", "-d", checkpointRef],
399274 allowNonZeroExit: true,
400 }).pipe(
401 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
402 ),
275 }),
403276 { discard: true },
404277 );
405278 });
......@@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () {
408281 isGitRepository,
409282 captureCheckpoint,
410283 hasCheckpointRef,
411 ensureRootCheckpoint,
412284 restoreCheckpoint,
413285 diffCheckpoints,
414286 deleteCheckpointRefs,
415287 } satisfies CheckpointStoreShape;
416288});
417289
418export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore);
290export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe(
291 Layer.provideMerge(GitServiceLive),
292);
apps/server/src/checkpointing/Services/CheckpointService.ts deleted-90
......@@ -1,90 +0,0 @@
1/**
2 * CheckpointService - Application orchestrator for checkpoint workflows.
3 *
4 * Coordinates `CheckpointStore` (Git refs), `CheckpointRepository` (metadata), and
5 * provider timeline operations resolved through `ProviderSessionDirectory` and
6 * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters).
7 *
8 * This service owns checkpoint workflow rules and validation, while concrete
9 * storage/process details stay in lower-level services.
10 *
11 * @module CheckpointService
12 */
13import type {
14 ProviderGetCheckpointDiffInput,
15 ProviderGetCheckpointDiffResult,
16 ProviderListCheckpointsInput,
17 ProviderListCheckpointsResult,
18 ProviderRevertToCheckpointInput,
19 ProviderRevertToCheckpointResult,
20} from "@t3tools/contracts";
21import { ServiceMap } from "effect";
22import type { Effect } from "effect";
23
24import type { CheckpointServiceError } from "../Errors.ts";
25
26export interface InitializeCheckpointSessionInput {
27 readonly providerSessionId: string;
28 readonly cwd: string;
29}
30
31export interface CaptureCurrentTurnInput {
32 readonly providerSessionId: string;
33 readonly turnId?: string;
34 readonly status?: string;
35}
36
37export interface CheckpointServiceShape {
38 /**
39 * Initialize root checkpoint and metadata for a provider session.
40 */
41 readonly initializeForSession: (
42 input: InitializeCheckpointSessionInput,
43 ) => Effect.Effect<void, CheckpointServiceError>;
44
45 /**
46 * Capture the current thread turn into checkpoint refs/metadata.
47 */
48 readonly captureCurrentTurn: (
49 input: CaptureCurrentTurnInput,
50 ) => Effect.Effect<void, CheckpointServiceError>;
51
52 /**
53 * List checkpoints for a provider session.
54 */
55 readonly listCheckpoints: (
56 input: ProviderListCheckpointsInput,
57 ) => Effect.Effect<ProviderListCheckpointsResult, CheckpointServiceError>;
58
59 /**
60 * Diff two checkpoint turn counts for a provider session.
61 */
62 readonly getCheckpointDiff: (
63 input: ProviderGetCheckpointDiffInput,
64 ) => Effect.Effect<ProviderGetCheckpointDiffResult, CheckpointServiceError>;
65
66 /**
67 * Revert provider/filesystem state to a checkpoint.
68 */
69 readonly revertToCheckpoint: (
70 input: ProviderRevertToCheckpointInput,
71 ) => Effect.Effect<ProviderRevertToCheckpointResult, CheckpointServiceError>;
72
73 /**
74 * Release in-memory checkpoint session state after session shutdown.
75 *
76 * This only clears local coordination state (locks/cwd tracking). It does not
77 * delete durable checkpoint metadata or git refs.
78 */
79 readonly releaseSession: (input: {
80 readonly providerSessionId: string;
81 }) => Effect.Effect<void, CheckpointServiceError>;
82}
83
84/**
85 * CheckpointService - Service tag for checkpoint orchestration.
86 */
87export class CheckpointService extends ServiceMap.Service<
88 CheckpointService,
89 CheckpointServiceShape
90>()("checkpointing/CheckpointService") {}
apps/server/src/checkpointing/Services/CheckpointStore.ts+6-17
......@@ -14,33 +14,29 @@ import { ServiceMap } from "effect";
1414import type { Effect } from "effect";
1515
1616import type { CheckpointStoreError } from "../Errors.ts";
17import { CheckpointRef } from "@t3tools/contracts";
1718
1819export interface CaptureCheckpointInput {
1920 readonly cwd: string;
20 readonly checkpointRef: string;
21}
22
23export interface EnsureRootCheckpointInput {
24 readonly cwd: string;
25 readonly checkpointRef: string;
21 readonly checkpointRef: CheckpointRef;
2622}
2723
2824export interface RestoreCheckpointInput {
2925 readonly cwd: string;
30 readonly checkpointRef: string;
26 readonly checkpointRef: CheckpointRef;
3127 readonly fallbackToHead?: boolean;
3228}
3329
3430export interface DiffCheckpointsInput {
3531 readonly cwd: string;
36 readonly fromCheckpointRef: string;
37 readonly toCheckpointRef: string;
32 readonly fromCheckpointRef: CheckpointRef;
33 readonly toCheckpointRef: CheckpointRef;
3834 readonly fallbackFromToHead?: boolean;
3935}
4036
4137export interface DeleteCheckpointRefsInput {
4238 readonly cwd: string;
43 readonly checkpointRefs: ReadonlyArray<string>;
39 readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
4440}
4541
4642export interface CheckpointStoreShape {
......@@ -63,13 +59,6 @@ export interface CheckpointStoreShape {
6359 input: Omit<RestoreCheckpointInput, "fallbackToHead">,
6460 ) => Effect.Effect<boolean, CheckpointStoreError>;
6561
66 /**
67 * Ensure a root checkpoint ref exists.
68 */
69 readonly ensureRootCheckpoint: (
70 input: EnsureRootCheckpointInput,
71 ) => Effect.Effect<boolean, CheckpointStoreError>;
72
7362 /**
7463 * Restore workspace/staging state to a checkpoint.
7564 */
apps/server/src/codexAppServerManager.ts+96-55
......@@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto";
33import { EventEmitter } from "node:events";
44import readline from "node:readline";
55
6import type {
7 ProviderApprovalDecision,
8 ProviderEvent,
6import {
7 ApprovalRequestId,
8 EventId,
9 ProviderItemId,
910 ProviderRequestKind,
10 ProviderSendTurnInput,
11 ProviderSession,
12 ProviderSessionStartInput,
13 ProviderTurnStartResult,
11 ProviderSessionId,
12 ProviderThreadId,
13 ProviderTurnId,
14 normalizeModelSlug,
15 type ProviderApprovalDecision,
16 type ProviderEvent,
17 type ProviderSendTurnInput,
18 type ProviderSession,
19 type ProviderSessionStartInput,
20 type ProviderTurnStartResult,
1421} from "@t3tools/contracts";
15import { normalizeModelSlug } from "@t3tools/contracts";
1622
1723type PendingRequestKey = string;
1824
......@@ -24,13 +30,13 @@ interface PendingRequest {
2430}
2531
2632interface PendingApprovalRequest {
27 requestId: string;
33 requestId: ApprovalRequestId;
2834 jsonRpcId: string | number;
2935 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
3036 requestKind: ProviderRequestKind;
31 threadId?: string;
32 turnId?: string;
33 itemId?: string;
37 threadId?: ProviderThreadId;
38 turnId?: ProviderTurnId;
39 itemId?: ProviderItemId;
3440}
3541
3642interface CodexSessionContext {
......@@ -38,7 +44,7 @@ interface CodexSessionContext {
3844 child: ChildProcessWithoutNullStreams;
3945 output: readline.Interface;
4046 pending: Map<PendingRequestKey, PendingRequest>;
41 pendingApprovals: Map<string, PendingApprovalRequest>;
47 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
4248 nextRequestId: number;
4349 stopping: boolean;
4450}
......@@ -66,12 +72,12 @@ interface JsonRpcNotification {
6672}
6773
6874export interface CodexThreadTurnSnapshot {
69 id: string;
75 id: ProviderTurnId;
7076 items: unknown[];
7177}
7278
7379export interface CodexThreadSnapshot {
74 threadId: string;
80 threadId: ProviderThreadId;
7581 turns: CodexThreadTurnSnapshot[];
7682}
7783
......@@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents {
143149}
144150
145151export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {
146 private readonly sessions = new Map<string, CodexSessionContext>();
152 private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>();
147153
148154 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
149 const sessionId = randomUUID();
155 const sessionId = ProviderSessionId.makeUnsafe(randomUUID());
150156 const now = new Date().toISOString();
151157 let context: CodexSessionContext | undefined;
152158
......@@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
244250 }
245251
246252 const threadOpenRecord = this.readObject(threadOpenResponse);
247 const threadId =
253 const threadIdRaw =
248254 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249255 this.readString(threadOpenRecord, "threadId");
250 if (!threadId) {
256 if (!threadIdRaw) {
251257 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
252258 }
259 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
253260
254261 this.updateSession(context, {
255262 status: "ready",
......@@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
268275 this.stopSession(sessionId);
269276 } else {
270277 this.emitEvent({
271 id: randomUUID(),
278 id: EventId.makeUnsafe(randomUUID()),
272279 kind: "error",
273280 provider: "codex",
274281 sessionId,
......@@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
310317 }
311318
312319 const turnStartParams: {
313 threadId: string;
320 threadId: ProviderThreadId;
314321 input: Array<
315322 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
316323 >;
......@@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
331338 const response = await this.sendRequest(context, "turn/start", turnStartParams);
332339
333340 const turn = this.readObject(this.readObject(response), "turn");
334 const turnId = this.readString(turn, "id");
335 if (!turnId) {
341 const turnIdRaw = this.readString(turn, "id");
342 if (!turnIdRaw) {
336343 throw new Error("turn/start response did not include a turn id.");
337344 }
345 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
338346
339347 this.updateSession(context, {
340348 status: "running",
......@@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
347355 };
348356 }
349357
350 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
358 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
351359 const context = this.requireSession(sessionId);
352360 const effectiveTurnId = turnId ?? context.session.activeTurnId;
353361
......@@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
361369 });
362370 }
363371
364 async readThread(sessionId: string): Promise<CodexThreadSnapshot> {
372 async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> {
365373 const context = this.requireSession(sessionId);
366374 const threadId = context.session.threadId;
367375 if (!threadId) {
......@@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
375383 return this.parseThreadSnapshot("thread/read", response);
376384 }
377385
378 async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> {
386 async rollbackThread(sessionId: ProviderSessionId, numTurns: number): Promise<CodexThreadSnapshot> {
379387 const context = this.requireSession(sessionId);
380388 const threadId = context.session.threadId;
381389 if (!threadId) {
......@@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
397405 }
398406
399407 async respondToRequest(
400 sessionId: string,
401 requestId: string,
408 sessionId: ProviderSessionId,
409 requestId: ApprovalRequestId,
402410 decision: ProviderApprovalDecision,
403411 ): Promise<void> {
404412 const context = this.requireSession(sessionId);
......@@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
416424 });
417425
418426 this.emitEvent({
419 id: randomUUID(),
427 id: EventId.makeUnsafe(randomUUID()),
420428 kind: "notification",
421429 provider: "codex",
422430 sessionId: context.session.sessionId,
......@@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
435443 });
436444 }
437445
438 stopSession(sessionId: string): void {
446 stopSession(sessionId: ProviderSessionId): void {
439447 const context = this.sessions.get(sessionId);
440448 if (!context) {
441449 return;
......@@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
470478 }));
471479 }
472480
473 hasSession(sessionId: string): boolean {
481 hasSession(sessionId: ProviderSessionId): boolean {
474482 return this.sessions.has(sessionId);
475483 }
476484
......@@ -480,7 +488,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
480488 }
481489 }
482490
483 private requireSession(sessionId: string): CodexSessionContext {
491 private requireSession(sessionId: ProviderSessionId): CodexSessionContext {
484492 const context = this.sessions.get(sessionId);
485493 if (!context) {
486494 throw new Error(`Unknown session: ${sessionId}`);
......@@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
591599 : undefined;
592600
593601 this.emitEvent({
594 id: randomUUID(),
602 id: EventId.makeUnsafe(randomUUID()),
595603 kind: "notification",
596604 provider: "codex",
597605 sessionId: context.session.sessionId,
......@@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
605613 });
606614
607615 if (notification.method === "thread/started") {
608 const threadId = this.readString(this.readObject(notification.params)?.thread, "id");
616 const threadId = toProviderThreadId(
617 this.readString(this.readObject(notification.params)?.thread, "id"),
618 );
609619 if (threadId) {
610620 this.updateSession(context, { threadId });
611621 }
......@@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
613623 }
614624
615625 if (notification.method === "turn/started") {
616 const turnId = this.readString(this.readObject(notification.params)?.turn, "id");
626 const turnId = toProviderTurnId(
627 this.readString(this.readObject(notification.params)?.turn, "id"),
628 );
617629 this.updateSession(context, {
618630 status: "running",
619631 activeTurnId: turnId,
......@@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
647659 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {
648660 const route = this.readRouteFields(request.params);
649661 const requestKind = this.requestKindForMethod(request.method);
650 let requestId: string | undefined;
662 let requestId: ApprovalRequestId | undefined;
651663 if (requestKind) {
652 requestId = randomUUID();
664 requestId = ApprovalRequestId.makeUnsafe(randomUUID());
653665 const pendingRequest: PendingApprovalRequest = {
654666 requestId,
655667 jsonRpcId: request.id,
......@@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
666678 }
667679
668680 this.emitEvent({
669 id: randomUUID(),
681 id: EventId.makeUnsafe(randomUUID()),
670682 kind: "request",
671683 provider: "codex",
672684 sessionId: context.session.sessionId,
......@@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
761773
762774 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {
763775 this.emitEvent({
764 id: randomUUID(),
776 id: EventId.makeUnsafe(randomUUID()),
765777 kind: "session",
766778 provider: "codex",
767779 sessionId: context.session.sessionId,
......@@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
773785
774786 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {
775787 this.emitEvent({
776 id: randomUUID(),
788 id: EventId.makeUnsafe(randomUUID()),
777789 kind: "error",
778790 provider: "codex",
779791 sessionId: context.session.sessionId,
......@@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
810822 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {
811823 const responseRecord = this.readObject(response);
812824 const thread = this.readObject(responseRecord, "thread");
813 const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId");
814 if (!threadId) {
825 const threadIdRaw = this.readString(thread, "id") ??
826 this.readString(responseRecord, "threadId");
827 if (!threadIdRaw) {
815828 throw new Error(`${method} response did not include a thread id.`);
816829 }
830 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
817831
818832 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
819833 const turns = turnsRaw.map((turnValue, index) => {
820834 const turn = this.readObject(turnValue);
821 const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`;
835 const turnIdRaw =
836 this.readString(turn, "id") ??
837 `${threadIdRaw}:turn:${index + 1}`;
838 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
822839 const items = this.readArray(turn, "items") ?? [];
823840 return {
824841 id: turnId,
......@@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
865882 }
866883
867884 private readRouteFields(params: unknown): {
868 threadId?: string;
869 turnId?: string;
870 itemId?: string;
885 threadId?: ProviderThreadId;
886 turnId?: ProviderTurnId;
887 itemId?: ProviderItemId;
871888 } {
872889 const route: {
873 threadId?: string;
874 turnId?: string;
875 itemId?: string;
890 threadId?: ProviderThreadId;
891 turnId?: ProviderTurnId;
892 itemId?: ProviderItemId;
876893 } = {};
877894
878 const threadId =
895 const threadId = toProviderThreadId(
879896 this.readString(params, "threadId") ??
880 this.readString(this.readObject(params, "thread"), "id");
881 const turnId =
882 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id");
883 const itemId =
884 this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id");
897 this.readString(this.readObject(params, "thread"), "id"),
898 );
899 const turnId = toProviderTurnId(
900 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"),
901 );
902 const itemId = toProviderItemId(
903 this.readString(params, "itemId") ??
904 this.readString(this.readObject(params, "item"), "id"),
905 );
885906
886907 if (threadId) {
887908 route.threadId = threadId;
......@@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
941962 return typeof candidate === "boolean" ? candidate : undefined;
942963 }
943964}
965
966function brandIfNonEmpty<T extends string>(
967 value: string | undefined,
968 maker: (value: string) => T,
969): T | undefined {
970 const normalized = value?.trim();
971 return normalized?.length ? maker(normalized) : undefined;
972}
973
974function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined {
975 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
976}
977
978function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
979 return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe);
980}
981
982function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
983 return brandIfNonEmpty(value, ProviderItemId.makeUnsafe);
984}
apps/server/src/filesystemCheckpointStore.test.ts deleted-243
......@@ -1,243 +0,0 @@
1import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2import fs from "node:fs";
3import os from "node:os";
4import path from "node:path";
5
6import { describe, expect, it } from "vitest";
7
8import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore";
9import { runProcess } from "./processRunner";
10
11async function runGit(cwd: string, args: readonly string[]): Promise<string> {
12 const result = await runProcess("git", args, {
13 cwd,
14 allowNonZeroExit: true,
15 });
16 if (result.code !== 0) {
17 throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
18 }
19 return result.stdout.trim();
20}
21
22function exists(filePath: string): boolean {
23 return fs.existsSync(filePath);
24}
25
26async function createRepo(): Promise<string> {
27 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-"));
28 await runGit(cwd, ["init", "--initial-branch=main"]);
29 await runGit(cwd, ["config", "user.email", "test@example.com"]);
30 await runGit(cwd, ["config", "user.name", "Test User"]);
31 await writeFile(path.join(cwd, "README.md"), "v1\n");
32 await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n");
33 await runGit(cwd, ["add", "."]);
34 await runGit(cwd, ["commit", "-m", "Initial"]);
35 return cwd;
36}
37
38async function createRepoWithoutHead(): Promise<string> {
39 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-"));
40 await runGit(cwd, ["init", "--initial-branch=main"]);
41 return cwd;
42}
43
44describe("FilesystemCheckpointStore", () => {
45 it("captures refs without mutating branch history and restores workspace state", async () => {
46 const cwd = await createRepo();
47 const store = new FilesystemCheckpointStore();
48 const threadId = "thr_1";
49
50 try {
51 const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]);
52
53 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
54 const rootRef = checkpointRefForThreadTurn(threadId, 0);
55 const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]);
56 expect(rootCommit.length).toBeGreaterThan(0);
57 expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore);
58
59 await writeFile(path.join(cwd, "README.md"), "v2\n");
60 await rm(path.join(cwd, "tracked.txt"));
61 await writeFile(path.join(cwd, "notes.md"), "notes\n");
62 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
63
64 await writeFile(path.join(cwd, "README.md"), "v3\n");
65 await writeFile(path.join(cwd, "scratch.txt"), "scratch\n");
66 await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n");
67
68 const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 });
69 expect(restoredRoot).toBe(true);
70 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n");
71 expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n");
72 expect(exists(path.join(cwd, "notes.md"))).toBe(false);
73 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
74
75 const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 });
76 expect(restoredTurn1).toBe(true);
77 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n");
78 expect(exists(path.join(cwd, "tracked.txt"))).toBe(false);
79 expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n");
80 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
81 } finally {
82 await rm(cwd, { recursive: true, force: true });
83 }
84 });
85
86 it("returns false when restoring a missing checkpoint", async () => {
87 const cwd = await createRepo();
88 const store = new FilesystemCheckpointStore();
89
90 try {
91 const restored = await store.restoreCheckpoint({
92 cwd,
93 threadId: "thr_2",
94 turnCount: 999,
95 });
96 expect(restored).toBe(false);
97 } finally {
98 await rm(cwd, { recursive: true, force: true });
99 }
100 });
101
102 it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => {
103 const cwd = await createRepo();
104 const store = new FilesystemCheckpointStore();
105 const threadId = "thr_2b";
106
107 try {
108 await writeFile(path.join(cwd, "README.md"), "v2\n");
109 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
110
111 await writeFile(path.join(cwd, "README.md"), "v3\n");
112 await writeFile(path.join(cwd, "scratch.txt"), "scratch\n");
113
114 const restored = await store.restoreCheckpoint({
115 cwd,
116 threadId,
117 turnCount: 0,
118 });
119
120 expect(restored).toBe(true);
121 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n");
122 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
123 } finally {
124 await rm(cwd, { recursive: true, force: true });
125 }
126 });
127
128 it("prunes refs newer than the retained turn", async () => {
129 const cwd = await createRepo();
130 const store = new FilesystemCheckpointStore();
131 const threadId = "thr_3";
132
133 try {
134 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
135 await writeFile(path.join(cwd, "README.md"), "turn1\n");
136 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
137 await writeFile(path.join(cwd, "README.md"), "turn2\n");
138 await store.captureCheckpoint({ cwd, threadId, turnCount: 2 });
139
140 await store.pruneAfterTurn({
141 cwd,
142 threadId,
143 maxTurnCount: 1,
144 });
145
146 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true);
147 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true);
148 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false);
149 } finally {
150 await rm(cwd, { recursive: true, force: true });
151 }
152 });
153
154 it("returns a unified patch between checkpoint turns", async () => {
155 const cwd = await createRepo();
156 const store = new FilesystemCheckpointStore();
157 const threadId = "thr_4";
158
159 try {
160 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
161 await writeFile(path.join(cwd, "README.md"), "v2\n");
162 await writeFile(path.join(cwd, "new.txt"), "new file\n");
163 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
164
165 const diff = await store.diffCheckpoints({
166 cwd,
167 threadId,
168 fromTurnCount: 0,
169 toTurnCount: 1,
170 });
171
172 expect(diff).toContain("diff --git a/README.md b/README.md");
173 expect(diff).toContain("diff --git a/new.txt b/new.txt");
174 } finally {
175 await rm(cwd, { recursive: true, force: true });
176 }
177 });
178
179 it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => {
180 const cwd = await createRepo();
181 const store = new FilesystemCheckpointStore();
182 const threadId = "thr_5";
183
184 try {
185 await writeFile(path.join(cwd, "README.md"), "v2\n");
186 await writeFile(path.join(cwd, "new.txt"), "new file\n");
187 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
188
189 const diff = await store.diffCheckpoints({
190 cwd,
191 threadId,
192 fromTurnCount: 0,
193 toTurnCount: 1,
194 });
195
196 expect(diff).toContain("diff --git a/README.md b/README.md");
197 expect(diff).toContain("diff --git a/new.txt b/new.txt");
198 } finally {
199 await rm(cwd, { recursive: true, force: true });
200 }
201 });
202
203 it("detects non-repo folders", async () => {
204 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-"));
205 const store = new FilesystemCheckpointStore();
206
207 try {
208 expect(await store.isGitRepository(cwd)).toBe(false);
209 } finally {
210 await rm(cwd, { recursive: true, force: true });
211 }
212 });
213
214 it("returns false when repository path does not exist", async () => {
215 const store = new FilesystemCheckpointStore();
216 const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`);
217
218 expect(await store.isGitRepository(cwd)).toBe(false);
219 });
220
221 it("restores checkpoint without reset when repository has no HEAD commit", async () => {
222 const cwd = await createRepoWithoutHead();
223 const store = new FilesystemCheckpointStore();
224 const threadId = "thr-no-head";
225
226 try {
227 await writeFile(path.join(cwd, "README.md"), "first\n");
228 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
229
230 await writeFile(path.join(cwd, "README.md"), "second\n");
231 const restored = await store.restoreCheckpoint({
232 cwd,
233 threadId,
234 turnCount: 0,
235 });
236
237 expect(restored).toBe(true);
238 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n");
239 } finally {
240 await rm(cwd, { recursive: true, force: true });
241 }
242 });
243});
apps/server/src/filesystemCheckpointStore.ts deleted-322
......@@ -1,322 +0,0 @@
1import { mkdtemp, rm } from "node:fs/promises";
2import os from "node:os";
3import path from "node:path";
4import { randomUUID } from "node:crypto";
5
6import { runProcess } from "./processRunner";
7
8const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
9const DEFAULT_TIMEOUT_MS = 30_000;
10const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
11
12function quoteGitCommand(args: readonly string[]): string {
13 return `git ${args.join(" ")}`;
14}
15
16function checkpointRefThreadSegment(threadId: string): string {
17 return Buffer.from(threadId, "utf8").toString("base64url");
18}
19
20export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string {
21 if (!Number.isInteger(turnCount) || turnCount < 0) {
22 throw new Error(`Invalid checkpoint turn count: ${turnCount}`);
23 }
24 return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`;
25}
26
27interface GitOptions {
28 env?: NodeJS.ProcessEnv;
29 stdin?: string;
30 allowNonZeroExit?: boolean;
31}
32
33export class FilesystemCheckpointStore {
34 async isGitRepository(cwd: string): Promise<boolean> {
35 try {
36 const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], {
37 allowNonZeroExit: true,
38 });
39 return result.code === 0 && result.stdout.trim() === "true";
40 } catch {
41 return false;
42 }
43 }
44
45 async captureCheckpoint(input: {
46 cwd: string;
47 threadId: string;
48 turnCount: number;
49 }): Promise<void> {
50 const { cwd, threadId, turnCount } = input;
51 const ref = checkpointRefForThreadTurn(threadId, turnCount);
52
53 const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-"));
54
55 try {
56 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
57 const commitEnv: NodeJS.ProcessEnv = {
58 ...process.env,
59 GIT_INDEX_FILE: tempIndexPath,
60 GIT_AUTHOR_NAME: "T3 Code",
61 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",
62 GIT_COMMITTER_NAME: "T3 Code",
63 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",
64 };
65
66 const hasHead = await this.hasHeadCommit(cwd);
67 if (hasHead) {
68 await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv });
69 }
70
71 await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv });
72 const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv });
73 const treeOid = writeTreeResult.stdout.trim();
74 if (!treeOid) {
75 throw new Error("git write-tree returned an empty tree oid.");
76 }
77
78 const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`;
79 const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], {
80 env: commitEnv,
81 stdin: `${message}\n`,
82 });
83 const commitOid = commitTreeResult.stdout.trim();
84 if (!commitOid) {
85 throw new Error("git commit-tree returned an empty commit oid.");
86 }
87
88 await this.runGit(cwd, ["update-ref", ref, commitOid]);
89 } finally {
90 await rm(tempDir, { recursive: true, force: true });
91 }
92 }
93
94 async hasCheckpoint(input: {
95 cwd: string;
96 threadId: string;
97 turnCount: number;
98 }): Promise<boolean> {
99 const { cwd, threadId, turnCount } = input;
100 const ref = checkpointRefForThreadTurn(threadId, turnCount);
101 const commit = await this.resolveCheckpointCommit(cwd, ref);
102 return commit !== null;
103 }
104
105 async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise<boolean> {
106 const { cwd, threadId } = input;
107 const rootRef = checkpointRefForThreadTurn(threadId, 0);
108 const existing = await this.resolveCheckpointCommit(cwd, rootRef);
109 if (existing) {
110 return true;
111 }
112
113 const headCommit = await this.resolveHeadCommit(cwd);
114 if (!headCommit) {
115 return false;
116 }
117
118 await this.runGit(cwd, ["update-ref", rootRef, headCommit]);
119 return true;
120 }
121
122 async restoreCheckpoint(input: {
123 cwd: string;
124 threadId: string;
125 turnCount: number;
126 }): Promise<boolean> {
127 const { cwd, threadId, turnCount } = input;
128 const ref = checkpointRefForThreadTurn(threadId, turnCount);
129 let commitOid = await this.resolveCheckpointCommit(cwd, ref);
130 if (!commitOid && turnCount === 0) {
131 commitOid = await this.resolveHeadCommit(cwd);
132 }
133 if (!commitOid) {
134 return false;
135 }
136
137 await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]);
138 await this.runGit(cwd, ["clean", "-fd", "--", "."]);
139 if (await this.hasHeadCommit(cwd)) {
140 await this.runGit(cwd, ["reset", "--quiet", "--", "."]);
141 }
142 return true;
143 }
144
145 async diffCheckpoints(input: {
146 cwd: string;
147 threadId: string;
148 fromTurnCount: number;
149 toTurnCount: number;
150 }): Promise<string> {
151 const { cwd, threadId, fromTurnCount, toTurnCount } = input;
152 if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) {
153 throw new Error(`Invalid from turn count: ${fromTurnCount}`);
154 }
155 if (!Number.isInteger(toTurnCount) || toTurnCount < 0) {
156 throw new Error(`Invalid to turn count: ${toTurnCount}`);
157 }
158 if (fromTurnCount > toTurnCount) {
159 throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`);
160 }
161
162 const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount);
163 const toRef = checkpointRefForThreadTurn(threadId, toTurnCount);
164 let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef);
165 const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef);
166 if (!fromCommitOid && fromTurnCount === 0) {
167 const headCommit = await this.resolveHeadCommit(cwd);
168 if (headCommit) {
169 fromCommitOid = headCommit;
170 }
171 }
172 if (!fromCommitOid) {
173 throw new Error(
174 `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`,
175 );
176 }
177 if (!toCommitOid) {
178 throw new Error(
179 `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`,
180 );
181 }
182
183 const result = await this.runGit(cwd, [
184 "diff",
185 "--patch",
186 "--minimal",
187 "--no-color",
188 fromCommitOid,
189 toCommitOid,
190 ]);
191 return result.stdout;
192 }
193
194 private async resolveHeadCommit(cwd: string): Promise<string | null> {
195 const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
196 allowNonZeroExit: true,
197 });
198 if (result.code !== 0) {
199 return null;
200 }
201 const commit = result.stdout.trim();
202 return commit.length > 0 ? commit : null;
203 }
204
205 async pruneAfterTurn(input: {
206 cwd: string;
207 threadId: string;
208 maxTurnCount: number;
209 }): Promise<void> {
210 const { cwd, threadId, maxTurnCount } = input;
211 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
212 throw new Error(`Invalid max turn count: ${maxTurnCount}`);
213 }
214
215 const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;
216 const result = await this.runGit(
217 cwd,
218 ["for-each-ref", "--format=%(refname)", threadRefPrefix],
219 {
220 allowNonZeroExit: true,
221 },
222 );
223 if (result.code !== 0) {
224 return;
225 }
226
227 const refsToDelete = result.stdout
228 .split(/\r?\n/g)
229 .map((line) => line.trim())
230 .filter((line) => line.length > 0)
231 .filter((refName) => {
232 const turn = this.readRefTurnCount(refName);
233 return turn !== null && turn > maxTurnCount;
234 });
235
236 const batchSize = 32;
237 const batches = Array.from(
238 { length: Math.ceil(refsToDelete.length / batchSize) },
239 (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize),
240 );
241 await batches.reduce<Promise<void>>((pending, batch) => {
242 return pending.then(async () => {
243 await Promise.all(
244 batch.map((refName) =>
245 this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }),
246 ),
247 );
248 });
249 }, Promise.resolve());
250 }
251
252 private async hasHeadCommit(cwd: string): Promise<boolean> {
253 const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], {
254 allowNonZeroExit: true,
255 });
256 return result.code === 0;
257 }
258
259 private async resolveCheckpointCommit(cwd: string, ref: string): Promise<string | null> {
260 const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
261 allowNonZeroExit: true,
262 });
263 if (result.code !== 0) {
264 return null;
265 }
266 const commit = result.stdout.trim();
267 return commit.length > 0 ? commit : null;
268 }
269
270 private readRefTurnCount(ref: string): number | null {
271 const segments = ref.split("/");
272 const rawTurn = segments.at(-1);
273 if (!rawTurn) {
274 return null;
275 }
276 const turn = Number.parseInt(rawTurn, 10);
277 return Number.isInteger(turn) && turn >= 0 ? turn : null;
278 }
279
280 private async runGit(
281 cwd: string,
282 args: readonly string[],
283 options: GitOptions = {},
284 ): Promise<Awaited<ReturnType<typeof runProcess>>> {
285 let result: Awaited<ReturnType<typeof runProcess>>;
286 try {
287 result = await runProcess("git", args, {
288 cwd,
289 env: options.env,
290 stdin: options.stdin,
291 timeoutMs: DEFAULT_TIMEOUT_MS,
292 allowNonZeroExit: true,
293 maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,
294 outputMode: "truncate",
295 });
296 } catch (error) {
297 const message = error instanceof Error ? error.message : String(error);
298 throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error });
299 }
300
301 if (result.timedOut) {
302 throw new Error(`${quoteGitCommand(args)} timed out.`);
303 }
304
305 if (result.stdoutTruncated || result.stderrTruncated) {
306 throw new Error(
307 `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`,
308 );
309 }
310
311 if (!options.allowNonZeroExit && result.code !== 0) {
312 const stderr = result.stderr.trim();
313 throw new Error(
314 stderr.length > 0
315 ? `${quoteGitCommand(args)} failed: ${stderr}`
316 : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`,
317 );
318 }
319
320 return result;
321 }
322}
apps/server/src/git.ts+10-20
......@@ -3,10 +3,10 @@ import os from "node:os";
33import path from "node:path";
44
55import {
6 gitPullInputSchema,
7 gitPullResultSchema,
8 gitStatusInputSchema,
9 gitStatusResultSchema,
6 GitPullInput,
7 GitPullResult,
8 GitStatusInput,
9 GitStatusResult,
1010 type GitCheckoutInput,
1111 type GitCreateBranchInput,
1212 type GitCreateWorktreeInput,
......@@ -14,11 +14,7 @@ import {
1414 type GitInitInput,
1515 type GitListBranchesInput,
1616 type GitListBranchesResult,
17 type GitPullInput,
18 type GitPullResult,
1917 type GitRemoveWorktreeInput,
20 type GitStatusInput,
21 type GitStatusResult,
2218} from "@t3tools/contracts";
2319import { type ProcessRunResult, runProcess } from "./processRunner";
2420
......@@ -29,8 +25,6 @@ export interface TerminalCommandInput {
2925 maxOutputBytes?: number;
3026}
3127
32
33
3428export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
3529 upstreamRef: string | null;
3630}
......@@ -220,10 +214,9 @@ async function executeGit(
220214export class GitCoreService {
221215 private readonly lastUpstreamRefreshAt = new Map<string, number>();
222216
223 async status(raw: GitStatusInput): Promise<GitStatusResult> {
224 const input = gitStatusInputSchema.parse(raw);
217 async status(input: GitStatusInput): Promise<GitStatusResult> {
225218 const details = await this.statusDetails(input.cwd);
226 return gitStatusResultSchema.parse({
219 return GitStatusResult.makeUnsafe({
227220 branch: details.branch,
228221 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
229222 workingTree: details.workingTree,
......@@ -392,10 +385,10 @@ export class GitCoreService {
392385 });
393386 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
394387 const refreshed = await this.statusDetails(cwd);
395 return gitPullResultSchema.parse({
388 return GitPullResult.makeUnsafe({
396389 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
397390 branch,
398 ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}),
391 upstreamBranch: refreshed.upstreamRef,
399392 });
400393 }
401394
......@@ -678,9 +671,7 @@ export class GitCoreService {
678671
679672const defaultGitCoreService = new GitCoreService();
680673
681export async function runTerminalCommand(
682 input: TerminalCommandInput,
683): Promise<ProcessRunResult> {
674export async function runTerminalCommand(input: TerminalCommandInput): Promise<ProcessRunResult> {
684675 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
685676 const shellPath =
686677 process.platform === "win32"
......@@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
712703 return defaultGitCoreService.listBranches(input);
713704}
714705
715export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> {
716 const input = gitPullInputSchema.parse(raw);
706export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> {
717707 return defaultGitCoreService.pullCurrentBranch(input.cwd);
718708}
719709
apps/server/src/git/Layer.test.ts created+59
......@@ -0,0 +1,59 @@
1import { NodeServices } from "@effect/platform-node";
2import { it, assert } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { GitCommandError } from "./Errors.ts";
6import { GitServiceLive } from "./Layer.ts";
7import { GitService } from "./Service.ts";
8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
10
11layer("GitServiceLive", (it) => {
12 it.effect("runGit executes successful git commands", () =>
13 Effect.gen(function* () {
14 const gitService = yield* GitService;
15 const result = yield* gitService.execute({
16 operation: "GitProcess.test.version",
17 cwd: process.cwd(),
18 args: ["--version"],
19 });
20
21 assert.equal(result.code, 0);
22 assert.ok(result.stdout.toLowerCase().includes("git version"));
23 }),
24 );
25
26 it.effect("runGit can return non-zero exit codes when allowed", () =>
27 Effect.gen(function* () {
28 const gitService = yield* GitService;
29 const result = yield* gitService.execute({
30 operation: "GitProcess.test.allowNonZero",
31 cwd: process.cwd(),
32 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
33 allowNonZeroExit: true,
34 });
35
36 assert.notEqual(result.code, 0);
37 }),
38 );
39
40 it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () =>
41 Effect.gen(function* () {
42 const gitService = yield* GitService;
43 const result = yield* Effect.result(
44 gitService.execute({
45 operation: "GitProcess.test.failOnNonZero",
46 cwd: process.cwd(),
47 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
48 }),
49 );
50
51 assert.equal(result._tag, "Failure");
52 if (result._tag === "Failure") {
53 assert.ok(result.failure instanceof GitCommandError);
54 assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero");
55 assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__");
56 }
57 }),
58 );
59});
apps/server/src/git/Layer.ts created+141
......@@ -0,0 +1,141 @@
1/**
2 * Git process helpers - Effect-native git execution with typed errors.
3 *
4 * Centralizes child-process git invocation for server modules. This module
5 * only executes git commands and reports structured failures.
6 *
7 * @module GitServiceLive
8 */
9import { Effect, Layer, Option, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "./Errors.ts";
12import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts";
13
14const DEFAULT_TIMEOUT_MS = 30_000;
15const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
16
17function quoteGitCommand(args: ReadonlyArray<string>): string {
18 return `git ${args.join(" ")}`;
19}
20
21function toGitCommandError(
22 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
23 detail: string,
24) {
25 return (cause: unknown) =>
26 cause instanceof GitCommandError
27 ? cause
28 : new GitCommandError({
29 operation: input.operation,
30 command: quoteGitCommand(input.args),
31 cwd: input.cwd,
32 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
33 ...(cause !== undefined ? { cause } : {}),
34 });
35}
36
37const collectOutput = Effect.fn(function* (
38 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
39 stream: Stream.Stream<Uint8Array, unknown>,
40 maxOutputBytes: number,
41): Effect.fn.Return<string, GitCommandError> {
42 const decoder = new TextDecoder();
43 let bytes = 0;
44 let text = "";
45
46 yield* Stream.runForEach(stream, (chunk) =>
47 Effect.gen(function* () {
48 bytes += chunk.byteLength;
49 if (bytes > maxOutputBytes) {
50 return yield* new GitCommandError({
51 operation: input.operation,
52 command: quoteGitCommand(input.args),
53 cwd: input.cwd,
54 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
55 });
56 }
57 text += decoder.decode(chunk, { stream: true });
58 }),
59 ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));
60
61 text += decoder.decode();
62 return text;
63});
64
65const makeGitService = Effect.gen(function* () {
66 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
67
68 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
69 const normalizedCwd = input.cwd.trim();
70 const commandInput = {
71 ...input,
72 cwd: normalizedCwd,
73 args: [...input.args],
74 } as const;
75 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
76 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
77
78 const commandEffect = Effect.gen(function* () {
79 const child = yield* commandSpawner
80 .spawn(
81 ChildProcess.make("git", commandInput.args, {
82 cwd: commandInput.cwd,
83 ...(input.env ? { env: input.env } : {}),
84 }),
85 )
86 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
87
88 const [stdout, stderr, exitCode] = yield* Effect.all(
89 [
90 collectOutput(commandInput, child.stdout, maxOutputBytes),
91 collectOutput(commandInput, child.stderr, maxOutputBytes),
92 child.exitCode.pipe(
93 Effect.map((value) => Number(value)),
94 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
95 ),
96 ],
97 { concurrency: "unbounded" },
98 );
99
100 if (!input.allowNonZeroExit && exitCode !== 0) {
101 const trimmedStderr = stderr.trim();
102 return yield* new GitCommandError({
103 operation: commandInput.operation,
104 command: quoteGitCommand(commandInput.args),
105 cwd: commandInput.cwd,
106 detail:
107 trimmedStderr.length > 0
108 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
109 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
110 });
111 }
112
113 return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;
114 });
115
116 return yield* commandEffect.pipe(
117 Effect.scoped,
118 Effect.timeoutOption(timeoutMs),
119 Effect.flatMap((result) =>
120 Option.match(result, {
121 onNone: () =>
122 Effect.fail(
123 new GitCommandError({
124 operation: commandInput.operation,
125 command: quoteGitCommand(commandInput.args),
126 cwd: commandInput.cwd,
127 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
128 }),
129 ),
130 onSome: Effect.succeed,
131 }),
132 ),
133 );
134 });
135
136 return {
137 execute,
138 } satisfies GitServiceShape;
139});
140
141export const GitServiceLive = Layer.effect(GitService, makeGitService);
apps/server/src/git/Service.ts created+42
......@@ -0,0 +1,42 @@
1/**
2 * GitService - Service for Git command execution.
3 *
4 * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed
5 * domain errors for Git command execution.
6 *
7 * @module GitService
8 */
9import { ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { GitCommandError } from "./Errors.ts";
13
14export interface ExecuteGitInput {
15 readonly operation: string;
16 readonly cwd: string;
17 readonly args: ReadonlyArray<string>;
18 readonly env?: NodeJS.ProcessEnv;
19 readonly allowNonZeroExit?: boolean;
20 readonly timeoutMs?: number;
21 readonly maxOutputBytes?: number;
22}
23
24export interface ExecuteGitResult {
25 readonly code: number;
26 readonly stdout: string;
27 readonly stderr: string;
28}
29
30export interface GitServiceShape {
31 /**
32 * Execute a Git command.
33 */
34 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
35}
36
37/**
38 * GitService - Service for Git command execution.
39 */
40export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()(
41 "git/GitService",
42) {}
apps/server/src/git/process.test.ts deleted-51
......@@ -1,51 +0,0 @@
1import { NodeServices } from "@effect/platform-node";
2import { it, assert } from "@effect/vitest";
3import { Effect } from "effect";
4
5import { GitCommandError } from "./Errors.ts";
6import { runGit } from "./Process.ts";
7
8it.effect("runGit executes successful git commands", () =>
9 Effect.gen(function* () {
10 const result = yield* runGit({
11 operation: "GitProcess.test.version",
12 cwd: process.cwd(),
13 args: ["--version"],
14 });
15
16 assert.equal(result.code, 0);
17 assert.ok(result.stdout.toLowerCase().includes("git version"));
18 }).pipe(Effect.provide(NodeServices.layer)),
19);
20
21it.effect("runGit can return non-zero exit codes when allowed", () =>
22 Effect.gen(function* () {
23 const result = yield* runGit({
24 operation: "GitProcess.test.allowNonZero",
25 cwd: process.cwd(),
26 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
27 allowNonZeroExit: true,
28 });
29
30 assert.notEqual(result.code, 0);
31 }).pipe(Effect.provide(NodeServices.layer)),
32);
33
34it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () =>
35 Effect.gen(function* () {
36 const result = yield* Effect.result(
37 runGit({
38 operation: "GitProcess.test.failOnNonZero",
39 cwd: process.cwd(),
40 args: ["rev-parse", "--verify", "__definitely_missing_ref__"],
41 }),
42 );
43
44 assert.equal(result._tag, "Failure");
45 if (result._tag === "Failure") {
46 assert.ok(result.failure instanceof GitCommandError);
47 assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero");
48 assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__");
49 }
50 }).pipe(Effect.provide(NodeServices.layer)),
51);
apps/server/src/git/process.ts deleted-174
......@@ -1,174 +0,0 @@
1/**
2 * Git process helpers - Effect-native git execution with typed errors.
3 *
4 * Centralizes child-process git invocation for server modules. This module
5 * only executes git commands and reports structured failures.
6 *
7 * @module GitProcess
8 */
9import { Effect, Option, Stream } from "effect";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import { GitCommandError } from "./Errors.ts";
12
13const DEFAULT_TIMEOUT_MS = 30_000;
14const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
15
16export interface RunGitInput {
17 readonly operation: string;
18 readonly cwd: string;
19 readonly args: ReadonlyArray<string>;
20 readonly env?: NodeJS.ProcessEnv;
21 readonly allowNonZeroExit?: boolean;
22 readonly timeoutMs?: number;
23 readonly maxOutputBytes?: number;
24}
25
26export interface GitRunResult {
27 readonly code: number;
28 readonly stdout: string;
29 readonly stderr: string;
30}
31
32function quoteGitCommand(args: ReadonlyArray<string>): string {
33 return `git ${args.join(" ")}`;
34}
35
36function causeMessage(cause: unknown, fallback: string): string {
37 if (cause instanceof Error && cause.message.length > 0) {
38 return cause.message;
39 }
40 return fallback;
41}
42
43function gitError(
44 input: Pick<RunGitInput, "operation" | "cwd" | "args">,
45 detail: string,
46 cause?: unknown,
47): GitCommandError {
48 return new GitCommandError({
49 operation: input.operation,
50 command: quoteGitCommand(input.args),
51 cwd: input.cwd,
52 detail,
53 ...(cause !== undefined ? { cause } : {}),
54 });
55}
56
57function collectOutput(
58 input: Pick<RunGitInput, "operation" | "cwd" | "args">,
59 stream: Stream.Stream<Uint8Array, unknown>,
60 maxOutputBytes: number,
61): Effect.Effect<string, GitCommandError> {
62 return Effect.gen(function* () {
63 const decoder = new TextDecoder();
64 let bytes = 0;
65 let text = "";
66
67 yield* Stream.runForEach(stream, (chunk) =>
68 Effect.gen(function* () {
69 bytes += chunk.byteLength;
70 if (bytes > maxOutputBytes) {
71 return yield* Effect.fail(
72 gitError(
73 input,
74 `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
75 ),
76 );
77 }
78 text += decoder.decode(chunk, { stream: true });
79 }),
80 );
81
82 text += decoder.decode();
83 return text;
84 }).pipe(
85 Effect.mapError((cause) =>
86 gitError(
87 input,
88 causeMessage(cause, `${quoteGitCommand(input.args)} output stream failed.`),
89 cause,
90 ),
91 ),
92 );
93}
94
95export function runGit(
96 input: RunGitInput,
97): Effect.Effect<GitRunResult, GitCommandError, ChildProcessSpawner.ChildProcessSpawner> {
98 const normalizedCwd = input.cwd.trim();
99 const commandInput = {
100 ...input,
101 cwd: normalizedCwd,
102 args: [...input.args],
103 } as const;
104 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
105 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
106
107 const commandEffect = Effect.gen(function* () {
108 const child = yield* ChildProcess.spawn(
109 ChildProcess.make("git", commandInput.args, {
110 cwd: commandInput.cwd,
111 ...(input.env ? { env: input.env } : {}),
112 }),
113 ).pipe(
114 Effect.mapError((cause) =>
115 gitError(
116 commandInput,
117 causeMessage(cause, `Failed to spawn ${quoteGitCommand(commandInput.args)}.`),
118 cause,
119 ),
120 ),
121 );
122
123 const [stdout, stderr, exitCode] = yield* Effect.all(
124 [
125 collectOutput(commandInput, child.stdout, maxOutputBytes),
126 collectOutput(commandInput, child.stderr, maxOutputBytes),
127 child.exitCode.pipe(
128 Effect.map((value) => Number(value)),
129 Effect.mapError((cause) =>
130 gitError(
131 commandInput,
132 causeMessage(
133 cause,
134 `${quoteGitCommand(commandInput.args)} failed to report exit code.`,
135 ),
136 cause,
137 ),
138 ),
139 ),
140 ],
141 { concurrency: "unbounded" },
142 );
143
144 if (!input.allowNonZeroExit && exitCode !== 0) {
145 const trimmedStderr = stderr.trim();
146 return yield* Effect.fail(
147 gitError(
148 commandInput,
149 trimmedStderr.length > 0
150 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
151 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
152 ),
153 );
154 }
155
156 return {
157 code: exitCode,
158 stdout,
159 stderr,
160 } satisfies GitRunResult;
161 });
162
163 return commandEffect.pipe(
164 Effect.scoped,
165 Effect.timeoutOption(timeoutMs),
166 Effect.flatMap((result) =>
167 Option.match(result, {
168 onNone: () =>
169 Effect.fail(gitError(commandInput, `${quoteGitCommand(commandInput.args)} timed out.`)),
170 onSome: Effect.succeed,
171 }),
172 ),
173 );
174}
apps/server/src/gitManager.ts+8-13
......@@ -4,13 +4,10 @@ import os from "node:os";
44import path from "node:path";
55
66import {
7 gitRunStackedActionInputSchema,
8 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,
10 type GitRunStackedActionInput,
11 type GitRunStackedActionResult,
12 type GitStatusInput,
13 type GitStatusResult,
7 GitRunStackedActionInput,
8 GitRunStackedActionResult,
9 GitStatusInput,
10 GitStatusResult,
1411} from "@t3tools/contracts";
1512
1613import type {
......@@ -187,8 +184,7 @@ export class GitManager {
187184 this.gitCore = deps.gitCore ?? new GitCoreService();
188185 }
189186
190 async status(raw: GitStatusInput): Promise<GitStatusResult> {
191 const input = gitStatusInputSchema.parse(raw);
187 async status(input: GitStatusInput): Promise<GitStatusResult> {
192188 const details = await this.gitCore.statusDetails(input.cwd);
193189
194190 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
......@@ -214,8 +210,7 @@ export class GitManager {
214210 };
215211 }
216212
217 async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> {
218 const input = gitRunStackedActionInputSchema.parse(raw);
213 async runStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> {
219214 const wantsPush = input.action !== "commit";
220215 const wantsPr = input.action === "commit_push_pr";
221216
......@@ -237,12 +232,12 @@ export class GitManager {
237232 ? await this.runPrStep(input.cwd, initialStatus.branch)
238233 : { status: "skipped_not_requested" as const };
239234
240 return gitRunStackedActionResultSchema.parse({
235 return {
241236 action: input.action,
242237 commit,
243238 push,
244239 pr,
245 });
240 };
246241 }
247242
248243 private async runCommitStep(
apps/server/src/keybindings.ts+11-15
......@@ -2,7 +2,7 @@ import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
44
5import type {
5import {
66 KeybindingRule,
77 KeybindingsConfig,
88 KeybindingShortcut,
......@@ -10,7 +10,8 @@ import type {
1010 ResolvedKeybindingRule,
1111 ResolvedKeybindingsConfig,
1212} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
13import { Mutable } from "effect/Types";
14import { Schema } from "effect";
1415
1516interface KeybindingsLogger {
1617 warn(message: string, context?: Record<string, unknown>): void;
......@@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey
267268export function compileResolvedKeybindingsConfig(
268269 config: KeybindingsConfig,
269270): ResolvedKeybindingsConfig {
270 const compiled: ResolvedKeybindingsConfig = [];
271 const compiled: Mutable<ResolvedKeybindingsConfig> = [];
271272 for (const rule of config) {
272273 const resolved = compileResolvedKeybindingRule(rule);
273274 if (!resolved) continue;
......@@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig(
277278}
278279
279280function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
280 const resolved: ResolvedKeybindingsConfig = [];
281 const resolved: Mutable<ResolvedKeybindingsConfig> = [];
281282 for (const rule of DEFAULT_KEYBINDINGS) {
282283 const compiled = compileResolvedKeybindingRule(rule);
283284 if (!compiled) {
......@@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig(
322323 return [];
323324 }
324325
325 const sanitized: KeybindingsConfig = [];
326 const sanitized: Mutable<KeybindingsConfig> = [];
326327 let invalidEntries = 0;
327328 for (const entry of parsed) {
328 const result = keybindingRuleSchema.safeParse(entry);
329 if (result.success) {
330 if (!compileResolvedKeybindingRule(result.data)) {
329 const result = Schema.decodeUnknownExit(KeybindingRule)(entry);
330 if (result._tag === "Success") {
331 if (!compileResolvedKeybindingRule(result.value)) {
331332 invalidEntries += 1;
332333 continue;
333334 }
334 sanitized.push(result.data);
335 sanitized.push(result.value);
335336 continue;
336337 }
337338 invalidEntries += 1;
......@@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig(
417418
418419export function upsertKeybindingRule(
419420 logger: KeybindingsLogger,
420 rawRule: unknown,
421 rule: KeybindingRule,
421422): ResolvedKeybindingsConfig {
422 const rule = keybindingRuleSchema.parse(rawRule);
423 if (!compileResolvedKeybindingRule(rule)) {
424 throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);
425 }
426
427423 const configPath = resolveKeybindingsConfigPath();
428424 const customConfig = loadCustomKeybindingsConfig(logger, {
429425 throwOnUnreadableConfig: true,
apps/server/src/orchestration/Errors.ts+21-1
......@@ -1,6 +1,10 @@
11import { SchemaIssue, Schema } from "effect";
22
3import type { OrchestrationEventStoreError } from "../persistence/Errors.ts";
3import type {
4 OrchestrationCommandReceiptRepositoryError,
5 OrchestrationEventStoreError,
6 ProjectionRepositoryError,
7} from "../persistence/Errors.ts";
48
59export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()(
610 "OrchestrationCommandJsonParseError",
......@@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<
3943 }
4044}
4145
46export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass<OrchestrationCommandPreviouslyRejectedError>()(
47 "OrchestrationCommandPreviouslyRejectedError",
48 {
49 commandId: Schema.String,
50 detail: Schema.String,
51 cause: Schema.optional(Schema.Defect),
52 },
53) {
54 override get message(): string {
55 return `Command previously rejected (${this.commandId}): ${this.detail}`;
56 }
57}
58
4259export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()(
4360 "OrchestrationProjectorDecodeError",
4461 {
......@@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<
6784
6885export type OrchestrationDispatchError =
6986 | OrchestrationEventStoreError
87 | OrchestrationCommandReceiptRepositoryError
88 | ProjectionRepositoryError
7089 | OrchestrationCommandInvariantError
90 | OrchestrationCommandPreviouslyRejectedError
7191 | OrchestrationProjectorDecodeError
7292 | OrchestrationListenerCallbackError;
7393
apps/server/src/orchestration/Layer.test.ts+155-203
......@@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts";
22import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
33import { describe, expect, it } from "vitest";
44
5import { PersistenceSqlError } from "../persistence/Errors.ts";
6import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts";
7import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
59import {
610 OrchestrationEventStore,
711 type OrchestrationEventStoreShape,
812} from "../persistence/Services/OrchestrationEventStore.ts";
9import { PersistenceSqlError } from "../persistence/Errors.ts";
10import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
1113import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
14import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
1215import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
13import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
1416
15export async function createOrchestrationSystem() {
17async function createOrchestrationSystem() {
1618 const orchestrationLayer = OrchestrationEngineLive.pipe(
19 Layer.provide(OrchestrationProjectionPipelineLive),
1720 Layer.provide(OrchestrationEventStoreLive),
21 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1822 Layer.provide(SqlitePersistenceMemory),
1923 );
2024 const runtime = ManagedRuntime.make(orchestrationLayer);
......@@ -26,20 +30,33 @@ export async function createOrchestrationSystem() {
2630 };
2731}
2832
33function now() {
34 return new Date().toISOString();
35}
36
2937describe("OrchestrationEngine", () => {
3038 it("returns deterministic read models for repeated reads", async () => {
31 const createdAt = new Date().toISOString();
32 const projectId = "project-1";
33 const threadId = "thread-1";
34
39 const createdAt = now();
3540 const system = await createOrchestrationSystem();
36 const engine = system.engine;
41 const { engine } = system;
42
43 await system.run(
44 engine.dispatch({
45 type: "project.create",
46 commandId: "cmd-project-1-create",
47 projectId: "project-1",
48 title: "Project 1",
49 workspaceRoot: "/tmp/project-1",
50 defaultModel: "gpt-5-codex",
51 createdAt,
52 }),
53 );
3754 await system.run(
3855 engine.dispatch({
3956 type: "thread.create",
40 commandId: "cmd-1",
41 threadId,
42 projectId,
57 commandId: "cmd-thread-1-create",
58 threadId: "thread-1",
59 projectId: "project-1",
4360 title: "Thread",
4461 model: "gpt-5-codex",
4562 branch: null,
......@@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => {
4966 );
5067 await system.run(
5168 engine.dispatch({
52 type: "message.send",
53 commandId: "cmd-3",
54 threadId,
55 messageId: "msg-1",
56 role: "user",
57 text: "hello",
58 streaming: false,
69 type: "thread.turn.start",
70 commandId: "cmd-turn-start-1",
71 threadId: "thread-1",
72 message: {
73 messageId: "msg-1",
74 role: "user",
75 text: "hello",
76 attachments: [],
77 },
5978 createdAt,
6079 }),
6180 );
81
6282 const readModelA = await system.run(engine.getReadModel());
6383 const readModelB = await system.run(engine.getReadModel());
6484 expect(readModelB).toEqual(readModelA);
......@@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => {
6787
6888 it("replays append-only events from sequence", async () => {
6989 const system = await createOrchestrationSystem();
70 const engine = system.engine;
90 const { engine } = system;
91 const createdAt = now();
92
93 await system.run(
94 engine.dispatch({
95 type: "project.create",
96 commandId: "cmd-project-replay-create",
97 projectId: "project-replay",
98 title: "Replay Project",
99 workspaceRoot: "/tmp/project-replay",
100 defaultModel: "gpt-5-codex",
101 createdAt,
102 }),
103 );
71104 await system.run(
72105 engine.dispatch({
73106 type: "thread.create",
74 commandId: "cmd-a",
107 commandId: "cmd-thread-replay-create",
75108 threadId: "thread-replay",
76109 projectId: "project-replay",
77110 title: "replay",
78111 model: "gpt-5-codex",
79112 branch: null,
80113 worktreePath: null,
81 createdAt: new Date().toISOString(),
114 createdAt,
82115 }),
83116 );
84117 await system.run(
85118 engine.dispatch({
86119 type: "thread.delete",
87 commandId: "cmd-b",
120 commandId: "cmd-thread-replay-delete",
88121 threadId: "thread-replay",
89 createdAt: new Date().toISOString(),
90122 }),
91123 );
124
92125 const events = await system.run(
93126 Stream.runCollect(engine.readEvents(0)).pipe(
94127 Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)),
95128 ),
96129 );
97 expect(events.length).toBe(2);
98 expect(events[0]?.type).toBe("thread.created");
99 expect(events[1]?.type).toBe("thread.deleted");
130 expect(events.map((event) => event.type)).toEqual([
131 "project.created",
132 "thread.created",
133 "thread.deleted",
134 ]);
100135 await system.dispose();
101136 });
102137
103138 it("streams persisted domain events in order", async () => {
104139 const system = await createOrchestrationSystem();
105 const engine = system.engine;
140 const { engine } = system;
141 const createdAt = now();
142
143 await system.run(
144 engine.dispatch({
145 type: "project.create",
146 commandId: "cmd-project-stream-create",
147 projectId: "project-stream",
148 title: "Stream Project",
149 workspaceRoot: "/tmp/project-stream",
150 defaultModel: "gpt-5-codex",
151 createdAt,
152 }),
153 );
154
106155 const eventTypes: string[] = [];
107156 await system.run(
108157 Effect.gen(function* () {
......@@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => {
113162 ),
114163 );
115164 yield* Effect.sleep("10 millis");
116 const createdAt = new Date().toISOString();
117165 yield* engine.dispatch({
118166 type: "thread.create",
119 commandId: "cmd-stream-domain-a",
120 threadId: "thread-stream-domain",
121 projectId: "project-stream-domain",
167 commandId: "cmd-stream-thread-create",
168 threadId: "thread-stream",
169 projectId: "project-stream",
122170 title: "domain-stream",
123171 model: "gpt-5-codex",
124172 branch: null,
......@@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => {
127175 });
128176 yield* engine.dispatch({
129177 type: "thread.meta.update",
130 commandId: "cmd-stream-domain-b",
131 threadId: "thread-stream-domain",
178 commandId: "cmd-stream-thread-update",
179 threadId: "thread-stream",
132180 title: "domain-stream-updated",
133 createdAt,
134181 });
135182 eventTypes.push((yield* Queue.take(eventQueue)).type);
136183 eventTypes.push((yield* Queue.take(eventQueue)).type);
137184 }).pipe(Effect.scoped),
138185 );
186
139187 expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]);
140188 await system.dispose();
141189 });
142190
143 it("stores completed turn summaries even when no files changed", async () => {
191 it("stores completed checkpoint summaries even when no files changed", async () => {
144192 const system = await createOrchestrationSystem();
145 const engine = system.engine;
146 const completedAt = new Date().toISOString();
193 const { engine } = system;
194 const createdAt = now();
195
196 await system.run(
197 engine.dispatch({
198 type: "project.create",
199 commandId: "cmd-project-turn-diff-create",
200 projectId: "project-turn-diff",
201 title: "Turn Diff Project",
202 workspaceRoot: "/tmp/project-turn-diff",
203 defaultModel: "gpt-5-codex",
204 createdAt,
205 }),
206 );
147207 await system.run(
148208 engine.dispatch({
149209 type: "thread.create",
150 commandId: "cmd-thread-turn-diff",
210 commandId: "cmd-thread-turn-diff-create",
151211 threadId: "thread-turn-diff",
152212 projectId: "project-turn-diff",
153213 title: "Turn diff thread",
154214 model: "gpt-5-codex",
155215 branch: null,
156216 worktreePath: null,
157 createdAt: completedAt,
217 createdAt,
158218 }),
159219 );
160220 await system.run(
161221 engine.dispatch({
162 type: "thread.turnDiff.complete",
222 type: "thread.turn.diff.complete",
163223 commandId: "cmd-turn-diff-complete",
164224 threadId: "thread-turn-diff",
165225 turnId: "turn-1",
166 completedAt,
167 status: "completed",
226 completedAt: createdAt,
227 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
228 status: "ready",
168229 files: [],
169230 checkpointTurnCount: 1,
170 createdAt: completedAt,
231 createdAt,
171232 }),
172233 );
173234
174 const firstThread = (await system.run(engine.getReadModel())).threads.find(
175 (thread) => thread.id === "thread-turn-diff",
235 const thread = (await system.run(engine.getReadModel())).threads.find(
236 (entry) => entry.id === "thread-turn-diff",
176237 );
177 expect(firstThread?.turnDiffSummaries).toEqual([
238 expect(thread?.checkpoints).toEqual([
178239 {
179240 turnId: "turn-1",
180 completedAt,
181 status: "completed",
182 files: [],
183241 checkpointTurnCount: 1,
242 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
243 status: "ready",
244 files: [],
245 assistantMessageId: null,
246 completedAt: createdAt,
184247 },
185248 ]);
186249 await system.dispose();
187250 });
188251
189 it("reverts thread messages and turn summaries to a checkpoint", async () => {
190 const system = await createOrchestrationSystem();
191 const engine = system.engine;
192 const createdAt = new Date().toISOString();
193 await system.run(
194 engine.dispatch({
195 type: "thread.create",
196 commandId: "cmd-thread-revert",
197 threadId: "thread-revert",
198 projectId: "project-revert",
199 title: "Revert thread",
200 model: "gpt-5-codex",
201 branch: null,
202 worktreePath: null,
203 createdAt,
204 }),
205 );
206 await system.run(
207 engine.dispatch({
208 type: "message.send",
209 commandId: "cmd-msg-1-user",
210 threadId: "thread-revert",
211 messageId: "user-1",
212 role: "user",
213 text: "first",
214 streaming: false,
215 createdAt,
216 }),
217 );
218 await system.run(
219 engine.dispatch({
220 type: "message.send",
221 commandId: "cmd-msg-1-assistant",
222 threadId: "thread-revert",
223 messageId: "assistant:turn-1",
224 role: "assistant",
225 text: "first-response",
226 streaming: false,
227 createdAt,
228 }),
229 );
230 await system.run(
231 engine.dispatch({
232 type: "thread.turnDiff.complete",
233 commandId: "cmd-turn-1-complete",
234 threadId: "thread-revert",
235 turnId: "turn-1",
236 completedAt: createdAt,
237 status: "completed",
238 files: [],
239 assistantMessageId: "assistant:turn-1",
240 checkpointTurnCount: 1,
241 createdAt,
242 }),
243 );
244
245 const createdAtSecond = new Date(Date.now() + 1_000).toISOString();
246 await system.run(
247 engine.dispatch({
248 type: "message.send",
249 commandId: "cmd-msg-2-user",
250 threadId: "thread-revert",
251 messageId: "user-2",
252 role: "user",
253 text: "second",
254 streaming: false,
255 createdAt: createdAtSecond,
256 }),
257 );
258 await system.run(
259 engine.dispatch({
260 type: "message.send",
261 commandId: "cmd-msg-2-assistant",
262 threadId: "thread-revert",
263 messageId: "assistant:turn-2",
264 role: "assistant",
265 text: "second-response",
266 streaming: false,
267 createdAt: createdAtSecond,
268 }),
269 );
270 await system.run(
271 engine.dispatch({
272 type: "thread.turnDiff.complete",
273 commandId: "cmd-turn-2-complete",
274 threadId: "thread-revert",
275 turnId: "turn-2",
276 completedAt: createdAtSecond,
277 status: "completed",
278 files: [],
279 assistantMessageId: "assistant:turn-2",
280 checkpointTurnCount: 2,
281 createdAt: createdAtSecond,
282 }),
283 );
284
285 await system.run(
286 engine.dispatch({
287 type: "thread.revert",
288 commandId: "cmd-thread-revert-apply",
289 threadId: "thread-revert",
290 turnCount: 1,
291 messageCount: 2,
292 createdAt: createdAtSecond,
293 }),
294 );
295
296 const thread = (await system.run(engine.getReadModel())).threads.find(
297 (entry) => entry.id === "thread-revert",
298 );
299 expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]);
300 expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]);
301 expect(thread?.latestTurnId).toBe("turn-1");
302 await system.dispose();
303 });
304
305 it("allows stop to be called multiple times", async () => {
306 const inMemoryStore: OrchestrationEventStoreShape = {
307 append(event) {
308 return Effect.succeed({ ...event, sequence: 1 });
309 },
310 readFromSequence() {
311 return Stream.empty;
312 },
313 readAll() {
314 return Stream.empty;
315 },
316 };
317 const runtime = ManagedRuntime.make(
318 OrchestrationEngineLive.pipe(
319 Layer.provide(Layer.succeed(OrchestrationEventStore, inMemoryStore)),
320 ),
321 );
322 await runtime.runPromise(Effect.service(OrchestrationEngineService));
323 await runtime.dispose();
324 await expect(runtime.dispose()).resolves.toBeUndefined();
325 });
326
327252 it("keeps processing queued commands after a storage failure", async () => {
328253 const events: OrchestrationEvent[] = [];
329254 let nextSequence = 1;
......@@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => {
331256
332257 const flakyStore: OrchestrationEventStoreShape = {
333258 append(event) {
334 if (shouldFailFirstAppend) {
259 if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") {
335260 shouldFailFirstAppend = false;
336261 return Effect.fail(
337262 new PersistenceSqlError({
......@@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => {
358283
359284 const runtime = ManagedRuntime.make(
360285 OrchestrationEngineLive.pipe(
286 Layer.provide(OrchestrationProjectionPipelineLive),
361287 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
288 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
289 Layer.provide(SqlitePersistenceMemory),
362290 ),
363291 );
364292 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
365 const createdAt = new Date().toISOString();
293 const createdAt = now();
294
295 await runtime.runPromise(
296 engine.dispatch({
297 type: "project.create",
298 commandId: "cmd-project-flaky-create",
299 projectId: "project-flaky",
300 title: "Flaky Project",
301 workspaceRoot: "/tmp/project-flaky",
302 defaultModel: "gpt-5-codex",
303 createdAt,
304 }),
305 );
366306
367307 await expect(
368308 runtime.runPromise(
......@@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => {
394334 }),
395335 );
396336
397 expect(result.sequence).toBe(1);
398 expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1);
399
337 expect(result.sequence).toBe(2);
338 expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);
400339 await runtime.dispose();
401340 });
402341
403342 it("fails command dispatch when command invariants are violated", async () => {
404343 const system = await createOrchestrationSystem();
405 const engine = system.engine;
406 const createdAt = new Date().toISOString();
344 const { engine } = system;
407345
408346 await expect(
409347 system.run(
410348 engine.dispatch({
411 type: "message.send",
349 type: "thread.turn.start",
412350 commandId: "cmd-invariant-missing-thread",
413351 threadId: "thread-missing",
414 messageId: "msg-missing",
415 role: "user",
416 text: "hello",
417 streaming: false,
418 createdAt,
352 message: {
353 messageId: "msg-missing",
354 role: "user",
355 text: "hello",
356 attachments: [],
357 },
358 createdAt: now(),
419359 }),
420360 ),
421361 ).rejects.toThrow("Thread 'thread-missing' does not exist");
......@@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => {
425365
426366 it("rejects duplicate thread creation", async () => {
427367 const system = await createOrchestrationSystem();
428 const engine = system.engine;
429 const createdAt = new Date().toISOString();
368 const { engine } = system;
369 const createdAt = now();
370
371 await system.run(
372 engine.dispatch({
373 type: "project.create",
374 commandId: "cmd-project-duplicate-create",
375 projectId: "project-duplicate",
376 title: "Duplicate Project",
377 workspaceRoot: "/tmp/project-duplicate",
378 defaultModel: "gpt-5-codex",
379 createdAt,
380 }),
381 );
430382
431383 await system.run(
432384 engine.dispatch({
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts created+610
......@@ -0,0 +1,610 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { execFileSync } from "node:child_process";
5
6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
7import {
8 CheckpointRef,
9 CommandId,
10 EventId,
11 MessageId,
12 ProjectId,
13 ProviderSessionId,
14 ProviderThreadId,
15 ThreadId,
16 TurnId,
17} from "@t3tools/contracts";
18import { NodeServices } from "@effect/platform-node";
19import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
20import { afterEach, describe, expect, it, vi } from "vitest";
21
22import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
23import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
24import { CheckpointReactorLive } from "./CheckpointReactor.ts";
25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
27import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts";
28import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
29import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
30import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
31import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
32import {
33 OrchestrationEngineService,
34 type OrchestrationEngineShape,
35} from "../Services/OrchestrationEngine.ts";
36import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
37import {
38 ProviderService,
39 type ProviderServiceShape,
40} from "../../provider/Services/ProviderService.ts";
41
42const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
43const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
44const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
45const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
46const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
47
48function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
49 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
50 return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`);
51}
52
53function createProviderServiceHarness(cwd: string, hasSession = true) {
54 const now = new Date().toISOString();
55 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
56 const rollbackConversation = vi.fn(() => Effect.void);
57
58 const unsupported = <A>() =>
59 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
60 const listSessions = () =>
61 hasSession
62 ? Effect.succeed([
63 {
64 sessionId: asSessionId("sess-1"),
65 provider: "codex",
66 status: "ready",
67 threadId: asThreadId("thread-1"),
68 cwd,
69 createdAt: now,
70 updatedAt: now,
71 },
72 ] satisfies ReadonlyArray<ProviderSession>)
73 : Effect.succeed([] as ReadonlyArray<ProviderSession>);
74 const service: ProviderServiceShape = {
75 startSession: () => unsupported(),
76 sendTurn: () => unsupported(),
77 interruptTurn: () => unsupported(),
78 respondToRequest: () => unsupported(),
79 stopSession: () => unsupported(),
80 listSessions,
81 rollbackConversation,
82 stopAll: () => Effect.void,
83 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
84 };
85
86 const emit = (event: ProviderRuntimeEvent): void => {
87 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
88 };
89
90 return {
91 service,
92 rollbackConversation,
93 emit,
94 };
95}
96
97async function waitForThread(
98 engine: OrchestrationEngineShape,
99 predicate: (thread: {
100 latestTurnId: string | null;
101 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
102 activities: ReadonlyArray<{ kind: string }>;
103 }) => boolean,
104 timeoutMs = 2000,
105) {
106 const deadline = Date.now() + timeoutMs;
107 const poll = async (): Promise<{
108 latestTurnId: string | null;
109 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
110 activities: ReadonlyArray<{ kind: string }>;
111 }> => {
112 const readModel = await Effect.runPromise(engine.getReadModel());
113 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
114 if (thread && predicate(thread)) {
115 return thread;
116 }
117 if (Date.now() >= deadline) {
118 throw new Error("Timed out waiting for thread state.");
119 }
120 await new Promise((resolve) => setTimeout(resolve, 10));
121 return poll();
122 };
123 return poll();
124}
125
126async function waitForEvent(
127 engine: OrchestrationEngineShape,
128 predicate: (event: { type: string }) => boolean,
129 timeoutMs = 2000,
130) {
131 const deadline = Date.now() + timeoutMs;
132 const poll = async () => {
133 const events = await Effect.runPromise(
134 Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))),
135 );
136 if (events.some(predicate)) {
137 return events;
138 }
139 if (Date.now() >= deadline) {
140 throw new Error("Timed out waiting for orchestration event.");
141 }
142 await new Promise((resolve) => setTimeout(resolve, 10));
143 return poll();
144 };
145 return poll();
146}
147
148async function waitForDiffBlob(
149 repository: CheckpointDiffBlobRepository["Service"],
150 input: { threadId: ThreadId; fromTurnCount: number; toTurnCount: number },
151 timeoutMs = 2000,
152) {
153 const deadline = Date.now() + timeoutMs;
154 const poll = async () => {
155 const blob = await Effect.runPromise(repository.get(input));
156 if (blob._tag === "Some") {
157 return blob.value;
158 }
159 if (Date.now() >= deadline) {
160 throw new Error("Timed out waiting for checkpoint diff blob persistence.");
161 }
162 await new Promise((resolve) => setTimeout(resolve, 10));
163 return poll();
164 };
165 return poll();
166}
167
168function runGit(cwd: string, args: ReadonlyArray<string>) {
169 return execFileSync("git", args, {
170 cwd,
171 stdio: ["ignore", "pipe", "pipe"],
172 encoding: "utf8",
173 });
174}
175
176function createGitRepository() {
177 const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-checkpoint-handler-"));
178 runGit(cwd, ["init", "--initial-branch=main"]);
179 runGit(cwd, ["config", "user.email", "test@example.com"]);
180 runGit(cwd, ["config", "user.name", "Test User"]);
181 fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8");
182 runGit(cwd, ["add", "."]);
183 runGit(cwd, ["commit", "-m", "Initial"]);
184 return cwd;
185}
186
187function gitRefExists(cwd: string, ref: string): boolean {
188 try {
189 runGit(cwd, ["show-ref", "--verify", "--quiet", ref]);
190 return true;
191 } catch {
192 return false;
193 }
194}
195
196function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
197 return runGit(cwd, ["show", `${ref}:${filePath}`]);
198}
199
200async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {
201 const deadline = Date.now() + timeoutMs;
202 const poll = async (): Promise<void> => {
203 if (gitRefExists(cwd, ref)) {
204 return;
205 }
206 if (Date.now() >= deadline) {
207 throw new Error(`Timed out waiting for git ref '${ref}'.`);
208 }
209 await new Promise((resolve) => setTimeout(resolve, 10));
210 return poll();
211 };
212 return poll();
213}
214
215describe("CheckpointReactor", () => {
216 let runtime: ManagedRuntime.ManagedRuntime<
217 OrchestrationEngineService | CheckpointReactor | CheckpointDiffBlobRepository | CheckpointStore,
218 unknown
219 > | null = null;
220 let scope: Scope.Closeable | null = null;
221 const tempDirs: string[] = [];
222
223 afterEach(async () => {
224 if (scope) {
225 await Effect.runPromise(Scope.close(scope, Exit.void));
226 }
227 scope = null;
228 if (runtime) {
229 await runtime.dispose();
230 }
231 runtime = null;
232 while (tempDirs.length > 0) {
233 const dir = tempDirs.pop();
234 if (dir) {
235 fs.rmSync(dir, { recursive: true, force: true });
236 }
237 }
238 });
239
240 async function createHarness(options?: {
241 readonly hasSession?: boolean;
242 readonly seedFilesystemCheckpoints?: boolean;
243 }) {
244 const cwd = createGitRepository();
245 tempDirs.push(cwd);
246 const provider = createProviderServiceHarness(cwd, options?.hasSession ?? true);
247 const orchestrationLayer = OrchestrationEngineLive.pipe(
248 Layer.provide(OrchestrationProjectionPipelineLive),
249 Layer.provide(OrchestrationEventStoreLive),
250 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
251 Layer.provide(SqlitePersistenceMemory),
252 );
253 const diffBlobLayer = CheckpointDiffBlobRepositoryLive.pipe(
254 Layer.provide(SqlitePersistenceMemory),
255 );
256 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
257 const layer = CheckpointReactorLive.pipe(
258 Layer.provideMerge(orchestrationLayer),
259 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
260 Layer.provideMerge(diffBlobLayer),
261 Layer.provideMerge(checkpointStoreLayer),
262 );
263
264 runtime = ManagedRuntime.make(layer);
265 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
266 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
267 const diffBlobRepository = await runtime.runPromise(
268 Effect.service(CheckpointDiffBlobRepository),
269 );
270 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
271 scope = await Effect.runPromise(Scope.make("sequential"));
272 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
273 await Effect.runPromise(Effect.sleep("10 millis"));
274
275 const createdAt = new Date().toISOString();
276 await Effect.runPromise(
277 engine.dispatch({
278 type: "project.create",
279 commandId: asCommandId("cmd-project-create"),
280 projectId: asProjectId("project-1"),
281 title: "Test Project",
282 workspaceRoot: "/tmp/test-project",
283 defaultModel: "gpt-5-codex",
284 createdAt,
285 }),
286 );
287 await Effect.runPromise(
288 engine.dispatch({
289 type: "thread.create",
290 commandId: asCommandId("cmd-thread-create"),
291 threadId: asThreadId("thread-1"),
292 projectId: asProjectId("project-1"),
293 title: "Thread",
294 model: "gpt-5-codex",
295 branch: null,
296 worktreePath: cwd,
297 createdAt,
298 }),
299 );
300
301 if (options?.seedFilesystemCheckpoints ?? true) {
302 await runtime.runPromise(
303 checkpointStore.captureCheckpoint({
304 cwd,
305 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0),
306 }),
307 );
308 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
309 await runtime.runPromise(
310 checkpointStore.captureCheckpoint({
311 cwd,
312 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
313 }),
314 );
315 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
316 await runtime.runPromise(
317 checkpointStore.captureCheckpoint({
318 cwd,
319 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),
320 }),
321 );
322 }
323
324 return {
325 engine,
326 provider,
327 cwd,
328 diffBlobRepository,
329 };
330 }
331
332 it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => {
333 const harness = await createHarness({ seedFilesystemCheckpoints: false });
334 const createdAt = new Date().toISOString();
335
336 await Effect.runPromise(
337 harness.engine.dispatch({
338 type: "thread.session.set",
339 commandId: asCommandId("cmd-session-set-capture"),
340 threadId: asThreadId("thread-1"),
341 session: {
342 threadId: asThreadId("thread-1"),
343 status: "ready",
344 providerName: "codex",
345 providerSessionId: asSessionId("sess-1"),
346 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
347 activeTurnId: null,
348 lastError: null,
349 updatedAt: createdAt,
350 },
351 createdAt,
352 }),
353 );
354
355 harness.provider.emit({
356 type: "turn.started",
357 eventId: EventId.makeUnsafe("evt-turn-started-1"),
358 provider: "codex",
359 sessionId: asSessionId("sess-1"),
360 createdAt: new Date().toISOString(),
361 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
362 turnId: asTurnId("turn-1"),
363 });
364 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));
365
366 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
367 harness.provider.emit({
368 type: "turn.completed",
369 eventId: EventId.makeUnsafe("evt-turn-completed-1"),
370 provider: "codex",
371 sessionId: asSessionId("sess-1"),
372 createdAt: new Date().toISOString(),
373 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
374 turnId: asTurnId("turn-1"),
375 status: "completed",
376 });
377
378 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
379 const thread = await waitForThread(
380 harness.engine,
381 (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1,
382 );
383 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
384 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true);
385 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true);
386 expect(
387 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"),
388 ).toBe("v1\n");
389 expect(
390 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"),
391 ).toBe("v2\n");
392 });
393
394 it("captures pre-turn baseline from thread.turn.start domain flow before runtime turn events", async () => {
395 const harness = await createHarness({
396 hasSession: false,
397 seedFilesystemCheckpoints: false,
398 });
399
400 await Effect.runPromise(
401 harness.engine.dispatch({
402 type: "thread.turn.start",
403 commandId: asCommandId("cmd-turn-start-for-baseline"),
404 threadId: asThreadId("thread-1"),
405 message: {
406 messageId: MessageId.makeUnsafe("message-user-1"),
407 role: "user",
408 text: "start turn",
409 attachments: [],
410 },
411 createdAt: new Date().toISOString(),
412 }),
413 );
414
415 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));
416 expect(
417 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"),
418 ).toBe("v1\n");
419 });
420
421 it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => {
422 const harness = await createHarness();
423 const createdAt = new Date().toISOString();
424
425 await Effect.runPromise(
426 harness.engine.dispatch({
427 type: "thread.session.set",
428 commandId: asCommandId("cmd-session-set-checkpoint-captured"),
429 threadId: asThreadId("thread-1"),
430 session: {
431 threadId: asThreadId("thread-1"),
432 status: "ready",
433 providerName: "codex",
434 providerSessionId: asSessionId("sess-1"),
435 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
436 activeTurnId: null,
437 lastError: null,
438 updatedAt: createdAt,
439 },
440 createdAt,
441 }),
442 );
443
444 harness.provider.emit({
445 type: "checkpoint.captured",
446 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
447 provider: "codex",
448 sessionId: asSessionId("sess-1"),
449 createdAt: new Date().toISOString(),
450 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
451 turnId: asTurnId("turn-3"),
452 turnCount: 3,
453 status: "completed",
454 });
455
456 const thread = await waitForThread(
457 harness.engine,
458 (entry) => entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3),
459 );
460 expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(true);
461 });
462
463 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
464 const harness = await createHarness();
465 const createdAt = new Date().toISOString();
466
467 await Effect.runPromise(
468 harness.engine.dispatch({
469 type: "thread.session.set",
470 commandId: asCommandId("cmd-session-set"),
471 threadId: asThreadId("thread-1"),
472 session: {
473 threadId: asThreadId("thread-1"),
474 status: "ready",
475 providerName: "codex",
476 providerSessionId: asSessionId("sess-1"),
477 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
478 activeTurnId: null,
479 lastError: null,
480 updatedAt: createdAt,
481 },
482 createdAt,
483 }),
484 );
485
486 await Effect.runPromise(
487 harness.engine.dispatch({
488 type: "thread.turn.diff.complete",
489 commandId: asCommandId("cmd-diff-1"),
490 threadId: asThreadId("thread-1"),
491 turnId: asTurnId("turn-1"),
492 completedAt: createdAt,
493 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
494 status: "ready",
495 files: [],
496 checkpointTurnCount: 1,
497 createdAt,
498 }),
499 );
500 await Effect.runPromise(
501 harness.engine.dispatch({
502 type: "thread.turn.diff.complete",
503 commandId: asCommandId("cmd-diff-2"),
504 threadId: asThreadId("thread-1"),
505 turnId: asTurnId("turn-2"),
506 completedAt: createdAt,
507 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),
508 status: "ready",
509 files: [],
510 checkpointTurnCount: 2,
511 createdAt,
512 }),
513 );
514
515 await Effect.runPromise(
516 harness.engine.dispatch({
517 type: "thread.checkpoint.revert",
518 commandId: asCommandId("cmd-revert-request"),
519 threadId: asThreadId("thread-1"),
520 turnCount: 1,
521 createdAt,
522 }),
523 );
524
525 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
526 const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1);
527
528 expect(thread.latestTurnId).toBe("turn-1");
529 expect(thread.checkpoints).toHaveLength(1);
530 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
531 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
532 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
533 sessionId: asSessionId("sess-1"),
534 numTurns: 1,
535 });
536 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
537 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 2))).toBe(false);
538 });
539
540 it("appends an error activity when revert is requested without an active session", async () => {
541 const harness = await createHarness({ hasSession: false });
542 const createdAt = new Date().toISOString();
543
544 await Effect.runPromise(
545 harness.engine.dispatch({
546 type: "thread.checkpoint.revert",
547 commandId: asCommandId("cmd-revert-no-session"),
548 threadId: asThreadId("thread-1"),
549 turnCount: 1,
550 createdAt,
551 }),
552 );
553
554 const thread = await waitForThread(harness.engine, (entry) =>
555 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),
556 );
557
558 expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe(
559 true,
560 );
561 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
562 });
563
564 it("persists full checkpoint diff blobs on thread.turn-diff-completed", async () => {
565 const harness = await createHarness();
566 const createdAt = new Date().toISOString();
567
568 await Effect.runPromise(
569 harness.engine.dispatch({
570 type: "thread.session.set",
571 commandId: asCommandId("cmd-session-set-diff"),
572 threadId: asThreadId("thread-1"),
573 session: {
574 threadId: asThreadId("thread-1"),
575 status: "ready",
576 providerName: "codex",
577 providerSessionId: asSessionId("sess-1"),
578 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
579 activeTurnId: null,
580 lastError: null,
581 updatedAt: createdAt,
582 },
583 createdAt,
584 }),
585 );
586
587 await Effect.runPromise(
588 harness.engine.dispatch({
589 type: "thread.turn.diff.complete",
590 commandId: asCommandId("cmd-diff-persist"),
591 threadId: asThreadId("thread-1"),
592 turnId: asTurnId("turn-1"),
593 completedAt: createdAt,
594 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
595 status: "ready",
596 files: [],
597 checkpointTurnCount: 1,
598 createdAt,
599 }),
600 );
601
602 const persistedBlob = await waitForDiffBlob(harness.diffBlobRepository, {
603 threadId: asThreadId("thread-1"),
604 fromTurnCount: 0,
605 toTurnCount: 1,
606 });
607 expect(persistedBlob.diff).toContain("diff --git");
608 expect(persistedBlob.diff).toContain("README.md");
609 });
610});
apps/server/src/orchestration/Layers/CheckpointReactor.ts created+745
......@@ -0,0 +1,745 @@
1import {
2 CheckpointRef,
3 CommandId,
4 EventId,
5 MessageId,
6 ProviderSessionId,
7 ThreadId,
8 TurnId,
9 type OrchestrationEvent,
10 type ProviderRuntimeEvent,
11} from "@t3tools/contracts";
12import { Effect, Layer, Option, Queue, Stream } from "effect";
13
14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
16import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
17import { ProviderService } from "../../provider/Services/ProviderService.ts";
18import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
19import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
20import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
21import { OrchestrationDispatchError } from "../Errors.ts";
22
23const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
24
25type ReactorInput =
26 | {
27 readonly source: "runtime";
28 readonly event: ProviderRuntimeEvent;
29 }
30 | {
31 readonly source: "domain";
32 readonly event: OrchestrationEvent;
33 };
34
35function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
36 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
37 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
38}
39
40function toTurnId(value: string | undefined): TurnId | null {
41 const normalized = value?.trim();
42 return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null;
43}
44
45function trimToNonEmpty(value: string | null | undefined): string | undefined {
46 const normalized = value?.trim();
47 return normalized && normalized.length > 0 ? normalized : undefined;
48}
49
50function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
51 switch (status) {
52 case "failed":
53 return "error";
54 case "cancelled":
55 case "interrupted":
56 return "missing";
57 case "completed":
58 default:
59 return "ready";
60 }
61}
62
63const serverCommandId = (tag: string): CommandId =>
64 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
65
66const make = Effect.gen(function* () {
67 const orchestrationEngine = yield* OrchestrationEngineService;
68 const providerService = yield* ProviderService;
69 const checkpointStore = yield* CheckpointStore;
70 const checkpointDiffBlobRepository = yield* CheckpointDiffBlobRepository;
71
72 const appendRevertFailureActivity = (input: {
73 readonly threadId: ThreadId;
74 readonly turnCount: number;
75 readonly detail: string;
76 readonly createdAt: string;
77 }) =>
78 orchestrationEngine.dispatch({
79 type: "thread.activity.append",
80 commandId: serverCommandId("checkpoint-revert-failure"),
81 threadId: input.threadId,
82 activity: {
83 id: EventId.makeUnsafe(crypto.randomUUID()),
84 tone: "error",
85 kind: "checkpoint.revert.failed",
86 summary: "Checkpoint revert failed",
87 payload: {
88 turnCount: input.turnCount,
89 detail: input.detail,
90 },
91 turnId: null,
92 createdAt: input.createdAt,
93 },
94 createdAt: input.createdAt,
95 });
96
97 const appendCaptureFailureActivity = (input: {
98 readonly sessionId: ProviderSessionId;
99 readonly turnId: TurnId | null;
100 readonly detail: string;
101 readonly createdAt: string;
102 }) =>
103 Effect.gen(function* () {
104 const readModel = yield* orchestrationEngine.getReadModel();
105 const thread = readModel.threads.find(
106 (entry) => entry.session?.providerSessionId === input.sessionId,
107 );
108 if (!thread) {
109 return;
110 }
111 yield* orchestrationEngine.dispatch({
112 type: "thread.activity.append",
113 commandId: serverCommandId("checkpoint-capture-failure"),
114 threadId: thread.id,
115 activity: {
116 id: EventId.makeUnsafe(crypto.randomUUID()),
117 tone: "error",
118 kind: "checkpoint.capture.failed",
119 summary: "Checkpoint capture failed",
120 payload: {
121 detail: input.detail,
122 },
123 turnId: input.turnId,
124 createdAt: input.createdAt,
125 },
126 createdAt: input.createdAt,
127 });
128 });
129
130 const resolveSessionRuntimeForThread = Effect.fnUntraced(function* (
131 threadId: ThreadId,
132 ): Effect.fn.Return<
133 Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }>
134 > {
135 const readModel = yield* orchestrationEngine.getReadModel();
136 const thread = readModel.threads.find((entry) => entry.id === threadId);
137 const projectedSessionId = thread?.session?.providerSessionId ?? null;
138
139 const sessions = yield* providerService.listSessions();
140
141 const findSessionWithCwd = (
142 session: (typeof sessions)[number] | undefined,
143 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {
144 const cwd = session?.cwd?.trim();
145 if (!session || !cwd) {
146 return Option.none();
147 }
148 return Option.some({ sessionId: session.sessionId, cwd });
149 };
150
151 if (projectedSessionId !== null) {
152 const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId);
153 const fromProjected = findSessionWithCwd(projectedSession);
154 if (Option.isSome(fromProjected)) {
155 return fromProjected;
156 }
157 }
158
159 const matchedSession = sessions.find((session) => session.threadId?.trim() === threadId.trim());
160 return findSessionWithCwd(matchedSession);
161 });
162
163 const persistDiffBlobFromTurnCompletion = Effect.fnUntraced(function* (
164 event: Extract<OrchestrationEvent, { type: "thread.turn-diff-completed" }>,
165 ) {
166 const toTurnCount = event.payload.checkpointTurnCount;
167 const fromTurnCount = Math.max(0, toTurnCount - 1);
168
169 const readModel = yield* orchestrationEngine.getReadModel();
170 const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
171 if (!thread) {
172 return;
173 }
174
175 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
176 if (Option.isNone(sessionRuntime)) {
177 yield* Effect.logWarning(
178 "checkpoint diff blob skipped: no active provider session cwd for thread",
179 {
180 threadId: event.payload.threadId,
181 fromTurnCount,
182 toTurnCount,
183 },
184 );
185 return;
186 }
187
188 const fromCheckpointRef =
189 fromTurnCount === 0
190 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
191 : thread.checkpoints.find((checkpoint) => checkpoint.checkpointTurnCount === fromTurnCount)
192 ?.checkpointRef;
193
194 if (!fromCheckpointRef) {
195 yield* Effect.logWarning("checkpoint diff blob skipped: missing from checkpoint ref", {
196 threadId: event.payload.threadId,
197 fromTurnCount,
198 toTurnCount,
199 });
200 return;
201 }
202
203 const diff = yield* checkpointStore.diffCheckpoints({
204 cwd: sessionRuntime.value.cwd,
205 fromCheckpointRef,
206 toCheckpointRef: event.payload.checkpointRef,
207 fallbackFromToHead: false,
208 });
209
210 yield* checkpointDiffBlobRepository.upsert({
211 threadId: event.payload.threadId,
212 fromTurnCount,
213 toTurnCount,
214 diff,
215 createdAt: event.payload.completedAt,
216 });
217 });
218
219 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
220 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
221 ) {
222 const turnId = toTurnId(event.turnId);
223 if (!turnId) {
224 return;
225 }
226
227 const readModel = yield* orchestrationEngine.getReadModel();
228 const thread = readModel.threads.find(
229 (entry) => entry.session?.providerSessionId === event.sessionId,
230 );
231 if (!thread) {
232 return;
233 }
234
235 if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId)) {
236 return;
237 }
238
239 const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id);
240 if (Option.isNone(sessionRuntime)) {
241 yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", {
242 threadId: thread.id,
243 turnId,
244 });
245 return;
246 }
247
248 const currentTurnCount = thread.checkpoints.reduce(
249 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
250 0,
251 );
252 const nextTurnCount = currentTurnCount + 1;
253 const fromTurnCount = Math.max(0, nextTurnCount - 1);
254 const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount);
255 const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount);
256
257 const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({
258 cwd: sessionRuntime.value.cwd,
259 checkpointRef: fromCheckpointRef,
260 });
261 if (!fromCheckpointExists) {
262 yield* Effect.logWarning("checkpoint completion missing pre-turn baseline", {
263 threadId: thread.id,
264 turnId,
265 fromTurnCount,
266 });
267 }
268
269 yield* checkpointStore.captureCheckpoint({
270 cwd: sessionRuntime.value.cwd,
271 checkpointRef: targetCheckpointRef,
272 });
273
274 const files = yield* checkpointStore
275 .diffCheckpoints({
276 cwd: sessionRuntime.value.cwd,
277 fromCheckpointRef,
278 toCheckpointRef: targetCheckpointRef,
279 fallbackFromToHead: false,
280 })
281 .pipe(
282 Effect.map((diff) =>
283 parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({
284 path: file.path,
285 kind: "modified" as const,
286 additions: file.additions,
287 deletions: file.deletions,
288 })),
289 ),
290 Effect.catch((error) =>
291 Effect.logWarning("failed to derive checkpoint file summary", {
292 threadId: thread.id,
293 turnId,
294 turnCount: nextTurnCount,
295 detail: error.message,
296 }).pipe(Effect.as([])),
297 ),
298 );
299
300 const assistantMessageId =
301 thread.messages
302 .toReversed()
303 .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ??
304 MessageId.makeUnsafe(`assistant:${turnId}`);
305
306 const now = event.createdAt;
307 yield* orchestrationEngine.dispatch({
308 type: "thread.turn.diff.complete",
309 commandId: serverCommandId("checkpoint-turn-diff-complete"),
310 threadId: thread.id,
311 turnId,
312 completedAt: now,
313 checkpointRef: targetCheckpointRef,
314 status: checkpointStatusFromRuntime(event.status),
315 files,
316 assistantMessageId,
317 checkpointTurnCount: nextTurnCount,
318 createdAt: now,
319 });
320
321 yield* orchestrationEngine.dispatch({
322 type: "thread.activity.append",
323 commandId: serverCommandId("checkpoint-captured-activity"),
324 threadId: thread.id,
325 activity: {
326 id: EventId.makeUnsafe(crypto.randomUUID()),
327 tone: "info",
328 kind: "checkpoint.captured",
329 summary: "Checkpoint captured",
330 payload: {
331 turnCount: nextTurnCount,
332 ...(event.status !== undefined ? { status: event.status } : {}),
333 },
334 turnId,
335 createdAt: now,
336 },
337 createdAt: now,
338 });
339 });
340
341 const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* (
342 event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>,
343 ) {
344 const turnId = toTurnId(event.turnId);
345 if (!turnId) {
346 return;
347 }
348
349 const readModel = yield* orchestrationEngine.getReadModel();
350 const thread = readModel.threads.find(
351 (entry) => entry.session?.providerSessionId === event.sessionId,
352 );
353 if (!thread) {
354 return;
355 }
356
357 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);
358 const checkpointCwd =
359 checkpointCwdFromThread ??
360 Option.match(yield* resolveSessionRuntimeForThread(thread.id), {
361 onNone: () => undefined,
362 onSome: (runtime) => runtime.cwd,
363 });
364 if (!checkpointCwd) {
365 yield* Effect.logWarning(
366 "checkpoint pre-turn capture skipped: no workspace cwd",
367 {
368 threadId: thread.id,
369 turnId,
370 },
371 );
372 return;
373 }
374
375 const currentTurnCount = thread.checkpoints.reduce(
376 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
377 0,
378 );
379 const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount);
380 const baselineExists = yield* checkpointStore.hasCheckpointRef({
381 cwd: checkpointCwd,
382 checkpointRef: baselineCheckpointRef,
383 });
384 if (baselineExists) {
385 return;
386 }
387
388 yield* checkpointStore.captureCheckpoint({
389 cwd: checkpointCwd,
390 checkpointRef: baselineCheckpointRef,
391 });
392 });
393
394 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* (
395 event: Extract<OrchestrationEvent, { type: "thread.turn-start-requested" | "thread.message-sent" }>,
396 ) {
397 if (event.type === "thread.message-sent") {
398 if (event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null) {
399 return;
400 }
401 }
402
403 const threadId = event.payload.threadId;
404 const readModel = yield* orchestrationEngine.getReadModel();
405 const thread = readModel.threads.find((entry) => entry.id === threadId);
406 if (!thread) {
407 return;
408 }
409
410 const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath);
411 const checkpointCwd =
412 checkpointCwdFromThread ??
413 Option.match(yield* resolveSessionRuntimeForThread(threadId), {
414 onNone: () => undefined,
415 onSome: (runtime) => runtime.cwd,
416 });
417 if (!checkpointCwd) {
418 yield* Effect.logWarning(
419 "checkpoint pre-turn capture skipped: no workspace cwd",
420 {
421 threadId,
422 },
423 );
424 return;
425 }
426
427 const currentTurnCount = thread.checkpoints.reduce(
428 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
429 0,
430 );
431 const baselineCheckpointRef = checkpointRefForThreadTurn(threadId, currentTurnCount);
432 const baselineExists = yield* checkpointStore.hasCheckpointRef({
433 cwd: checkpointCwd,
434 checkpointRef: baselineCheckpointRef,
435 });
436 if (baselineExists) {
437 return;
438 }
439
440 yield* checkpointStore.captureCheckpoint({
441 cwd: checkpointCwd,
442 checkpointRef: baselineCheckpointRef,
443 });
444 });
445
446 const handleRevertRequested = Effect.fnUntraced(function* (
447 event: Extract<OrchestrationEvent, { type: "thread.checkpoint-revert-requested" }>,
448 ) {
449 const now = new Date().toISOString();
450
451 const readModel = yield* orchestrationEngine.getReadModel();
452 const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
453 if (!thread) {
454 yield* appendRevertFailureActivity({
455 threadId: event.payload.threadId,
456 turnCount: event.payload.turnCount,
457 detail: "Thread was not found in read model.",
458 createdAt: now,
459 }).pipe(Effect.catch(() => Effect.void));
460 return;
461 }
462
463 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
464 if (Option.isNone(sessionRuntime)) {
465 yield* appendRevertFailureActivity({
466 threadId: event.payload.threadId,
467 turnCount: event.payload.turnCount,
468 detail: "No active provider session with workspace cwd is bound to this thread.",
469 createdAt: now,
470 }).pipe(Effect.catch(() => Effect.void));
471 return;
472 }
473
474 const currentTurnCount = thread.checkpoints.reduce(
475 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
476 0,
477 );
478
479 if (event.payload.turnCount > currentTurnCount) {
480 yield* appendRevertFailureActivity({
481 threadId: event.payload.threadId,
482 turnCount: event.payload.turnCount,
483 detail: `Checkpoint turn count ${event.payload.turnCount} exceeds current turn count ${currentTurnCount}.`,
484 createdAt: now,
485 }).pipe(Effect.catch(() => Effect.void));
486 return;
487 }
488
489 const targetCheckpointRef =
490 event.payload.turnCount === 0
491 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
492 : thread.checkpoints.find(
493 (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
494 )?.checkpointRef;
495
496 if (!targetCheckpointRef) {
497 yield* appendRevertFailureActivity({
498 threadId: event.payload.threadId,
499 turnCount: event.payload.turnCount,
500 detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,
501 createdAt: now,
502 }).pipe(Effect.catch(() => Effect.void));
503 return;
504 }
505
506 const restored = yield* checkpointStore.restoreCheckpoint({
507 cwd: sessionRuntime.value.cwd,
508 checkpointRef: targetCheckpointRef,
509 fallbackToHead: event.payload.turnCount === 0,
510 });
511 if (!restored) {
512 yield* appendRevertFailureActivity({
513 threadId: event.payload.threadId,
514 turnCount: event.payload.turnCount,
515 detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
516 createdAt: now,
517 }).pipe(Effect.catch(() => Effect.void));
518 return;
519 }
520
521 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
522 if (rolledBackTurns > 0) {
523 yield* providerService.rollbackConversation({
524 sessionId: sessionRuntime.value.sessionId,
525 numTurns: rolledBackTurns,
526 });
527 }
528
529 const staleCheckpointRefs = thread.checkpoints
530 .filter((checkpoint) => checkpoint.checkpointTurnCount > event.payload.turnCount)
531 .map((checkpoint) => checkpoint.checkpointRef);
532
533 if (staleCheckpointRefs.length > 0) {
534 yield* checkpointStore.deleteCheckpointRefs({
535 cwd: sessionRuntime.value.cwd,
536 checkpointRefs: staleCheckpointRefs,
537 });
538 }
539
540 yield* orchestrationEngine
541 .dispatch({
542 type: "thread.revert.complete",
543 commandId: serverCommandId("checkpoint-revert-complete"),
544 threadId: event.payload.threadId,
545 turnCount: event.payload.turnCount,
546 createdAt: now,
547 })
548 .pipe(
549 Effect.catch((error) =>
550 appendRevertFailureActivity({
551 threadId: event.payload.threadId,
552 turnCount: event.payload.turnCount,
553 detail: error.message,
554 createdAt: now,
555 }),
556 ),
557 Effect.forkScoped,
558 Effect.asVoid,
559 );
560 });
561
562 const processDomainEvent = Effect.fnUntraced(function* (event: OrchestrationEvent) {
563 if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") {
564 yield* ensurePreTurnBaselineFromDomainTurnStart(event);
565 return;
566 }
567
568 if (event.type === "thread.turn-diff-completed") {
569 yield* persistDiffBlobFromTurnCompletion(event).pipe(
570 Effect.catch((error) =>
571 Effect.logWarning("checkpoint diff blob persistence failed", {
572 threadId: event.payload.threadId,
573 turnId: event.payload.turnId,
574 checkpointTurnCount: event.payload.checkpointTurnCount,
575 detail: error.message,
576 }),
577 ),
578 );
579 return;
580 }
581
582 if (event.type === "thread.checkpoint-revert-requested") {
583 yield* handleRevertRequested(event).pipe(
584 Effect.catch((error) =>
585 appendRevertFailureActivity({
586 threadId: event.payload.threadId,
587 turnCount: event.payload.turnCount,
588 detail: error.message,
589 createdAt: new Date().toISOString(),
590 }),
591 ),
592 );
593 }
594 });
595
596 const processRuntimeEvent = Effect.fnUntraced(function* (event: ProviderRuntimeEvent) {
597 if (event.type === "turn.started") {
598 yield* ensurePreTurnBaselineFromTurnStart(event);
599 return;
600 }
601
602 if (event.type === "turn.completed") {
603 const turnId = toTurnId(event.turnId);
604 yield* captureCheckpointFromTurnCompletion(event).pipe(
605 Effect.catch((error) =>
606 appendCaptureFailureActivity({
607 sessionId: event.sessionId,
608 turnId,
609 detail: error.message,
610 createdAt: new Date().toISOString(),
611 }).pipe(Effect.catch(() => Effect.void)),
612 ),
613 );
614 return;
615 }
616
617 if (event.type === "checkpoint.captured") {
618 const turnId = toTurnId(event.turnId);
619 if (!turnId) {
620 return;
621 }
622
623 const readModel = yield* orchestrationEngine.getReadModel();
624 const thread = readModel.threads.find(
625 (entry) => entry.session?.providerSessionId === event.sessionId,
626 );
627 if (!thread) {
628 return;
629 }
630
631 if (
632 thread.checkpoints.some(
633 (checkpoint) =>
634 checkpoint.turnId === turnId || checkpoint.checkpointTurnCount === event.turnCount,
635 )
636 ) {
637 return;
638 }
639
640 const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id);
641 if (Option.isNone(sessionRuntime)) {
642 return;
643 }
644
645 const fromTurnCount = Math.max(0, event.turnCount - 1);
646 const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount);
647 const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, event.turnCount);
648
649 const targetExists = yield* checkpointStore.hasCheckpointRef({
650 cwd: sessionRuntime.value.cwd,
651 checkpointRef: targetCheckpointRef,
652 });
653 if (!targetExists) {
654 yield* checkpointStore.captureCheckpoint({
655 cwd: sessionRuntime.value.cwd,
656 checkpointRef: targetCheckpointRef,
657 });
658 }
659
660 const files = yield* checkpointStore
661 .diffCheckpoints({
662 cwd: sessionRuntime.value.cwd,
663 fromCheckpointRef,
664 toCheckpointRef: targetCheckpointRef,
665 fallbackFromToHead: false,
666 })
667 .pipe(
668 Effect.map((diff) =>
669 parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({
670 path: file.path,
671 kind: "modified" as const,
672 additions: file.additions,
673 deletions: file.deletions,
674 })),
675 ),
676 Effect.catch(() => Effect.succeed([])),
677 );
678
679 const assistantMessageId =
680 thread.messages
681 .toReversed()
682 .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ??
683 MessageId.makeUnsafe(`assistant:${turnId}`);
684
685 yield* orchestrationEngine.dispatch({
686 type: "thread.turn.diff.complete",
687 commandId: serverCommandId("checkpoint-runtime-captured-diff-complete"),
688 threadId: thread.id,
689 turnId,
690 completedAt: event.createdAt,
691 checkpointRef: targetCheckpointRef,
692 status: checkpointStatusFromRuntime(event.status),
693 files,
694 assistantMessageId,
695 checkpointTurnCount: event.turnCount,
696 createdAt: event.createdAt,
697 });
698 }
699 });
700
701 const processInput = (
702 input: ReactorInput,
703 ): Effect.Effect<void, CheckpointStoreError | OrchestrationDispatchError, never> =>
704 input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event);
705
706 const start: CheckpointReactorShape["start"] = Effect.gen(function* () {
707 const queue = yield* Queue.unbounded<ReactorInput>();
708 yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));
709
710 yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInput))));
711
712 yield* Effect.forkScoped(
713 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
714 if (
715 event.type !== "thread.turn-start-requested" &&
716 event.type !== "thread.message-sent" &&
717 event.type !== "thread.turn-diff-completed" &&
718 event.type !== "thread.checkpoint-revert-requested"
719 ) {
720 return Effect.void;
721 }
722 return Queue.offer(queue, { source: "domain", event }).pipe(Effect.asVoid);
723 }),
724 );
725
726 yield* Effect.forkScoped(
727 Stream.runForEach(providerService.streamEvents, (event) => {
728 if (
729 event.type !== "turn.started" &&
730 event.type !== "turn.completed" &&
731 event.type !== "checkpoint.captured"
732 ) {
733 return Effect.void;
734 }
735 return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid);
736 }),
737 );
738 });
739
740 return {
741 start,
742 } satisfies CheckpointReactorShape;
743});
744
745export const CheckpointReactorLive = Layer.effect(CheckpointReactor, make);
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+117-17
......@@ -1,20 +1,24 @@
11import type {
2 OrchestrationCommand,
32 OrchestrationEvent,
43 OrchestrationReadModel,
4 ProjectId,
5 ThreadId,
56} from "@t3tools/contracts";
6import { OrchestrationCommandSchema } from "@t3tools/contracts";
7import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect";
7import { OrchestrationCommand } from "@t3tools/contracts";
8import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect";
89
9import { createLogger } from "../../logger.ts";
1010import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
11import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
1112import {
1213 toOrchestrationCommandDecodeError,
1314 toOrchestrationJsonParseError,
15 OrchestrationCommandInvariantError,
16 OrchestrationCommandPreviouslyRejectedError,
1417 type OrchestrationDispatchError,
1518} from "../Errors.ts";
1619import { decideOrchestrationCommand } from "../decider.ts";
1720import { createEmptyReadModel, projectEvent } from "../projector.ts";
21import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
1822import {
1923 OrchestrationEngineService,
2024 type OrchestrationEngineShape,
......@@ -25,11 +29,39 @@ interface CommandEnvelope {
2529 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
2630}
2731
28const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema);
32const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
33
34function commandToAggregateRef(command: OrchestrationCommand): {
35 readonly aggregateKind: "project" | "thread";
36 readonly aggregateId: ProjectId | ThreadId;
37} {
38 switch (command.type) {
39 case "project.create":
40 case "project.meta.update":
41 case "project.delete":
42 return {
43 aggregateKind: "project",
44 aggregateId: command.projectId,
45 };
46 default:
47 return {
48 aggregateKind: "thread",
49 aggregateId: command.threadId,
50 };
51 }
52}
53
54function formatDispatchError(error: OrchestrationDispatchError): string {
55 if (error instanceof Error && error.message.length > 0) {
56 return error.message;
57 }
58 return String(error);
59}
2960
3061const makeOrchestrationEngine = Effect.gen(function* () {
31 const logger = createLogger("orchestration");
3262 const eventStore = yield* OrchestrationEventStore;
63 const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;
64 const projectionPipeline = yield* OrchestrationProjectionPipeline;
3365
3466 let readModel = createEmptyReadModel(new Date().toISOString());
3567
......@@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () {
3870
3971 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>
4072 Effect.gen(function* () {
73 const existingReceipt = yield* commandReceiptRepository.getByCommandId({
74 commandId: envelope.command.commandId,
75 });
76 if (Option.isSome(existingReceipt)) {
77 if (existingReceipt.value.status === "accepted") {
78 yield* Deferred.succeed(envelope.result, {
79 sequence: existingReceipt.value.resultSequence,
80 });
81 return;
82 }
83 yield* Deferred.fail(
84 envelope.result,
85 new OrchestrationCommandPreviouslyRejectedError({
86 commandId: envelope.command.commandId,
87 detail: existingReceipt.value.error ?? "Previously rejected.",
88 }),
89 );
90 return;
91 }
92
4193 const eventBase = yield* decideOrchestrationCommand({
4294 command: envelope.command,
4395 readModel,
4496 });
45 const savedEvent = yield* eventStore.append(eventBase);
46 readModel = yield* projectEvent(readModel, savedEvent);
47 yield* PubSub.publish(eventPubSub, savedEvent);
48
49 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });
50 }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)));
51
52 // bootstrap read model from event store
97 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
98 let lastSavedEvent = null as OrchestrationEvent | null;
99
100 for (const nextEvent of eventBases) {
101 const savedEvent = yield* eventStore.append(nextEvent);
102 yield* projectionPipeline.projectEvent(savedEvent);
103 readModel = yield* projectEvent(readModel, savedEvent);
104 yield* PubSub.publish(eventPubSub, savedEvent);
105 lastSavedEvent = savedEvent;
106 }
107
108 if (lastSavedEvent === null) {
109 return yield* Deferred.fail(
110 envelope.result,
111 new OrchestrationCommandInvariantError({
112 commandType: envelope.command.type,
113 detail: "Command produced no events.",
114 }),
115 );
116 }
117
118 yield* commandReceiptRepository.upsert({
119 commandId: envelope.command.commandId,
120 aggregateKind: lastSavedEvent.aggregateKind,
121 aggregateId: lastSavedEvent.aggregateId,
122 acceptedAt: lastSavedEvent.occurredAt,
123 resultSequence: lastSavedEvent.sequence,
124 status: "accepted",
125 error: null,
126 });
127 yield* Deferred.succeed(envelope.result, { sequence: lastSavedEvent.sequence });
128 }).pipe(
129 Effect.catch((error) =>
130 Effect.gen(function* () {
131 if (error instanceof OrchestrationCommandInvariantError) {
132 const aggregateRef = commandToAggregateRef(envelope.command);
133 yield* commandReceiptRepository
134 .upsert({
135 commandId: envelope.command.commandId,
136 aggregateKind: aggregateRef.aggregateKind,
137 aggregateId: aggregateRef.aggregateId,
138 acceptedAt: new Date().toISOString(),
139 resultSequence: readModel.snapshotSequence,
140 status: "rejected",
141 error: formatDispatchError(error),
142 })
143 .pipe(Effect.catch(() => Effect.void));
144 }
145 yield* Deferred.fail(envelope.result, error);
146 }),
147 ),
148 );
149
150 yield* projectionPipeline.bootstrap;
151
152 // bootstrap in-memory read model from event store
53153 yield* Stream.runForEach(eventStore.readAll(), (event) =>
54154 Effect.gen(function* () {
55155 readModel = yield* projectEvent(readModel, event);
......@@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () {
58158
59159 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
60160 yield* Effect.forkScoped(worker);
61 logger.info("orchestration engine started", {
62 sequence: readModel.sequence,
63 });
161 yield* Effect.log("orchestration engine started").pipe(
162 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
163 );
64164
65165 const getReadModel: OrchestrationEngineShape["getReadModel"] = () =>
66166 Effect.sync((): OrchestrationReadModel => readModel);
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts created+61
......@@ -0,0 +1,61 @@
1import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
2import { afterEach, describe, expect, it } from "vitest";
3
4import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
5import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
6import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
7import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
8import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
9
10describe("OrchestrationReactor", () => {
11 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
12
13 afterEach(async () => {
14 if (runtime) {
15 await runtime.dispose();
16 }
17 runtime = null;
18 });
19
20 it("starts provider ingestion, provider command, and checkpoint reactors", async () => {
21 const started: string[] = [];
22
23 runtime = ManagedRuntime.make(
24 Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe(
25 Layer.provideMerge(
26 Layer.succeed(ProviderRuntimeIngestionService, {
27 start: Effect.sync(() => {
28 started.push("provider-runtime-ingestion");
29 }),
30 }),
31 ),
32 Layer.provideMerge(
33 Layer.succeed(ProviderCommandReactor, {
34 start: Effect.sync(() => {
35 started.push("provider-command-reactor");
36 }),
37 }),
38 ),
39 Layer.provideMerge(
40 Layer.succeed(CheckpointReactor, {
41 start: Effect.sync(() => {
42 started.push("checkpoint-reactor");
43 }),
44 }),
45 ),
46 ),
47 );
48
49 const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor));
50 const scope = await Effect.runPromise(Scope.make("sequential"));
51 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
52
53 expect(started).toEqual([
54 "provider-runtime-ingestion",
55 "provider-command-reactor",
56 "checkpoint-reactor",
57 ]);
58
59 await Effect.runPromise(Scope.close(scope, Exit.void));
60 });
61});
apps/server/src/orchestration/Layers/OrchestrationReactor.ts created+27
......@@ -0,0 +1,27 @@
1import { Effect, Layer } from "effect";
2
3import {
4 OrchestrationReactor,
5 type OrchestrationReactorShape,
6} from "../Services/OrchestrationReactor.ts";
7import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
8import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
9import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
10
11export const makeOrchestrationReactor = Effect.gen(function* () {
12 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
13 const providerCommandReactor = yield* ProviderCommandReactor;
14 const checkpointReactor = yield* CheckpointReactor;
15
16 const start: OrchestrationReactorShape["start"] = Effect.gen(function* () {
17 yield* providerRuntimeIngestion.start;
18 yield* providerCommandReactor.start;
19 yield* checkpointReactor.start;
20 });
21
22 return {
23 start,
24 } satisfies OrchestrationReactorShape;
25});
26
27export const OrchestrationReactorLive = Layer.effect(OrchestrationReactor, makeOrchestrationReactor);
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts created+367
......@@ -0,0 +1,367 @@
1import {
2 CommandId,
3 CorrelationId,
4 EventId,
5 MessageId,
6 ProjectId,
7 ThreadId,
8} from "@t3tools/contracts";
9import { assert, it } from "@effect/vitest";
10import { Effect, Layer } from "effect";
11import * as SqlClient from "effect/unstable/sql/SqlClient";
12
13import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
14import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
15import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
16import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
17import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
18import {
19 ORCHESTRATION_PROJECTOR_NAMES,
20 OrchestrationProjectionPipelineLive,
21} from "./ProjectionPipeline.ts";
22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
24
25const projectionLayer = it.layer(
26 OrchestrationProjectionPipelineLive.pipe(
27 Layer.provideMerge(OrchestrationEventStoreLive),
28 Layer.provideMerge(SqlitePersistenceMemory),
29 ),
30);
31
32projectionLayer("OrchestrationProjectionPipeline", (it) => {
33 it.effect("bootstraps all projection states and writes projection rows", () =>
34 Effect.gen(function* () {
35 const projectionPipeline = yield* OrchestrationProjectionPipeline;
36 const eventStore = yield* OrchestrationEventStore;
37 const sql = yield* SqlClient.SqlClient;
38 const now = new Date().toISOString();
39
40 yield* eventStore.append({
41 type: "project.created",
42 eventId: EventId.makeUnsafe("evt-1"),
43 aggregateKind: "project",
44 aggregateId: ProjectId.makeUnsafe("project-1"),
45 occurredAt: now,
46 commandId: CommandId.makeUnsafe("cmd-1"),
47 causationEventId: null,
48 correlationId: CommandId.makeUnsafe("cmd-1"),
49 metadata: {},
50 payload: {
51 projectId: ProjectId.makeUnsafe("project-1"),
52 title: "Project 1",
53 workspaceRoot: "/tmp/project-1",
54 defaultModel: null,
55 scripts: [],
56 createdAt: now,
57 updatedAt: now,
58 },
59 });
60
61 yield* eventStore.append({
62 type: "thread.created",
63 eventId: EventId.makeUnsafe("evt-2"),
64 aggregateKind: "thread",
65 aggregateId: ThreadId.makeUnsafe("thread-1"),
66 occurredAt: now,
67 commandId: CommandId.makeUnsafe("cmd-2"),
68 causationEventId: null,
69 correlationId: CommandId.makeUnsafe("cmd-2"),
70 metadata: {},
71 payload: {
72 threadId: ThreadId.makeUnsafe("thread-1"),
73 projectId: ProjectId.makeUnsafe("project-1"),
74 title: "Thread 1",
75 model: "gpt-5-codex",
76 branch: null,
77 worktreePath: null,
78 createdAt: now,
79 updatedAt: now,
80 },
81 });
82
83 yield* eventStore.append({
84 type: "thread.message-sent",
85 eventId: EventId.makeUnsafe("evt-3"),
86 aggregateKind: "thread",
87 aggregateId: ThreadId.makeUnsafe("thread-1"),
88 occurredAt: now,
89 commandId: CommandId.makeUnsafe("cmd-3"),
90 causationEventId: null,
91 correlationId: CommandId.makeUnsafe("cmd-3"),
92 metadata: {},
93 payload: {
94 threadId: ThreadId.makeUnsafe("thread-1"),
95 messageId: MessageId.makeUnsafe("message-1"),
96 role: "assistant",
97 text: "hello",
98 turnId: null,
99 streaming: false,
100 createdAt: now,
101 updatedAt: now,
102 },
103 });
104
105 yield* projectionPipeline.bootstrap;
106
107 const projectRows = yield* sql<{
108 readonly projectId: string;
109 readonly title: string;
110 readonly scriptsJson: string;
111 }>`
112 SELECT
113 project_id AS "projectId",
114 title,
115 scripts_json AS "scriptsJson"
116 FROM projection_projects
117 `;
118 assert.deepEqual(projectRows, [
119 { projectId: "project-1", title: "Project 1", scriptsJson: "[]" },
120 ]);
121
122 const messageRows = yield* sql<{
123 readonly messageId: string;
124 readonly text: string;
125 }>`
126 SELECT
127 message_id AS "messageId",
128 text
129 FROM projection_thread_messages
130 `;
131 assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]);
132
133 const stateRows = yield* sql<{
134 readonly projector: string;
135 readonly lastAppliedSequence: number;
136 }>`
137 SELECT
138 projector,
139 last_applied_sequence AS "lastAppliedSequence"
140 FROM projection_state
141 ORDER BY projector ASC
142 `;
143 assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length);
144 for (const row of stateRows) {
145 assert.equal(row.lastAppliedSequence, 3);
146 }
147 }),
148 );
149
150 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
151 Effect.gen(function* () {
152 const projectionPipeline = yield* OrchestrationProjectionPipeline;
153 const eventStore = yield* OrchestrationEventStore;
154 const sql = yield* SqlClient.SqlClient;
155 const now = new Date().toISOString();
156
157 yield* eventStore.append({
158 type: "project.created",
159 eventId: EventId.makeUnsafe("evt-a1"),
160 aggregateKind: "project",
161 aggregateId: ProjectId.makeUnsafe("project-a"),
162 occurredAt: now,
163 commandId: CommandId.makeUnsafe("cmd-a1"),
164 causationEventId: null,
165 correlationId: CorrelationId.makeUnsafe("cmd-a1"),
166 metadata: {},
167 payload: {
168 projectId: ProjectId.makeUnsafe("project-a"),
169 title: "Project A",
170 workspaceRoot: "/tmp/project-a",
171 defaultModel: null,
172 scripts: [],
173 createdAt: now,
174 updatedAt: now,
175 },
176 });
177
178 yield* eventStore.append({
179 type: "thread.created",
180 eventId: EventId.makeUnsafe("evt-a2"),
181 aggregateKind: "thread",
182 aggregateId: ThreadId.makeUnsafe("thread-a"),
183 occurredAt: now,
184 commandId: CommandId.makeUnsafe("cmd-a2"),
185 causationEventId: null,
186 correlationId: CorrelationId.makeUnsafe("cmd-a2"),
187 metadata: {},
188 payload: {
189 threadId: ThreadId.makeUnsafe("thread-a"),
190 projectId: ProjectId.makeUnsafe("project-a"),
191 title: "Thread A",
192 model: "gpt-5-codex",
193 branch: null,
194 worktreePath: null,
195 createdAt: now,
196 updatedAt: now,
197 },
198 });
199
200 yield* eventStore.append({
201 type: "thread.message-sent",
202 eventId: EventId.makeUnsafe("evt-a3"),
203 aggregateKind: "thread",
204 aggregateId: ThreadId.makeUnsafe("thread-a"),
205 occurredAt: now,
206 commandId: CommandId.makeUnsafe("cmd-a3"),
207 causationEventId: null,
208 correlationId: CorrelationId.makeUnsafe("cmd-a3"),
209 metadata: {},
210 payload: {
211 threadId: ThreadId.makeUnsafe("thread-a"),
212 messageId: MessageId.makeUnsafe("message-a"),
213 role: "assistant",
214 text: "hello",
215 turnId: null,
216 streaming: false,
217 createdAt: now,
218 updatedAt: now,
219 },
220 });
221
222 yield* projectionPipeline.bootstrap;
223
224 yield* eventStore.append({
225 type: "thread.message-sent",
226 eventId: EventId.makeUnsafe("evt-a4"),
227 aggregateKind: "thread",
228 aggregateId: ThreadId.makeUnsafe("thread-a"),
229 occurredAt: now,
230 commandId: CommandId.makeUnsafe("cmd-a4"),
231 causationEventId: null,
232 correlationId: CorrelationId.makeUnsafe("cmd-a4"),
233 metadata: {},
234 payload: {
235 threadId: ThreadId.makeUnsafe("thread-a"),
236 messageId: MessageId.makeUnsafe("message-a"),
237 role: "assistant",
238 text: " world",
239 turnId: null,
240 streaming: true,
241 createdAt: now,
242 updatedAt: now,
243 },
244 });
245
246 yield* projectionPipeline.bootstrap;
247 yield* projectionPipeline.bootstrap;
248
249 const messageRows = yield* sql<{ readonly text: string }>`
250 SELECT text FROM projection_thread_messages WHERE message_id = 'message-a'
251 `;
252 assert.deepEqual(messageRows, [{ text: "hello world" }]);
253
254 const stateRows = yield* sql<{
255 readonly projector: string;
256 readonly lastAppliedSequence: number;
257 }>`
258 SELECT
259 projector,
260 last_applied_sequence AS "lastAppliedSequence"
261 FROM projection_state
262 `;
263 const maxSequenceRows = yield* sql<{ readonly maxSequence: number }>`
264 SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events
265 `;
266 const maxSequence = maxSequenceRows[0]?.maxSequence ?? 0;
267 for (const row of stateRows) {
268 assert.equal(row.lastAppliedSequence, maxSequence);
269 }
270 }),
271 );
272});
273
274const engineLayer = it.layer(
275 OrchestrationEngineLive.pipe(
276 Layer.provide(OrchestrationProjectionPipelineLive),
277 Layer.provide(OrchestrationEventStoreLive),
278 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
279 Layer.provideMerge(SqlitePersistenceMemory),
280 ),
281);
282
283engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
284 it.effect("projects dispatched engine events immediately", () =>
285 Effect.gen(function* () {
286 const engine = yield* OrchestrationEngineService;
287 const sql = yield* SqlClient.SqlClient;
288 const createdAt = new Date().toISOString();
289
290 yield* engine.dispatch({
291 type: "project.create",
292 commandId: CommandId.makeUnsafe("cmd-live-project"),
293 projectId: ProjectId.makeUnsafe("project-live"),
294 title: "Live Project",
295 workspaceRoot: "/tmp/project-live",
296 defaultModel: "gpt-5-codex",
297 createdAt,
298 });
299
300 const projectRows = yield* sql<{ readonly title: string; readonly scriptsJson: string }>`
301 SELECT
302 title,
303 scripts_json AS "scriptsJson"
304 FROM projection_projects
305 WHERE project_id = 'project-live'
306 `;
307 assert.deepEqual(projectRows, [{ title: "Live Project", scriptsJson: "[]" }]);
308
309 const projectorRows = yield* sql<{ readonly lastAppliedSequence: number }>`
310 SELECT
311 last_applied_sequence AS "lastAppliedSequence"
312 FROM projection_state
313 WHERE projector = 'projection.projects'
314 `;
315 assert.deepEqual(projectorRows, [{ lastAppliedSequence: 1 }]);
316 }),
317 );
318
319 it.effect("projects persist updated scripts from project.meta.update", () =>
320 Effect.gen(function* () {
321 const engine = yield* OrchestrationEngineService;
322 const sql = yield* SqlClient.SqlClient;
323 const createdAt = new Date().toISOString();
324
325 yield* engine.dispatch({
326 type: "project.create",
327 commandId: CommandId.makeUnsafe("cmd-scripts-project-create"),
328 projectId: ProjectId.makeUnsafe("project-scripts"),
329 title: "Scripts Project",
330 workspaceRoot: "/tmp/project-scripts",
331 defaultModel: "gpt-5-codex",
332 createdAt,
333 });
334
335 yield* engine.dispatch({
336 type: "project.meta.update",
337 commandId: CommandId.makeUnsafe("cmd-scripts-project-update"),
338 projectId: ProjectId.makeUnsafe("project-scripts"),
339 scripts: [
340 {
341 id: "script-1",
342 name: "Build",
343 command: "bun run build",
344 icon: "build",
345 runOnWorktreeCreate: false,
346 },
347 ],
348 defaultModel: "gpt-5",
349 });
350
351 const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModel: string }>`
352 SELECT
353 scripts_json AS "scriptsJson",
354 default_model AS "defaultModel"
355 FROM projection_projects
356 WHERE project_id = 'project-scripts'
357 `;
358 assert.deepEqual(projectRows, [
359 {
360 scriptsJson:
361 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
362 defaultModel: "gpt-5",
363 },
364 ]);
365 }),
366 );
367});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts created+721
......@@ -0,0 +1,721 @@
1import {
2 ApprovalRequestId,
3 type OrchestrationEvent,
4} from "@t3tools/contracts";
5import { Effect, Layer, Option, Stream } from "effect";
6import * as SqlClient from "effect/unstable/sql/SqlClient";
7
8import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
9import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
10import { ProjectionCheckpointRepository } from "../../persistence/Services/ProjectionCheckpoints.ts";
11import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts";
12import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
13import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
14import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
15import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts";
16import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
17import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts";
18import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts";
19import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
20import { ProjectionCheckpointRepositoryLive } from "../../persistence/Layers/ProjectionCheckpoints.ts";
21import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
22import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
23import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
24import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";
25import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts";
26import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
27import { ProjectionThreadTurnRepositoryLive } from "../../persistence/Layers/ProjectionThreadTurns.ts";
28import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
29import {
30 OrchestrationProjectionPipeline,
31 type OrchestrationProjectionPipelineShape,
32} from "../Services/ProjectionPipeline.ts";
33
34export const ORCHESTRATION_PROJECTOR_NAMES = {
35 projects: "projection.projects",
36 threads: "projection.threads",
37 threadMessages: "projection.thread-messages",
38 threadActivities: "projection.thread-activities",
39 threadSessions: "projection.thread-sessions",
40 threadTurns: "projection.thread-turns",
41 checkpoints: "projection.checkpoints",
42 pendingApprovals: "projection.pending-approvals",
43} as const;
44
45type ProjectorName =
46 (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];
47
48interface ProjectorDefinition {
49 readonly name: ProjectorName;
50 readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>;
51}
52
53function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
54 if (typeof payload !== "object" || payload === null) {
55 return null;
56 }
57 const requestId = (payload as Record<string, unknown>).requestId;
58 return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null;
59}
60
61function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number {
62 return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1;
63}
64
65const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;
67 const eventStore = yield* OrchestrationEventStore;
68 const projectionStateRepository = yield* ProjectionStateRepository;
69 const projectionProjectRepository = yield* ProjectionProjectRepository;
70 const projectionThreadRepository = yield* ProjectionThreadRepository;
71 const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;
72 const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;
73 const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;
74 const projectionThreadTurnRepository = yield* ProjectionThreadTurnRepository;
75 const projectionCheckpointRepository = yield* ProjectionCheckpointRepository;
76 const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository;
77 const pendingTurnStartByThreadId = new Map<
78 string,
79 {
80 readonly messageId: NonNullable<ProjectionThreadTurn["userMessageId"]>;
81 readonly createdAt: string;
82 }
83 >();
84
85 const applyProjectsProjection: ProjectorDefinition["apply"] = (event) =>
86 Effect.gen(function* () {
87 switch (event.type) {
88 case "project.created":
89 yield* projectionProjectRepository.upsert({
90 projectId: event.payload.projectId,
91 title: event.payload.title,
92 workspaceRoot: event.payload.workspaceRoot,
93 defaultModel: event.payload.defaultModel,
94 scripts: event.payload.scripts,
95 createdAt: event.payload.createdAt,
96 updatedAt: event.payload.updatedAt,
97 deletedAt: null,
98 });
99 return;
100
101 case "project.meta-updated": {
102 const existingRow = yield* projectionProjectRepository.getById({
103 projectId: event.payload.projectId,
104 });
105 if (Option.isNone(existingRow)) {
106 return;
107 }
108 yield* projectionProjectRepository.upsert({
109 ...existingRow.value,
110 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
111 ...(event.payload.workspaceRoot !== undefined
112 ? { workspaceRoot: event.payload.workspaceRoot }
113 : {}),
114 ...(event.payload.defaultModel !== undefined
115 ? { defaultModel: event.payload.defaultModel }
116 : {}),
117 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
118 updatedAt: event.payload.updatedAt,
119 });
120 return;
121 }
122
123 case "project.deleted": {
124 const existingRow = yield* projectionProjectRepository.getById({
125 projectId: event.payload.projectId,
126 });
127 if (Option.isNone(existingRow)) {
128 return;
129 }
130 yield* projectionProjectRepository.upsert({
131 ...existingRow.value,
132 deletedAt: event.payload.deletedAt,
133 updatedAt: event.payload.deletedAt,
134 });
135 return;
136 }
137
138 default:
139 return;
140 }
141 });
142
143 const applyThreadsProjection: ProjectorDefinition["apply"] = (event) =>
144 Effect.gen(function* () {
145 switch (event.type) {
146 case "thread.created":
147 yield* projectionThreadRepository.upsert({
148 threadId: event.payload.threadId,
149 projectId: event.payload.projectId,
150 title: event.payload.title,
151 model: event.payload.model,
152 branch: event.payload.branch,
153 worktreePath: event.payload.worktreePath,
154 latestTurnId: null,
155 createdAt: event.payload.createdAt,
156 updatedAt: event.payload.updatedAt,
157 deletedAt: null,
158 });
159 return;
160
161 case "thread.meta-updated": {
162 const existingRow = yield* projectionThreadRepository.getById({
163 threadId: event.payload.threadId,
164 });
165 if (Option.isNone(existingRow)) {
166 return;
167 }
168 yield* projectionThreadRepository.upsert({
169 ...existingRow.value,
170 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
171 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
172 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
173 ...(event.payload.worktreePath !== undefined
174 ? { worktreePath: event.payload.worktreePath }
175 : {}),
176 updatedAt: event.payload.updatedAt,
177 });
178 return;
179 }
180
181 case "thread.deleted": {
182 const existingRow = yield* projectionThreadRepository.getById({
183 threadId: event.payload.threadId,
184 });
185 if (Option.isNone(existingRow)) {
186 return;
187 }
188 yield* projectionThreadRepository.upsert({
189 ...existingRow.value,
190 deletedAt: event.payload.deletedAt,
191 updatedAt: event.payload.deletedAt,
192 });
193 return;
194 }
195
196 case "thread.message-sent":
197 case "thread.activity-appended": {
198 const existingRow = yield* projectionThreadRepository.getById({
199 threadId: event.payload.threadId,
200 });
201 if (Option.isNone(existingRow)) {
202 return;
203 }
204 yield* projectionThreadRepository.upsert({
205 ...existingRow.value,
206 updatedAt: event.occurredAt,
207 });
208 return;
209 }
210
211 case "thread.session-set": {
212 const existingRow = yield* projectionThreadRepository.getById({
213 threadId: event.payload.threadId,
214 });
215 if (Option.isNone(existingRow)) {
216 return;
217 }
218 yield* projectionThreadRepository.upsert({
219 ...existingRow.value,
220 latestTurnId: event.payload.session.activeTurnId,
221 updatedAt: event.occurredAt,
222 });
223 return;
224 }
225
226 case "thread.turn-diff-completed": {
227 const existingRow = yield* projectionThreadRepository.getById({
228 threadId: event.payload.threadId,
229 });
230 if (Option.isNone(existingRow)) {
231 return;
232 }
233 yield* projectionThreadRepository.upsert({
234 ...existingRow.value,
235 latestTurnId: event.payload.turnId,
236 updatedAt: event.occurredAt,
237 });
238 return;
239 }
240
241 case "thread.reverted": {
242 const existingRow = yield* projectionThreadRepository.getById({
243 threadId: event.payload.threadId,
244 });
245 if (Option.isNone(existingRow)) {
246 return;
247 }
248 yield* projectionThreadRepository.upsert({
249 ...existingRow.value,
250 latestTurnId: null,
251 updatedAt: event.occurredAt,
252 });
253 return;
254 }
255
256 default:
257 return;
258 }
259 });
260
261 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>
262 Effect.gen(function* () {
263 if (event.type !== "thread.message-sent") {
264 return;
265 }
266 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
267 threadId: event.payload.threadId,
268 });
269 const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId);
270 const nextText =
271 existingMessage && event.payload.streaming
272 ? `${existingMessage.text}${event.payload.text}`
273 : event.payload.text;
274 yield* projectionThreadMessageRepository.upsert({
275 messageId: event.payload.messageId,
276 threadId: event.payload.threadId,
277 turnId: event.payload.turnId,
278 role: event.payload.role,
279 text: nextText,
280 isStreaming: event.payload.streaming,
281 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
282 updatedAt: event.payload.updatedAt,
283 });
284 });
285
286 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>
287 Effect.gen(function* () {
288 if (event.type !== "thread.activity-appended") {
289 return;
290 }
291 yield* projectionThreadActivityRepository.upsert({
292 activityId: event.payload.activity.id,
293 threadId: event.payload.threadId,
294 turnId: event.payload.activity.turnId,
295 tone: event.payload.activity.tone,
296 kind: event.payload.activity.kind,
297 summary: event.payload.activity.summary,
298 payload: event.payload.activity.payload,
299 createdAt: event.payload.activity.createdAt,
300 });
301 });
302
303 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>
304 Effect.gen(function* () {
305 if (event.type !== "thread.session-set") {
306 return;
307 }
308 yield* projectionThreadSessionRepository.upsert({
309 threadId: event.payload.threadId,
310 status: event.payload.session.status,
311 providerName: event.payload.session.providerName,
312 providerSessionId: event.payload.session.providerSessionId,
313 providerThreadId: event.payload.session.providerThreadId,
314 activeTurnId: event.payload.session.activeTurnId,
315 lastError: event.payload.session.lastError,
316 updatedAt: event.payload.session.updatedAt,
317 });
318 });
319
320 const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) =>
321 Effect.gen(function* () {
322 switch (event.type) {
323 case "thread.turn-start-requested": {
324 pendingTurnStartByThreadId.set(event.payload.threadId, {
325 messageId: event.payload.messageId,
326 createdAt: event.payload.createdAt,
327 });
328 return;
329 }
330
331 case "thread.session-set": {
332 const turnId = event.payload.session.activeTurnId;
333 if (turnId === null || event.payload.session.status !== "running") {
334 return;
335 }
336
337 const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ turnId });
338 const pendingTurnStart = pendingTurnStartByThreadId.get(event.payload.threadId);
339 if (Option.isSome(existingTurn)) {
340 yield* projectionThreadTurnRepository.upsert({
341 ...existingTurn.value,
342 status: existingTurn.value.status === "completed" ? "completed" : "running",
343 userMessageId: existingTurn.value.userMessageId ?? (pendingTurnStart?.messageId ?? null),
344 startedAt: existingTurn.value.startedAt,
345 });
346 } else {
347 const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({
348 threadId: event.payload.threadId,
349 });
350 yield* projectionThreadTurnRepository.upsert({
351 turnId,
352 threadId: event.payload.threadId,
353 turnCount: nextTurnCount(existingThreadTurns),
354 status: "running",
355 userMessageId: pendingTurnStart?.messageId ?? null,
356 assistantMessageId: null,
357 startedAt: pendingTurnStart?.createdAt ?? event.occurredAt,
358 completedAt: null,
359 });
360 }
361
362 pendingTurnStartByThreadId.delete(event.payload.threadId);
363 return;
364 }
365
366 case "thread.message-sent": {
367 if (event.payload.turnId === null || event.payload.role !== "assistant") {
368 return;
369 }
370 const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({
371 turnId: event.payload.turnId,
372 });
373 if (Option.isSome(existingTurn)) {
374 yield* projectionThreadTurnRepository.upsert({
375 ...existingTurn.value,
376 assistantMessageId: event.payload.messageId,
377 status: event.payload.streaming
378 ? existingTurn.value.status
379 : existingTurn.value.status === "interrupted"
380 ? "interrupted"
381 : existingTurn.value.status === "error"
382 ? "error"
383 : "completed",
384 completedAt: event.payload.streaming
385 ? existingTurn.value.completedAt
386 : (existingTurn.value.completedAt ?? event.payload.updatedAt),
387 });
388 return;
389 }
390 const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({
391 threadId: event.payload.threadId,
392 });
393 yield* projectionThreadTurnRepository.upsert({
394 turnId: event.payload.turnId,
395 threadId: event.payload.threadId,
396 turnCount: nextTurnCount(existingThreadTurns),
397 status: event.payload.streaming ? "running" : "completed",
398 userMessageId: null,
399 assistantMessageId: event.payload.messageId,
400 startedAt: event.payload.createdAt,
401 completedAt: event.payload.streaming ? null : event.payload.updatedAt,
402 });
403 return;
404 }
405
406 case "thread.turn-interrupt-requested": {
407 if (event.payload.turnId === undefined) {
408 return;
409 }
410 const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({
411 turnId: event.payload.turnId,
412 });
413 if (Option.isSome(existingTurn)) {
414 yield* projectionThreadTurnRepository.upsert({
415 ...existingTurn.value,
416 status: "interrupted",
417 completedAt: existingTurn.value.completedAt ?? event.payload.createdAt,
418 });
419 return;
420 }
421 const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({
422 threadId: event.payload.threadId,
423 });
424 yield* projectionThreadTurnRepository.upsert({
425 turnId: event.payload.turnId,
426 threadId: event.payload.threadId,
427 turnCount: nextTurnCount(existingThreadTurns),
428 status: "interrupted",
429 userMessageId: null,
430 assistantMessageId: null,
431 startedAt: event.payload.createdAt,
432 completedAt: event.payload.createdAt,
433 });
434 return;
435 }
436
437 case "thread.turn-diff-completed": {
438 const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({
439 turnId: event.payload.turnId,
440 });
441 const nextStatus = event.payload.status === "error" ? "error" : "completed";
442 if (Option.isSome(existingTurn)) {
443 yield* projectionThreadTurnRepository.upsert({
444 ...existingTurn.value,
445 turnCount: event.payload.checkpointTurnCount,
446 status: nextStatus,
447 assistantMessageId: event.payload.assistantMessageId,
448 completedAt: event.payload.completedAt,
449 });
450 return;
451 }
452 yield* projectionThreadTurnRepository.upsert({
453 turnId: event.payload.turnId,
454 threadId: event.payload.threadId,
455 turnCount: event.payload.checkpointTurnCount,
456 status: nextStatus,
457 userMessageId: null,
458 assistantMessageId: event.payload.assistantMessageId,
459 startedAt: event.payload.completedAt,
460 completedAt: event.payload.completedAt,
461 });
462 return;
463 }
464
465 case "thread.reverted": {
466 const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({
467 threadId: event.payload.threadId,
468 });
469 const keptTurns = existingTurns.filter(
470 (turn) => turn.turnCount <= event.payload.turnCount,
471 );
472 yield* projectionThreadTurnRepository.deleteByThreadId({
473 threadId: event.payload.threadId,
474 });
475 yield* Effect.forEach(keptTurns, projectionThreadTurnRepository.upsert, {
476 concurrency: 1,
477 }).pipe(Effect.asVoid);
478 return;
479 }
480
481 default:
482 return;
483 }
484 });
485
486 const applyCheckpointsProjection: ProjectorDefinition["apply"] = (event) =>
487 Effect.gen(function* () {
488 switch (event.type) {
489 case "thread.turn-diff-completed":
490 yield* projectionCheckpointRepository.upsert({
491 threadId: event.payload.threadId,
492 turnId: event.payload.turnId,
493 checkpointTurnCount: event.payload.checkpointTurnCount,
494 checkpointRef: event.payload.checkpointRef,
495 status: event.payload.status,
496 files: event.payload.files,
497 assistantMessageId: event.payload.assistantMessageId,
498 completedAt: event.payload.completedAt,
499 });
500 return;
501
502 case "thread.reverted": {
503 const existingCheckpoints = yield* projectionCheckpointRepository.listByThreadId({
504 threadId: event.payload.threadId,
505 });
506 const keptCheckpoints = existingCheckpoints.filter(
507 (checkpoint) => checkpoint.checkpointTurnCount <= event.payload.turnCount,
508 );
509 yield* projectionCheckpointRepository.deleteByThreadId({
510 threadId: event.payload.threadId,
511 });
512 yield* Effect.forEach(keptCheckpoints, projectionCheckpointRepository.upsert, {
513 concurrency: 1,
514 }).pipe(Effect.asVoid);
515 return;
516 }
517
518 default:
519 return;
520 }
521 });
522
523 const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) =>
524 Effect.gen(function* () {
525 switch (event.type) {
526 case "thread.activity-appended": {
527 const requestId =
528 extractActivityRequestId(event.payload.activity.payload) ??
529 event.metadata.requestId ??
530 null;
531 if (requestId === null) {
532 return;
533 }
534 const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({
535 requestId,
536 });
537 if (event.payload.activity.kind === "approval.resolved") {
538 const resolvedDecisionRaw =
539 typeof event.payload.activity.payload === "object" &&
540 event.payload.activity.payload !== null &&
541 "decision" in event.payload.activity.payload
542 ? (event.payload.activity.payload as { decision?: unknown }).decision
543 : null;
544 const resolvedDecision =
545 resolvedDecisionRaw === "accept" ||
546 resolvedDecisionRaw === "acceptForSession" ||
547 resolvedDecisionRaw === "decline" ||
548 resolvedDecisionRaw === "cancel"
549 ? resolvedDecisionRaw
550 : null;
551 yield* projectionPendingApprovalRepository.upsert({
552 requestId,
553 threadId: Option.isSome(existingRow)
554 ? existingRow.value.threadId
555 : event.payload.threadId,
556 turnId: Option.isSome(existingRow)
557 ? existingRow.value.turnId
558 : event.payload.activity.turnId,
559 status: "resolved",
560 decision: resolvedDecision,
561 createdAt: Option.isSome(existingRow)
562 ? existingRow.value.createdAt
563 : event.payload.activity.createdAt,
564 resolvedAt: event.payload.activity.createdAt,
565 });
566 return;
567 }
568 if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
569 return;
570 }
571 yield* projectionPendingApprovalRepository.upsert({
572 requestId,
573 threadId: event.payload.threadId,
574 turnId: event.payload.activity.turnId,
575 status: "pending",
576 decision: null,
577 createdAt: Option.isSome(existingRow)
578 ? existingRow.value.createdAt
579 : event.payload.activity.createdAt,
580 resolvedAt: null,
581 });
582 return;
583 }
584
585 case "thread.approval-response-requested": {
586 const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({
587 requestId: event.payload.requestId,
588 });
589 yield* projectionPendingApprovalRepository.upsert({
590 requestId: event.payload.requestId,
591 threadId: Option.isSome(existingRow)
592 ? existingRow.value.threadId
593 : event.payload.threadId,
594 turnId: Option.isSome(existingRow) ? existingRow.value.turnId : null,
595 status: "resolved",
596 decision: event.payload.decision,
597 createdAt: Option.isSome(existingRow)
598 ? existingRow.value.createdAt
599 : event.payload.createdAt,
600 resolvedAt: event.payload.createdAt,
601 });
602 return;
603 }
604
605 default:
606 return;
607 }
608 });
609
610 const projectors: ReadonlyArray<ProjectorDefinition> = [
611 {
612 name: ORCHESTRATION_PROJECTOR_NAMES.projects,
613 apply: applyProjectsProjection,
614 },
615 {
616 name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages,
617 apply: applyThreadMessagesProjection,
618 },
619 {
620 name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities,
621 apply: applyThreadActivitiesProjection,
622 },
623 {
624 name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions,
625 apply: applyThreadSessionsProjection,
626 },
627 {
628 name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns,
629 apply: applyThreadTurnsProjection,
630 },
631 {
632 name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints,
633 apply: applyCheckpointsProjection,
634 },
635 {
636 name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals,
637 apply: applyPendingApprovalsProjection,
638 },
639 {
640 name: ORCHESTRATION_PROJECTOR_NAMES.threads,
641 apply: applyThreadsProjection,
642 },
643 ];
644
645 const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) =>
646 sql.withTransaction(
647 projector.apply(event).pipe(
648 Effect.flatMap(() =>
649 projectionStateRepository.upsert({
650 projector: projector.name,
651 lastAppliedSequence: event.sequence,
652 updatedAt: event.occurredAt,
653 }),
654 ),
655 ),
656 );
657
658 const bootstrapProjector = (projector: ProjectorDefinition) =>
659 projectionStateRepository
660 .getByProjector({
661 projector: projector.name,
662 })
663 .pipe(
664 Effect.flatMap((stateRow) =>
665 Stream.runForEach(
666 eventStore.readFromSequence(
667 Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0,
668 ),
669 (event) => runProjectorForEvent(projector, event),
670 ),
671 ),
672 );
673
674 const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) =>
675 Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), {
676 concurrency: 1,
677 }).pipe(
678 Effect.asVoid,
679 Effect.catchTag("SqlError", (sqlError) =>
680 Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)),
681 ),
682 );
683
684 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(
685 projectors,
686 bootstrapProjector,
687 {
688 concurrency: 1,
689 },
690 ).pipe(
691 Effect.asVoid,
692 Effect.tap(() =>
693 Effect.log("orchestration projection pipeline bootstrapped").pipe(
694 Effect.annotateLogs({ projectors: projectors.length }),
695 ),
696 ),
697 Effect.catchTag("SqlError", (sqlError) =>
698 Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)),
699 ),
700 );
701
702 return {
703 bootstrap,
704 projectEvent,
705 } satisfies OrchestrationProjectionPipelineShape;
706});
707
708export const OrchestrationProjectionPipelineLive = Layer.effect(
709 OrchestrationProjectionPipeline,
710 makeOrchestrationProjectionPipeline,
711).pipe(
712 Layer.provideMerge(ProjectionProjectRepositoryLive),
713 Layer.provideMerge(ProjectionThreadRepositoryLive),
714 Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
715 Layer.provideMerge(ProjectionThreadActivityRepositoryLive),
716 Layer.provideMerge(ProjectionThreadSessionRepositoryLive),
717 Layer.provideMerge(ProjectionThreadTurnRepositoryLive),
718 Layer.provideMerge(ProjectionCheckpointRepositoryLive),
719 Layer.provideMerge(ProjectionPendingApprovalRepositoryLive),
720 Layer.provideMerge(ProjectionStateRepositoryLive),
721);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts created+265
......@@ -0,0 +1,265 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
6import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
8import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
9
10const projectionSnapshotLayer = it.layer(
11 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
12);
13
14projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
15 it.effect("hydrates read model from projection tables and computes snapshot sequence", () =>
16 Effect.gen(function* () {
17 const snapshotQuery = yield* ProjectionSnapshotQuery;
18 const sql = yield* SqlClient.SqlClient;
19
20 yield* sql`DELETE FROM projection_projects`;
21 yield* sql`DELETE FROM projection_state`;
22
23 yield* sql`
24 INSERT INTO projection_projects (
25 project_id,
26 title,
27 workspace_root,
28 default_model,
29 scripts_json,
30 created_at,
31 updated_at,
32 deleted_at
33 )
34 VALUES (
35 'project-1',
36 'Project 1',
37 '/tmp/project-1',
38 'gpt-5-codex',
39 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
40 '2026-02-24T00:00:00.000Z',
41 '2026-02-24T00:00:01.000Z',
42 NULL
43 )
44 `;
45
46 yield* sql`
47 INSERT INTO projection_threads (
48 thread_id,
49 project_id,
50 title,
51 model,
52 branch,
53 worktree_path,
54 latest_turn_id,
55 created_at,
56 updated_at,
57 deleted_at
58 )
59 VALUES (
60 'thread-1',
61 'project-1',
62 'Thread 1',
63 'gpt-5-codex',
64 NULL,
65 NULL,
66 'turn-1',
67 '2026-02-24T00:00:02.000Z',
68 '2026-02-24T00:00:03.000Z',
69 NULL
70 )
71 `;
72
73 yield* sql`
74 INSERT INTO projection_thread_messages (
75 message_id,
76 thread_id,
77 turn_id,
78 role,
79 text,
80 is_streaming,
81 created_at,
82 updated_at
83 )
84 VALUES (
85 'message-1',
86 'thread-1',
87 'turn-1',
88 'assistant',
89 'hello from projection',
90 0,
91 '2026-02-24T00:00:04.000Z',
92 '2026-02-24T00:00:05.000Z'
93 )
94 `;
95
96 yield* sql`
97 INSERT INTO projection_thread_activities (
98 activity_id,
99 thread_id,
100 turn_id,
101 tone,
102 kind,
103 summary,
104 payload_json,
105 created_at
106 )
107 VALUES (
108 'activity-1',
109 'thread-1',
110 'turn-1',
111 'info',
112 'runtime.note',
113 'provider started',
114 '{"stage":"start"}',
115 '2026-02-24T00:00:06.000Z'
116 )
117 `;
118
119 yield* sql`
120 INSERT INTO projection_thread_sessions (
121 thread_id,
122 status,
123 provider_name,
124 provider_session_id,
125 provider_thread_id,
126 active_turn_id,
127 last_error,
128 updated_at
129 )
130 VALUES (
131 'thread-1',
132 'running',
133 'codex',
134 'provider-session-1',
135 'provider-thread-1',
136 'turn-1',
137 NULL,
138 '2026-02-24T00:00:07.000Z'
139 )
140 `;
141
142 yield* sql`
143 INSERT INTO projection_checkpoints (
144 thread_id,
145 turn_id,
146 checkpoint_turn_count,
147 checkpoint_ref,
148 status,
149 files_json,
150 assistant_message_id,
151 completed_at
152 )
153 VALUES (
154 'thread-1',
155 'turn-1',
156 1,
157 'checkpoint-1',
158 'ready',
159 '[{"path":"README.md","kind":"modified","additions":2,"deletions":1}]',
160 'message-1',
161 '2026-02-24T00:00:08.000Z'
162 )
163 `;
164
165 let sequence = 5;
166 for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) {
167 yield* sql`
168 INSERT INTO projection_state (
169 projector,
170 last_applied_sequence,
171 updated_at
172 )
173 VALUES (
174 ${projector},
175 ${sequence},
176 '2026-02-24T00:00:09.000Z'
177 )
178 `;
179 sequence += 1;
180 }
181
182 const snapshot = yield* snapshotQuery.getSnapshot();
183
184 assert.equal(snapshot.snapshotSequence, 5);
185 assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z");
186 assert.deepEqual(snapshot.projects, [
187 {
188 id: "project-1",
189 title: "Project 1",
190 workspaceRoot: "/tmp/project-1",
191 defaultModel: "gpt-5-codex",
192 scripts: [
193 {
194 id: "script-1",
195 name: "Build",
196 command: "bun run build",
197 icon: "build",
198 runOnWorktreeCreate: false,
199 },
200 ],
201 createdAt: "2026-02-24T00:00:00.000Z",
202 updatedAt: "2026-02-24T00:00:01.000Z",
203 deletedAt: null,
204 },
205 ]);
206 assert.deepEqual(snapshot.threads, [
207 {
208 id: "thread-1",
209 projectId: "project-1",
210 title: "Thread 1",
211 model: "gpt-5-codex",
212 branch: null,
213 worktreePath: null,
214 latestTurnId: "turn-1",
215 createdAt: "2026-02-24T00:00:02.000Z",
216 updatedAt: "2026-02-24T00:00:03.000Z",
217 deletedAt: null,
218 messages: [
219 {
220 id: "message-1",
221 role: "assistant",
222 text: "hello from projection",
223 turnId: "turn-1",
224 streaming: false,
225 createdAt: "2026-02-24T00:00:04.000Z",
226 updatedAt: "2026-02-24T00:00:05.000Z",
227 },
228 ],
229 activities: [
230 {
231 id: "activity-1",
232 tone: "info",
233 kind: "runtime.note",
234 summary: "provider started",
235 payload: { stage: "start" },
236 turnId: "turn-1",
237 createdAt: "2026-02-24T00:00:06.000Z",
238 },
239 ],
240 checkpoints: [
241 {
242 turnId: "turn-1",
243 checkpointTurnCount: 1,
244 checkpointRef: "checkpoint-1",
245 status: "ready",
246 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
247 assistantMessageId: "message-1",
248 completedAt: "2026-02-24T00:00:08.000Z",
249 },
250 ],
251 session: {
252 threadId: "thread-1",
253 status: "running",
254 providerName: "codex",
255 providerSessionId: "provider-session-1",
256 providerThreadId: "provider-thread-1",
257 activeTurnId: "turn-1",
258 lastError: null,
259 updatedAt: "2026-02-24T00:00:07.000Z",
260 },
261 },
262 ]);
263 }),
264 );
265});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts created+435
......@@ -0,0 +1,435 @@
1import {
2 OrchestrationCheckpointFile,
3 OrchestrationReadModel,
4 ProjectScript,
5 type OrchestrationCheckpointSummary,
6 type OrchestrationMessage,
7 type OrchestrationProject,
8 type OrchestrationSession,
9 type OrchestrationThread,
10 type OrchestrationThreadActivity,
11} from "@t3tools/contracts";
12import { Effect, Layer, Schema, Struct } from "effect";
13import * as SqlClient from "effect/unstable/sql/SqlClient";
14import * as SqlSchema from "effect/unstable/sql/SqlSchema";
15
16import {
17 PersistenceDecodeError,
18 PersistenceSerializationError,
19 PersistenceSqlError,
20 toPersistenceDecodeError,
21 toPersistenceSqlError,
22 type ProjectionRepositoryError,
23} from "../../persistence/Errors.ts";
24import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
25import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
26import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
27import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
28import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts";
29import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
30import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
31import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
32import {
33 ProjectionSnapshotQuery,
34 type ProjectionSnapshotQueryShape,
35} from "../Services/ProjectionSnapshotQuery.ts";
36
37const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
38const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
39 Struct.assign({
40 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
41 }),
42);
43const ProjectionThreadDbRowSchema = ProjectionThread;
44const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
45 Struct.assign({
46 isStreaming: Schema.Number,
47 }),
48);
49const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
50 Struct.assign({
51 payload: Schema.fromJsonString(Schema.Unknown),
52 }),
53);
54const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
55const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
56 Struct.assign({
57 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
58 }),
59);
60const ProjectionStateDbRowSchema = ProjectionState;
61
62const REQUIRED_SNAPSHOT_PROJECTORS = Object.values(ORCHESTRATION_PROJECTOR_NAMES);
63
64function maxIso(left: string | null, right: string): string {
65 if (left === null) {
66 return right;
67 }
68 return left > right ? left : right;
69}
70
71function computeSnapshotSequence(
72 stateRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionStateDbRowSchema>>,
73): number {
74 if (stateRows.length === 0) {
75 return 0;
76 }
77 const sequenceByProjector = new Map(
78 stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const),
79 );
80
81 let minSequence = Number.POSITIVE_INFINITY;
82 for (const projector of REQUIRED_SNAPSHOT_PROJECTORS) {
83 const sequence = sequenceByProjector.get(projector);
84 if (sequence === undefined) {
85 return 0;
86 }
87 if (sequence < minSequence) {
88 minSequence = sequence;
89 }
90 }
91
92 return Number.isFinite(minSequence) ? minSequence : 0;
93}
94
95function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
96 return (cause: unknown): ProjectionRepositoryError =>
97 Schema.isSchemaError(cause)
98 ? toPersistenceDecodeError(decodeOperation)(cause)
99 : toPersistenceSqlError(sqlOperation)(cause);
100}
101
102function toProjectionRepositoryError(operation: string) {
103 return (cause: unknown): ProjectionRepositoryError => {
104 if (
105 cause instanceof PersistenceSqlError ||
106 cause instanceof PersistenceDecodeError ||
107 cause instanceof PersistenceSerializationError
108 ) {
109 return cause;
110 }
111 return toPersistenceSqlError(operation)(cause);
112 };
113}
114
115const makeProjectionSnapshotQuery = Effect.gen(function* () {
116 const sql = yield* SqlClient.SqlClient;
117
118 const listProjectRows = SqlSchema.findAll({
119 Request: Schema.Void,
120 Result: ProjectionProjectDbRowSchema,
121 execute: () =>
122 sql`
123 SELECT
124 project_id AS "projectId",
125 title,
126 workspace_root AS "workspaceRoot",
127 default_model AS "defaultModel",
128 scripts_json AS "scripts",
129 created_at AS "createdAt",
130 updated_at AS "updatedAt",
131 deleted_at AS "deletedAt"
132 FROM projection_projects
133 ORDER BY created_at ASC, project_id ASC
134 `,
135 });
136
137 const listThreadRows = SqlSchema.findAll({
138 Request: Schema.Void,
139 Result: ProjectionThreadDbRowSchema,
140 execute: () =>
141 sql`
142 SELECT
143 thread_id AS "threadId",
144 project_id AS "projectId",
145 title,
146 model,
147 branch,
148 worktree_path AS "worktreePath",
149 latest_turn_id AS "latestTurnId",
150 created_at AS "createdAt",
151 updated_at AS "updatedAt",
152 deleted_at AS "deletedAt"
153 FROM projection_threads
154 ORDER BY created_at ASC, thread_id ASC
155 `,
156 });
157
158 const listThreadMessageRows = SqlSchema.findAll({
159 Request: Schema.Void,
160 Result: ProjectionThreadMessageDbRowSchema,
161 execute: () =>
162 sql`
163 SELECT
164 message_id AS "messageId",
165 thread_id AS "threadId",
166 turn_id AS "turnId",
167 role,
168 text,
169 is_streaming AS "isStreaming",
170 created_at AS "createdAt",
171 updated_at AS "updatedAt"
172 FROM projection_thread_messages
173 ORDER BY thread_id ASC, created_at ASC, message_id ASC
174 `,
175 });
176
177 const listThreadActivityRows = SqlSchema.findAll({
178 Request: Schema.Void,
179 Result: ProjectionThreadActivityDbRowSchema,
180 execute: () =>
181 sql`
182 SELECT
183 activity_id AS "activityId",
184 thread_id AS "threadId",
185 turn_id AS "turnId",
186 tone,
187 kind,
188 summary,
189 payload_json AS "payload",
190 created_at AS "createdAt"
191 FROM projection_thread_activities
192 ORDER BY thread_id ASC, created_at ASC, activity_id ASC
193 `,
194 });
195
196 const listThreadSessionRows = SqlSchema.findAll({
197 Request: Schema.Void,
198 Result: ProjectionThreadSessionDbRowSchema,
199 execute: () =>
200 sql`
201 SELECT
202 thread_id AS "threadId",
203 status,
204 provider_name AS "providerName",
205 provider_session_id AS "providerSessionId",
206 provider_thread_id AS "providerThreadId",
207 active_turn_id AS "activeTurnId",
208 last_error AS "lastError",
209 updated_at AS "updatedAt"
210 FROM projection_thread_sessions
211 ORDER BY thread_id ASC
212 `,
213 });
214
215 const listCheckpointRows = SqlSchema.findAll({
216 Request: Schema.Void,
217 Result: ProjectionCheckpointDbRowSchema,
218 execute: () =>
219 sql`
220 SELECT
221 thread_id AS "threadId",
222 turn_id AS "turnId",
223 checkpoint_turn_count AS "checkpointTurnCount",
224 checkpoint_ref AS "checkpointRef",
225 status,
226 files_json AS "files",
227 assistant_message_id AS "assistantMessageId",
228 completed_at AS "completedAt"
229 FROM projection_checkpoints
230 ORDER BY thread_id ASC, checkpoint_turn_count ASC
231 `,
232 });
233
234 const listProjectionStateRows = SqlSchema.findAll({
235 Request: Schema.Void,
236 Result: ProjectionStateDbRowSchema,
237 execute: () =>
238 sql`
239 SELECT
240 projector,
241 last_applied_sequence AS "lastAppliedSequence",
242 updated_at AS "updatedAt"
243 FROM projection_state
244 `,
245 });
246
247 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
248 sql.withTransaction(
249 Effect.gen(function* () {
250 const [projectRows, threadRows, messageRows, activityRows, sessionRows, checkpointRows, stateRows] =
251 yield* Effect.all([
252 listProjectRows(undefined).pipe(
253 Effect.mapError(
254 toPersistenceSqlOrDecodeError(
255 "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
256 "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
257 ),
258 ),
259 ),
260 listThreadRows(undefined).pipe(
261 Effect.mapError(
262 toPersistenceSqlOrDecodeError(
263 "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
264 "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
265 ),
266 ),
267 ),
268 listThreadMessageRows(undefined).pipe(
269 Effect.mapError(
270 toPersistenceSqlOrDecodeError(
271 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
272 "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
273 ),
274 ),
275 ),
276 listThreadActivityRows(undefined).pipe(
277 Effect.mapError(
278 toPersistenceSqlOrDecodeError(
279 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
280 "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
281 ),
282 ),
283 ),
284 listThreadSessionRows(undefined).pipe(
285 Effect.mapError(
286 toPersistenceSqlOrDecodeError(
287 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
288 "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
289 ),
290 ),
291 ),
292 listCheckpointRows(undefined).pipe(
293 Effect.mapError(
294 toPersistenceSqlOrDecodeError(
295 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
296 "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
297 ),
298 ),
299 ),
300 listProjectionStateRows(undefined).pipe(
301 Effect.mapError(
302 toPersistenceSqlOrDecodeError(
303 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
304 "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
305 ),
306 ),
307 ),
308 ]);
309
310 const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
311 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
312 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
313 const sessionsByThread = new Map<string, OrchestrationSession>();
314
315 let updatedAt: string | null = null;
316
317 for (const row of projectRows) {
318 updatedAt = maxIso(updatedAt, row.updatedAt);
319 }
320 for (const row of threadRows) {
321 updatedAt = maxIso(updatedAt, row.updatedAt);
322 }
323 for (const row of stateRows) {
324 updatedAt = maxIso(updatedAt, row.updatedAt);
325 }
326
327 for (const row of messageRows) {
328 updatedAt = maxIso(updatedAt, row.updatedAt);
329 const threadMessages = messagesByThread.get(row.threadId) ?? [];
330 threadMessages.push({
331 id: row.messageId,
332 role: row.role,
333 text: row.text,
334 turnId: row.turnId,
335 streaming: row.isStreaming === 1,
336 createdAt: row.createdAt,
337 updatedAt: row.updatedAt,
338 });
339 messagesByThread.set(row.threadId, threadMessages);
340 }
341
342 for (const row of activityRows) {
343 updatedAt = maxIso(updatedAt, row.createdAt);
344 const threadActivities = activitiesByThread.get(row.threadId) ?? [];
345 threadActivities.push({
346 id: row.activityId,
347 tone: row.tone,
348 kind: row.kind,
349 summary: row.summary,
350 payload: row.payload,
351 turnId: row.turnId,
352 createdAt: row.createdAt,
353 });
354 activitiesByThread.set(row.threadId, threadActivities);
355 }
356
357 for (const row of checkpointRows) {
358 updatedAt = maxIso(updatedAt, row.completedAt);
359 const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
360 threadCheckpoints.push({
361 turnId: row.turnId,
362 checkpointTurnCount: row.checkpointTurnCount,
363 checkpointRef: row.checkpointRef,
364 status: row.status,
365 files: row.files,
366 assistantMessageId: row.assistantMessageId,
367 completedAt: row.completedAt,
368 });
369 checkpointsByThread.set(row.threadId, threadCheckpoints);
370 }
371
372 for (const row of sessionRows) {
373 updatedAt = maxIso(updatedAt, row.updatedAt);
374 sessionsByThread.set(row.threadId, {
375 threadId: row.threadId,
376 status: row.status,
377 providerName: row.providerName,
378 providerSessionId: row.providerSessionId,
379 providerThreadId: row.providerThreadId,
380 activeTurnId: row.activeTurnId,
381 lastError: row.lastError,
382 updatedAt: row.updatedAt,
383 });
384 }
385
386 const projects: Array<OrchestrationProject> = projectRows.map((row) => ({
387 id: row.projectId,
388 title: row.title,
389 workspaceRoot: row.workspaceRoot,
390 defaultModel: row.defaultModel,
391 scripts: row.scripts,
392 createdAt: row.createdAt,
393 updatedAt: row.updatedAt,
394 deletedAt: row.deletedAt,
395 }));
396
397 const threads: Array<OrchestrationThread> = threadRows.map((row) => ({
398 id: row.threadId,
399 projectId: row.projectId,
400 title: row.title,
401 model: row.model,
402 branch: row.branch,
403 worktreePath: row.worktreePath,
404 latestTurnId: row.latestTurnId,
405 createdAt: row.createdAt,
406 updatedAt: row.updatedAt,
407 deletedAt: row.deletedAt,
408 messages: messagesByThread.get(row.threadId) ?? [],
409 activities: activitiesByThread.get(row.threadId) ?? [],
410 checkpoints: checkpointsByThread.get(row.threadId) ?? [],
411 session: sessionsByThread.get(row.threadId) ?? null,
412 }));
413
414 const snapshot = {
415 snapshotSequence: computeSnapshotSequence(stateRows),
416 projects,
417 threads,
418 updatedAt: updatedAt ?? new Date(0).toISOString(),
419 };
420
421 return yield* decodeReadModel(snapshot).pipe(
422 Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")),
423 );
424 }),
425 ).pipe(Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query")));
426
427 return {
428 getSnapshot,
429 } satisfies ProjectionSnapshotQueryShape;
430});
431
432export const OrchestrationProjectionSnapshotQueryLive = Layer.effect(
433 ProjectionSnapshotQuery,
434 makeProjectionSnapshotQuery,
435);
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts created+308
......@@ -0,0 +1,308 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {
3 ApprovalRequestId,
4 CommandId,
5 ProjectId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ProviderTurnId,
9 ThreadId,
10} from "@t3tools/contracts";
11import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
12import { afterEach, describe, expect, it, vi } from "vitest";
13
14import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
15import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import {
18 ProviderService,
19 type ProviderServiceShape,
20} from "../../provider/Services/ProviderService.ts";
21import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
22import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
23import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
24import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
25import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
26
27const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
28const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
29const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
31const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
32const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
33
34async function waitFor(
35 predicate: () => boolean,
36 timeoutMs = 2000,
37): Promise<void> {
38 const deadline = Date.now() + timeoutMs;
39 const poll = async (): Promise<void> => {
40 if (predicate()) {
41 return;
42 }
43 if (Date.now() >= deadline) {
44 throw new Error("Timed out waiting for expectation.");
45 }
46 await new Promise((resolve) => setTimeout(resolve, 10));
47 return poll();
48 };
49
50 return poll();
51}
52
53describe("ProviderCommandReactor", () => {
54 let runtime: ManagedRuntime.ManagedRuntime<
55 OrchestrationEngineService | ProviderCommandReactor,
56 unknown
57 > | null = null;
58 let scope: Scope.Closeable | null = null;
59
60 afterEach(async () => {
61 if (scope) {
62 await Effect.runPromise(Scope.close(scope, Exit.void));
63 }
64 scope = null;
65 if (runtime) {
66 await runtime.dispose();
67 }
68 runtime = null;
69 });
70
71 async function createHarness() {
72 const now = new Date().toISOString();
73 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
74 const startSession = vi.fn((_: unknown) =>
75 Effect.succeed({
76 sessionId: asSessionId("sess-1"),
77 provider: "codex" as const,
78 status: "ready" as const,
79 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
80 createdAt: now,
81 updatedAt: now,
82 }),
83 );
84 const sendTurn = vi.fn((_: unknown) =>
85 Effect.succeed({
86 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
87 turnId: asProviderTurnId("provider-turn-1"),
88 }),
89 );
90 const interruptTurn = vi.fn((_: unknown) => Effect.void);
91 const respondToRequest = vi.fn((_: unknown) => Effect.void);
92 const stopSession = vi.fn((_: unknown) => Effect.void);
93
94 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
95 const service: ProviderServiceShape = {
96 startSession: startSession as ProviderServiceShape["startSession"],
97 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
98 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
99 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
100 stopSession: stopSession as ProviderServiceShape["stopSession"],
101 listSessions: () => Effect.succeed([]),
102 rollbackConversation: () => unsupported(),
103 stopAll: () => Effect.void,
104 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
105 };
106
107 const orchestrationLayer = OrchestrationEngineLive.pipe(
108 Layer.provide(OrchestrationProjectionPipelineLive),
109 Layer.provide(OrchestrationEventStoreLive),
110 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
111 Layer.provide(SqlitePersistenceMemory),
112 );
113 const layer = ProviderCommandReactorLive.pipe(
114 Layer.provideMerge(orchestrationLayer),
115 Layer.provideMerge(Layer.succeed(ProviderService, service)),
116 );
117 runtime = ManagedRuntime.make(layer);
118 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
119 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
120 scope = await Effect.runPromise(Scope.make("sequential"));
121 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
122 await Effect.runPromise(Effect.sleep("10 millis"));
123
124 await Effect.runPromise(
125 engine.dispatch({
126 type: "project.create",
127 commandId: asCommandId("cmd-project-create"),
128 projectId: asProjectId("project-1"),
129 title: "Provider Project",
130 workspaceRoot: "/tmp/provider-project",
131 defaultModel: "gpt-5-codex",
132 createdAt: now,
133 }),
134 );
135 await Effect.runPromise(
136 engine.dispatch({
137 type: "thread.create",
138 commandId: asCommandId("cmd-thread-create"),
139 threadId: asThreadId("thread-1"),
140 projectId: asProjectId("project-1"),
141 title: "Thread",
142 model: "gpt-5-codex",
143 branch: null,
144 worktreePath: null,
145 createdAt: now,
146 }),
147 );
148
149 return {
150 engine,
151 startSession,
152 sendTurn,
153 interruptTurn,
154 respondToRequest,
155 stopSession,
156 };
157 }
158
159 it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {
160 const harness = await createHarness();
161 const now = new Date().toISOString();
162
163 await Effect.runPromise(
164 harness.engine.dispatch({
165 type: "thread.turn.start",
166 commandId: asCommandId("cmd-turn-start-1"),
167 threadId: asThreadId("thread-1"),
168 message: {
169 messageId: "user-message-1",
170 role: "user",
171 text: "hello reactor",
172 attachments: [],
173 },
174 createdAt: now,
175 }),
176 );
177
178 await waitFor(() => harness.startSession.mock.calls.length === 1);
179 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
180
181 const readModel = await Effect.runPromise(harness.engine.getReadModel());
182 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
183 expect(thread?.session?.providerSessionId).toBe("sess-1");
184 });
185
186 it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => {
187 const harness = await createHarness();
188 const now = new Date().toISOString();
189
190 await Effect.runPromise(
191 harness.engine.dispatch({
192 type: "thread.session.set",
193 commandId: asCommandId("cmd-session-set"),
194 threadId: asThreadId("thread-1"),
195 session: {
196 threadId: asThreadId("thread-1"),
197 status: "running",
198 providerName: "codex",
199 providerSessionId: asSessionId("sess-1"),
200 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
201 activeTurnId: "turn-1",
202 lastError: null,
203 updatedAt: now,
204 },
205 createdAt: now,
206 }),
207 );
208
209 await Effect.runPromise(
210 harness.engine.dispatch({
211 type: "thread.turn.interrupt",
212 commandId: asCommandId("cmd-turn-interrupt"),
213 threadId: asThreadId("thread-1"),
214 turnId: "turn-1",
215 createdAt: now,
216 }),
217 );
218
219 await waitFor(() => harness.interruptTurn.mock.calls.length === 1);
220 expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
221 sessionId: "sess-1",
222 });
223 });
224
225 it("reacts to thread.approval.respond by forwarding provider approval response", async () => {
226 const harness = await createHarness();
227 const now = new Date().toISOString();
228
229 await Effect.runPromise(
230 harness.engine.dispatch({
231 type: "thread.session.set",
232 commandId: asCommandId("cmd-session-set-for-approval"),
233 threadId: asThreadId("thread-1"),
234 session: {
235 threadId: asThreadId("thread-1"),
236 status: "running",
237 providerName: "codex",
238 providerSessionId: asSessionId("sess-1"),
239 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
240 activeTurnId: null,
241 lastError: null,
242 updatedAt: now,
243 },
244 createdAt: now,
245 }),
246 );
247
248 await Effect.runPromise(
249 harness.engine.dispatch({
250 type: "thread.approval.respond",
251 commandId: asCommandId("cmd-approval-respond"),
252 threadId: asThreadId("thread-1"),
253 requestId: asApprovalRequestId("approval-request-1"),
254 decision: "accept",
255 createdAt: now,
256 }),
257 );
258
259 await waitFor(() => harness.respondToRequest.mock.calls.length === 1);
260 expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({
261 sessionId: "sess-1",
262 requestId: "approval-request-1",
263 decision: "accept",
264 });
265 });
266
267 it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => {
268 const harness = await createHarness();
269 const now = new Date().toISOString();
270
271 await Effect.runPromise(
272 harness.engine.dispatch({
273 type: "thread.session.set",
274 commandId: asCommandId("cmd-session-set-for-stop"),
275 threadId: asThreadId("thread-1"),
276 session: {
277 threadId: asThreadId("thread-1"),
278 status: "ready",
279 providerName: "codex",
280 providerSessionId: asSessionId("sess-1"),
281 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
282 activeTurnId: null,
283 lastError: null,
284 updatedAt: now,
285 },
286 createdAt: now,
287 }),
288 );
289
290 await Effect.runPromise(
291 harness.engine.dispatch({
292 type: "thread.session.stop",
293 commandId: asCommandId("cmd-session-stop"),
294 threadId: asThreadId("thread-1"),
295 createdAt: now,
296 }),
297 );
298
299 await waitFor(() => harness.stopSession.mock.calls.length === 1);
300 const readModel = await Effect.runPromise(harness.engine.getReadModel());
301 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
302 expect(thread?.session).not.toBeNull();
303 expect(thread?.session?.status).toBe("stopped");
304 expect(thread?.session?.providerSessionId).toBeNull();
305 expect(thread?.session?.providerThreadId).toBeNull();
306 expect(thread?.session?.activeTurnId).toBeNull();
307 });
308});
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts created+347
......@@ -0,0 +1,347 @@
1import {
2 type ChatAttachment,
3 CommandId,
4 EventId,
5 type OrchestrationEvent,
6 type ProviderKind,
7 type OrchestrationSession,
8 type ProviderSessionId,
9 type ThreadId,
10 type TurnId,
11} from "@t3tools/contracts";
12import { Effect, Layer, Queue, Stream } from "effect";
13
14import { ProviderService } from "../../provider/Services/ProviderService.ts";
15import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
16import {
17 ProviderCommandReactor,
18 type ProviderCommandReactorShape,
19} from "../Services/ProviderCommandReactor.ts";
20
21type ProviderIntentEvent = Extract<
22 OrchestrationEvent,
23 {
24 type:
25 | "thread.turn-start-requested"
26 | "thread.turn-interrupt-requested"
27 | "thread.approval-response-requested"
28 | "thread.session-stop-requested";
29 }
30>;
31
32function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined {
33 const normalized = value?.trim();
34 return normalized && normalized.length > 0 ? normalized : undefined;
35}
36
37function mapProviderSessionStatusToOrchestrationStatus(
38 status: "connecting" | "ready" | "running" | "error" | "closed",
39): OrchestrationSession["status"] {
40 switch (status) {
41 case "connecting":
42 return "starting";
43 case "running":
44 return "running";
45 case "error":
46 return "error";
47 case "closed":
48 return "stopped";
49 case "ready":
50 default:
51 return "ready";
52 }
53}
54
55const turnStartKeyForEvent = (event: ProviderIntentEvent): string =>
56 event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`;
57
58const serverCommandId = (tag: string): CommandId =>
59 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
60
61const make = Effect.gen(function* () {
62 const orchestrationEngine = yield* OrchestrationEngineService;
63 const providerService = yield* ProviderService;
64 const handledTurnStartKeys = new Set<string>();
65
66 const appendProviderFailureActivity = (input: {
67 readonly threadId: ThreadId;
68 readonly kind:
69 | "provider.turn.start.failed"
70 | "provider.turn.interrupt.failed"
71 | "provider.approval.respond.failed"
72 | "provider.session.stop.failed";
73 readonly summary: string;
74 readonly detail: string;
75 readonly turnId: TurnId | null;
76 readonly createdAt: string;
77 }) =>
78 orchestrationEngine.dispatch({
79 type: "thread.activity.append",
80 commandId: serverCommandId("provider-failure-activity"),
81 threadId: input.threadId,
82 activity: {
83 id: EventId.makeUnsafe(crypto.randomUUID()),
84 tone: "error",
85 kind: input.kind,
86 summary: input.summary,
87 payload: {
88 detail: input.detail,
89 },
90 turnId: input.turnId,
91 createdAt: input.createdAt,
92 },
93 createdAt: input.createdAt,
94 });
95
96 const setThreadSession = (input: {
97 readonly threadId: ThreadId;
98 readonly session: OrchestrationSession;
99 readonly createdAt: string;
100 }) =>
101 orchestrationEngine.dispatch({
102 type: "thread.session.set",
103 commandId: serverCommandId("provider-session-set"),
104 threadId: input.threadId,
105 session: input.session,
106 createdAt: input.createdAt,
107 });
108
109 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
110 const readModel = yield* orchestrationEngine.getReadModel();
111 return readModel.threads.find((entry) => entry.id === threadId);
112 });
113
114 const ensureSessionForThread = Effect.fnUntraced(function* (
115 threadId: ThreadId,
116 createdAt: string,
117 ) {
118 const thread = yield* resolveThread(threadId);
119 if (!thread) {
120 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
121 }
122
123 const existingSessionId = thread.session?.providerSessionId;
124 if (existingSessionId) {
125 return existingSessionId;
126 }
127
128 const preferredProvider: ProviderKind | undefined =
129 thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode"
130 ? thread.session.providerName
131 : undefined;
132
133 const startedSession = yield* providerService.startSession({
134 ...(preferredProvider ? { provider: preferredProvider } : {}),
135 ...(trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined)
136 ? { cwd: trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) }
137 : {}),
138 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),
139 });
140
141 yield* setThreadSession({
142 threadId,
143 session: {
144 threadId,
145 status: mapProviderSessionStatusToOrchestrationStatus(startedSession.status),
146 providerName: startedSession.provider,
147 providerSessionId: startedSession.sessionId,
148 providerThreadId: startedSession.threadId ?? null,
149 // Provider turn ids are not orchestration turn ids.
150 activeTurnId: null,
151 lastError: startedSession.lastError ?? null,
152 updatedAt: startedSession.updatedAt,
153 },
154 createdAt,
155 });
156
157 return startedSession.sessionId;
158 });
159
160 const sendTurnForThread = Effect.fnUntraced(function* (input: {
161 readonly threadId: ThreadId;
162 readonly messageText: string;
163 readonly attachments?: ReadonlyArray<ChatAttachment>;
164 readonly model?: string;
165 readonly effort?: string;
166 readonly createdAt: string;
167 }) {
168 const thread = yield* resolveThread(input.threadId);
169 if (!thread) {
170 return;
171 }
172 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt);
173 const normalizedInput = trimToNonEmptyOrUndefined(input.messageText);
174 const normalizedAttachments = input.attachments ?? [];
175
176 yield* providerService.sendTurn({
177 sessionId,
178 ...(normalizedInput ? { input: normalizedInput } : {}),
179 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
180 ...(trimToNonEmptyOrUndefined(input.model) ? { model: input.model } : {}),
181 ...(trimToNonEmptyOrUndefined(input.effort) ? { effort: input.effort } : {}),
182 });
183 });
184
185 const processTurnStartRequested = Effect.fnUntraced(function* (
186 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
187 ) {
188 const key = turnStartKeyForEvent(event);
189 if (handledTurnStartKeys.has(key)) {
190 return;
191 }
192 handledTurnStartKeys.add(key);
193
194 const thread = yield* resolveThread(event.payload.threadId);
195 if (!thread) {
196 return;
197 }
198
199 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
200 if (!message || message.role !== "user") {
201 yield* appendProviderFailureActivity({
202 threadId: event.payload.threadId,
203 kind: "provider.turn.start.failed",
204 summary: "Provider turn start failed",
205 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
206 turnId: null,
207 createdAt: event.payload.createdAt,
208 });
209 return;
210 }
211
212 yield* sendTurnForThread({
213 threadId: event.payload.threadId,
214 messageText: message.text,
215 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
216 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
217 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
218 createdAt: event.payload.createdAt,
219 });
220 });
221
222 const processTurnInterruptRequested = Effect.fnUntraced(function* (
223 event: Extract<ProviderIntentEvent, { type: "thread.turn-interrupt-requested" }>,
224 ) {
225 const thread = yield* resolveThread(event.payload.threadId);
226 if (!thread) {
227 return;
228 }
229 const sessionId = thread.session?.providerSessionId;
230 if (!sessionId) {
231 return yield* appendProviderFailureActivity({
232 threadId: event.payload.threadId,
233 kind: "provider.turn.interrupt.failed",
234 summary: "Provider turn interrupt failed",
235 detail: "No active provider session is bound to this thread.",
236 turnId: event.payload.turnId ?? null,
237 createdAt: event.payload.createdAt,
238 });
239 }
240
241 // Orchestration turn ids are not provider turn ids, so interrupt by session.
242 yield* providerService.interruptTurn({ sessionId });
243 });
244
245 const processApprovalResponseRequested = Effect.fnUntraced(function* (
246 event: Extract<ProviderIntentEvent, { type: "thread.approval-response-requested" }>,
247 ) {
248 const thread = yield* resolveThread(event.payload.threadId);
249 if (!thread) {
250 return;
251 }
252 const sessionId = thread.session?.providerSessionId;
253 if (!sessionId) {
254 return yield* appendProviderFailureActivity({
255 threadId: event.payload.threadId,
256 kind: "provider.approval.respond.failed",
257 summary: "Provider approval response failed",
258 detail: "No active provider session is bound to this thread.",
259 turnId: null,
260 createdAt: event.payload.createdAt,
261 });
262 }
263
264 yield* providerService.respondToRequest({
265 sessionId,
266 requestId: event.payload.requestId,
267 decision: event.payload.decision,
268 });
269 });
270
271 const processSessionStopRequested = Effect.fnUntraced(function* (
272 event: Extract<ProviderIntentEvent, { type: "thread.session-stop-requested" }>,
273 ) {
274 const thread = yield* resolveThread(event.payload.threadId);
275 if (!thread) {
276 return;
277 }
278
279 const now = event.payload.createdAt;
280 const sessionId = thread.session?.providerSessionId;
281
282 if (sessionId) {
283 yield* providerService.stopSession({ sessionId });
284 }
285
286 yield* setThreadSession({
287 threadId: thread.id,
288 session: {
289 threadId: thread.id,
290 status: "stopped",
291 providerName: thread.session?.providerName ?? null,
292 providerSessionId: null,
293 providerThreadId: null,
294 activeTurnId: null,
295 lastError: thread.session?.lastError ?? null,
296 updatedAt: now,
297 },
298 createdAt: now,
299 });
300 });
301
302 const processDomainEvent = (event: ProviderIntentEvent) =>
303 Effect.gen(function* () {
304 switch (event.type) {
305 case "thread.turn-start-requested":
306 yield* processTurnStartRequested(event);
307 return;
308 case "thread.turn-interrupt-requested":
309 yield* processTurnInterruptRequested(event);
310 return;
311 case "thread.approval-response-requested":
312 yield* processApprovalResponseRequested(event);
313 return;
314 case "thread.session-stop-requested":
315 yield* processSessionStopRequested(event);
316 return;
317 }
318 });
319
320 const start: ProviderCommandReactorShape["start"] = Effect.gen(function* () {
321 const queue = yield* Queue.unbounded<ProviderIntentEvent>();
322 yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));
323
324 yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent))));
325
326 yield* Effect.forkScoped(
327 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
328 if (
329 event.type !== "thread.turn-start-requested" &&
330 event.type !== "thread.turn-interrupt-requested" &&
331 event.type !== "thread.approval-response-requested" &&
332 event.type !== "thread.session-stop-requested"
333 ) {
334 return Effect.void;
335 }
336
337 return Queue.offer(queue, event).pipe(Effect.asVoid);
338 }),
339 );
340 });
341
342 return {
343 start,
344 } satisfies ProviderCommandReactorShape;
345});
346
347export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make);
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+116-48
......@@ -1,14 +1,26 @@
11import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {
3 CommandId,
4 EventId,
5 ProjectId,
6 ProviderItemId,
7 ProviderSessionId,
8 ProviderThreadId,
9 ProviderTurnId,
10 ThreadId,
11} from "@t3tools/contracts";
212import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
3import { afterEach, describe, expect, it, vi } from "vitest";
13import { afterEach, describe, expect, it } from "vitest";
414
515import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
16import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
617import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
718import {
819 ProviderService,
920 type ProviderServiceShape,
1021} from "../../provider/Services/ProviderService.ts";
1122import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
23import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
1224import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
1325import {
1426 OrchestrationEngineService,
......@@ -16,17 +28,16 @@ import {
1628} from "../Services/OrchestrationEngine.ts";
1729import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1830
31const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
32const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
33const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
34const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
35const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
36const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
37const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
38
1939function createProviderServiceHarness() {
2040 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
21 const getCheckpointDiff = vi.fn(
22 (input: { sessionId: string; fromTurnCount: number; toTurnCount: number }) =>
23 Effect.succeed({
24 threadId: "thread-1",
25 fromTurnCount: input.fromTurnCount,
26 toTurnCount: input.toTurnCount,
27 diff: "",
28 }),
29 );
3041
3142 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
3243 const service: ProviderServiceShape = {
......@@ -36,9 +47,7 @@ function createProviderServiceHarness() {
3647 respondToRequest: () => unsupported(),
3748 stopSession: () => unsupported(),
3849 listSessions: () => Effect.succeed([]),
39 listCheckpoints: () => unsupported(),
40 getCheckpointDiff,
41 revertToCheckpoint: () => unsupported(),
50 rollbackConversation: () => unsupported(),
4251 stopAll: () => Effect.void,
4352 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
4453 };
......@@ -50,7 +59,6 @@ function createProviderServiceHarness() {
5059 return {
5160 service,
5261 emit,
53 getCheckpointDiff,
5462 };
5563}
5664
......@@ -59,6 +67,7 @@ async function waitForThread(
5967 predicate: (thread: {
6068 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;
6169 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;
70 activities: ReadonlyArray<{ kind: string }>;
6271 }) => boolean,
6372 timeoutMs = 2000,
6473) {
......@@ -66,9 +75,10 @@ async function waitForThread(
6675 const poll = async (): Promise<{
6776 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;
6877 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;
78 activities: ReadonlyArray<{ kind: string }>;
6979 }> => {
7080 const readModel = await Effect.runPromise(engine.getReadModel());
71 const thread = readModel.threads.find((entry) => entry.id === "thread-1");
81 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
7282 if (thread && predicate(thread)) {
7383 return thread;
7484 }
......@@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => {
102112 async function createHarness() {
103113 const provider = createProviderServiceHarness();
104114 const orchestrationLayer = OrchestrationEngineLive.pipe(
115 Layer.provide(OrchestrationProjectionPipelineLive),
105116 Layer.provide(OrchestrationEventStoreLive),
117 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
106118 Layer.provide(SqlitePersistenceMemory),
107119 );
108120 const layer = ProviderRuntimeIngestionLive.pipe(
......@@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => {
117129 await Effect.runPromise(Effect.sleep("10 millis"));
118130
119131 const createdAt = new Date().toISOString();
132 await Effect.runPromise(
133 engine.dispatch({
134 type: "project.create",
135 commandId: asCommandId("cmd-provider-project-create"),
136 projectId: asProjectId("project-1"),
137 title: "Provider Project",
138 workspaceRoot: "/tmp/provider-project",
139 defaultModel: "gpt-5-codex",
140 createdAt,
141 }),
142 );
120143 await Effect.runPromise(
121144 engine.dispatch({
122145 type: "thread.create",
123 commandId: "cmd-thread-create",
124 threadId: "thread-1",
125 projectId: "project-1",
146 commandId: asCommandId("cmd-thread-create"),
147 threadId: asThreadId("thread-1"),
148 projectId: asProjectId("project-1"),
126149 title: "Thread",
127150 model: "gpt-5-codex",
128151 branch: null,
......@@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => {
132155 );
133156 await Effect.runPromise(
134157 engine.dispatch({
135 type: "thread.session",
136 commandId: "cmd-session-seed",
137 threadId: "thread-1",
158 type: "thread.session.set",
159 commandId: asCommandId("cmd-session-seed"),
160 threadId: asThreadId("thread-1"),
138161 session: {
139 sessionId: "sess-1",
140 provider: "codex",
162 threadId: asThreadId("thread-1"),
141163 status: "ready",
142 threadId: "thread-1",
164 providerName: "codex",
165 providerSessionId: asSessionId("sess-1"),
166 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
143167 activeTurnId: null,
144 createdAt,
145168 updatedAt: createdAt,
146169 lastError: null,
147170 },
......@@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => {
161184
162185 harness.emit({
163186 type: "turn.started",
164 eventId: "evt-turn-started",
187 eventId: asEventId("evt-turn-started"),
165188 provider: "codex",
166 sessionId: "sess-1",
189 sessionId: asSessionId("sess-1"),
167190 createdAt: now,
168 turnId: "turn-1",
191 turnId: asProviderTurnId("turn-1"),
169192 });
170193
171194 await waitForThread(
......@@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => {
175198
176199 harness.emit({
177200 type: "turn.completed",
178 eventId: "evt-turn-completed",
201 eventId: asEventId("evt-turn-completed"),
179202 provider: "codex",
180 sessionId: "sess-1",
203 sessionId: asSessionId("sess-1"),
181204 createdAt: new Date().toISOString(),
182 turnId: "turn-1",
205 turnId: asProviderTurnId("turn-1"),
183206 status: "failed",
184207 errorMessage: "turn failed",
185208 });
......@@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => {
187210 const thread = await waitForThread(
188211 harness.engine,
189212 (entry) =>
190 entry.session?.status === "ready" &&
213 entry.session?.status === "error" &&
191214 entry.session?.activeTurnId === null &&
192215 entry.session?.lastError === "turn failed",
193216 );
194 expect(thread.session?.status).toBe("ready");
217 expect(thread.session?.status).toBe("error");
195218 expect(thread.session?.lastError).toBe("turn failed");
196219 });
197220
......@@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => {
201224
202225 harness.emit({
203226 type: "message.delta",
204 eventId: "evt-message-delta-1",
227 eventId: asEventId("evt-message-delta-1"),
205228 provider: "codex",
206 sessionId: "sess-1",
229 sessionId: asSessionId("sess-1"),
207230 createdAt: now,
208 turnId: "turn-2",
209 itemId: "item-1",
231 turnId: asProviderTurnId("turn-2"),
232 itemId: asItemId("item-1"),
210233 delta: "hello",
211234 });
212235 harness.emit({
213236 type: "message.delta",
214 eventId: "evt-message-delta-2",
237 eventId: asEventId("evt-message-delta-2"),
215238 provider: "codex",
216 sessionId: "sess-1",
239 sessionId: asSessionId("sess-1"),
217240 createdAt: now,
218 turnId: "turn-2",
219 itemId: "item-1",
241 turnId: asProviderTurnId("turn-2"),
242 itemId: asItemId("item-1"),
220243 delta: " world",
221244 });
222245 harness.emit({
223246 type: "message.completed",
224 eventId: "evt-message-completed",
247 eventId: asEventId("evt-message-completed"),
225248 provider: "codex",
226 sessionId: "sess-1",
249 sessionId: asSessionId("sess-1"),
227250 createdAt: now,
228 turnId: "turn-2",
229 itemId: "item-1",
251 turnId: asProviderTurnId("turn-2"),
252 itemId: asItemId("item-1"),
230253 });
231254
232255 const thread = await waitForThread(harness.engine, (entry) =>
......@@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => {
243266
244267 harness.emit({
245268 type: "runtime.error",
246 eventId: "evt-runtime-error",
269 eventId: asEventId("evt-runtime-error"),
247270 provider: "codex",
248 sessionId: "sess-1",
271 sessionId: asSessionId("sess-1"),
249272 createdAt: now,
250 turnId: "turn-3",
273 turnId: asProviderTurnId("turn-3"),
251274 message: "runtime exploded",
252275 });
253276
......@@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => {
261284 expect(thread.session?.status).toBe("error");
262285 expect(thread.session?.lastError).toBe("runtime exploded");
263286 });
287
288 it("maps session/thread lifecycle and tool.started into session/activity projections", async () => {
289 const harness = await createHarness();
290 const now = new Date().toISOString();
291
292 harness.emit({
293 type: "session.started",
294 eventId: asEventId("evt-session-started"),
295 provider: "codex",
296 sessionId: asSessionId("sess-1"),
297 createdAt: now,
298 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
299 message: "session started",
300 });
301 harness.emit({
302 type: "thread.started",
303 eventId: asEventId("evt-thread-started"),
304 provider: "codex",
305 sessionId: asSessionId("sess-1"),
306 createdAt: now,
307 threadId: ProviderThreadId.makeUnsafe("provider-thread-2"),
308 });
309 harness.emit({
310 type: "tool.started",
311 eventId: asEventId("evt-tool-started"),
312 provider: "codex",
313 sessionId: asSessionId("sess-1"),
314 createdAt: now,
315 turnId: asProviderTurnId("turn-9"),
316 toolKind: "read",
317 title: "Read file",
318 detail: "/tmp/file.ts",
319 });
320
321 const thread = await waitForThread(
322 harness.engine,
323 (entry) =>
324 entry.session?.status === "ready" &&
325 entry.session?.activeTurnId === null &&
326 entry.activities.some((activity) => activity.kind === "tool.started"),
327 );
328
329 expect(thread.session?.status).toBe("ready");
330 expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true);
331 });
264332});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+168-151
......@@ -1,11 +1,14 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
1import {
2 CommandId,
3 MessageId,
4 ProviderThreadId,
5 TurnId,
6 type OrchestrationThreadActivity,
7 type ProviderRuntimeEvent,
8 type ProviderSessionId,
9} from "@t3tools/contracts";
210import { Effect, Layer, Queue, Stream } from "effect";
311
4import {
5 parseTurnDiffFilesFromUnifiedDiff,
6 type TurnDiffFileSummary,
7} from "../../checkpointing/Diffs.ts";
8import { createLogger } from "../../logger.ts";
912import { ProviderService } from "../../provider/Services/ProviderService.ts";
1013import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
1114import {
......@@ -13,87 +16,108 @@ import {
1316 type ProviderRuntimeIngestionShape,
1417} from "../Services/ProviderRuntimeIngestion.ts";
1518
16const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`;
17
18type ActivityTone = "thinking" | "tool" | "info" | "error";
19const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;
20const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
21 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
1922
20interface ThreadActivityInput {
21 readonly id: string;
22 readonly createdAt: string;
23 readonly label: string;
24 readonly detail?: string;
25 readonly tone: ActivityTone;
26 readonly turnId?: string;
27 readonly requestId?: string;
28 readonly requestKind?: "command" | "file-change";
23function toTurnId(value: string | undefined): TurnId | undefined {
24 const normalized = value?.trim();
25 return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined;
2926}
3027
3128function truncateDetail(value: string, limit = 180): string {
3229 return value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
3330}
3431
35function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<ThreadActivityInput> {
32function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<OrchestrationThreadActivity> {
3633 switch (event.type) {
37 case "approval.requested":
34 case "approval.requested": {
3835 return [
3936 {
4037 id: event.eventId,
4138 createdAt: event.createdAt,
42 label:
39 tone: "approval",
40 kind: "approval.requested",
41 summary:
4342 event.requestKind === "command"
4443 ? "Command approval requested"
4544 : "File-change approval requested",
46 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
47 tone: "tool",
48 ...(event.turnId ? { turnId: event.turnId } : {}),
49 requestId: event.requestId,
50 requestKind: event.requestKind,
45 payload: {
46 requestId: event.requestId,
47 requestKind: event.requestKind,
48 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
49 },
50 turnId: toTurnId(event.turnId) ?? null,
5151 },
5252 ];
53 case "approval.resolved":
53 }
54
55 case "approval.resolved": {
5456 return [
5557 {
5658 id: event.eventId,
5759 createdAt: event.createdAt,
58 label: "Approval resolved",
59 tone: "info",
60 ...(event.turnId ? { turnId: event.turnId } : {}),
61 requestId: event.requestId,
62 ...(event.requestKind ? { requestKind: event.requestKind } : {}),
60 tone: "approval",
61 kind: "approval.resolved",
62 summary: "Approval resolved",
63 payload: {
64 requestId: event.requestId,
65 ...(event.requestKind ? { requestKind: event.requestKind } : {}),
66 ...(event.decision ? { decision: event.decision } : {}),
67 },
68 turnId: toTurnId(event.turnId) ?? null,
6369 },
6470 ];
65 case "runtime.error":
71 }
72
73 case "runtime.error": {
6674 return [
6775 {
6876 id: event.eventId,
6977 createdAt: event.createdAt,
70 label: "Runtime error",
71 detail: truncateDetail(event.message),
7278 tone: "error",
73 ...(event.turnId ? { turnId: event.turnId } : {}),
79 kind: "runtime.error",
80 summary: "Runtime error",
81 payload: {
82 message: truncateDetail(event.message),
83 },
84 turnId: toTurnId(event.turnId) ?? null,
7485 },
7586 ];
76 case "checkpoint.captured":
87 }
88 case "tool.completed": {
7789 return [
7890 {
7991 id: event.eventId,
8092 createdAt: event.createdAt,
81 label: "Checkpoint captured",
82 tone: "info",
83 ...(event.turnId ? { turnId: event.turnId } : {}),
93 tone: "tool",
94 kind: "tool.completed",
95 summary: `${event.title} complete`,
96 payload: {
97 toolKind: event.toolKind,
98 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
99 },
100 turnId: toTurnId(event.turnId) ?? null,
84101 },
85102 ];
86 case "tool.completed":
103 }
104 case "tool.started": {
87105 return [
88106 {
89107 id: event.eventId,
90108 createdAt: event.createdAt,
91 label: `${event.title} complete`,
92 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
93109 tone: "tool",
94 ...(event.turnId ? { turnId: event.turnId } : {}),
110 kind: "tool.started",
111 summary: `${event.title} started`,
112 payload: {
113 toolKind: event.toolKind,
114 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
115 },
116 turnId: toTurnId(event.turnId) ?? null,
95117 },
96118 ];
119 }
120
97121 default:
98122 return [];
99123 }
......@@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th
102126const make = Effect.gen(function* () {
103127 const orchestrationEngine = yield* OrchestrationEngineService;
104128 const providerService = yield* ProviderService;
105 const logger = createLogger("provider-runtime-ingestion");
106129
107 const turnMessageIdsByTurnKey = new Map<string, Set<string>>();
108 const latestMessageIdByTurnKey = new Map<string, string>();
130 const turnMessageIdsByTurnKey = new Map<string, Set<MessageId>>();
131 const latestMessageIdByTurnKey = new Map<string, MessageId>();
109132
110 const rememberAssistantMessageId = (sessionId: string, turnId: string, messageId: string) => {
133 const rememberAssistantMessageId = (
134 sessionId: ProviderSessionId,
135 turnId: TurnId,
136 messageId: MessageId,
137 ) => {
111138 const key = providerTurnKey(sessionId, turnId);
112139 const existingIds = turnMessageIdsByTurnKey.get(key);
113140 if (existingIds) {
......@@ -118,19 +145,15 @@ const make = Effect.gen(function* () {
118145 latestMessageIdByTurnKey.set(key, messageId);
119146 };
120147
121 const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => {
122 return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<string>();
148 const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => {
149 return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<MessageId>();
123150 };
124151
125 const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => {
152 const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => {
126153 turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId));
127154 };
128155
129 const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => {
130 return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId));
131 };
132
133 const clearTurnStateForSession = (sessionId: string) => {
156 const clearTurnStateForSession = (sessionId: ProviderSessionId) => {
134157 const prefix = `${sessionId}:`;
135158 for (const key of turnMessageIdsByTurnKey.keys()) {
136159 if (key.startsWith(prefix)) {
......@@ -148,144 +171,138 @@ const make = Effect.gen(function* () {
148171 Effect.gen(function* () {
149172 const readModel = yield* orchestrationEngine.getReadModel();
150173 const thread = readModel.threads.find(
151 (entry) => entry.session?.sessionId === event.sessionId,
174 (entry) => entry.session?.providerSessionId === event.sessionId,
152175 );
153176 if (!thread) return;
154177
155178 const now = event.createdAt;
156 if (event.type === "turn.started" || event.type === "turn.completed") {
179
180 if (
181 event.type === "session.started" ||
182 event.type === "session.exited" ||
183 event.type === "thread.started" ||
184 event.type === "turn.started" ||
185 event.type === "turn.completed"
186 ) {
187 const activeTurnId = event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null;
188 const providerThreadIdFromEvent =
189 event.type === "thread.started"
190 ? ProviderThreadId.makeUnsafe(event.threadId)
191 : event.threadId !== undefined
192 ? ProviderThreadId.makeUnsafe(event.threadId)
193 : null;
194 const providerThreadId = providerThreadIdFromEvent ?? (thread.session?.providerThreadId ?? null);
195 const status =
196 event.type === "turn.started"
197 ? "running"
198 : event.type === "session.exited"
199 ? "stopped"
200 : event.type === "turn.completed" && event.status === "failed"
201 ? "error"
202 : "ready";
203 const lastError =
204 event.type === "turn.completed" && event.status === "failed"
205 ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed")
206 : status === "ready"
207 ? null
208 : (thread.session?.lastError ?? null);
209
157210 yield* orchestrationEngine.dispatch({
158 type: "thread.session",
159 commandId: crypto.randomUUID(),
211 type: "thread.session.set",
212 commandId: providerCommandId(event, "thread-session-set"),
160213 threadId: thread.id,
161214 session: {
162 sessionId: event.sessionId,
163 provider: event.provider,
164 status: event.type === "turn.started" ? "running" : "ready",
165215 threadId: thread.id,
166 activeTurnId: event.type === "turn.started" ? event.turnId : null,
167 createdAt: thread.session?.createdAt ?? now,
216 status,
217 providerName: event.provider,
218 providerSessionId: event.sessionId,
219 providerThreadId,
220 activeTurnId,
221 lastError,
168222 updatedAt: now,
169 lastError:
170 event.type === "turn.completed" && event.status === "failed"
171 ? (event.errorMessage ?? null)
172 : null,
173223 },
174224 createdAt: now,
175225 });
176226 }
177227
178228 if (event.type === "message.delta" && event.delta.length > 0) {
179 const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`;
180 if (event.turnId) {
181 rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId);
229 const assistantMessageId = MessageId.makeUnsafe(
230 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
231 );
232 const turnId = toTurnId(event.turnId);
233 if (turnId) {
234 rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
182235 }
236
183237 yield* orchestrationEngine.dispatch({
184 type: "message.send",
185 commandId: crypto.randomUUID(),
238 type: "thread.message.assistant.delta",
239 commandId: providerCommandId(event, "assistant-delta"),
186240 threadId: thread.id,
187241 messageId: assistantMessageId,
188 role: "assistant",
189 text: event.delta,
190 streaming: true,
242 delta: event.delta,
243 ...(turnId ? { turnId } : {}),
191244 createdAt: now,
192245 });
193246 }
194247
195248 if (event.type === "message.completed") {
196 const assistantMessageId = `assistant:${event.itemId}`;
197 if (event.turnId) {
198 rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId);
249 const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`);
250 const turnId = toTurnId(event.turnId);
251 if (turnId) {
252 rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
199253 }
254
200255 yield* orchestrationEngine.dispatch({
201 type: "message.send",
202 commandId: crypto.randomUUID(),
256 type: "thread.message.assistant.complete",
257 commandId: providerCommandId(event, "assistant-complete"),
203258 threadId: thread.id,
204259 messageId: assistantMessageId,
205 role: "assistant",
206 text: "",
207 streaming: false,
260 ...(turnId ? { turnId } : {}),
208261 createdAt: now,
209262 });
210263 }
211264
212 if (event.type === "turn.completed" && event.turnId) {
213 const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId);
214 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>
215 orchestrationEngine.dispatch({
216 type: "message.send",
217 commandId: crypto.randomUUID(),
218 threadId: thread.id,
219 messageId: assistantMessageId,
220 role: "assistant",
221 text: "",
222 streaming: false,
223 createdAt: now,
224 }),
225 ).pipe(Effect.asVoid);
226 clearAssistantMessageIdsForTurn(event.sessionId, event.turnId);
265 if (event.type === "turn.completed") {
266 const turnId = toTurnId(event.turnId);
267 if (turnId) {
268 const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, turnId);
269 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>
270 orchestrationEngine.dispatch({
271 type: "thread.message.assistant.complete",
272 commandId: providerCommandId(event, "assistant-complete-finalize"),
273 threadId: thread.id,
274 messageId: assistantMessageId,
275 turnId,
276 createdAt: now,
277 }),
278 ).pipe(Effect.asVoid);
279 clearAssistantMessageIdsForTurn(event.sessionId, turnId);
280 }
227281 }
228282
229283 if (event.type === "session.exited") {
230284 clearTurnStateForSession(event.sessionId);
231285 }
232286
233 if (event.type === "checkpoint.captured" && event.turnId) {
234 const files: ReadonlyArray<TurnDiffFileSummary> = yield* providerService
235 .getCheckpointDiff({
236 sessionId: event.sessionId,
237 fromTurnCount: Math.max(0, event.turnCount - 1),
238 toTurnCount: event.turnCount,
239 })
240 .pipe(
241 Effect.map((result) => parseTurnDiffFilesFromUnifiedDiff(result.diff)),
242 Effect.catch((error) =>
243 Effect.sync(() => {
244 logger.warn("failed to derive checkpoint file summary", {
245 sessionId: event.sessionId,
246 turnId: event.turnId,
247 turnCount: event.turnCount,
248 error:
249 error instanceof Error
250 ? error.message
251 : typeof error === "string"
252 ? error
253 : "unknown error",
254 });
255 }).pipe(Effect.as([] as ReadonlyArray<TurnDiffFileSummary>)),
256 ),
257 );
258 const assistantMessageId =
259 getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ??
260 `assistant:${event.turnId}`;
261 yield* orchestrationEngine.dispatch({
262 type: "thread.turnDiff.complete",
263 commandId: crypto.randomUUID(),
264 threadId: thread.id,
265 turnId: event.turnId,
266 completedAt: now,
267 ...(event.status ? { status: event.status } : {}),
268 files,
269 assistantMessageId,
270 checkpointTurnCount: event.turnCount,
271 createdAt: now,
272 });
273 }
274
275287 if (event.type === "runtime.error") {
288 const providerThreadId =
289 event.threadId !== undefined
290 ? ProviderThreadId.makeUnsafe(event.threadId)
291 : (thread.session?.providerThreadId ?? null);
292
276293 yield* orchestrationEngine.dispatch({
277 type: "thread.session",
278 commandId: crypto.randomUUID(),
294 type: "thread.session.set",
295 commandId: providerCommandId(event, "runtime-error-session-set"),
279296 threadId: thread.id,
280297 session: {
281 sessionId: event.sessionId,
282 provider: event.provider,
283 status: "error",
284298 threadId: thread.id,
285 activeTurnId: event.turnId ?? null,
286 createdAt: thread.session?.createdAt ?? now,
287 updatedAt: now,
299 status: "error",
300 providerName: event.provider,
301 providerSessionId: event.sessionId,
302 providerThreadId,
303 activeTurnId: toTurnId(event.turnId) ?? null,
288304 lastError: event.message,
305 updatedAt: now,
289306 },
290307 createdAt: now,
291308 });
......@@ -295,7 +312,7 @@ const make = Effect.gen(function* () {
295312 yield* Effect.forEach(activities, (activity) =>
296313 orchestrationEngine.dispatch({
297314 type: "thread.activity.append",
298 commandId: crypto.randomUUID(),
315 commandId: providerCommandId(event, "thread-activity-append"),
299316 threadId: thread.id,
300317 activity,
301318 createdAt: activity.createdAt,
apps/server/src/orchestration/Schemas.ts created+41
......@@ -0,0 +1,41 @@
1import {
2 ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema,
3 ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema,
4 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
7 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
8 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
9 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
10 ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema,
11 ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema,
12 ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema,
13 ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema,
14 ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema,
15 ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema,
16 ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema,
17 ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema,
18} from "@t3tools/contracts";
19
20// Server-internal alias surface, backed by contract schemas as the source of truth.
21export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema;
22export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema;
23export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
24
25export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
26export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
27export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
28
29export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
30export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema;
31export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema;
32export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema;
33export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema;
34
35export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema;
36export const ThreadTurnInterruptRequestedPayload = ContractsThreadTurnInterruptRequestedPayloadSchema;
37export const ThreadApprovalResponseRequestedPayload =
38 ContractsThreadApprovalResponseRequestedPayloadSchema;
39export const ThreadCheckpointRevertRequestedPayload =
40 ContractsThreadCheckpointRevertRequestedPayloadSchema;
41export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopRequestedPayloadSchema;
apps/server/src/orchestration/Services/CheckpointReactor.ts created+17
......@@ -0,0 +1,17 @@
1import { ServiceMap } from "effect";
2import type { Effect, Scope } from "effect";
3
4export interface CheckpointReactorShape {
5 /**
6 * Start the checkpoint reactor.
7 *
8 * The returned effect must be run in a scope so all worker fibers can be
9 * finalized on shutdown.
10 */
11 readonly start: Effect.Effect<void, never, Scope.Scope>;
12}
13
14export class CheckpointReactor extends ServiceMap.Service<
15 CheckpointReactor,
16 CheckpointReactorShape
17>()("orchestration/CheckpointReactor") {}
apps/server/src/orchestration/Services/OrchestrationReactor.ts created+17
......@@ -0,0 +1,17 @@
1import { ServiceMap } from "effect";
2import type { Effect, Scope } from "effect";
3
4export interface OrchestrationReactorShape {
5 /**
6 * Start orchestration-side reactors for provider/runtime/checkpoint flows.
7 *
8 * The returned effect must be run in a scope so all worker fibers can be
9 * finalized on shutdown.
10 */
11 readonly start: Effect.Effect<void, never, Scope.Scope>;
12}
13
14export class OrchestrationReactor extends ServiceMap.Service<
15 OrchestrationReactor,
16 OrchestrationReactorShape
17>()("orchestration/OrchestrationReactor") {}
apps/server/src/orchestration/Services/ProjectionPipeline.ts created+22
......@@ -0,0 +1,22 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";
2import { ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { OrchestrationEventStoreError, ProjectionRepositoryError } from "../../persistence/Errors.ts";
6
7export type OrchestrationProjectionPipelineError =
8 | OrchestrationEventStoreError
9 | ProjectionRepositoryError;
10
11export interface OrchestrationProjectionPipelineShape {
12 readonly bootstrap: Effect.Effect<void, OrchestrationProjectionPipelineError>;
13
14 readonly projectEvent: (
15 event: OrchestrationEvent,
16 ) => Effect.Effect<void, ProjectionRepositoryError>;
17}
18
19export class OrchestrationProjectionPipeline extends ServiceMap.Service<
20 OrchestrationProjectionPipeline,
21 OrchestrationProjectionPipelineShape
22>()("orchestration/ProjectionPipeline") {}
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts created+14
......@@ -0,0 +1,14 @@
1import type { OrchestrationReadModel } from "@t3tools/contracts";
2import { ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { ProjectionRepositoryError } from "../../persistence/Errors.ts";
6
7export interface ProjectionSnapshotQueryShape {
8 readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>;
9}
10
11export class ProjectionSnapshotQuery extends ServiceMap.Service<
12 ProjectionSnapshotQuery,
13 ProjectionSnapshotQueryShape
14>()("orchestration/ProjectionSnapshotQuery") {}
apps/server/src/orchestration/Services/ProviderCommandReactor.ts created+17
......@@ -0,0 +1,17 @@
1import { ServiceMap } from "effect";
2import type { Effect, Scope } from "effect";
3
4export interface ProviderCommandReactorShape {
5 /**
6 * Start reacting to provider-intent orchestration domain events.
7 *
8 * The returned effect must be run in a scope so all worker fibers can be
9 * finalized on shutdown.
10 */
11 readonly start: Effect.Effect<void, never, Scope.Scope>;
12}
13
14export class ProviderCommandReactor extends ServiceMap.Service<
15 ProviderCommandReactor,
16 ProviderCommandReactorShape
17>()("orchestration/ProviderCommandReactor") {}
apps/server/src/orchestration/commandInvariants.test.ts+32-17
......@@ -13,9 +13,30 @@ import {
1313const now = new Date().toISOString();
1414
1515const readModel: OrchestrationReadModel = {
16 sequence: 2,
16 snapshotSequence: 2,
1717 updatedAt: now,
18 gitByProjectId: {},
18 projects: [
19 {
20 id: "project-a",
21 title: "Project A",
22 workspaceRoot: "/tmp/project-a",
23 defaultModel: "gpt-5-codex",
24 scripts: [],
25 createdAt: now,
26 updatedAt: now,
27 deletedAt: null,
28 },
29 {
30 id: "project-b",
31 title: "Project B",
32 workspaceRoot: "/tmp/project-b",
33 defaultModel: "gpt-5-codex",
34 scripts: [],
35 createdAt: now,
36 updatedAt: now,
37 deletedAt: null,
38 },
39 ],
1940 threads: [
2041 {
2142 id: "thread-1",
......@@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = {
2748 createdAt: now,
2849 updatedAt: now,
2950 latestTurnId: null,
30 latestTurnStartedAt: null,
31 latestTurnCompletedAt: null,
32 latestTurnDurationMs: null,
3351 messages: [],
3452 session: null,
35 turnDiffSummaries: [],
3653 activities: [],
37 error: null,
54 checkpoints: [],
3855 },
3956 {
4057 id: "thread-2",
......@@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = {
4663 createdAt: now,
4764 updatedAt: now,
4865 latestTurnId: null,
49 latestTurnStartedAt: null,
50 latestTurnCompletedAt: null,
51 latestTurnDurationMs: null,
5266 messages: [],
5367 session: null,
54 turnDiffSummaries: [],
5568 activities: [],
56 error: null,
69 checkpoints: [],
5770 },
5871 ],
5972};
6073
6174const messageSendCommand: OrchestrationCommand = {
62 type: "message.send",
75 type: "thread.turn.start",
6376 commandId: "cmd-1",
6477 threadId: "thread-1",
65 messageId: "msg-1",
66 role: "user",
67 text: "hello",
68 streaming: false,
78 message: {
79 messageId: "msg-1",
80 role: "user",
81 text: "hello",
82 attachments: [],
83 },
6984 createdAt: now,
7085};
7186
apps/server/src/orchestration/commandInvariants.ts+56-5
......@@ -1,4 +1,11 @@
1import type { OrchestrationCommand, OrchestrationReadModel, OrchestrationThread } from "@t3tools/contracts";
1import type {
2 OrchestrationCommand,
3 OrchestrationProject,
4 OrchestrationReadModel,
5 OrchestrationThread,
6 ProjectId,
7 ThreadId,
8} from "@t3tools/contracts";
29import { Effect } from "effect";
310
411import { OrchestrationCommandInvariantError } from "./Errors.ts";
......@@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma
1219
1320export function findThreadById(
1421 readModel: OrchestrationReadModel,
15 threadId: string,
22 threadId: ThreadId,
1623): OrchestrationThread | undefined {
1724 return readModel.threads.find((thread) => thread.id === threadId);
1825}
1926
27export function findProjectById(
28 readModel: OrchestrationReadModel,
29 projectId: ProjectId,
30): OrchestrationProject | undefined {
31 return readModel.projects.find((project) => project.id === projectId);
32}
33
2034export function listThreadsByProjectId(
2135 readModel: OrchestrationReadModel,
22 projectId: string,
36 projectId: ProjectId,
2337): ReadonlyArray<OrchestrationThread> {
2438 return readModel.threads.filter((thread) => thread.projectId === projectId);
2539}
2640
41export function requireProject(
42 input: {
43 readonly readModel: OrchestrationReadModel;
44 readonly command: OrchestrationCommand;
45 readonly projectId: ProjectId;
46 },
47): Effect.Effect<OrchestrationProject, OrchestrationCommandInvariantError> {
48 const project = findProjectById(input.readModel, input.projectId);
49 if (project) {
50 return Effect.succeed(project);
51 }
52 return Effect.fail(
53 invariantError(
54 input.command.type,
55 `Project '${input.projectId}' does not exist for command '${input.command.type}'.`,
56 ),
57 );
58}
59
60export function requireProjectAbsent(
61 input: {
62 readonly readModel: OrchestrationReadModel;
63 readonly command: OrchestrationCommand;
64 readonly projectId: ProjectId;
65 },
66): Effect.Effect<void, OrchestrationCommandInvariantError> {
67 if (!findProjectById(input.readModel, input.projectId)) {
68 return Effect.void;
69 }
70 return Effect.fail(
71 invariantError(
72 input.command.type,
73 `Project '${input.projectId}' already exists and cannot be created twice.`,
74 ),
75 );
76}
77
2778export function requireThread(
2879 input: {
2980 readonly readModel: OrchestrationReadModel;
3081 readonly command: OrchestrationCommand;
31 readonly threadId: string;
82 readonly threadId: ThreadId;
3283 },
3384): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
3485 const thread = findThreadById(input.readModel, input.threadId);
......@@ -47,7 +98,7 @@ export function requireThreadAbsent(
4798 input: {
4899 readonly readModel: OrchestrationReadModel;
49100 readonly command: OrchestrationCommand;
50 readonly threadId: string;
101 readonly threadId: ThreadId;
51102 },
52103): Effect.Effect<void, OrchestrationCommandInvariantError> {
53104 if (!findThreadById(input.readModel, input.threadId)) {
apps/server/src/orchestration/decider.projectScripts.test.ts created+163
......@@ -0,0 +1,163 @@
1import { describe, expect, it } from "vitest";
2import { Effect } from "effect";
3
4import { decideOrchestrationCommand } from "./decider.ts";
5import { createEmptyReadModel, projectEvent } from "./projector.ts";
6
7describe("decider project scripts", () => {
8 it("emits empty scripts on project.create", async () => {
9 const now = new Date().toISOString();
10 const readModel = createEmptyReadModel(now);
11
12 const result = await Effect.runPromise(
13 decideOrchestrationCommand({
14 command: {
15 type: "project.create",
16 commandId: "cmd-project-create-scripts",
17 projectId: "project-scripts",
18 title: "Scripts",
19 workspaceRoot: "/tmp/scripts",
20 createdAt: now,
21 },
22 readModel,
23 }),
24 );
25
26 const event = Array.isArray(result) ? result[0] : result;
27 expect(event.type).toBe("project.created");
28 expect((event.payload as { scripts: unknown[] }).scripts).toEqual([]);
29 });
30
31 it("propagates scripts in project.meta.update payload", async () => {
32 const now = new Date().toISOString();
33 const initial = createEmptyReadModel(now);
34 const readModel = await Effect.runPromise(
35 projectEvent(initial, {
36 sequence: 1,
37 eventId: "evt-project-create-scripts",
38 aggregateType: "project",
39 aggregateId: "project-scripts",
40 type: "project.created",
41 occurredAt: now,
42 commandId: "cmd-project-create-scripts",
43 causationEventId: null,
44 correlationId: "cmd-project-create-scripts",
45 metadata: {},
46 payload: {
47 projectId: "project-scripts",
48 title: "Scripts",
49 workspaceRoot: "/tmp/scripts",
50 defaultModel: null,
51 scripts: [],
52 createdAt: now,
53 updatedAt: now,
54 },
55 }),
56 );
57
58 const scripts = [
59 {
60 id: "lint",
61 name: "Lint",
62 command: "bun run lint",
63 icon: "lint",
64 runOnWorktreeCreate: false,
65 },
66 ] as const;
67
68 const result = await Effect.runPromise(
69 decideOrchestrationCommand({
70 command: {
71 type: "project.meta.update",
72 commandId: "cmd-project-update-scripts",
73 projectId: "project-scripts",
74 scripts: Array.from(scripts),
75 },
76 readModel,
77 }),
78 );
79
80 const event = Array.isArray(result) ? result[0] : result;
81 expect(event.type).toBe("project.meta-updated");
82 expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts);
83 });
84
85 it("emits user message and turn-start-requested events for thread.turn.start", async () => {
86 const now = new Date().toISOString();
87 const initial = createEmptyReadModel(now);
88 const withProject = await Effect.runPromise(
89 projectEvent(initial, {
90 sequence: 1,
91 eventId: "evt-project-create",
92 aggregateType: "project",
93 aggregateId: "project-1",
94 type: "project.created",
95 occurredAt: now,
96 commandId: "cmd-project-create",
97 causationEventId: null,
98 correlationId: "cmd-project-create",
99 metadata: {},
100 payload: {
101 projectId: "project-1",
102 title: "Project",
103 workspaceRoot: "/tmp/project",
104 defaultModel: null,
105 scripts: [],
106 createdAt: now,
107 updatedAt: now,
108 },
109 }),
110 );
111 const readModel = await Effect.runPromise(
112 projectEvent(withProject, {
113 sequence: 2,
114 eventId: "evt-thread-create",
115 aggregateType: "thread",
116 aggregateId: "thread-1",
117 type: "thread.created",
118 occurredAt: now,
119 commandId: "cmd-thread-create",
120 causationEventId: null,
121 correlationId: "cmd-thread-create",
122 metadata: {},
123 payload: {
124 threadId: "thread-1",
125 projectId: "project-1",
126 title: "Thread",
127 model: "gpt-5-codex",
128 branch: null,
129 worktreePath: null,
130 createdAt: now,
131 updatedAt: now,
132 },
133 }),
134 );
135
136 const result = await Effect.runPromise(
137 decideOrchestrationCommand({
138 command: {
139 type: "thread.turn.start",
140 commandId: "cmd-turn-start",
141 threadId: "thread-1",
142 message: {
143 messageId: "message-user-1",
144 role: "user",
145 text: "hello",
146 attachments: [],
147 },
148 model: "gpt-5",
149 effort: "high",
150 createdAt: now,
151 },
152 readModel,
153 }),
154 );
155
156 expect(Array.isArray(result)).toBe(true);
157 const events = Array.isArray(result) ? result : [result];
158 expect(events).toHaveLength(2);
159 expect(events[0]?.type).toBe("thread.message-sent");
160 expect(events[1]?.type).toBe("thread.turn-start-requested");
161 expect(events[1]?.causationEventId).toBe(events[0]?.eventId ?? null);
162 });
163});
apps/server/src/orchestration/decider.ts+388-96
......@@ -7,36 +7,152 @@ import { Effect } from "effect";
77
88import { OrchestrationCommandInvariantError } from "./Errors.ts";
99import {
10 requireNonNegativeInteger,
10 requireProject,
11 requireProjectAbsent,
1112 requireThread,
1213 requireThreadAbsent,
1314} from "./commandInvariants.ts";
1415
16const nowIso = () => new Date().toISOString();
17
18const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
19 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
20 aggregateKind: "thread",
21 aggregateId: "" as OrchestrationEvent["aggregateId"],
22 occurredAt: nowIso(),
23 commandId: null,
24 causationEventId: null,
25 correlationId: null,
26 metadata: {},
27};
28
29function withEventBase(
30 input: Pick<OrchestrationCommand, "commandId"> & {
31 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
32 readonly aggregateId: OrchestrationEvent["aggregateId"];
33 readonly occurredAt: string;
34 readonly metadata?: OrchestrationEvent["metadata"];
35 },
36): Omit<OrchestrationEvent, "sequence" | "type" | "payload"> {
37 return {
38 ...defaultMetadata,
39 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
40 aggregateKind: input.aggregateKind,
41 aggregateId: input.aggregateId,
42 occurredAt: input.occurredAt,
43 commandId: input.commandId,
44 correlationId: input.commandId,
45 metadata: input.metadata ?? {},
46 };
47}
48
1549export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
1650 command,
1751 readModel,
1852}: {
1953 readonly command: OrchestrationCommand;
2054 readonly readModel: OrchestrationReadModel;
21}): Effect.fn.Return<Omit<OrchestrationEvent, "sequence">, OrchestrationCommandInvariantError> {
22 const eventId = crypto.randomUUID();
23
55}): Effect.fn.Return<
56 Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>,
57 OrchestrationCommandInvariantError
58> {
2459 switch (command.type) {
25 case "thread.create":
60 case "project.create": {
61 yield* requireProjectAbsent({
62 readModel,
63 command,
64 projectId: command.projectId,
65 });
66
67 return {
68 ...withEventBase({
69 aggregateKind: "project",
70 aggregateId: command.projectId,
71 occurredAt: command.createdAt,
72 commandId: command.commandId,
73 }),
74 type: "project.created",
75 payload: {
76 projectId: command.projectId,
77 title: command.title,
78 workspaceRoot: command.workspaceRoot,
79 defaultModel: command.defaultModel ?? null,
80 scripts: [],
81 createdAt: command.createdAt,
82 updatedAt: command.createdAt,
83 },
84 };
85 }
86
87 case "project.meta.update": {
88 yield* requireProject({
89 readModel,
90 command,
91 projectId: command.projectId,
92 });
93 const occurredAt = nowIso();
94 return {
95 ...withEventBase({
96 aggregateKind: "project",
97 aggregateId: command.projectId,
98 occurredAt,
99 commandId: command.commandId,
100 }),
101 type: "project.meta-updated",
102 payload: {
103 projectId: command.projectId,
104 ...(command.title !== undefined ? { title: command.title } : {}),
105 ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
106 ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}),
107 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
108 updatedAt: occurredAt,
109 },
110 };
111 }
112
113 case "project.delete": {
114 yield* requireProject({
115 readModel,
116 command,
117 projectId: command.projectId,
118 });
119 const occurredAt = nowIso();
120 return {
121 ...withEventBase({
122 aggregateKind: "project",
123 aggregateId: command.projectId,
124 occurredAt,
125 commandId: command.commandId,
126 }),
127 type: "project.deleted",
128 payload: {
129 projectId: command.projectId,
130 deletedAt: occurredAt,
131 },
132 };
133 }
134
135 case "thread.create": {
136 yield* requireProject({
137 readModel,
138 command,
139 projectId: command.projectId,
140 });
26141 yield* requireThreadAbsent({
27142 readModel,
28143 command,
29144 threadId: command.threadId,
30145 });
31146 return {
32 eventId,
147 ...withEventBase({
148 aggregateKind: "thread",
149 aggregateId: command.threadId,
150 occurredAt: command.createdAt,
151 commandId: command.commandId,
152 }),
33153 type: "thread.created",
34 aggregateType: "thread",
35 aggregateId: command.threadId,
36 occurredAt: command.createdAt,
37 commandId: command.commandId,
38154 payload: {
39 id: command.threadId,
155 threadId: command.threadId,
40156 projectId: command.projectId,
41157 title: command.title,
42158 model: command.model,
......@@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
46162 updatedAt: command.createdAt,
47163 },
48164 };
49 case "thread.delete":
165 }
166
167 case "thread.delete": {
50168 yield* requireThread({
51169 readModel,
52170 command,
53171 threadId: command.threadId,
54172 });
173 const occurredAt = nowIso();
55174 return {
56 eventId,
175 ...withEventBase({
176 aggregateKind: "thread",
177 aggregateId: command.threadId,
178 occurredAt,
179 commandId: command.commandId,
180 }),
57181 type: "thread.deleted",
58 aggregateType: "thread",
59 aggregateId: command.threadId,
60 occurredAt: command.createdAt,
61 commandId: command.commandId,
62182 payload: {
63 id: command.threadId,
64 deletedAt: command.createdAt,
183 threadId: command.threadId,
184 deletedAt: occurredAt,
65185 },
66186 };
67 case "thread.meta.update":
187 }
188
189 case "thread.meta.update": {
68190 yield* requireThread({
69191 readModel,
70192 command,
71193 threadId: command.threadId,
72194 });
195 const occurredAt = nowIso();
73196 return {
74 eventId,
197 ...withEventBase({
198 aggregateKind: "thread",
199 aggregateId: command.threadId,
200 occurredAt,
201 commandId: command.commandId,
202 }),
75203 type: "thread.meta-updated",
76 aggregateType: "thread",
77 aggregateId: command.threadId,
78 occurredAt: command.createdAt,
79 commandId: command.commandId,
80204 payload: {
81205 threadId: command.threadId,
82206 ...(command.title !== undefined ? { title: command.title } : {}),
83207 ...(command.model !== undefined ? { model: command.model } : {}),
84208 ...(command.branch !== undefined ? { branch: command.branch } : {}),
85209 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
210 updatedAt: occurredAt,
211 },
212 };
213 }
214
215 case "thread.turn.start": {
216 yield* requireThread({
217 readModel,
218 command,
219 threadId: command.threadId,
220 });
221 const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = {
222 ...withEventBase({
223 aggregateKind: "thread",
224 aggregateId: command.threadId,
225 occurredAt: command.createdAt,
226 commandId: command.commandId,
227 }),
228 type: "thread.message-sent",
229 payload: {
230 threadId: command.threadId,
231 messageId: command.message.messageId,
232 role: "user",
233 text: command.message.text,
234 attachments: command.message.attachments,
235 turnId: null,
236 streaming: false,
237 createdAt: command.createdAt,
86238 updatedAt: command.createdAt,
87239 },
88240 };
89 case "message.send":
241 const turnStartRequestedEvent: Omit<OrchestrationEvent, "sequence"> = {
242 ...withEventBase({
243 aggregateKind: "thread",
244 aggregateId: command.threadId,
245 occurredAt: command.createdAt,
246 commandId: command.commandId,
247 }),
248 causationEventId: userMessageEvent.eventId,
249 type: "thread.turn-start-requested",
250 payload: {
251 threadId: command.threadId,
252 messageId: command.message.messageId,
253 ...(command.model !== undefined ? { model: command.model } : {}),
254 ...(command.effort !== undefined ? { effort: command.effort } : {}),
255 createdAt: command.createdAt,
256 },
257 };
258 return [userMessageEvent, turnStartRequestedEvent];
259 }
260
261 case "thread.turn.interrupt": {
90262 yield* requireThread({
91263 readModel,
92264 command,
93265 threadId: command.threadId,
94266 });
95267 return {
96 eventId,
97 type: "message.sent",
98 aggregateType: "thread",
99 aggregateId: command.threadId,
100 occurredAt: command.createdAt,
101 commandId: command.commandId,
268 ...withEventBase({
269 aggregateKind: "thread",
270 aggregateId: command.threadId,
271 occurredAt: command.createdAt,
272 commandId: command.commandId,
273 }),
274 type: "thread.turn-interrupt-requested",
102275 payload: {
103 id: command.messageId,
104 role: command.role,
105 text: command.text,
106276 threadId: command.threadId,
277 ...(command.turnId !== undefined ? { turnId: command.turnId } : {}),
107278 createdAt: command.createdAt,
108 streaming: command.streaming === true,
109279 },
110280 };
111 case "thread.session":
281 }
282
283 case "thread.approval.respond": {
112284 yield* requireThread({
113285 readModel,
114286 command,
115287 threadId: command.threadId,
116288 });
117289 return {
118 eventId,
290 ...withEventBase({
291 aggregateKind: "thread",
292 aggregateId: command.threadId,
293 occurredAt: command.createdAt,
294 commandId: command.commandId,
295 metadata: {
296 requestId: command.requestId,
297 },
298 }),
299 type: "thread.approval-response-requested",
300 payload: {
301 threadId: command.threadId,
302 requestId: command.requestId,
303 decision: command.decision,
304 createdAt: command.createdAt,
305 },
306 };
307 }
308
309 case "thread.checkpoint.revert": {
310 yield* requireThread({
311 readModel,
312 command,
313 threadId: command.threadId,
314 });
315 return {
316 ...withEventBase({
317 aggregateKind: "thread",
318 aggregateId: command.threadId,
319 occurredAt: command.createdAt,
320 commandId: command.commandId,
321 }),
322 type: "thread.checkpoint-revert-requested",
323 payload: {
324 threadId: command.threadId,
325 turnCount: command.turnCount,
326 createdAt: command.createdAt,
327 },
328 };
329 }
330
331 case "thread.session.stop": {
332 yield* requireThread({
333 readModel,
334 command,
335 threadId: command.threadId,
336 });
337 return {
338 ...withEventBase({
339 aggregateKind: "thread",
340 aggregateId: command.threadId,
341 occurredAt: command.createdAt,
342 commandId: command.commandId,
343 }),
344 type: "thread.session-stop-requested",
345 payload: {
346 threadId: command.threadId,
347 createdAt: command.createdAt,
348 },
349 };
350 }
351
352 case "thread.session.set": {
353 yield* requireThread({
354 readModel,
355 command,
356 threadId: command.threadId,
357 });
358 return {
359 ...withEventBase({
360 aggregateKind: "thread",
361 aggregateId: command.threadId,
362 occurredAt: command.createdAt,
363 commandId: command.commandId,
364 metadata: {
365 ...(command.session.providerSessionId !== null
366 ? { providerSessionId: command.session.providerSessionId }
367 : {}),
368 ...(command.session.providerThreadId !== null
369 ? { providerThreadId: command.session.providerThreadId }
370 : {}),
371 },
372 }),
119373 type: "thread.session-set",
120 aggregateType: "thread",
121 aggregateId: command.threadId,
122 occurredAt: command.createdAt,
123 commandId: command.commandId,
124374 payload: {
125375 threadId: command.threadId,
126376 session: command.session,
127377 },
128378 };
129 case "git.readModel.upsert":
379 }
380
381 case "thread.message.assistant.delta": {
382 yield* requireThread({
383 readModel,
384 command,
385 threadId: command.threadId,
386 });
130387 return {
131 eventId,
132 type: "git.read-model-upsert",
133 aggregateType: "project",
134 aggregateId: command.projectId,
135 occurredAt: command.createdAt,
136 commandId: command.commandId,
388 ...withEventBase({
389 aggregateKind: "thread",
390 aggregateId: command.threadId,
391 occurredAt: command.createdAt,
392 commandId: command.commandId,
393 }),
394 type: "thread.message-sent",
137395 payload: {
138 projectId: command.projectId,
139 branch: command.branch,
140 hasWorkingTreeChanges: command.hasWorkingTreeChanges,
141 aheadCount: command.aheadCount,
142 behindCount: command.behindCount,
396 threadId: command.threadId,
397 messageId: command.messageId,
398 role: "assistant",
399 text: command.delta,
400 turnId: command.turnId ?? null,
401 streaming: true,
402 createdAt: command.createdAt,
143403 updatedAt: command.createdAt,
144404 },
145405 };
146 case "thread.turnDiff.complete":
406 }
407
408 case "thread.message.assistant.complete": {
409 yield* requireThread({
410 readModel,
411 command,
412 threadId: command.threadId,
413 });
414 return {
415 ...withEventBase({
416 aggregateKind: "thread",
417 aggregateId: command.threadId,
418 occurredAt: command.createdAt,
419 commandId: command.commandId,
420 }),
421 type: "thread.message-sent",
422 payload: {
423 threadId: command.threadId,
424 messageId: command.messageId,
425 role: "assistant",
426 text: "",
427 turnId: command.turnId ?? null,
428 streaming: false,
429 createdAt: command.createdAt,
430 updatedAt: command.createdAt,
431 },
432 };
433 }
434
435 case "thread.turn.diff.complete": {
147436 yield* requireThread({
148437 readModel,
149438 command,
150439 threadId: command.threadId,
151440 });
152441 return {
153 eventId,
442 ...withEventBase({
443 aggregateKind: "thread",
444 aggregateId: command.threadId,
445 occurredAt: command.createdAt,
446 commandId: command.commandId,
447 }),
154448 type: "thread.turn-diff-completed",
155 aggregateType: "thread",
156 aggregateId: command.threadId,
157 occurredAt: command.createdAt,
158 commandId: command.commandId,
159449 payload: {
160450 threadId: command.threadId,
161451 turnId: command.turnId,
162 completedAt: command.completedAt,
163 ...(command.status !== undefined ? { status: command.status } : {}),
452 checkpointTurnCount: command.checkpointTurnCount,
453 checkpointRef: command.checkpointRef,
454 status: command.status,
164455 files: command.files,
165 ...(command.assistantMessageId !== undefined
166 ? { assistantMessageId: command.assistantMessageId }
167 : {}),
168 ...(command.checkpointTurnCount !== undefined
169 ? { checkpointTurnCount: command.checkpointTurnCount }
170 : {}),
456 assistantMessageId: command.assistantMessageId ?? null,
457 completedAt: command.completedAt,
171458 },
172459 };
173 case "thread.activity.append":
460 }
461
462 case "thread.revert.complete": {
174463 yield* requireThread({
175464 readModel,
176465 command,
177466 threadId: command.threadId,
178467 });
179468 return {
180 eventId,
181 type: "thread.activity-appended",
182 aggregateType: "thread",
183 aggregateId: command.threadId,
184 occurredAt: command.createdAt,
185 commandId: command.commandId,
469 ...withEventBase({
470 aggregateKind: "thread",
471 aggregateId: command.threadId,
472 occurredAt: command.createdAt,
473 commandId: command.commandId,
474 }),
475 type: "thread.reverted",
186476 payload: {
187477 threadId: command.threadId,
188 activity: command.activity,
478 turnCount: command.turnCount,
189479 },
190480 };
191 case "thread.revert":
481 }
482
483 case "thread.activity.append": {
192484 yield* requireThread({
193485 readModel,
194486 command,
195487 threadId: command.threadId,
196488 });
197 yield* requireNonNegativeInteger({
198 commandType: command.type,
199 field: "turnCount",
200 value: command.turnCount,
201 });
202 yield* requireNonNegativeInteger({
203 commandType: command.type,
204 field: "messageCount",
205 value: command.messageCount,
206 });
489 const requestId =
490 typeof command.activity.payload === "object" &&
491 command.activity.payload !== null &&
492 "requestId" in command.activity.payload &&
493 typeof (command.activity.payload as { requestId?: unknown }).requestId === "string"
494 ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"])
495 : undefined;
207496 return {
208 eventId,
209 type: "thread.reverted",
210 aggregateType: "thread",
211 aggregateId: command.threadId,
212 occurredAt: command.createdAt,
213 commandId: command.commandId,
497 ...withEventBase({
498 aggregateKind: "thread",
499 aggregateId: command.threadId,
500 occurredAt: command.createdAt,
501 commandId: command.commandId,
502 ...(requestId !== undefined ? { metadata: { requestId } } : {}),
503 }),
504 type: "thread.activity-appended",
214505 payload: {
215506 threadId: command.threadId,
216 turnCount: command.turnCount,
217 messageCount: command.messageCount,
507 activity: command.activity,
218508 },
219509 };
510 }
511
220512 default: {
221513 command satisfies never;
222 const fallback = command as any;
514 const fallback = command as never as { type: string };
223515 return yield* new OrchestrationCommandInvariantError({
224516 commandType: fallback.type,
225517 detail: `Unknown command type: ${fallback.type}`,
apps/server/src/orchestration/projector.test.ts+226-199
......@@ -4,32 +4,60 @@ import { describe, expect, it } from "vitest";
44
55import { createEmptyReadModel, projectEvent } from "./projector.ts";
66
7function makeEvent(input: {
8 sequence: number;
9 type: OrchestrationEvent["type"];
10 occurredAt: string;
11 aggregateType: OrchestrationEvent["aggregateType"];
12 aggregateId: string;
13 commandId: string | null;
14 payload: unknown;
15}): OrchestrationEvent {
16 return {
17 sequence: input.sequence,
18 eventId: `event-${input.sequence}`,
19 type: input.type,
20 aggregateType: input.aggregateType,
21 aggregateId: input.aggregateId as OrchestrationEvent["aggregateId"],
22 occurredAt: input.occurredAt,
23 commandId: input.commandId as OrchestrationEvent["commandId"],
24 causationEventId: null,
25 correlationId: null,
26 metadata: {},
27 payload: input.payload,
28 };
29}
30
731describe("orchestration projector", () => {
8 it("applies thread.created events using runtime schema decoding", async () => {
32 it("applies thread.created events", async () => {
933 const now = new Date().toISOString();
1034 const model = createEmptyReadModel(now);
11 const event: OrchestrationEvent = {
12 sequence: 1,
13 eventId: "event-thread-created",
14 type: "thread.created",
15 aggregateType: "thread",
16 aggregateId: "thread-1",
17 occurredAt: now,
18 commandId: "cmd-1",
19 payload: {
20 id: "thread-1",
21 projectId: "project-1",
22 title: "demo",
23 model: "gpt-5-codex",
24 branch: null,
25 worktreePath: null,
26 createdAt: now,
27 updatedAt: now,
28 },
29 };
3035
31 const next = await Effect.runPromise(projectEvent(model, event));
32 expect(next.sequence).toBe(1);
36 const next = await Effect.runPromise(
37 projectEvent(
38 model,
39 makeEvent({
40 sequence: 1,
41 type: "thread.created",
42 aggregateType: "thread",
43 aggregateId: "thread-1",
44 occurredAt: now,
45 commandId: "cmd-thread-create",
46 payload: {
47 threadId: "thread-1",
48 projectId: "project-1",
49 title: "demo",
50 model: "gpt-5-codex",
51 branch: null,
52 worktreePath: null,
53 createdAt: now,
54 updatedAt: now,
55 },
56 }),
57 ),
58 );
59
60 expect(next.snapshotSequence).toBe(1);
3361 expect(next.threads).toEqual([
3462 {
3563 id: "thread-1",
......@@ -38,17 +66,14 @@ describe("orchestration projector", () => {
3866 model: "gpt-5-codex",
3967 branch: null,
4068 worktreePath: null,
69 latestTurnId: null,
4170 createdAt: now,
4271 updatedAt: now,
43 latestTurnId: null,
44 latestTurnStartedAt: null,
45 latestTurnCompletedAt: null,
46 latestTurnDurationMs: null,
72 deletedAt: null,
4773 messages: [],
48 session: null,
4974 activities: [],
50 turnDiffSummaries: [],
51 error: null,
75 checkpoints: [],
76 session: null,
5277 },
5378 ]);
5479 });
......@@ -56,203 +81,205 @@ describe("orchestration projector", () => {
5681 it("fails when event payload cannot be decoded by runtime schema", async () => {
5782 const now = new Date().toISOString();
5883 const model = createEmptyReadModel(now);
59 const event: OrchestrationEvent = {
60 sequence: 1,
61 eventId: "event-message-invalid",
62 type: "message.sent",
63 aggregateType: "thread",
64 aggregateId: "thread-1",
65 occurredAt: now,
66 commandId: "cmd-1",
67 payload: {
68 id: "message-1",
69 threadId: "thread-1",
70 text: "hello",
71 createdAt: now,
72 streaming: false,
73 },
74 };
7584
76 await expect(Effect.runPromise(projectEvent(model, event))).rejects.toBeDefined();
85 await expect(
86 Effect.runPromise(
87 projectEvent(
88 model,
89 makeEvent({
90 sequence: 1,
91 type: "thread.created",
92 aggregateType: "thread",
93 aggregateId: "thread-1",
94 occurredAt: now,
95 commandId: "cmd-invalid",
96 payload: {
97 // missing required threadId
98 projectId: "project-1",
99 title: "demo",
100 model: "gpt-5-codex",
101 branch: null,
102 worktreePath: null,
103 createdAt: now,
104 updatedAt: now,
105 },
106 }),
107 ),
108 ),
109 ).rejects.toBeDefined();
77110 });
78111
79 it("keeps projector forward-compatible for unknown event types", async () => {
112 it("keeps projector forward-compatible for unhandled event types", async () => {
80113 const now = new Date().toISOString();
81114 const model = createEmptyReadModel(now);
82 const event: OrchestrationEvent = {
83 sequence: 7,
84 eventId: "event-unknown",
85 type: "custom.event.ignored",
86 aggregateType: "unknown",
87 aggregateId: "aggregate-1",
88 occurredAt: "2026-01-01T00:00:00.000Z",
89 commandId: null,
90 payload: { test: true },
91 };
92115
93 const next = await Effect.runPromise(projectEvent(model, event));
94 expect(next.sequence).toBe(7);
116 const next = await Effect.runPromise(
117 projectEvent(
118 model,
119 makeEvent({
120 sequence: 7,
121 type: "thread.turn-start-requested",
122 aggregateType: "thread",
123 aggregateId: "thread-1",
124 occurredAt: "2026-01-01T00:00:00.000Z",
125 commandId: "cmd-unhandled",
126 payload: {
127 threadId: "thread-1",
128 messageId: "message-1",
129 createdAt: "2026-01-01T00:00:00.000Z",
130 },
131 }),
132 ),
133 );
134
135 expect(next.snapshotSequence).toBe(7);
95136 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");
96137 expect(next.threads).toEqual([]);
97138 });
98139
99 it("tracks latest turn timing from session lifecycle events", async () => {
140 it("tracks latest turn id from session lifecycle events", async () => {
100141 const createdAt = "2026-02-23T08:00:00.000Z";
101142 const startedAt = "2026-02-23T08:00:05.000Z";
102 const completedAt = "2026-02-23T08:00:11.250Z";
103143 const model = createEmptyReadModel(createdAt);
104144
105 const createdEvent: OrchestrationEvent = {
106 sequence: 1,
107 eventId: "thread-created",
108 type: "thread.created",
109 aggregateType: "thread",
110 aggregateId: "thread-1",
111 occurredAt: createdAt,
112 commandId: "cmd-create",
113 payload: {
114 id: "thread-1",
115 projectId: "project-1",
116 title: "demo",
117 model: "gpt-5.3-codex",
118 branch: null,
119 worktreePath: null,
120 createdAt,
121 updatedAt: createdAt,
122 },
123 };
124
125 const runningSessionEvent: OrchestrationEvent = {
126 sequence: 2,
127 eventId: "session-running",
128 type: "thread.session-set",
129 aggregateType: "thread",
130 aggregateId: "thread-1",
131 occurredAt: startedAt,
132 commandId: "cmd-running",
133 payload: {
134 threadId: "thread-1",
135 session: {
136 sessionId: "session-1",
137 status: "running",
138 provider: "codex",
139 threadId: "thread-1",
140 activeTurnId: "turn-1",
141 createdAt,
142 updatedAt: startedAt,
143 lastError: null,
144 },
145 },
146 };
147
148 const readySessionEvent: OrchestrationEvent = {
149 sequence: 3,
150 eventId: "session-ready",
151 type: "thread.session-set",
152 aggregateType: "thread",
153 aggregateId: "thread-1",
154 occurredAt: completedAt,
155 commandId: "cmd-ready",
156 payload: {
157 threadId: "thread-1",
158 session: {
159 sessionId: "session-1",
160 status: "ready",
161 provider: "codex",
162 threadId: "thread-1",
163 activeTurnId: null,
164 createdAt,
165 updatedAt: completedAt,
166 lastError: null,
167 },
168 },
169 };
145 const afterCreate = await Effect.runPromise(
146 projectEvent(
147 model,
148 makeEvent({
149 sequence: 1,
150 type: "thread.created",
151 aggregateType: "thread",
152 aggregateId: "thread-1",
153 occurredAt: createdAt,
154 commandId: "cmd-create",
155 payload: {
156 threadId: "thread-1",
157 projectId: "project-1",
158 title: "demo",
159 model: "gpt-5.3-codex",
160 branch: null,
161 worktreePath: null,
162 createdAt,
163 updatedAt: createdAt,
164 },
165 }),
166 ),
167 );
170168
171 const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent));
172 const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent));
173 const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent));
174 const thread = afterReady.threads[0];
169 const afterRunning = await Effect.runPromise(
170 projectEvent(
171 afterCreate,
172 makeEvent({
173 sequence: 2,
174 type: "thread.session-set",
175 aggregateType: "thread",
176 aggregateId: "thread-1",
177 occurredAt: startedAt,
178 commandId: "cmd-running",
179 payload: {
180 threadId: "thread-1",
181 session: {
182 threadId: "thread-1",
183 status: "running",
184 providerName: "codex",
185 providerSessionId: "session-1",
186 providerThreadId: "provider-thread-1",
187 activeTurnId: "turn-1",
188 lastError: null,
189 updatedAt: startedAt,
190 },
191 },
192 }),
193 ),
194 );
175195
196 const thread = afterRunning.threads[0];
176197 expect(thread?.latestTurnId).toBe("turn-1");
177 expect(thread?.latestTurnStartedAt).toBe(startedAt);
178 expect(thread?.latestTurnCompletedAt).toBe(completedAt);
179 expect(thread?.latestTurnDurationMs).toBe(6250);
198 expect(thread?.session?.status).toBe("running");
180199 });
181200
182 it("marks assistant messages completed using non-streaming updates", async () => {
201 it("marks assistant messages completed with non-streaming updates", async () => {
183202 const createdAt = "2026-02-23T09:00:00.000Z";
184203 const deltaAt = "2026-02-23T09:00:01.000Z";
185204 const completeAt = "2026-02-23T09:00:03.500Z";
186205 const model = createEmptyReadModel(createdAt);
187206
188 const createdEvent: OrchestrationEvent = {
189 sequence: 1,
190 eventId: "thread-created",
191 type: "thread.created",
192 aggregateType: "thread",
193 aggregateId: "thread-1",
194 occurredAt: createdAt,
195 commandId: "cmd-create",
196 payload: {
197 id: "thread-1",
198 projectId: "project-1",
199 title: "demo",
200 model: "gpt-5.3-codex",
201 branch: null,
202 worktreePath: null,
203 createdAt,
204 updatedAt: createdAt,
205 },
206 };
207 const afterCreate = await Effect.runPromise(
208 projectEvent(
209 model,
210 makeEvent({
211 sequence: 1,
212 type: "thread.created",
213 aggregateType: "thread",
214 aggregateId: "thread-1",
215 occurredAt: createdAt,
216 commandId: "cmd-create",
217 payload: {
218 threadId: "thread-1",
219 projectId: "project-1",
220 title: "demo",
221 model: "gpt-5.3-codex",
222 branch: null,
223 worktreePath: null,
224 createdAt,
225 updatedAt: createdAt,
226 },
227 }),
228 ),
229 );
207230
208 const deltaEvent: OrchestrationEvent = {
209 sequence: 2,
210 eventId: "assistant-delta",
211 type: "message.sent",
212 aggregateType: "thread",
213 aggregateId: "thread-1",
214 occurredAt: deltaAt,
215 commandId: "cmd-delta",
216 payload: {
217 id: "assistant:msg-1",
218 role: "assistant",
219 text: "hello",
220 threadId: "thread-1",
221 createdAt: deltaAt,
222 streaming: true,
223 },
224 };
231 const afterDelta = await Effect.runPromise(
232 projectEvent(
233 afterCreate,
234 makeEvent({
235 sequence: 2,
236 type: "thread.message-sent",
237 aggregateType: "thread",
238 aggregateId: "thread-1",
239 occurredAt: deltaAt,
240 commandId: "cmd-delta",
241 payload: {
242 threadId: "thread-1",
243 messageId: "assistant:msg-1",
244 role: "assistant",
245 text: "hello",
246 turnId: "turn-1",
247 streaming: true,
248 createdAt: deltaAt,
249 updatedAt: deltaAt,
250 },
251 }),
252 ),
253 );
225254
226 const completionEvent: OrchestrationEvent = {
227 sequence: 3,
228 eventId: "assistant-complete",
229 type: "message.sent",
230 aggregateType: "thread",
231 aggregateId: "thread-1",
232 occurredAt: completeAt,
233 commandId: "cmd-complete",
234 payload: {
235 id: "assistant:msg-1",
236 role: "assistant",
237 text: "",
238 threadId: "thread-1",
239 createdAt: completeAt,
240 streaming: false,
241 },
242 };
255 const afterComplete = await Effect.runPromise(
256 projectEvent(
257 afterDelta,
258 makeEvent({
259 sequence: 3,
260 type: "thread.message-sent",
261 aggregateType: "thread",
262 aggregateId: "thread-1",
263 occurredAt: completeAt,
264 commandId: "cmd-complete",
265 payload: {
266 threadId: "thread-1",
267 messageId: "assistant:msg-1",
268 role: "assistant",
269 text: "",
270 turnId: "turn-1",
271 streaming: false,
272 createdAt: completeAt,
273 updatedAt: completeAt,
274 },
275 }),
276 ),
277 );
243278
244 const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent));
245 const afterDelta = await Effect.runPromise(projectEvent(afterCreate, deltaEvent));
246 const afterComplete = await Effect.runPromise(projectEvent(afterDelta, completionEvent));
247279 const message = afterComplete.threads[0]?.messages[0];
248
249 expect(message).toEqual({
250 id: "assistant:msg-1",
251 role: "assistant",
252 text: "hello",
253 createdAt: deltaAt,
254 completedAt: completeAt,
255 streaming: false,
256 });
280 expect(message?.id).toBe("assistant:msg-1");
281 expect(message?.text).toBe("hello");
282 expect(message?.streaming).toBe(false);
283 expect(message?.updatedAt).toBe(completeAt);
257284 });
258285});
apps/server/src/orchestration/projector.ts+176-253
......@@ -1,29 +1,36 @@
11import type {
22 OrchestrationEvent,
3 OrchestrationGitReadModel,
4 OrchestrationMessage,
53 OrchestrationReadModel,
6 OrchestrationSession,
7 OrchestrationThread,
4 ThreadId,
85} from "@t3tools/contracts";
96import {
10 OrchestrationThreadActivitySchema,
11 OrchestrationGitReadModelSchema,
12 OrchestrationMessageSchema,
13 OrchestrationSessionSchema,
14 OrchestrationThreadSchema,
15 OrchestrationTurnDiffFileSchema,
16 OrchestrationTurnDiffSummarySchema,
7 OrchestrationCheckpointSummary,
8 OrchestrationMessage,
9 OrchestrationSession,
10 OrchestrationThread,
1711} from "@t3tools/contracts";
1812import { Effect, Schema } from "effect";
1913
2014import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";
15import {
16 MessageSentPayloadSchema,
17 ProjectCreatedPayload,
18 ProjectDeletedPayload,
19 ProjectMetaUpdatedPayload,
20 ThreadActivityAppendedPayload,
21 ThreadCreatedPayload,
22 ThreadDeletedPayload,
23 ThreadMetaUpdatedPayload,
24 ThreadRevertedPayload,
25 ThreadSessionSetPayload,
26 ThreadTurnDiffCompletedPayload,
27} from "./Schemas.ts";
2128
2229type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
2330
2431function updateThread(
2532 threads: ReadonlyArray<OrchestrationThread>,
26 threadId: string,
33 threadId: ThreadId,
2734 patch: ThreadPatch,
2835): OrchestrationThread[] {
2936 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
......@@ -32,7 +39,7 @@ function updateThread(
3239function decodeForEvent<A>(
3340 schema: Schema.Schema<A>,
3441 value: unknown,
35 eventType: string,
42 eventType: OrchestrationEvent["type"],
3643 field: string,
3744): Effect.Effect<A, OrchestrationProjectorDecodeError> {
3845 return Schema.decodeUnknownEffect(schema)(value).pipe(
......@@ -40,66 +47,11 @@ function decodeForEvent<A>(
4047 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;
4148}
4249
43const ThreadCreatedPayloadSchema = Schema.Struct({
44 id: Schema.String,
45 projectId: Schema.String,
46 title: Schema.String,
47 model: Schema.String,
48 branch: Schema.NullOr(Schema.String),
49 worktreePath: Schema.NullOr(Schema.String),
50 createdAt: Schema.String,
51 updatedAt: Schema.String,
52});
53
54const ThreadMetaUpdatedPayloadSchema = Schema.Struct({
55 threadId: Schema.String,
56 title: Schema.optional(Schema.String),
57 model: Schema.optional(Schema.String),
58 branch: Schema.optional(Schema.NullOr(Schema.String)),
59 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
60 updatedAt: Schema.String,
61});
62
63const MessageSentPayloadSchema = Schema.Struct({
64 id: Schema.String,
65 role: Schema.Literals(["user", "assistant"]),
66 text: Schema.String,
67 threadId: Schema.String,
68 createdAt: Schema.String,
69 streaming: Schema.Boolean,
70});
71
72const ThreadSessionSetPayloadSchema = Schema.Struct({
73 threadId: Schema.String,
74 session: OrchestrationSessionSchema,
75});
76
77const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({
78 threadId: Schema.String,
79 turnId: Schema.String,
80 completedAt: Schema.String,
81 status: Schema.optional(Schema.String),
82 files: Schema.Array(OrchestrationTurnDiffFileSchema),
83 assistantMessageId: Schema.optional(Schema.String),
84 checkpointTurnCount: Schema.optional(Schema.Number),
85});
86
87const ThreadRevertedPayloadSchema = Schema.Struct({
88 threadId: Schema.String,
89 turnCount: Schema.Number,
90 messageCount: Schema.Number,
91});
92
93const ThreadActivityAppendedPayloadSchema = Schema.Struct({
94 threadId: Schema.String,
95 activity: OrchestrationThreadActivitySchema,
96});
97
9850export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
9951 return {
100 sequence: 0,
52 snapshotSequence: 0,
53 projects: [],
10154 threads: [],
102 gitByProjectId: {},
10355 updatedAt: nowIso,
10456 };
10557}
......@@ -110,39 +62,104 @@ export function projectEvent(
11062): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> {
11163 const nextBase: OrchestrationReadModel = {
11264 ...model,
113 sequence: event.sequence,
65 snapshotSequence: event.sequence,
11466 updatedAt: event.occurredAt,
11567 };
11668
11769 switch (event.type) {
70 case "project.created":
71 return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe(
72 Effect.map((payload) => {
73 const existing = nextBase.projects.find((entry) => entry.id === payload.projectId);
74 const nextProject = {
75 id: payload.projectId,
76 title: payload.title,
77 workspaceRoot: payload.workspaceRoot,
78 defaultModel: payload.defaultModel,
79 scripts: payload.scripts,
80 createdAt: payload.createdAt,
81 updatedAt: payload.updatedAt,
82 deletedAt: null,
83 };
84
85 return {
86 ...nextBase,
87 projects: existing
88 ? nextBase.projects.map((entry) => (entry.id === payload.projectId ? nextProject : entry))
89 : [...nextBase.projects, nextProject],
90 };
91 }),
92 );
93
94 case "project.meta-updated":
95 return decodeForEvent(
96 ProjectMetaUpdatedPayload,
97 event.payload,
98 event.type,
99 "payload",
100 ).pipe(
101 Effect.map((payload) => ({
102 ...nextBase,
103 projects: nextBase.projects.map((project) =>
104 project.id === payload.projectId
105 ? {
106 ...project,
107 ...(payload.title !== undefined ? { title: payload.title } : {}),
108 ...(payload.workspaceRoot !== undefined
109 ? { workspaceRoot: payload.workspaceRoot }
110 : {}),
111 ...(payload.defaultModel !== undefined
112 ? { defaultModel: payload.defaultModel }
113 : {}),
114 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
115 updatedAt: payload.updatedAt,
116 }
117 : project,
118 ),
119 })),
120 );
121
122 case "project.deleted":
123 return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe(
124 Effect.map((payload) => ({
125 ...nextBase,
126 projects: nextBase.projects.map((project) =>
127 project.id === payload.projectId
128 ? {
129 ...project,
130 deletedAt: payload.deletedAt,
131 updatedAt: payload.deletedAt,
132 }
133 : project,
134 ),
135 })),
136 );
137
118138 case "thread.created":
119139 return Effect.gen(function* () {
120140 const payload = yield* decodeForEvent(
121 ThreadCreatedPayloadSchema,
141 ThreadCreatedPayload,
122142 event.payload,
123143 event.type,
124144 "payload",
125145 );
126146 const thread: OrchestrationThread = yield* decodeForEvent(
127 OrchestrationThreadSchema,
147 OrchestrationThread,
128148 {
129 id: payload.id,
149 id: payload.threadId,
130150 projectId: payload.projectId,
131151 title: payload.title,
132152 model: payload.model,
133153 branch: payload.branch,
134154 worktreePath: payload.worktreePath,
155 latestTurnId: null,
135156 createdAt: payload.createdAt,
136157 updatedAt: payload.updatedAt,
137 latestTurnId: null,
138 latestTurnStartedAt: null,
139 latestTurnCompletedAt: null,
140 latestTurnDurationMs: null,
158 deletedAt: null,
141159 messages: [],
142 session: null,
143 turnDiffSummaries: [],
144160 activities: [],
145 error: null,
161 checkpoints: [],
162 session: null,
146163 },
147164 event.type,
148165 "thread",
......@@ -155,36 +172,38 @@ export function projectEvent(
155172 : [...nextBase.threads, thread],
156173 };
157174 });
175
158176 case "thread.deleted":
159 return Effect.succeed({
160 ...nextBase,
161 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),
162 });
177 return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe(
178 Effect.map((payload) => ({
179 ...nextBase,
180 threads: updateThread(nextBase.threads, payload.threadId, {
181 deletedAt: payload.deletedAt,
182 updatedAt: payload.deletedAt,
183 }),
184 })),
185 );
186
163187 case "thread.meta-updated":
164188 return decodeForEvent(
165 ThreadMetaUpdatedPayloadSchema,
189 ThreadMetaUpdatedPayload,
166190 event.payload,
167191 event.type,
168192 "payload",
169193 ).pipe(
170 Effect.map((payload) => {
171 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
172 if (!thread) {
173 return nextBase;
174 }
175 return {
176 ...nextBase,
177 threads: updateThread(nextBase.threads, payload.threadId, {
178 ...(payload.title !== undefined ? { title: payload.title } : {}),
179 ...(payload.model !== undefined ? { model: payload.model } : {}),
180 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
181 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
182 updatedAt: payload.updatedAt,
183 }),
184 };
185 }),
194 Effect.map((payload) => ({
195 ...nextBase,
196 threads: updateThread(nextBase.threads, payload.threadId, {
197 ...(payload.title !== undefined ? { title: payload.title } : {}),
198 ...(payload.model !== undefined ? { model: payload.model } : {}),
199 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
200 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
201 updatedAt: payload.updatedAt,
202 }),
203 })),
186204 );
187 case "message.sent":
205
206 case "thread.message-sent":
188207 return Effect.gen(function* () {
189208 const payload = yield* decodeForEvent(
190209 MessageSentPayloadSchema,
......@@ -192,27 +211,30 @@ export function projectEvent(
192211 event.type,
193212 "payload",
194213 );
195 const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId);
196 if (!targetThread) {
214 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
215 if (!thread) {
197216 return nextBase;
198217 }
199218
200219 const message: OrchestrationMessage = yield* decodeForEvent(
201 OrchestrationMessageSchema,
220 OrchestrationMessage,
202221 {
203 id: payload.id,
222 id: payload.messageId,
204223 role: payload.role,
205224 text: payload.text,
206 createdAt: payload.createdAt,
207 ...(payload.streaming ? {} : { completedAt: event.occurredAt }),
225 ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}),
226 turnId: payload.turnId,
208227 streaming: payload.streaming,
228 createdAt: payload.createdAt,
229 updatedAt: payload.updatedAt,
209230 },
210231 event.type,
211232 "message",
212233 );
213 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);
214 const nextMessages = existingMessage
215 ? targetThread.messages.map((entry) =>
234
235 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
236 const messages = existingMessage
237 ? thread.messages.map((entry) =>
216238 entry.id === message.id
217239 ? {
218240 ...entry,
......@@ -222,29 +244,29 @@ export function projectEvent(
222244 ? message.text
223245 : entry.text,
224246 streaming: message.streaming,
225 createdAt: entry.createdAt,
226 ...(message.streaming
227 ? { completedAt: undefined }
228 : { completedAt: message.completedAt ?? event.occurredAt }),
247 updatedAt: message.updatedAt,
248 turnId: message.turnId,
249 ...(message.attachments !== undefined
250 ? { attachments: message.attachments }
251 : {}),
229252 }
230253 : entry,
231254 )
232 : !message.streaming && message.text.length === 0
233 ? targetThread.messages
234 : [...targetThread.messages, message];
255 : [...thread.messages, message];
235256
236257 return {
237258 ...nextBase,
238259 threads: updateThread(nextBase.threads, payload.threadId, {
239 messages: nextMessages,
260 messages,
240261 updatedAt: event.occurredAt,
241262 }),
242263 };
243264 });
265
244266 case "thread.session-set":
245267 return Effect.gen(function* () {
246268 const payload = yield* decodeForEvent(
247 ThreadSessionSetPayloadSchema,
269 ThreadSessionSetPayload,
248270 event.payload,
249271 event.type,
250272 "payload",
......@@ -255,67 +277,26 @@ export function projectEvent(
255277 }
256278
257279 const session: OrchestrationSession = yield* decodeForEvent(
258 OrchestrationSessionSchema,
259 {
260 ...payload.session,
261 threadId: payload.threadId,
262 },
280 OrchestrationSession,
281 payload.session,
263282 event.type,
264283 "session",
265284 );
266285
267 const sessionUpdatedAt = session.updatedAt;
268 const turnTimingPatch: ThreadPatch =
269 session.status === "running" && session.activeTurnId
270 ? (() => {
271 const isSameTurn = thread.latestTurnId === session.activeTurnId;
272 return {
273 latestTurnId: session.activeTurnId,
274 latestTurnStartedAt:
275 isSameTurn && thread.latestTurnStartedAt
276 ? thread.latestTurnStartedAt
277 : sessionUpdatedAt,
278 ...(isSameTurn
279 ? {}
280 : {
281 latestTurnCompletedAt: null,
282 latestTurnDurationMs: null,
283 }),
284 };
285 })()
286 : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt
287 ? (() => {
288 const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt;
289 const startedAtMs = Date.parse(thread.latestTurnStartedAt);
290 const completedAtMs = Date.parse(latestTurnCompletedAt);
291 const latestTurnDurationMs =
292 !Number.isNaN(startedAtMs) &&
293 !Number.isNaN(completedAtMs) &&
294 completedAtMs >= startedAtMs
295 ? completedAtMs - startedAtMs
296 : thread.latestTurnDurationMs;
297
298 return {
299 latestTurnCompletedAt,
300 latestTurnDurationMs,
301 };
302 })()
303 : {};
304
305286 return {
306287 ...nextBase,
307288 threads: updateThread(nextBase.threads, payload.threadId, {
308289 session,
290 latestTurnId: session.activeTurnId,
309291 updatedAt: event.occurredAt,
310 error: session.lastError,
311 ...turnTimingPatch,
312292 }),
313293 };
314294 });
295
315296 case "thread.turn-diff-completed":
316297 return Effect.gen(function* () {
317298 const payload = yield* decodeForEvent(
318 ThreadTurnDiffCompletedPayloadSchema,
299 ThreadTurnDiffCompletedPayload,
319300 event.payload,
320301 event.type,
321302 "payload",
......@@ -325,110 +306,65 @@ export function projectEvent(
325306 return nextBase;
326307 }
327308
328 const nextSummary = yield* decodeForEvent(
329 OrchestrationTurnDiffSummarySchema,
309 const checkpoint = yield* decodeForEvent(
310 OrchestrationCheckpointSummary,
330311 {
331312 turnId: payload.turnId,
332 completedAt: payload.completedAt,
333 ...(payload.status !== undefined ? { status: payload.status } : {}),
313 checkpointTurnCount: payload.checkpointTurnCount,
314 checkpointRef: payload.checkpointRef,
315 status: payload.status,
334316 files: payload.files,
335 ...(payload.assistantMessageId !== undefined
336 ? { assistantMessageId: payload.assistantMessageId }
337 : {}),
338 ...(payload.checkpointTurnCount !== undefined
339 ? { checkpointTurnCount: payload.checkpointTurnCount }
340 : {}),
317 assistantMessageId: payload.assistantMessageId,
318 completedAt: payload.completedAt,
341319 },
342320 event.type,
343 "turnDiffSummary",
321 "checkpoint",
344322 );
345 const turnDiffSummaries = [
346 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),
347 nextSummary,
348 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
349 const startedAtMs = thread.latestTurnStartedAt
350 ? Date.parse(thread.latestTurnStartedAt)
351 : Number.NaN;
352 const completedAtMs = Date.parse(nextSummary.completedAt);
353 const latestTurnDurationMs =
354 !Number.isNaN(startedAtMs) &&
355 !Number.isNaN(completedAtMs) &&
356 completedAtMs >= startedAtMs
357 ? completedAtMs - startedAtMs
358 : thread.latestTurnDurationMs;
323
324 const checkpoints = [
325 ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId),
326 checkpoint,
327 ].toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
359328
360329 return {
361330 ...nextBase,
362331 threads: updateThread(nextBase.threads, payload.threadId, {
332 checkpoints,
363333 latestTurnId: payload.turnId,
364 latestTurnCompletedAt: nextSummary.completedAt,
365 latestTurnDurationMs,
366 turnDiffSummaries,
367334 updatedAt: event.occurredAt,
368335 }),
369336 };
370337 });
338
371339 case "thread.reverted":
372 return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe(
340 return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe(
373341 Effect.map((payload) => {
374342 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
375343 if (!thread) {
376344 return nextBase;
377345 }
378346
379 const targetTurnCount = Math.max(0, Math.floor(payload.turnCount));
380 const targetMessageCount = Math.max(0, Math.floor(payload.messageCount));
381 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>
382 left.completedAt.localeCompare(right.completedAt),
383 );
384 const inferredTurnCountByTurnId = new Map<string, number>();
385 for (let index = 0; index < sortedSummaries.length; index += 1) {
386 const summary = sortedSummaries[index];
387 if (!summary) continue;
388 inferredTurnCountByTurnId.set(summary.turnId, index + 1);
389 }
347 const checkpoints = thread.checkpoints
348 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)
349 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
390350
391 const turnDiffSummaries = thread.turnDiffSummaries
392 .filter((summary) => {
393 const checkpointTurnCount =
394 summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0;
395 return checkpointTurnCount <= targetTurnCount;
396 })
397 .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
398 const latestSummary =
399 turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null;
400 const activities = thread.activities.filter((activity) => {
401 if (!activity.turnId) {
402 return true;
403 }
404 const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId);
405 if (checkpointTurnCount === undefined) {
406 return true;
407 }
408 return checkpointTurnCount <= targetTurnCount;
409 });
351 const latestTurnId =
352 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;
410353
411354 return {
412355 ...nextBase,
413356 threads: updateThread(nextBase.threads, payload.threadId, {
414 messages: thread.messages.slice(
415 0,
416 Math.min(targetMessageCount, thread.messages.length),
417 ),
418 turnDiffSummaries,
419 latestTurnId: latestSummary?.turnId ?? null,
420 latestTurnStartedAt: null,
421 latestTurnCompletedAt: latestSummary?.completedAt ?? null,
422 latestTurnDurationMs: null,
423 activities,
357 checkpoints,
358 latestTurnId,
424359 updatedAt: event.occurredAt,
425360 }),
426361 };
427362 }),
428363 );
364
429365 case "thread.activity-appended":
430366 return decodeForEvent(
431 ThreadActivityAppendedPayloadSchema,
367 ThreadActivityAppendedPayload,
432368 event.payload,
433369 event.type,
434370 "payload",
......@@ -438,8 +374,9 @@ export function projectEvent(
438374 if (!thread) {
439375 return nextBase;
440376 }
377
441378 const activities = [
442 ...thread.activities.filter((activity) => activity.id !== payload.activity.id),
379 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
443380 payload.activity,
444381 ]
445382 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt))
......@@ -454,21 +391,7 @@ export function projectEvent(
454391 };
455392 }),
456393 );
457 case "git.read-model-upsert":
458 return decodeForEvent(
459 OrchestrationGitReadModelSchema,
460 event.payload,
461 event.type,
462 "payload",
463 ).pipe(
464 Effect.map((gitReadModel: OrchestrationGitReadModel) => ({
465 ...nextBase,
466 gitByProjectId: {
467 ...nextBase.gitByProjectId,
468 [gitReadModel.projectId]: gitReadModel,
469 },
470 })),
471 );
394
472395 default:
473396 return Effect.succeed(nextBase);
474397 }
apps/server/src/persistence/Errors.ts+16
......@@ -200,3 +200,19 @@ export type CheckpointRepositoryError =
200200export type ProviderSessionRepositoryError =
201201 | ProviderSessionRepositoryValidationError
202202 | ProviderSessionRepositoryPersistenceError;
203
204export type OrchestrationCommandReceiptRepositoryError =
205 | PersistenceSqlError
206 | PersistenceDecodeError;
207
208export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError;
209
210export type ProviderSessionRuntimeRepositoryError =
211 | PersistenceSqlError
212 | PersistenceDecodeError
213 | PersistenceSerializationError;
214
215export type ProjectionRepositoryError =
216 | PersistenceSqlError
217 | PersistenceDecodeError
218 | PersistenceSerializationError;
apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts created+79
......@@ -0,0 +1,79 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 CheckpointDiffBlobRepository,
9 CheckpointDiffBlob,
10 GetCheckpointDiffBlobInput,
11 type CheckpointDiffBlobRepositoryShape,
12} from "../Services/CheckpointDiffBlobs.ts";
13
14const makeCheckpointDiffBlobRepository = Effect.gen(function* () {
15 const sql = yield* SqlClient.SqlClient;
16
17 const upsertDiffBlobRow = SqlSchema.void({
18 Request: CheckpointDiffBlob,
19 execute: (blob) =>
20 sql`
21 INSERT INTO checkpoint_diff_blobs (
22 thread_id,
23 from_turn_count,
24 to_turn_count,
25 diff,
26 created_at
27 )
28 VALUES (
29 ${blob.threadId},
30 ${blob.fromTurnCount},
31 ${blob.toTurnCount},
32 ${blob.diff},
33 ${blob.createdAt}
34 )
35 ON CONFLICT (thread_id, from_turn_count, to_turn_count)
36 DO UPDATE SET
37 diff = excluded.diff,
38 created_at = excluded.created_at
39 `,
40 });
41
42 const getDiffBlobRow = SqlSchema.findOneOption({
43 Request: GetCheckpointDiffBlobInput,
44 Result: CheckpointDiffBlob,
45 execute: (request) =>
46 sql`
47 SELECT
48 thread_id AS "threadId",
49 from_turn_count AS "fromTurnCount",
50 to_turn_count AS "toTurnCount",
51 diff,
52 created_at AS "createdAt"
53 FROM checkpoint_diff_blobs
54 WHERE thread_id = ${request.threadId}
55 AND from_turn_count = ${request.fromTurnCount}
56 AND to_turn_count = ${request.toTurnCount}
57 `,
58 });
59
60 const upsert: CheckpointDiffBlobRepositoryShape["upsert"] = (blob) =>
61 upsertDiffBlobRow(blob).pipe(
62 Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.upsert:query")),
63 );
64
65 const get: CheckpointDiffBlobRepositoryShape["get"] = (input) =>
66 getDiffBlobRow(input).pipe(
67 Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.get:query")),
68 );
69
70 return {
71 upsert,
72 get,
73 } satisfies CheckpointDiffBlobRepositoryShape;
74});
75
76export const CheckpointDiffBlobRepositoryLive = Layer.effect(
77 CheckpointDiffBlobRepository,
78 makeCheckpointDiffBlobRepository,
79);
apps/server/src/persistence/Layers/Checkpoints.test.ts deleted-115
......@@ -1,115 +0,0 @@
1import { it, assert } from "@effect/vitest";
2import { Effect, Layer, Option } from "effect";
3
4import { CheckpointRepository } from "../Services/Checkpoints.ts";
5import { CheckpointRepositoryLive } from "./Checkpoints.ts";
6import { SqlitePersistenceMemory } from "./Sqlite.ts";
7
8function makeCheckpointRepositoryLayer() {
9 return CheckpointRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory));
10}
11
12it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () =>
13 Effect.gen(function* () {
14 const repository = yield* CheckpointRepository;
15
16 yield* repository.upsertCheckpoint({
17 providerSessionId: "sess-1",
18 threadId: "thread-1",
19 checkpointId: "root",
20 checkpointRef: "refs/t3/checkpoints/thread-1/turn/0",
21 turnCount: 0,
22 messageCount: 0,
23 label: "Start of conversation",
24 createdAt: new Date().toISOString(),
25 });
26
27 yield* repository.upsertCheckpoint({
28 providerSessionId: "sess-1",
29 threadId: "thread-1",
30 checkpointId: "turn-1",
31 checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",
32 turnCount: 1,
33 messageCount: 2,
34 label: "Turn 1",
35 preview: "hello",
36 createdAt: new Date().toISOString(),
37 });
38
39 yield* repository.upsertCheckpoint({
40 providerSessionId: "sess-1",
41 threadId: "thread-1",
42 checkpointId: "turn-2",
43 checkpointRef: "refs/t3/checkpoints/thread-1/turn/2",
44 turnCount: 2,
45 messageCount: 4,
46 label: "Turn 2",
47 preview: "done",
48 createdAt: new Date().toISOString(),
49 });
50
51 const checkpoints = yield* repository.listCheckpoints({ threadId: "thread-1" });
52 assert.equal(checkpoints.length, 3);
53 assert.equal(checkpoints[0]?.id, "root");
54 assert.equal(checkpoints[0]?.isCurrent, false);
55 assert.equal(checkpoints[2]?.id, "turn-2");
56 assert.equal(checkpoints[2]?.isCurrent, true);
57
58 const one = yield* repository.getCheckpoint({ threadId: "thread-1", turnCount: 1 });
59 assert.equal(Option.isSome(one), true);
60 if (Option.isSome(one)) {
61 assert.equal(one.value.id, "turn-1");
62 assert.equal(one.value.isCurrent, false);
63 assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1");
64 }
65
66 yield* repository.upsertCheckpoint({
67 providerSessionId: "sess-2",
68 threadId: "thread-1",
69 checkpointId: "turn-3",
70 checkpointRef: "refs/t3/checkpoints/thread-1/turn/3",
71 turnCount: 3,
72 messageCount: 6,
73 label: "Turn 3",
74 preview: "new session",
75 createdAt: new Date().toISOString(),
76 });
77
78 const resumed = yield* repository.listCheckpoints({ threadId: "thread-1" });
79 assert.equal(resumed.length, 4);
80 assert.equal(resumed[3]?.id, "turn-3");
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())),
115);
apps/server/src/persistence/Layers/Checkpoints.ts deleted-359
......@@ -1,359 +0,0 @@
1/**
2 * CheckpointRepositoryLive - SQLite-backed checkpoint metadata repository layer.
3 *
4 * Persists and queries checkpoint metadata (`turnCount`, labels, previews,
5 * message counts) and the associated hidden Git ref for each turn.
6 *
7 * This layer does not read/write Git refs and does not perform provider-thread
8 * rollback. Those responsibilities remain in `CheckpointStore` and
9 * `CheckpointService`.
10 *
11 * @module CheckpointRepositoryLive
12 */
13import type { ProviderCheckpoint } from "@t3tools/contracts";
14import * as SqlClient from "effect/unstable/sql/SqlClient";
15import * as SqlSchema from "effect/unstable/sql/SqlSchema";
16import { Effect, Layer, Option, Schema } from "effect";
17
18import {
19 CheckpointRepositoryPersistenceError,
20 CheckpointRepositoryValidationError,
21 type CheckpointRepositoryError,
22} from "../Errors.ts";
23import {
24 CheckpointRepository,
25 type CheckpointRepositoryEntry,
26 type CheckpointRepositoryShape,
27} from "../Services/Checkpoints.ts";
28
29const NonEmptyString = Schema.NonEmptyString;
30
31const CheckpointRowSchema = Schema.Struct({
32 providerSessionId: Schema.NullOr(Schema.String),
33 threadId: Schema.String,
34 checkpointId: Schema.String,
35 checkpointRef: Schema.String,
36 turnCount: Schema.Number,
37 messageCount: Schema.Number,
38 label: Schema.String,
39 preview: Schema.NullOr(Schema.String),
40 createdAt: Schema.String,
41});
42
43const ThreadRequestSchema = Schema.Struct({
44 threadId: NonEmptyString,
45});
46
47const UpsertRequestSchema = Schema.Struct({
48 providerSessionId: Schema.NullOr(NonEmptyString),
49 threadId: NonEmptyString,
50 checkpointId: NonEmptyString,
51 checkpointRef: NonEmptyString,
52 turnCount: Schema.Int,
53 messageCount: Schema.Int,
54 label: NonEmptyString,
55 preview: Schema.NullOr(Schema.String),
56 createdAt: NonEmptyString,
57});
58
59const GetCheckpointRequestSchema = Schema.Struct({
60 threadId: NonEmptyString,
61 turnCount: Schema.Int,
62});
63
64const DeleteAfterTurnRequestSchema = Schema.Struct({
65 threadId: NonEmptyString,
66 maxTurnCount: Schema.Int,
67});
68
69function errorMessage(cause: unknown, fallback: string): string {
70 if (cause instanceof Error && cause.message.length > 0) {
71 return cause.message;
72 }
73 return fallback;
74}
75
76function toValidationError(operation: string, cause: unknown): CheckpointRepositoryValidationError {
77 return new CheckpointRepositoryValidationError({
78 operation,
79 issue: errorMessage(cause, "Invalid checkpoint repository input."),
80 cause,
81 });
82}
83
84function decodeInput<A>(
85 schema: Schema.Schema<A>,
86 input: unknown,
87 operation: string,
88): Effect.Effect<A, CheckpointRepositoryValidationError> {
89 return Schema.decodeUnknownEffect(schema)(input).pipe(
90 Effect.mapError((cause) => toValidationError(operation, cause)),
91 ) as Effect.Effect<A, CheckpointRepositoryValidationError>;
92}
93
94function ensureNonNegativeInt(
95 operation: string,
96 field: string,
97 value: number,
98): Effect.Effect<number, CheckpointRepositoryValidationError> {
99 if (value < 0) {
100 return Effect.fail(
101 new CheckpointRepositoryValidationError({
102 operation,
103 issue: `${field} must be an integer >= 0.`,
104 }),
105 );
106 }
107 return Effect.succeed(value);
108}
109
110function toPersistenceError(
111 operation: string,
112 cause: unknown,
113): CheckpointRepositoryPersistenceError {
114 return new CheckpointRepositoryPersistenceError({
115 operation,
116 detail: `Failed to execute ${operation}.`,
117 cause,
118 });
119}
120
121function toCheckpointEntry(
122 row: Schema.Schema.Type<typeof CheckpointRowSchema>,
123 isCurrent: boolean,
124): CheckpointRepositoryEntry {
125 return {
126 id: row.checkpointId,
127 threadId: row.threadId,
128 checkpointRef: row.checkpointRef,
129 turnCount: row.turnCount,
130 messageCount: row.messageCount,
131 label: row.label,
132 ...(row.preview !== null ? { preview: row.preview } : {}),
133 createdAt: row.createdAt,
134 isCurrent,
135 };
136}
137
138function toProviderCheckpoint(entry: CheckpointRepositoryEntry): ProviderCheckpoint {
139 return {
140 id: entry.id,
141 turnCount: entry.turnCount,
142 messageCount: entry.messageCount,
143 label: entry.label,
144 ...(entry.preview !== undefined ? { preview: entry.preview } : {}),
145 isCurrent: entry.isCurrent,
146 };
147}
148
149const makeCheckpointRepository = Effect.gen(function* () {
150 const sql = yield* SqlClient.SqlClient;
151
152 const upsertCheckpointRow = SqlSchema.findOne({
153 Request: UpsertRequestSchema,
154 Result: CheckpointRowSchema,
155 execute: (request) =>
156 sql`
157 INSERT INTO provider_checkpoints (
158 provider_session_id,
159 thread_id,
160 checkpoint_id,
161 checkpoint_ref,
162 turn_count,
163 message_count,
164 label,
165 preview,
166 created_at
167 )
168 VALUES (
169 ${request.providerSessionId},
170 ${request.threadId},
171 ${request.checkpointId},
172 ${request.checkpointRef},
173 ${request.turnCount},
174 ${request.messageCount},
175 ${request.label},
176 ${request.preview},
177 ${request.createdAt}
178 )
179 ON CONFLICT (thread_id, turn_count)
180 DO UPDATE SET
181 provider_session_id = excluded.provider_session_id,
182 thread_id = excluded.thread_id,
183 checkpoint_id = excluded.checkpoint_id,
184 checkpoint_ref = excluded.checkpoint_ref,
185 message_count = excluded.message_count,
186 label = excluded.label,
187 preview = excluded.preview,
188 created_at = excluded.created_at
189 RETURNING
190 provider_session_id AS "providerSessionId",
191 thread_id AS "threadId",
192 checkpoint_id AS "checkpointId",
193 checkpoint_ref AS "checkpointRef",
194 turn_count AS "turnCount",
195 message_count AS "messageCount",
196 label,
197 preview,
198 created_at AS "createdAt"
199 `,
200 });
201
202 const listCheckpointRows = SqlSchema.findAll({
203 Request: ThreadRequestSchema,
204 Result: CheckpointRowSchema,
205 execute: ({ threadId }) =>
206 sql`
207 SELECT
208 provider_session_id AS "providerSessionId",
209 thread_id AS "threadId",
210 checkpoint_id AS "checkpointId",
211 checkpoint_ref AS "checkpointRef",
212 turn_count AS "turnCount",
213 message_count AS "messageCount",
214 label,
215 preview,
216 created_at AS "createdAt"
217 FROM provider_checkpoints
218 WHERE thread_id = ${threadId}
219 ORDER BY turn_count ASC
220 `,
221 });
222
223 const listForThread = (
224 threadId: string,
225 ): Effect.Effect<ReadonlyArray<CheckpointRepositoryEntry>, CheckpointRepositoryError> =>
226 listCheckpointRows({ threadId }).pipe(
227 Effect.mapError((cause) =>
228 toPersistenceError("CheckpointRepository.listCheckpoints:query", cause),
229 ),
230 Effect.map((rows) => {
231 const currentTurnCount = rows[rows.length - 1]?.turnCount;
232 return rows.map((row) => toCheckpointEntry(row, row.turnCount === currentTurnCount));
233 }),
234 );
235
236 const upsertCheckpoint: CheckpointRepositoryShape["upsertCheckpoint"] = (input) =>
237 Effect.gen(function* () {
238 const operation = "CheckpointRepository.upsertCheckpoint";
239 const parsed = yield* decodeInput(
240 UpsertRequestSchema,
241 {
242 providerSessionId: input.providerSessionId?.trim() || null,
243 threadId: input.threadId.trim(),
244 checkpointId: input.checkpointId.trim(),
245 checkpointRef: input.checkpointRef.trim(),
246 turnCount: input.turnCount,
247 messageCount: input.messageCount,
248 label: input.label.trim(),
249 preview: input.preview?.trim() || null,
250 createdAt: input.createdAt.trim(),
251 },
252 operation,
253 );
254 yield* ensureNonNegativeInt(operation, "turnCount", parsed.turnCount);
255 yield* ensureNonNegativeInt(operation, "messageCount", parsed.messageCount);
256
257 yield* upsertCheckpointRow(parsed).pipe(
258 Effect.mapError((cause) =>
259 toPersistenceError("CheckpointRepository.upsertCheckpoint:query", cause),
260 ),
261 Effect.asVoid,
262 );
263 });
264
265 const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) =>
266 Effect.gen(function* () {
267 const parsed = yield* decodeInput(
268 ThreadRequestSchema,
269 {
270 threadId: input.threadId.trim(),
271 },
272 "CheckpointRepository.listCheckpoints",
273 );
274
275 const entries = yield* listForThread(parsed.threadId);
276 return entries.map(toProviderCheckpoint);
277 });
278
279 const getCheckpoint: CheckpointRepositoryShape["getCheckpoint"] = (input) =>
280 Effect.gen(function* () {
281 const parsed = yield* decodeInput(
282 GetCheckpointRequestSchema,
283 {
284 threadId: input.threadId.trim(),
285 turnCount: input.turnCount,
286 },
287 "CheckpointRepository.getCheckpoint",
288 );
289 yield* ensureNonNegativeInt(
290 "CheckpointRepository.getCheckpoint",
291 "turnCount",
292 parsed.turnCount,
293 );
294
295 const entries = yield* listForThread(parsed.threadId);
296 const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount);
297 return Option.fromNullishOr(checkpoint);
298 });
299
300 const deleteAfterTurn: CheckpointRepositoryShape["deleteAfterTurn"] = (input) =>
301 Effect.gen(function* () {
302 const parsed = yield* decodeInput(
303 DeleteAfterTurnRequestSchema,
304 {
305 threadId: input.threadId.trim(),
306 maxTurnCount: input.maxTurnCount,
307 },
308 "CheckpointRepository.deleteAfterTurn",
309 );
310 yield* ensureNonNegativeInt(
311 "CheckpointRepository.deleteAfterTurn",
312 "maxTurnCount",
313 parsed.maxTurnCount,
314 );
315
316 yield* sql`
317 DELETE FROM provider_checkpoints
318 WHERE thread_id = ${parsed.threadId}
319 AND turn_count > ${parsed.maxTurnCount}
320 `.pipe(
321 Effect.mapError((cause) =>
322 toPersistenceError("CheckpointRepository.deleteAfterTurn:query", cause),
323 ),
324 );
325 });
326
327 const deleteAllForThread: CheckpointRepositoryShape["deleteAllForThread"] = (input) =>
328 Effect.gen(function* () {
329 const parsed = yield* decodeInput(
330 ThreadRequestSchema,
331 {
332 threadId: input.threadId.trim(),
333 },
334 "CheckpointRepository.deleteAllForThread",
335 );
336
337 yield* sql`
338 DELETE FROM provider_checkpoints
339 WHERE thread_id = ${parsed.threadId}
340 `.pipe(
341 Effect.mapError((cause) =>
342 toPersistenceError("CheckpointRepository.deleteAllForThread:query", cause),
343 ),
344 );
345 });
346
347 return {
348 upsertCheckpoint,
349 listCheckpoints,
350 getCheckpoint,
351 deleteAfterTurn,
352 deleteAllForThread,
353 } satisfies CheckpointRepositoryShape;
354});
355
356export const CheckpointRepositoryLive = Layer.effect(
357 CheckpointRepository,
358 makeCheckpointRepository,
359);
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts created+89
......@@ -0,0 +1,89 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 GetByCommandIdInput,
9 OrchestrationCommandReceipt,
10 OrchestrationCommandReceiptRepository,
11 type OrchestrationCommandReceiptRepositoryShape,
12} from "../Services/OrchestrationCommandReceipts.ts";
13
14const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () {
15 const sql = yield* SqlClient.SqlClient;
16
17 const upsertReceiptRow = SqlSchema.void({
18 Request: OrchestrationCommandReceipt,
19 execute: (receipt) =>
20 sql`
21 INSERT INTO orchestration_command_receipts (
22 command_id,
23 aggregate_kind,
24 aggregate_id,
25 accepted_at,
26 result_sequence,
27 status,
28 error
29 )
30 VALUES (
31 ${receipt.commandId},
32 ${receipt.aggregateKind},
33 ${receipt.aggregateId},
34 ${receipt.acceptedAt},
35 ${receipt.resultSequence},
36 ${receipt.status},
37 ${receipt.error}
38 )
39 ON CONFLICT (command_id)
40 DO UPDATE SET
41 aggregate_kind = excluded.aggregate_kind,
42 aggregate_id = excluded.aggregate_id,
43 accepted_at = excluded.accepted_at,
44 result_sequence = excluded.result_sequence,
45 status = excluded.status,
46 error = excluded.error
47 `,
48 });
49
50 const findReceiptByCommandId = SqlSchema.findOneOption({
51 Request: GetByCommandIdInput,
52 Result: OrchestrationCommandReceipt,
53 execute: ({ commandId }) =>
54 sql`
55 SELECT
56 command_id AS "commandId",
57 aggregate_kind AS "aggregateKind",
58 aggregate_id AS "aggregateId",
59 accepted_at AS "acceptedAt",
60 result_sequence AS "resultSequence",
61 status,
62 error
63 FROM orchestration_command_receipts
64 WHERE command_id = ${commandId}
65 `,
66 });
67
68 const upsert: OrchestrationCommandReceiptRepositoryShape["upsert"] = (receipt) =>
69 upsertReceiptRow(receipt).pipe(
70 Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")),
71 );
72
73 const getByCommandId: OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = (input) =>
74 findReceiptByCommandId(input).pipe(
75 Effect.mapError(
76 toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"),
77 ),
78 );
79
80 return {
81 upsert,
82 getByCommandId,
83 } satisfies OrchestrationCommandReceiptRepositoryShape;
84});
85
86export const OrchestrationCommandReceiptRepositoryLive = Layer.effect(
87 OrchestrationCommandReceiptRepository,
88 makeOrchestrationCommandReceiptRepository,
89);
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+102-22
......@@ -1,39 +1,119 @@
1import { Effect, Layer, Stream } from "effect";
1import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
22import { assert, it } from "@effect/vitest";
3import { Effect, Layer, Stream } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
35
6import { PersistenceDecodeError } from "../Errors.ts";
47import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts";
5import { SqlitePersistenceMemory } from "./Sqlite.ts";
68import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts";
7import { NodeServices } from "@effect/platform-node";
9import { SqlitePersistenceMemory } from "./Sqlite.ts";
810
911const layer = it.layer(
10 Layer.mergeAll(
11 Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory),
12 NodeServices.layer,
13 ),
12 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
1413);
1514
1615layer("OrchestrationEventStore", (it) => {
17 it.effect("stores and replays events", () =>
16 it.effect("stores json columns as strings and replays decoded events", () =>
1817 Effect.gen(function* () {
19 const createdAt = new Date().toISOString();
20
2118 const eventStore = yield* OrchestrationEventStore;
22 const saved = yield* eventStore.append({
23 eventId: "event-1",
24 type: "thread.created",
25 aggregateType: "thread",
26 aggregateId: "thread-1",
27 occurredAt: createdAt,
28 commandId: "cmd-1",
29 payload: { id: "thread-1", projectId: "project-1", title: "demo" },
19 const sql = yield* SqlClient.SqlClient;
20 const now = new Date().toISOString();
21
22 const appended = yield* eventStore.append({
23 type: "project.created",
24 eventId: EventId.makeUnsafe("evt-store-roundtrip"),
25 aggregateKind: "project",
26 aggregateId: ProjectId.makeUnsafe("project-roundtrip"),
27 occurredAt: now,
28 commandId: CommandId.makeUnsafe("cmd-store-roundtrip"),
29 causationEventId: null,
30 correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"),
31 metadata: {
32 adapterKey: "codex",
33 },
34 payload: {
35 projectId: ProjectId.makeUnsafe("project-roundtrip"),
36 title: "Roundtrip Project",
37 workspaceRoot: "/tmp/project-roundtrip",
38 defaultModel: null,
39 scripts: [],
40 createdAt: now,
41 updatedAt: now,
42 },
3043 });
31 assert.equal(saved.sequence, 1);
3244
33 const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe(
34 Effect.map(Array.from),
45 const storedRows = yield* sql<{
46 readonly payloadJson: string;
47 readonly metadataJson: string;
48 }>`
49 SELECT
50 payload_json AS "payloadJson",
51 metadata_json AS "metadataJson"
52 FROM orchestration_events
53 WHERE event_id = ${appended.eventId}
54 `;
55 assert.equal(storedRows.length, 1);
56 assert.equal(typeof storedRows[0]?.payloadJson, "string");
57 assert.equal(typeof storedRows[0]?.metadataJson, "string");
58
59 const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe(
60 Effect.map((chunk) => Array.from(chunk)),
61 );
62 assert.equal(replayed.length, 1);
63 assert.equal(replayed[0]?.type, "project.created");
64 assert.equal(replayed[0]?.metadata.adapterKey, "codex");
65 }),
66 );
67
68 it.effect("fails with PersistenceDecodeError when stored json is invalid", () =>
69 Effect.gen(function* () {
70 const eventStore = yield* OrchestrationEventStore;
71 const sql = yield* SqlClient.SqlClient;
72 const now = new Date().toISOString();
73
74 yield* sql`
75 INSERT INTO orchestration_events (
76 event_id,
77 aggregate_kind,
78 stream_id,
79 stream_version,
80 event_type,
81 occurred_at,
82 command_id,
83 causation_event_id,
84 correlation_id,
85 actor_kind,
86 payload_json,
87 metadata_json
88 )
89 VALUES (
90 ${EventId.makeUnsafe("evt-store-invalid-json")},
91 ${"project"},
92 ${ProjectId.makeUnsafe("project-invalid-json")},
93 ${0},
94 ${"project.created"},
95 ${now},
96 ${CommandId.makeUnsafe("cmd-store-invalid-json")},
97 ${null},
98 ${null},
99 ${"server"},
100 ${"{"},
101 ${"{}"}
102 )
103 `;
104
105 const replayResult = yield* Effect.result(
106 Stream.runCollect(eventStore.readFromSequence(0, 10)),
35107 );
36 assert.deepEqual(replayed, [saved]);
108 assert.equal(replayResult._tag, "Failure");
109 if (replayResult._tag === "Failure") {
110 assert.ok(replayResult.failure instanceof PersistenceDecodeError);
111 assert.ok(
112 replayResult.failure.operation.includes(
113 "OrchestrationEventStore.readFromSequence:decodeRows",
114 ),
115 );
116 }
37117 }),
38118 );
39119});
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+149-87
......@@ -1,13 +1,22 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";
2import { OrchestrationEventSchema } from "@t3tools/contracts";
1import {
2 CommandId,
3 EventId,
4 IsoDateTime,
5 NonNegativeInt,
6 OrchestrationActorKind,
7 OrchestrationAggregateKind,
8 OrchestrationEvent,
9 OrchestrationEventMetadata,
10 OrchestrationEventType,
11 ProjectId,
12 ThreadId,
13} from "@t3tools/contracts";
314import * as SqlClient from "effect/unstable/sql/SqlClient";
415import * as SqlSchema from "effect/unstable/sql/SqlSchema";
516import { Effect, Layer, Schema, Stream } from "effect";
617
718import {
8 toPersistenceDecodeCauseError,
919 toPersistenceDecodeError,
10 toPersistenceSerializationError,
1120 toPersistenceSqlError,
1221 type OrchestrationEventStoreError,
1322} from "../Errors.ts";
......@@ -16,57 +25,77 @@ import {
1625 type OrchestrationEventStoreShape,
1726} from "../Services/OrchestrationEventStore.ts";
1827
19const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema);
28const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent);
29const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
30const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata);
2031
21const EventRowSchema = Schema.Struct({
22 sequence: Schema.Number,
23 eventId: Schema.String,
24 type: Schema.String,
25 aggregateType: Schema.String,
26 aggregateId: Schema.String,
27 occurredAt: Schema.String,
28 commandId: Schema.NullOr(Schema.String),
29 payloadJson: Schema.String,
32const AppendEventRequestSchema = Schema.Struct({
33 eventId: EventId,
34 aggregateKind: OrchestrationAggregateKind,
35 streamId: Schema.Union([ProjectId, ThreadId]),
36 type: OrchestrationEventType,
37 causationEventId: Schema.NullOr(EventId),
38 correlationId: Schema.NullOr(CommandId),
39 actorKind: OrchestrationActorKind,
40 occurredAt: IsoDateTime,
41 commandId: Schema.NullOr(CommandId),
42 payloadJson: UnknownFromJsonString,
43 metadataJson: EventMetadataFromJsonString,
3044});
3145
32const AppendEventRequestSchema = Schema.Struct({
33 eventId: Schema.String,
34 type: Schema.String,
35 aggregateType: Schema.String,
36 aggregateId: Schema.String,
37 occurredAt: Schema.String,
38 commandId: Schema.NullOr(Schema.String),
39 payloadJson: Schema.String,
46const OrchestrationEventPersistedRowSchema = Schema.Struct({
47 sequence: NonNegativeInt,
48 eventId: EventId,
49 type: OrchestrationEventType,
50 aggregateKind: OrchestrationAggregateKind,
51 aggregateId: Schema.Union([ProjectId, ThreadId]),
52 occurredAt: IsoDateTime,
53 commandId: Schema.NullOr(CommandId),
54 causationEventId: Schema.NullOr(EventId),
55 correlationId: Schema.NullOr(CommandId),
56 payload: UnknownFromJsonString,
57 metadata: EventMetadataFromJsonString,
4058});
4159
4260const ReadFromSequenceRequestSchema = Schema.Struct({
43 sequenceExclusive: Schema.Number,
61 sequenceExclusive: NonNegativeInt,
4462 limit: Schema.Number,
4563});
4664const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;
4765const READ_PAGE_SIZE = 500;
4866
49function eventRowToOrchestrationEvent(
50 row: Schema.Schema.Type<typeof EventRowSchema>,
51 operation: string,
52): Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError> {
53 return Effect.try({
54 try: () => JSON.parse(row.payloadJson) as unknown,
55 catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`),
56 }).pipe(
57 Effect.flatMap((payload) =>
58 decodeEvent({
59 sequence: row.sequence,
60 eventId: row.eventId,
61 type: row.type,
62 aggregateType: row.aggregateType,
63 aggregateId: row.aggregateId,
64 occurredAt: row.occurredAt,
65 commandId: row.commandId,
66 payload,
67 }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))),
68 ),
69 );
67function inferActorKind(
68 event: Omit<OrchestrationEvent, "sequence">,
69): Schema.Schema.Type<typeof OrchestrationActorKind> {
70 if (event.commandId !== null && event.commandId.startsWith("provider:")) {
71 return "provider";
72 }
73 if (event.commandId !== null && event.commandId.startsWith("server:")) {
74 return "server";
75 }
76 if (
77 event.metadata.providerSessionId !== undefined ||
78 event.metadata.providerThreadId !== undefined ||
79 event.metadata.providerTurnId !== undefined ||
80 event.metadata.providerItemId !== undefined ||
81 event.metadata.adapterKey !== undefined
82 ) {
83 return "provider";
84 }
85 if (event.commandId === null) {
86 return "server";
87 }
88 return "client";
89}
90
91function toPersistenceSqlOrDecodeError(
92 sqlOperation: string,
93 decodeOperation: string,
94) {
95 return (cause: unknown): OrchestrationEventStoreError =>
96 Schema.isSchemaError(cause)
97 ? toPersistenceDecodeError(decodeOperation)(cause)
98 : toPersistenceSqlError(sqlOperation)(cause);
7099}
71100
72101const makeEventStore = Effect.gen(function* () {
......@@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () {
74103
75104 const appendEventRow = SqlSchema.findOne({
76105 Request: AppendEventRequestSchema,
77 Result: EventRowSchema,
106 Result: OrchestrationEventPersistedRowSchema,
78107 execute: (request) =>
79108 sql`
80109 INSERT INTO orchestration_events (
81110 event_id,
111 aggregate_kind,
112 stream_id,
113 stream_version,
82114 event_type,
83 aggregate_type,
84 aggregate_id,
85115 occurred_at,
86116 command_id,
87 payload_json
117 causation_event_id,
118 correlation_id,
119 actor_kind,
120 payload_json,
121 metadata_json
88122 )
89123 VALUES (
90124 ${request.eventId},
125 ${request.aggregateKind},
126 ${request.streamId},
127 COALESCE(
128 (
129 SELECT stream_version + 1
130 FROM orchestration_events
131 WHERE aggregate_kind = ${request.aggregateKind}
132 AND stream_id = ${request.streamId}
133 ORDER BY stream_version DESC
134 LIMIT 1
135 ),
136 0
137 ),
91138 ${request.type},
92 ${request.aggregateType},
93 ${request.aggregateId},
94139 ${request.occurredAt},
95140 ${request.commandId},
96 ${request.payloadJson}
141 ${request.causationEventId},
142 ${request.correlationId},
143 ${request.actorKind},
144 ${request.payloadJson},
145 ${request.metadataJson}
97146 )
98147 RETURNING
99148 sequence,
100149 event_id AS "eventId",
101150 event_type AS "type",
102 aggregate_type AS "aggregateType",
103 aggregate_id AS "aggregateId",
151 aggregate_kind AS "aggregateKind",
152 stream_id AS "aggregateId",
104153 occurred_at AS "occurredAt",
105154 command_id AS "commandId",
106 payload_json AS "payloadJson"
155 causation_event_id AS "causationEventId",
156 correlation_id AS "correlationId",
157 payload_json AS "payload",
158 metadata_json AS "metadata"
107159 `,
108160 });
109161
110162 const readEventRowsFromSequence = SqlSchema.findAll({
111163 Request: ReadFromSequenceRequestSchema,
112 Result: EventRowSchema,
164 Result: OrchestrationEventPersistedRowSchema,
113165 execute: (request) =>
114166 sql`
115167 SELECT
116168 sequence,
117169 event_id AS "eventId",
118170 event_type AS "type",
119 aggregate_type AS "aggregateType",
120 aggregate_id AS "aggregateId",
171 aggregate_kind AS "aggregateKind",
172 stream_id AS "aggregateId",
121173 occurred_at AS "occurredAt",
122174 command_id AS "commandId",
123 payload_json AS "payloadJson"
175 causation_event_id AS "causationEventId",
176 correlation_id AS "correlationId",
177 payload_json AS "payload",
178 metadata_json AS "metadata"
124179 FROM orchestration_events
125180 WHERE sequence > ${request.sequenceExclusive}
126181 ORDER BY sequence ASC
......@@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () {
129184 });
130185
131186 const append: OrchestrationEventStoreShape["append"] = (event) =>
132 Effect.try({
133 try: () => JSON.stringify(event.payload),
134 catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"),
187 appendEventRow({
188 eventId: event.eventId,
189 aggregateKind: event.aggregateKind,
190 streamId: event.aggregateId,
191 type: event.type,
192 causationEventId: event.causationEventId,
193 correlationId: event.correlationId,
194 actorKind: inferActorKind(event),
195 occurredAt: event.occurredAt,
196 commandId: event.commandId,
197 payloadJson: event.payload,
198 metadataJson: event.metadata,
135199 }).pipe(
136 Effect.flatMap((payloadJson) =>
137 appendEventRow({
138 eventId: event.eventId,
139 type: event.type,
140 aggregateType: event.aggregateType,
141 aggregateId: event.aggregateId,
142 occurredAt: event.occurredAt,
143 commandId: event.commandId,
144 payloadJson,
145 }).pipe(
146 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")),
147 Effect.flatMap((row) =>
148 eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"),
149 ),
200 Effect.mapError(
201 toPersistenceSqlOrDecodeError(
202 "OrchestrationEventStore.append:insert",
203 "OrchestrationEventStore.append:decodeRow",
204 ),
205 ),
206 Effect.flatMap((row) =>
207 decodeEvent(row).pipe(
208 Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")),
150209 ),
151210 ),
152211 );
......@@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () {
159218 if (normalizedLimit === 0) {
160219 return Stream.empty;
161220 }
162 const readPage = (cursor: number, remaining: number): Stream.Stream<
163 OrchestrationEvent,
164 OrchestrationEventStoreError
165 > =>
221 const readPage = (
222 cursor: number,
223 remaining: number,
224 ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> =>
166225 Stream.fromEffect(
167226 readEventRowsFromSequence({
168227 sequenceExclusive: cursor,
169228 limit: Math.min(remaining, READ_PAGE_SIZE),
170229 }).pipe(
171 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readFromSequence:query")),
230 Effect.mapError(
231 toPersistenceSqlOrDecodeError(
232 "OrchestrationEventStore.readFromSequence:query",
233 "OrchestrationEventStore.readFromSequence:decodeRows",
234 ),
235 ),
172236 Effect.flatMap((rows) =>
173237 Effect.forEach(rows, (row) =>
174 eventRowToOrchestrationEvent(
175 row,
176 "OrchestrationEventStore.readFromSequence:rowToEvent",
238 decodeEvent(row).pipe(
239 Effect.mapError(
240 toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"),
241 ),
177242 ),
178243 ),
179244 ),
......@@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () {
204269 } satisfies OrchestrationEventStoreShape;
205270});
206271
207export const OrchestrationEventStoreLive = Layer.effect(
208 OrchestrationEventStore,
209 makeEventStore,
210);
272export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore);
apps/server/src/persistence/Layers/ProjectionCheckpoints.ts created+177
......@@ -0,0 +1,177 @@
1import { OrchestrationCheckpointFile } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";
5
6import {
7 toPersistenceDecodeError,
8 toPersistenceSqlError,
9} from "../Errors.ts";
10import {
11 DeleteByThreadIdInput,
12 GetByThreadAndTurnCountInput,
13 ListByThreadIdInput,
14 ProjectionCheckpoint,
15 ProjectionCheckpointRepository,
16 type ProjectionCheckpointRepositoryShape,
17} from "../Services/ProjectionCheckpoints.ts";
18
19const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
20 Struct.assign({
21 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
22 }),
23);
24
25function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
26 return (cause: unknown) =>
27 Schema.isSchemaError(cause)
28 ? toPersistenceDecodeError(decodeOperation)(cause)
29 : toPersistenceSqlError(sqlOperation)(cause);
30}
31
32const makeProjectionCheckpointRepository = Effect.gen(function* () {
33 const sql = yield* SqlClient.SqlClient;
34
35 const upsertProjectionCheckpointRow = SqlSchema.void({
36 Request: ProjectionCheckpointDbRowSchema,
37 execute: (row) =>
38 sql`
39 INSERT INTO projection_checkpoints (
40 thread_id,
41 turn_id,
42 checkpoint_turn_count,
43 checkpoint_ref,
44 status,
45 files_json,
46 assistant_message_id,
47 completed_at
48 )
49 VALUES (
50 ${row.threadId},
51 ${row.turnId},
52 ${row.checkpointTurnCount},
53 ${row.checkpointRef},
54 ${row.status},
55 ${row.files},
56 ${row.assistantMessageId},
57 ${row.completedAt}
58 )
59 ON CONFLICT (thread_id, checkpoint_turn_count)
60 DO UPDATE SET
61 turn_id = excluded.turn_id,
62 checkpoint_ref = excluded.checkpoint_ref,
63 status = excluded.status,
64 files_json = excluded.files_json,
65 assistant_message_id = excluded.assistant_message_id,
66 completed_at = excluded.completed_at
67 `,
68 });
69
70 const listProjectionCheckpointRows = SqlSchema.findAll({
71 Request: ListByThreadIdInput,
72 Result: ProjectionCheckpointDbRowSchema,
73 execute: ({ threadId }) =>
74 sql`
75 SELECT
76 thread_id AS "threadId",
77 turn_id AS "turnId",
78 checkpoint_turn_count AS "checkpointTurnCount",
79 checkpoint_ref AS "checkpointRef",
80 status,
81 files_json AS "files",
82 assistant_message_id AS "assistantMessageId",
83 completed_at AS "completedAt"
84 FROM projection_checkpoints
85 WHERE thread_id = ${threadId}
86 ORDER BY checkpoint_turn_count ASC
87 `,
88 });
89
90 const getProjectionCheckpointRow = SqlSchema.findOneOption({
91 Request: GetByThreadAndTurnCountInput,
92 Result: ProjectionCheckpointDbRowSchema,
93 execute: ({ threadId, checkpointTurnCount }) =>
94 sql`
95 SELECT
96 thread_id AS "threadId",
97 turn_id AS "turnId",
98 checkpoint_turn_count AS "checkpointTurnCount",
99 checkpoint_ref AS "checkpointRef",
100 status,
101 files_json AS "files",
102 assistant_message_id AS "assistantMessageId",
103 completed_at AS "completedAt"
104 FROM projection_checkpoints
105 WHERE thread_id = ${threadId}
106 AND checkpoint_turn_count = ${checkpointTurnCount}
107 `,
108 });
109
110 const deleteProjectionCheckpointRows = SqlSchema.void({
111 Request: DeleteByThreadIdInput,
112 execute: ({ threadId }) =>
113 sql`
114 DELETE FROM projection_checkpoints
115 WHERE thread_id = ${threadId}
116 `,
117 });
118
119 const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) =>
120 upsertProjectionCheckpointRow(row).pipe(
121 Effect.mapError(
122 toPersistenceSqlOrDecodeError(
123 "ProjectionCheckpointRepository.upsert:query",
124 "ProjectionCheckpointRepository.upsert:encodeRequest",
125 ),
126 ),
127 );
128
129 const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) =>
130 listProjectionCheckpointRows(input).pipe(
131 Effect.mapError(
132 toPersistenceSqlOrDecodeError(
133 "ProjectionCheckpointRepository.listByThreadId:query",
134 "ProjectionCheckpointRepository.listByThreadId:decodeRows",
135 ),
136 ),
137 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionCheckpoint>>),
138 );
139
140 const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = (
141 input,
142 ) =>
143 getProjectionCheckpointRow(input).pipe(
144 Effect.mapError(
145 toPersistenceSqlOrDecodeError(
146 "ProjectionCheckpointRepository.getByThreadAndTurnCount:query",
147 "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow",
148 ),
149 ),
150 Effect.flatMap((rowOption) =>
151 Option.match(rowOption, {
152 onNone: () => Effect.succeed(Option.none()),
153 onSome: (row) =>
154 Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionCheckpoint>)),
155 }),
156 ),
157 );
158
159 const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) =>
160 deleteProjectionCheckpointRows(input).pipe(
161 Effect.mapError(
162 toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"),
163 ),
164 );
165
166 return {
167 upsert,
168 listByThreadId,
169 getByThreadAndTurnCount,
170 deleteByThreadId,
171 } satisfies ProjectionCheckpointRepositoryShape;
172});
173
174export const ProjectionCheckpointRepositoryLive = Layer.effect(
175 ProjectionCheckpointRepository,
176 makeProjectionCheckpointRepository,
177);
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts created+136
......@@ -0,0 +1,136 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 GetProjectionPendingApprovalInput,
8 DeleteProjectionPendingApprovalInput,
9 ListProjectionPendingApprovalsInput,
10 ProjectionPendingApproval,
11 ProjectionPendingApprovalRepository,
12 type ProjectionPendingApprovalRepositoryShape,
13} from "../Services/ProjectionPendingApprovals.ts";
14
15const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 const upsertProjectionPendingApprovalRow = SqlSchema.void({
19 Request: ProjectionPendingApproval,
20 execute: (row) =>
21 sql`
22 INSERT INTO projection_pending_approvals (
23 request_id,
24 thread_id,
25 turn_id,
26 status,
27 decision,
28 created_at,
29 resolved_at
30 )
31 VALUES (
32 ${row.requestId},
33 ${row.threadId},
34 ${row.turnId},
35 ${row.status},
36 ${row.decision},
37 ${row.createdAt},
38 ${row.resolvedAt}
39 )
40 ON CONFLICT (request_id)
41 DO UPDATE SET
42 thread_id = excluded.thread_id,
43 turn_id = excluded.turn_id,
44 status = excluded.status,
45 decision = excluded.decision,
46 created_at = excluded.created_at,
47 resolved_at = excluded.resolved_at
48 `,
49 });
50
51 const listProjectionPendingApprovalRows = SqlSchema.findAll({
52 Request: ListProjectionPendingApprovalsInput,
53 Result: ProjectionPendingApproval,
54 execute: ({ threadId }) =>
55 sql`
56 SELECT
57 request_id AS "requestId",
58 thread_id AS "threadId",
59 turn_id AS "turnId",
60 status,
61 decision,
62 created_at AS "createdAt",
63 resolved_at AS "resolvedAt"
64 FROM projection_pending_approvals
65 WHERE thread_id = ${threadId}
66 ORDER BY created_at ASC, request_id ASC
67 `,
68 });
69
70 const getProjectionPendingApprovalRow = SqlSchema.findOneOption({
71 Request: GetProjectionPendingApprovalInput,
72 Result: ProjectionPendingApproval,
73 execute: ({ requestId }) =>
74 sql`
75 SELECT
76 request_id AS "requestId",
77 thread_id AS "threadId",
78 turn_id AS "turnId",
79 status,
80 decision,
81 created_at AS "createdAt",
82 resolved_at AS "resolvedAt"
83 FROM projection_pending_approvals
84 WHERE request_id = ${requestId}
85 `,
86 });
87
88 const deleteProjectionPendingApprovalRow = SqlSchema.void({
89 Request: DeleteProjectionPendingApprovalInput,
90 execute: ({ requestId }) =>
91 sql`
92 DELETE FROM projection_pending_approvals
93 WHERE request_id = ${requestId}
94 `,
95 });
96
97 const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) =>
98 upsertProjectionPendingApprovalRow(row).pipe(
99 Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")),
100 );
101
102 const listByThreadId: ProjectionPendingApprovalRepositoryShape["listByThreadId"] = (input) =>
103 listProjectionPendingApprovalRows(input).pipe(
104 Effect.mapError(
105 toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"),
106 ),
107 );
108
109 const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) =>
110 getProjectionPendingApprovalRow(input).pipe(
111 Effect.mapError(
112 toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"),
113 ),
114 );
115
116 const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = (
117 input,
118 ) =>
119 deleteProjectionPendingApprovalRow(input).pipe(
120 Effect.mapError(
121 toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"),
122 ),
123 );
124
125 return {
126 upsert,
127 listByThreadId,
128 getByRequestId,
129 deleteByRequestId,
130 } satisfies ProjectionPendingApprovalRepositoryShape;
131});
132
133export const ProjectionPendingApprovalRepositoryLive = Layer.effect(
134 ProjectionPendingApprovalRepository,
135 makeProjectionPendingApprovalRepository,
136);
apps/server/src/persistence/Layers/ProjectionProjects.ts created+168
......@@ -0,0 +1,168 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Option, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 DeleteProjectionProjectInput,
9 GetProjectionProjectInput,
10 ProjectionProject,
11 ProjectionProjectRepository,
12 type ProjectionProjectRepositoryShape,
13} from "../Services/ProjectionProjects.ts";
14import { ProjectScript } from "@t3tools/contracts";
15
16// Makes sure that the scripts are parsed from the JSON string the DB returns
17const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
18 Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }),
19);
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
27
28const makeProjectionProjectRepository = Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;
30
31 const upsertProjectionProjectRow = SqlSchema.void({
32 Request: ProjectionProjectDbRowSchema,
33 execute: (row) =>
34 sql`
35 INSERT INTO projection_projects (
36 project_id,
37 title,
38 workspace_root,
39 default_model,
40 scripts_json,
41 created_at,
42 updated_at,
43 deleted_at
44 )
45 VALUES (
46 ${row.projectId},
47 ${row.title},
48 ${row.workspaceRoot},
49 ${row.defaultModel},
50 ${row.scripts},
51 ${row.createdAt},
52 ${row.updatedAt},
53 ${row.deletedAt}
54 )
55 ON CONFLICT (project_id)
56 DO UPDATE SET
57 title = excluded.title,
58 workspace_root = excluded.workspace_root,
59 default_model = excluded.default_model,
60 scripts_json = excluded.scripts_json,
61 created_at = excluded.created_at,
62 updated_at = excluded.updated_at,
63 deleted_at = excluded.deleted_at
64 `,
65 });
66
67 const getProjectionProjectRow = SqlSchema.findOneOption({
68 Request: GetProjectionProjectInput,
69 Result: ProjectionProjectDbRowSchema,
70 execute: ({ projectId }) =>
71 sql`
72 SELECT
73 project_id AS "projectId",
74 title,
75 workspace_root AS "workspaceRoot",
76 default_model AS "defaultModel",
77 scripts_json AS "scripts",
78 created_at AS "createdAt",
79 updated_at AS "updatedAt",
80 deleted_at AS "deletedAt"
81 FROM projection_projects
82 WHERE project_id = ${projectId}
83 `,
84 });
85
86 const listProjectionProjectRows = SqlSchema.findAll({
87 Request: Schema.Void,
88 Result: ProjectionProjectDbRowSchema,
89 execute: () =>
90 sql`
91 SELECT
92 project_id AS "projectId",
93 title,
94 workspace_root AS "workspaceRoot",
95 default_model AS "defaultModel",
96 scripts_json AS "scripts",
97 created_at AS "createdAt",
98 updated_at AS "updatedAt",
99 deleted_at AS "deletedAt"
100 FROM projection_projects
101 ORDER BY created_at ASC, project_id ASC
102 `,
103 });
104
105 const deleteProjectionProjectRow = SqlSchema.void({
106 Request: DeleteProjectionProjectInput,
107 execute: ({ projectId }) =>
108 sql`
109 DELETE FROM projection_projects
110 WHERE project_id = ${projectId}
111 `,
112 });
113
114 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
115 upsertProjectionProjectRow(row).pipe(
116 Effect.mapError(
117 toPersistenceSqlOrDecodeError(
118 "ProjectionProjectRepository.upsert:query",
119 "ProjectionProjectRepository.upsert:encodeRequest",
120 ),
121 ),
122 );
123
124 const getById: ProjectionProjectRepositoryShape["getById"] = (input) =>
125 getProjectionProjectRow(input).pipe(
126 Effect.mapError(
127 toPersistenceSqlOrDecodeError(
128 "ProjectionProjectRepository.getById:query",
129 "ProjectionProjectRepository.getById:decodeRow",
130 ),
131 ),
132 Effect.flatMap((rowOption) =>
133 Option.match(rowOption, {
134 onNone: () => Effect.succeed(Option.none()),
135 onSome: (row) =>
136 Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionProject>)),
137 }),
138 ),
139 );
140
141 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
142 listProjectionProjectRows().pipe(
143 Effect.mapError(
144 toPersistenceSqlOrDecodeError(
145 "ProjectionProjectRepository.listAll:query",
146 "ProjectionProjectRepository.listAll:decodeRows",
147 ),
148 ),
149 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionProject>>),
150 );
151
152 const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) =>
153 deleteProjectionProjectRow(input).pipe(
154 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")),
155 );
156
157 return {
158 upsert,
159 getById,
160 listAll,
161 deleteById,
162 } satisfies ProjectionProjectRepositoryShape;
163});
164
165export const ProjectionProjectRepositoryLive = Layer.effect(
166 ProjectionProjectRepository,
167 makeProjectionProjectRepository,
168);
apps/server/src/persistence/Layers/ProjectionState.ts created+116
......@@ -0,0 +1,116 @@
1import { NonNegativeInt } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Schema } from "effect";
5
6import { toPersistenceSqlError } from "../Errors.ts";
7
8import {
9 ProjectionStateRepository,
10 type ProjectionStateRepositoryShape,
11 GetProjectionStateInput,
12 ProjectionState,
13} from "../Services/ProjectionState.ts";
14
15const MinLastAppliedSequenceRowSchema = Schema.Struct({
16 minLastAppliedSequence: Schema.NullOr(NonNegativeInt),
17});
18
19const makeProjectionStateRepository = Effect.gen(function* () {
20 const sql = yield* SqlClient.SqlClient;
21
22 const upsertProjectionStateRow = SqlSchema.void({
23 Request: ProjectionState,
24 execute: (row) =>
25 sql`
26 INSERT INTO projection_state (
27 projector,
28 last_applied_sequence,
29 updated_at
30 )
31 VALUES (
32 ${row.projector},
33 ${row.lastAppliedSequence},
34 ${row.updatedAt}
35 )
36 ON CONFLICT (projector)
37 DO UPDATE SET
38 last_applied_sequence = excluded.last_applied_sequence,
39 updated_at = excluded.updated_at
40 `,
41 });
42
43 const getProjectionStateRow = SqlSchema.findOneOption({
44 Request: GetProjectionStateInput,
45 Result: ProjectionState,
46 execute: ({ projector }) =>
47 sql`
48 SELECT
49 projector,
50 last_applied_sequence AS "lastAppliedSequence",
51 updated_at AS "updatedAt"
52 FROM projection_state
53 WHERE projector = ${projector}
54 `,
55 });
56
57 const listProjectionStateRows = SqlSchema.findAll({
58 Request: Schema.Void,
59 Result: ProjectionState,
60 execute: () =>
61 sql`
62 SELECT
63 projector,
64 last_applied_sequence AS "lastAppliedSequence",
65 updated_at AS "updatedAt"
66 FROM projection_state
67 ORDER BY projector ASC
68 `,
69 });
70
71 const readMinLastAppliedSequence = SqlSchema.findOne({
72 Request: Schema.Void,
73 Result: MinLastAppliedSequenceRowSchema,
74 execute: () =>
75 sql`
76 SELECT
77 MIN(last_applied_sequence) AS "minLastAppliedSequence"
78 FROM projection_state
79 `,
80 });
81
82 const upsert: ProjectionStateRepositoryShape["upsert"] = (row) =>
83 upsertProjectionStateRow(row).pipe(
84 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),
85 );
86
87 const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) =>
88 getProjectionStateRow(input).pipe(
89 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")),
90 );
91
92 const listAll: ProjectionStateRepositoryShape["listAll"] = () =>
93 listProjectionStateRows(undefined).pipe(
94 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")),
95 );
96
97 const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () =>
98 readMinLastAppliedSequence(undefined).pipe(
99 Effect.mapError(
100 toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"),
101 ),
102 Effect.map((row) => row.minLastAppliedSequence),
103 );
104
105 return {
106 upsert,
107 getByProjector,
108 listAll,
109 minLastAppliedSequence,
110 } satisfies ProjectionStateRepositoryShape;
111});
112
113export const ProjectionStateRepositoryLive = Layer.effect(
114 ProjectionStateRepository,
115 makeProjectionStateRepository,
116);
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts created+134
......@@ -0,0 +1,134 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 DeleteProjectionThreadActivitiesInput,
9 ListProjectionThreadActivitiesInput,
10 ProjectionThreadActivity,
11 ProjectionThreadActivityRepository,
12 type ProjectionThreadActivityRepositoryShape,
13} from "../Services/ProjectionThreadActivities.ts";
14
15const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
16 Struct.assign({
17 payload: Schema.fromJsonString(Schema.Unknown),
18 }),
19);
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
27
28const makeProjectionThreadActivityRepository = Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;
30
31 const upsertProjectionThreadActivityRow = SqlSchema.void({
32 Request: ProjectionThreadActivityDbRowSchema,
33 execute: (row) =>
34 sql`
35 INSERT INTO projection_thread_activities (
36 activity_id,
37 thread_id,
38 turn_id,
39 tone,
40 kind,
41 summary,
42 payload_json,
43 created_at
44 )
45 VALUES (
46 ${row.activityId},
47 ${row.threadId},
48 ${row.turnId},
49 ${row.tone},
50 ${row.kind},
51 ${row.summary},
52 ${row.payload},
53 ${row.createdAt}
54 )
55 ON CONFLICT (activity_id)
56 DO UPDATE SET
57 thread_id = excluded.thread_id,
58 turn_id = excluded.turn_id,
59 tone = excluded.tone,
60 kind = excluded.kind,
61 summary = excluded.summary,
62 payload_json = excluded.payload_json,
63 created_at = excluded.created_at
64 `,
65 });
66
67 const listProjectionThreadActivityRows = SqlSchema.findAll({
68 Request: ListProjectionThreadActivitiesInput,
69 Result: ProjectionThreadActivityDbRowSchema,
70 execute: ({ threadId }) =>
71 sql`
72 SELECT
73 activity_id AS "activityId",
74 thread_id AS "threadId",
75 turn_id AS "turnId",
76 tone,
77 kind,
78 summary,
79 payload_json AS "payload",
80 created_at AS "createdAt"
81 FROM projection_thread_activities
82 WHERE thread_id = ${threadId}
83 ORDER BY created_at ASC, activity_id ASC
84 `,
85 });
86
87 const deleteProjectionThreadActivityRows = SqlSchema.void({
88 Request: DeleteProjectionThreadActivitiesInput,
89 execute: ({ threadId }) =>
90 sql`
91 DELETE FROM projection_thread_activities
92 WHERE thread_id = ${threadId}
93 `,
94 });
95
96 const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) =>
97 upsertProjectionThreadActivityRow(row).pipe(
98 Effect.mapError(
99 toPersistenceSqlOrDecodeError(
100 "ProjectionThreadActivityRepository.upsert:query",
101 "ProjectionThreadActivityRepository.upsert:encodeRequest",
102 ),
103 ),
104 );
105
106 const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) =>
107 listProjectionThreadActivityRows(input).pipe(
108 Effect.mapError(
109 toPersistenceSqlOrDecodeError(
110 "ProjectionThreadActivityRepository.listByThreadId:query",
111 "ProjectionThreadActivityRepository.listByThreadId:decodeRows",
112 ),
113 ),
114 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionThreadActivity>>),
115 );
116
117 const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) =>
118 deleteProjectionThreadActivityRows(input).pipe(
119 Effect.mapError(
120 toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"),
121 ),
122 );
123
124 return {
125 upsert,
126 listByThreadId,
127 deleteByThreadId,
128 } satisfies ProjectionThreadActivityRepositoryShape;
129});
130
131export const ProjectionThreadActivityRepositoryLive = Layer.effect(
132 ProjectionThreadActivityRepository,
133 makeProjectionThreadActivityRepository,
134);
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts created+123
......@@ -0,0 +1,123 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema, Struct } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 ProjectionThreadMessageRepository,
8 type ProjectionThreadMessageRepositoryShape,
9 DeleteProjectionThreadMessagesInput,
10 ListProjectionThreadMessagesInput,
11 ProjectionThreadMessage,
12} from "../Services/ProjectionThreadMessages.ts";
13
14const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
15 Struct.assign({
16 isStreaming: Schema.Number,
17 }),
18);
19
20const makeProjectionThreadMessageRepository = Effect.gen(function* () {
21 const sql = yield* SqlClient.SqlClient;
22
23 const upsertProjectionThreadMessageRow = SqlSchema.void({
24 Request: ProjectionThreadMessage,
25 execute: (row) =>
26 sql`
27 INSERT INTO projection_thread_messages (
28 message_id,
29 thread_id,
30 turn_id,
31 role,
32 text,
33 is_streaming,
34 created_at,
35 updated_at
36 )
37 VALUES (
38 ${row.messageId},
39 ${row.threadId},
40 ${row.turnId},
41 ${row.role},
42 ${row.text},
43 ${row.isStreaming ? 1 : 0},
44 ${row.createdAt},
45 ${row.updatedAt}
46 )
47 ON CONFLICT (message_id)
48 DO UPDATE SET
49 thread_id = excluded.thread_id,
50 turn_id = excluded.turn_id,
51 role = excluded.role,
52 text = excluded.text,
53 is_streaming = excluded.is_streaming,
54 created_at = excluded.created_at,
55 updated_at = excluded.updated_at
56 `,
57 });
58
59 const listProjectionThreadMessageRows = SqlSchema.findAll({
60 Request: ListProjectionThreadMessagesInput,
61 Result: ProjectionThreadMessageDbRowSchema,
62 execute: ({ threadId }) =>
63 sql`
64 SELECT
65 message_id AS "messageId",
66 thread_id AS "threadId",
67 turn_id AS "turnId",
68 role,
69 text,
70 is_streaming AS "isStreaming",
71 created_at AS "createdAt",
72 updated_at AS "updatedAt"
73 FROM projection_thread_messages
74 WHERE thread_id = ${threadId}
75 ORDER BY created_at ASC, message_id ASC
76 `,
77 });
78
79 const deleteProjectionThreadMessageRows = SqlSchema.void({
80 Request: DeleteProjectionThreadMessagesInput,
81 execute: ({ threadId }) =>
82 sql`
83 DELETE FROM projection_thread_messages
84 WHERE thread_id = ${threadId}
85 `,
86 });
87
88 const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) =>
89 upsertProjectionThreadMessageRow(row).pipe(
90 Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")),
91 );
92
93 const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) =>
94 listProjectionThreadMessageRows(input).pipe(
95 Effect.mapError(
96 toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"),
97 ),
98 Effect.map((rows) =>
99 rows.map((row) => ({
100 ...row,
101 isStreaming: row.isStreaming === 1,
102 })),
103 ),
104 );
105
106 const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) =>
107 deleteProjectionThreadMessageRows(input).pipe(
108 Effect.mapError(
109 toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"),
110 ),
111 );
112
113 return {
114 upsert,
115 listByThreadId,
116 deleteByThreadId,
117 } satisfies ProjectionThreadMessageRepositoryShape;
118});
119
120export const ProjectionThreadMessageRepositoryLive = Layer.effect(
121 ProjectionThreadMessageRepository,
122 makeProjectionThreadMessageRepository,
123);
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts created+111
......@@ -0,0 +1,111 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 ProjectionThreadSession,
9 ProjectionThreadSessionRepository,
10 type ProjectionThreadSessionRepositoryShape,
11 DeleteProjectionThreadSessionInput,
12 GetProjectionThreadSessionInput,
13} from "../Services/ProjectionThreadSessions.ts";
14
15const makeProjectionThreadSessionRepository = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 const upsertProjectionThreadSessionRow = SqlSchema.void({
19 Request: ProjectionThreadSession,
20 execute: (row) =>
21 sql`
22 INSERT INTO projection_thread_sessions (
23 thread_id,
24 status,
25 provider_name,
26 provider_session_id,
27 provider_thread_id,
28 active_turn_id,
29 last_error,
30 updated_at
31 )
32 VALUES (
33 ${row.threadId},
34 ${row.status},
35 ${row.providerName},
36 ${row.providerSessionId},
37 ${row.providerThreadId},
38 ${row.activeTurnId},
39 ${row.lastError},
40 ${row.updatedAt}
41 )
42 ON CONFLICT (thread_id)
43 DO UPDATE SET
44 status = excluded.status,
45 provider_name = excluded.provider_name,
46 provider_session_id = excluded.provider_session_id,
47 provider_thread_id = excluded.provider_thread_id,
48 active_turn_id = excluded.active_turn_id,
49 last_error = excluded.last_error,
50 updated_at = excluded.updated_at
51 `,
52 });
53
54 const getProjectionThreadSessionRow = SqlSchema.findOneOption({
55 Request: GetProjectionThreadSessionInput,
56 Result: ProjectionThreadSession,
57 execute: ({ threadId }) =>
58 sql`
59 SELECT
60 thread_id AS "threadId",
61 status,
62 provider_name AS "providerName",
63 provider_session_id AS "providerSessionId",
64 provider_thread_id AS "providerThreadId",
65 active_turn_id AS "activeTurnId",
66 last_error AS "lastError",
67 updated_at AS "updatedAt"
68 FROM projection_thread_sessions
69 WHERE thread_id = ${threadId}
70 `,
71 });
72
73 const deleteProjectionThreadSessionRow = SqlSchema.void({
74 Request: DeleteProjectionThreadSessionInput,
75 execute: ({ threadId }) =>
76 sql`
77 DELETE FROM projection_thread_sessions
78 WHERE thread_id = ${threadId}
79 `,
80 });
81
82 const upsert: ProjectionThreadSessionRepositoryShape["upsert"] = (row) =>
83 upsertProjectionThreadSessionRow(row).pipe(
84 Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")),
85 );
86
87 const getByThreadId: ProjectionThreadSessionRepositoryShape["getByThreadId"] = (input) =>
88 getProjectionThreadSessionRow(input).pipe(
89 Effect.mapError(
90 toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"),
91 ),
92 );
93
94 const deleteByThreadId: ProjectionThreadSessionRepositoryShape["deleteByThreadId"] = (input) =>
95 deleteProjectionThreadSessionRow(input).pipe(
96 Effect.mapError(
97 toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"),
98 ),
99 );
100
101 return {
102 upsert,
103 getByThreadId,
104 deleteByThreadId,
105 } satisfies ProjectionThreadSessionRepositoryShape;
106});
107
108export const ProjectionThreadSessionRepositoryLive = Layer.effect(
109 ProjectionThreadSessionRepository,
110 makeProjectionThreadSessionRepository,
111);
apps/server/src/persistence/Layers/ProjectionThreadTurns.ts created+135
......@@ -0,0 +1,135 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 ProjectionThreadTurn,
8 ProjectionThreadTurnRepository,
9 type ProjectionThreadTurnRepositoryShape,
10 DeleteProjectionThreadTurnsInput,
11 GetProjectionThreadTurnInput,
12 ListProjectionThreadTurnsInput,
13} from "../Services/ProjectionThreadTurns.ts";
14
15const makeProjectionThreadTurnRepository = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 const upsertProjectionThreadTurnRow = SqlSchema.void({
19 Request: ProjectionThreadTurn,
20 execute: (row) =>
21 sql`
22 INSERT INTO projection_thread_turns (
23 turn_id,
24 thread_id,
25 turn_count,
26 status,
27 user_message_id,
28 assistant_message_id,
29 started_at,
30 completed_at
31 )
32 VALUES (
33 ${row.turnId},
34 ${row.threadId},
35 ${row.turnCount},
36 ${row.status},
37 ${row.userMessageId},
38 ${row.assistantMessageId},
39 ${row.startedAt},
40 ${row.completedAt}
41 )
42 ON CONFLICT (turn_id)
43 DO UPDATE SET
44 thread_id = excluded.thread_id,
45 turn_count = excluded.turn_count,
46 status = excluded.status,
47 user_message_id = excluded.user_message_id,
48 assistant_message_id = excluded.assistant_message_id,
49 started_at = excluded.started_at,
50 completed_at = excluded.completed_at
51 `,
52 });
53
54 const listProjectionThreadTurnRows = SqlSchema.findAll({
55 Request: ListProjectionThreadTurnsInput,
56 Result: ProjectionThreadTurn,
57 execute: ({ threadId }) =>
58 sql`
59 SELECT
60 turn_id AS "turnId",
61 thread_id AS "threadId",
62 turn_count AS "turnCount",
63 status,
64 user_message_id AS "userMessageId",
65 assistant_message_id AS "assistantMessageId",
66 started_at AS "startedAt",
67 completed_at AS "completedAt"
68 FROM projection_thread_turns
69 WHERE thread_id = ${threadId}
70 ORDER BY turn_count ASC
71 `,
72 });
73
74 const getProjectionThreadTurnRow = SqlSchema.findOneOption({
75 Request: GetProjectionThreadTurnInput,
76 Result: ProjectionThreadTurn,
77 execute: ({ turnId }) =>
78 sql`
79 SELECT
80 turn_id AS "turnId",
81 thread_id AS "threadId",
82 turn_count AS "turnCount",
83 status,
84 user_message_id AS "userMessageId",
85 assistant_message_id AS "assistantMessageId",
86 started_at AS "startedAt",
87 completed_at AS "completedAt"
88 FROM projection_thread_turns
89 WHERE turn_id = ${turnId}
90 `,
91 });
92
93 const deleteProjectionThreadTurnRows = SqlSchema.void({
94 Request: DeleteProjectionThreadTurnsInput,
95 execute: ({ threadId }) =>
96 sql`
97 DELETE FROM projection_thread_turns
98 WHERE thread_id = ${threadId}
99 `,
100 });
101
102 const upsert: ProjectionThreadTurnRepositoryShape["upsert"] = (row) =>
103 upsertProjectionThreadTurnRow(row).pipe(
104 Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.upsert:query")),
105 );
106
107 const listByThreadId: ProjectionThreadTurnRepositoryShape["listByThreadId"] = (input) =>
108 listProjectionThreadTurnRows(input).pipe(
109 Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.listByThreadId:query")),
110 );
111
112 const getByTurnId: ProjectionThreadTurnRepositoryShape["getByTurnId"] = (input) =>
113 getProjectionThreadTurnRow(input).pipe(
114 Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.getByTurnId:query")),
115 );
116
117 const deleteByThreadId: ProjectionThreadTurnRepositoryShape["deleteByThreadId"] = (input) =>
118 deleteProjectionThreadTurnRows(input).pipe(
119 Effect.mapError(
120 toPersistenceSqlError("ProjectionThreadTurnRepository.deleteByThreadId:query"),
121 ),
122 );
123
124 return {
125 upsert,
126 listByThreadId,
127 getByTurnId,
128 deleteByThreadId,
129 } satisfies ProjectionThreadTurnRepositoryShape;
130});
131
132export const ProjectionThreadTurnRepositoryLive = Layer.effect(
133 ProjectionThreadTurnRepository,
134 makeProjectionThreadTurnRepository,
135);
apps/server/src/persistence/Layers/ProjectionThreads.ts created+143
......@@ -0,0 +1,143 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6import {
7 DeleteProjectionThreadInput,
8 GetProjectionThreadInput,
9 ListProjectionThreadsByProjectInput,
10 ProjectionThread,
11 ProjectionThreadRepository,
12 type ProjectionThreadRepositoryShape,
13} from "../Services/ProjectionThreads.ts";
14
15const makeProjectionThreadRepository = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 const upsertProjectionThreadRow = SqlSchema.void({
19 Request: ProjectionThread,
20 execute: (row) =>
21 sql`
22 INSERT INTO projection_threads (
23 thread_id,
24 project_id,
25 title,
26 model,
27 branch,
28 worktree_path,
29 latest_turn_id,
30 created_at,
31 updated_at,
32 deleted_at
33 )
34 VALUES (
35 ${row.threadId},
36 ${row.projectId},
37 ${row.title},
38 ${row.model},
39 ${row.branch},
40 ${row.worktreePath},
41 ${row.latestTurnId},
42 ${row.createdAt},
43 ${row.updatedAt},
44 ${row.deletedAt}
45 )
46 ON CONFLICT (thread_id)
47 DO UPDATE SET
48 project_id = excluded.project_id,
49 title = excluded.title,
50 model = excluded.model,
51 branch = excluded.branch,
52 worktree_path = excluded.worktree_path,
53 latest_turn_id = excluded.latest_turn_id,
54 created_at = excluded.created_at,
55 updated_at = excluded.updated_at,
56 deleted_at = excluded.deleted_at
57 `,
58 });
59
60 const getProjectionThreadRow = SqlSchema.findOneOption({
61 Request: GetProjectionThreadInput,
62 Result: ProjectionThread,
63 execute: ({ threadId }) =>
64 sql`
65 SELECT
66 thread_id AS "threadId",
67 project_id AS "projectId",
68 title,
69 model,
70 branch,
71 worktree_path AS "worktreePath",
72 latest_turn_id AS "latestTurnId",
73 created_at AS "createdAt",
74 updated_at AS "updatedAt",
75 deleted_at AS "deletedAt"
76 FROM projection_threads
77 WHERE thread_id = ${threadId}
78 `,
79 });
80
81 const listProjectionThreadRows = SqlSchema.findAll({
82 Request: ListProjectionThreadsByProjectInput,
83 Result: ProjectionThread,
84 execute: ({ projectId }) =>
85 sql`
86 SELECT
87 thread_id AS "threadId",
88 project_id AS "projectId",
89 title,
90 model,
91 branch,
92 worktree_path AS "worktreePath",
93 latest_turn_id AS "latestTurnId",
94 created_at AS "createdAt",
95 updated_at AS "updatedAt",
96 deleted_at AS "deletedAt"
97 FROM projection_threads
98 WHERE project_id = ${projectId}
99 ORDER BY created_at ASC, thread_id ASC
100 `,
101 });
102
103 const deleteProjectionThreadRow = SqlSchema.void({
104 Request: DeleteProjectionThreadInput,
105 execute: ({ threadId }) =>
106 sql`
107 DELETE FROM projection_threads
108 WHERE thread_id = ${threadId}
109 `,
110 });
111
112 const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) =>
113 upsertProjectionThreadRow(row).pipe(
114 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),
115 );
116
117 const getById: ProjectionThreadRepositoryShape["getById"] = (input) =>
118 getProjectionThreadRow(input).pipe(
119 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),
120 );
121
122 const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) =>
123 listProjectionThreadRows(input).pipe(
124 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")),
125 );
126
127 const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) =>
128 deleteProjectionThreadRow(input).pipe(
129 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")),
130 );
131
132 return {
133 upsert,
134 getById,
135 listByProjectId,
136 deleteById,
137 } satisfies ProjectionThreadRepositoryShape;
138});
139
140export const ProjectionThreadRepositoryLive = Layer.effect(
141 ProjectionThreadRepository,
142 makeProjectionThreadRepository,
143);
apps/server/src/persistence/Layers/Projects.test.ts deleted-56
......@@ -1,56 +0,0 @@
1import { Effect, Layer, FileSystem } from "effect";
2import { assert, it } from "@effect/vitest";
3
4import { ProjectRepository } from "../Services/Projects.ts";
5import { ProjectRepositoryLive } from "./Projects.ts";
6import { SqlitePersistenceMemory } from "./Sqlite.ts";
7import { NodeServices } from "@effect/platform-node";
8
9const layer = it.layer(
10 Layer.mergeAll(Layer.provide(ProjectRepositoryLive, SqlitePersistenceMemory), NodeServices.layer),
11);
12
13layer("ProjectRepository", (it) => {
14 it.effect("stores projects and deduplicates adds by cwd", () =>
15 Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 const projectDir = yield* fs.makeTempDirectoryScoped();
18
19 const repository = yield* ProjectRepository;
20
21 const created = yield* repository.add({ cwd: projectDir });
22 assert.equal(created.created, true);
23
24 const duplicate = yield* repository.add({ cwd: projectDir });
25 assert.equal(duplicate.created, false);
26 assert.equal(duplicate.project.id, created.project.id);
27
28 const listed = yield* repository.list();
29 assert.equal(listed.length, 1);
30 assert.equal(listed[0]?.cwd, projectDir);
31 }),
32 );
33
34 it.effect("prunes missing project paths", () =>
35 Effect.gen(function* () {
36 const fs = yield* FileSystem.FileSystem;
37 const existing = yield* fs.makeTempDirectoryScoped();
38 const missing = yield* fs.makeTempDirectory();
39
40 const repository = yield* ProjectRepository;
41
42 const existingProject = yield* repository.add({ cwd: existing });
43 const missingProject = yield* repository.add({ cwd: missing });
44
45 assert.isTrue(existingProject.created);
46 assert.isTrue(missingProject.created);
47
48 yield* fs.remove(missing, { recursive: true, force: true });
49
50 yield* repository.pruneMissing();
51 const listed = yield* repository.list();
52 assert.equal(listed.length, 1);
53 assert.equal(listed[0]?.cwd, existing);
54 }),
55 );
56});
apps/server/src/persistence/Layers/Projects.ts deleted-454
......@@ -1,454 +0,0 @@
1import { randomUUID } from "node:crypto";
2import fs from "node:fs";
3import path from "node:path";
4
5import type {
6 ProjectAddInput,
7 ProjectAddResult,
8 ProjectListResult,
9 ProjectRecord,
10 ProjectScript,
11 ProjectUpdateScriptsInput,
12 ProjectUpdateScriptsResult,
13} from "@t3tools/contracts";
14import {
15 normalizeProjectScripts,
16 projectAddInputSchema,
17 projectRecordSchema,
18 projectRemoveInputSchema,
19 projectScriptsSchema,
20 projectUpdateScriptsInputSchema,
21} from "@t3tools/contracts";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
23import * as SqlSchema from "effect/unstable/sql/SqlSchema";
24import { Effect, Layer, Option, Schema } from "effect";
25
26import {
27 PersistenceDecodeError,
28 ProjectNotFoundError,
29 ProjectPathMissingError,
30 ProjectValidationError,
31 toPersistenceDecodeCauseError,
32 toPersistenceSerializationError,
33 toPersistenceSqlError,
34 type ProjectRepositoryError,
35} from "../Errors.ts";
36import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects.ts";
37
38const ProjectRowSchema = Schema.Struct({
39 id: Schema.String,
40 cwd: Schema.String,
41 name: Schema.String,
42 scriptsJson: Schema.String,
43 createdAt: Schema.String,
44 updatedAt: Schema.String,
45});
46
47const FindByIdRequestSchema = Schema.Struct({
48 id: Schema.String,
49});
50
51const FindByCwdRequestSchema = Schema.Struct({
52 cwd: Schema.String,
53});
54
55const InsertProjectRequestSchema = Schema.Struct({
56 id: Schema.String,
57 cwd: Schema.String,
58 name: Schema.String,
59 scriptsJson: Schema.String,
60 createdAt: Schema.String,
61 updatedAt: Schema.String,
62});
63
64const UpdateScriptsRequestSchema = Schema.Struct({
65 id: Schema.String,
66 scriptsJson: Schema.String,
67 updatedAt: Schema.String,
68});
69
70const DeleteProjectRequestSchema = Schema.Struct({
71 id: Schema.String,
72});
73
74function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
75 const cloned: ProjectScript[] = [];
76 for (const script of scripts) {
77 cloned.push({
78 id: script.id,
79 name: script.name,
80 command: script.command,
81 icon: script.icon,
82 runOnWorktreeCreate: script.runOnWorktreeCreate,
83 });
84 }
85 return cloned;
86}
87
88function normalizeCwd(rawCwd: string): string {
89 const resolved = path.resolve(rawCwd.trim());
90 const normalized = path.normalize(resolved);
91 if (process.platform === "win32") {
92 return normalized.toLowerCase();
93 }
94 return normalized;
95}
96
97function isDirectory(cwd: string): boolean {
98 try {
99 return fs.statSync(cwd).isDirectory();
100 } catch {
101 return false;
102 }
103}
104
105function inferProjectName(cwd: string): string {
106 const name = path.basename(cwd);
107 return name.length > 0 ? name : "project";
108}
109
110function parseProjectScripts(
111 value: unknown,
112 operation: string,
113): Effect.Effect<ProjectScript[], PersistenceDecodeError> {
114 const parsed = projectScriptsSchema.safeParse(value);
115 if (!parsed.success) {
116 return Effect.fail(
117 new PersistenceDecodeError({
118 operation,
119 issue: parsed.error.message,
120 cause: (parsed.error as { cause?: unknown }).cause,
121 }),
122 );
123 }
124 return Effect.succeed(normalizeProjectScripts(parsed.data));
125}
126
127function decodeScriptsJson(
128 raw: string,
129 operation: string,
130): Effect.Effect<ProjectScript[], ProjectRepositoryError> {
131 return Effect.try({
132 try: () => JSON.parse(raw) as unknown,
133 catch: toPersistenceDecodeCauseError(`${operation}:parseScriptsJson`),
134 }).pipe(Effect.flatMap((value) => parseProjectScripts(value, `${operation}:decodeScripts`)));
135}
136
137function rowToProjectRecord(
138 row: Schema.Schema.Type<typeof ProjectRowSchema>,
139 operation: string,
140): Effect.Effect<ProjectRecord, ProjectRepositoryError> {
141 return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe(
142 Effect.flatMap((scripts) => {
143 const parsed = projectRecordSchema.safeParse({
144 id: row.id,
145 cwd: row.cwd,
146 name: row.name,
147 scripts,
148 createdAt: row.createdAt,
149 updatedAt: row.updatedAt,
150 });
151 if (!parsed.success) {
152 return Effect.fail(
153 new PersistenceDecodeError({
154 operation: `${operation}:projectRecord`,
155 issue: parsed.error.message,
156 cause: (parsed.error as { cause?: unknown }).cause,
157 }),
158 );
159 }
160 return Effect.succeed(parsed.data);
161 }),
162 );
163}
164
165function decodeAddInput(rawInput: ProjectAddInput) {
166 const parsed = projectAddInputSchema.safeParse(rawInput);
167 if (!parsed.success) {
168 return Effect.fail(
169 new ProjectValidationError({
170 operation: "ProjectRepository.add",
171 issue: parsed.error.message,
172 cause: (parsed.error as { cause?: unknown }).cause,
173 }),
174 );
175 }
176 return Effect.succeed(parsed.data);
177}
178
179function decodeUpdateScriptsInput(rawInput: ProjectUpdateScriptsInput) {
180 const parsed = projectUpdateScriptsInputSchema.safeParse(rawInput);
181 if (!parsed.success) {
182 return Effect.fail(
183 new ProjectValidationError({
184 operation: "ProjectRepository.updateScripts",
185 issue: parsed.error.message,
186 cause: (parsed.error as { cause?: unknown }).cause,
187 }),
188 );
189 }
190 return Effect.succeed(parsed.data);
191}
192
193function decodeRemoveInput(rawInput: unknown) {
194 const parsed = projectRemoveInputSchema.safeParse(rawInput);
195 if (!parsed.success) {
196 return Effect.fail(
197 new ProjectValidationError({
198 operation: "ProjectRepository.remove",
199 issue: parsed.error.message,
200 cause: (parsed.error as { cause?: unknown }).cause,
201 }),
202 );
203 }
204 return Effect.succeed(parsed.data);
205}
206
207const makeRepository = Effect.gen(function* () {
208 const sql = yield* SqlClient.SqlClient;
209
210 const listProjectRows = SqlSchema.findAll({
211 Request: Schema.Void,
212 Result: ProjectRowSchema,
213 execute: () =>
214 sql`
215 SELECT
216 id,
217 cwd,
218 name,
219 scripts_json AS "scriptsJson",
220 created_at AS "createdAt",
221 updated_at AS "updatedAt"
222 FROM projects
223 ORDER BY created_at DESC
224 `,
225 });
226
227 const findProjectById = SqlSchema.findOneOption({
228 Request: FindByIdRequestSchema,
229 Result: ProjectRowSchema,
230 execute: ({ id }) =>
231 sql`
232 SELECT
233 id,
234 cwd,
235 name,
236 scripts_json AS "scriptsJson",
237 created_at AS "createdAt",
238 updated_at AS "updatedAt"
239 FROM projects
240 WHERE id = ${id}
241 `,
242 });
243
244 const findProjectByCwd = SqlSchema.findOneOption({
245 Request: FindByCwdRequestSchema,
246 Result: ProjectRowSchema,
247 execute: ({ cwd }) =>
248 sql`
249 SELECT
250 id,
251 cwd,
252 name,
253 scripts_json AS "scriptsJson",
254 created_at AS "createdAt",
255 updated_at AS "updatedAt"
256 FROM projects
257 WHERE cwd = ${cwd}
258 `,
259 });
260
261 const insertProject = SqlSchema.findOne({
262 Request: InsertProjectRequestSchema,
263 Result: ProjectRowSchema,
264 execute: (request) =>
265 sql`
266 INSERT INTO projects (
267 id,
268 cwd,
269 name,
270 scripts_json,
271 created_at,
272 updated_at
273 )
274 VALUES (
275 ${request.id},
276 ${request.cwd},
277 ${request.name},
278 ${request.scriptsJson},
279 ${request.createdAt},
280 ${request.updatedAt}
281 )
282 RETURNING
283 id,
284 cwd,
285 name,
286 scripts_json AS "scriptsJson",
287 created_at AS "createdAt",
288 updated_at AS "updatedAt"
289 `,
290 });
291
292 const updateProjectScripts = SqlSchema.findOne({
293 Request: UpdateScriptsRequestSchema,
294 Result: ProjectRowSchema,
295 execute: (request) =>
296 sql`
297 UPDATE projects
298 SET
299 scripts_json = ${request.scriptsJson},
300 updated_at = ${request.updatedAt}
301 WHERE id = ${request.id}
302 RETURNING
303 id,
304 cwd,
305 name,
306 scripts_json AS "scriptsJson",
307 created_at AS "createdAt",
308 updated_at AS "updatedAt"
309 `,
310 });
311
312 const deleteProject = SqlSchema.void({
313 Request: DeleteProjectRequestSchema,
314 execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`,
315 });
316
317 const list: ProjectRepositoryShape["list"] = () =>
318 listProjectRows(undefined).pipe(
319 Effect.mapError(toPersistenceSqlError("ProjectRepository.list:query")),
320 Effect.flatMap((rows) =>
321 Effect.forEach(rows, (row) =>
322 rowToProjectRecord(row, "ProjectRepository.list:rowToProject"),
323 ),
324 ),
325 Effect.map(
326 (projects): ProjectListResult =>
327 projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })),
328 ),
329 );
330
331 const add: ProjectRepositoryShape["add"] = (rawInput) =>
332 Effect.gen(function* () {
333 const input = yield* decodeAddInput(rawInput);
334 const normalizedCwd = normalizeCwd(input.cwd);
335 if (!isDirectory(normalizedCwd)) {
336 return yield* new ProjectPathMissingError({ cwd: normalizedCwd });
337 }
338
339 const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(
340 Effect.mapError(toPersistenceSqlError("ProjectRepository.add:findByCwd")),
341 );
342 if (Option.isSome(existing)) {
343 const project = yield* rowToProjectRecord(existing.value, "ProjectRepository.add:existing");
344 const result: ProjectAddResult = {
345 project: {
346 ...project,
347 scripts: cloneScripts(project.scripts),
348 },
349 created: false,
350 };
351 return result;
352 }
353
354 const now = new Date().toISOString();
355 const row = yield* insertProject({
356 id: randomUUID(),
357 cwd: normalizedCwd,
358 name: inferProjectName(normalizedCwd),
359 scriptsJson: "[]",
360 createdAt: now,
361 updatedAt: now,
362 }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.add:insert")));
363
364 const created = yield* rowToProjectRecord(row, "ProjectRepository.add:created");
365 const result: ProjectAddResult = {
366 project: {
367 ...created,
368 scripts: cloneScripts(created.scripts),
369 },
370 created: true,
371 };
372 return result;
373 });
374
375 const remove: ProjectRepositoryShape["remove"] = (rawInput) =>
376 Effect.gen(function* () {
377 const input = yield* decodeRemoveInput(rawInput);
378 const existing = yield* findProjectById({ id: input.id }).pipe(
379 Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")),
380 );
381 if (Option.isNone(existing)) {
382 return yield* new ProjectNotFoundError({ projectId: input.id });
383 }
384
385 yield* deleteProject({ id: input.id }).pipe(
386 Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:delete")),
387 );
388 });
389
390 const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) =>
391 Effect.gen(function* () {
392 const input = yield* decodeUpdateScriptsInput(rawInput);
393 const existing = yield* findProjectById({ id: input.id }).pipe(
394 Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")),
395 );
396 if (Option.isNone(existing)) {
397 return yield* new ProjectNotFoundError({ projectId: input.id });
398 }
399
400 const nextScripts = yield* parseProjectScripts(
401 input.scripts,
402 "ProjectRepository.updateScripts:decodeScripts",
403 );
404 const scriptsJson = yield* Effect.try({
405 try: () => JSON.stringify(nextScripts),
406 catch: toPersistenceSerializationError("ProjectRepository.updateScripts:encodeScripts"),
407 });
408 const nextUpdatedAt = new Date().toISOString();
409 const updatedRow = yield* updateProjectScripts({
410 id: input.id,
411 scriptsJson,
412 updatedAt: nextUpdatedAt,
413 }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:update")));
414
415 const updated = yield* rowToProjectRecord(
416 updatedRow,
417 "ProjectRepository.updateScripts:updated",
418 );
419 const result: ProjectUpdateScriptsResult = {
420 project: {
421 ...updated,
422 scripts: cloneScripts(updated.scripts),
423 },
424 };
425 return result;
426 });
427
428 const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () =>
429 listProjectRows(undefined).pipe(
430 Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:query")),
431 Effect.flatMap((rows) =>
432 Effect.forEach(rows, (row) => {
433 const normalizedCwd = normalizeCwd(row.cwd);
434 if (isDirectory(normalizedCwd)) {
435 return Effect.void;
436 }
437 return deleteProject({ id: row.id }).pipe(
438 Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:delete")),
439 );
440 }),
441 ),
442 Effect.asVoid,
443 );
444
445 return {
446 list,
447 add,
448 remove,
449 updateScripts,
450 pruneMissing,
451 } satisfies ProjectRepositoryShape;
452});
453
454export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository);
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts created+204
......@@ -0,0 +1,204 @@
1import { ProviderSessionId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema, Struct } from "effect";
5
6import {
7 toPersistenceDecodeError,
8 toPersistenceSqlError,
9 type ProviderSessionRuntimeRepositoryError,
10} from "../Errors.ts";
11import {
12 ProviderSessionRuntime,
13 ProviderSessionRuntimeRepository,
14 type ProviderSessionRuntimeRepositoryShape,
15} from "../Services/ProviderSessionRuntime.ts";
16
17const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields(
18 Struct.assign({
19 resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)),
20 runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)),
21 }),
22);
23
24const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime);
25
26const GetRuntimeRequestSchema = Schema.Struct({
27 providerSessionId: ProviderSessionId,
28});
29
30const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema;
31
32function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
33 return (cause: unknown): ProviderSessionRuntimeRepositoryError =>
34 Schema.isSchemaError(cause)
35 ? toPersistenceDecodeError(decodeOperation)(cause)
36 : toPersistenceSqlError(sqlOperation)(cause);
37}
38
39const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
40 const sql = yield* SqlClient.SqlClient;
41
42 const upsertRuntimeRow = SqlSchema.void({
43 Request: ProviderSessionRuntimeDbRowSchema,
44 execute: (runtime) =>
45 sql`
46 INSERT INTO provider_session_runtime (
47 provider_session_id,
48 thread_id,
49 provider_name,
50 adapter_key,
51 provider_thread_id,
52 status,
53 last_seen_at,
54 resume_cursor_json,
55 runtime_payload_json
56 )
57 VALUES (
58 ${runtime.providerSessionId},
59 ${runtime.threadId},
60 ${runtime.providerName},
61 ${runtime.adapterKey},
62 ${runtime.providerThreadId},
63 ${runtime.status},
64 ${runtime.lastSeenAt},
65 ${runtime.resumeCursor},
66 ${runtime.runtimePayload}
67 )
68 ON CONFLICT (provider_session_id)
69 DO UPDATE SET
70 thread_id = excluded.thread_id,
71 provider_name = excluded.provider_name,
72 adapter_key = excluded.adapter_key,
73 provider_thread_id = excluded.provider_thread_id,
74 status = excluded.status,
75 last_seen_at = excluded.last_seen_at,
76 resume_cursor_json = excluded.resume_cursor_json,
77 runtime_payload_json = excluded.runtime_payload_json
78 `,
79 });
80
81 const getRuntimeRowBySessionId = SqlSchema.findOneOption({
82 Request: GetRuntimeRequestSchema,
83 Result: ProviderSessionRuntimeDbRowSchema,
84 execute: ({ providerSessionId }) =>
85 sql`
86 SELECT
87 provider_session_id AS "providerSessionId",
88 thread_id AS "threadId",
89 provider_name AS "providerName",
90 adapter_key AS "adapterKey",
91 provider_thread_id AS "providerThreadId",
92 status,
93 last_seen_at AS "lastSeenAt",
94 resume_cursor_json AS "resumeCursor",
95 runtime_payload_json AS "runtimePayload"
96 FROM provider_session_runtime
97 WHERE provider_session_id = ${providerSessionId}
98 `,
99 });
100
101 const listRuntimeRows = SqlSchema.findAll({
102 Request: Schema.Void,
103 Result: ProviderSessionRuntimeDbRowSchema,
104 execute: () =>
105 sql`
106 SELECT
107 provider_session_id AS "providerSessionId",
108 thread_id AS "threadId",
109 provider_name AS "providerName",
110 adapter_key AS "adapterKey",
111 provider_thread_id AS "providerThreadId",
112 status,
113 last_seen_at AS "lastSeenAt",
114 resume_cursor_json AS "resumeCursor",
115 runtime_payload_json AS "runtimePayload"
116 FROM provider_session_runtime
117 ORDER BY last_seen_at ASC, provider_session_id ASC
118 `,
119 });
120
121 const deleteRuntimeBySessionId = SqlSchema.void({
122 Request: DeleteRuntimeRequestSchema,
123 execute: ({ providerSessionId }) =>
124 sql`
125 DELETE FROM provider_session_runtime
126 WHERE provider_session_id = ${providerSessionId}
127 `,
128 });
129
130 const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) =>
131 upsertRuntimeRow(runtime).pipe(
132 Effect.mapError(
133 toPersistenceSqlOrDecodeError(
134 "ProviderSessionRuntimeRepository.upsert:query",
135 "ProviderSessionRuntimeRepository.upsert:encodeRequest",
136 ),
137 ),
138 );
139
140 const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) =>
141 getRuntimeRowBySessionId(input).pipe(
142 Effect.mapError(
143 toPersistenceSqlOrDecodeError(
144 "ProviderSessionRuntimeRepository.getBySessionId:query",
145 "ProviderSessionRuntimeRepository.getBySessionId:decodeRow",
146 ),
147 ),
148 Effect.flatMap((runtimeRowOption) =>
149 Option.match(runtimeRowOption, {
150 onNone: () => Effect.succeed(Option.none()),
151 onSome: (row) =>
152 decodeRuntime(row).pipe(
153 Effect.mapError(
154 toPersistenceDecodeError(
155 "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime",
156 ),
157 ),
158 Effect.map((runtime) => Option.some(runtime)),
159 ),
160 }),
161 ),
162 );
163
164 const list: ProviderSessionRuntimeRepositoryShape["list"] = () =>
165 listRuntimeRows(undefined).pipe(
166 Effect.mapError(
167 toPersistenceSqlOrDecodeError(
168 "ProviderSessionRuntimeRepository.list:query",
169 "ProviderSessionRuntimeRepository.list:decodeRows",
170 ),
171 ),
172 Effect.flatMap((rows) =>
173 Effect.forEach(
174 rows,
175 (row) =>
176 decodeRuntime(row).pipe(
177 Effect.mapError(
178 toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"),
179 ),
180 ),
181 { concurrency: "unbounded" },
182 ),
183 ),
184 );
185
186 const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) =>
187 deleteRuntimeBySessionId(input).pipe(
188 Effect.mapError(
189 toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"),
190 ),
191 );
192
193 return {
194 upsert,
195 getBySessionId,
196 list,
197 deleteBySessionId,
198 } satisfies ProviderSessionRuntimeRepositoryShape;
199});
200
201export const ProviderSessionRuntimeRepositoryLive = Layer.effect(
202 ProviderSessionRuntimeRepository,
203 makeProviderSessionRuntimeRepository,
204);
apps/server/src/persistence/Layers/ProviderSessions.test.ts deleted-81
......@@ -1,81 +0,0 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { NodeServices } from "@effect/platform-node";
6import { it, assert } from "@effect/vitest";
7import { Effect, Layer, Option } from "effect";
8import * as SqlClient from "effect/unstable/sql/SqlClient";
9
10import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./Sqlite.ts";
11import { ProviderSessionRepository } from "../Services/ProviderSessions.ts";
12import { ProviderSessionRepositoryLive } from "./ProviderSessions.ts";
13
14function makeRepositoryLayer<E, R>(
15 persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>,
16) {
17 return ProviderSessionRepositoryLive.pipe(Layer.provide(persistenceLayer));
18}
19
20it.effect("ProviderSessionRepositoryLive upserts, lists, gets, and deletes sessions", () =>
21 Effect.gen(function* () {
22 const repository = yield* ProviderSessionRepository;
23
24 yield* repository.upsertSession({
25 sessionId: "sess-1",
26 provider: "codex",
27 threadId: "thread-1",
28 });
29 yield* repository.upsertSession({
30 sessionId: "sess-2",
31 provider: "claudeCode",
32 });
33
34 const all = yield* repository.listSessions();
35 assert.equal(all.length, 2);
36 assert.equal(all[0]?.sessionId, "sess-1");
37 assert.equal(all[1]?.sessionId, "sess-2");
38
39 const existing = yield* repository.getSession({ sessionId: "sess-1" });
40 assert.equal(Option.isSome(existing), true);
41 if (Option.isSome(existing)) {
42 assert.equal(existing.value.provider, "codex");
43 assert.equal(existing.value.threadId, "thread-1");
44 }
45
46 yield* repository.deleteSession({ sessionId: "sess-2" });
47 const afterDelete = yield* repository.listSessions();
48 assert.deepEqual(afterDelete.map((entry) => entry.sessionId), ["sess-1"]);
49 }).pipe(Effect.provide(makeRepositoryLayer(SqlitePersistenceMemory))),
50);
51
52it.effect("ProviderSessionRepositoryLive persists sessions across layer restarts", () =>
53 Effect.gen(function* () {
54 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-sessions-"));
55 const dbPath = path.join(tempDir, "orchestration.sqlite");
56
57 const repositoryLayer = makeRepositoryLayer(makeSqlitePersistenceLive(dbPath));
58
59 yield* Effect.gen(function* () {
60 const repository = yield* ProviderSessionRepository;
61 yield* repository.upsertSession({
62 sessionId: "sess-persisted",
63 provider: "codex",
64 threadId: "thread-persisted",
65 });
66 }).pipe(Effect.provide(repositoryLayer));
67
68 const persisted = yield* Effect.gen(function* () {
69 const repository = yield* ProviderSessionRepository;
70 return yield* repository.getSession({ sessionId: "sess-persisted" });
71 }).pipe(Effect.provide(repositoryLayer));
72
73 assert.equal(Option.isSome(persisted), true);
74 if (Option.isSome(persisted)) {
75 assert.equal(persisted.value.provider, "codex");
76 assert.equal(persisted.value.threadId, "thread-persisted");
77 }
78
79 fs.rmSync(tempDir, { recursive: true, force: true });
80 }).pipe(Effect.provide(NodeServices.layer)),
81);
apps/server/src/persistence/Layers/ProviderSessions.ts+13-14
......@@ -1,3 +1,4 @@
1import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
12import * as SqlClient from "effect/unstable/sql/SqlClient";
23import * as SqlSchema from "effect/unstable/sql/SqlSchema";
34import { Effect, Layer, Option, Schema } from "effect";
......@@ -12,26 +13,24 @@ import {
1213 type ProviderSessionRepositoryShape,
1314} from "../Services/ProviderSessions.ts";
1415
15const NonEmptyString = Schema.NonEmptyString;
16
17const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]);
16const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
1817
1918const ProviderSessionRowSchema = Schema.Struct({
20 sessionId: Schema.String,
21 provider: ProviderKindSchema,
22 threadId: Schema.NullOr(Schema.String),
19 sessionId: ProviderSessionId,
20 provider: ProviderKind,
21 threadId: Schema.NullOr(ThreadId),
2322 createdAt: Schema.String,
2423 updatedAt: Schema.String,
2524});
2625
2726const SessionIdRequestSchema = Schema.Struct({
28 sessionId: NonEmptyString,
27 sessionId: ProviderSessionId,
2928});
3029
3130const UpsertSessionRequestSchema = Schema.Struct({
32 sessionId: NonEmptyString,
33 provider: ProviderKindSchema,
34 threadId: Schema.NullOr(NonEmptyString),
31 sessionId: ProviderSessionId,
32 provider: ProviderKind,
33 threadId: Schema.NullOr(ThreadId),
3534});
3635
3736function errorMessage(cause: unknown, fallback: string): string {
......@@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () {
163162 const parsed = yield* decodeInput(
164163 UpsertSessionRequestSchema,
165164 {
166 sessionId: input.sessionId.trim(),
165 sessionId: input.sessionId,
167166 provider: input.provider,
168 threadId: input.threadId?.trim() || null,
167 threadId: input.threadId ?? null,
169168 },
170169 "ProviderSessionRepository.upsertSession",
171170 );
......@@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {
182181 Effect.gen(function* () {
183182 const parsed = yield* decodeInput(
184183 SessionIdRequestSchema,
185 { sessionId: input.sessionId.trim() },
184 { sessionId: input.sessionId },
186185 "ProviderSessionRepository.getSession",
187186 );
188187
......@@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {
207206 Effect.gen(function* () {
208207 const parsed = yield* decodeInput(
209208 SessionIdRequestSchema,
210 { sessionId: input.sessionId.trim() },
209 { sessionId: input.sessionId },
211210 "ProviderSessionRepository.deleteSession",
212211 );
213212
apps/server/src/persistence/Migrations.ts+8-6
......@@ -13,9 +13,10 @@ import * as Layer from "effect/Layer";
1313
1414// Import all migrations statically
1515import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
16import Migration0002 from "./Migrations/002_Projects.ts";
17import Migration0003 from "./Migrations/003_Checkpoints.ts";
18import Migration0004 from "./Migrations/004_ProviderSessions.ts";
16import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts";
17import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";
1920
2021/**
2122 * Migration loader with all migrations defined inline.
......@@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts";
2930 */
3031const loader = Migrator.fromRecord({
3132 "1_OrchestrationEvents": Migration0001,
32 "2_Projects": Migration0002,
33 "3_CheckpointCatalog": Migration0003,
34 "4_ProviderSessions": Migration0004,
33 "2_OrchestrationCommandReceipts": Migration0002,
34 "3_CheckpointDiffBlobs": Migration0003,
35 "4_ProviderSessionRuntime": Migration0004,
36 "5_Projections": Migration0005,
3537});
3638
3739/**
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+25-5
......@@ -8,17 +8,37 @@ export default Effect.gen(function* () {
88 CREATE TABLE IF NOT EXISTS orchestration_events (
99 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
1010 event_id TEXT NOT NULL UNIQUE,
11 aggregate_kind TEXT NOT NULL,
12 stream_id TEXT NOT NULL,
13 stream_version INTEGER NOT NULL,
1114 event_type TEXT NOT NULL,
12 aggregate_type TEXT NOT NULL,
13 aggregate_id TEXT NOT NULL,
1415 occurred_at TEXT NOT NULL,
1516 command_id TEXT,
16 payload_json TEXT NOT NULL
17 causation_event_id TEXT,
18 correlation_id TEXT,
19 actor_kind TEXT NOT NULL,
20 payload_json TEXT NOT NULL,
21 metadata_json TEXT NOT NULL
1722 )
1823 `;
1924
2025 yield* sql`
21 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate
22 ON orchestration_events(aggregate_type, aggregate_id, sequence)
26 CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version
27 ON orchestration_events(aggregate_kind, stream_id, stream_version)
28 `;
29
30 yield* sql`
31 CREATE INDEX IF NOT EXISTS idx_orch_events_stream_sequence
32 ON orchestration_events(aggregate_kind, stream_id, sequence)
33 `;
34
35 yield* sql`
36 CREATE INDEX IF NOT EXISTS idx_orch_events_command_id
37 ON orchestration_events(command_id)
38 `;
39
40 yield* sql`
41 CREATE INDEX IF NOT EXISTS idx_orch_events_correlation_id
42 ON orchestration_events(correlation_id)
2343 `;
2444});
apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts created+28
......@@ -0,0 +1,28 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS orchestration_command_receipts (
9 command_id TEXT PRIMARY KEY,
10 aggregate_kind TEXT NOT NULL,
11 aggregate_id TEXT NOT NULL,
12 accepted_at TEXT NOT NULL,
13 result_sequence INTEGER NOT NULL,
14 status TEXT NOT NULL,
15 error TEXT
16 )
17 `;
18
19 yield* sql`
20 CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_aggregate
21 ON orchestration_command_receipts(aggregate_kind, aggregate_id)
22 `;
23
24 yield* sql`
25 CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_sequence
26 ON orchestration_command_receipts(result_sequence)
27 `;
28});
apps/server/src/persistence/Migrations/002_Projects.ts deleted-22
......@@ -1,22 +0,0 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS projects (
9 id TEXT PRIMARY KEY,
10 cwd TEXT NOT NULL UNIQUE,
11 name TEXT NOT NULL,
12 scripts_json TEXT NOT NULL,
13 created_at TEXT NOT NULL,
14 updated_at TEXT NOT NULL
15 )
16 `;
17
18 yield* sql`
19 CREATE INDEX IF NOT EXISTS idx_projects_created_at
20 ON projects(created_at DESC)
21 `;
22});
apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts created+22
......@@ -0,0 +1,22 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS checkpoint_diff_blobs (
9 thread_id TEXT NOT NULL,
10 from_turn_count INTEGER NOT NULL,
11 to_turn_count INTEGER NOT NULL,
12 diff TEXT NOT NULL,
13 created_at TEXT NOT NULL,
14 UNIQUE (thread_id, from_turn_count, to_turn_count)
15 )
16 `;
17
18 yield* sql`
19 CREATE INDEX IF NOT EXISTS idx_checkpoint_diff_blobs_thread_to_turn
20 ON checkpoint_diff_blobs(thread_id, to_turn_count)
21 `;
22});
apps/server/src/persistence/Migrations/003_Checkpoints.ts deleted-31
......@@ -1,31 +0,0 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS provider_checkpoints (
9 provider_session_id TEXT,
10 thread_id TEXT NOT NULL,
11 checkpoint_id TEXT NOT NULL,
12 checkpoint_ref TEXT NOT NULL,
13 turn_count INTEGER NOT NULL,
14 message_count INTEGER NOT NULL,
15 label TEXT NOT NULL,
16 preview TEXT,
17 created_at TEXT NOT NULL,
18 PRIMARY KEY (thread_id, turn_count)
19 )
20 `;
21
22 yield* sql`
23 CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_thread_turn
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)
30 `;
31});
apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts created+35
......@@ -0,0 +1,35 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS provider_session_runtime (
9 provider_session_id TEXT PRIMARY KEY,
10 thread_id TEXT NOT NULL,
11 provider_name TEXT NOT NULL,
12 adapter_key TEXT NOT NULL,
13 provider_thread_id TEXT,
14 status TEXT NOT NULL,
15 last_seen_at TEXT NOT NULL,
16 resume_cursor_json TEXT,
17 runtime_payload_json TEXT
18 )
19 `;
20
21 yield* sql`
22 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread
23 ON provider_session_runtime(thread_id)
24 `;
25
26 yield* sql`
27 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status
28 ON provider_session_runtime(status)
29 `;
30
31 yield* sql`
32 CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_provider
33 ON provider_session_runtime(provider_name)
34 `;
35});
apps/server/src/persistence/Migrations/004_ProviderSessions.ts deleted-21
......@@ -1,21 +0,0 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS provider_sessions (
9 session_id TEXT PRIMARY KEY,
10 provider TEXT NOT NULL,
11 thread_id TEXT,
12 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
13 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
14 )
15 `;
16
17 yield* sql`
18 CREATE INDEX IF NOT EXISTS idx_provider_sessions_provider
19 ON provider_sessions(provider)
20 `;
21});
apps/server/src/persistence/Migrations/005_Projections.ts created+161
......@@ -0,0 +1,161 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE TABLE IF NOT EXISTS projection_projects (
9 project_id TEXT PRIMARY KEY,
10 title TEXT NOT NULL,
11 workspace_root TEXT NOT NULL,
12 default_model TEXT,
13 scripts_json TEXT NOT NULL,
14 created_at TEXT NOT NULL,
15 updated_at TEXT NOT NULL,
16 deleted_at TEXT
17 )
18 `;
19
20 yield* sql`
21 CREATE TABLE IF NOT EXISTS projection_threads (
22 thread_id TEXT PRIMARY KEY,
23 project_id TEXT NOT NULL,
24 title TEXT NOT NULL,
25 model TEXT NOT NULL,
26 branch TEXT,
27 worktree_path TEXT,
28 latest_turn_id TEXT,
29 created_at TEXT NOT NULL,
30 updated_at TEXT NOT NULL,
31 deleted_at TEXT
32 )
33 `;
34
35 yield* sql`
36 CREATE TABLE IF NOT EXISTS projection_thread_messages (
37 message_id TEXT PRIMARY KEY,
38 thread_id TEXT NOT NULL,
39 turn_id TEXT,
40 role TEXT NOT NULL,
41 text TEXT NOT NULL,
42 is_streaming INTEGER NOT NULL,
43 created_at TEXT NOT NULL,
44 updated_at TEXT NOT NULL
45 )
46 `;
47
48 yield* sql`
49 CREATE TABLE IF NOT EXISTS projection_thread_activities (
50 activity_id TEXT PRIMARY KEY,
51 thread_id TEXT NOT NULL,
52 turn_id TEXT,
53 tone TEXT NOT NULL,
54 kind TEXT NOT NULL,
55 summary TEXT NOT NULL,
56 payload_json TEXT NOT NULL,
57 created_at TEXT NOT NULL
58 )
59 `;
60
61 yield* sql`
62 CREATE TABLE IF NOT EXISTS projection_thread_sessions (
63 thread_id TEXT PRIMARY KEY,
64 status TEXT NOT NULL,
65 provider_name TEXT,
66 provider_session_id TEXT,
67 provider_thread_id TEXT,
68 active_turn_id TEXT,
69 last_error TEXT,
70 updated_at TEXT NOT NULL
71 )
72 `;
73
74 yield* sql`
75 CREATE TABLE IF NOT EXISTS projection_thread_turns (
76 turn_id TEXT PRIMARY KEY,
77 thread_id TEXT NOT NULL,
78 turn_count INTEGER NOT NULL,
79 status TEXT NOT NULL,
80 user_message_id TEXT,
81 assistant_message_id TEXT,
82 started_at TEXT NOT NULL,
83 completed_at TEXT,
84 UNIQUE (thread_id, turn_count)
85 )
86 `;
87
88 yield* sql`
89 CREATE TABLE IF NOT EXISTS projection_checkpoints (
90 thread_id TEXT NOT NULL,
91 turn_id TEXT NOT NULL,
92 checkpoint_turn_count INTEGER NOT NULL,
93 checkpoint_ref TEXT NOT NULL,
94 status TEXT NOT NULL,
95 files_json TEXT NOT NULL,
96 assistant_message_id TEXT,
97 completed_at TEXT NOT NULL,
98 UNIQUE (thread_id, checkpoint_turn_count)
99 )
100 `;
101
102 yield* sql`
103 CREATE TABLE IF NOT EXISTS projection_pending_approvals (
104 request_id TEXT PRIMARY KEY,
105 thread_id TEXT NOT NULL,
106 turn_id TEXT,
107 status TEXT NOT NULL,
108 decision TEXT,
109 created_at TEXT NOT NULL,
110 resolved_at TEXT
111 )
112 `;
113
114 yield* sql`
115 CREATE TABLE IF NOT EXISTS projection_state (
116 projector TEXT PRIMARY KEY,
117 last_applied_sequence INTEGER NOT NULL,
118 updated_at TEXT NOT NULL
119 )
120 `;
121
122 yield* sql`
123 CREATE INDEX IF NOT EXISTS idx_projection_projects_updated_at
124 ON projection_projects(updated_at)
125 `;
126
127 yield* sql`
128 CREATE INDEX IF NOT EXISTS idx_projection_threads_project_id
129 ON projection_threads(project_id)
130 `;
131
132 yield* sql`
133 CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created
134 ON projection_thread_messages(thread_id, created_at)
135 `;
136
137 yield* sql`
138 CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_created
139 ON projection_thread_activities(thread_id, created_at)
140 `;
141
142 yield* sql`
143 CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_provider_session
144 ON projection_thread_sessions(provider_session_id)
145 `;
146
147 yield* sql`
148 CREATE INDEX IF NOT EXISTS idx_projection_thread_turns_thread_turn_count
149 ON projection_thread_turns(thread_id, turn_count)
150 `;
151
152 yield* sql`
153 CREATE INDEX IF NOT EXISTS idx_projection_checkpoints_thread_completed
154 ON projection_checkpoints(thread_id, completed_at)
155 `;
156
157 yield* sql`
158 CREATE INDEX IF NOT EXISTS idx_projection_pending_approvals_thread_status
159 ON projection_pending_approvals(thread_id, status)
160 `;
161});
apps/server/src/persistence/Services/CheckpointDiffBlobs.ts created+36
......@@ -0,0 +1,36 @@
1import { IsoDateTime, NonNegativeInt, ThreadId } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { CheckpointDiffBlobRepositoryError } from "../Errors.ts";
6
7export const CheckpointDiffBlob = Schema.Struct({
8 threadId: ThreadId,
9 fromTurnCount: NonNegativeInt,
10 toTurnCount: NonNegativeInt,
11 diff: Schema.String,
12 createdAt: IsoDateTime,
13});
14export type CheckpointDiffBlob = typeof CheckpointDiffBlob.Type;
15
16export const GetCheckpointDiffBlobInput = Schema.Struct({
17 threadId: ThreadId,
18 fromTurnCount: NonNegativeInt,
19 toTurnCount: NonNegativeInt,
20});
21export type GetCheckpointDiffBlobInput = typeof GetCheckpointDiffBlobInput.Type;
22
23export interface CheckpointDiffBlobRepositoryShape {
24 readonly upsert: (
25 blob: CheckpointDiffBlob,
26 ) => Effect.Effect<void, CheckpointDiffBlobRepositoryError>;
27
28 readonly get: (
29 input: GetCheckpointDiffBlobInput,
30 ) => Effect.Effect<Option.Option<CheckpointDiffBlob>, CheckpointDiffBlobRepositoryError>;
31}
32
33export class CheckpointDiffBlobRepository extends ServiceMap.Service<
34 CheckpointDiffBlobRepository,
35 CheckpointDiffBlobRepositoryShape
36>()("persistence/CheckpointDiffBlobRepository") {}
apps/server/src/persistence/Services/Checkpoints.ts deleted-95
......@@ -1,95 +0,0 @@
1/**
2 * CheckpointRepository - Repository interface for checkpoint metadata persistence.
3 *
4 * Stores and queries checkpoint metadata and checkpoint Git refs keyed by
5 * thread + turn count. It does not read/write Git refs itself and it
6 * does not coordinate provider rollback workflows.
7 *
8 * @module CheckpointRepository
9 */
10import type { ProviderCheckpoint } from "@t3tools/contracts";
11import { Option, ServiceMap } from "effect";
12import type { Effect } from "effect";
13
14import type { CheckpointRepositoryError } from "../Errors.ts";
15
16export interface UpsertCheckpointInput {
17 readonly providerSessionId?: string;
18 readonly threadId: string;
19 readonly checkpointId: string;
20 readonly checkpointRef: string;
21 readonly turnCount: number;
22 readonly messageCount: number;
23 readonly label: string;
24 readonly preview?: string;
25 readonly createdAt: string;
26}
27
28export interface CheckpointRepositoryEntry extends ProviderCheckpoint {
29 readonly threadId: string;
30 readonly checkpointRef: string;
31 readonly createdAt: string;
32}
33
34export interface ListThreadCheckpointsInput {
35 readonly threadId: string;
36}
37
38export interface GetCheckpointInput {
39 readonly threadId: string;
40 readonly turnCount: number;
41}
42
43export interface DeleteAfterTurnInput {
44 readonly threadId: string;
45 readonly maxTurnCount: number;
46}
47
48export interface DeleteAllForThreadInput {
49 readonly threadId: string;
50}
51
52export interface CheckpointRepositoryShape {
53 /**
54 * Insert or update one checkpoint metadata row.
55 */
56 readonly upsertCheckpoint: (
57 input: UpsertCheckpointInput,
58 ) => Effect.Effect<void, CheckpointRepositoryError>;
59
60 /**
61 * List user-facing checkpoints for a thread.
62 */
63 readonly listCheckpoints: (
64 input: ListThreadCheckpointsInput,
65 ) => Effect.Effect<ReadonlyArray<ProviderCheckpoint>, CheckpointRepositoryError>;
66
67 /**
68 * Read one checkpoint record (including checkpoint ref) by turn count.
69 */
70 readonly getCheckpoint: (
71 input: GetCheckpointInput,
72 ) => Effect.Effect<Option.Option<CheckpointRepositoryEntry>, CheckpointRepositoryError>;
73
74 /**
75 * Delete checkpoint metadata newer than the provided turn count.
76 */
77 readonly deleteAfterTurn: (
78 input: DeleteAfterTurnInput,
79 ) => Effect.Effect<void, CheckpointRepositoryError>;
80
81 /**
82 * Delete all checkpoint metadata for a thread.
83 */
84 readonly deleteAllForThread: (
85 input: DeleteAllForThreadInput,
86 ) => Effect.Effect<void, CheckpointRepositoryError>;
87}
88
89/**
90 * CheckpointRepository - Service tag for checkpoint metadata persistence.
91 */
92export class CheckpointRepository extends ServiceMap.Service<
93 CheckpointRepository,
94 CheckpointRepositoryShape
95>()("persistence/CheckpointRepository") {}
apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts created+47
......@@ -0,0 +1,47 @@
1import {
2 CommandId,
3 IsoDateTime,
4 NonNegativeInt,
5 OrchestrationAggregateKind,
6 OrchestrationCommandReceiptStatus,
7 ProjectId,
8 ThreadId,
9} from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";
12
13import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts";
14
15export const OrchestrationCommandReceipt = Schema.Struct({
16 commandId: CommandId,
17 aggregateKind: OrchestrationAggregateKind,
18 aggregateId: Schema.Union([ProjectId, ThreadId]),
19 acceptedAt: IsoDateTime,
20 resultSequence: NonNegativeInt,
21 status: OrchestrationCommandReceiptStatus,
22 error: Schema.NullOr(Schema.String),
23});
24export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type;
25
26export const GetByCommandIdInput = Schema.Struct({
27 commandId: CommandId,
28});
29export type GetByCommandIdInput = typeof GetByCommandIdInput.Type;
30
31export interface OrchestrationCommandReceiptRepositoryShape {
32 readonly upsert: (
33 receipt: OrchestrationCommandReceipt,
34 ) => Effect.Effect<void, OrchestrationCommandReceiptRepositoryError>;
35
36 readonly getByCommandId: (
37 input: GetByCommandIdInput,
38 ) => Effect.Effect<
39 Option.Option<OrchestrationCommandReceipt>,
40 OrchestrationCommandReceiptRepositoryError
41 >;
42}
43
44export class OrchestrationCommandReceiptRepository extends ServiceMap.Service<
45 OrchestrationCommandReceiptRepository,
46 OrchestrationCommandReceiptRepositoryShape
47>()("persistence/OrchestrationCommandReceiptRepository") {}
apps/server/src/persistence/Services/OrchestrationEventStore.ts+1-1
......@@ -9,7 +9,7 @@
99 *
1010 * @module OrchestrationEventStore
1111 */
12import type { OrchestrationEvent } from "@t3tools/contracts";
12import { OrchestrationEvent } from "@t3tools/contracts";
1313import { ServiceMap } from "effect";
1414import type { Effect, Stream } from "effect";
1515
apps/server/src/persistence/Services/ProjectionCheckpoints.ts created+63
......@@ -0,0 +1,63 @@
1import {
2 CheckpointRef,
3 IsoDateTime,
4 MessageId,
5 NonNegativeInt,
6 OrchestrationCheckpointFile,
7 OrchestrationCheckpointStatus,
8 ThreadId,
9 TurnId,
10} from "@t3tools/contracts";
11import { Option, ServiceMap, Schema } from "effect";
12import type { Effect } from "effect";
13
14import type { ProjectionRepositoryError } from "../Errors.ts";
15
16export const ProjectionCheckpoint = Schema.Struct({
17 threadId: ThreadId,
18 turnId: TurnId,
19 checkpointTurnCount: NonNegativeInt,
20 checkpointRef: CheckpointRef,
21 status: OrchestrationCheckpointStatus,
22 files: Schema.Array(OrchestrationCheckpointFile),
23 assistantMessageId: Schema.NullOr(MessageId),
24 completedAt: IsoDateTime,
25});
26export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type;
27
28export const ListByThreadIdInput = Schema.Struct({
29 threadId: ThreadId,
30});
31export type ListByThreadIdInput = typeof ListByThreadIdInput.Type;
32
33export const GetByThreadAndTurnCountInput = Schema.Struct({
34 threadId: ThreadId,
35 checkpointTurnCount: NonNegativeInt,
36});
37export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type;
38
39export const DeleteByThreadIdInput = Schema.Struct({
40 threadId: ThreadId,
41});
42export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type;
43
44export interface ProjectionCheckpointRepositoryShape {
45 readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect<void, ProjectionRepositoryError>;
46
47 readonly listByThreadId: (
48 input: ListByThreadIdInput,
49 ) => Effect.Effect<ReadonlyArray<ProjectionCheckpoint>, ProjectionRepositoryError>;
50
51 readonly getByThreadAndTurnCount: (
52 input: GetByThreadAndTurnCountInput,
53 ) => Effect.Effect<Option.Option<ProjectionCheckpoint>, ProjectionRepositoryError>;
54
55 readonly deleteByThreadId: (
56 input: DeleteByThreadIdInput,
57 ) => Effect.Effect<void, ProjectionRepositoryError>;
58}
59
60export class ProjectionCheckpointRepository extends ServiceMap.Service<
61 ProjectionCheckpointRepository,
62 ProjectionCheckpointRepositoryShape
63>()("persistence/ProjectionCheckpointRepository") {}
apps/server/src/persistence/Services/ProjectionPendingApprovals.ts created+61
......@@ -0,0 +1,61 @@
1import {
2 ApprovalRequestId,
3 IsoDateTime,
4 ProjectionPendingApprovalDecision,
5 ProjectionPendingApprovalStatus,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
9import { Option, Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { ProjectionRepositoryError } from "../Errors.ts";
13
14export const ProjectionPendingApproval = Schema.Struct({
15 requestId: ApprovalRequestId,
16 threadId: ThreadId,
17 turnId: Schema.NullOr(TurnId),
18 status: ProjectionPendingApprovalStatus,
19 decision: ProjectionPendingApprovalDecision,
20 createdAt: IsoDateTime,
21 resolvedAt: Schema.NullOr(IsoDateTime),
22});
23export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type;
24
25export const ListProjectionPendingApprovalsInput = Schema.Struct({
26 threadId: ThreadId,
27});
28export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type;
29
30export const GetProjectionPendingApprovalInput = Schema.Struct({
31 requestId: ApprovalRequestId,
32});
33export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;
34
35export const DeleteProjectionPendingApprovalInput = Schema.Struct({
36 requestId: ApprovalRequestId,
37});
38export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type;
39
40export interface ProjectionPendingApprovalRepositoryShape {
41 readonly upsert: (
42 row: ProjectionPendingApproval,
43 ) => Effect.Effect<void, ProjectionRepositoryError>;
44
45 readonly listByThreadId: (
46 input: ListProjectionPendingApprovalsInput,
47 ) => Effect.Effect<ReadonlyArray<ProjectionPendingApproval>, ProjectionRepositoryError>;
48
49 readonly getByRequestId: (
50 input: GetProjectionPendingApprovalInput,
51 ) => Effect.Effect<Option.Option<ProjectionPendingApproval>, ProjectionRepositoryError>;
52
53 readonly deleteByRequestId: (
54 input: DeleteProjectionPendingApprovalInput,
55 ) => Effect.Effect<void, ProjectionRepositoryError>;
56}
57
58export class ProjectionPendingApprovalRepository extends ServiceMap.Service<
59 ProjectionPendingApprovalRepository,
60 ProjectionPendingApprovalRepositoryShape
61>()("persistence/ProjectionPendingApprovalRepository") {}
apps/server/src/persistence/Services/ProjectionProjects.ts created+49
......@@ -0,0 +1,49 @@
1import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { ProjectionRepositoryError } from "../Errors.ts";
6
7export const ProjectionProject = Schema.Struct({
8 projectId: ProjectId,
9 title: Schema.String,
10 workspaceRoot: Schema.String,
11 defaultModel: Schema.NullOr(Schema.String),
12 scripts: Schema.Array(ProjectScript),
13 createdAt: IsoDateTime,
14 updatedAt: IsoDateTime,
15 deletedAt: Schema.NullOr(IsoDateTime),
16});
17export type ProjectionProject = typeof ProjectionProject.Type;
18
19export const GetProjectionProjectInput = Schema.Struct({
20 projectId: ProjectId,
21});
22export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;
23
24export const DeleteProjectionProjectInput = Schema.Struct({
25 projectId: ProjectId,
26});
27export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type;
28
29export interface ProjectionProjectRepositoryShape {
30 readonly upsert: (row: ProjectionProject) => Effect.Effect<void, ProjectionRepositoryError>;
31
32 readonly getById: (
33 input: GetProjectionProjectInput,
34 ) => Effect.Effect<Option.Option<ProjectionProject>, ProjectionRepositoryError>;
35
36 readonly listAll: () => Effect.Effect<
37 ReadonlyArray<ProjectionProject>,
38 ProjectionRepositoryError
39 >;
40
41 readonly deleteById: (
42 input: DeleteProjectionProjectInput,
43 ) => Effect.Effect<void, ProjectionRepositoryError>;
44}
45
46export class ProjectionProjectRepository extends ServiceMap.Service<
47 ProjectionProjectRepository,
48 ProjectionProjectRepositoryShape
49>()("persistence/ProjectionProjectRepository") {}
apps/server/src/persistence/Services/ProjectionState.ts created+34
......@@ -0,0 +1,34 @@
1import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { ProjectionRepositoryError } from "../Errors.ts";
6
7export const ProjectionState = Schema.Struct({
8 projector: Schema.String,
9 lastAppliedSequence: NonNegativeInt,
10 updatedAt: IsoDateTime,
11});
12export type ProjectionState = typeof ProjectionState.Type;
13
14export const GetProjectionStateInput = Schema.Struct({
15 projector: Schema.String,
16});
17export type GetProjectionStateInput = typeof GetProjectionStateInput.Type;
18
19export interface ProjectionStateRepositoryShape {
20 readonly upsert: (row: ProjectionState) => Effect.Effect<void, ProjectionRepositoryError>;
21
22 readonly getByProjector: (
23 input: GetProjectionStateInput,
24 ) => Effect.Effect<Option.Option<ProjectionState>, ProjectionRepositoryError>;
25
26 readonly listAll: () => Effect.Effect<ReadonlyArray<ProjectionState>, ProjectionRepositoryError>;
27
28 readonly minLastAppliedSequence: () => Effect.Effect<number | null, ProjectionRepositoryError>;
29}
30
31export class ProjectionStateRepository extends ServiceMap.Service<
32 ProjectionStateRepository,
33 ProjectionStateRepositoryShape
34>()("persistence/ProjectionStateRepository") {}
apps/server/src/persistence/Services/ProjectionThreadActivities.ts created+53
......@@ -0,0 +1,53 @@
1import {
2 EventId,
3 IsoDateTime,
4 OrchestrationThreadActivityTone,
5 ThreadId,
6 TurnId,
7} from "@t3tools/contracts";
8import { Schema, ServiceMap } from "effect";
9import type { Effect } from "effect";
10
11import type { ProjectionRepositoryError } from "../Errors.ts";
12
13export const ProjectionThreadActivity = Schema.Struct({
14 activityId: EventId,
15 threadId: ThreadId,
16 turnId: Schema.NullOr(TurnId),
17 tone: OrchestrationThreadActivityTone,
18 kind: Schema.String,
19 summary: Schema.String,
20 payload: Schema.Unknown,
21 createdAt: IsoDateTime,
22});
23export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type;
24
25export const ListProjectionThreadActivitiesInput = Schema.Struct({
26 threadId: ThreadId,
27});
28export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type;
29
30export const DeleteProjectionThreadActivitiesInput = Schema.Struct({
31 threadId: ThreadId,
32});
33export type DeleteProjectionThreadActivitiesInput =
34 typeof DeleteProjectionThreadActivitiesInput.Type;
35
36export interface ProjectionThreadActivityRepositoryShape {
37 readonly upsert: (
38 row: ProjectionThreadActivity,
39 ) => Effect.Effect<void, ProjectionRepositoryError>;
40
41 readonly listByThreadId: (
42 input: ListProjectionThreadActivitiesInput,
43 ) => Effect.Effect<ReadonlyArray<ProjectionThreadActivity>, ProjectionRepositoryError>;
44
45 readonly deleteByThreadId: (
46 input: DeleteProjectionThreadActivitiesInput,
47 ) => Effect.Effect<void, ProjectionRepositoryError>;
48}
49
50export class ProjectionThreadActivityRepository extends ServiceMap.Service<
51 ProjectionThreadActivityRepository,
52 ProjectionThreadActivityRepositoryShape
53>()("persistence/ProjectionThreadActivityRepository") {}
apps/server/src/persistence/Services/ProjectionThreadMessages.ts created+52
......@@ -0,0 +1,52 @@
1import {
2 OrchestrationMessageRole,
3 MessageId,
4 ThreadId,
5 TurnId,
6 IsoDateTime,
7} from "@t3tools/contracts";
8import { Schema, ServiceMap } from "effect";
9import type { Effect } from "effect";
10
11import type { ProjectionRepositoryError } from "../Errors.ts";
12
13export const ProjectionThreadMessage = Schema.Struct({
14 messageId: MessageId,
15 threadId: ThreadId,
16 turnId: Schema.NullOr(TurnId),
17 role: OrchestrationMessageRole,
18 text: Schema.String,
19 isStreaming: Schema.Boolean,
20 createdAt: IsoDateTime,
21 updatedAt: IsoDateTime,
22});
23export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type;
24
25export const ListProjectionThreadMessagesInput = Schema.Struct({
26 threadId: ThreadId,
27});
28export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type;
29
30export const DeleteProjectionThreadMessagesInput = Schema.Struct({
31 threadId: ThreadId,
32});
33export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type;
34
35export interface ProjectionThreadMessageRepositoryShape {
36 readonly upsert: (
37 message: ProjectionThreadMessage,
38 ) => Effect.Effect<void, ProjectionRepositoryError>;
39
40 readonly listByThreadId: (
41 input: ListProjectionThreadMessagesInput,
42 ) => Effect.Effect<ReadonlyArray<ProjectionThreadMessage>, ProjectionRepositoryError>;
43
44 readonly deleteByThreadId: (
45 input: DeleteProjectionThreadMessagesInput,
46 ) => Effect.Effect<void, ProjectionRepositoryError>;
47}
48
49export class ProjectionThreadMessageRepository extends ServiceMap.Service<
50 ProjectionThreadMessageRepository,
51 ProjectionThreadMessageRepositoryShape
52>()("persistence/ProjectionThreadMessageRepository") {}
apps/server/src/persistence/Services/ProjectionThreadSessions.ts created+51
......@@ -0,0 +1,51 @@
1import {
2 IsoDateTime,
3 OrchestrationSessionStatus,
4 ProviderSessionId,
5 ProviderThreadId,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
9import { Option, Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { ProjectionRepositoryError } from "../Errors.ts";
13
14export const ProjectionThreadSession = Schema.Struct({
15 threadId: ThreadId,
16 status: OrchestrationSessionStatus,
17 providerName: Schema.NullOr(Schema.String),
18 providerSessionId: Schema.NullOr(ProviderSessionId),
19 providerThreadId: Schema.NullOr(ProviderThreadId),
20 activeTurnId: Schema.NullOr(TurnId),
21 lastError: Schema.NullOr(Schema.String),
22 updatedAt: IsoDateTime,
23});
24export type ProjectionThreadSession = typeof ProjectionThreadSession.Type;
25
26export const GetProjectionThreadSessionInput = Schema.Struct({
27 threadId: ThreadId,
28});
29export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type;
30
31export const DeleteProjectionThreadSessionInput = Schema.Struct({
32 threadId: ThreadId,
33});
34export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type;
35
36export interface ProjectionThreadSessionRepositoryShape {
37 readonly upsert: (row: ProjectionThreadSession) => Effect.Effect<void, ProjectionRepositoryError>;
38
39 readonly getByThreadId: (
40 input: GetProjectionThreadSessionInput,
41 ) => Effect.Effect<Option.Option<ProjectionThreadSession>, ProjectionRepositoryError>;
42
43 readonly deleteByThreadId: (
44 input: DeleteProjectionThreadSessionInput,
45 ) => Effect.Effect<void, ProjectionRepositoryError>;
46}
47
48export class ProjectionThreadSessionRepository extends ServiceMap.Service<
49 ProjectionThreadSessionRepository,
50 ProjectionThreadSessionRepositoryShape
51>()("persistence/ProjectionThreadSessionRepository") {}
apps/server/src/persistence/Services/ProjectionThreadTurns.ts created+60
......@@ -0,0 +1,60 @@
1import {
2 IsoDateTime,
3 MessageId,
4 NonNegativeInt,
5 ProjectionThreadTurnStatus,
6 ThreadId,
7 TurnId,
8} from "@t3tools/contracts";
9import { Option, Schema, ServiceMap } from "effect";
10import type { Effect } from "effect";
11
12import type { ProjectionRepositoryError } from "../Errors.ts";
13
14export const ProjectionThreadTurn = Schema.Struct({
15 turnId: TurnId,
16 threadId: ThreadId,
17 turnCount: NonNegativeInt,
18 status: ProjectionThreadTurnStatus,
19 userMessageId: Schema.NullOr(MessageId),
20 assistantMessageId: Schema.NullOr(MessageId),
21 startedAt: IsoDateTime,
22 completedAt: Schema.NullOr(IsoDateTime),
23});
24export type ProjectionThreadTurn = typeof ProjectionThreadTurn.Type;
25
26export const ListProjectionThreadTurnsInput = Schema.Struct({
27 threadId: ThreadId,
28});
29export type ListProjectionThreadTurnsInput = typeof ListProjectionThreadTurnsInput.Type;
30
31export const GetProjectionThreadTurnInput = Schema.Struct({
32 turnId: TurnId,
33});
34export type GetProjectionThreadTurnInput = typeof GetProjectionThreadTurnInput.Type;
35
36export const DeleteProjectionThreadTurnsInput = Schema.Struct({
37 threadId: ThreadId,
38});
39export type DeleteProjectionThreadTurnsInput = typeof DeleteProjectionThreadTurnsInput.Type;
40
41export interface ProjectionThreadTurnRepositoryShape {
42 readonly upsert: (turn: ProjectionThreadTurn) => Effect.Effect<void, ProjectionRepositoryError>;
43
44 readonly listByThreadId: (
45 input: ListProjectionThreadTurnsInput,
46 ) => Effect.Effect<ReadonlyArray<ProjectionThreadTurn>, ProjectionRepositoryError>;
47
48 readonly getByTurnId: (
49 input: GetProjectionThreadTurnInput,
50 ) => Effect.Effect<Option.Option<ProjectionThreadTurn>, ProjectionRepositoryError>;
51
52 readonly deleteByThreadId: (
53 input: DeleteProjectionThreadTurnsInput,
54 ) => Effect.Effect<void, ProjectionRepositoryError>;
55}
56
57export class ProjectionThreadTurnRepository extends ServiceMap.Service<
58 ProjectionThreadTurnRepository,
59 ProjectionThreadTurnRepositoryShape
60>()("persistence/ProjectionThreadTurnRepository") {}
apps/server/src/persistence/Services/ProjectionThreads.ts created+55
......@@ -0,0 +1,55 @@
1import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { ProjectionRepositoryError } from "../Errors.ts";
6
7export const ProjectionThread = Schema.Struct({
8 threadId: ThreadId,
9 projectId: ProjectId,
10 title: Schema.String,
11 model: Schema.String,
12 branch: Schema.NullOr(Schema.String),
13 worktreePath: Schema.NullOr(Schema.String),
14 latestTurnId: Schema.NullOr(TurnId),
15 createdAt: IsoDateTime,
16 updatedAt: IsoDateTime,
17 deletedAt: Schema.NullOr(IsoDateTime),
18});
19export type ProjectionThread = typeof ProjectionThread.Type;
20
21export const GetProjectionThreadInput = Schema.Struct({
22 threadId: ThreadId,
23});
24export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type;
25
26export const DeleteProjectionThreadInput = Schema.Struct({
27 threadId: ThreadId,
28});
29export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type;
30
31export const ListProjectionThreadsByProjectInput = Schema.Struct({
32 projectId: ProjectId,
33});
34export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type;
35
36export interface ProjectionThreadRepositoryShape {
37 readonly upsert: (thread: ProjectionThread) => Effect.Effect<void, ProjectionRepositoryError>;
38
39 readonly getById: (
40 input: GetProjectionThreadInput,
41 ) => Effect.Effect<Option.Option<ProjectionThread>, ProjectionRepositoryError>;
42
43 readonly listByProjectId: (
44 input: ListProjectionThreadsByProjectInput,
45 ) => Effect.Effect<ReadonlyArray<ProjectionThread>, ProjectionRepositoryError>;
46
47 readonly deleteById: (
48 input: DeleteProjectionThreadInput,
49 ) => Effect.Effect<void, ProjectionRepositoryError>;
50}
51
52export class ProjectionThreadRepository extends ServiceMap.Service<
53 ProjectionThreadRepository,
54 ProjectionThreadRepositoryShape
55>()("persistence/ProjectionThreadRepository") {}
apps/server/src/persistence/Services/Projects.ts deleted-81
......@@ -1,81 +0,0 @@
1/**
2 * ProjectRepository - Repository interface for project persistence.
3 *
4 * Owns persisted project records (cwd, scripts, and cleanup of missing paths).
5 * It does not manage provider sessions, orchestration events, or websocket APIs.
6 *
7 * Uses Effect `ServiceMap.Service` for dependency injection and returns typed
8 * domain errors for all operations.
9 *
10 * @module ProjectRepository
11 */
12import type {
13 ProjectAddInput,
14 ProjectAddResult,
15 ProjectListResult,
16 ProjectRemoveInput,
17 ProjectUpdateScriptsInput,
18 ProjectUpdateScriptsResult,
19} from "@t3tools/contracts";
20import { ServiceMap } from "effect";
21import type { Effect } from "effect";
22
23import type { ProjectRepositoryError } from "../Errors.ts";
24
25export interface ProjectRepositoryShape {
26 /**
27 * List all persisted projects.
28 *
29 * @returns Effect containing all project records.
30 */
31 readonly list: () => Effect.Effect<ProjectListResult, ProjectRepositoryError>;
32
33 /**
34 * Add a project by cwd, or return the existing project when the cwd already exists.
35 *
36 * @param input - Project creation payload.
37 * @returns Effect containing the project and whether it was newly created.
38 */
39 readonly add: (input: ProjectAddInput) => Effect.Effect<ProjectAddResult, ProjectRepositoryError>;
40
41 /**
42 * Remove a persisted project.
43 *
44 * @param input - Project removal payload.
45 * @returns Effect containing void.
46 */
47 readonly remove: (input: ProjectRemoveInput) => Effect.Effect<void, ProjectRepositoryError>;
48
49 /**
50 * Update the scripts for an existing project.
51 *
52 * @param input - Script update payload.
53 * @returns Effect containing the updated project.
54 */
55 readonly updateScripts: (
56 input: ProjectUpdateScriptsInput,
57 ) => Effect.Effect<ProjectUpdateScriptsResult, ProjectRepositoryError>;
58
59 /**
60 * Remove projects whose cwd no longer exists on disk.
61 *
62 * @returns Effect containing void.
63 */
64 readonly pruneMissing: () => Effect.Effect<void, ProjectRepositoryError>;
65}
66
67/**
68 * ProjectRepository - Service tag for project persistence dependency injection.
69 *
70 * @example
71 * ```ts
72 * const program = Effect.gen(function* () {
73 * const projects = yield* ProjectRepository
74 * return yield* projects.list()
75 * })
76 * ```
77 */
78export class ProjectRepository extends ServiceMap.Service<
79 ProjectRepository,
80 ProjectRepositoryShape
81>()("persistence/ProjectRepository") {}
apps/server/src/persistence/Services/ProviderSessionRuntime.ts created+58
......@@ -0,0 +1,58 @@
1import {
2 IsoDateTime,
3 ProviderSessionId,
4 ProviderSessionRuntimeStatus,
5 ProviderThreadId,
6 ThreadId,
7} from "@t3tools/contracts";
8import { Option, Schema, ServiceMap } from "effect";
9import type { Effect } from "effect";
10
11import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts";
12
13export const ProviderSessionRuntime = Schema.Struct({
14 providerSessionId: ProviderSessionId,
15 threadId: ThreadId,
16 providerName: Schema.String,
17 adapterKey: Schema.String,
18 providerThreadId: Schema.NullOr(ProviderThreadId),
19 status: ProviderSessionRuntimeStatus,
20 lastSeenAt: IsoDateTime,
21 resumeCursor: Schema.NullOr(Schema.Unknown),
22 runtimePayload: Schema.NullOr(Schema.Unknown),
23});
24export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type;
25
26export const GetProviderSessionRuntimeInput = Schema.Struct({
27 providerSessionId: ProviderSessionId,
28});
29export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type;
30
31export const DeleteProviderSessionRuntimeInput = Schema.Struct({
32 providerSessionId: ProviderSessionId,
33});
34export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;
35
36export interface ProviderSessionRuntimeRepositoryShape {
37 readonly upsert: (
38 runtime: ProviderSessionRuntime,
39 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
40
41 readonly getBySessionId: (
42 input: GetProviderSessionRuntimeInput,
43 ) => Effect.Effect<Option.Option<ProviderSessionRuntime>, ProviderSessionRuntimeRepositoryError>;
44
45 readonly list: () => Effect.Effect<
46 ReadonlyArray<ProviderSessionRuntime>,
47 ProviderSessionRuntimeRepositoryError
48 >;
49
50 readonly deleteBySessionId: (
51 input: DeleteProviderSessionRuntimeInput,
52 ) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
53}
54
55export class ProviderSessionRuntimeRepository extends ServiceMap.Service<
56 ProviderSessionRuntimeRepository,
57 ProviderSessionRuntimeRepositoryShape
58>()("persistence/ProviderSessionRuntimeRepository") {}
apps/server/src/persistence/Services/ProviderSessions.ts+7-7
......@@ -1,29 +1,29 @@
1import type { ProviderKind } from "@t3tools/contracts";
1import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts";
22import { Option, ServiceMap } from "effect";
33import type { Effect } from "effect";
44
55import type { ProviderSessionRepositoryError } from "../Errors.ts";
66
77export interface ProviderSessionEntry {
8 readonly sessionId: string;
8 readonly sessionId: ProviderSessionId;
99 readonly provider: ProviderKind;
10 readonly threadId?: string;
10 readonly threadId?: ThreadId;
1111 readonly createdAt: string;
1212 readonly updatedAt: string;
1313}
1414
1515export interface UpsertProviderSessionInput {
16 readonly sessionId: string;
16 readonly sessionId: ProviderSessionId;
1717 readonly provider: ProviderKind;
18 readonly threadId?: string;
18 readonly threadId?: ThreadId;
1919}
2020
2121export interface DeleteProviderSessionInput {
22 readonly sessionId: string;
22 readonly sessionId: ProviderSessionId;
2323}
2424
2525export interface GetProviderSessionInput {
26 readonly sessionId: string;
26 readonly sessionId: ProviderSessionId;
2727}
2828
2929export interface ProviderSessionRepositoryShape {
apps/server/src/provider/Layers/CodexAdapter.ts+26-12
......@@ -6,13 +6,18 @@
66 *
77 * @module CodexAdapterLive
88 */
9import type {
10 ProviderEvent,
11 ProviderRuntimeEvent,
12 ProviderRuntimeTurnStatus,
13 ProviderRuntimeToolKind,
9import {
10 type ProviderEvent,
11 type ProviderRuntimeEvent,
12 type ProviderRuntimeTurnStatus,
13 type ProviderRuntimeToolKind,
14 ProviderItemId,
15 ProviderApprovalDecision,
16 ProviderSessionId,
17 ProviderThreadId,
18 ProviderTurnId,
1419} from "@t3tools/contracts";
15import { Effect, Layer, Queue, Stream } from "effect";
20import { Effect, Layer, Queue, Schema, Stream } from "effect";
1621
1722import {
1823 ProviderAdapterProcessError,
......@@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string {
4247}
4348
4449function toSessionError(
45 sessionId: string,
50 sessionId: ProviderSessionId,
4651 cause: unknown,
4752): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
4853 const normalized = toMessage(cause, "").toLowerCase();
......@@ -63,7 +68,11 @@ function toSessionError(
6368 return undefined;
6469}
6570
66function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError {
71function toRequestError(
72 sessionId: ProviderSessionId,
73 method: string,
74 cause: unknown,
75): ProviderAdapterError {
6776 const sessionError = toSessionError(sessionId, cause);
6877 if (sessionError) {
6978 return sessionError;
......@@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent |
188197 provider: event.provider,
189198 sessionId: event.sessionId,
190199 createdAt: event.createdAt,
191 itemId,
200 itemId: ProviderItemId.makeUnsafe(itemId),
192201 ...(event.threadId ? { threadId: event.threadId } : {}),
193202 ...(event.turnId ? { turnId: event.turnId } : {}),
194203 };
......@@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
282291 }
283292
284293 if (event.method === "item/requestApproval/decision" && event.requestId) {
285 const decision = asString(payload?.decision);
294 const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision);
286295 return [
287296 {
288297 type: "approval.resolved",
......@@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
339348 }
340349
341350 if (event.method === "thread/started") {
342 const threadId = event.threadId ?? asString(asObject(payload?.thread)?.id);
351 const payloadThreadId = asString(asObject(payload?.thread)?.id);
352 const threadId =
353 event.threadId ??
354 (payloadThreadId ? ProviderThreadId.makeUnsafe(payloadThreadId) : undefined);
343355 if (!threadId) {
344356 return [];
345357 }
......@@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
356368 }
357369
358370 if (event.method === "turn/started") {
359 const turnId = event.turnId ?? asString(turn?.id);
371 const payloadTurnId = asString(turn?.id);
372 const turnId =
373 event.turnId ?? (payloadTurnId ? ProviderTurnId.makeUnsafe(payloadTurnId) : undefined);
360374 if (!turnId) {
361375 return [];
362376 }
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-5
......@@ -7,7 +7,6 @@
77 *
88 * @module ProviderAdapterRegistryLive
99 */
10import type { ProviderKind } from "@t3tools/contracts";
1110import { Effect, Layer } from "effect";
1211
1312import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
......@@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions {
2524const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>
2625 Effect.gen(function* () {
2726 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];
28 const byProvider = new Map<ProviderKind, ProviderAdapterShape<ProviderAdapterError>>();
29 for (const adapter of adapters) {
30 byProvider.set(adapter.provider, adapter);
31 }
27 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3228
3329 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
3430 const adapter = byProvider.get(provider);
apps/server/src/provider/Layers/ProviderService.test.ts+216-234
......@@ -4,25 +4,26 @@ import path from "node:path";
44
55import type {
66 ProviderApprovalDecision,
7 ProviderGetCheckpointDiffInput,
8 ProviderGetCheckpointDiffResult,
9 ProviderListCheckpointsInput,
10 ProviderListCheckpointsResult,
11 ProviderRevertToCheckpointInput,
127 ProviderRuntimeEvent,
13 ProviderRevertToCheckpointResult,
148 ProviderSendTurnInput,
159 ProviderSession,
16 ProviderSessionStartInput,
1710 ProviderTurnStartResult,
1811} from "@t3tools/contracts";
19import { providerSessionStartInputSchema } from "@t3tools/contracts";
12import {
13 ApprovalRequestId,
14 EventId,
15 ProviderSessionStartInput,
16 ProviderSessionId,
17 ProviderThreadId,
18 ProviderTurnId,
19 ThreadId,
20} from "@t3tools/contracts";
2021import { it, assert, vi } from "@effect/vitest";
2122import { assertFailure } from "@effect/vitest/utils";
2223
23import { Effect, Fiber, Layer, PubSub, Ref, Stream } from "effect";
24import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect";
25import * as SqlClient from "effect/unstable/sql/SqlClient";
2426
25import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";
2627import {
2728 ProviderAdapterSessionNotFoundError,
2829 ProviderSessionNotFoundError,
......@@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
3435import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
3536import { ProviderService } from "../Services/ProviderService.ts";
3637import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
37import { ProviderServiceLive } from "./ProviderService.ts";
38import { makeProviderServiceLive } from "./ProviderService.ts";
3839import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
3940import { NodeServices } from "@effect/platform-node";
40import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts";
41import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
42import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
4143import {
4244 makeSqlitePersistenceLive,
4345 SqlitePersistenceMemory,
4446} from "../../persistence/Layers/Sqlite.ts";
4547
48function decodeIssueMessage(cause: unknown): string {
49 if (cause instanceof Error && cause.message.length > 0) {
50 return cause.message;
51 }
52 try {
53 return JSON.stringify(cause);
54 } catch {
55 return "Invalid provider input.";
56 }
57}
58
59const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
60const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
61const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
62const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
63const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
64
4665function makeFakeCodexAdapter() {
47 const sessions = new Map<string, ProviderSession>();
66 const sessions = new Map<ProviderSessionId, ProviderSession>();
4867 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
4968 let nextSession = 1;
5069
5170 const startSession = vi.fn((input: ProviderSessionStartInput) =>
5271 Effect.sync(() => {
5372 const now = new Date().toISOString();
73 const next = nextSession;
5474 const session: ProviderSession = {
55 sessionId: `sess-${nextSession}`,
75 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
5676 provider: "codex",
5777 status: "ready",
58 threadId: input.resumeThreadId ?? `thread-${nextSession}`,
78 threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`),
5979 cwd: input.cwd ?? process.cwd(),
6080 createdAt: now,
6181 updatedAt: now,
......@@ -80,8 +100,8 @@ function makeFakeCodexAdapter() {
80100 }
81101
82102 return Effect.succeed({
83 threadId: "thread-1",
84 turnId: "turn-1",
103 threadId: ProviderThreadId.makeUnsafe("thread-1"),
104 turnId: ProviderTurnId.makeUnsafe("turn-1"),
85105 });
86106 },
87107 );
......@@ -102,7 +122,7 @@ function makeFakeCodexAdapter() {
102122 const stopSession = vi.fn(
103123 (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>
104124 Effect.sync(() => {
105 sessions.delete(sessionId);
125 sessions.delete(ProviderSessionId.makeUnsafe(sessionId));
106126 }),
107127 );
108128
......@@ -112,22 +132,29 @@ function makeFakeCodexAdapter() {
112132 );
113133
114134 const hasSession = vi.fn(
115 (sessionId: string): Effect.Effect<boolean> => Effect.succeed(sessions.has(sessionId)),
135 (sessionId: string): Effect.Effect<boolean> =>
136 Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))),
116137 );
117138
118139 const readThread = vi.fn(
119140 (
120 _sessionId: string,
121 ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> =>
122 Effect.succeed({ threadId: "thread-1", turns: [] }),
141 _sessionId: ProviderSessionId,
142 ): Effect.Effect<
143 { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> },
144 ProviderAdapterError
145 > =>
146 Effect.succeed({
147 threadId: ProviderThreadId.makeUnsafe("thread-1"),
148 turns: [{ id: asTurnId("turn-1"), items: [] }],
149 }),
123150 );
124151
125152 const rollbackThread = vi.fn(
126153 (
127 _sessionId: string,
154 _sessionId: ProviderSessionId,
128155 _numTurns: number,
129 ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> =>
130 Effect.succeed({ threadId: "thread-1", turns: [] }),
156 ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> =>
157 Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }),
131158 );
132159
133160 const stopAll = vi.fn(
......@@ -172,80 +199,11 @@ function makeFakeCodexAdapter() {
172199 };
173200}
174201
175function makeCheckpointServiceDouble() {
176 const initializeForSession = vi.fn(
177 (_: { providerSessionId: string; cwd: string }) => Effect.void,
178 );
179 const captureCurrentTurn = vi.fn(
180 (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void,
181 );
182 const listCheckpoints = vi.fn(
183 (_input: ProviderListCheckpointsInput): Effect.Effect<ProviderListCheckpointsResult> =>
184 Effect.succeed({
185 threadId: "thread-1",
186 checkpoints: [
187 {
188 id: "root",
189 turnCount: 0,
190 messageCount: 0,
191 label: "Start of conversation",
192 isCurrent: true,
193 },
194 ],
195 }),
196 );
197 const getCheckpointDiff = vi.fn(
198 (input: ProviderGetCheckpointDiffInput): Effect.Effect<ProviderGetCheckpointDiffResult> =>
199 Effect.succeed({
200 threadId: "thread-1",
201 fromTurnCount: input.fromTurnCount,
202 toTurnCount: input.toTurnCount,
203 diff: "diff --git a/a.ts b/a.ts",
204 }),
205 );
206 const revertToCheckpoint = vi.fn(
207 (input: ProviderRevertToCheckpointInput): Effect.Effect<ProviderRevertToCheckpointResult> =>
208 Effect.succeed({
209 threadId: "thread-1",
210 turnCount: input.turnCount,
211 messageCount: 0,
212 rolledBackTurns: 0,
213 checkpoints: [
214 {
215 id: "root",
216 turnCount: 0,
217 messageCount: 0,
218 label: "Start of conversation",
219 isCurrent: input.turnCount === 0,
220 },
221 ],
222 }),
223 );
224 const releaseSession = vi.fn((_input: { providerSessionId: string }) => Effect.void);
225
226 const service: typeof CheckpointService.Service = {
227 initializeForSession,
228 captureCurrentTurn,
229 listCheckpoints,
230 getCheckpointDiff,
231 revertToCheckpoint,
232 releaseSession,
233 };
234
235 return {
236 service,
237 initializeForSession,
238 captureCurrentTurn,
239 listCheckpoints,
240 getCheckpointDiff,
241 revertToCheckpoint,
242 releaseSession,
243 };
244}
202const sleep = (ms: number) =>
203 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
245204
246205function makeProviderServiceLayer() {
247206 const codex = makeFakeCodexAdapter();
248 const checkpoint = makeCheckpointServiceDouble();
249207 const registry: typeof ProviderAdapterRegistry.Service = {
250208 getByProvider: (provider) =>
251209 provider === "codex"
......@@ -254,28 +212,26 @@ function makeProviderServiceLayer() {
254212 listProviders: () => Effect.succeed(["codex"]),
255213 };
256214
257 const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service);
258215 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
259 const directoryLayer = ProviderSessionDirectoryLive.pipe(
260 Layer.provide(ProviderSessionRepositoryLive),
216 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
261217 Layer.provide(SqlitePersistenceMemory),
262218 );
219 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
263220
264221 const layer = it.layer(
265222 Layer.mergeAll(
266 ProviderServiceLive.pipe(
223 makeProviderServiceLive().pipe(
267224 Layer.provide(providerAdapterLayer),
268225 Layer.provide(directoryLayer),
269 Layer.provide(checkpointLayer),
270226 ),
271227 directoryLayer,
228 runtimeRepositoryLayer,
272229 NodeServices.layer,
273230 ),
274231 );
275232
276233 return {
277234 codex,
278 checkpoint,
279235 layer,
280236 };
281237}
......@@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
287243 const dbPath = path.join(tempDir, "orchestration.sqlite");
288244
289245 const codex = makeFakeCodexAdapter();
290 const checkpoint = makeCheckpointServiceDouble();
291246 const registry: typeof ProviderAdapterRegistry.Service = {
292247 getByProvider: (provider) =>
293248 provider === "codex"
......@@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
297252 };
298253
299254 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
300 const directoryLayer = ProviderSessionDirectoryLive.pipe(
301 Layer.provide(ProviderSessionRepositoryLive),
255 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
302256 Layer.provide(persistenceLayer),
303257 );
258 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
304259
305260 yield* Effect.gen(function* () {
306261 const directory = yield* ProviderSessionDirectory;
307262 yield* directory.upsert({
308 sessionId: "sess-stale",
263 sessionId: asSessionId("sess-stale"),
309264 provider: "codex",
310 threadId: "thread-stale",
265 threadId: ThreadId.makeUnsafe("thread-stale"),
311266 });
312267 }).pipe(Effect.provide(directoryLayer));
313268
314 const providerLayer = ProviderServiceLive.pipe(
269 const providerLayer = makeProviderServiceLive().pipe(
315270 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
316271 Layer.provide(directoryLayer),
317 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
318272 );
319273
320 checkpoint.releaseSession.mockClear();
321274 yield* Effect.gen(function* () {
322275 yield* ProviderService;
323276 }).pipe(Effect.provide(providerLayer));
324277
325 assert.deepEqual(checkpoint.releaseSession.mock.calls, []);
326278 const persistedProvider = yield* Effect.gen(function* () {
327279 const directory = yield* ProviderSessionDirectory;
328 return yield* directory.getProvider("sess-stale");
280 return yield* directory.getProvider(asSessionId("sess-stale"));
329281 }).pipe(Effect.provide(directoryLayer));
330282 assert.equal(persistedProvider, "codex");
331283
284 const runtime = yield* Effect.gen(function* () {
285 const repository = yield* ProviderSessionRuntimeRepository;
286 return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") });
287 }).pipe(Effect.provide(runtimeRepositoryLayer));
288 assert.equal(Option.isSome(runtime), true);
289
290 const legacyTableRows = yield* Effect.gen(function* () {
291 const sql = yield* SqlClient.SqlClient;
292 return yield* sql<{ readonly name: string }>`
293 SELECT name
294 FROM sqlite_master
295 WHERE type = 'table' AND name = 'provider_sessions'
296 `;
297 }).pipe(Effect.provide(persistenceLayer));
298 assert.equal(legacyTableRows.length, 0);
299
332300 fs.rmSync(tempDir, { recursive: true, force: true });
333301 }).pipe(Effect.provide(NodeServices.layer)),
334302);
335303
336it.effect("ProviderServiceLive restores checkpoint RPC routing after restart using persisted thread mapping", () =>
304it.effect("ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () =>
337305 Effect.gen(function* () {
338306 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-"));
339307 const dbPath = path.join(tempDir, "orchestration.sqlite");
340308 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
309 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
310 Layer.provide(persistenceLayer),
311 );
341312
342 const checkpoint = makeCheckpointServiceDouble();
343313 const firstCodex = makeFakeCodexAdapter();
344314 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
345315 getByProvider: (provider) =>
......@@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
350320 };
351321
352322 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
353 Layer.provide(ProviderSessionRepositoryLive),
354 Layer.provide(persistenceLayer),
323 Layer.provide(runtimeRepositoryLayer),
355324 );
356 const firstProviderLayer = ProviderServiceLive.pipe(
325 const firstProviderLayer = makeProviderServiceLive().pipe(
357326 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
358327 Layer.provide(firstDirectoryLayer),
359 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
360328 );
361329
362330 const startedSession = yield* Effect.gen(function* () {
......@@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
372340 yield* provider.stopAll();
373341 }).pipe(Effect.provide(firstProviderLayer));
374342
343 const persistedAfterStopAll = yield* Effect.gen(function* () {
344 const repository = yield* ProviderSessionRuntimeRepository;
345 return yield* repository.getBySessionId({
346 providerSessionId: startedSession.sessionId,
347 });
348 }).pipe(Effect.provide(runtimeRepositoryLayer));
349 assert.equal(Option.isSome(persistedAfterStopAll), true);
350 if (Option.isSome(persistedAfterStopAll)) {
351 assert.equal(persistedAfterStopAll.value.status, "stopped");
352 assert.deepEqual(persistedAfterStopAll.value.resumeCursor, {
353 resumeThreadId: startedSession.threadId,
354 });
355 }
356
375357 const secondCodex = makeFakeCodexAdapter();
376358 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
377359 getByProvider: (provider) =>
......@@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
381363 listProviders: () => Effect.succeed(["codex"]),
382364 };
383365 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
384 Layer.provide(ProviderSessionRepositoryLive),
385 Layer.provide(persistenceLayer),
366 Layer.provide(runtimeRepositoryLayer),
386367 );
387 const secondProviderLayer = ProviderServiceLive.pipe(
368 const secondProviderLayer = makeProviderServiceLive().pipe(
388369 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
389370 Layer.provide(secondDirectoryLayer),
390 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
391371 );
392372
393 checkpoint.getCheckpointDiff.mockClear();
394373 secondCodex.startSession.mockClear();
374 secondCodex.rollbackThread.mockClear();
395375
396376 yield* Effect.gen(function* () {
397377 const provider = yield* ProviderService;
398 yield* provider.getCheckpointDiff({
378 yield* provider.rollbackConversation({
399379 sessionId: startedSession.sessionId,
400 fromTurnCount: 0,
401 toTurnCount: 0,
380 numTurns: 1,
402381 });
403382 }).pipe(Effect.provide(secondProviderLayer));
404383
......@@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
410389 },
411390 ],
412391 ]);
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);
392 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
393 const rollbackCall = secondCodex.rollbackThread.mock.calls[0];
394 assert.equal(typeof rollbackCall?.[0], "string");
395 assert.equal(rollbackCall?.[1], 1);
419396
420397 fs.rmSync(tempDir, { recursive: true, force: true });
421398 }).pipe(Effect.provide(NodeServices.layer)),
422399);
423400
424401routing.layer("ProviderServiceLive routing", (it) => {
425 it.effect("routes provider operations and delegates checkpoint workflows", () =>
402 it.effect("routes provider operations and rollback conversation", () =>
426403 Effect.gen(function* () {
427404 const provider = yield* ProviderService;
428405
......@@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
431408 cwd: "/tmp/project",
432409 });
433410 assert.equal(session.provider, "codex");
434 assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [
435 [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }],
436 ]);
437411
438412 const sessions = yield* provider.listSessions();
439413 assert.equal(sessions.length, 1);
......@@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => {
450424
451425 yield* provider.respondToRequest({
452426 sessionId: session.sessionId,
453 requestId: "req-1",
427 requestId: asRequestId("req-1"),
454428 decision: "accept",
455429 });
456430 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [
457 [session.sessionId, "req-1", "accept"],
431 [session.sessionId, asRequestId("req-1"), "accept"],
458432 ]);
459433
460 yield* provider.listCheckpoints({ sessionId: session.sessionId });
461 yield* provider.getCheckpointDiff({
434 yield* provider.rollbackConversation({
462435 sessionId: session.sessionId,
463 fromTurnCount: 0,
464 toTurnCount: 0,
436 numTurns: 0,
465437 });
466 yield* provider.revertToCheckpoint({
467 sessionId: session.sessionId,
468 turnCount: 0,
469 });
470
471 assert.deepEqual(routing.checkpoint.listCheckpoints.mock.calls, [
472 [{ sessionId: session.sessionId }],
473 ]);
474 assert.deepEqual(routing.checkpoint.getCheckpointDiff.mock.calls, [
475 [{ sessionId: session.sessionId, fromTurnCount: 0, toTurnCount: 0 }],
476 ]);
477 assert.deepEqual(routing.checkpoint.revertToCheckpoint.mock.calls, [
478 [{ sessionId: session.sessionId, turnCount: 0 }],
479 ]);
480438
481439 yield* provider.stopSession({ sessionId: session.sessionId });
482 assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [
483 [{ providerSessionId: session.sessionId }],
484 ]);
485440 const sendAfterStop = yield* Effect.result(
486441 provider.sendTurn({
487442 sessionId: session.sessionId,
......@@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
496451 }),
497452 );
498453
499 it.effect("recovers stale persisted sessions for checkpoint RPCs by resuming thread identity", () =>
454 it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () =>
500455 Effect.gen(function* () {
501456 const provider = yield* ProviderService;
502457
......@@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
505460 cwd: "/tmp/project",
506461 });
507462 yield* routing.codex.stopSession(initial.sessionId);
508
509 routing.checkpoint.getCheckpointDiff.mockClear();
510 routing.checkpoint.revertToCheckpoint.mockClear();
511463 routing.codex.startSession.mockClear();
464 routing.codex.rollbackThread.mockClear();
512465
513 yield* provider.getCheckpointDiff({
466 yield* provider.rollbackConversation({
514467 sessionId: initial.sessionId,
515 fromTurnCount: 0,
516 toTurnCount: 0,
517 });
518 yield* provider.revertToCheckpoint({
519 sessionId: initial.sessionId,
520 turnCount: 0,
468 numTurns: 1,
521469 });
522470
523471 assert.deepEqual(routing.codex.startSession.mock.calls, [
......@@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => {
528476 },
529477 ],
530478 ]);
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);
479 assert.equal(routing.codex.rollbackThread.mock.calls.length, 1);
480 const rollbackCall = routing.codex.rollbackThread.mock.calls[0];
481 assert.equal(rollbackCall?.[1], 1);
541482 }),
542483 );
543484
544 it.effect("releases checkpoint session state when stopping all sessions", () =>
485 it.effect("stops all sessions and clears adapter runtime", () =>
545486 Effect.gen(function* () {
546487 const provider = yield* ProviderService;
547488
548 const first = yield* provider.startSession({
489 yield* provider.startSession({
549490 provider: "codex",
550491 });
551 const second = yield* provider.startSession({
492 yield* provider.startSession({
552493 provider: "codex",
553494 });
554495
555496 yield* provider.stopAll();
556497
557 assert.deepEqual(routing.checkpoint.releaseSession.mock.calls.slice(-2), [
558 [{ providerSessionId: first.sessionId }],
559 [{ providerSessionId: second.sessionId }],
560 ]);
561
562498 const remaining = yield* provider.listSessions();
563499 assert.equal(remaining.length, 0);
564500 }),
565501 );
502
503 it.effect("persists runtime status transitions in provider_session_runtime", () =>
504 Effect.gen(function* () {
505 const provider = yield* ProviderService;
506 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
507
508 const session = yield* provider.startSession({
509 provider: "codex",
510 });
511 yield* provider.sendTurn({
512 sessionId: session.sessionId,
513 input: "hello",
514 attachments: [],
515 });
516
517 const runningRuntime = yield* runtimeRepository.getBySessionId({
518 providerSessionId: session.sessionId,
519 });
520 assert.equal(Option.isSome(runningRuntime), true);
521 if (Option.isSome(runningRuntime)) {
522 assert.equal(runningRuntime.value.status, "running");
523 assert.deepEqual(runningRuntime.value.resumeCursor, {
524 resumeThreadId: "thread-1",
525 });
526 const payload = runningRuntime.value.runtimePayload;
527 assert.equal(payload !== null && typeof payload === "object", true);
528 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
529 assert.equal(payload.cwd, process.cwd());
530 assert.equal(payload.model, null);
531 assert.equal(payload.activeTurnId, "turn-1");
532 assert.equal(payload.lastError, null);
533 assert.equal(payload.lastRuntimeEvent, "provider.sendTurn");
534 }
535 }
536
537 yield* provider.stopAll();
538 const stoppedRuntime = yield* runtimeRepository.getBySessionId({
539 providerSessionId: session.sessionId,
540 });
541 assert.equal(Option.isSome(stoppedRuntime), true);
542 if (Option.isSome(stoppedRuntime)) {
543 assert.equal(stoppedRuntime.value.status, "stopped");
544 }
545 }),
546 );
566547});
567548
568549const fanout = makeProviderServiceLayer();
569550fanout.layer("ProviderServiceLive fanout", (it) => {
570 it.effect("fans out adapter events and captures turn completion checkpoints", () =>
551 it.effect("fans out adapter turn completion events", () =>
571552 Effect.gen(function* () {
572553 const provider = yield* ProviderService;
573554 const session = yield* provider.startSession({
......@@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
577558 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
578559 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>
579560 Ref.update(eventsRef, (current) => [...current, event]),
580 ).pipe(
581 Effect.forkChild,
582 );
583 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
561 ).pipe(Effect.forkChild);
562 yield* sleep(20);
584563
585564 const completedEvent: ProviderRuntimeEvent = {
586565 type: "turn.completed",
587 eventId: "evt-1",
566 eventId: asEventId("evt-1"),
588567 provider: "codex",
589568 sessionId: session.sessionId,
590569 createdAt: new Date().toISOString(),
591 threadId: "thread-1",
592 turnId: "turn-1",
570 threadId: asThreadId("thread-1"),
571 turnId: asTurnId("turn-1"),
593572 status: "completed",
594573 };
595574
596575 fanout.codex.emit(completedEvent);
597 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
576 yield* sleep(20);
598577
599578 const events = yield* Ref.get(eventsRef);
600579 yield* Fiber.interrupt(consumer);
601580
602 assert.equal(events.length >= 2, true);
603 assert.equal(events[0]?.type, "turn.completed");
604 assert.equal(events[1]?.type, "checkpoint.captured");
605 assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [
606 [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }],
607 ]);
581 assert.equal(events.some((entry) => entry.type === "turn.completed"), true);
608582 }),
609583 );
610584
......@@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
614588 const session = yield* provider.startSession({
615589 provider: "codex",
616590 });
617 fanout.checkpoint.captureCurrentTurn.mockClear();
618591
619592 const receivedByHealthy: string[] = [];
593 const expectedEventIds = new Set<string>([
594 "evt-ordered-1",
595 "evt-ordered-2",
596 "evt-ordered-3",
597 ]);
620598 const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe(
621599 Stream.runForEach((event) =>
622600 Effect.sync(() => {
......@@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
629607 Stream.runForEach(() => Effect.fail("listener crash")),
630608 Effect.forkChild,
631609 );
632 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
610 yield* sleep(20);
633611
634612 const events: ReadonlyArray<ProviderRuntimeEvent> = [
635613 {
636614 type: "tool.completed",
637 eventId: "evt-ordered-1",
615 eventId: asEventId("evt-ordered-1"),
638616 provider: "codex",
639617 sessionId: session.sessionId,
640618 createdAt: new Date().toISOString(),
641 threadId: "thread-1",
642 turnId: "turn-1",
619 threadId: asThreadId("thread-1"),
620 turnId: asTurnId("turn-1"),
643621 toolKind: "command",
644622 title: "Command run",
645623 detail: "echo one",
646624 },
647625 {
648626 type: "message.delta",
649 eventId: "evt-ordered-2",
627 eventId: asEventId("evt-ordered-2"),
650628 provider: "codex",
651629 sessionId: session.sessionId,
652630 createdAt: new Date().toISOString(),
653 threadId: "thread-1",
654 turnId: "turn-1",
631 threadId: asThreadId("thread-1"),
632 turnId: asTurnId("turn-1"),
655633 delta: "hello",
656634 },
657635 {
658636 type: "turn.completed",
659 eventId: "evt-ordered-3",
637 eventId: asEventId("evt-ordered-3"),
660638 provider: "codex",
661639 sessionId: session.sessionId,
662640 createdAt: new Date().toISOString(),
663 threadId: "thread-1",
664 turnId: "turn-1",
641 threadId: asThreadId("thread-1"),
642 turnId: asTurnId("turn-1"),
665643 status: "completed",
666644 },
667645 ];
......@@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
673651 assert.equal(failingResult._tag, "Failure");
674652 yield* Fiber.join(healthyFiber);
675653
676 assert.deepEqual(receivedByHealthy.slice(0, 3), [
677 "evt-ordered-1",
678 "evt-ordered-2",
679 "evt-ordered-3",
680 ]);
681 assert.equal(receivedByHealthy.length, 3);
654 assert.deepEqual(
655 receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3),
656 [
657 "evt-ordered-1",
658 "evt-ordered-2",
659 "evt-ordered-3",
660 ],
661 );
682662 }),
683663 );
684664});
......@@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => {
689669 Effect.gen(function* () {
690670 const provider = yield* ProviderService;
691671
692 const parse = providerSessionStartInputSchema.safeParse({
693 provider: "invalid-provider",
694 });
695 assert.equal(parse.success, false);
696 if (parse.success) {
697 return;
672 let parseCause: unknown;
673 let decodeFailed = false;
674 try {
675 Schema.decodeUnknownSync(ProviderSessionStartInput)({
676 provider: "invalid-provider",
677 });
678 } catch (cause) {
679 parseCause = cause;
680 decodeFailed = true;
698681 }
682 assert.equal(decodeFailed, true);
699683
700684 const failure = yield* Effect.result(
701685 provider.startSession({
......@@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => {
703687 } as never),
704688 );
705689
706 const cause = (parse.error as { cause?: unknown }).cause;
707690 assertFailure(
708691 failure,
709692 new ProviderValidationError({
710693 operation: "ProviderService.startSession",
711 issue: parse.error.message,
712 ...(cause !== undefined ? { cause } : {}),
694 issue: decodeIssueMessage(parseCause),
695 cause: parseCause,
713696 }),
714697 );
715698 }),
......@@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
723706 Effect.sync(() => {
724707 const now = new Date().toISOString();
725708 return {
726 sessionId: "sess-missing-thread",
709 sessionId: asSessionId("sess-missing-thread"),
727710 provider: "codex",
728711 status: "ready",
729712 cwd: input.cwd ?? process.cwd(),
......@@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => {
743726 assertFailure(
744727 failure,
745728 new ProviderValidationError({
746 operation: "ProviderService.startSession",
747 issue:
748 "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.",
749 }),
729 operation: "ProviderService.startSession",
730 issue: "Provider 'codex' returned a session without threadId.",
731 }),
750732 );
751733 }),
752734 );
apps/server/src/provider/Layers/ProviderService.ts+190-237
......@@ -5,29 +5,25 @@
55 * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a
66 * unified provider event stream for subscribers.
77 *
8 * It does not implement provider protocol details (adapter concern) and does
9 * not implement checkpoint persistence mechanics (checkpointing concern).
8 * It does not implement provider protocol details (adapter concern).
109 *
1110 * @module ProviderServiceLive
1211 */
13import { randomUUID } from "node:crypto";
14
1512import {
16 providerGetCheckpointDiffInputSchema,
17 providerInterruptTurnInputSchema,
18 providerListCheckpointsInputSchema,
19 providerRespondToRequestInputSchema,
20 providerRevertToCheckpointInputSchema,
21 providerSendTurnInputSchema,
22 providerSessionStartInputSchema,
23 providerStopSessionInputSchema,
13 NonNegativeInt,
14 ProviderSessionId,
15 ProviderThreadId,
16 ThreadId,
17 ProviderInterruptTurnInput,
18 ProviderRespondToRequestInput,
19 ProviderSendTurnInput,
20 ProviderSessionStartInput,
21 ProviderStopSessionInput,
2422 type ProviderRuntimeEvent,
25 type ProviderRuntimeTurnCompletedEvent,
2623 type ProviderSession,
2724} from "@t3tools/contracts";
28import { Effect, Layer, Option, PubSub, Queue, Ref, Stream } from "effect";
25import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, Stream } from "effect";
2926
30import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts";
3127import { ProviderValidationError } from "../Errors.ts";
3228import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
3329import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
......@@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions {
3834 readonly canonicalEventLogPath?: string;
3935}
4036
41function toCheckpointCaptureErrorEvent(
42 event: ProviderRuntimeTurnCompletedEvent,
43 error: { readonly message: string },
44): ProviderRuntimeEvent {
45 return {
46 type: "runtime.error",
47 eventId: randomUUID(),
48 provider: event.provider,
49 sessionId: event.sessionId,
50 createdAt: new Date().toISOString(),
51 ...(event.threadId !== undefined ? { threadId: event.threadId } : {}),
52 ...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
53 message: error.message,
54 };
55}
56
57function toCheckpointCapturedEvent(input: {
58 readonly event: ProviderRuntimeTurnCompletedEvent;
59 readonly threadId: string;
60 readonly turnCount: number;
61}): ProviderRuntimeEvent {
62 const { event, threadId, turnCount } = input;
63 return {
64 type: "checkpoint.captured",
65 eventId: randomUUID(),
66 provider: event.provider,
67 sessionId: event.sessionId,
68 createdAt: new Date().toISOString(),
69 threadId,
70 ...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
71 turnCount,
72 ...(event.status !== undefined ? { status: event.status } : {}),
73 };
74}
37const ProviderRollbackConversationInput = Schema.Struct({
38 sessionId: ProviderSessionId,
39 numTurns: NonNegativeInt,
40});
7541
7642function toValidationError(
7743 operation: string,
......@@ -85,6 +51,63 @@ function toValidationError(
8551 });
8652}
8753
54function decodeIssueMessage(cause: unknown): string {
55 if (cause instanceof Error && cause.message.length > 0) {
56 return cause.message;
57 }
58 try {
59 return JSON.stringify(cause);
60 } catch {
61 return "Invalid provider input.";
62 }
63}
64
65function decodeInputOrValidationError<S extends Schema.Top>(input: {
66 readonly operation: string;
67 readonly schema: S;
68 readonly payload: unknown;
69}): Effect.Effect<Schema.Schema.Type<S>, ProviderValidationError> {
70 try {
71 return Effect.succeed(
72 Schema.decodeUnknownSync(input.schema as never)(input.payload) as Schema.Schema.Type<S>,
73 );
74 } catch (cause) {
75 return Effect.fail(toValidationError(input.operation, decodeIssueMessage(cause), cause));
76 }
77}
78
79function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" {
80 switch (session.status) {
81 case "connecting":
82 return "starting";
83 case "error":
84 return "error";
85 case "closed":
86 return "stopped";
87 case "ready":
88 case "running":
89 default:
90 return "running";
91 }
92}
93
94function toRuntimePayloadFromSession(session: ProviderSession): Record<string, unknown> {
95 return {
96 cwd: session.cwd ?? null,
97 model: session.model ?? null,
98 activeTurnId: session.activeTurnId ?? null,
99 lastError: session.lastError ?? null,
100 };
101}
102
103function asProviderSessionId(value: string | ProviderSessionId): ProviderSessionId {
104 return typeof value === "string" ? ProviderSessionId.makeUnsafe(value) : value;
105}
106
107function asProviderThreadId(value: string | ProviderThreadId): ProviderThreadId {
108 return typeof value === "string" ? ProviderThreadId.makeUnsafe(value) : value;
109}
110
88111const makeProviderService = (options?: ProviderServiceLiveOptions) =>
89112 Effect.gen(function* () {
90113 const canonicalEventLogger =
......@@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
94117
95118 const registry = yield* ProviderAdapterRegistry;
96119 const directory = yield* ProviderSessionDirectory;
97 const checkpointService = yield* CheckpointService;
98
99120 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
100121 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
101 const routedSessionAliasesRef = yield* Ref.make<Map<string, string>>(new Map());
122 const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>(
123 new Map(),
124 );
102125
103126 const canonicalizeRuntimeEventSession = (
104127 event: ProviderRuntimeEvent,
......@@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
109132 if (liveSessionId === event.sessionId) {
110133 return {
111134 ...event,
112 sessionId: staleSessionId,
135 sessionId: ProviderSessionId.makeUnsafe(staleSessionId),
113136 } satisfies ProviderRuntimeEvent;
114137 }
115138 }
......@@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
131154 Effect.asVoid,
132155 );
133156
134 const upsertSessionBinding = (
135 session: ProviderSession,
136 operation: string,
137 ) =>
157 const upsertSessionBinding = (session: ProviderSession, operation: string) =>
138158 Effect.gen(function* () {
139 const threadId = session.threadId?.trim();
140 if (!threadId) {
159 const providerThreadId = session.threadId;
160 if (!providerThreadId) {
141161 return yield* toValidationError(
142162 operation,
143 `Provider '${session.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`,
163 `Provider '${session.provider}' returned a session without threadId.`,
144164 );
145165 }
146166
167 const providerSessionId = asProviderSessionId(session.sessionId);
168 const brandedThreadId = ThreadId.makeUnsafe(providerThreadId);
147169 yield* directory.upsert({
148 sessionId: session.sessionId,
170 sessionId: providerSessionId,
149171 provider: session.provider,
150 threadId,
172 threadId: brandedThreadId,
173 providerThreadId,
174 status: toRuntimeStatus(session),
175 resumeCursor: { resumeThreadId: providerThreadId },
176 runtimePayload: toRuntimePayloadFromSession(session),
151177 });
152178
153 return threadId;
179 return brandedThreadId;
154180 });
155181
156 const clearAliasKey = (staleSessionId: string) =>
182 const clearAliasKey = (staleSessionId: ProviderSessionId) =>
157183 Ref.update(routedSessionAliasesRef, (current) => {
158184 if (!current.has(staleSessionId)) {
159185 return current;
......@@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
163189 return next;
164190 });
165191
166 const clearAliasesReferencing = (sessionId: string) =>
192 const clearAliasesReferencing = (sessionId: ProviderSessionId) =>
167193 Ref.update(routedSessionAliasesRef, (current) => {
168194 let changed = false;
169 const next = new Map<string, string>();
195 const next = new Map<ProviderSessionId, ProviderSessionId>();
170196 for (const [key, value] of current) {
171197 if (key === sessionId || value === sessionId) {
172198 changed = true;
......@@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
177203 return changed ? next : current;
178204 });
179205
180 const setAlias = (staleSessionId: string, liveSessionId: string) =>
206 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>
181207 Ref.update(routedSessionAliasesRef, (current) => {
182208 const existing = current.get(staleSessionId);
183209 if (existing === liveSessionId) {
......@@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
193219 registry.getByProvider(provider),
194220 );
195221
196 const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect<void> =>
197 checkpointService
198 .captureCurrentTurn({
199 providerSessionId: event.sessionId,
200 ...(event.turnId !== undefined ? { turnId: event.turnId } : {}),
201 ...(event.status !== undefined ? { status: event.status } : {}),
202 })
203 .pipe(
204 Effect.flatMap(() => checkpointService.listCheckpoints({ sessionId: event.sessionId })),
205 Effect.flatMap((result) => {
206 const currentCheckpoint =
207 result.checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
208 result.checkpoints[result.checkpoints.length - 1];
209 if (!currentCheckpoint) {
210 return Effect.void;
211 }
212
213 return publishRuntimeEvent(
214 toCheckpointCapturedEvent({
215 event,
216 threadId: result.threadId,
217 turnCount: currentCheckpoint.turnCount,
218 }),
219 );
220 }),
221 Effect.catch((error) => publishRuntimeEvent(toCheckpointCaptureErrorEvent(event, error))),
222 );
223
224222 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
225 publishRuntimeEvent(event).pipe(
226 Effect.flatMap(() => {
227 if (event.type !== "turn.completed") {
228 return Effect.void;
229 }
230 return onTurnCompleted(event);
231 }),
232 );
223 publishRuntimeEvent(event);
233224
234225 const worker = Effect.forever(
235226 Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)),
......@@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
243234 ).pipe(Effect.asVoid);
244235
245236 const recoverSessionForThread = (input: {
246 readonly staleSessionId: string;
237 readonly staleSessionId: ProviderSessionId;
247238 readonly provider: ProviderSession["provider"];
248 readonly threadId: string;
239 readonly threadId: ThreadId;
249240 readonly operation: string;
250241 }) =>
251242 Effect.gen(function* () {
252243 const adapter = yield* registry.getByProvider(input.provider);
253244 const activeSessions = yield* adapter.listSessions();
254245 const existing = activeSessions.find(
255 (session) => session.threadId?.trim() === input.threadId,
246 (session) => session.threadId?.trim() === input.threadId.trim(),
256247 );
257248 if (existing) {
258249 const existingThreadId = yield* upsertSessionBinding(
......@@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
277268
278269 const resumed = yield* adapter.startSession({
279270 provider: input.provider,
280 resumeThreadId: input.threadId,
271 resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId),
281272 });
282273 if (resumed.provider !== adapter.provider) {
283274 return yield* toValidationError(
......@@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
303294 threadId: resumedThreadId,
304295 });
305296
306 const checkpointCwd = resumed.cwd ?? process.cwd();
307 yield* checkpointService.initializeForSession({
308 providerSessionId: resumed.sessionId,
309 cwd: checkpointCwd,
310 });
311
312297 if (resumed.sessionId !== input.staleSessionId) {
313298 yield* setAlias(input.staleSessionId, resumed.sessionId);
314299 } else {
......@@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
322307 });
323308
324309 const resolveRoutableSession = (input: {
325 readonly sessionId: string;
310 readonly sessionId: ProviderSessionId;
326311 readonly operation: string;
327312 readonly allowRecovery: boolean;
328313 }) =>
......@@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
364349 }
365350
366351 const threadIdOption = yield* directory.getThreadId(input.sessionId);
367 const threadId = Option.getOrUndefined(threadIdOption)?.trim();
352 const threadId = Option.getOrUndefined(threadIdOption);
368353 if (!threadId) {
369354 return yield* toValidationError(
370355 input.operation,
......@@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
388373
389374 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>
390375 Effect.gen(function* () {
391 const parsed = providerSessionStartInputSchema.safeParse(rawInput);
392 if (!parsed.success) {
393 return yield* toValidationError(
394 "ProviderService.startSession",
395 parsed.error.message,
396 parsed.error.cause,
397 );
398 }
376 const parsed = yield* decodeInputOrValidationError({
377 operation: "ProviderService.startSession",
378 schema: ProviderSessionStartInput,
379 payload: rawInput,
380 });
399381
400 const input = parsed.data;
382 const input = {
383 ...parsed,
384 provider: parsed.provider ?? "codex",
385 approvalPolicy: parsed.approvalPolicy ?? "never",
386 sandboxMode: parsed.sandboxMode ?? "workspace-write",
387 };
401388 const adapter = yield* registry.getByProvider(input.provider);
402389 const session = yield* adapter.startSession(input);
403390
......@@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
410397
411398 yield* upsertSessionBinding(session, "ProviderService.startSession");
412399
413 const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd();
414 yield* checkpointService.initializeForSession({
415 providerSessionId: session.sessionId,
416 cwd: checkpointCwd,
417 });
418
419400 return session;
420401 });
421402
422403 const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) =>
423404 Effect.gen(function* () {
424 const parsed = providerSendTurnInputSchema.safeParse(rawInput);
425 if (!parsed.success) {
405 const parsed = yield* decodeInputOrValidationError({
406 operation: "ProviderService.sendTurn",
407 schema: ProviderSendTurnInput,
408 payload: rawInput,
409 });
410
411 const input = {
412 ...parsed,
413 attachments: parsed.attachments ?? [],
414 };
415 if (!input.input && input.attachments.length === 0) {
426416 return yield* toValidationError(
427417 "ProviderService.sendTurn",
428 parsed.error.message,
429 parsed.error.cause,
418 "Either input text or at least one attachment is required",
430419 );
431420 }
432
433 const input = parsed.data;
434421 const routed = yield* resolveRoutableSession({
435422 sessionId: input.sessionId,
436423 operation: "ProviderService.sendTurn",
437424 allowRecovery: true,
438425 });
439 return yield* routed.adapter.sendTurn({
426 const turn = yield* routed.adapter.sendTurn({
440427 ...input,
441428 sessionId: routed.sessionId,
442429 });
443 });
444
445 const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) =>
446 Effect.gen(function* () {
447 const parsed = providerInterruptTurnInputSchema.safeParse(rawInput);
448 if (!parsed.success) {
430 const threadId = yield* directory
431 .getThreadId(asProviderSessionId(input.sessionId))
432 .pipe(Effect.map(Option.getOrUndefined));
433 if (!threadId) {
449434 return yield* toValidationError(
450 "ProviderService.interruptTurn",
451 parsed.error.message,
452 parsed.error.cause,
435 "ProviderService.sendTurn",
436 `No thread id is tracked for provider session '${input.sessionId}'.`,
453437 );
454438 }
439 yield* directory.upsert({
440 sessionId: asProviderSessionId(input.sessionId),
441 provider: routed.adapter.provider,
442 threadId,
443 providerThreadId: asProviderThreadId(turn.threadId),
444 status: "running",
445 resumeCursor: { resumeThreadId: turn.threadId },
446 runtimePayload: {
447 activeTurnId: turn.turnId,
448 lastRuntimeEvent: "provider.sendTurn",
449 lastRuntimeEventAt: new Date().toISOString(),
450 },
451 });
452 return turn;
453 });
455454
456 const input = parsed.data;
455 const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) =>
456 Effect.gen(function* () {
457 const input = yield* decodeInputOrValidationError({
458 operation: "ProviderService.interruptTurn",
459 schema: ProviderInterruptTurnInput,
460 payload: rawInput,
461 });
457462 const routed = yield* resolveRoutableSession({
458463 sessionId: input.sessionId,
459464 operation: "ProviderService.interruptTurn",
......@@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
464469
465470 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
466471 Effect.gen(function* () {
467 const parsed = providerRespondToRequestInputSchema.safeParse(rawInput);
468 if (!parsed.success) {
469 return yield* toValidationError(
470 "ProviderService.respondToRequest",
471 parsed.error.message,
472 parsed.error.cause,
473 );
474 }
475
476 const input = parsed.data;
472 const input = yield* decodeInputOrValidationError({
473 operation: "ProviderService.respondToRequest",
474 schema: ProviderRespondToRequestInput,
475 payload: rawInput,
476 });
477477 const routed = yield* resolveRoutableSession({
478478 sessionId: input.sessionId,
479479 operation: "ProviderService.respondToRequest",
......@@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
484484
485485 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
486486 Effect.gen(function* () {
487 const parsed = providerStopSessionInputSchema.safeParse(rawInput);
488 if (!parsed.success) {
489 return yield* toValidationError(
490 "ProviderService.stopSession",
491 parsed.error.message,
492 parsed.error.cause,
493 );
494 }
495
496 const input = parsed.data;
487 const input = yield* decodeInputOrValidationError({
488 operation: "ProviderService.stopSession",
489 schema: ProviderStopSessionInput,
490 payload: rawInput,
491 });
497492 const routed = yield* resolveRoutableSession({
498493 sessionId: input.sessionId,
499494 operation: "ProviderService.stopSession",
......@@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
503498 yield* routed.adapter.stopSession(routed.sessionId);
504499 }
505500 if (routed.sessionId !== input.sessionId) {
506 yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId });
507 yield* directory.remove(routed.sessionId);
501 yield* directory.remove(asProviderSessionId(routed.sessionId));
508502 yield* clearAliasesReferencing(routed.sessionId);
509503 }
510 yield* checkpointService.releaseSession({ providerSessionId: input.sessionId });
511 yield* directory.remove(input.sessionId);
504 yield* directory.remove(asProviderSessionId(input.sessionId));
512505 yield* clearAliasesReferencing(input.sessionId);
513506 });
514507
......@@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
517510 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
518511 );
519512
520 const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) =>
521 Effect.gen(function* () {
522 const parsed = providerListCheckpointsInputSchema.safeParse(rawInput);
523 if (!parsed.success) {
524 return yield* toValidationError(
525 "ProviderService.listCheckpoints",
526 parsed.error.message,
527 parsed.error.cause,
528 );
529 }
530
531 const input = parsed.data;
532 const routed = yield* resolveRoutableSession({
533 sessionId: input.sessionId,
534 operation: "ProviderService.listCheckpoints",
535 allowRecovery: true,
536 });
537 return yield* checkpointService.listCheckpoints({
538 ...input,
539 sessionId: routed.sessionId,
540 });
541 });
542
543 const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) =>
513 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) =>
544514 Effect.gen(function* () {
545 const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput);
546 if (!parsed.success) {
547 return yield* toValidationError(
548 "ProviderService.getCheckpointDiff",
549 parsed.error.message,
550 parsed.error.cause,
551 );
552 }
553
554 const input = parsed.data;
555 const routed = yield* resolveRoutableSession({
556 sessionId: input.sessionId,
557 operation: "ProviderService.getCheckpointDiff",
558 allowRecovery: true,
559 });
560 return yield* checkpointService.getCheckpointDiff({
561 ...input,
562 sessionId: routed.sessionId,
515 const input = yield* decodeInputOrValidationError({
516 operation: "ProviderService.rollbackConversation",
517 schema: ProviderRollbackConversationInput,
518 payload: rawInput,
563519 });
564 });
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(
571 "ProviderService.revertToCheckpoint",
572 parsed.error.message,
573 parsed.error.cause,
574 );
520 if (input.numTurns === 0) {
521 return;
575522 }
576
577 const input = parsed.data;
578523 const routed = yield* resolveRoutableSession({
579524 sessionId: input.sessionId,
580 operation: "ProviderService.revertToCheckpoint",
525 operation: "ProviderService.rollbackConversation",
581526 allowRecovery: true,
582527 });
583 return yield* checkpointService.revertToCheckpoint({
584 ...input,
585 sessionId: routed.sessionId,
586 });
528 yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns);
587529 });
588530
589531 const stopAll: ProviderServiceShape["stopAll"] = () =>
590532 Effect.gen(function* () {
591533 const sessionIds = yield* directory.listSessionIds();
592534 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);
593 // Keep persisted session bindings so stale sessions can be resumed after
594 // process restart via resumeThreadId.
595535 yield* Effect.forEach(sessionIds, (sessionId) =>
596 checkpointService.releaseSession({ providerSessionId: sessionId }),
536 directory.getProvider(sessionId).pipe(
537 Effect.flatMap((provider) =>
538 directory.upsert({
539 sessionId,
540 provider,
541 status: "stopped",
542 runtimePayload: {
543 activeTurnId: null,
544 lastRuntimeEvent: "provider.stopAll",
545 lastRuntimeEventAt: new Date().toISOString(),
546 },
547 }),
548 ),
549 ),
597550 ).pipe(Effect.asVoid);
551 // Keep persisted session bindings so stale sessions can be resumed after
552 // process restart via resumeThreadId.
598553 yield* Ref.set(routedSessionAliasesRef, new Map());
599554 });
600555
......@@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
605560 respondToRequest,
606561 stopSession,
607562 listSessions,
608 listCheckpoints,
609 getCheckpointDiff,
610 revertToCheckpoint,
563 rollbackConversation,
611564 stopAll,
612565 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
613566 } satisfies ProviderServiceShape;
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+103-22
......@@ -3,22 +3,35 @@ import os from "node:os";
33import path from "node:path";
44
55import { NodeServices } from "@effect/platform-node";
6import {
7 ProviderSessionId,
8 ProviderThreadId,
9 ThreadId,
10} from "@t3tools/contracts";
611import { it, assert } from "@effect/vitest";
712import { assertFailure, assertSome } from "@effect/vitest/utils";
8import { Effect, Layer } from "effect";
13import { Effect, Layer, Option } from "effect";
914import * as SqlClient from "effect/unstable/sql/SqlClient";
1015
1116import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
12import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts";
17import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
18import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
1319import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
1420import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
1521import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
1622
23const sessionId = (value: string) => ProviderSessionId.makeUnsafe(value);
24const threadId = (value: string) => ThreadId.makeUnsafe(value);
25const providerThreadId = (value: string) => ProviderThreadId.makeUnsafe(value);
26
1727function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {
18 return ProviderSessionDirectoryLive.pipe(
19 Layer.provide(ProviderSessionRepositoryLive),
28 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
2029 Layer.provide(persistenceLayer),
2130 );
31 return Layer.mergeAll(
32 runtimeRepositoryLayer,
33 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
34 );
2235}
2336
2437const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));
......@@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => {
2740 it("upserts, reads, and removes session bindings", () =>
2841 Effect.gen(function* () {
2942 const directory = yield* ProviderSessionDirectory;
43 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
3044
3145 yield* directory.upsert({
32 sessionId: "sess-1",
46 sessionId: sessionId("sess-1"),
3347 provider: "codex",
34 threadId: "thread-1",
48 threadId: threadId("thread-1"),
3549 });
3650
37 const provider = yield* directory.getProvider("sess-1");
51 const provider = yield* directory.getProvider(sessionId("sess-1"));
3852 assert.equal(provider, "codex");
39 const threadId = yield* directory.getThreadId("sess-1");
40 assertSome(threadId, "thread-1");
53 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1"));
54 assertSome(resolvedThreadId, threadId("thread-1"));
4155
4256 yield* directory.upsert({
43 sessionId: "sess-1",
57 sessionId: sessionId("sess-1"),
4458 provider: "codex",
45 threadId: "thread-2",
59 threadId: threadId("thread-2"),
60 });
61 const updatedThreadId = yield* directory.getThreadId(sessionId("sess-1"));
62 assertSome(updatedThreadId, threadId("thread-2"));
63
64 const runtime = yield* runtimeRepository.getBySessionId({
65 providerSessionId: sessionId("sess-1"),
4666 });
47 const updatedThreadId = yield* directory.getThreadId("sess-1");
48 assertSome(updatedThreadId, "thread-2");
67 assert.equal(Option.isSome(runtime), true);
68 if (Option.isSome(runtime)) {
69 assert.equal(runtime.value.threadId, "thread-2");
70 assert.equal(runtime.value.providerThreadId, "thread-2");
71 assert.equal(runtime.value.status, "running");
72 assert.equal(runtime.value.adapterKey, "codex");
73 }
4974
5075 const sessionIds = yield* directory.listSessionIds();
51 assert.deepEqual(sessionIds, ["sess-1"]);
76 assert.deepEqual(sessionIds, [sessionId("sess-1")]);
5277
53 yield* directory.remove("sess-1");
54 const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result);
78 yield* directory.remove(sessionId("sess-1"));
79 const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result);
5580 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
5681 }));
5782
......@@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
6085 const directory = yield* ProviderSessionDirectory;
6186 const result = yield* Effect.result(
6287 directory.upsert({
63 sessionId: " ",
88 sessionId: sessionId(" "),
6489 provider: "codex",
6590 }),
6691 );
......@@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => {
7398 );
7499 }));
75100
101 it("persists runtime fields and merges payload updates", () =>
102 Effect.gen(function* () {
103 const directory = yield* ProviderSessionDirectory;
104 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
105
106 yield* directory.upsert({
107 sessionId: sessionId("sess-runtime"),
108 provider: "codex",
109 threadId: threadId("thread-runtime"),
110 providerThreadId: providerThreadId("provider-thread-runtime"),
111 status: "starting",
112 resumeCursor: {
113 resumeThreadId: "provider-thread-runtime",
114 },
115 runtimePayload: {
116 cwd: "/tmp/project",
117 model: "gpt-5-codex",
118 },
119 });
120
121 yield* directory.upsert({
122 sessionId: sessionId("sess-runtime"),
123 provider: "codex",
124 status: "running",
125 runtimePayload: {
126 activeTurnId: "turn-1",
127 },
128 });
129
130 const runtime = yield* runtimeRepository.getBySessionId({
131 providerSessionId: sessionId("sess-runtime"),
132 });
133 assert.equal(Option.isSome(runtime), true);
134 if (Option.isSome(runtime)) {
135 assert.equal(runtime.value.threadId, "thread-runtime");
136 assert.equal(runtime.value.providerThreadId, "provider-thread-runtime");
137 assert.equal(runtime.value.status, "running");
138 assert.deepEqual(runtime.value.resumeCursor, {
139 resumeThreadId: "provider-thread-runtime",
140 });
141 assert.deepEqual(runtime.value.runtimePayload, {
142 cwd: "/tmp/project",
143 model: "gpt-5-codex",
144 activeTurnId: "turn-1",
145 });
146 }
147 }));
148
76149 it("rehydrates persisted mappings across layer restart", () =>
77150 Effect.gen(function* () {
78151 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));
......@@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => {
82155 yield* Effect.gen(function* () {
83156 const directory = yield* ProviderSessionDirectory;
84157 yield* directory.upsert({
85 sessionId: "sess-restart",
158 sessionId: sessionId("sess-restart"),
86159 provider: "codex",
87 threadId: "thread-restart",
160 threadId: threadId("thread-restart"),
88161 });
89162 }).pipe(Effect.provide(directoryLayer));
90163
91164 yield* Effect.gen(function* () {
92165 const directory = yield* ProviderSessionDirectory;
93 const provider = yield* directory.getProvider("sess-restart");
166 const sql = yield* SqlClient.SqlClient;
167 const provider = yield* directory.getProvider(sessionId("sess-restart"));
94168 assert.equal(provider, "codex");
95169
96 const threadId = yield* directory.getThreadId("sess-restart");
97 assertSome(threadId, "thread-restart");
170 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart"));
171 assertSome(resolvedThreadId, threadId("thread-restart"));
172
173 const legacyTableRows = yield* sql<{ readonly name: string }>`
174 SELECT name
175 FROM sqlite_master
176 WHERE type = 'table' AND name = 'provider_sessions'
177 `;
178 assert.equal(legacyTableRows.length, 0);
98179 }).pipe(Effect.provide(directoryLayer));
99180
100181 fs.rmSync(tempDir, { recursive: true, force: true });
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+142-33
......@@ -1,6 +1,12 @@
1import {
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 type ProviderKind,
6} from "@t3tools/contracts";
17import { Effect, Layer, Option } from "effect";
28
3import { ProviderSessionRepository } from "../../persistence/Services/ProviderSessions.ts";
9import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
410import {
511 ProviderSessionDirectoryPersistenceError,
612 ProviderSessionNotFoundError,
......@@ -22,51 +28,154 @@ function toPersistenceError(operation: string) {
2228}
2329
2430function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding {
31 const sessionId = binding.sessionId.trim();
32 const threadId = binding.threadId?.trim();
33 const adapterKey = binding.adapterKey?.trim();
34 const providerThreadId =
35 binding.providerThreadId === null ? null : binding.providerThreadId?.trim();
36
2537 return {
26 sessionId: binding.sessionId.trim(),
38 sessionId: ProviderSessionId.makeUnsafe(sessionId),
2739 provider: binding.provider,
28 ...(binding.threadId?.trim() ? { threadId: binding.threadId.trim() } : {}),
40 ...(adapterKey !== undefined && adapterKey.length > 0 ? { adapterKey } : {}),
41 ...(threadId !== undefined && threadId.length > 0
42 ? { threadId: ThreadId.makeUnsafe(threadId) }
43 : {}),
44 ...(providerThreadId === null
45 ? { providerThreadId: null }
46 : providerThreadId !== undefined && providerThreadId.length > 0
47 ? { providerThreadId: ProviderThreadId.makeUnsafe(providerThreadId) }
48 : {}),
49 ...(binding.status !== undefined ? { status: binding.status } : {}),
50 ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}),
51 ...(binding.runtimePayload !== undefined ? { runtimePayload: binding.runtimePayload } : {}),
2952 };
3053}
3154
55function decodeProviderKind(
56 providerName: string,
57 operation: string,
58): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
59 if (providerName === "codex" || providerName === "claudeCode") {
60 return Effect.succeed(providerName);
61 }
62 return Effect.fail(
63 new ProviderSessionDirectoryPersistenceError({
64 operation,
65 detail: `Unknown persisted provider '${providerName}'.`,
66 }),
67 );
68}
69
70function isRecord(value: unknown): value is Record<string, unknown> {
71 return value !== null && typeof value === "object" && !Array.isArray(value);
72}
73
74function mergeRuntimePayload(
75 existing: unknown | null,
76 next: unknown | null | undefined,
77): unknown | null {
78 if (next === undefined) {
79 return existing ?? null;
80 }
81 if (isRecord(existing) && isRecord(next)) {
82 return {
83 ...existing,
84 ...next,
85 };
86 }
87 return next;
88}
89
3290const makeProviderSessionDirectory = Effect.gen(function* () {
33 const repository = yield* ProviderSessionRepository;
91 const repository = yield* ProviderSessionRuntimeRepository;
3492
3593 const persistUpsert = (binding: ProviderSessionBinding) =>
94 Effect.gen(function* () {
95 const existing = yield* repository
96 .getBySessionId({
97 providerSessionId: binding.sessionId,
98 })
99 .pipe(
100 Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")),
101 );
102
103 const existingRuntime = Option.getOrUndefined(existing);
104 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
105 if (!resolvedThreadId) {
106 return yield* Effect.fail(
107 new ProviderValidationError({
108 operation: "ProviderSessionDirectory.upsert",
109 issue: "threadId must be a non-empty string.",
110 }),
111 );
112 }
113
114 const now = new Date().toISOString();
115 yield* repository
116 .upsert({
117 providerSessionId: binding.sessionId,
118 threadId: resolvedThreadId,
119 providerName: binding.provider,
120 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,
121 providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null,
122 status: binding.status ?? existingRuntime?.status ?? "running",
123 lastSeenAt: now,
124 resumeCursor:
125 binding.resumeCursor !== undefined
126 ? binding.resumeCursor
127 : (existingRuntime?.resumeCursor ?? null),
128 runtimePayload: mergeRuntimePayload(
129 existingRuntime?.runtimePayload ?? null,
130 binding.runtimePayload,
131 ),
132 })
133 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
134 });
135
136 const persistDelete = (sessionId: ProviderSessionId) =>
36137 repository
37 .upsertSession({
38 sessionId: binding.sessionId,
39 provider: binding.provider,
40 ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}),
41 })
42 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession")));
43
44 const persistDelete = (sessionId: string) =>
45 repository
46 .deleteSession({ sessionId })
47 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession")));
48
49 const getBinding = (sessionId: string) =>
50 repository.getSession({ sessionId }).pipe(
51 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")),
52 Effect.map((entry) =>
53 Option.map(entry, (value) => ({
54 sessionId: value.sessionId,
55 provider: value.provider,
56 ...(value.threadId !== undefined ? { threadId: value.threadId } : {}),
57 })),
138 .deleteBySessionId({ providerSessionId: sessionId })
139 .pipe(
140 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
141 );
142
143 const getBinding = (sessionId: ProviderSessionId) =>
144 repository.getBySessionId({ providerSessionId: sessionId }).pipe(
145 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),
146 Effect.flatMap((runtime) =>
147 Option.match(runtime, {
148 onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()),
149 onSome: (value) =>
150 decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe(
151 Effect.map((provider) =>
152 Option.some({
153 sessionId: value.providerSessionId,
154 provider,
155 threadId: value.threadId,
156 }),
157 ),
158 ),
159 }),
58160 ),
59161 );
60162
61163 const listBindings = () =>
62 repository.listSessions().pipe(
63 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")),
64 Effect.map((rows) =>
65 rows.map((row) => ({
66 sessionId: row.sessionId,
67 provider: row.provider,
68 ...(row.threadId !== undefined ? { threadId: row.threadId } : {}),
69 })),
164 repository.list().pipe(
165 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),
166 Effect.flatMap((rows) =>
167 Effect.forEach(
168 rows,
169 (row) =>
170 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(
171 Effect.map((provider) => ({
172 sessionId: row.providerSessionId,
173 provider,
174 threadId: row.threadId,
175 })),
176 ),
177 { concurrency: "unbounded" },
178 ),
70179 ),
71180 );
72181
apps/server/src/provider/Services/ProviderAdapter.ts+18-9
......@@ -8,24 +8,28 @@
88 * @module ProviderAdapter
99 */
1010import type {
11 ApprovalRequestId,
1112 ProviderApprovalDecision,
1213 ProviderKind,
1314 ProviderRuntimeEvent,
1415 ProviderSendTurnInput,
1516 ProviderSession,
17 ProviderSessionId,
1618 ProviderSessionStartInput,
19 ProviderThreadId,
20 ProviderTurnId,
1721 ProviderTurnStartResult,
1822} from "@t3tools/contracts";
1923import type { Effect } from "effect";
2024import type { Stream } from "effect";
2125
2226export interface ProviderThreadTurnSnapshot {
23 readonly id: string;
27 readonly id: ProviderTurnId;
2428 readonly items: ReadonlyArray<unknown>;
2529}
2630
2731export interface ProviderThreadSnapshot {
28 readonly threadId: string;
32 readonly threadId: ProviderThreadId;
2933 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
3034}
3135
......@@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> {
5256 /**
5357 * Interrupt an active turn.
5458 */
55 readonly interruptTurn: (sessionId: string, turnId?: string) => Effect.Effect<void, TError>;
59 readonly interruptTurn: (
60 sessionId: ProviderSessionId,
61 turnId?: ProviderTurnId,
62 ) => Effect.Effect<void, TError>;
5663
5764 /**
5865 * Respond to an interactive approval request.
5966 */
6067 readonly respondToRequest: (
61 sessionId: string,
62 requestId: string,
68 sessionId: ProviderSessionId,
69 requestId: ApprovalRequestId,
6370 decision: ProviderApprovalDecision,
6471 ) => Effect.Effect<void, TError>;
6572
6673 /**
6774 * Stop one provider session.
6875 */
69 readonly stopSession: (sessionId: string) => Effect.Effect<void, TError>;
76 readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>;
7077
7178 /**
7279 * List currently active provider sessions for this adapter.
......@@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> {
7683 /**
7784 * Check whether this adapter owns an active session id.
7885 */
79 readonly hasSession: (sessionId: string) => Effect.Effect<boolean>;
86 readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>;
8087
8188 /**
8289 * Read a provider thread snapshot.
8390 */
84 readonly readThread: (sessionId: string) => Effect.Effect<ProviderThreadSnapshot, TError>;
91 readonly readThread: (
92 sessionId: ProviderSessionId,
93 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
8594
8695 /**
8796 * Roll back a provider thread by N turns.
8897 */
8998 readonly rollbackThread: (
90 sessionId: string,
99 sessionId: ProviderSessionId,
91100 numTurns: number,
92101 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
93102
apps/server/src/provider/Services/ProviderService.ts+7-25
......@@ -12,20 +12,15 @@
1212 * @module ProviderService
1313 */
1414import type {
15 ProviderGetCheckpointDiffInput,
16 ProviderGetCheckpointDiffResult,
1715 ProviderInterruptTurnInput,
18 ProviderListCheckpointsInput,
19 ProviderListCheckpointsResult,
2016 ProviderRespondToRequestInput,
21 ProviderRevertToCheckpointInput,
22 ProviderRevertToCheckpointResult,
17 ProviderRuntimeEvent,
2318 ProviderSendTurnInput,
2419 ProviderSession,
20 ProviderSessionId,
2521 ProviderSessionStartInput,
2622 ProviderStopSessionInput,
2723 ProviderTurnStartResult,
28 ProviderRuntimeEvent,
2924} from "@t3tools/contracts";
3025import { ServiceMap } from "effect";
3126import type { Effect, Stream } from "effect";
......@@ -74,25 +69,12 @@ export interface ProviderServiceShape {
7469 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;
7570
7671 /**
77 * List checkpoints for a provider session.
78 */
79 readonly listCheckpoints: (
80 input: ProviderListCheckpointsInput,
81 ) => Effect.Effect<ProviderListCheckpointsResult, ProviderServiceError>;
82
83 /**
84 * Diff two checkpoints for a provider session.
85 */
86 readonly getCheckpointDiff: (
87 input: ProviderGetCheckpointDiffInput,
88 ) => Effect.Effect<ProviderGetCheckpointDiffResult, ProviderServiceError>;
89
90 /**
91 * Revert a provider session to a checkpoint.
72 * Roll back provider conversation state by a number of turns.
9273 */
93 readonly revertToCheckpoint: (
94 input: ProviderRevertToCheckpointInput,
95 ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>;
74 readonly rollbackConversation: (input: {
75 readonly sessionId: ProviderSessionId;
76 readonly numTurns: number;
77 }) => Effect.Effect<void, ProviderServiceError>;
9678
9779 /**
9880 * Stop all active provider sessions.
apps/server/src/provider/Services/ProviderSessionDirectory.ts+19-8
......@@ -7,7 +7,13 @@
77 *
88 * @module ProviderSessionDirectory
99 */
10import type { ProviderKind } from "@t3tools/contracts";
10import type {
11 ProviderKind,
12 ProviderSessionId,
13 ProviderSessionRuntimeStatus,
14 ProviderThreadId,
15 ThreadId,
16} from "@t3tools/contracts";
1117import { Option, ServiceMap } from "effect";
1218import type { Effect } from "effect";
1319
......@@ -18,9 +24,14 @@ import type {
1824} from "../Errors.ts";
1925
2026export interface ProviderSessionBinding {
21 readonly sessionId: string;
27 readonly sessionId: ProviderSessionId;
2228 readonly provider: ProviderKind;
23 readonly threadId?: string;
29 readonly threadId?: ThreadId;
30 readonly adapterKey?: string;
31 readonly providerThreadId?: ProviderThreadId | null;
32 readonly status?: ProviderSessionRuntimeStatus;
33 readonly resumeCursor?: unknown | null;
34 readonly runtimePayload?: unknown | null;
2435}
2536
2637export type ProviderSessionDirectoryReadError =
......@@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape {
4354 * Resolve the provider owner for a session id.
4455 */
4556 readonly getProvider: (
46 sessionId: string,
57 sessionId: ProviderSessionId,
4758 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
4859
4960 /**
5061 * Resolve the tracked thread id for a session, if known.
5162 */
5263 readonly getThreadId: (
53 sessionId: string,
54 ) => Effect.Effect<Option.Option<string>, ProviderSessionDirectoryReadError>;
64 sessionId: ProviderSessionId,
65 ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>;
5566
5667 /**
5768 * Remove a session binding.
5869 */
5970 readonly remove: (
60 sessionId: string,
71 sessionId: ProviderSessionId,
6172 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;
6273
6374 /**
6475 * List tracked session ids.
6576 */
6677 readonly listSessionIds: () => Effect.Effect<
67 ReadonlyArray<string>,
78 ReadonlyArray<ProviderSessionId>,
6879 ProviderSessionDirectoryPersistenceError
6980 >;
7081
apps/server/src/providerManager.test.ts deleted-892
......@@ -1,892 +0,0 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { ProviderManager } from "./providerManager";
4
5describe("ProviderManager", () => {
6 it("detaches provider event listener and ends thread log streams on dispose", () => {
7 const manager = new ProviderManager();
8 const internals = manager as unknown as {
9 codex: { listenerCount: (event: string) => number };
10 onCodexEvent: (event: {
11 id: string;
12 kind: "notification";
13 provider: "codex";
14 sessionId: string;
15 createdAt: string;
16 method: string;
17 threadId: string;
18 }) => void;
19 threadLogStreams: Map<string, { writableEnded: boolean; destroyed: boolean }>;
20 };
21 internals.onCodexEvent({
22 id: "evt-1",
23 kind: "notification",
24 provider: "codex",
25 sessionId: "sess-1",
26 createdAt: new Date().toISOString(),
27 method: "thread/started",
28 threadId: "thread-1",
29 });
30 const stream = internals.threadLogStreams.get("thread-1");
31 expect(stream).toBeDefined();
32 if (!stream) return;
33
34 expect(internals.codex.listenerCount("event")).toBe(1);
35 manager.dispose();
36
37 expect(internals.codex.listenerCount("event")).toBe(0);
38 expect(stream.writableEnded || stream.destroyed).toBe(true);
39 expect(internals.threadLogStreams.size).toBe(0);
40 });
41
42 it("allows multiple dispose calls", () => {
43 const manager = new ProviderManager();
44
45 manager.dispose();
46 expect(() => manager.dispose()).not.toThrow();
47 });
48
49 it("rejects request responses for unknown sessions", async () => {
50 const manager = new ProviderManager();
51
52 await expect(
53 manager.respondToRequest({
54 sessionId: "missing-session",
55 requestId: "req-1",
56 decision: "accept",
57 }),
58 ).rejects.toThrow("Unknown provider session: missing-session");
59
60 manager.dispose();
61 });
62
63 it("rejects checkpoint operations for unknown sessions", async () => {
64 const manager = new ProviderManager();
65
66 await expect(
67 manager.listCheckpoints({
68 sessionId: "missing-session",
69 }),
70 ).rejects.toThrow("Unknown provider session: missing-session");
71
72 await expect(
73 manager.revertToCheckpoint({
74 sessionId: "missing-session",
75 turnCount: 0,
76 }),
77 ).rejects.toThrow("Unknown provider session: missing-session");
78
79 await expect(
80 manager.getCheckpointDiff({
81 sessionId: "missing-session",
82 fromTurnCount: 0,
83 toTurnCount: 1,
84 }),
85 ).rejects.toThrow("Unknown provider session: missing-session");
86
87 manager.dispose();
88 });
89
90 it("derives checkpoints from thread turns", async () => {
91 const manager = new ProviderManager();
92 const codex = (
93 manager as unknown as {
94 codex: {
95 hasSession: (sessionId: string) => boolean;
96 readThread: (sessionId: string) => Promise<{
97 threadId: string;
98 turns: Array<{ id: string; items: unknown[] }>;
99 }>;
100 };
101 }
102 ).codex;
103
104 codex.hasSession = () => true;
105 codex.readThread = async () => ({
106 threadId: "thr_1",
107 turns: [
108 {
109 id: "turn_1",
110 items: [
111 {
112 type: "userMessage",
113 content: [{ type: "text", text: "Refactor the logger" }],
114 },
115 {
116 type: "agentMessage",
117 text: "I refactored it.",
118 },
119 ],
120 },
121 ],
122 });
123
124 const result = await manager.listCheckpoints({
125 sessionId: "sess_1",
126 });
127
128 expect(result).toEqual({
129 threadId: "thr_1",
130 checkpoints: [
131 {
132 id: "root",
133 turnCount: 0,
134 messageCount: 0,
135 label: "Start of conversation",
136 isCurrent: false,
137 },
138 {
139 id: "turn_1",
140 turnCount: 1,
141 messageCount: 2,
142 label: "Turn 1",
143 preview: "Refactor the logger",
144 isCurrent: true,
145 },
146 ],
147 });
148
149 manager.dispose();
150 });
151
152 it("rolls back from a selected checkpoint turn count", async () => {
153 const manager = new ProviderManager();
154 const internals = manager as unknown as {
155 codex: {
156 hasSession: (sessionId: string) => boolean;
157 readThread: (sessionId: string) => Promise<{
158 threadId: string;
159 turns: Array<{ id: string; items: unknown[] }>;
160 }>;
161 rollbackThread: (
162 sessionId: string,
163 numTurns: number,
164 ) => Promise<{
165 threadId: string;
166 turns: Array<{ id: string; items: unknown[] }>;
167 }>;
168 };
169 filesystemCheckpointStore: {
170 hasCheckpoint: (input: {
171 cwd: string;
172 threadId: string;
173 turnCount: number;
174 }) => Promise<boolean>;
175 restoreCheckpoint: (input: {
176 cwd: string;
177 threadId: string;
178 turnCount: number;
179 }) => Promise<boolean>;
180 pruneAfterTurn: (input: {
181 cwd: string;
182 threadId: string;
183 maxTurnCount: number;
184 }) => Promise<void>;
185 };
186 sessionCheckpointCwds: Map<string, string>;
187 };
188
189 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
190 internals.codex.hasSession = () => true;
191 internals.codex.readThread = async () => ({
192 threadId: "thr_1",
193 turns: [
194 { id: "turn_1", items: [] },
195 { id: "turn_2", items: [] },
196 { id: "turn_3", items: [] },
197 ],
198 });
199 internals.codex.rollbackThread = async (sessionId, numTurns) => {
200 rollbackCalls.push({ sessionId, numTurns });
201 return {
202 threadId: "thr_1",
203 turns: [{ id: "turn_1", items: [] }],
204 };
205 };
206 const hasCheckpoint = vi.fn(async () => true);
207 const restoreCheckpoint = vi.fn(async () => true);
208 const pruneAfterTurn = vi.fn(async () => undefined);
209 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
210 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
211 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
212 internals.sessionCheckpointCwds.set("sess_1", "/repo");
213
214 const result = await manager.revertToCheckpoint({
215 sessionId: "sess_1",
216 turnCount: 1,
217 });
218
219 expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]);
220 expect(result.threadId).toBe("thr_1");
221 expect(result.turnCount).toBe(1);
222 expect(result.messageCount).toBe(0);
223 expect(result.rolledBackTurns).toBe(2);
224 expect(result.checkpoints).toHaveLength(2);
225 expect(hasCheckpoint).toHaveBeenCalledWith({
226 cwd: "/repo",
227 threadId: "thr_1",
228 turnCount: 1,
229 });
230 expect(restoreCheckpoint).toHaveBeenCalledWith({
231 cwd: "/repo",
232 threadId: "thr_1",
233 turnCount: 1,
234 });
235 expect(pruneAfterTurn).toHaveBeenCalledWith({
236 cwd: "/repo",
237 threadId: "thr_1",
238 maxTurnCount: 1,
239 });
240
241 manager.dispose();
242 });
243
244 it("returns diff text for a checkpoint range", async () => {
245 const manager = new ProviderManager();
246 const internals = manager as unknown as {
247 codex: {
248 hasSession: (sessionId: string) => boolean;
249 readThread: (sessionId: string) => Promise<{
250 threadId: string;
251 turns: Array<{ id: string; items: unknown[] }>;
252 }>;
253 };
254 filesystemCheckpointStore: {
255 diffCheckpoints: (input: {
256 cwd: string;
257 threadId: string;
258 fromTurnCount: number;
259 toTurnCount: number;
260 }) => Promise<string>;
261 };
262 sessionCheckpointCwds: Map<string, string>;
263 };
264
265 internals.codex.hasSession = () => true;
266 internals.codex.readThread = async () => ({
267 threadId: "thr_1",
268 turns: [
269 { id: "turn_1", items: [] },
270 { id: "turn_2", items: [] },
271 ],
272 });
273 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
274 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
275 internals.sessionCheckpointCwds.set("sess_1", "/repo");
276
277 const result = await manager.getCheckpointDiff({
278 sessionId: "sess_1",
279 fromTurnCount: 1,
280 toTurnCount: 2,
281 });
282
283 expect(result).toEqual({
284 threadId: "thr_1",
285 fromTurnCount: 1,
286 toTurnCount: 2,
287 diff: "diff --git a/a.ts b/a.ts",
288 });
289 expect(diffCheckpoints).toHaveBeenCalledWith({
290 cwd: "/repo",
291 threadId: "thr_1",
292 fromTurnCount: 1,
293 toTurnCount: 2,
294 });
295
296 manager.dispose();
297 });
298
299 it("emits checkpoint/captured after turn completion checkpoint capture", async () => {
300 const manager = new ProviderManager();
301 const events: Array<{ method: string; payload?: unknown; threadId?: string; turnId?: string }> =
302 [];
303 manager.on("event", (event) => {
304 events.push({
305 method: event.method,
306 ...(event.payload !== undefined ? { payload: event.payload } : {}),
307 ...(event.threadId ? { threadId: event.threadId } : {}),
308 ...(event.turnId ? { turnId: event.turnId } : {}),
309 });
310 });
311
312 const internals = manager as unknown as {
313 onCodexEvent: (event: {
314 id: string;
315 kind: "notification";
316 provider: "codex";
317 sessionId: string;
318 createdAt: string;
319 method: string;
320 threadId: string;
321 turnId?: string;
322 payload?: unknown;
323 }) => void;
324 codex: {
325 hasSession: (sessionId: string) => boolean;
326 readThread: (sessionId: string) => Promise<{
327 threadId: string;
328 turns: Array<{ id: string; items: unknown[] }>;
329 }>;
330 };
331 filesystemCheckpointStore: {
332 captureCheckpoint: (input: {
333 cwd: string;
334 threadId: string;
335 turnCount: number;
336 }) => Promise<void>;
337 };
338 sessionCheckpointCwds: Map<string, string>;
339 };
340
341 internals.codex.hasSession = () => true;
342 internals.codex.readThread = async () => ({
343 threadId: "thr_1",
344 turns: [{ id: "turn_1", items: [] }],
345 });
346 const captureCheckpoint = vi.fn(async () => undefined);
347 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
348 internals.sessionCheckpointCwds.set("sess_1", "/repo");
349
350 internals.onCodexEvent({
351 id: "evt-turn-complete",
352 kind: "notification",
353 provider: "codex",
354 sessionId: "sess_1",
355 createdAt: "2026-02-19T00:00:00.000Z",
356 method: "turn/completed",
357 threadId: "thr_1",
358 turnId: "turn_1",
359 payload: {
360 turn: {
361 status: "completed",
362 },
363 },
364 });
365
366 await vi.waitFor(() => {
367 expect(captureCheckpoint).toHaveBeenCalledWith({
368 cwd: "/repo",
369 threadId: "thr_1",
370 turnCount: 1,
371 });
372 expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true);
373 });
374
375 const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured");
376 expect(checkpointCapturedEvent?.threadId).toBe("thr_1");
377 expect(checkpointCapturedEvent?.turnId).toBe("turn_1");
378 expect(checkpointCapturedEvent?.payload).toEqual({
379 threadId: "thr_1",
380 turnCount: 1,
381 turnId: "turn_1",
382 status: "completed",
383 });
384
385 manager.dispose();
386 });
387
388 it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => {
389 const manager = new ProviderManager();
390 const internals = manager as unknown as {
391 codex: {
392 hasSession: (sessionId: string) => boolean;
393 listSessions: () => Array<{
394 sessionId: string;
395 provider: "codex";
396 status: "ready";
397 createdAt: string;
398 updatedAt: string;
399 cwd?: string;
400 }>;
401 readThread: (sessionId: string) => Promise<{
402 threadId: string;
403 turns: Array<{ id: string; items: unknown[] }>;
404 }>;
405 };
406 filesystemCheckpointStore: {
407 isGitRepository: (cwd: string) => Promise<boolean>;
408 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
409 captureCheckpoint: (input: {
410 cwd: string;
411 threadId: string;
412 turnCount: number;
413 }) => Promise<void>;
414 diffCheckpoints: (input: {
415 cwd: string;
416 threadId: string;
417 fromTurnCount: number;
418 toTurnCount: number;
419 }) => Promise<string>;
420 };
421 sessionCheckpointCwds: Map<string, string>;
422 };
423
424 internals.codex.hasSession = () => true;
425 internals.codex.listSessions = () => [
426 {
427 sessionId: "sess_1",
428 provider: "codex",
429 status: "ready",
430 createdAt: "2026-02-18T00:00:00.000Z",
431 updatedAt: "2026-02-18T00:00:00.000Z",
432 cwd: "/repo",
433 },
434 ];
435 internals.codex.readThread = async () => ({
436 threadId: "thr_1",
437 turns: [
438 { id: "turn_1", items: [] },
439 { id: "turn_2", items: [] },
440 ],
441 });
442 const isGitRepository = vi.fn(async () => true);
443 const ensureRootCheckpoint = vi.fn(async () => true);
444 const captureCheckpoint = vi.fn(async () => undefined);
445 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
446 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
447 internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
448 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
449 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
450 internals.sessionCheckpointCwds.delete("sess_1");
451
452 const result = await manager.getCheckpointDiff({
453 sessionId: "sess_1",
454 fromTurnCount: 1,
455 toTurnCount: 2,
456 });
457
458 expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
459 expect(isGitRepository).toHaveBeenCalledWith("/repo");
460 expect(captureCheckpoint).toHaveBeenCalledWith({
461 cwd: "/repo",
462 threadId: "thr_1",
463 turnCount: 2,
464 });
465 expect(ensureRootCheckpoint).toHaveBeenCalledWith({
466 cwd: "/repo",
467 threadId: "thr_1",
468 });
469 expect(diffCheckpoints).toHaveBeenCalledWith({
470 cwd: "/repo",
471 threadId: "thr_1",
472 fromTurnCount: 1,
473 toTurnCount: 2,
474 });
475 expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo");
476
477 manager.dispose();
478 });
479
480 it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => {
481 const manager = new ProviderManager();
482 const internals = manager as unknown as {
483 codex: {
484 hasSession: (sessionId: string) => boolean;
485 listSessions: () => Array<{
486 sessionId: string;
487 provider: "codex";
488 status: "ready";
489 createdAt: string;
490 updatedAt: string;
491 cwd?: string;
492 }>;
493 readThread: (sessionId: string) => Promise<{
494 threadId: string;
495 turns: Array<{ id: string; items: unknown[] }>;
496 }>;
497 };
498 filesystemCheckpointStore: {
499 isGitRepository: (cwd: string) => Promise<boolean>;
500 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
501 captureCheckpoint: (input: {
502 cwd: string;
503 threadId: string;
504 turnCount: number;
505 }) => Promise<void>;
506 diffCheckpoints: (input: {
507 cwd: string;
508 threadId: string;
509 fromTurnCount: number;
510 toTurnCount: number;
511 }) => Promise<string>;
512 };
513 sessionCheckpointCwds: Map<string, string>;
514 };
515
516 const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
517 try {
518 internals.codex.hasSession = () => true;
519 internals.codex.listSessions = () => [
520 {
521 sessionId: "sess_1",
522 provider: "codex",
523 status: "ready",
524 createdAt: "2026-02-18T00:00:00.000Z",
525 updatedAt: "2026-02-18T00:00:00.000Z",
526 },
527 ];
528 internals.codex.readThread = async () => ({
529 threadId: "thr_1",
530 turns: [{ id: "turn_1", items: [] }],
531 });
532 const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
533 const ensureRootCheckpoint = vi.fn(async () => true);
534 const captureCheckpoint = vi.fn(async () => undefined);
535 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
536 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
537 internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
538 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
539 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
540 internals.sessionCheckpointCwds.delete("sess_1");
541
542 const result = await manager.getCheckpointDiff({
543 sessionId: "sess_1",
544 fromTurnCount: 0,
545 toTurnCount: 1,
546 });
547
548 expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
549 expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo");
550 expect(captureCheckpoint).toHaveBeenCalledWith({
551 cwd: "/fallback-repo",
552 threadId: "thr_1",
553 turnCount: 1,
554 });
555 expect(ensureRootCheckpoint).toHaveBeenCalledWith({
556 cwd: "/fallback-repo",
557 threadId: "thr_1",
558 });
559 expect(diffCheckpoints).toHaveBeenCalledWith({
560 cwd: "/fallback-repo",
561 threadId: "thr_1",
562 fromTurnCount: 0,
563 toTurnCount: 1,
564 });
565 expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo");
566 } finally {
567 processCwd.mockRestore();
568 manager.dispose();
569 }
570 });
571
572 it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => {
573 const manager = new ProviderManager();
574 const internals = manager as unknown as {
575 codex: {
576 hasSession: (sessionId: string) => boolean;
577 listSessions: () => Array<{
578 sessionId: string;
579 provider: "codex";
580 status: "ready";
581 createdAt: string;
582 updatedAt: string;
583 cwd?: string;
584 }>;
585 };
586 filesystemCheckpointStore: {
587 isGitRepository: (cwd: string) => Promise<boolean>;
588 };
589 sessionCheckpointCwds: Map<string, string>;
590 };
591
592 const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
593 try {
594 internals.codex.hasSession = () => true;
595 internals.codex.listSessions = () => [
596 {
597 sessionId: "sess_1",
598 provider: "codex",
599 status: "ready",
600 createdAt: "2026-02-18T00:00:00.000Z",
601 updatedAt: "2026-02-18T00:00:00.000Z",
602 cwd: "/session-cwd",
603 },
604 ];
605 const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
606 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
607 internals.sessionCheckpointCwds.delete("sess_1");
608
609 await expect(
610 manager.getCheckpointDiff({
611 sessionId: "sess_1",
612 fromTurnCount: 0,
613 toTurnCount: 1,
614 }),
615 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
616 expect(isGitRepository).toHaveBeenCalledWith("/session-cwd");
617 expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo");
618 } finally {
619 processCwd.mockRestore();
620 manager.dispose();
621 }
622 });
623
624 it("fails revert when filesystem checkpoints are unavailable for the session", async () => {
625 const manager = new ProviderManager();
626 const internals = manager as unknown as {
627 codex: {
628 hasSession: (sessionId: string) => boolean;
629 listSessions: () => Array<{
630 sessionId: string;
631 provider: "codex";
632 status: "ready";
633 createdAt: string;
634 updatedAt: string;
635 cwd?: string;
636 }>;
637 };
638 filesystemCheckpointStore: {
639 isGitRepository: (cwd: string) => Promise<boolean>;
640 };
641 sessionCheckpointCwds: Map<string, string>;
642 };
643
644 internals.codex.hasSession = () => true;
645 internals.codex.listSessions = () => [
646 {
647 sessionId: "sess_1",
648 provider: "codex",
649 status: "ready",
650 createdAt: "2026-02-18T00:00:00.000Z",
651 updatedAt: "2026-02-18T00:00:00.000Z",
652 },
653 ];
654 internals.filesystemCheckpointStore.isGitRepository = async () => false;
655 internals.sessionCheckpointCwds.delete("sess_1");
656
657 await expect(
658 manager.revertToCheckpoint({
659 sessionId: "sess_1",
660 turnCount: 1,
661 }),
662 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
663
664 manager.dispose();
665 });
666
667 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {
668 const manager = new ProviderManager();
669 const internals = manager as unknown as {
670 codex: {
671 hasSession: (sessionId: string) => boolean;
672 readThread: (sessionId: string) => Promise<{
673 threadId: string;
674 turns: Array<{ id: string; items: unknown[] }>;
675 }>;
676 rollbackThread: (
677 sessionId: string,
678 numTurns: number,
679 ) => Promise<{
680 threadId: string;
681 turns: Array<{ id: string; items: unknown[] }>;
682 }>;
683 };
684 filesystemCheckpointStore: {
685 hasCheckpoint: (input: {
686 cwd: string;
687 threadId: string;
688 turnCount: number;
689 }) => Promise<boolean>;
690 restoreCheckpoint: (input: {
691 cwd: string;
692 threadId: string;
693 turnCount: number;
694 }) => Promise<boolean>;
695 pruneAfterTurn: (input: {
696 cwd: string;
697 threadId: string;
698 maxTurnCount: number;
699 }) => Promise<void>;
700 };
701 sessionCheckpointCwds: Map<string, string>;
702 };
703
704 internals.codex.hasSession = () => true;
705 const callOrder: string[] = [];
706 internals.codex.readThread = async () => ({
707 threadId: "thr_1",
708 turns: [
709 { id: "turn_1", items: [] },
710 { id: "turn_2", items: [] },
711 ],
712 });
713 internals.codex.rollbackThread = async () => {
714 callOrder.push("rollback");
715 return {
716 threadId: "thr_1",
717 turns: [{ id: "turn_1", items: [] }],
718 };
719 };
720 const hasCheckpoint = vi.fn(async () => true);
721 const restoreCheckpoint = vi.fn(async () => {
722 callOrder.push("restore");
723 return true;
724 });
725 const pruneAfterTurn = vi.fn(async () => {
726 callOrder.push("prune");
727 return undefined;
728 });
729 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
730 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
731 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
732 internals.sessionCheckpointCwds.set("sess_1", "/repo");
733
734 const result = await manager.revertToCheckpoint({
735 sessionId: "sess_1",
736 turnCount: 1,
737 });
738
739 expect(result.turnCount).toBe(1);
740 expect(hasCheckpoint).toHaveBeenCalledWith({
741 cwd: "/repo",
742 threadId: "thr_1",
743 turnCount: 1,
744 });
745 expect(restoreCheckpoint).toHaveBeenCalledWith({
746 cwd: "/repo",
747 threadId: "thr_1",
748 turnCount: 1,
749 });
750 expect(pruneAfterTurn).toHaveBeenCalledWith({
751 cwd: "/repo",
752 threadId: "thr_1",
753 maxTurnCount: 1,
754 });
755 expect(callOrder).toEqual(["restore", "rollback", "prune"]);
756
757 manager.dispose();
758 });
759
760 it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => {
761 const manager = new ProviderManager();
762 const internals = manager as unknown as {
763 codex: {
764 hasSession: (sessionId: string) => boolean;
765 readThread: (sessionId: string) => Promise<{
766 threadId: string;
767 turns: Array<{ id: string; items: unknown[] }>;
768 }>;
769 rollbackThread: (
770 sessionId: string,
771 numTurns: number,
772 ) => Promise<{
773 threadId: string;
774 turns: Array<{ id: string; items: unknown[] }>;
775 }>;
776 };
777 filesystemCheckpointStore: {
778 hasCheckpoint: (input: {
779 cwd: string;
780 threadId: string;
781 turnCount: number;
782 }) => Promise<boolean>;
783 restoreCheckpoint: (input: {
784 cwd: string;
785 threadId: string;
786 turnCount: number;
787 }) => Promise<boolean>;
788 pruneAfterTurn: (input: {
789 cwd: string;
790 threadId: string;
791 maxTurnCount: number;
792 }) => Promise<void>;
793 };
794 sessionCheckpointCwds: Map<string, string>;
795 };
796
797 internals.codex.hasSession = () => true;
798 internals.codex.readThread = async () => ({
799 threadId: "thr_1",
800 turns: [
801 { id: "turn_1", items: [] },
802 { id: "turn_2", items: [] },
803 ],
804 });
805 internals.codex.rollbackThread = async () => ({
806 threadId: "thr_1",
807 turns: [],
808 });
809 const hasCheckpoint = vi.fn(async () => false);
810 const restoreCheckpoint = vi.fn(async () => true);
811 const pruneAfterTurn = vi.fn(async () => undefined);
812 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
813 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
814 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
815 internals.sessionCheckpointCwds.set("sess_1", "/repo");
816
817 const result = await manager.revertToCheckpoint({
818 sessionId: "sess_1",
819 turnCount: 0,
820 });
821
822 expect(result.turnCount).toBe(0);
823 expect(hasCheckpoint).not.toHaveBeenCalled();
824 expect(restoreCheckpoint).toHaveBeenCalledWith({
825 cwd: "/repo",
826 threadId: "thr_1",
827 turnCount: 0,
828 });
829 expect(pruneAfterTurn).toHaveBeenCalledWith({
830 cwd: "/repo",
831 threadId: "thr_1",
832 maxTurnCount: 0,
833 });
834
835 manager.dispose();
836 });
837
838 it("fails before rollback when filesystem checkpoint is missing", async () => {
839 const manager = new ProviderManager();
840 const internals = manager as unknown as {
841 codex: {
842 hasSession: (sessionId: string) => boolean;
843 readThread: (sessionId: string) => Promise<{
844 threadId: string;
845 turns: Array<{ id: string; items: unknown[] }>;
846 }>;
847 rollbackThread: (
848 sessionId: string,
849 numTurns: number,
850 ) => Promise<{
851 threadId: string;
852 turns: Array<{ id: string; items: unknown[] }>;
853 }>;
854 };
855 filesystemCheckpointStore: {
856 hasCheckpoint: (input: {
857 cwd: string;
858 threadId: string;
859 turnCount: number;
860 }) => Promise<boolean>;
861 };
862 sessionCheckpointCwds: Map<string, string>;
863 };
864
865 const rollbackSpy = vi.fn(async () => ({
866 threadId: "thr_1",
867 turns: [{ id: "turn_1", items: [] }],
868 }));
869
870 internals.codex.hasSession = () => true;
871 internals.codex.readThread = async () => ({
872 threadId: "thr_1",
873 turns: [
874 { id: "turn_1", items: [] },
875 { id: "turn_2", items: [] },
876 ],
877 });
878 internals.codex.rollbackThread = rollbackSpy;
879 internals.filesystemCheckpointStore.hasCheckpoint = async () => false;
880 internals.sessionCheckpointCwds.set("sess_1", "/repo");
881
882 await expect(
883 manager.revertToCheckpoint({
884 sessionId: "sess_1",
885 turnCount: 1,
886 }),
887 ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1.");
888 expect(rollbackSpy).not.toHaveBeenCalled();
889
890 manager.dispose();
891 });
892});
apps/server/src/providerManager.ts deleted-670
......@@ -1,670 +0,0 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4import { randomUUID } from "node:crypto";
5
6import {
7 type ProviderCheckpoint,
8 type ProviderEvent,
9 type ProviderGetCheckpointDiffInput,
10 type ProviderGetCheckpointDiffResult,
11 type ProviderInterruptTurnInput,
12 type ProviderListCheckpointsInput,
13 type ProviderListCheckpointsResult,
14 type ProviderRevertToCheckpointInput,
15 type ProviderRevertToCheckpointResult,
16 type ProviderRespondToRequestInput,
17 type ProviderSendTurnInput,
18 type ProviderSession,
19 type ProviderSessionStartInput,
20 type ProviderStopSessionInput,
21 type ProviderTurnStartResult,
22 providerInterruptTurnInputSchema,
23 providerGetCheckpointDiffInputSchema,
24 providerListCheckpointsInputSchema,
25 providerRevertToCheckpointInputSchema,
26 providerRespondToRequestInputSchema,
27 providerSendTurnInputSchema,
28 providerSessionStartInputSchema,
29 providerStopSessionInputSchema,
30} from "@t3tools/contracts";
31import type { CodexThreadTurnSnapshot } from "./codexAppServerManager";
32import { CodexAppServerManager } from "./codexAppServerManager";
33import { FilesystemCheckpointStore } from "./filesystemCheckpointStore";
34
35export interface ProviderManagerEvents {
36 event: [event: ProviderEvent];
37}
38
39function asObject(value: unknown): Record<string, unknown> | undefined {
40 if (!value || typeof value !== "object") {
41 return undefined;
42 }
43 return value as Record<string, unknown>;
44}
45
46function asString(value: unknown): string | undefined {
47 return typeof value === "string" ? value : undefined;
48}
49
50function asArray(value: unknown): unknown[] {
51 return Array.isArray(value) ? value : [];
52}
53
54function trimToPreview(value: string): string {
55 const normalized = value.replace(/\s+/g, " ").trim();
56 if (normalized.length <= 120) {
57 return normalized;
58 }
59 return `${normalized.slice(0, 117)}...`;
60}
61
62function summarizeUserMessageContent(content: unknown[]): string | undefined {
63 const segments: string[] = [];
64
65 for (const part of content) {
66 const record = asObject(part);
67 const type = asString(record?.type);
68 if (!type) continue;
69
70 if (type === "text") {
71 const text = asString(record?.text);
72 if (text && text.trim().length > 0) {
73 segments.push(text.trim());
74 }
75 continue;
76 }
77
78 if (type === "image") {
79 segments.push("[Image attachment]");
80 continue;
81 }
82
83 if (type === "localImage") {
84 segments.push("[Local image attachment]");
85 continue;
86 }
87 }
88
89 if (segments.length === 0) {
90 return undefined;
91 }
92 return trimToPreview(segments.join(" "));
93}
94
95function summarizeTurn(turn: CodexThreadTurnSnapshot): {
96 messageCountDelta: number;
97 preview?: string;
98} {
99 let messageCountDelta = 0;
100 let preview: string | undefined;
101
102 for (const item of turn.items) {
103 const record = asObject(item);
104 const type = asString(record?.type);
105 if (!type) continue;
106
107 if (type === "userMessage") {
108 messageCountDelta += 1;
109 if (!preview) {
110 const content = asArray(record?.content);
111 preview = summarizeUserMessageContent(content);
112 }
113 continue;
114 }
115
116 if (type === "agentMessage") {
117 messageCountDelta += 1;
118 if (!preview) {
119 const text = asString(record?.text);
120 if (text && text.trim().length > 0) {
121 preview = trimToPreview(text);
122 }
123 }
124 }
125 }
126
127 return {
128 messageCountDelta,
129 ...(preview ? { preview } : {}),
130 };
131}
132
133function readTurnCompletionStatus(event: ProviderEvent): string | undefined {
134 const payload = asObject(event.payload);
135 const turn = asObject(payload?.turn);
136 return asString(turn?.status);
137}
138
139function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] {
140 const checkpoints: ProviderCheckpoint[] = [];
141 let messageCount = 0;
142 const isEmpty = turns.length === 0;
143 checkpoints.push({
144 id: "root",
145 turnCount: 0,
146 messageCount: 0,
147 label: "Start of conversation",
148 isCurrent: isEmpty,
149 });
150
151 for (let index = 0; index < turns.length; index += 1) {
152 const turn = turns[index];
153 if (!turn) continue;
154 const turnSummary = summarizeTurn(turn);
155 messageCount += turnSummary.messageCountDelta;
156 checkpoints.push({
157 id: turn.id,
158 turnCount: index + 1,
159 messageCount,
160 label: `Turn ${index + 1}`,
161 ...(turnSummary.preview ? { preview: turnSummary.preview } : {}),
162 isCurrent: index === turns.length - 1,
163 });
164 }
165
166 return checkpoints;
167}
168
169export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
170 private readonly codex = new CodexAppServerManager();
171 private readonly filesystemCheckpointStore = new FilesystemCheckpointStore();
172 private readonly threadLogsDir: string;
173 private readonly threadLogStreams = new Map<string, fs.WriteStream>();
174 private readonly sessionThreadIds = new Map<string, string>();
175 private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>();
176 private readonly sessionCheckpointCwds = new Map<string, string>();
177 private readonly filesystemLocks = new Map<string, Promise<void>>();
178 private disposed = false;
179 private readonly onCodexEvent = (event: ProviderEvent) => {
180 if (this.disposed) {
181 return;
182 }
183
184 this.routeEventToThreadLog(event);
185 this.emit("event", event);
186 this.maybeCaptureFilesystemCheckpoint(event);
187 };
188
189 constructor() {
190 super();
191
192 const logsDir = path.resolve(process.cwd(), ".logs");
193 this.threadLogsDir = path.join(logsDir, "threads");
194 fs.mkdirSync(this.threadLogsDir, { recursive: true });
195
196 this.codex.on("event", this.onCodexEvent);
197 }
198
199 async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> {
200 const input = providerSessionStartInputSchema.parse(raw);
201 if (input.provider !== "codex") {
202 throw new Error(`Provider '${input.provider}' is not implemented yet.`);
203 }
204
205 const session = await this.codex.startSession(input);
206 if (session.threadId) {
207 this.sessionThreadIds.set(session.sessionId, session.threadId);
208 this.flushPendingSessionEvents(session.sessionId, session.threadId);
209 }
210 await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => {
211 const message =
212 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
213 this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId);
214 });
215 return session;
216 }
217
218 async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
219 const input = providerSendTurnInputSchema.parse(raw);
220 if (!this.codex.hasSession(input.sessionId)) {
221 throw new Error(`Unknown provider session: ${input.sessionId}`);
222 }
223
224 return this.codex.sendTurn(input);
225 }
226
227 async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> {
228 const input = providerInterruptTurnInputSchema.parse(raw);
229 if (!this.codex.hasSession(input.sessionId)) {
230 throw new Error(`Unknown provider session: ${input.sessionId}`);
231 }
232
233 await this.codex.interruptTurn(input.sessionId, input.turnId);
234 }
235
236 async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> {
237 const input = providerRespondToRequestInputSchema.parse(raw);
238 if (!this.codex.hasSession(input.sessionId)) {
239 throw new Error(`Unknown provider session: ${input.sessionId}`);
240 }
241
242 await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision);
243 }
244
245 stopSession(raw: ProviderStopSessionInput): void {
246 const input = providerStopSessionInputSchema.parse(raw);
247 this.codex.stopSession(input.sessionId);
248 this.sessionThreadIds.delete(input.sessionId);
249 this.pendingEventsBySession.delete(input.sessionId);
250 this.sessionCheckpointCwds.delete(input.sessionId);
251 this.filesystemLocks.delete(input.sessionId);
252 }
253
254 listSessions(): ProviderSession[] {
255 return this.codex.listSessions();
256 }
257
258 async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> {
259 const input = providerListCheckpointsInputSchema.parse(raw);
260 if (!this.codex.hasSession(input.sessionId)) {
261 throw new Error(`Unknown provider session: ${input.sessionId}`);
262 }
263
264 const snapshot = await this.codex.readThread(input.sessionId);
265 return {
266 threadId: snapshot.threadId,
267 checkpoints: buildCheckpoints(snapshot.turns),
268 };
269 }
270
271 async getCheckpointDiff(
272 raw: ProviderGetCheckpointDiffInput,
273 ): Promise<ProviderGetCheckpointDiffResult> {
274 const input = providerGetCheckpointDiffInputSchema.parse(raw);
275 if (!this.codex.hasSession(input.sessionId)) {
276 throw new Error(`Unknown provider session: ${input.sessionId}`);
277 }
278
279 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
280 if (!checkpointCwd) {
281 throw new Error("Filesystem checkpoints are unavailable for this session.");
282 }
283
284 return this.withFilesystemLock(input.sessionId, async () => {
285 const snapshot = await this.codex.readThread(input.sessionId);
286 if (input.toTurnCount > snapshot.turns.length) {
287 throw new Error(
288 `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
289 );
290 }
291
292 const diff = await this.filesystemCheckpointStore.diffCheckpoints({
293 cwd: checkpointCwd,
294 threadId: snapshot.threadId,
295 fromTurnCount: input.fromTurnCount,
296 toTurnCount: input.toTurnCount,
297 });
298
299 return {
300 threadId: snapshot.threadId,
301 fromTurnCount: input.fromTurnCount,
302 toTurnCount: input.toTurnCount,
303 diff,
304 };
305 });
306 }
307
308 async revertToCheckpoint(
309 raw: ProviderRevertToCheckpointInput,
310 ): Promise<ProviderRevertToCheckpointResult> {
311 const input = providerRevertToCheckpointInputSchema.parse(raw);
312 if (!this.codex.hasSession(input.sessionId)) {
313 throw new Error(`Unknown provider session: ${input.sessionId}`);
314 }
315
316 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
317 if (!checkpointCwd) {
318 throw new Error("Filesystem checkpoints are unavailable for this session.");
319 }
320 return this.withFilesystemLock(input.sessionId, async () => {
321 const beforeSnapshot = await this.codex.readThread(input.sessionId);
322 const currentTurnCount = beforeSnapshot.turns.length;
323 if (input.turnCount > currentTurnCount) {
324 throw new Error(
325 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
326 );
327 }
328
329 if (checkpointCwd && input.turnCount > 0) {
330 const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({
331 cwd: checkpointCwd,
332 threadId: beforeSnapshot.threadId,
333 turnCount: input.turnCount,
334 });
335 if (!hasCheckpoint) {
336 throw new Error(
337 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
338 );
339 }
340 }
341
342 const restored = await this.filesystemCheckpointStore.restoreCheckpoint({
343 cwd: checkpointCwd,
344 threadId: beforeSnapshot.threadId,
345 turnCount: input.turnCount,
346 });
347 if (!restored) {
348 throw new Error(
349 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
350 );
351 }
352
353 const requestedRollbackTurns = currentTurnCount - input.turnCount;
354 const afterSnapshot =
355 requestedRollbackTurns > 0
356 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)
357 : beforeSnapshot;
358
359 await this.filesystemCheckpointStore.pruneAfterTurn({
360 cwd: checkpointCwd,
361 threadId: afterSnapshot.threadId,
362 maxTurnCount: afterSnapshot.turns.length,
363 });
364
365 const checkpoints = buildCheckpoints(afterSnapshot.turns);
366 const currentCheckpoint =
367 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
368 checkpoints[checkpoints.length - 1] ??
369 checkpoints[0];
370 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);
371
372 return {
373 threadId: afterSnapshot.threadId,
374 turnCount: currentCheckpoint?.turnCount ?? 0,
375 messageCount: currentCheckpoint?.messageCount ?? 0,
376 rolledBackTurns,
377 checkpoints,
378 };
379 });
380 }
381
382 stopAll(): void {
383 this.codex.stopAll();
384 }
385
386 dispose(): void {
387 if (this.disposed) {
388 return;
389 }
390
391 this.disposed = true;
392 this.codex.off("event", this.onCodexEvent);
393 for (const stream of this.threadLogStreams.values()) {
394 stream.end();
395 }
396 this.threadLogStreams.clear();
397 this.sessionThreadIds.clear();
398 this.pendingEventsBySession.clear();
399 this.sessionCheckpointCwds.clear();
400 this.filesystemLocks.clear();
401 }
402
403 private async initializeFilesystemCheckpointing(
404 session: ProviderSession,
405 preferredCwd?: string,
406 ): Promise<void> {
407 const cwd = preferredCwd ?? session.cwd ?? process.cwd();
408
409 await this.withFilesystemLock(session.sessionId, async () => {
410 const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd);
411 if (!supportsGit) {
412 this.sessionCheckpointCwds.delete(session.sessionId);
413 return;
414 }
415
416 const snapshot = await this.codex.readThread(session.sessionId);
417 await this.filesystemCheckpointStore.ensureRootCheckpoint({
418 cwd,
419 threadId: snapshot.threadId,
420 });
421 await this.filesystemCheckpointStore.captureCheckpoint({
422 cwd,
423 threadId: snapshot.threadId,
424 turnCount: snapshot.turns.length,
425 });
426 if (this.codex.hasSession(session.sessionId)) {
427 this.sessionCheckpointCwds.set(session.sessionId, cwd);
428 }
429 });
430 }
431
432 private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise<string | null> {
433 const existingCwd = this.sessionCheckpointCwds.get(sessionId);
434 if (existingCwd) {
435 return existingCwd;
436 }
437
438 const session = this.codex
439 .listSessions()
440 .find((candidate) => candidate.sessionId === sessionId);
441 const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()];
442
443 await this.withFilesystemLock(sessionId, async () => {
444 const currentCwd = this.sessionCheckpointCwds.get(sessionId);
445 if (currentCwd) {
446 return;
447 }
448
449 const cwdSupport = await Promise.all(
450 candidateCwds.map(async (cwd) => ({
451 cwd,
452 supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd),
453 })),
454 );
455 const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd;
456 if (supportedCwd) {
457 const snapshot = await this.codex.readThread(sessionId);
458 await this.filesystemCheckpointStore.ensureRootCheckpoint({
459 cwd: supportedCwd,
460 threadId: snapshot.threadId,
461 });
462 await this.filesystemCheckpointStore.captureCheckpoint({
463 cwd: supportedCwd,
464 threadId: snapshot.threadId,
465 turnCount: snapshot.turns.length,
466 });
467 if (this.codex.hasSession(sessionId)) {
468 this.sessionCheckpointCwds.set(sessionId, supportedCwd);
469 }
470 return;
471 }
472
473 this.sessionCheckpointCwds.delete(sessionId);
474 });
475
476 return this.sessionCheckpointCwds.get(sessionId) ?? null;
477 }
478
479 private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void {
480 if (event.kind !== "notification" || event.method !== "turn/completed") {
481 return;
482 }
483
484 const turnStatus = readTurnCompletionStatus(event);
485 const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId);
486 if (!checkpointCwd) {
487 void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId)
488 .then(async (initializedCwd) => {
489 if (!initializedCwd || !this.codex.hasSession(event.sessionId)) {
490 return;
491 }
492 const snapshot = await this.codex.readThread(event.sessionId);
493 this.emitCheckpointCaptured(
494 event.sessionId,
495 snapshot.threadId,
496 snapshot.turns.length,
497 event.turnId,
498 turnStatus,
499 );
500 })
501 .catch((error) => {
502 const message =
503 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
504 this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId);
505 });
506 return;
507 }
508
509 void this.withFilesystemLock(event.sessionId, async () => {
510 if (!this.codex.hasSession(event.sessionId)) {
511 return;
512 }
513
514 const snapshot = await this.codex.readThread(event.sessionId);
515 await this.filesystemCheckpointStore.captureCheckpoint({
516 cwd: checkpointCwd,
517 threadId: snapshot.threadId,
518 turnCount: snapshot.turns.length,
519 });
520 this.emitCheckpointCaptured(
521 event.sessionId,
522 snapshot.threadId,
523 snapshot.turns.length,
524 event.turnId,
525 turnStatus,
526 );
527 }).catch((error) => {
528 const message = error instanceof Error ? error.message : "Failed to capture checkpoint.";
529 this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId);
530 });
531 }
532
533 private emitCheckpointCaptured(
534 sessionId: string,
535 threadId: string,
536 turnCount: number,
537 turnId?: string,
538 status?: string,
539 ): void {
540 this.emit("event", {
541 id: randomUUID(),
542 kind: "notification",
543 provider: "codex",
544 sessionId,
545 createdAt: new Date().toISOString(),
546 method: "checkpoint/captured",
547 threadId,
548 ...(turnId ? { turnId } : {}),
549 payload: {
550 threadId,
551 turnCount,
552 ...(turnId ? { turnId } : {}),
553 ...(status ? { status } : {}),
554 },
555 });
556 }
557
558 private emitFilesystemCheckpointError(
559 sessionId: string,
560 message: string,
561 threadId?: string,
562 ): void {
563 this.emit("event", {
564 id: randomUUID(),
565 kind: "error",
566 provider: "codex",
567 sessionId,
568 createdAt: new Date().toISOString(),
569 method: "checkpoint/filesystemError",
570 message,
571 ...(threadId ? { threadId } : {}),
572 });
573 }
574
575 private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
576 const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve();
577 const next = previous.catch(() => undefined).then(fn);
578 const completion = next.then(
579 () => undefined,
580 () => undefined,
581 );
582 this.filesystemLocks.set(sessionId, completion);
583 return next.finally(() => {
584 const tracked = this.filesystemLocks.get(sessionId);
585 if (tracked === completion) {
586 this.filesystemLocks.delete(sessionId);
587 }
588 });
589 }
590
591 private routeEventToThreadLog(event: ProviderEvent): void {
592 const threadId = this.resolveThreadId(event);
593 if (!threadId) {
594 const pending = this.pendingEventsBySession.get(event.sessionId) ?? [];
595 pending.push(event);
596 this.pendingEventsBySession.set(event.sessionId, pending.slice(-100));
597 return;
598 }
599
600 this.flushPendingSessionEvents(event.sessionId, threadId);
601 this.writeThreadEvent(threadId, event);
602 }
603
604 private flushPendingSessionEvents(sessionId: string, threadId: string): void {
605 const pending = this.pendingEventsBySession.get(sessionId);
606 if (!pending || pending.length === 0) {
607 return;
608 }
609
610 for (const event of pending) {
611 this.writeThreadEvent(threadId, event);
612 }
613 this.pendingEventsBySession.delete(sessionId);
614 }
615
616 private resolveThreadId(event: ProviderEvent): string | undefined {
617 const fromPayload = this.readThreadIdFromPayload(event.payload);
618 const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId);
619
620 if (threadId) {
621 this.sessionThreadIds.set(event.sessionId, threadId);
622 }
623
624 return threadId;
625 }
626
627 private readThreadIdFromPayload(payload: unknown): string | undefined {
628 if (!payload || typeof payload !== "object") {
629 return undefined;
630 }
631
632 const record = payload as Record<string, unknown>;
633 const direct = record.threadId;
634 if (typeof direct === "string" && direct.length > 0) {
635 return direct;
636 }
637
638 const thread = record.thread;
639 if (!thread || typeof thread !== "object") {
640 return undefined;
641 }
642
643 const nested = (thread as Record<string, unknown>).id;
644 if (typeof nested === "string" && nested.length > 0) {
645 return nested;
646 }
647
648 return undefined;
649 }
650
651 private writeThreadEvent(threadId: string, event: ProviderEvent): void {
652 const stream = this.getOrCreateThreadLogStream(threadId);
653 stream.write(`${JSON.stringify(event)}\n`);
654 }
655
656 private getOrCreateThreadLogStream(threadId: string): fs.WriteStream {
657 const cached = this.threadLogStreams.get(threadId);
658 if (cached) {
659 return cached;
660 }
661
662 const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
663 const stream = fs.createWriteStream(
664 path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`),
665 { flags: "a" },
666 );
667 this.threadLogStreams.set(threadId, stream);
668 return stream;
669 }
670}
apps/server/src/terminalManager.ts+18-16
......@@ -4,20 +4,16 @@ import path from "node:path";
44
55import {
66 DEFAULT_TERMINAL_ID,
7 type TerminalClearInput,
8 type TerminalCloseInput,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalWriteInput,
912 type TerminalEvent,
10 type TerminalOpenInput,
11 type TerminalResizeInput,
1213 type TerminalSessionSnapshot,
1314 type TerminalSessionStatus,
14 type TerminalWriteInput,
15 terminalClearInputSchema,
16 terminalCloseInputSchema,
17 terminalOpenInputSchema,
18 terminalResizeInputSchema,
19 terminalWriteInputSchema,
2015} from "@t3tools/contracts";
16import { Schema } from "effect";
2117
2218import { createLogger } from "./logger";
2319import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
......@@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120;
3026const DEFAULT_OPEN_ROWS = 30;
3127const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
3228
29const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);
30const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
31const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
32const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
33const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
34
3335type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
3436
3537export interface TerminalManagerEvents {
......@@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
332334 }
333335
334336 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
335 const input = terminalOpenInputSchema.parse(raw);
337 const input = decodeTerminalOpenInput(raw);
336338 return this.runWithThreadLock(input.threadId, async () => {
337339 await this.assertValidCwd(input.cwd);
338340
......@@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
404406 }
405407
406408 async write(raw: TerminalWriteInput): Promise<void> {
407 const input = terminalWriteInputSchema.parse(raw);
409 const input = decodeTerminalWriteInput(raw);
408410 const session = this.requireSession(input.threadId, input.terminalId);
409411 if (!session.process || session.status !== "running") {
410412 throw new Error(
......@@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
415417 }
416418
417419 async resize(raw: TerminalResizeInput): Promise<void> {
418 const input = terminalResizeInputSchema.parse(raw);
420 const input = decodeTerminalResizeInput(raw);
419421 const session = this.requireSession(input.threadId, input.terminalId);
420422 if (!session.process || session.status !== "running") {
421423 throw new Error(
......@@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
429431 }
430432
431433 async clear(raw: TerminalClearInput): Promise<void> {
432 const input = terminalClearInputSchema.parse(raw);
434 const input = decodeTerminalClearInput(raw);
433435 await this.runWithThreadLock(input.threadId, async () => {
434436 const session = this.requireSession(input.threadId, input.terminalId);
435437 session.history = "";
......@@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
445447 }
446448
447449 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
448 const input = terminalOpenInputSchema.parse(raw);
450 const input = decodeTerminalOpenInput(raw);
449451 return this.runWithThreadLock(input.threadId, async () => {
450452 await this.assertValidCwd(input.cwd);
451453
......@@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
490492 }
491493
492494 async close(raw: TerminalCloseInput): Promise<void> {
493 const input = terminalCloseInputSchema.parse(raw);
495 const input = decodeTerminalCloseInput(raw);
494496 await this.runWithThreadLock(input.threadId, async () => {
495497 if (input.terminalId) {
496498 await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true);
apps/server/src/workspaceEntries.ts+7-9
......@@ -4,10 +4,9 @@ import path from "node:path";
44import { runProcess } from "./processRunner";
55
66import {
7 projectSearchEntriesInputSchema,
8 type ProjectEntry,
9 type ProjectSearchEntriesInput,
10 type ProjectSearchEntriesResult,
7 ProjectEntry,
8 ProjectSearchEntriesInput,
9 ProjectSearchEntriesResult,
1110} from "@t3tools/contracts";
1211
1312const WORKSPACE_CACHE_TTL_MS = 15_000;
......@@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
413412export async function searchWorkspaceEntries(
414413 input: ProjectSearchEntriesInput,
415414): Promise<ProjectSearchEntriesResult> {
416 const parsed = projectSearchEntriesInputSchema.parse(input);
417 const index = await getWorkspaceIndex(parsed.cwd);
418 const normalizedQuery = normalizeQuery(parsed.query);
415 const index = await getWorkspaceIndex(input.cwd);
416 const normalizedQuery = normalizeQuery(input.query);
419417 const candidates = normalizedQuery
420418 ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
421419 : index.entries;
......@@ -427,7 +425,7 @@ export async function searchWorkspaceEntries(
427425 });
428426
429427 return {
430 entries: ranked.slice(0, parsed.limit),
431 truncated: index.truncated || ranked.length > parsed.limit,
428 entries: ranked.slice(0, input.limit),
429 truncated: index.truncated || ranked.length > input.limit,
432430 };
433431}
apps/server/src/wsServer.test.ts+30-168
......@@ -3,7 +3,7 @@ import os from "node:os";
33import path from "node:path";
44import { EventEmitter } from "node:events";
55
6import { Effect, Layer, ManagedRuntime, PubSub, Stream } from "effect";
6import { Effect, Layer, PubSub, Stream } from "effect";
77import { describe, expect, it, afterEach, vi } from "vitest";
88import { createServer } from "./wsServer";
99import WebSocket from "ws";
......@@ -21,7 +21,6 @@ import {
2121 type WsResponse,
2222} from "@t3tools/contracts";
2323import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
24import { ProjectRepository } from "./persistence/Services/Projects";
2524import type {
2625 TerminalClearInput,
2726 TerminalCloseInput,
......@@ -32,9 +31,7 @@ import type {
3231 TerminalWriteInput,
3332} from "@t3tools/contracts";
3433import type { TerminalManager } from "./terminalManager";
35import { ProjectRepositoryLive } from "./persistence/Layers/Projects";
36import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
37import { NodeServices } from "@effect/platform-node";
34import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
3835import { SqlClient } from "effect/unstable/sql";
3936import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
4037
......@@ -664,38 +661,6 @@ describe("WebSocket Server", () => {
664661 expect(response.error!.message).toContain("Unknown method");
665662 });
666663
667 it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => {
668 server = createTestServer({ cwd: "/test" });
669 await server.start();
670 const addr = server.httpServer.address();
671 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
672
673 const ws = await connectWs(port);
674 connections.push(ws);
675 await waitForMessage(ws);
676
677 const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, {
678 sessionId: "missing-session",
679 });
680 expect(listResponse.result).toBeUndefined();
681 expect(listResponse.error?.message).toContain("Unknown provider session");
682
683 const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, {
684 sessionId: "missing-session",
685 turnCount: 0,
686 });
687 expect(revertResponse.result).toBeUndefined();
688 expect(revertResponse.error?.message).toContain("Unknown provider session");
689
690 const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, {
691 sessionId: "missing-session",
692 fromTurnCount: 0,
693 toTurnCount: 1,
694 });
695 expect(diffResponse.result).toBeUndefined();
696 expect(diffResponse.error?.message).toContain("Unknown provider session");
697 });
698
699664 it("keeps orchestration domain push behavior for provider runtime events", async () => {
700665 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
701666 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
......@@ -709,15 +674,7 @@ describe("WebSocket Server", () => {
709674 respondToRequest: () => unsupported(),
710675 stopSession: () => unsupported(),
711676 listSessions: () => Effect.succeed([]),
712 listCheckpoints: () => unsupported(),
713 getCheckpointDiff: (input) =>
714 Effect.succeed({
715 threadId: "thread-1",
716 fromTurnCount: input.fromTurnCount,
717 toTurnCount: input.toTurnCount,
718 diff: "",
719 }),
720 revertToCheckpoint: () => unsupported(),
677 rollbackConversation: () => unsupported(),
721678 stopAll: () => Effect.void,
722679 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
723680 };
......@@ -736,6 +693,16 @@ describe("WebSocket Server", () => {
736693 await waitForMessage(ws);
737694
738695 const createdAt = new Date().toISOString();
696 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
697 type: "project.create",
698 commandId: "cmd-ws-project-create",
699 projectId: "project-1",
700 title: "WS Project",
701 workspaceRoot: "/tmp/ws-project",
702 defaultModel: "gpt-5-codex",
703 createdAt,
704 });
705 expect(createProjectResponse.error).toBeUndefined();
739706 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
740707 type: "thread.create",
741708 commandId: "cmd-ws-runtime-thread-create",
......@@ -928,11 +895,8 @@ describe("WebSocket Server", () => {
928895 expect(response!.error!.message).toContain("Invalid request format");
929896 });
930897
931 it("supports projects list/add/dedupe/remove", async () => {
932 const stateDir = makeTempDir("t3code-ws-projects-state-");
933 const firstProjectCwd = makeTempDir("t3code-ws-project-a-");
934
935 server = createTestServer({ stateDir, cwd: "/test" });
898 it("returns unknown method for removed projects CRUD methods", async () => {
899 server = createTestServer({ cwd: "/test" });
936900 await server.start();
937901 const addr = server.httpServer.address();
938902 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
......@@ -941,75 +905,21 @@ describe("WebSocket Server", () => {
941905 connections.push(ws);
942906 await waitForMessage(ws);
943907
944 const emptyList = await sendRequest(ws, WS_METHODS.projectsList);
945 expect(emptyList.error).toBeUndefined();
946 expect(emptyList.result).toEqual([]);
947
948 const created = await sendRequest(ws, WS_METHODS.projectsAdd, {
949 cwd: firstProjectCwd,
950 });
951 expect(created.error).toBeUndefined();
952 expect((created.result as { created: boolean }).created).toBe(true);
908 const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
909 expect(listResponse.result).toBeUndefined();
910 expect(listResponse.error?.message).toContain("Unknown method");
953911
954 const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, {
955 cwd: firstProjectCwd,
956 });
957 expect(duplicate.error).toBeUndefined();
958 expect((duplicate.result as { created: boolean }).created).toBe(false);
959
960 const listed = await sendRequest(ws, WS_METHODS.projectsList);
961 expect(listed.error).toBeUndefined();
962 const listedProjects = listed.result as Array<{ id: string; cwd: string }>;
963 expect(listedProjects).toHaveLength(1);
964
965 const projectId = listedProjects[0]?.id;
966 expect(projectId).toBeTruthy();
967 if (!projectId) return;
968
969 const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, {
970 id: projectId,
971 scripts: [
972 {
973 id: "setup",
974 name: "Setup",
975 command: "bun install",
976 icon: "configure",
977 runOnWorktreeCreate: true,
978 },
979 ],
912 const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
913 cwd: "/tmp/project-a",
980914 });
981 expect(updatedScripts.error).toBeUndefined();
982 const scriptPayload = (
983 updatedScripts.result as {
984 project: {
985 scripts: Array<{
986 id: string;
987 name: string;
988 command: string;
989 icon: string;
990 runOnWorktreeCreate: boolean;
991 }>;
992 };
993 }
994 ).project.scripts;
995 expect(scriptPayload).toEqual([
996 {
997 id: "setup",
998 name: "Setup",
999 command: "bun install",
1000 icon: "configure",
1001 runOnWorktreeCreate: true,
1002 },
1003 ]);
915 expect(addResponse.result).toBeUndefined();
916 expect(addResponse.error?.message).toContain("Unknown method");
1004917
1005 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {
1006 id: projectId,
918 const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
919 id: "project-a",
1007920 });
1008 expect(removed.error).toBeUndefined();
1009
1010 const afterRemove = await sendRequest(ws, WS_METHODS.projectsList);
1011 expect(afterRemove.error).toBeUndefined();
1012 expect(afterRemove.result).toEqual([]);
921 expect(removeResponse.result).toBeUndefined();
922 expect(removeResponse.error?.message).toContain("Unknown method");
1013923 });
1014924
1015925 it("supports projects.searchEntries", async () => {
......@@ -1079,7 +989,7 @@ describe("WebSocket Server", () => {
1079989 expect(pullResponse.error?.message).not.toContain("Unknown method");
1080990 });
1081991
1082 it("responds to git.status via the git manager", async () => {
992 it("returns unknown method for git.status", async () => {
1083993 const gitManager = {
1084994 status: vi.fn().mockResolvedValue({
1085995 branch: "feature/test",
......@@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => {
11091019 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
11101020 cwd: "/test",
11111021 });
1112 expect(response.error).toBeUndefined();
1113 expect(response.result).toEqual({
1114 branch: "feature/test",
1115 hasWorkingTreeChanges: true,
1116 workingTree: {
1117 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1118 insertions: 7,
1119 deletions: 2,
1120 },
1121 hasUpstream: false,
1122 aheadCount: 0,
1123 behindCount: 0,
1124 openPr: null,
1125 });
1126 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
1022 expect(response.result).toBeUndefined();
1023 expect(response.error?.message).toContain("Unknown method");
1024 expect(gitManager.status).not.toHaveBeenCalled();
11271025 });
11281026
11291027 it("returns errors from git.runStackedAction", async () => {
......@@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => {
11531051 });
11541052 });
11551053
1156 it("prunes missing projects on startup", async () => {
1157 const stateDir = makeTempDir("t3code-ws-prune-state-");
1158 const existing = makeTempDir("t3code-ws-existing-project-");
1159 const missing = makeTempDir("t3code-ws-missing-project-");
1160 const dbPath = path.join(stateDir, "test-prune.sqlite");
1161 const persistenceLayer = Layer.provide(makeSqlitePersistenceLive(dbPath), NodeServices.layer);
1162
1163 const projectRuntime = ManagedRuntime.make(
1164 ProjectRepositoryLive.pipe(Layer.provide(persistenceLayer)),
1165 );
1166 const projectRepository = await projectRuntime.runPromise(Effect.service(ProjectRepository));
1167 await projectRuntime.runPromise(projectRepository.add({ cwd: existing }));
1168 await projectRuntime.runPromise(projectRepository.add({ cwd: missing }));
1169 await projectRuntime.dispose();
1170 fs.rmSync(missing, { recursive: true, force: true });
1171
1172 server = createTestServer({
1173 stateDir,
1174 cwd: "/test",
1175 persistenceLayer,
1176 });
1177 await server.start();
1178 const addr = server.httpServer.address();
1179 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1180
1181 const ws = await connectWs(port);
1182 connections.push(ws);
1183 await waitForMessage(ws);
1184
1185 const response = await sendRequest(ws, WS_METHODS.projectsList);
1186 expect(response.error).toBeUndefined();
1187 const listed = response.result as Array<{ cwd: string }>;
1188 expect(listed).toHaveLength(1);
1189 expect(listed[0]?.cwd).toBe(existing);
1190 });
1191
11921054 it("rejects websocket connections without a valid auth token", async () => {
11931055 server = createTestServer({ cwd: "/test", authToken: "secret-token" });
11941056 await server.start();
apps/server/src/wsServer.ts+161-197
......@@ -9,16 +9,15 @@ import {
99 EDITORS,
1010 ORCHESTRATION_WS_CHANNELS,
1111 ORCHESTRATION_WS_METHODS,
12 TerminalEvent,
1213 WS_CHANNELS,
1314 WS_METHODS,
14 type TerminalEvent,
15 type WsPush,
16 type WsRequest,
17 type WsResponse,
18 wsRequestSchema,
15 WebSocketRequest,
16 WsPush,
17 WsResponse,
1918} from "@t3tools/contracts";
2019import { NodeServices } from "@effect/platform-node";
21import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect";
20import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect";
2221import { WebSocketServer, type WebSocket } from "ws";
2322
2423import { createLogger } from "./logger";
......@@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager";
3635import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3736import { searchWorkspaceEntries } from "./workspaceEntries";
3837import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
38import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
39import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
40import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
41import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
42import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
3943import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
4044import {
4145 OrchestrationEngineService,
4246 type OrchestrationEngineShape,
4347} from "./orchestration/Services/OrchestrationEngine";
44import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion";
45import { ProjectRepositoryLive } from "./persistence/Layers/Projects";
48import {
49 ProjectionSnapshotQuery,
50 type ProjectionSnapshotQueryShape,
51} from "./orchestration/Services/ProjectionSnapshotQuery";
52import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
4653import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
47import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects";
4854import assert from "node:assert";
55import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs";
4956import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
57import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
5058import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
5159import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
5260import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
5361import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
5462import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
5563import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
56import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService";
57import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints";
58import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions";
64import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65import {
66 CheckpointDiffBlobRepository,
67 type CheckpointDiffBlobRepositoryShape,
68} from "./persistence/Services/CheckpointDiffBlobs";
5969import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
6070import * as SqlClient from "effect/unstable/sql/SqlClient";
6171import * as SqlError from "effect/unstable/sql/SqlError";
6272import * as Migrator from "effect/unstable/sql/Migrator";
73import { clamp } from "effect/Number";
6374
6475const MIME_TYPES: Record<string, string> = {
6576 ".html": "text/html; charset=utf-8",
......@@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
122133}
123134
124135type EffectRuntime = ManagedRuntime.ManagedRuntime<
125 | ProjectRepository
126136 | OrchestrationEngineService
137 | ProjectionSnapshotQuery
127138 | ProviderService
128 | ProviderRuntimeIngestionService,
139 | CheckpointDiffBlobRepository
140 | OrchestrationReactor,
129141 unknown
130142>;
131143
......@@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) {
147159
148160 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
149161 let effectRuntime: EffectRuntime | null = null;
150 let projectRepository: ProjectRepositoryShape | null = null;
151162 let orchestrationEngine: OrchestrationEngineShape | null = null;
163 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
152164 let providerService: ProviderServiceShape | null = null;
165 let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null;
153166 const clients = new Set<WebSocket>();
154167 const logger = createLogger("ws");
155168 const logWebSocketEvents =
......@@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) {
173186 return orchestrationEngine;
174187 };
175188
176 const getProjectRepository = () => {
177 assert(projectRepository, "Project repository is not started");
178 return projectRepository;
179 };
180
181189 const getEffectRuntime = () => {
182190 assert(effectRuntime, "Effect runtime is not started");
183191 return effectRuntime;
184192 };
185193
186 const getProviderService = () => {
187 assert(providerService, "Provider service is not started");
188 return providerService;
194 const getProjectionSnapshotQuery = () => {
195 assert(projectionSnapshotQuery, "Projection snapshot query is not started");
196 return projectionSnapshotQuery;
197 };
198
199 const getCheckpointDiffBlobRepository = () => {
200 assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started");
201 return checkpointDiffBlobRepository;
189202 };
190203
191204 const attachSubscriptionss = Effect.gen(function* () {
192205 const orchestrationEngine = yield* OrchestrationEngineService;
193 const runtimeIngestion = yield* ProviderRuntimeIngestionService;
206 const orchestrationReactor = yield* OrchestrationReactor;
194207 subscriptionsScope = yield* Scope.make("sequential");
195208
196209 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
......@@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) {
216229 }),
217230 ).pipe(Effect.forkIn(subscriptionsScope));
218231
219 yield* runtimeIngestion.start.pipe(Scope.provide(subscriptionsScope));
232 yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope));
220233 });
221234
222235 const onTerminalEvent = (event: TerminalEvent) => {
......@@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) {
347360 });
348361
349362 async function handleMessage(ws: WebSocket, raw: unknown) {
350 let request: WsRequest;
363 let request: WebSocketRequest;
351364 try {
352 const parsed = JSON.parse(String(raw));
353 request = wsRequestSchema.parse(parsed);
365 request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw);
354366 } catch {
355367 const errorResponse: WsResponse = {
356368 id: "unknown",
......@@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) {
374386 }
375387 }
376388
377 async function routeRequest(request: WsRequest): Promise<unknown> {
389 async function routeRequest(request: WebSocketRequest): Promise<unknown> {
378390 const orchestrationEngine = getOrchestrationEngine();
379 const projectRepository = getProjectRepository();
391 const projectionReadModelQuery = getProjectionSnapshotQuery();
380392 const effectRuntime = getEffectRuntime();
381 const providerService = getProviderService();
382
383 switch (request.method) {
384 case WS_METHODS.providersStartSession:
385 return effectRuntime.runPromise(providerService.startSession(request.params as never));
386
387 case WS_METHODS.providersSendTurn:
388 return effectRuntime.runPromise(providerService.sendTurn(request.params as never));
393 const diffBlobRepository = getCheckpointDiffBlobRepository();
389394
390 case WS_METHODS.providersInterruptTurn:
391 return effectRuntime.runPromise(providerService.interruptTurn(request.params as never));
392
393 case WS_METHODS.providersRespondToRequest:
394 return effectRuntime.runPromise(providerService.respondToRequest(request.params as never));
395
396 case WS_METHODS.providersStopSession: {
397 await effectRuntime.runPromise(providerService.stopSession(request.params as never));
398 return undefined;
395 switch (request.body._tag) {
396 case ORCHESTRATION_WS_METHODS.getSnapshot: {
397 return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
399398 }
400399
401 case WS_METHODS.providersListCheckpoints:
402 return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never));
403
404 case WS_METHODS.providersGetCheckpointDiff:
405 return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never));
406
407 case WS_METHODS.providersRevertToCheckpoint: {
408 const result = await effectRuntime.runPromise(
409 providerService.revertToCheckpoint(request.params as never),
410 );
411 const params = request.params as { sessionId?: string };
412 const sessionId = params.sessionId;
413 if (typeof sessionId === "string") {
414 const readModel = await effectRuntime.runPromise(orchestrationEngine.getReadModel());
415 const thread = readModel.threads.find((entry) => entry.session?.sessionId === sessionId);
416 if (thread) {
417 const now = new Date().toISOString();
418 await effectRuntime.runPromise(
419 orchestrationEngine.dispatch({
420 type: "thread.revert",
421 commandId: crypto.randomUUID(),
422 threadId: thread.id,
423 turnCount: result.turnCount,
424 messageCount: result.messageCount,
425 createdAt: now,
426 }),
427 );
428 if (thread.session) {
429 await effectRuntime.runPromise(
430 orchestrationEngine.dispatch({
431 type: "thread.session",
432 commandId: crypto.randomUUID(),
433 threadId: thread.id,
434 session: {
435 ...thread.session,
436 status: "ready",
437 activeTurnId: null,
438 updatedAt: now,
439 lastError: null,
440 },
441 createdAt: now,
442 }),
443 );
444 }
445 }
446 }
447 return result;
400 case ORCHESTRATION_WS_METHODS.dispatchCommand: {
401 orchestrationCommandLogger?.write({
402 observedAt: new Date().toISOString(),
403 command: request.body.command,
404 });
405 return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command));
448406 }
449407
450 case WS_METHODS.projectsList:
451 return effectRuntime.runPromise(projectRepository.list());
408 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
409 const blob = await effectRuntime.runPromise(diffBlobRepository.get(request.body));
410 if (Option.isNone(blob)) {
411 throw new Error(
412 `Turn diff not found for thread '${request.body.threadId}' from ${request.body.fromTurnCount} to ${request.body.toTurnCount}.`,
413 );
414 }
452415
453 case WS_METHODS.projectsAdd:
454 return effectRuntime.runPromise(projectRepository.add(request.params as never));
416 return {
417 threadId: request.body.threadId,
418 fromTurnCount: request.body.fromTurnCount,
419 toTurnCount: request.body.toTurnCount,
420 diff: blob.value?.diff,
421 };
422 }
455423
456 case WS_METHODS.projectsRemove:
457 return effectRuntime.runPromise(projectRepository.remove(request.params as never));
424 case ORCHESTRATION_WS_METHODS.replayEvents:
425 return effectRuntime.runPromise(
426 Stream.runCollect(
427 orchestrationEngine.readEvents(
428 clamp(request.body.fromSequenceExclusive, {
429 maximum: Number.MAX_SAFE_INTEGER,
430 minimum: 0,
431 }),
432 ),
433 ).pipe(Effect.map((events) => Array.from(events))),
434 );
458435
459436 case WS_METHODS.projectsSearchEntries:
460 return searchWorkspaceEntries(request.params as never);
461 case WS_METHODS.projectsUpdateScripts:
462 return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never));
437 return searchWorkspaceEntries(request.body);
463438
464439 case WS_METHODS.shellOpenInEditor: {
465 const params = request.params as {
466 cwd: string;
467 editor: string;
468 };
469 if (!params?.cwd) throw new Error("cwd is required");
470 const editorDef = EDITORS.find((e) => e.id === params.editor);
471 if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`);
440 const body = request.body;
441 const editorDef = EDITORS.find((e) => e.id === body.editor);
442 if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`);
472443
473444 let command: string;
474445 let args: string[];
475446
476447 if (editorDef.command) {
477448 command = editorDef.command;
478 args = [params.cwd];
449 args = [body.cwd];
479450 } else if (editorDef.id === "file-manager") {
480451 // Use platform-specific file manager command
481452 switch (process.platform) {
......@@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) {
489460 command = "xdg-open";
490461 break;
491462 }
492 args = [params.cwd];
463 args = [body.cwd];
493464 } else {
494465 return undefined;
495466 }
......@@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) {
505476 return undefined;
506477 }
507478
508 case WS_METHODS.gitStatus: {
509 const params = request.params as { cwd: string };
510 const status = await gitManager.status(request.params as never);
511 const projects = await effectRuntime.runPromise(projectRepository.list());
512 const project = projects.find((entry) => entry.cwd === params.cwd);
513 if (project) {
514 await effectRuntime.runPromise(
515 orchestrationEngine.dispatch({
516 type: "git.readModel.upsert",
517 commandId: crypto.randomUUID(),
518 projectId: project.id,
519 branch: status.branch,
520 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
521 aheadCount: status.aheadCount,
522 behindCount: status.behindCount,
523 createdAt: new Date().toISOString(),
524 }),
525 );
526 }
527 return status;
528 }
479 // TODO: Bring back
480 // case WS_METHODS.gitStatus: {
481 // const params = request.params as { cwd: string };
482 // const status = await gitManager.status(request.params as never);
483 // const projects = await effectRuntime.runPromise(projectRepository.list());
484 // const project = projects.find((entry) => entry.cwd === params.cwd);
485 // if (project) {
486 // await effectRuntime.runPromise(
487 // orchestrationEngine.dispatch({
488 // type: "git.readModel.upsert",
489 // commandId: crypto.randomUUID(),
490 // projectId: project.id,
491 // branch: status.branch,
492 // hasWorkingTreeChanges: status.hasWorkingTreeChanges,
493 // aheadCount: status.aheadCount,
494 // behindCount: status.behindCount,
495 // createdAt: new Date().toISOString(),
496 // }),
497 // );
498 // }
499 // return status;
500 // }
529501
530502 case WS_METHODS.gitPull:
531 return pullGitBranch(request.params as never);
503 return pullGitBranch(request.body);
532504
533505 case WS_METHODS.gitRunStackedAction:
534 return gitManager.runStackedAction(request.params as never);
506 return gitManager.runStackedAction(request.body);
535507 case WS_METHODS.gitListBranches:
536 return listGitBranches(request.params as never);
508 return listGitBranches(request.body);
537509
538510 case WS_METHODS.gitCreateWorktree:
539 return createGitWorktree(request.params as never);
511 return createGitWorktree(request.body);
540512
541513 case WS_METHODS.gitRemoveWorktree:
542 return removeGitWorktree(request.params as never);
514 return removeGitWorktree(request.body);
543515
544516 case WS_METHODS.gitCreateBranch:
545 return createGitBranch(request.params as never);
517 return createGitBranch(request.body);
546518
547519 case WS_METHODS.gitCheckout:
548 return checkoutGitBranch(request.params as never);
520 return checkoutGitBranch(request.body);
549521
550522 case WS_METHODS.gitInit:
551 return initGitRepo(request.params as never);
523 return initGitRepo(request.body);
552524
553525 case WS_METHODS.terminalOpen:
554 return terminalManager.open(request.params as never);
526 return terminalManager.open(request.body);
555527
556528 case WS_METHODS.terminalWrite:
557 await terminalManager.write(request.params as never);
529 await terminalManager.write(request.body);
558530 return undefined;
559531
560532 case WS_METHODS.terminalResize:
561 await terminalManager.resize(request.params as never);
533 await terminalManager.resize(request.body);
562534 return undefined;
563535
564536 case WS_METHODS.terminalClear:
565 await terminalManager.clear(request.params as never);
537 await terminalManager.clear(request.body);
566538 return undefined;
567539
568540 case WS_METHODS.terminalRestart:
569 return terminalManager.restart(request.params as never);
541 return terminalManager.restart(request.body);
570542
571543 case WS_METHODS.terminalClose:
572 await terminalManager.close(request.params as never);
544 await terminalManager.close(request.body);
573545 return undefined;
574546
575547 case WS_METHODS.serverGetConfig:
......@@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) {
579551 };
580552
581553 case WS_METHODS.serverUpsertKeybinding:
582 keybindingsConfig = upsertKeybindingRule(logger, request.params);
554 keybindingsConfig = upsertKeybindingRule(logger, request.body);
583555 return {
584556 keybindings: keybindingsConfig,
585557 };
586558
587 case ORCHESTRATION_WS_METHODS.getSnapshot:
588 return effectRuntime.runPromise(orchestrationEngine.getReadModel());
589
590 case ORCHESTRATION_WS_METHODS.dispatchCommand:
591 orchestrationCommandLogger?.write({
592 observedAt: new Date().toISOString(),
593 requestId: request.id,
594 command: request.params,
595 });
596 return effectRuntime.runPromise(orchestrationEngine.dispatchUnknownCommand(request.params));
597
598 case ORCHESTRATION_WS_METHODS.replayEvents:
599 return effectRuntime.runPromise(
600 Stream.runCollect(
601 orchestrationEngine.readEvents(
602 Math.max(
603 0,
604 Math.floor(
605 Number(
606 (request.params as { fromSequenceExclusive?: number } | undefined)
607 ?.fromSequenceExclusive ?? 0,
608 ),
609 ),
610 ),
611 ),
612 ).pipe(Effect.map((events) => Array.from(events))),
613 );
614
615559 default:
616 throw new Error(`Unknown method: ${request.method}`);
560 throw new Error(`Unknown method: ${request.body._tag}`);
617561 }
618562 }
619563
......@@ -628,7 +572,12 @@ export function createServer(options: ServerOptions) {
628572 path.join(orchestrationLogsDir, "orchestration-command.ndjson"),
629573 );
630574 const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath);
631 const orchestrationLayer = Layer.provide(OrchestrationEngineLive, OrchestrationEventStoreLive);
575 const orchestrationLayer = OrchestrationEngineLive.pipe(
576 Layer.provide(OrchestrationProjectionPipelineLive),
577 Layer.provide(OrchestrationEventStoreLive),
578 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
579 );
580 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
632581 const providerLayer =
633582 customProviderLayer ??
634583 (() => {
......@@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) {
639588 Layer.provide(codexAdapterLayer),
640589 );
641590 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
642 Layer.provide(ProviderSessionRepositoryLive),
643 );
644 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
645 const checkpointDependenciesLayer = Layer.mergeAll(
646 checkpointStoreLayer,
647 CheckpointRepositoryLive,
648 adapterRegistryLayer,
649 providerSessionDirectoryLayer,
650 );
651 const checkpointServiceLayer = CheckpointServiceLive.pipe(
652 Layer.provideMerge(checkpointDependenciesLayer),
591 Layer.provide(ProviderSessionRuntimeRepositoryLive),
653592 );
654593 return makeProviderServiceLive({
655594 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
656 }).pipe(Layer.provideMerge(checkpointServiceLayer));
595 }).pipe(
596 Layer.provide(adapterRegistryLayer),
597 Layer.provide(providerSessionDirectoryLayer),
598 );
657599 })();
658600
659601 const runtimeServicesLayer = Layer.mergeAll(
660602 orchestrationLayer,
661 ProjectRepositoryLive,
603 OrchestrationProjectionSnapshotQueryLive,
604 CheckpointDiffBlobRepositoryLive,
605 checkpointStoreLayer,
662606 providerLayer,
663607 );
664 const layer = ProviderRuntimeIngestionLive.pipe(
608 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
609 Layer.provideMerge(runtimeServicesLayer),
610 );
611 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
665612 Layer.provideMerge(runtimeServicesLayer),
613 );
614 const checkpointReactorLayer = CheckpointReactorLive.pipe(
615 Layer.provideMerge(runtimeServicesLayer),
616 );
617 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
618 Layer.provideMerge(runtimeIngestionLayer),
619 Layer.provideMerge(providerCommandReactorLayer),
620 Layer.provideMerge(checkpointReactorLayer),
621 );
622 const layer = orchestrationReactorLayer.pipe(
666623 Layer.provide(persistenceLayer),
667624 Layer.provideMerge(NodeServices.layer),
668625 );
669626 const runtime = ManagedRuntime.make(layer);
670627
671628 try {
672 const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([
629 const [
630 nextOrchestrationEngine,
631 nextProjectionSnapshotQuery,
632 nextCheckpointDiffBlobRepository,
633 nextProviderService,
634 ] = await Promise.all([
673635 runtime.runPromise(Effect.service(OrchestrationEngineService)),
674 runtime.runPromise(Effect.service(ProjectRepository)),
636 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
637 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
675638 runtime.runPromise(Effect.service(ProviderService)),
676639 ]);
677640 orchestrationEngine = nextOrchestrationEngine;
678 projectRepository = repository;
641 projectionSnapshotQuery = nextProjectionSnapshotQuery;
642 checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository;
679643 providerService = nextProviderService;
680 await runtime.runPromise(repository.pruneMissing());
681644 await runtime.runPromise(attachSubscriptionss);
682645 } catch (error) {
683646 await runtime.dispose().catch(() => undefined);
......@@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) {
701664 const liveProviderService = providerService;
702665 effectRuntime = null;
703666 orchestrationEngine = null;
704 projectRepository = null;
667 projectionSnapshotQuery = null;
668 checkpointDiffBlobRepository = null;
705669 providerService = null;
706670
707671 if (!runtime) {
apps/server/tsconfig.json+2-1
......@@ -12,5 +12,6 @@
1212 }
1313 ]
1414 },
15 "include": ["src", "integration", "tsdown.config.ts", "scripts"]
15 "include": ["src", "tsdown.config.ts", "scripts"],
16 "exclude": ["src/**/*.test.ts", "integration"]
1617}
packages/contracts/package.json+1-2
......@@ -24,8 +24,7 @@
2424 "test": "vitest run"
2525 },
2626 "dependencies": {
27 "effect": "4.0.0-beta.10",
28 "zod": "^3.24.1"
27 "effect": "4.0.0-beta.10"
2928 },
3029 "devDependencies": {
3130 "@effect/language-service": "0.75.1",
packages/contracts/src/agent.ts deleted-27
......@@ -1,27 +0,0 @@
1import { z } from "zod";
2
3export const agentSessionIdSchema = z.string().min(1);
4
5export const agentConfigSchema = z.object({
6 command: z.string().min(1),
7 args: z.array(z.string()).default([]),
8 cwd: z.string().optional(),
9 env: z.record(z.string()).optional(),
10 usePty: z.boolean().optional(),
11});
12
13export const outputChunkSchema = z.object({
14 sessionId: z.string(),
15 stream: z.enum(["stdout", "stderr"]),
16 data: z.string(),
17});
18
19export const agentExitSchema = z.object({
20 sessionId: z.string(),
21 code: z.number().nullable(),
22 signal: z.string().nullable(),
23});
24
25export type AgentConfig = z.infer<typeof agentConfigSchema>;
26export type OutputChunk = z.infer<typeof outputChunkSchema>;
27export type AgentExit = z.infer<typeof agentExitSchema>;
packages/contracts/src/editor.ts created+14
......@@ -0,0 +1,14 @@
1import { Schema } from "effect";
2
3export const EDITORS = [
4 { id: "cursor", label: "Cursor", command: "cursor" },
5 { id: "file-manager", label: "File Manager", command: null },
6] as const;
7
8export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
9export type EditorId = typeof EditorId.Type;
10
11export class OpenInEditorInput extends Schema.Class<OpenInEditorInput>("OpenInEditorInput")({
12 cwd: Schema.String,
13 editor: EditorId,
14}) {}
packages/contracts/src/git.test.ts deleted-107
......@@ -1,107 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 gitPullInputSchema,
5 gitPullResultSchema,
6 gitRunStackedActionInputSchema,
7 gitRunStackedActionResultSchema,
8 gitStackedActionSchema,
9 gitStatusInputSchema,
10 gitStatusResultSchema,
11} from "./git";
12
13describe("git contracts", () => {
14 it("parses git status input and trims cwd", () => {
15 const parsed = gitStatusInputSchema.parse({ cwd: " /tmp/repo " });
16 expect(parsed.cwd).toBe("/tmp/repo");
17 });
18
19 it("parses git pull input and trims cwd", () => {
20 const parsed = gitPullInputSchema.parse({ cwd: " /tmp/repo " });
21 expect(parsed.cwd).toBe("/tmp/repo");
22 });
23
24 it("parses git status result", () => {
25 const parsed = gitStatusResultSchema.parse({
26 branch: "feature/git-actions",
27 hasWorkingTreeChanges: true,
28 workingTree: {
29 files: [
30 { path: "src/git.ts", insertions: 8, deletions: 2 },
31 { path: "src/git.test.ts", insertions: 4, deletions: 1 },
32 ],
33 insertions: 12,
34 deletions: 3,
35 },
36 hasUpstream: false,
37 aheadCount: 0,
38 behindCount: 0,
39 openPr: null,
40 });
41 expect(parsed.branch).toBe("feature/git-actions");
42 expect(parsed.hasWorkingTreeChanges).toBe(true);
43 expect(parsed.workingTree.files[0]?.insertions).toBe(8);
44 expect(parsed.workingTree.files).toHaveLength(2);
45 });
46
47 it("accepts supported stacked actions", () => {
48 expect(gitStackedActionSchema.parse("commit")).toBe("commit");
49 expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push");
50 expect(gitStackedActionSchema.parse("commit_push_pr")).toBe("commit_push_pr");
51 });
52
53 it("parses stacked action input", () => {
54 const parsed = gitRunStackedActionInputSchema.parse({
55 cwd: "/tmp/repo",
56 action: "commit_push",
57 });
58 expect(parsed.action).toBe("commit_push");
59 });
60
61 it("accepts custom commit message input", () => {
62 const parsed = gitRunStackedActionInputSchema.parse({
63 cwd: "/tmp/repo",
64 action: "commit",
65 commitMessage: " chore: update git action modal ",
66 });
67 expect(parsed.commitMessage).toBe("chore: update git action modal");
68 });
69
70 it("parses stacked action result with metadata", () => {
71 const parsed = gitRunStackedActionResultSchema.parse({
72 action: "commit_push_pr",
73 commit: {
74 status: "created",
75 commitSha: "abc123",
76 subject: "Implement stacked git actions",
77 },
78 push: {
79 status: "pushed",
80 branch: "feature/git-actions",
81 upstreamBranch: "origin/feature/git-actions",
82 setUpstream: true,
83 },
84 pr: {
85 status: "created",
86 url: "https://github.com/pingdotgg/codething-mvp/pull/123",
87 number: 123,
88 baseBranch: "main",
89 headBranch: "feature/git-actions",
90 title: "Add stacked git actions workflow",
91 },
92 });
93
94 expect(parsed.commit.status).toBe("created");
95 expect(parsed.pr.number).toBe(123);
96 });
97
98 it("parses git pull result", () => {
99 const parsed = gitPullResultSchema.parse({
100 status: "pulled",
101 branch: "feature/my-branch",
102 upstreamBranch: "origin/feature/my-branch",
103 });
104 expect(parsed.status).toBe("pulled");
105 expect(parsed.branch).toBe("feature/my-branch");
106 });
107});
packages/contracts/src/git.ts+153-146
......@@ -1,152 +1,159 @@
1import { z } from "zod";
2
3// ── Existing branch/worktree Git API ─────────────────────────────────
4
5export const gitListBranchesInputSchema = z.object({
6 cwd: z.string().min(1),
7});
8
9export const gitCreateWorktreeInputSchema = z.object({
10 cwd: z.string().min(1),
11 branch: z.string().min(1),
12 newBranch: z.string().min(1),
13 path: z.string().min(1).optional(),
14});
15
16export const gitRemoveWorktreeInputSchema = z.object({
17 cwd: z.string().min(1),
18 path: z.string().min(1),
19 force: z.boolean().optional(),
20});
21
22export const gitCreateBranchInputSchema = z.object({
23 cwd: z.string().min(1),
24 branch: z.string().min(1),
25});
26
27export const gitCheckoutInputSchema = z.object({
28 cwd: z.string().min(1),
29 branch: z.string().min(1),
30});
31
32export const gitInitInputSchema = z.object({
33 cwd: z.string().min(1),
34});
35
36export const gitBranchSchema = z.object({
37 name: z.string().min(1),
38 current: z.boolean(),
39 isDefault: z.boolean(),
40 worktreePath: z.string().min(1).nullable(),
41});
42
43export const gitWorktreeSchema = z.object({
44 path: z.string().min(1),
45 branch: z.string().min(1),
46});
47
48export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;
49export type GitBranch = z.infer<typeof gitBranchSchema>;
50export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;
51export type GitWorktree = z.infer<typeof gitWorktreeSchema>;
52export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>;
53export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;
54export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;
55export type GitInitInput = z.infer<typeof gitInitInputSchema>;
56
57export interface GitListBranchesResult {
58 branches: GitBranch[];
59 isRepo: boolean;
60}
61
62export interface GitCreateWorktreeResult {
63 worktree: GitWorktree;
64}
65
66// ── Stacked action Git API ───────────────────────────────────────────
67
68export const gitStatusInputSchema = z.object({
69 cwd: z.string().trim().min(1),
70});
71
72export const gitPullInputSchema = z.object({
73 cwd: z.string().trim().min(1),
74});
75
76export const gitStatusResultSchema = z.object({
77 branch: z.string().min(1).nullable(),
78 hasWorkingTreeChanges: z.boolean(),
79 workingTree: z.object({
80 files: z.array(
81 z.object({
82 path: z.string().min(1),
83 insertions: z.number().int().nonnegative(),
84 deletions: z.number().int().nonnegative(),
1import { Schema } from "effect";
2
3const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
4
5// Domain Types
6
7export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
8export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
9export const GitPushStepStatus = Schema.Literals([
10 "pushed",
11 "skipped_not_requested",
12 "skipped_up_to_date",
13]);
14export const GitPrStepStatus = Schema.Literals([
15 "created",
16 "opened_existing",
17 "skipped_not_requested",
18]);
19
20export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({
21 name: TrimmedNonEmptyString,
22 current: Schema.Boolean,
23 isDefault: Schema.Boolean,
24 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),
25}) {}
26
27export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({
28 path: TrimmedNonEmptyString,
29 branch: TrimmedNonEmptyString,
30}) {}
31
32// RPC Inputs
33
34export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({
35 cwd: TrimmedNonEmptyString,
36}) {}
37
38export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({
39 cwd: TrimmedNonEmptyString,
40}) {}
41
42export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>(
43 "GitRunStackedActionInput",
44)({
45 cwd: TrimmedNonEmptyString,
46 action: GitStackedAction,
47 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),
48}) {}
49
50export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>(
51 "GitListBranchesInput",
52)({
53 cwd: TrimmedNonEmptyString,
54}) {}
55
56export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>(
57 "GitCreateWorktreeInput",
58)({
59 cwd: TrimmedNonEmptyString,
60 branch: TrimmedNonEmptyString,
61 newBranch: TrimmedNonEmptyString,
62 path: TrimmedNonEmptyString.pipe(Schema.NullOr),
63}) {}
64
65export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>(
66 "GitRemoveWorktreeInput",
67)({
68 cwd: TrimmedNonEmptyString,
69 path: TrimmedNonEmptyString,
70 force: Schema.Boolean.pipe(Schema.NullOr),
71}) {}
72
73export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(
74 "GitCreateBranchInput",
75)({
76 cwd: TrimmedNonEmptyString,
77 branch: TrimmedNonEmptyString,
78}) {}
79
80export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({
81 cwd: TrimmedNonEmptyString,
82 branch: TrimmedNonEmptyString,
83}) {}
84
85export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({
86 cwd: TrimmedNonEmptyString,
87}) {}
88
89// RPC Results
90
91export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusResult")({
92 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),
93 hasWorkingTreeChanges: Schema.Boolean,
94 workingTree: Schema.Struct({
95 files: Schema.Array(
96 Schema.Struct({
97 path: TrimmedNonEmptyString,
98 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
99 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
85100 }),
86101 ),
87 insertions: z.number().int().nonnegative(),
88 deletions: z.number().int().nonnegative(),
102 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
103 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
89104 }),
90 hasUpstream: z.boolean(),
91 aheadCount: z.number().int().nonnegative(),
92 behindCount: z.number().int().nonnegative(),
93 openPr: z
94 .object({
95 number: z.number().int().positive(),
96 title: z.string().min(1),
97 url: z.string().url(),
98 baseBranch: z.string().min(1),
99 headBranch: z.string().min(1),
100 })
101 .nullable(),
102});
103
104export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);
105
106const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]);
107const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]);
108const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]);
109
110export const gitRunStackedActionInputSchema = z.object({
111 cwd: z.string().trim().min(1),
112 action: gitStackedActionSchema,
113 commitMessage: z.string().trim().min(1).max(10_000).optional(),
114});
115
116export const gitRunStackedActionResultSchema = z.object({
117 action: gitStackedActionSchema,
118 commit: z.object({
119 status: gitCommitStepStatusSchema,
120 commitSha: z.string().min(1).optional(),
121 subject: z.string().min(1).optional(),
105 hasUpstream: Schema.Boolean,
106 aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
107 behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
108 openPr: Schema.Struct({
109 number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
110 title: TrimmedNonEmptyString,
111 url: Schema.String,
112 baseBranch: TrimmedNonEmptyString,
113 headBranch: TrimmedNonEmptyString,
114 }).pipe(Schema.NullOr),
115}) {}
116
117export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>(
118 "GitListBranchesResult",
119)({
120 branches: Schema.Array(GitBranch),
121 isRepo: Schema.Boolean,
122}) {}
123
124export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>(
125 "GitCreateWorktreeResult",
126)({
127 worktree: GitWorktree,
128}) {}
129
130export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>(
131 "GitRunStackedActionResult",
132)({
133 action: GitStackedAction,
134 commit: Schema.Struct({
135 status: GitCommitStepStatus,
136 commitSha: Schema.optional(TrimmedNonEmptyString),
137 subject: Schema.optional(TrimmedNonEmptyString),
122138 }),
123 push: z.object({
124 status: gitPushStepStatusSchema,
125 branch: z.string().min(1).optional(),
126 upstreamBranch: z.string().min(1).optional(),
127 setUpstream: z.boolean().optional(),
139 push: Schema.Struct({
140 status: GitPushStepStatus,
141 branch: Schema.optional(TrimmedNonEmptyString),
142 upstreamBranch: Schema.optional(TrimmedNonEmptyString),
143 setUpstream: Schema.optional(Schema.Boolean),
128144 }),
129 pr: z.object({
130 status: gitPrStepStatusSchema,
131 url: z.string().url().optional(),
132 number: z.number().int().positive().optional(),
133 baseBranch: z.string().min(1).optional(),
134 headBranch: z.string().min(1).optional(),
135 title: z.string().min(1).optional(),
145 pr: Schema.Struct({
146 status: GitPrStepStatus,
147 url: Schema.optional(Schema.String),
148 number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
149 baseBranch: Schema.optional(TrimmedNonEmptyString),
150 headBranch: Schema.optional(TrimmedNonEmptyString),
151 title: Schema.optional(TrimmedNonEmptyString),
136152 }),
137});
138
139export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
140export type GitPullInput = z.input<typeof gitPullInputSchema>;
141export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
142export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
143export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;
144export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>;
145
146export const gitPullResultSchema = z.object({
147 status: z.enum(["pulled", "skipped_up_to_date"]),
148 branch: z.string().min(1),
149 upstreamBranch: z.string().min(1).optional(),
150});
151
152export type GitPullResult = z.infer<typeof gitPullResultSchema>;
153}) {}
154
155export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({
156 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
157 branch: TrimmedNonEmptyString,
158 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),
159}) {}
packages/contracts/src/index.ts+2-3
......@@ -1,6 +1,4 @@
1export * from "./todo";
21export * from "./ipc";
3export * from "./agent";
42export * from "./terminal";
53export * from "./provider";
64export * from "./providerRuntime";
......@@ -8,6 +6,7 @@ export * from "./model";
86export * from "./ws";
97export * from "./keybindings";
108export * from "./server";
11export * from "./project";
129export * from "./git";
1310export * from "./orchestration";
11export * from "./editor";
12export * from "./project";
packages/contracts/src/ipc.ts+7-69
......@@ -1,4 +1,3 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
21import type {
32 GitCheckoutInput,
43 GitCreateBranchInput,
......@@ -15,31 +14,7 @@ import type {
1514 GitStatusInput,
1615 GitStatusResult,
1716} from "./git";
18import type {
19 ProviderGetCheckpointDiffInput,
20 ProviderGetCheckpointDiffResult,
21 ProviderInterruptTurnInput,
22 ProviderListCheckpointsInput,
23 ProviderListCheckpointsResult,
24 ProviderRevertToCheckpointInput,
25 ProviderRevertToCheckpointResult,
26 ProviderRespondToRequestInput,
27 ProviderSendTurnInput,
28 ProviderSession,
29 ProviderSessionStartInput,
30 ProviderStopSessionInput,
31 ProviderTurnStartResult,
32} from "./provider";
33import type {
34 ProjectAddInput,
35 ProjectAddResult,
36 ProjectListResult,
37 ProjectSearchEntriesInput,
38 ProjectSearchEntriesResult,
39 ProjectRemoveInput,
40 ProjectUpdateScriptsInput,
41 ProjectUpdateScriptsResult,
42} from "./project";
17import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project";
4318import type { ServerConfig } from "./server";
4419import type {
4520 TerminalClearInput,
......@@ -50,28 +25,17 @@ import type {
5025 TerminalSessionSnapshot,
5126 TerminalWriteInput,
5227} from "./terminal";
53import type { NewTodoInput, Todo } from "./todo";
5428import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
5529import type {
56 OrchestrationCommand,
30 ClientOrchestrationCommand,
31 OrchestrationGetTurnDiffInput,
32 OrchestrationGetTurnDiffResult,
5733 OrchestrationEvent,
5834 OrchestrationReadModel,
5935} from "./orchestration";
60
61export const EDITORS = [
62 { id: "cursor", label: "Cursor", command: "cursor" },
63 { id: "file-manager", label: "File Manager", command: null },
64] as const;
65
66export type EditorId = (typeof EDITORS)[number]["id"];
36import { EditorId } from "./editor";
6737
6838export interface NativeApi {
69 todos: {
70 list: () => Promise<Todo[]>;
71 add: (input: NewTodoInput) => Promise<Todo[]>;
72 toggle: (id: string) => Promise<Todo[]>;
73 remove: (id: string) => Promise<Todo[]>;
74 };
7539 dialogs: {
7640 pickFolder: () => Promise<string | null>;
7741 confirm: (message: string) => Promise<boolean>;
......@@ -85,35 +49,8 @@ export interface NativeApi {
8549 close: (input: TerminalCloseInput) => Promise<void>;
8650 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
8751 };
88 agent: {
89 spawn: (config: AgentConfig) => Promise<string>;
90 kill: (sessionId: string) => Promise<void>;
91 write: (sessionId: string, data: string) => Promise<void>;
92 onOutput: (callback: (chunk: OutputChunk) => void) => () => void;
93 onExit: (callback: (exit: AgentExit) => void) => () => void;
94 };
95 providers: {
96 startSession: (input: ProviderSessionStartInput) => Promise<ProviderSession>;
97 sendTurn: (input: ProviderSendTurnInput) => Promise<ProviderTurnStartResult>;
98 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
99 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
100 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
101 listCheckpoints: (
102 input: ProviderListCheckpointsInput,
103 ) => Promise<ProviderListCheckpointsResult>;
104 getCheckpointDiff: (
105 input: ProviderGetCheckpointDiffInput,
106 ) => Promise<ProviderGetCheckpointDiffResult>;
107 revertToCheckpoint: (
108 input: ProviderRevertToCheckpointInput,
109 ) => Promise<ProviderRevertToCheckpointResult>;
110 };
11152 projects: {
112 list: () => Promise<ProjectListResult>;
113 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
114 remove: (input: ProjectRemoveInput) => Promise<void>;
11553 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
116 updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
11754 };
11855 shell: {
11956 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
......@@ -144,7 +81,8 @@ export interface NativeApi {
14481 };
14582 orchestration: {
14683 getSnapshot: () => Promise<OrchestrationReadModel>;
147 dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>;
84 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
85 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
14886 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
14987 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
15088 };
packages/contracts/src/keybindings.test.ts+22-13
......@@ -1,27 +1,36 @@
1import { Schema } from "effect";
12import { assert, describe, it } from "vitest";
23
34import {
4 keybindingRuleSchema,
5 keybindingsConfigSchema,
6 resolvedKeybindingRuleSchema,
7 resolvedKeybindingsConfigSchema,
5 KeybindingsConfig,
6 KeybindingRule,
7 ResolvedKeybindingRule,
8 ResolvedKeybindingsConfig,
89} from "./keybindings";
910
11const decode = <S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> =>
12 Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
13
14const decodeResolvedRuleStrict = (input: unknown) =>
15 Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, {
16 onExcessProperty: "error",
17 });
18
1019describe("keybindings contracts", () => {
1120 it("parses keybinding rules", () => {
12 const parsed = keybindingRuleSchema.parse({
21 const parsed = decode(KeybindingRule, {
1322 key: "mod+j",
1423 command: "terminal.toggle",
1524 });
1625 assert.strictEqual(parsed.command, "terminal.toggle");
1726
18 const parsedClose = keybindingRuleSchema.parse({
27 const parsedClose = decode(KeybindingRule, {
1928 key: "mod+w",
2029 command: "terminal.close",
2130 });
2231 assert.strictEqual(parsedClose.command, "terminal.close");
2332
24 const parsedLocal = keybindingRuleSchema.parse({
33 const parsedLocal = decode(KeybindingRule, {
2534 key: "mod+shift+n",
2635 command: "chat.newLocal",
2736 });
......@@ -30,7 +39,7 @@ describe("keybindings contracts", () => {
3039
3140 it("rejects invalid command values", () => {
3241 assert.throws(() =>
33 keybindingRuleSchema.parse({
42 decode(KeybindingRule, {
3443 key: "mod+j",
3544 command: "script.Test.run",
3645 }),
......@@ -38,7 +47,7 @@ describe("keybindings contracts", () => {
3847 });
3948
4049 it("accepts dynamic script run commands", () => {
41 const parsed = keybindingRuleSchema.parse({
50 const parsed = decode(KeybindingRule, {
4251 key: "mod+r",
4352 command: "script.setup.run",
4453 });
......@@ -46,7 +55,7 @@ describe("keybindings contracts", () => {
4655 });
4756
4857 it("parses keybindings array payload", () => {
49 const parsed = keybindingsConfigSchema.parse([
58 const parsed = decode(KeybindingsConfig, [
5059 { key: "mod+j", command: "terminal.toggle" },
5160 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
5261 ]);
......@@ -54,7 +63,7 @@ describe("keybindings contracts", () => {
5463 });
5564
5665 it("parses resolved keybinding rules", () => {
57 const parsed = resolvedKeybindingRuleSchema.parse({
66 const parsed = decode(ResolvedKeybindingRule, {
5867 command: "terminal.split",
5968 shortcut: {
6069 key: "d",
......@@ -77,7 +86,7 @@ describe("keybindings contracts", () => {
7786 });
7887
7988 it("parses resolved keybindings arrays", () => {
80 const parsed = resolvedKeybindingsConfigSchema.parse([
89 const parsed = decode(ResolvedKeybindingsConfig, [
8190 {
8291 command: "terminal.toggle",
8392 shortcut: {
......@@ -95,7 +104,7 @@ describe("keybindings contracts", () => {
95104
96105 it("rejects unknown fields in resolved keybinding rules", () => {
97106 assert.throws(() =>
98 resolvedKeybindingRuleSchema.parse({
107 decodeResolvedRuleStrict({
99108 command: "terminal.toggle",
100109 shortcut: {
101110 key: "j",
packages/contracts/src/keybindings.ts+58-65
......@@ -1,4 +1,4 @@
1import { z } from "zod";
1import { Schema } from "effect";
22
33export const STATIC_KEYBINDING_COMMANDS = [
44 "terminal.toggle",
......@@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [
1010 "editor.openFavorite",
1111] as const;
1212
13export const keybindingCommandSchema = z.union([
14 z.enum(STATIC_KEYBINDING_COMMANDS),
15 z
16 .string()
17 .trim()
18 .min(1)
19 .max(96)
20 .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
13export const KeybindingCommand = Schema.Union([
14 Schema.Literals(STATIC_KEYBINDING_COMMANDS),
15 Schema.NonEmptyString.check(
16 Schema.isMaxLength(96),
17 Schema.isPattern(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
18 ),
2119]);
20export type KeybindingCommand = typeof KeybindingCommand.Type;
2221
23const keybindingValueSchema = z.string().trim().min(1).max(64);
24const keybindingWhenSchema = z.string().trim().min(1).max(256);
22export const KeybindingValue = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(64));
23export type KeybindingValue = typeof KeybindingValue.Type;
2524
26export const keybindingRuleSchema = z.object({
27 key: keybindingValueSchema,
28 command: keybindingCommandSchema,
29 when: keybindingWhenSchema.optional(),
30});
25export const KeybindingWhen = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(256));
26export type KeybindingWhen = typeof KeybindingWhen.Type;
27export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({
28 key: KeybindingValue,
29 command: KeybindingCommand,
30 when: Schema.optional(KeybindingWhen),
31}) {}
3132
32export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
33export const KeybindingsConfig = Schema.Array(KeybindingRule).check(Schema.isMaxLength(256));
34export type KeybindingsConfig = typeof KeybindingsConfig.Type;
3335
34export const keybindingShortcutSchema = z
35 .object({
36 key: z.string().trim().min(1).max(32),
37 metaKey: z.boolean(),
38 ctrlKey: z.boolean(),
39 shiftKey: z.boolean(),
40 altKey: z.boolean(),
41 modKey: z.boolean(),
42 })
43 .strict();
36export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({
37 key: KeybindingValue,
38 metaKey: Schema.Boolean,
39 ctrlKey: Schema.Boolean,
40 shiftKey: Schema.Boolean,
41 altKey: Schema.Boolean,
42 modKey: Schema.Boolean,
43}) {}
4444
45export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([
46 Schema.Struct({
47 type: Schema.Literal("identifier"),
48 name: Schema.NonEmptyString,
49 }),
50 Schema.Struct({
51 type: Schema.Literal("not"),
52 node: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
53 }),
54 Schema.Struct({
55 type: Schema.Literal("and"),
56 left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
57 right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
58 }),
59 Schema.Struct({
60 type: Schema.Literal("or"),
61 left: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
62 right: Schema.suspend((): Schema.Schema<KeybindingWhenNode> => KeybindingWhenNode),
63 }),
64]);
4565export type KeybindingWhenNode =
4666 | { type: "identifier"; name: string }
4767 | { type: "not"; node: KeybindingWhenNode }
4868 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
4969 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
5070
51export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>
52 z.union([
53 z.object({
54 type: z.literal("identifier"),
55 name: z.string().min(1),
56 }),
57 z.object({
58 type: z.literal("not"),
59 node: keybindingWhenNodeSchema,
60 }),
61 z.object({
62 type: z.literal("and"),
63 left: keybindingWhenNodeSchema,
64 right: keybindingWhenNodeSchema,
65 }),
66 z.object({
67 type: z.literal("or"),
68 left: keybindingWhenNodeSchema,
69 right: keybindingWhenNodeSchema,
70 }),
71 ]),
72);
73
74export const resolvedKeybindingRuleSchema = z
75 .object({
76 command: keybindingCommandSchema,
77 shortcut: keybindingShortcutSchema,
78 whenAst: keybindingWhenNodeSchema.optional(),
79 })
80 .strict();
71export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(
72 "ResolvedKeybindingRule",
73)({
74 command: KeybindingCommand,
75 shortcut: KeybindingShortcut,
76 whenAst: Schema.optional(KeybindingWhenNode),
77}) {}
8178
82export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
83
84export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
85export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
86export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
87export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>;
88export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>;
89export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>;
79export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
80 Schema.isMaxLength(256),
81);
82export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
packages/contracts/src/orchestration.ts+798-177
......@@ -3,6 +3,7 @@ import { Schema } from "effect";
33export const ORCHESTRATION_WS_METHODS = {
44 getSnapshot: "orchestration.getSnapshot",
55 dispatchCommand: "orchestration.dispatchCommand",
6 getTurnDiff: "orchestration.getTurnDiff",
67 replayEvents: "orchestration.replayEvents",
78} as const;
89
......@@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = {
1011 domainEvent: "orchestration.domainEvent",
1112} as const;
1213
13const IsoDateTimeSchema = Schema.String;
14const IdSchema = Schema.String;
14export const IsoDateTime = Schema.String;
15export type IsoDateTime = typeof IsoDateTime.Type;
16
17export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"));
18export type ThreadId = typeof ThreadId.Type;
19export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
20export type ProjectId = typeof ProjectId.Type;
21export const CommandId = Schema.String.pipe(Schema.brand("CommandId"));
22export type CommandId = typeof CommandId.Type;
23export const EventId = Schema.String.pipe(Schema.brand("EventId"));
24export type EventId = typeof EventId.Type;
25export const MessageId = Schema.String.pipe(Schema.brand("MessageId"));
26export type MessageId = typeof MessageId.Type;
27export const TurnId = Schema.String.pipe(Schema.brand("TurnId"));
28export type TurnId = typeof TurnId.Type;
29
30export const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId"));
31export type ProviderSessionId = typeof ProviderSessionId.Type;
32export const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId"));
33export type ProviderThreadId = typeof ProviderThreadId.Type;
34export const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId"));
35export type ProviderTurnId = typeof ProviderTurnId.Type;
36export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId"));
37export type ProviderItemId = typeof ProviderItemId.Type;
38export const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId"));
39export type ApprovalRequestId = typeof ApprovalRequestId.Type;
40export const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef"));
41export type CheckpointRef = typeof CheckpointRef.Type;
42
43export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
44export type ProviderKind = typeof ProviderKind.Type;
45export const ProviderApprovalPolicy = Schema.Literals([
46 "untrusted",
47 "on-failure",
48 "on-request",
49 "never",
50]);
51export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
52export const ProviderSandboxMode = Schema.Literals([
53 "read-only",
54 "workspace-write",
55 "danger-full-access",
56]);
57export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
58export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);
59export type ProviderRequestKind = typeof ProviderRequestKind.Type;
60export const ProviderApprovalDecision = Schema.Literals([
61 "accept",
62 "acceptForSession",
63 "decline",
64 "cancel",
65]);
66export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
1567
16export const OrchestrationMessageSchema = Schema.Struct({
17 id: IdSchema,
18 role: Schema.Literals(["user", "assistant"]),
19 text: Schema.String,
20 createdAt: IsoDateTimeSchema,
21 completedAt: Schema.optional(IsoDateTimeSchema),
22 streaming: Schema.Boolean,
23});
68export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
69export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
70export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
71export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
2472
25export type OrchestrationMessage = Schema.Schema.Type<typeof OrchestrationMessageSchema>;
73// Correlation id is command id by design in this model.
74export const CorrelationId = CommandId;
75export type CorrelationId = typeof CorrelationId.Type;
2676
27export const OrchestrationSessionSchema = Schema.Struct({
28 sessionId: IdSchema,
29 status: Schema.Literals(["connecting", "ready", "running", "error", "closed"]),
30 provider: Schema.Literals(["codex", "claudeCode"]),
31 threadId: IdSchema,
32 activeTurnId: Schema.NullOr(Schema.String),
33 createdAt: IsoDateTimeSchema,
34 updatedAt: IsoDateTimeSchema,
35 lastError: Schema.NullOr(Schema.String),
77export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(
78 Schema.isGreaterThanOrEqualTo(0),
79);
80
81const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
82
83export const ChatImageAttachment = Schema.Struct({
84 type: Schema.Literal("image"),
85 name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)),
86 mimeType: TrimmedNonEmptyStringSchema.check(
87 Schema.isMaxLength(100),
88 Schema.isPattern(/^image\//i),
89 ),
90 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
91 dataUrl: TrimmedNonEmptyStringSchema.check(
92 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
93 ),
3694});
95export type ChatImageAttachment = typeof ChatImageAttachment.Type;
96
97export const ChatAttachment = Schema.Union([ChatImageAttachment]);
98export type ChatAttachment = typeof ChatAttachment.Type;
99
100export const ProjectScriptIcon = Schema.Literals([
101 "play",
102 "test",
103 "lint",
104 "configure",
105 "build",
106 "debug",
107]);
37108
38export type OrchestrationSession = Schema.Schema.Type<typeof OrchestrationSessionSchema>;
109export const ProjectScript = Schema.Struct({
110 id: Schema.String,
111 name: Schema.String,
112 command: Schema.String,
113 icon: ProjectScriptIcon,
114 runOnWorktreeCreate: Schema.Boolean,
115});
116export type ProjectScript = typeof ProjectScript.Type;
39117
40export const OrchestrationTurnDiffFileSchema = Schema.Struct({
41 path: Schema.String,
42 kind: Schema.optional(Schema.String),
43 additions: Schema.optional(Schema.Number),
44 deletions: Schema.optional(Schema.Number),
118export const OrchestrationProject = Schema.Struct({
119 id: ProjectId,
120 title: Schema.String,
121 workspaceRoot: Schema.String,
122 defaultModel: Schema.NullOr(Schema.String),
123 scripts: Schema.Array(ProjectScript),
124 createdAt: IsoDateTime,
125 updatedAt: IsoDateTime,
126 deletedAt: Schema.NullOr(IsoDateTime),
45127});
128export type OrchestrationProject = typeof OrchestrationProject.Type;
46129
47export type OrchestrationTurnDiffFile = Schema.Schema.Type<typeof OrchestrationTurnDiffFileSchema>;
130export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]);
131export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type;
48132
49export const OrchestrationTurnDiffSummarySchema = Schema.Struct({
50 turnId: IdSchema,
51 completedAt: IsoDateTimeSchema,
52 status: Schema.optional(Schema.String),
53 files: Schema.Array(OrchestrationTurnDiffFileSchema),
54 assistantMessageId: Schema.optional(Schema.String),
55 checkpointTurnCount: Schema.optional(Schema.Number),
133export const OrchestrationMessage = Schema.Struct({
134 id: MessageId,
135 role: OrchestrationMessageRole,
136 text: Schema.String,
137 attachments: Schema.optional(Schema.Array(ChatAttachment)),
138 turnId: Schema.NullOr(TurnId),
139 streaming: Schema.Boolean,
140 createdAt: IsoDateTime,
141 updatedAt: IsoDateTime,
142});
143export type OrchestrationMessage = typeof OrchestrationMessage.Type;
144
145export const OrchestrationSessionStatus = Schema.Literals([
146 "idle",
147 "starting",
148 "running",
149 "ready",
150 "interrupted",
151 "stopped",
152 "error",
153]);
154export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type;
155
156export const OrchestrationSession = Schema.Struct({
157 threadId: ThreadId,
158 status: OrchestrationSessionStatus,
159 providerName: Schema.NullOr(Schema.String),
160 providerSessionId: Schema.NullOr(ProviderSessionId),
161 providerThreadId: Schema.NullOr(ProviderThreadId),
162 activeTurnId: Schema.NullOr(TurnId),
163 lastError: Schema.NullOr(Schema.String),
164 updatedAt: IsoDateTime,
56165});
166export type OrchestrationSession = typeof OrchestrationSession.Type;
57167
58export type OrchestrationTurnDiffSummary = Schema.Schema.Type<
59 typeof OrchestrationTurnDiffSummarySchema
60>;
168export const OrchestrationCheckpointFile = Schema.Struct({
169 path: Schema.String,
170 kind: Schema.String,
171 additions: NonNegativeInt,
172 deletions: NonNegativeInt,
173});
174export type OrchestrationCheckpointFile = typeof OrchestrationCheckpointFile.Type;
175
176export const OrchestrationCheckpointStatus = Schema.Literals(["ready", "missing", "error"]);
177export type OrchestrationCheckpointStatus = typeof OrchestrationCheckpointStatus.Type;
178
179export const OrchestrationCheckpointSummary = Schema.Struct({
180 turnId: TurnId,
181 checkpointTurnCount: NonNegativeInt,
182 checkpointRef: CheckpointRef,
183 status: OrchestrationCheckpointStatus,
184 files: Schema.Array(OrchestrationCheckpointFile),
185 assistantMessageId: Schema.NullOr(MessageId),
186 completedAt: IsoDateTime,
187});
188export type OrchestrationCheckpointSummary = typeof OrchestrationCheckpointSummary.Type;
61189
62export const OrchestrationThreadActivityToneSchema = Schema.Literals([
63 "thinking",
64 "tool",
190export const OrchestrationThreadActivityTone = Schema.Literals([
65191 "info",
192 "tool",
193 "approval",
66194 "error",
67195]);
196export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type;
68197
69export type OrchestrationThreadActivityTone = Schema.Schema.Type<
70 typeof OrchestrationThreadActivityToneSchema
71>;
72
73export const OrchestrationThreadActivitySchema = Schema.Struct({
74 id: IdSchema,
75 createdAt: IsoDateTimeSchema,
76 label: Schema.String,
77 detail: Schema.optional(Schema.String),
78 tone: OrchestrationThreadActivityToneSchema,
79 turnId: Schema.optional(Schema.String),
80 requestId: Schema.optional(Schema.String),
81 requestKind: Schema.optional(Schema.Literals(["command", "file-change"])),
198export const OrchestrationThreadActivity = Schema.Struct({
199 id: EventId,
200 tone: OrchestrationThreadActivityTone,
201 kind: Schema.String,
202 summary: Schema.String,
203 payload: Schema.Unknown,
204 turnId: Schema.NullOr(TurnId),
205 createdAt: IsoDateTime,
82206});
207export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
83208
84export type OrchestrationThreadActivity = Schema.Schema.Type<typeof OrchestrationThreadActivitySchema>;
85
86export const OrchestrationThreadSchema = Schema.Struct({
87 id: IdSchema,
88 projectId: IdSchema,
209export const OrchestrationThread = Schema.Struct({
210 id: ThreadId,
211 projectId: ProjectId,
89212 title: Schema.String,
90213 model: Schema.String,
91214 branch: Schema.NullOr(Schema.String),
92215 worktreePath: Schema.NullOr(Schema.String),
93 createdAt: IsoDateTimeSchema,
94 updatedAt: IsoDateTimeSchema,
95 latestTurnId: Schema.NullOr(Schema.String),
96 latestTurnStartedAt: Schema.NullOr(Schema.String),
97 latestTurnCompletedAt: Schema.NullOr(Schema.String),
98 latestTurnDurationMs: Schema.NullOr(Schema.Number),
99 messages: Schema.Array(OrchestrationMessageSchema),
100 session: Schema.NullOr(OrchestrationSessionSchema),
101 turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema),
102 activities: Schema.Array(OrchestrationThreadActivitySchema),
103 error: Schema.NullOr(Schema.String),
104});
105
106export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>;
107
108export const OrchestrationGitReadModelSchema = Schema.Struct({
109 projectId: IdSchema,
110 branch: Schema.NullOr(Schema.String),
111 hasWorkingTreeChanges: Schema.Boolean,
112 aheadCount: Schema.Number,
113 behindCount: Schema.Number,
114 updatedAt: IsoDateTimeSchema,
216 latestTurnId: Schema.NullOr(TurnId),
217 createdAt: IsoDateTime,
218 updatedAt: IsoDateTime,
219 deletedAt: Schema.NullOr(IsoDateTime),
220 messages: Schema.Array(OrchestrationMessage),
221 activities: Schema.Array(OrchestrationThreadActivity),
222 checkpoints: Schema.Array(OrchestrationCheckpointSummary),
223 session: Schema.NullOr(OrchestrationSession),
224});
225export type OrchestrationThread = typeof OrchestrationThread.Type;
226
227export const OrchestrationReadModel = Schema.Struct({
228 snapshotSequence: NonNegativeInt,
229 projects: Schema.Array(OrchestrationProject),
230 threads: Schema.Array(OrchestrationThread),
231 updatedAt: IsoDateTime,
115232});
233export type OrchestrationReadModel = typeof OrchestrationReadModel.Type;
116234
117export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationGitReadModelSchema>;
235export const ProjectCreateCommand = Schema.Struct({
236 type: Schema.Literal("project.create"),
237 commandId: CommandId,
238 projectId: ProjectId,
239 title: Schema.String,
240 workspaceRoot: Schema.String,
241 defaultModel: Schema.optional(Schema.String),
242 createdAt: IsoDateTime,
243});
118244
119export const OrchestrationReadModelSchema = Schema.Struct({
120 sequence: Schema.Number,
121 threads: Schema.Array(OrchestrationThreadSchema),
122 gitByProjectId: Schema.Record( Schema.String, OrchestrationGitReadModelSchema),
123 updatedAt: IsoDateTimeSchema,
245export const ProjectMetaUpdateCommand = Schema.Struct({
246 type: Schema.Literal("project.meta.update"),
247 commandId: CommandId,
248 projectId: ProjectId,
249 title: Schema.optional(Schema.String),
250 workspaceRoot: Schema.optional(Schema.String),
251 defaultModel: Schema.optional(Schema.String),
252 scripts: Schema.optional(Schema.Array(ProjectScript)),
124253});
125254
126export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>;
255export const ProjectDeleteCommand = Schema.Struct({
256 type: Schema.Literal("project.delete"),
257 commandId: CommandId,
258 projectId: ProjectId,
259});
127260
128export const CreateThreadCommandSchema = Schema.Struct({
261export const ThreadCreateCommand = Schema.Struct({
129262 type: Schema.Literal("thread.create"),
130 commandId: IdSchema,
131 threadId: IdSchema,
132 projectId: IdSchema,
263 commandId: CommandId,
264 threadId: ThreadId,
265 projectId: ProjectId,
133266 title: Schema.String,
134267 model: Schema.String,
135268 branch: Schema.NullOr(Schema.String),
136269 worktreePath: Schema.NullOr(Schema.String),
137 createdAt: IsoDateTimeSchema,
270 createdAt: IsoDateTime,
138271});
139272
140export const DeleteThreadCommandSchema = Schema.Struct({
273export const ThreadDeleteCommand = Schema.Struct({
141274 type: Schema.Literal("thread.delete"),
142 commandId: IdSchema,
143 threadId: IdSchema,
144 createdAt: IsoDateTimeSchema,
275 commandId: CommandId,
276 threadId: ThreadId,
145277});
146278
147export const UpdateThreadMetaCommandSchema = Schema.Struct({
279export const ThreadMetaUpdateCommand = Schema.Struct({
148280 type: Schema.Literal("thread.meta.update"),
149 commandId: IdSchema,
150 threadId: IdSchema,
281 commandId: CommandId,
282 threadId: ThreadId,
151283 title: Schema.optional(Schema.String),
152284 model: Schema.optional(Schema.String),
153285 branch: Schema.optional(Schema.NullOr(Schema.String)),
154286 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
155 createdAt: IsoDateTimeSchema,
156287});
157288
158export const SendMessageCommandSchema = Schema.Struct({
159 type: Schema.Literal("message.send"),
160 commandId: IdSchema,
161 threadId: IdSchema,
162 messageId: IdSchema,
163 role: Schema.Literals(["user", "assistant"]),
164 text: Schema.String,
165 streaming: Schema.optional(Schema.Boolean),
166 createdAt: IsoDateTimeSchema,
289export const ThreadTurnStartCommand = Schema.Struct({
290 type: Schema.Literal("thread.turn.start"),
291 commandId: CommandId,
292 threadId: ThreadId,
293 message: Schema.Struct({
294 messageId: MessageId,
295 role: Schema.Literal("user"),
296 text: Schema.String,
297 attachments: Schema.Array(ChatAttachment),
298 }),
299 model: Schema.optional(Schema.String),
300 effort: Schema.optional(Schema.String),
301 createdAt: IsoDateTime,
167302});
168303
169export const SetThreadSessionSchema = Schema.Struct({
170 type: Schema.Literal("thread.session"),
171 commandId: IdSchema,
172 threadId: IdSchema,
173 session: OrchestrationSessionSchema,
174 createdAt: IsoDateTimeSchema,
304export const ThreadTurnInterruptCommand = Schema.Struct({
305 type: Schema.Literal("thread.turn.interrupt"),
306 commandId: CommandId,
307 threadId: ThreadId,
308 turnId: Schema.optional(TurnId),
309 createdAt: IsoDateTime,
175310});
176311
177export const UpsertGitReadModelCommandSchema = Schema.Struct({
178 type: Schema.Literal("git.readModel.upsert"),
179 commandId: IdSchema,
180 projectId: IdSchema,
181 branch: Schema.NullOr(Schema.String),
182 hasWorkingTreeChanges: Schema.Boolean,
183 aheadCount: Schema.Number,
184 behindCount: Schema.Number,
185 createdAt: IsoDateTimeSchema,
186});
187
188export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({
189 type: Schema.Literal("thread.turnDiff.complete"),
190 commandId: IdSchema,
191 threadId: IdSchema,
192 turnId: IdSchema,
193 completedAt: IsoDateTimeSchema,
194 status: Schema.optional(Schema.String),
195 files: Schema.Array(OrchestrationTurnDiffFileSchema),
196 assistantMessageId: Schema.optional(Schema.String),
197 checkpointTurnCount: Schema.optional(Schema.Number),
198 createdAt: IsoDateTimeSchema,
199});
200
201export const RevertThreadCommandSchema = Schema.Struct({
202 type: Schema.Literal("thread.revert"),
203 commandId: IdSchema,
204 threadId: IdSchema,
205 turnCount: Schema.Number,
206 messageCount: Schema.Number,
207 createdAt: IsoDateTimeSchema,
208});
209
210export const AppendThreadActivityCommandSchema = Schema.Struct({
312export const ThreadApprovalRespondCommand = Schema.Struct({
313 type: Schema.Literal("thread.approval.respond"),
314 commandId: CommandId,
315 threadId: ThreadId,
316 requestId: ApprovalRequestId,
317 decision: ProviderApprovalDecision,
318 createdAt: IsoDateTime,
319});
320
321export const ThreadCheckpointRevertCommand = Schema.Struct({
322 type: Schema.Literal("thread.checkpoint.revert"),
323 commandId: CommandId,
324 threadId: ThreadId,
325 turnCount: NonNegativeInt,
326 createdAt: IsoDateTime,
327});
328
329export const ThreadSessionStopCommand = Schema.Struct({
330 type: Schema.Literal("thread.session.stop"),
331 commandId: CommandId,
332 threadId: ThreadId,
333 createdAt: IsoDateTime,
334});
335
336export const ClientOrchestrationCommand = Schema.Union([
337 ProjectCreateCommand,
338 ProjectMetaUpdateCommand,
339 ProjectDeleteCommand,
340 ThreadCreateCommand,
341 ThreadDeleteCommand,
342 ThreadMetaUpdateCommand,
343 ThreadTurnStartCommand,
344 ThreadTurnInterruptCommand,
345 ThreadApprovalRespondCommand,
346 ThreadCheckpointRevertCommand,
347 ThreadSessionStopCommand,
348]);
349export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
350
351export const ThreadSessionSetCommand = Schema.Struct({
352 type: Schema.Literal("thread.session.set"),
353 commandId: CommandId,
354 threadId: ThreadId,
355 session: OrchestrationSession,
356 createdAt: IsoDateTime,
357});
358
359export const ThreadMessageAssistantDeltaCommand = Schema.Struct({
360 type: Schema.Literal("thread.message.assistant.delta"),
361 commandId: CommandId,
362 threadId: ThreadId,
363 messageId: MessageId,
364 delta: Schema.String,
365 turnId: Schema.optional(TurnId),
366 createdAt: IsoDateTime,
367});
368
369export const ThreadMessageAssistantCompleteCommand = Schema.Struct({
370 type: Schema.Literal("thread.message.assistant.complete"),
371 commandId: CommandId,
372 threadId: ThreadId,
373 messageId: MessageId,
374 turnId: Schema.optional(TurnId),
375 createdAt: IsoDateTime,
376});
377
378export const ThreadTurnDiffCompleteCommand = Schema.Struct({
379 type: Schema.Literal("thread.turn.diff.complete"),
380 commandId: CommandId,
381 threadId: ThreadId,
382 turnId: TurnId,
383 completedAt: IsoDateTime,
384 checkpointRef: CheckpointRef,
385 status: OrchestrationCheckpointStatus,
386 files: Schema.Array(OrchestrationCheckpointFile),
387 assistantMessageId: Schema.optional(MessageId),
388 checkpointTurnCount: NonNegativeInt,
389 createdAt: IsoDateTime,
390});
391
392export const ThreadActivityAppendCommand = Schema.Struct({
211393 type: Schema.Literal("thread.activity.append"),
212 commandId: IdSchema,
213 threadId: IdSchema,
214 activity: OrchestrationThreadActivitySchema,
215 createdAt: IsoDateTimeSchema,
216});
217
218export const OrchestrationCommandSchema = Schema.Union([
219 CreateThreadCommandSchema,
220 DeleteThreadCommandSchema,
221 UpdateThreadMetaCommandSchema,
222 SendMessageCommandSchema,
223 SetThreadSessionSchema,
224 UpsertGitReadModelCommandSchema,
225 CompleteThreadTurnDiffCommandSchema,
226 AppendThreadActivityCommandSchema,
227 RevertThreadCommandSchema,]
228);
394 commandId: CommandId,
395 threadId: ThreadId,
396 activity: OrchestrationThreadActivity,
397 createdAt: IsoDateTime,
398});
229399
230export type OrchestrationCommand = Schema.Schema.Type<typeof OrchestrationCommandSchema>;
400export const ThreadRevertCompleteCommand = Schema.Struct({
401 type: Schema.Literal("thread.revert.complete"),
402 commandId: CommandId,
403 threadId: ThreadId,
404 turnCount: NonNegativeInt,
405 createdAt: IsoDateTime,
406});
231407
232export const OrchestrationEventSchema = Schema.Struct({
233 sequence: Schema.Number,
234 eventId: IdSchema,
235 type: Schema.String,
236 aggregateType: Schema.String,
237 aggregateId: Schema.String,
238 occurredAt: IsoDateTimeSchema,
239 commandId: Schema.NullOr(Schema.String),
240 payload: Schema.Unknown,
408export const InternalOrchestrationCommand = Schema.Union([
409 ThreadSessionSetCommand,
410 ThreadMessageAssistantDeltaCommand,
411 ThreadMessageAssistantCompleteCommand,
412 ThreadTurnDiffCompleteCommand,
413 ThreadActivityAppendCommand,
414 ThreadRevertCompleteCommand,
415]);
416export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
417
418export const OrchestrationCommand = Schema.Union([
419 ClientOrchestrationCommand,
420 InternalOrchestrationCommand,
421]);
422export type OrchestrationCommand = typeof OrchestrationCommand.Type;
423
424export const OrchestrationEventType = Schema.Literals([
425 "project.created",
426 "project.meta-updated",
427 "project.deleted",
428 "thread.created",
429 "thread.deleted",
430 "thread.meta-updated",
431 "thread.message-sent",
432 "thread.turn-start-requested",
433 "thread.turn-interrupt-requested",
434 "thread.approval-response-requested",
435 "thread.checkpoint-revert-requested",
436 "thread.reverted",
437 "thread.session-stop-requested",
438 "thread.session-set",
439 "thread.turn-diff-completed",
440 "thread.activity-appended",
441]);
442export type OrchestrationEventType = typeof OrchestrationEventType.Type;
443
444export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]);
445export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type;
446export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]);
447
448export const ProjectCreatedPayload = Schema.Struct({
449 projectId: ProjectId,
450 title: Schema.String,
451 workspaceRoot: Schema.String,
452 defaultModel: Schema.NullOr(Schema.String),
453 scripts: Schema.Array(ProjectScript),
454 createdAt: IsoDateTime,
455 updatedAt: IsoDateTime,
456});
457
458export const ProjectMetaUpdatedPayload = Schema.Struct({
459 projectId: ProjectId,
460 title: Schema.optional(Schema.String),
461 workspaceRoot: Schema.optional(Schema.String),
462 defaultModel: Schema.optional(Schema.NullOr(Schema.String)),
463 scripts: Schema.optional(Schema.Array(ProjectScript)),
464 updatedAt: IsoDateTime,
465});
466
467export const ProjectDeletedPayload = Schema.Struct({
468 projectId: ProjectId,
469 deletedAt: IsoDateTime,
241470});
242471
243export type OrchestrationEvent = Schema.Schema.Type<typeof OrchestrationEventSchema>;
472export const ThreadCreatedPayload = Schema.Struct({
473 threadId: ThreadId,
474 projectId: ProjectId,
475 title: Schema.String,
476 model: Schema.String,
477 branch: Schema.NullOr(Schema.String),
478 worktreePath: Schema.NullOr(Schema.String),
479 createdAt: IsoDateTime,
480 updatedAt: IsoDateTime,
481});
482
483export const ThreadDeletedPayload = Schema.Struct({
484 threadId: ThreadId,
485 deletedAt: IsoDateTime,
486});
487
488export const ThreadMetaUpdatedPayload = Schema.Struct({
489 threadId: ThreadId,
490 title: Schema.optional(Schema.String),
491 model: Schema.optional(Schema.String),
492 branch: Schema.optional(Schema.NullOr(Schema.String)),
493 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
494 updatedAt: IsoDateTime,
495});
496
497export const ThreadMessageSentPayload = Schema.Struct({
498 threadId: ThreadId,
499 messageId: MessageId,
500 role: OrchestrationMessageRole,
501 text: Schema.String,
502 attachments: Schema.optional(Schema.Array(ChatAttachment)),
503 turnId: Schema.NullOr(TurnId),
504 streaming: Schema.Boolean,
505 createdAt: IsoDateTime,
506 updatedAt: IsoDateTime,
507});
508
509export const ThreadTurnStartRequestedPayload = Schema.Struct({
510 threadId: ThreadId,
511 messageId: MessageId,
512 model: Schema.optional(Schema.String),
513 effort: Schema.optional(Schema.String),
514 createdAt: IsoDateTime,
515});
516
517export const ThreadTurnInterruptRequestedPayload = Schema.Struct({
518 threadId: ThreadId,
519 turnId: Schema.optional(TurnId),
520 createdAt: IsoDateTime,
521});
522
523export const ThreadApprovalResponseRequestedPayload = Schema.Struct({
524 threadId: ThreadId,
525 requestId: ApprovalRequestId,
526 decision: ProviderApprovalDecision,
527 createdAt: IsoDateTime,
528});
529
530export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
531 threadId: ThreadId,
532 turnCount: NonNegativeInt,
533 createdAt: IsoDateTime,
534});
535
536export const ThreadRevertedPayload = Schema.Struct({
537 threadId: ThreadId,
538 turnCount: NonNegativeInt,
539});
540
541export const ThreadSessionStopRequestedPayload = Schema.Struct({
542 threadId: ThreadId,
543 createdAt: IsoDateTime,
544});
545
546export const ThreadSessionSetPayload = Schema.Struct({
547 threadId: ThreadId,
548 session: OrchestrationSession,
549});
550
551export const ThreadTurnDiffCompletedPayload = Schema.Struct({
552 threadId: ThreadId,
553 turnId: TurnId,
554 checkpointTurnCount: NonNegativeInt,
555 checkpointRef: CheckpointRef,
556 status: OrchestrationCheckpointStatus,
557 files: Schema.Array(OrchestrationCheckpointFile),
558 assistantMessageId: Schema.NullOr(MessageId),
559 completedAt: IsoDateTime,
560});
561
562export const ThreadActivityAppendedPayload = Schema.Struct({
563 threadId: ThreadId,
564 activity: OrchestrationThreadActivity,
565});
566
567export const OrchestrationEventMetadata = Schema.Struct({
568 providerSessionId: Schema.optional(ProviderSessionId),
569 providerThreadId: Schema.optional(ProviderThreadId),
570 providerTurnId: Schema.optional(ProviderTurnId),
571 providerItemId: Schema.optional(ProviderItemId),
572 adapterKey: Schema.optional(Schema.String),
573 requestId: Schema.optional(ApprovalRequestId),
574 ingestedAt: Schema.optional(IsoDateTime),
575});
576export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
577
578const EventBaseFields = {
579 sequence: NonNegativeInt,
580 eventId: EventId,
581 aggregateKind: OrchestrationAggregateKind,
582 aggregateId: Schema.Union([ProjectId, ThreadId]),
583 occurredAt: IsoDateTime,
584 commandId: Schema.NullOr(CommandId),
585 causationEventId: Schema.NullOr(EventId),
586 correlationId: Schema.NullOr(CommandId),
587 metadata: OrchestrationEventMetadata,
588} as const;
589
590const PersistedEventBaseFields = {
591 sequence: NonNegativeInt,
592 eventId: EventId,
593 aggregateKind: OrchestrationAggregateKind,
594 streamId: Schema.Union([ProjectId, ThreadId]),
595 streamVersion: NonNegativeInt,
596 occurredAt: IsoDateTime,
597 commandId: Schema.NullOr(CommandId),
598 causationEventId: Schema.NullOr(EventId),
599 correlationId: Schema.NullOr(CommandId),
600 actorKind: OrchestrationActorKind,
601 metadata: OrchestrationEventMetadata,
602} as const;
603
604export const OrchestrationEvent = Schema.Union([
605 Schema.Struct({
606 ...EventBaseFields,
607 type: Schema.Literal("project.created"),
608 payload: ProjectCreatedPayload,
609 }),
610 Schema.Struct({
611 ...EventBaseFields,
612 type: Schema.Literal("project.meta-updated"),
613 payload: ProjectMetaUpdatedPayload,
614 }),
615 Schema.Struct({
616 ...EventBaseFields,
617 type: Schema.Literal("project.deleted"),
618 payload: ProjectDeletedPayload,
619 }),
620 Schema.Struct({
621 ...EventBaseFields,
622 type: Schema.Literal("thread.created"),
623 payload: ThreadCreatedPayload,
624 }),
625 Schema.Struct({
626 ...EventBaseFields,
627 type: Schema.Literal("thread.deleted"),
628 payload: ThreadDeletedPayload,
629 }),
630 Schema.Struct({
631 ...EventBaseFields,
632 type: Schema.Literal("thread.meta-updated"),
633 payload: ThreadMetaUpdatedPayload,
634 }),
635 Schema.Struct({
636 ...EventBaseFields,
637 type: Schema.Literal("thread.message-sent"),
638 payload: ThreadMessageSentPayload,
639 }),
640 Schema.Struct({
641 ...EventBaseFields,
642 type: Schema.Literal("thread.turn-start-requested"),
643 payload: ThreadTurnStartRequestedPayload,
644 }),
645 Schema.Struct({
646 ...EventBaseFields,
647 type: Schema.Literal("thread.turn-interrupt-requested"),
648 payload: ThreadTurnInterruptRequestedPayload,
649 }),
650 Schema.Struct({
651 ...EventBaseFields,
652 type: Schema.Literal("thread.approval-response-requested"),
653 payload: ThreadApprovalResponseRequestedPayload,
654 }),
655 Schema.Struct({
656 ...EventBaseFields,
657 type: Schema.Literal("thread.checkpoint-revert-requested"),
658 payload: ThreadCheckpointRevertRequestedPayload,
659 }),
660 Schema.Struct({
661 ...EventBaseFields,
662 type: Schema.Literal("thread.reverted"),
663 payload: ThreadRevertedPayload,
664 }),
665 Schema.Struct({
666 ...EventBaseFields,
667 type: Schema.Literal("thread.session-stop-requested"),
668 payload: ThreadSessionStopRequestedPayload,
669 }),
670 Schema.Struct({
671 ...EventBaseFields,
672 type: Schema.Literal("thread.session-set"),
673 payload: ThreadSessionSetPayload,
674 }),
675 Schema.Struct({
676 ...EventBaseFields,
677 type: Schema.Literal("thread.turn-diff-completed"),
678 payload: ThreadTurnDiffCompletedPayload,
679 }),
680 Schema.Struct({
681 ...EventBaseFields,
682 type: Schema.Literal("thread.activity-appended"),
683 payload: ThreadActivityAppendedPayload,
684 }),
685]);
686export type OrchestrationEvent = typeof OrchestrationEvent.Type;
687
688export const OrchestrationPersistedEvent = Schema.Union([
689 Schema.Struct({
690 ...PersistedEventBaseFields,
691 eventType: Schema.Literal("project.created"),
692 payload: ProjectCreatedPayload,
693 }),
694 Schema.Struct({
695 ...PersistedEventBaseFields,
696 eventType: Schema.Literal("project.meta-updated"),
697 payload: ProjectMetaUpdatedPayload,
698 }),
699 Schema.Struct({
700 ...PersistedEventBaseFields,
701 eventType: Schema.Literal("project.deleted"),
702 payload: ProjectDeletedPayload,
703 }),
704 Schema.Struct({
705 ...PersistedEventBaseFields,
706 eventType: Schema.Literal("thread.created"),
707 payload: ThreadCreatedPayload,
708 }),
709 Schema.Struct({
710 ...PersistedEventBaseFields,
711 eventType: Schema.Literal("thread.deleted"),
712 payload: ThreadDeletedPayload,
713 }),
714 Schema.Struct({
715 ...PersistedEventBaseFields,
716 eventType: Schema.Literal("thread.meta-updated"),
717 payload: ThreadMetaUpdatedPayload,
718 }),
719 Schema.Struct({
720 ...PersistedEventBaseFields,
721 eventType: Schema.Literal("thread.message-sent"),
722 payload: ThreadMessageSentPayload,
723 }),
724 Schema.Struct({
725 ...PersistedEventBaseFields,
726 eventType: Schema.Literal("thread.turn-start-requested"),
727 payload: ThreadTurnStartRequestedPayload,
728 }),
729 Schema.Struct({
730 ...PersistedEventBaseFields,
731 eventType: Schema.Literal("thread.turn-interrupt-requested"),
732 payload: ThreadTurnInterruptRequestedPayload,
733 }),
734 Schema.Struct({
735 ...PersistedEventBaseFields,
736 eventType: Schema.Literal("thread.approval-response-requested"),
737 payload: ThreadApprovalResponseRequestedPayload,
738 }),
739 Schema.Struct({
740 ...PersistedEventBaseFields,
741 eventType: Schema.Literal("thread.checkpoint-revert-requested"),
742 payload: ThreadCheckpointRevertRequestedPayload,
743 }),
744 Schema.Struct({
745 ...PersistedEventBaseFields,
746 eventType: Schema.Literal("thread.reverted"),
747 payload: ThreadRevertedPayload,
748 }),
749 Schema.Struct({
750 ...PersistedEventBaseFields,
751 eventType: Schema.Literal("thread.session-stop-requested"),
752 payload: ThreadSessionStopRequestedPayload,
753 }),
754 Schema.Struct({
755 ...PersistedEventBaseFields,
756 eventType: Schema.Literal("thread.session-set"),
757 payload: ThreadSessionSetPayload,
758 }),
759 Schema.Struct({
760 ...PersistedEventBaseFields,
761 eventType: Schema.Literal("thread.turn-diff-completed"),
762 payload: ThreadTurnDiffCompletedPayload,
763 }),
764 Schema.Struct({
765 ...PersistedEventBaseFields,
766 eventType: Schema.Literal("thread.activity-appended"),
767 payload: ThreadActivityAppendedPayload,
768 }),
769]);
770export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type;
771
772export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);
773export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;
774
775export const TurnCountRange = Schema.Struct({
776 fromTurnCount: NonNegativeInt,
777 toTurnCount: NonNegativeInt,
778});
779
780export const ThreadTurnDiff = Schema.Struct({
781 threadId: ThreadId,
782 ...TurnCountRange.fields,
783 diff: Schema.String,
784});
785
786export const ProviderSessionRuntimeStatus = Schema.Literals([
787 "starting",
788 "running",
789 "stopped",
790 "error",
791]);
792export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type;
793
794export const ProjectionThreadTurnStatus = Schema.Literals([
795 "running",
796 "completed",
797 "interrupted",
798 "error",
799]);
800export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type;
801
802export const ProjectionCheckpointRow = Schema.Struct({
803 threadId: ThreadId,
804 turnId: TurnId,
805 checkpointTurnCount: NonNegativeInt,
806 checkpointRef: CheckpointRef,
807 status: OrchestrationCheckpointStatus,
808 files: Schema.Array(OrchestrationCheckpointFile),
809 assistantMessageId: Schema.NullOr(MessageId),
810 completedAt: IsoDateTime,
811});
812export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type;
813
814export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]);
815export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type;
816
817export const ProjectionPendingApprovalDecision = Schema.NullOr(ProviderApprovalDecision);
818export type ProjectionPendingApprovalDecision = typeof ProjectionPendingApprovalDecision.Type;
819
820export const DispatchResult = Schema.Struct({
821 sequence: NonNegativeInt,
822});
823export type DispatchResult = typeof DispatchResult.Type;
824
825export const OrchestrationGetSnapshotInput = Schema.Struct({});
826export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type;
827export const OrchestrationGetSnapshotResult = OrchestrationReadModel;
828export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;
829
830export const OrchestrationGetTurnDiffInput = Schema.Struct({
831 threadId: ThreadId,
832 ...TurnCountRange.fields,
833});
834export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
835
836export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
837export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type;
838
839export const OrchestrationReplayEventsInput = Schema.Struct({
840 fromSequenceExclusive: NonNegativeInt,
841});
842export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type;
843
844export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent);
845export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type;
846
847export const OrchestrationRpcSchemas = {
848 getSnapshot: {
849 input: OrchestrationGetSnapshotInput,
850 output: OrchestrationGetSnapshotResult,
851 },
852 dispatchCommand: {
853 input: ClientOrchestrationCommand,
854 output: DispatchResult,
855 },
856 getTurnDiff: {
857 input: OrchestrationGetTurnDiffInput,
858 output: OrchestrationGetTurnDiffResult,
859 },
860 replayEvents: {
861 input: OrchestrationReplayEventsInput,
862 output: OrchestrationReplayEventsResult,
863 },
864} as const;
packages/contracts/src/project.test.ts deleted-147
......@@ -1,147 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 projectAddInputSchema,
5 projectAddResultSchema,
6 projectSearchEntriesInputSchema,
7 projectSearchEntriesResultSchema,
8 projectListResultSchema,
9 projectRemoveInputSchema,
10 projectUpdateScriptsInputSchema,
11 projectUpdateScriptsResultSchema,
12} from "./project";
13
14describe("project contracts", () => {
15 it("parses project list result", () => {
16 const result = projectListResultSchema.parse([
17 {
18 id: "project-1",
19 cwd: "/tmp/project",
20 name: "project",
21 scripts: [],
22 createdAt: "2026-02-10T00:00:00.000Z",
23 updatedAt: "2026-02-10T00:00:00.000Z",
24 },
25 ]);
26
27 expect(result).toHaveLength(1);
28 expect(result[0]?.id).toBe("project-1");
29 });
30
31 it("trims add input cwd", () => {
32 const parsed = projectAddInputSchema.parse({ cwd: " /tmp/project " });
33 expect(parsed.cwd).toBe("/tmp/project");
34 });
35
36 it("requires add result created flag", () => {
37 const parsed = projectAddResultSchema.parse({
38 project: {
39 id: "project-1",
40 cwd: "/tmp/project",
41 name: "project",
42 scripts: [],
43 createdAt: "2026-02-10T00:00:00.000Z",
44 updatedAt: "2026-02-10T00:00:00.000Z",
45 },
46 created: true,
47 });
48 expect(parsed.created).toBe(true);
49 });
50
51 it("parses remove input", () => {
52 const parsed = projectRemoveInputSchema.parse({ id: "project-1" });
53 expect(parsed.id).toBe("project-1");
54 });
55
56 it("parses workspace entry search input with defaults", () => {
57 const parsed = projectSearchEntriesInputSchema.parse({
58 cwd: " /tmp/project ",
59 query: " src ",
60 });
61
62 expect(parsed).toEqual({
63 cwd: "/tmp/project",
64 query: "src",
65 limit: 80,
66 });
67 });
68
69 it("parses workspace entry search result", () => {
70 const parsed = projectSearchEntriesResultSchema.parse({
71 entries: [
72 {
73 path: "src/components",
74 kind: "directory",
75 parentPath: "src",
76 },
77 {
78 path: "src/components/Button.tsx",
79 kind: "file",
80 parentPath: "src/components",
81 },
82 ],
83 truncated: false,
84 });
85
86 expect(parsed.entries).toHaveLength(2);
87 expect(parsed.entries[0]?.kind).toBe("directory");
88 });
89
90 it("parses update scripts input", () => {
91 const parsed = projectUpdateScriptsInputSchema.parse({
92 id: "project-1",
93 scripts: [
94 {
95 id: "test",
96 name: "Test",
97 command: "bun test",
98 icon: "test",
99 runOnWorktreeCreate: false,
100 },
101 ],
102 });
103
104 expect(parsed.scripts).toHaveLength(1);
105 expect(parsed.scripts[0]?.id).toBe("test");
106 });
107
108 it("parses debug icon in scripts", () => {
109 const parsed = projectUpdateScriptsInputSchema.parse({
110 id: "project-1",
111 scripts: [
112 {
113 id: "debug",
114 name: "Debug",
115 command: "bun --inspect test",
116 icon: "debug",
117 runOnWorktreeCreate: false,
118 },
119 ],
120 });
121
122 expect(parsed.scripts[0]?.icon).toBe("debug");
123 });
124
125 it("parses update scripts result", () => {
126 const parsed = projectUpdateScriptsResultSchema.parse({
127 project: {
128 id: "project-1",
129 cwd: "/tmp/project",
130 name: "project",
131 scripts: [
132 {
133 id: "setup",
134 name: "Setup",
135 command: "bun install",
136 icon: "configure",
137 runOnWorktreeCreate: true,
138 },
139 ],
140 createdAt: "2026-02-10T00:00:00.000Z",
141 updatedAt: "2026-02-10T00:00:00.000Z",
142 },
143 });
144
145 expect(parsed.project.scripts[0]?.id).toBe("setup");
146 });
147});
packages/contracts/src/project.ts+28-132
......@@ -1,134 +1,30 @@
1import { z } from "zod";
2
3export const PROJECT_SCRIPT_ICON_VALUES = [
4 "play",
5 "test",
6 "lint",
7 "configure",
8 "build",
9 "debug",
10] as const;
11
12export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
13
14export const projectScriptSchema = z
15 .object({
16 id: z
17 .string()
18 .trim()
19 .min(1)
20 .max(64)
21 .regex(/^[a-z0-9][a-z0-9-]*$/),
22 name: z.string().trim().min(1).max(64),
23 command: z.string().trim().min(1).max(10_000),
24 icon: projectScriptIconSchema,
25 runOnWorktreeCreate: z.boolean().default(false),
26 })
27 .strict();
28
29export const projectScriptsSchema = z.array(projectScriptSchema).max(64);
30
31export const projectRecordSchema = z.object({
32 id: z.string().min(1),
33 cwd: z.string().min(1),
34 name: z.string().min(1),
35 scripts: projectScriptsSchema.default([]),
36 createdAt: z.string().datetime(),
37 updatedAt: z.string().datetime(),
38});
39
40export const projectListResultSchema = z.array(projectRecordSchema);
41
42export const projectAddInputSchema = z.object({
43 cwd: z.string().trim().min(1),
44});
45
46export const projectAddResultSchema = z.object({
47 project: projectRecordSchema,
48 created: z.boolean(),
49});
50
51export const projectRemoveInputSchema = z.object({
52 id: z.string().min(1),
53});
1import { Schema } from "effect";
542
553export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
56
57export const projectSearchEntriesInputSchema = z.object({
58 cwd: z.string().trim().min(1),
59 query: z.string().trim().max(256).default(""),
60 limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80),
61});
62
63export const projectEntryKindSchema = z.enum(["file", "directory"]);
64
65export const projectEntrySchema = z.object({
66 path: z.string().min(1),
67 kind: projectEntryKindSchema,
68 parentPath: z.string().optional(),
69});
70
71export const projectSearchEntriesResultSchema = z.object({
72 entries: z.array(projectEntrySchema),
73 truncated: z.boolean(),
74});
75
76export const projectUpdateScriptsInputSchema = z.object({
77 id: z.string().min(1),
78 scripts: projectScriptsSchema,
79});
80
81export const projectUpdateScriptsResultSchema = z.object({
82 project: projectRecordSchema,
83});
84
85export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
86export type ProjectScript = z.infer<typeof projectScriptSchema>;
87
88/**
89 * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true`
90 * flag (the first one wins). Fields are trimmed defensively so this is safe to
91 * call on data that hasn't been through Zod parsing.
92 */
93export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
94 const seenIds = new Set<string>();
95 const deduped: ProjectScript[] = [];
96 for (const script of scripts) {
97 const id = script.id.trim();
98 if (id.length === 0 || seenIds.has(id)) continue;
99 seenIds.add(id);
100 deduped.push({
101 ...script,
102 id,
103 name: script.name.trim(),
104 command: script.command.trim(),
105 });
106 }
107
108 let setupAssigned = false;
109 const normalized: ProjectScript[] = [];
110 for (const script of deduped) {
111 if (!script.runOnWorktreeCreate) {
112 normalized.push(script);
113 continue;
114 }
115 if (!setupAssigned) {
116 setupAssigned = true;
117 normalized.push(script);
118 continue;
119 }
120 normalized.push({ ...script, runOnWorktreeCreate: false });
121 }
122 return normalized;
123}
124export type ProjectRecord = z.infer<typeof projectRecordSchema>;
125export type ProjectListResult = z.infer<typeof projectListResultSchema>;
126export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
127export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
128export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
129export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>;
130export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
131export type ProjectEntry = z.infer<typeof projectEntrySchema>;
132export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
133export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>;
134export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>;
4const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
5
6export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>(
7 "ProjectSearchEntriesInput",
8)({
9 cwd: TrimmedNonEmptyString,
10 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
11 limit: Schema.Int.check(
12 Schema.isGreaterThanOrEqualTo(1),
13 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
14 ),
15}) {}
16
17export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
18
19export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({
20 path: TrimmedNonEmptyString,
21 kind: ProjectEntryKind,
22 parentPath: Schema.optional(TrimmedNonEmptyString),
23}) {}
24
25export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>(
26 "ProjectSearchEntriesResult",
27)({
28 entries: Schema.Array(ProjectEntry),
29 truncated: Schema.Boolean,
30}) {}
packages/contracts/src/provider.test.ts deleted-283
......@@ -1,283 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5 providerCheckpointSchema,
6 providerEventSchema,
7 providerGetCheckpointDiffInputSchema,
8 providerGetCheckpointDiffResultSchema,
9 providerListCheckpointsInputSchema,
10 providerListCheckpointsResultSchema,
11 providerRevertToCheckpointInputSchema,
12 providerRevertToCheckpointResultSchema,
13 providerRespondToRequestInputSchema,
14 providerSendTurnInputSchema,
15 providerSessionStartInputSchema,
16} from "./provider";
17
18describe("providerSessionStartInputSchema", () => {
19 it("defaults to codex with safe policies", () => {
20 const parsed = providerSessionStartInputSchema.parse({});
21 expect(parsed.provider).toBe("codex");
22 expect(parsed.approvalPolicy).toBe("never");
23 expect(parsed.sandboxMode).toBe("workspace-write");
24 });
25
26 it("accepts optional resumeThreadId", () => {
27 const parsed = providerSessionStartInputSchema.parse({
28 resumeThreadId: "thread_123",
29 });
30 expect(parsed.resumeThreadId).toBe("thread_123");
31 });
32
33 it("rejects blank resumeThreadId", () => {
34 expect(() =>
35 providerSessionStartInputSchema.parse({
36 resumeThreadId: " ",
37 }),
38 ).toThrow();
39 });
40
41 it("accepts optional codex binary and home path overrides", () => {
42 const parsed = providerSessionStartInputSchema.parse({
43 codexBinaryPath: "/opt/codex/bin/codex",
44 codexHomePath: "/Users/theo/.codex",
45 });
46 expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex");
47 expect(parsed.codexHomePath).toBe("/Users/theo/.codex");
48 });
49
50 it("rejects blank codex overrides", () => {
51 expect(() =>
52 providerSessionStartInputSchema.parse({
53 codexBinaryPath: " ",
54 }),
55 ).toThrow();
56 expect(() =>
57 providerSessionStartInputSchema.parse({
58 codexHomePath: " ",
59 }),
60 ).toThrow();
61 });
62});
63
64describe("providerSendTurnInputSchema", () => {
65 it("trims input text and optional model/effort", () => {
66 const parsed = providerSendTurnInputSchema.parse({
67 sessionId: "sess_1",
68 input: " summarize this repo ",
69 model: " gpt-5.2-codex ",
70 effort: " high ",
71 });
72 expect(parsed.input).toBe("summarize this repo");
73 expect(parsed.attachments).toEqual([]);
74 expect(parsed.model).toBe("gpt-5.2-codex");
75 expect(parsed.effort).toBe("high");
76 });
77
78 it("accepts image-only turns", () => {
79 const parsed = providerSendTurnInputSchema.parse({
80 sessionId: "sess_1",
81 attachments: [
82 {
83 type: "image",
84 name: "diagram.png",
85 mimeType: "image/png",
86 sizeBytes: 1_024,
87 dataUrl: "data:image/png;base64,AAAA",
88 },
89 ],
90 });
91 expect(parsed.input).toBeUndefined();
92 expect(parsed.attachments).toHaveLength(1);
93 });
94
95 it("rejects turns with neither text nor attachments", () => {
96 expect(() =>
97 providerSendTurnInputSchema.parse({
98 sessionId: "sess_1",
99 }),
100 ).toThrow();
101 });
102
103 it("rejects non-image data urls", () => {
104 expect(() =>
105 providerSendTurnInputSchema.parse({
106 sessionId: "sess_1",
107 attachments: [
108 {
109 type: "image",
110 name: "not-image.txt",
111 mimeType: "text/plain",
112 sizeBytes: 25,
113 dataUrl: "data:text/plain;base64,QQ==",
114 },
115 ],
116 }),
117 ).toThrow();
118 });
119
120 it("rejects more than the max attachment count", () => {
121 expect(() =>
122 providerSendTurnInputSchema.parse({
123 sessionId: "sess_1",
124 attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({
125 type: "image" as const,
126 name: `image-${index}.png`,
127 mimeType: "image/png",
128 sizeBytes: 1_024,
129 dataUrl: "data:image/png;base64,AAAA",
130 })),
131 }),
132 ).toThrow();
133 });
134});
135
136describe("providerEventSchema", () => {
137 it("accepts notification events with routing metadata", () => {
138 const parsed = providerEventSchema.parse({
139 id: "evt_1",
140 kind: "notification",
141 provider: "codex",
142 sessionId: "sess_1",
143 createdAt: "2026-01-01T00:00:00.000Z",
144 method: "item/agentMessage/delta",
145 threadId: "thr_1",
146 turnId: "turn_1",
147 itemId: "item_1",
148 textDelta: "hi",
149 });
150 expect(parsed.method).toBe("item/agentMessage/delta");
151 });
152
153 it("accepts request approval metadata", () => {
154 const parsed = providerEventSchema.parse({
155 id: "evt_2",
156 kind: "request",
157 provider: "codex",
158 sessionId: "sess_1",
159 createdAt: "2026-01-01T00:00:00.000Z",
160 method: "item/commandExecution/requestApproval",
161 requestId: "req_123",
162 requestKind: "command",
163 });
164 expect(parsed.requestId).toBe("req_123");
165 expect(parsed.requestKind).toBe("command");
166 });
167});
168
169describe("providerRespondToRequestInputSchema", () => {
170 it("accepts valid decisions", () => {
171 const parsed = providerRespondToRequestInputSchema.parse({
172 sessionId: "sess_1",
173 requestId: "req_1",
174 decision: "acceptForSession",
175 });
176 expect(parsed.decision).toBe("acceptForSession");
177 });
178
179 it("rejects unknown decisions", () => {
180 expect(() =>
181 providerRespondToRequestInputSchema.parse({
182 sessionId: "sess_1",
183 requestId: "req_1",
184 decision: "always",
185 }),
186 ).toThrow();
187 });
188});
189
190describe("provider checkpoint schemas", () => {
191 it("accepts list checkpoint inputs", () => {
192 const parsed = providerListCheckpointsInputSchema.parse({
193 sessionId: "sess_1",
194 });
195 expect(parsed.sessionId).toBe("sess_1");
196 });
197
198 it("accepts checkpoint records and list results", () => {
199 const checkpoint = providerCheckpointSchema.parse({
200 id: "turn_1",
201 turnCount: 1,
202 messageCount: 2,
203 label: "Turn 1",
204 preview: "Summarize this file",
205 isCurrent: true,
206 });
207 expect(checkpoint.turnCount).toBe(1);
208
209 const listResult = providerListCheckpointsResultSchema.parse({
210 threadId: "thr_1",
211 checkpoints: [checkpoint],
212 });
213 expect(listResult.checkpoints).toHaveLength(1);
214 });
215
216 it("accepts revert checkpoint input/result", () => {
217 const input = providerRevertToCheckpointInputSchema.parse({
218 sessionId: "sess_1",
219 turnCount: 2,
220 });
221 expect(input.turnCount).toBe(2);
222
223 const result = providerRevertToCheckpointResultSchema.parse({
224 threadId: "thr_1",
225 turnCount: 2,
226 messageCount: 4,
227 rolledBackTurns: 1,
228 checkpoints: [
229 {
230 id: "root",
231 turnCount: 0,
232 messageCount: 0,
233 label: "Start of conversation",
234 isCurrent: false,
235 },
236 {
237 id: "turn_2",
238 turnCount: 2,
239 messageCount: 4,
240 label: "Turn 2",
241 isCurrent: true,
242 },
243 ],
244 });
245 expect(result.rolledBackTurns).toBe(1);
246 });
247
248 it("rejects negative turn counts", () => {
249 expect(() =>
250 providerRevertToCheckpointInputSchema.parse({
251 sessionId: "sess_1",
252 turnCount: -1,
253 }),
254 ).toThrow();
255 });
256
257 it("accepts checkpoint diff input/result", () => {
258 const input = providerGetCheckpointDiffInputSchema.parse({
259 sessionId: "sess_1",
260 fromTurnCount: 1,
261 toTurnCount: 2,
262 });
263 expect(input.fromTurnCount).toBe(1);
264
265 const result = providerGetCheckpointDiffResultSchema.parse({
266 threadId: "thr_1",
267 fromTurnCount: 1,
268 toTurnCount: 2,
269 diff: "diff --git a/src/app.ts b/src/app.ts",
270 });
271 expect(result.toTurnCount).toBe(2);
272 });
273
274 it("rejects checkpoint diff ranges where start is after end", () => {
275 expect(() =>
276 providerGetCheckpointDiffInputSchema.parse({
277 sessionId: "sess_1",
278 fromTurnCount: 3,
279 toTurnCount: 2,
280 }),
281 ).toThrow();
282 });
283});
packages/contracts/src/provider.ts+139-191
......@@ -1,221 +1,169 @@
1import { z } from "zod";
2
3export const providerKindSchema = z.enum(["codex", "claudeCode"]);
4
5export const providerApprovalPolicySchema = z.enum([
6 "untrusted",
7 "on-failure",
8 "on-request",
9 "never",
10]);
11
12export const providerSandboxModeSchema = z.enum([
13 "read-only",
14 "workspace-write",
15 "danger-full-access",
16]);
17
18export const providerRequestKindSchema = z.enum(["command", "file-change"]);
19
20export const providerApprovalDecisionSchema = z.enum([
21 "accept",
22 "acceptForSession",
23 "decline",
24 "cancel",
25]);
26
27export const providerSessionStatusSchema = z.enum([
1import { Schema } from "effect";
2
3import {
4 ApprovalRequestId,
5 ChatAttachment,
6 EventId,
7 IsoDateTime,
8 NonNegativeInt,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
11 ProviderApprovalDecision,
12 ProviderApprovalPolicy,
13 ProviderKind,
14 ProviderItemId,
15 ProviderRequestKind,
16 ProviderSandboxMode,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
20 ThreadId,
21 TurnCountRange,
22} from "./orchestration";
23
24const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
25
26export const ProviderSessionStatus = Schema.Literals([
2827 "connecting",
2928 "ready",
3029 "running",
3130 "error",
3231 "closed",
3332]);
34
35export const providerSessionSchema = z.object({
36 sessionId: z.string().min(1),
37 provider: providerKindSchema,
38 status: providerSessionStatusSchema,
39 cwd: z.string().min(1).optional(),
40 model: z.string().min(1).optional(),
41 threadId: z.string().min(1).optional(),
42 activeTurnId: z.string().min(1).optional(),
43 createdAt: z.string().datetime(),
44 updatedAt: z.string().datetime(),
45 lastError: z.string().min(1).optional(),
33export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;
34
35export const ProviderSession = Schema.Struct({
36 sessionId: ProviderSessionId,
37 provider: ProviderKind,
38 status: ProviderSessionStatus,
39 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
40 model: Schema.optional(TrimmedNonEmptyStringSchema),
41 threadId: Schema.optional(ProviderThreadId),
42 activeTurnId: Schema.optional(ProviderTurnId),
43 createdAt: IsoDateTime,
44 updatedAt: IsoDateTime,
45 lastError: Schema.optional(TrimmedNonEmptyStringSchema),
4646});
47
48export const providerSessionStartInputSchema = z.object({
49 provider: providerKindSchema.default("codex"),
50 cwd: z.string().min(1).optional(),
51 model: z.string().trim().min(1).optional(),
52 resumeThreadId: z.string().trim().min(1).optional(),
53 codexBinaryPath: z.string().trim().min(1).optional(),
54 codexHomePath: z.string().trim().min(1).optional(),
55 approvalPolicy: providerApprovalPolicySchema.default("never"),
56 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
47export type ProviderSession = typeof ProviderSession.Type;
48
49export const ProviderSessionStartInput = Schema.Struct({
50 provider: Schema.optional(ProviderKind),
51 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
52 model: Schema.optional(TrimmedNonEmptyStringSchema),
53 resumeThreadId: Schema.optional(ProviderThreadId),
54 codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
55 codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema),
56 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
57 sandboxMode: Schema.optional(ProviderSandboxMode),
5758});
58
59export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
60export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
61export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
62export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
63
64export const providerSendTurnImageAttachmentSchema = z.object({
65 type: z.literal("image"),
66 name: z.string().trim().min(1).max(255),
67 mimeType: z
68 .string()
69 .trim()
70 .min(1)
71 .max(100)
72 .regex(/^image\//i, "mimeType must be an image/* MIME type"),
73 sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
74 dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
59export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
60
61export const ProviderSendTurnInput = Schema.Struct({
62 sessionId: ProviderSessionId,
63 input: Schema.optional(
64 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
65 ),
66 attachments: Schema.optional(
67 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
68 ),
69 model: Schema.optional(TrimmedNonEmptyStringSchema),
70 effort: Schema.optional(TrimmedNonEmptyStringSchema),
7571});
72export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
7673
77export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [
78 providerSendTurnImageAttachmentSchema,
79]);
80
81export const providerSendTurnInputSchema = z
82 .object({
83 sessionId: z.string().min(1),
84 input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(),
85 attachments: z
86 .array(providerSendTurnAttachmentInputSchema)
87 .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)
88 .default([]),
89 model: z.string().trim().min(1).optional(),
90 effort: z.string().trim().min(1).optional(),
91 })
92 .superRefine((value, ctx) => {
93 if (!value.input && value.attachments.length === 0) {
94 ctx.addIssue({
95 code: z.ZodIssueCode.custom,
96 message: "Either input text or at least one attachment is required",
97 path: ["input"],
98 });
99 }
100 });
101
102export const providerTurnStartResultSchema = z.object({
103 threadId: z.string().min(1),
104 turnId: z.string().min(1),
74export const ProviderTurnStartResult = Schema.Struct({
75 threadId: ProviderThreadId,
76 turnId: ProviderTurnId,
10577});
78export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
10679
107export const providerInterruptTurnInputSchema = z.object({
108 sessionId: z.string().min(1),
109 turnId: z.string().min(1).optional(),
80export const ProviderInterruptTurnInput = Schema.Struct({
81 sessionId: ProviderSessionId,
82 turnId: Schema.optional(ProviderTurnId),
11083});
84export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;
11185
112export const providerStopSessionInputSchema = z.object({
113 sessionId: z.string().min(1),
86export const ProviderStopSessionInput = Schema.Struct({
87 sessionId: ProviderSessionId,
11488});
89export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;
11590
116export const providerListCheckpointsInputSchema = z.object({
117 sessionId: z.string().min(1),
91export const ProviderListCheckpointsInput = Schema.Struct({
92 sessionId: ProviderSessionId,
11893});
119
120export const providerCheckpointSchema = z.object({
121 id: z.string().min(1),
122 turnCount: z.number().int().min(0),
123 messageCount: z.number().int().min(0),
124 label: z.string().min(1),
125 preview: z.string().min(1).optional(),
126 isCurrent: z.boolean(),
94export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;
95
96export const ProviderCheckpoint = Schema.Struct({
97 id: TrimmedNonEmptyStringSchema,
98 turnCount: NonNegativeInt,
99 messageCount: NonNegativeInt,
100 label: TrimmedNonEmptyStringSchema,
101 preview: Schema.optional(TrimmedNonEmptyStringSchema),
102 isCurrent: Schema.Boolean,
127103});
104export type ProviderCheckpoint = typeof ProviderCheckpoint.Type;
128105
129export const providerListCheckpointsResultSchema = z.object({
130 threadId: z.string().min(1),
131 checkpoints: z.array(providerCheckpointSchema),
106export const ProviderListCheckpointsResult = Schema.Struct({
107 threadId: ThreadId,
108 checkpoints: Schema.Array(ProviderCheckpoint),
132109});
110export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;
133111
134export const providerRevertToCheckpointInputSchema = z.object({
135 sessionId: z.string().min(1),
136 turnCount: z.number().int().min(0),
112export const ProviderRevertToCheckpointInput = Schema.Struct({
113 sessionId: ProviderSessionId,
114 turnCount: NonNegativeInt,
137115});
138
139export const providerRevertToCheckpointResultSchema = z.object({
140 threadId: z.string().min(1),
141 turnCount: z.number().int().min(0),
142 messageCount: z.number().int().min(0),
143 rolledBackTurns: z.number().int().min(0),
144 checkpoints: z.array(providerCheckpointSchema),
116export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;
117
118export const ProviderRevertToCheckpointResult = Schema.Struct({
119 threadId: ThreadId,
120 turnCount: NonNegativeInt,
121 messageCount: NonNegativeInt,
122 rolledBackTurns: NonNegativeInt,
123 checkpoints: Schema.Array(ProviderCheckpoint),
145124});
125export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;
146126
147export const providerGetCheckpointDiffInputSchema = z
148 .object({
149 sessionId: z.string().min(1),
150 fromTurnCount: z.number().int().min(0),
151 toTurnCount: z.number().int().min(0),
152 })
153 .superRefine((value, ctx) => {
154 if (value.fromTurnCount > value.toTurnCount) {
155 ctx.addIssue({
156 code: z.ZodIssueCode.custom,
157 message: "fromTurnCount must be less than or equal to toTurnCount",
158 path: ["fromTurnCount"],
159 });
160 }
161 });
162
163export const providerGetCheckpointDiffResultSchema = z.object({
164 threadId: z.string().min(1),
165 fromTurnCount: z.number().int().min(0),
166 toTurnCount: z.number().int().min(0),
167 diff: z.string(),
127export const ProviderGetCheckpointDiffInput = Schema.Struct({
128 sessionId: ProviderSessionId,
129 ...TurnCountRange.fields,
168130});
131export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;
169132
170export const providerRespondToRequestInputSchema = z.object({
171 sessionId: z.string().min(1),
172 requestId: z.string().min(1),
173 decision: providerApprovalDecisionSchema,
133export const ProviderGetCheckpointDiffResult = Schema.Struct({
134 threadId: ThreadId,
135 ...TurnCountRange.fields,
136 diff: Schema.String,
174137});
138export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;
175139
176export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);
177
178export const providerEventSchema = z.object({
179 id: z.string().min(1),
180 kind: providerEventKindSchema,
181 provider: providerKindSchema,
182 sessionId: z.string().min(1),
183 createdAt: z.string().datetime(),
184 method: z.string().min(1),
185 message: z.string().min(1).optional(),
186 threadId: z.string().min(1).optional(),
187 turnId: z.string().min(1).optional(),
188 itemId: z.string().min(1).optional(),
189 requestId: z.string().min(1).optional(),
190 requestKind: providerRequestKindSchema.optional(),
191 textDelta: z.string().optional(),
192 payload: z.unknown().optional(),
140export const ProviderRespondToRequestInput = Schema.Struct({
141 sessionId: ProviderSessionId,
142 requestId: ApprovalRequestId,
143 decision: ProviderApprovalDecision,
144});
145export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type;
146
147export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
148export type ProviderEventKind = typeof ProviderEventKind.Type;
149
150export const ProviderEvent = Schema.Struct({
151 id: EventId,
152 kind: ProviderEventKind,
153 provider: ProviderKind,
154 sessionId: ProviderSessionId,
155 createdAt: IsoDateTime,
156 method: TrimmedNonEmptyStringSchema,
157 message: Schema.optional(TrimmedNonEmptyStringSchema),
158 threadId: Schema.optional(ProviderThreadId),
159 turnId: Schema.optional(ProviderTurnId),
160 itemId: Schema.optional(ProviderItemId),
161 requestId: Schema.optional(ApprovalRequestId),
162 requestKind: Schema.optional(ProviderRequestKind),
163 textDelta: Schema.optional(Schema.String),
164 payload: Schema.optional(Schema.Unknown),
193165});
166export type ProviderEvent = typeof ProviderEvent.Type;
194167
195export type ProviderKind = z.infer<typeof providerKindSchema>;
196export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;
197export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
198export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;
199export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>;
200export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
201export type ProviderSession = z.infer<typeof providerSessionSchema>;
202export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
203export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>;
204export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
205export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
206export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
207export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
208export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
209export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
210export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>;
211export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>;
212export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>;
213export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>;
214export type ProviderRevertToCheckpointResult = z.infer<
215 typeof providerRevertToCheckpointResultSchema
216>;
217export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
218export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
219export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
220export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
221export type ProviderEvent = z.infer<typeof providerEventSchema>;
168export type ProviderSendTurnAttachment = typeof ChatAttachment.Type;
169export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type;
packages/contracts/src/providerRuntime.ts+160-173
......@@ -1,232 +1,219 @@
11import { Schema } from "effect";
22
3const IsoDateTimeSchema = Schema.String;
4const IdSchema = Schema.String;
5
6export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]);
7export type ProviderRuntimeProvider = Schema.Schema.Type<typeof ProviderRuntimeProviderSchema>;
8
9export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]);
10export type ProviderRuntimeApprovalKind = Schema.Schema.Type<
11 typeof ProviderRuntimeApprovalKindSchema
12>;
13
14export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]);
15export type ProviderRuntimeToolKind = Schema.Schema.Type<typeof ProviderRuntimeToolKindSchema>;
3import {
4 ApprovalRequestId,
5 EventId,
6 IsoDateTime,
7 NonNegativeInt,
8 ProviderApprovalDecision,
9 ProviderItemId,
10 ProviderKind,
11 ProviderRequestKind,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,
16 TurnId,
17} from "./orchestration";
18
19const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]);
20const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]);
21
22export const ProviderRuntimeToolKind = Schema.Union([
23 ProviderRequestKind,
24 Schema.Literal("other"),
25]);
26export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type;
1627
17export const ProviderRuntimeTurnStatusSchema = Schema.Literals([
28export const ProviderRuntimeTurnStatus = Schema.Literals([
1829 "completed",
1930 "failed",
2031 "cancelled",
2132 "interrupted",
2233]);
23export type ProviderRuntimeTurnStatus = Schema.Schema.Type<typeof ProviderRuntimeTurnStatusSchema>;
34export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type;
2435
25export const ProviderRuntimeSessionStartedEventSchema = Schema.Struct({
36export const ProviderRuntimeSessionStartedEvent = Schema.Struct({
2637 type: Schema.Literal("session.started"),
27 eventId: IdSchema,
28 provider: ProviderRuntimeProviderSchema,
29 sessionId: IdSchema,
30 createdAt: IsoDateTimeSchema,
31 threadId: Schema.optional(Schema.String),
38 eventId: EventId,
39 provider: ProviderKind,
40 sessionId: ProviderSessionId,
41 createdAt: IsoDateTime,
42 threadId: Schema.optional(RuntimeThreadIdSchema),
3243 message: Schema.optional(Schema.String),
3344});
45export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;
3446
35export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({
47export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
3648 type: Schema.Literal("session.exited"),
37 eventId: IdSchema,
38 provider: ProviderRuntimeProviderSchema,
39 sessionId: IdSchema,
40 createdAt: IsoDateTimeSchema,
41 threadId: Schema.optional(Schema.String),
49 eventId: EventId,
50 provider: ProviderKind,
51 sessionId: ProviderSessionId,
52 createdAt: IsoDateTime,
53 threadId: Schema.optional(RuntimeThreadIdSchema),
4254 message: Schema.optional(Schema.String),
4355});
56export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;
4457
45export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({
58export const ProviderRuntimeThreadStartedEvent = Schema.Struct({
4659 type: Schema.Literal("thread.started"),
47 eventId: IdSchema,
48 provider: ProviderRuntimeProviderSchema,
49 sessionId: IdSchema,
50 createdAt: IsoDateTimeSchema,
51 threadId: IdSchema,
60 eventId: EventId,
61 provider: ProviderKind,
62 sessionId: ProviderSessionId,
63 createdAt: IsoDateTime,
64 threadId: RuntimeThreadIdSchema,
5265});
66export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type;
5367
54export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({
68export const ProviderRuntimeTurnStartedEvent = Schema.Struct({
5569 type: Schema.Literal("turn.started"),
56 eventId: IdSchema,
57 provider: ProviderRuntimeProviderSchema,
58 sessionId: IdSchema,
59 createdAt: IsoDateTimeSchema,
60 threadId: Schema.optional(Schema.String),
61 turnId: IdSchema,
70 eventId: EventId,
71 provider: ProviderKind,
72 sessionId: ProviderSessionId,
73 createdAt: IsoDateTime,
74 threadId: Schema.optional(RuntimeThreadIdSchema),
75 turnId: RuntimeTurnIdSchema,
6276});
77export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type;
6378
64export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({
79export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
6580 type: Schema.Literal("turn.completed"),
66 eventId: IdSchema,
67 provider: ProviderRuntimeProviderSchema,
68 sessionId: IdSchema,
69 createdAt: IsoDateTimeSchema,
70 threadId: Schema.optional(Schema.String),
71 turnId: Schema.optional(Schema.String),
72 status: Schema.optional(ProviderRuntimeTurnStatusSchema),
81 eventId: EventId,
82 provider: ProviderKind,
83 sessionId: ProviderSessionId,
84 createdAt: IsoDateTime,
85 threadId: Schema.optional(RuntimeThreadIdSchema),
86 turnId: Schema.optional(RuntimeTurnIdSchema),
87 status: Schema.optional(ProviderRuntimeTurnStatus),
7388 errorMessage: Schema.optional(Schema.String),
7489});
90export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;
7591
76export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({
92export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({
7793 type: Schema.Literal("message.delta"),
78 eventId: IdSchema,
79 provider: ProviderRuntimeProviderSchema,
80 sessionId: IdSchema,
81 createdAt: IsoDateTimeSchema,
82 threadId: Schema.optional(Schema.String),
83 turnId: Schema.optional(Schema.String),
84 itemId: Schema.optional(Schema.String),
94 eventId: EventId,
95 provider: ProviderKind,
96 sessionId: ProviderSessionId,
97 createdAt: IsoDateTime,
98 threadId: Schema.optional(RuntimeThreadIdSchema),
99 turnId: Schema.optional(RuntimeTurnIdSchema),
100 itemId: Schema.optional(ProviderItemId),
85101 delta: Schema.String,
86102});
103export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type;
87104
88export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({
105export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({
89106 type: Schema.Literal("message.completed"),
90 eventId: IdSchema,
91 provider: ProviderRuntimeProviderSchema,
92 sessionId: IdSchema,
93 createdAt: IsoDateTimeSchema,
94 itemId: IdSchema,
95 threadId: Schema.optional(Schema.String),
96 turnId: Schema.optional(Schema.String),
107 eventId: EventId,
108 provider: ProviderKind,
109 sessionId: ProviderSessionId,
110 createdAt: IsoDateTime,
111 itemId: ProviderItemId,
112 threadId: Schema.optional(RuntimeThreadIdSchema),
113 turnId: Schema.optional(RuntimeTurnIdSchema),
97114});
115export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type;
98116
99export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({
117export const ProviderRuntimeToolStartedEvent = Schema.Struct({
100118 type: Schema.Literal("tool.started"),
101 eventId: IdSchema,
102 provider: ProviderRuntimeProviderSchema,
103 sessionId: IdSchema,
104 createdAt: IsoDateTimeSchema,
105 threadId: Schema.optional(Schema.String),
106 turnId: Schema.optional(Schema.String),
107 itemId: Schema.optional(Schema.String),
108 toolKind: ProviderRuntimeToolKindSchema,
119 eventId: EventId,
120 provider: ProviderKind,
121 sessionId: ProviderSessionId,
122 createdAt: IsoDateTime,
123 threadId: Schema.optional(RuntimeThreadIdSchema),
124 turnId: Schema.optional(RuntimeTurnIdSchema),
125 itemId: Schema.optional(ProviderItemId),
126 toolKind: ProviderRuntimeToolKind,
109127 title: Schema.String,
110128 detail: Schema.optional(Schema.String),
111129});
130export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;
112131
113export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({
132export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
114133 type: Schema.Literal("tool.completed"),
115 eventId: IdSchema,
116 provider: ProviderRuntimeProviderSchema,
117 sessionId: IdSchema,
118 createdAt: IsoDateTimeSchema,
119 threadId: Schema.optional(Schema.String),
120 turnId: Schema.optional(Schema.String),
121 itemId: Schema.optional(Schema.String),
122 toolKind: ProviderRuntimeToolKindSchema,
134 eventId: EventId,
135 provider: ProviderKind,
136 sessionId: ProviderSessionId,
137 createdAt: IsoDateTime,
138 threadId: Schema.optional(RuntimeThreadIdSchema),
139 turnId: Schema.optional(RuntimeTurnIdSchema),
140 itemId: Schema.optional(ProviderItemId),
141 toolKind: ProviderRuntimeToolKind,
123142 title: Schema.String,
124143 detail: Schema.optional(Schema.String),
125144});
145export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;
126146
127export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({
147export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
128148 type: Schema.Literal("approval.requested"),
129 eventId: IdSchema,
130 provider: ProviderRuntimeProviderSchema,
131 sessionId: IdSchema,
132 createdAt: IsoDateTimeSchema,
133 threadId: Schema.optional(Schema.String),
134 turnId: Schema.optional(Schema.String),
135 itemId: Schema.optional(Schema.String),
136 requestId: IdSchema,
137 requestKind: ProviderRuntimeApprovalKindSchema,
149 eventId: EventId,
150 provider: ProviderKind,
151 sessionId: ProviderSessionId,
152 createdAt: IsoDateTime,
153 threadId: Schema.optional(RuntimeThreadIdSchema),
154 turnId: Schema.optional(RuntimeTurnIdSchema),
155 itemId: Schema.optional(ProviderItemId),
156 requestId: ApprovalRequestId,
157 requestKind: ProviderRequestKind,
138158 detail: Schema.optional(Schema.String),
139159});
160export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type;
140161
141export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({
162export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({
142163 type: Schema.Literal("approval.resolved"),
143 eventId: IdSchema,
144 provider: ProviderRuntimeProviderSchema,
145 sessionId: IdSchema,
146 createdAt: IsoDateTimeSchema,
147 threadId: Schema.optional(Schema.String),
148 turnId: Schema.optional(Schema.String),
149 itemId: Schema.optional(Schema.String),
150 requestId: IdSchema,
151 requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema),
152 decision: Schema.optional(Schema.String),
164 eventId: EventId,
165 provider: ProviderKind,
166 sessionId: ProviderSessionId,
167 createdAt: IsoDateTime,
168 threadId: Schema.optional(RuntimeThreadIdSchema),
169 turnId: Schema.optional(RuntimeTurnIdSchema),
170 itemId: Schema.optional(ProviderItemId),
171 requestId: ApprovalRequestId,
172 requestKind: Schema.optional(ProviderRequestKind),
173 decision: Schema.optional(ProviderApprovalDecision),
153174});
175export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type;
154176
155export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({
177export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({
156178 type: Schema.Literal("checkpoint.captured"),
157 eventId: IdSchema,
158 provider: ProviderRuntimeProviderSchema,
159 sessionId: IdSchema,
160 createdAt: IsoDateTimeSchema,
161 threadId: IdSchema,
162 turnId: Schema.optional(Schema.String),
163 turnCount: Schema.Number,
164 status: Schema.optional(ProviderRuntimeTurnStatusSchema),
179 eventId: EventId,
180 provider: ProviderKind,
181 sessionId: ProviderSessionId,
182 createdAt: IsoDateTime,
183 threadId: RuntimeThreadIdSchema,
184 turnId: Schema.optional(RuntimeTurnIdSchema),
185 turnCount: NonNegativeInt,
186 status: Schema.optional(ProviderRuntimeTurnStatus),
165187});
188export type ProviderRuntimeCheckpointCapturedEvent = typeof ProviderRuntimeCheckpointCapturedEvent.Type;
166189
167export const ProviderRuntimeErrorEventSchema = Schema.Struct({
190export const ProviderRuntimeErrorEvent = Schema.Struct({
168191 type: Schema.Literal("runtime.error"),
169 eventId: IdSchema,
170 provider: ProviderRuntimeProviderSchema,
171 sessionId: IdSchema,
172 createdAt: IsoDateTimeSchema,
173 threadId: Schema.optional(Schema.String),
174 turnId: Schema.optional(Schema.String),
175 itemId: Schema.optional(Schema.String),
192 eventId: EventId,
193 provider: ProviderKind,
194 sessionId: ProviderSessionId,
195 createdAt: IsoDateTime,
196 threadId: Schema.optional(RuntimeThreadIdSchema),
197 turnId: Schema.optional(RuntimeTurnIdSchema),
198 itemId: Schema.optional(ProviderItemId),
176199 message: Schema.String,
177200});
178
179export const ProviderRuntimeEventSchema = Schema.Union([
180 ProviderRuntimeSessionStartedEventSchema,
181 ProviderRuntimeSessionExitedEventSchema,
182 ProviderRuntimeThreadStartedEventSchema,
183 ProviderRuntimeTurnStartedEventSchema,
184 ProviderRuntimeTurnCompletedEventSchema,
185 ProviderRuntimeMessageDeltaEventSchema,
186 ProviderRuntimeMessageCompletedEventSchema,
187 ProviderRuntimeToolStartedEventSchema,
188 ProviderRuntimeToolCompletedEventSchema,
189 ProviderRuntimeApprovalRequestedEventSchema,
190 ProviderRuntimeApprovalResolvedEventSchema,
191 ProviderRuntimeCheckpointCapturedEventSchema,
192 ProviderRuntimeErrorEventSchema,
201export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
202
203export const ProviderRuntimeEvent = Schema.Union([
204 ProviderRuntimeSessionStartedEvent,
205 ProviderRuntimeSessionExitedEvent,
206 ProviderRuntimeThreadStartedEvent,
207 ProviderRuntimeTurnStartedEvent,
208 ProviderRuntimeTurnCompletedEvent,
209 ProviderRuntimeMessageDeltaEvent,
210 ProviderRuntimeMessageCompletedEvent,
211 ProviderRuntimeToolStartedEvent,
212 ProviderRuntimeToolCompletedEvent,
213 ProviderRuntimeApprovalRequestedEvent,
214 ProviderRuntimeApprovalResolvedEvent,
215 ProviderRuntimeCheckpointCapturedEvent,
216 ProviderRuntimeErrorEvent,
193217]);
218export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type;
194219
195export type ProviderRuntimeSessionStartedEvent = Schema.Schema.Type<
196 typeof ProviderRuntimeSessionStartedEventSchema
197>;
198export type ProviderRuntimeSessionExitedEvent = Schema.Schema.Type<
199 typeof ProviderRuntimeSessionExitedEventSchema
200>;
201export type ProviderRuntimeThreadStartedEvent = Schema.Schema.Type<
202 typeof ProviderRuntimeThreadStartedEventSchema
203>;
204export type ProviderRuntimeTurnStartedEvent = Schema.Schema.Type<
205 typeof ProviderRuntimeTurnStartedEventSchema
206>;
207export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type<
208 typeof ProviderRuntimeTurnCompletedEventSchema
209>;
210export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type<
211 typeof ProviderRuntimeMessageDeltaEventSchema
212>;
213export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type<
214 typeof ProviderRuntimeMessageCompletedEventSchema
215>;
216export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type<
217 typeof ProviderRuntimeToolStartedEventSchema
218>;
219export type ProviderRuntimeToolCompletedEvent = Schema.Schema.Type<
220 typeof ProviderRuntimeToolCompletedEventSchema
221>;
222export type ProviderRuntimeApprovalRequestedEvent = Schema.Schema.Type<
223 typeof ProviderRuntimeApprovalRequestedEventSchema
224>;
225export type ProviderRuntimeApprovalResolvedEvent = Schema.Schema.Type<
226 typeof ProviderRuntimeApprovalResolvedEventSchema
227>;
228export type ProviderRuntimeCheckpointCapturedEvent = Schema.Schema.Type<
229 typeof ProviderRuntimeCheckpointCapturedEventSchema
230>;
231export type ProviderRuntimeErrorEvent = Schema.Schema.Type<typeof ProviderRuntimeErrorEventSchema>;
232export type ProviderRuntimeEvent = Schema.Schema.Type<typeof ProviderRuntimeEventSchema>;
packages/contracts/src/server.ts+15-11
......@@ -1,17 +1,21 @@
1import { z } from "zod";
2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";
1import { Schema } from "effect";
2import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
33
4export const serverConfigSchema = z.object({
5 cwd: z.string().min(1),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),
4export const ServerConfig = Schema.Struct({
5 cwd: Schema.NonEmptyString,
6 keybindings: ResolvedKeybindingsConfig,
77});
8export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>;
89
9export const serverUpsertKeybindingInputSchema = keybindingRuleSchema;
10export const ServerUpsertKeybindingInput = KeybindingRule;
11export type ServerUpsertKeybindingInput = Schema.Codec.Encoded<
12 typeof ServerUpsertKeybindingInput
13>;
1014
11export const serverUpsertKeybindingResultSchema = z.object({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),
15export const ServerUpsertKeybindingResult = Schema.Struct({
16 keybindings: ResolvedKeybindingsConfig,
1317});
18export type ServerUpsertKeybindingResult = Schema.Codec.Encoded<
19 typeof ServerUpsertKeybindingResult
20>;
1421
15export type ServerConfig = z.infer<typeof serverConfigSchema>;
16export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>;
17export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>;
packages/contracts/src/terminal.test.ts+127-102
......@@ -1,40 +1,56 @@
1import { Schema } from "effect";
12import { describe, expect, it } from "vitest";
23
34import {
45 DEFAULT_TERMINAL_ID,
5 terminalClearInputSchema,
6 terminalCloseInputSchema,
7 terminalEventSchema,
8 terminalOpenInputSchema,
9 terminalResizeInputSchema,
10 terminalSessionSnapshotSchema,
11 terminalThreadInputSchema,
12 terminalWriteInputSchema,
6 TerminalClearInput,
7 TerminalCloseInput,
8 TerminalEvent,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalSessionSnapshot,
12 TerminalThreadInput,
13 TerminalWriteInput,
1314} from "./terminal";
1415
15describe("terminalOpenInputSchema", () => {
16function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> {
17 return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
18}
19
20function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean {
21 try {
22 Schema.decodeUnknownSync(schema as never)(input);
23 return true;
24 } catch {
25 return false;
26 }
27}
28
29describe("TerminalOpenInput", () => {
1630 it("accepts valid open input", () => {
17 const result = terminalOpenInputSchema.safeParse({
18 threadId: "thread-1",
19 cwd: "/tmp/project",
20 cols: 120,
21 rows: 40,
22 });
23 expect(result.success).toBe(true);
31 expect(
32 decodes(TerminalOpenInput, {
33 threadId: "thread-1",
34 cwd: "/tmp/project",
35 cols: 120,
36 rows: 40,
37 }),
38 ).toBe(true);
2439 });
2540
2641 it("rejects invalid bounds", () => {
27 const result = terminalOpenInputSchema.safeParse({
28 threadId: "thread-1",
29 cwd: "/tmp/project",
30 cols: 10,
31 rows: 2,
32 });
33 expect(result.success).toBe(false);
42 expect(
43 decodes(TerminalOpenInput, {
44 threadId: "thread-1",
45 cwd: "/tmp/project",
46 cols: 10,
47 rows: 2,
48 }),
49 ).toBe(false);
3450 });
3551
3652 it("defaults terminalId when missing", () => {
37 const parsed = terminalOpenInputSchema.parse({
53 const parsed = decodeSync(TerminalOpenInput, {
3854 threadId: "thread-1",
3955 cwd: "/tmp/project",
4056 cols: 100,
......@@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => {
4460 });
4561
4662 it("accepts optional env overrides", () => {
47 const parsed = terminalOpenInputSchema.parse({
63 const parsed = decodeSync(TerminalOpenInput, {
4864 threadId: "thread-1",
4965 cwd: "/tmp/project",
5066 cols: 100,
......@@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => {
6177 });
6278
6379 it("rejects invalid env keys", () => {
64 const result = terminalOpenInputSchema.safeParse({
65 threadId: "thread-1",
66 cwd: "/tmp/project",
67 cols: 100,
68 rows: 24,
69 env: {
70 "bad-key": "1",
71 },
72 });
73 expect(result.success).toBe(false);
80 expect(
81 decodes(TerminalOpenInput, {
82 threadId: "thread-1",
83 cwd: "/tmp/project",
84 cols: 100,
85 rows: 24,
86 env: {
87 "bad-key": "1",
88 },
89 }),
90 ).toBe(false);
7491 });
7592});
7693
77describe("terminalWriteInputSchema", () => {
94describe("TerminalWriteInput", () => {
7895 it("accepts non-empty data", () => {
79 const result = terminalWriteInputSchema.safeParse({
80 threadId: "thread-1",
81 data: "echo hello\n",
82 });
83 expect(result.success).toBe(true);
96 expect(
97 decodes(TerminalWriteInput, {
98 threadId: "thread-1",
99 data: "echo hello\n",
100 }),
101 ).toBe(true);
84102 });
85103
86104 it("rejects empty data", () => {
87 const result = terminalWriteInputSchema.safeParse({
88 threadId: "thread-1",
89 data: "",
90 });
91 expect(result.success).toBe(false);
105 expect(
106 decodes(TerminalWriteInput, {
107 threadId: "thread-1",
108 data: "",
109 }),
110 ).toBe(false);
92111 });
93112});
94113
95describe("terminalThreadInputSchema", () => {
114describe("TerminalThreadInput", () => {
96115 it("trims thread ids", () => {
97 const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " });
116 const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " });
98117 expect(parsed.threadId).toBe("thread-1");
99118 });
100119});
101120
102describe("terminalResizeInputSchema", () => {
121describe("TerminalResizeInput", () => {
103122 it("accepts valid size", () => {
104 const result = terminalResizeInputSchema.safeParse({
105 threadId: "thread-1",
106 cols: 80,
107 rows: 24,
108 });
109 expect(result.success).toBe(true);
123 expect(
124 decodes(TerminalResizeInput, {
125 threadId: "thread-1",
126 cols: 80,
127 rows: 24,
128 }),
129 ).toBe(true);
110130 });
111131});
112132
113describe("terminalClearInputSchema", () => {
133describe("TerminalClearInput", () => {
114134 it("defaults terminal id", () => {
115 const parsed = terminalClearInputSchema.parse({
135 const parsed = decodeSync(TerminalClearInput, {
116136 threadId: "thread-1",
117137 });
118138 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
119139 });
120140});
121141
122describe("terminalCloseInputSchema", () => {
142describe("TerminalCloseInput", () => {
123143 it("accepts optional deleteHistory", () => {
124 const result = terminalCloseInputSchema.safeParse({
125 threadId: "thread-1",
126 deleteHistory: true,
127 });
128 expect(result.success).toBe(true);
144 expect(
145 decodes(TerminalCloseInput, {
146 threadId: "thread-1",
147 deleteHistory: true,
148 }),
149 ).toBe(true);
129150 });
130151});
131152
132describe("terminalSessionSnapshotSchema", () => {
153describe("TerminalSessionSnapshot", () => {
133154 it("accepts running snapshots", () => {
134 const result = terminalSessionSnapshotSchema.safeParse({
135 threadId: "thread-1",
136 terminalId: DEFAULT_TERMINAL_ID,
137 cwd: "/tmp/project",
138 status: "running",
139 pid: 1234,
140 history: "hello\n",
141 exitCode: null,
142 exitSignal: null,
143 updatedAt: new Date().toISOString(),
144 });
145 expect(result.success).toBe(true);
155 expect(
156 decodes(TerminalSessionSnapshot, {
157 threadId: "thread-1",
158 terminalId: DEFAULT_TERMINAL_ID,
159 cwd: "/tmp/project",
160 status: "running",
161 pid: 1234,
162 history: "hello\n",
163 exitCode: null,
164 exitSignal: null,
165 updatedAt: new Date().toISOString(),
166 }),
167 ).toBe(true);
146168 });
147169});
148170
149describe("terminalEventSchema", () => {
171describe("TerminalEvent", () => {
150172 it("accepts output events", () => {
151 const result = terminalEventSchema.safeParse({
152 type: "output",
153 threadId: "thread-1",
154 terminalId: DEFAULT_TERMINAL_ID,
155 createdAt: new Date().toISOString(),
156 data: "line\n",
157 });
158 expect(result.success).toBe(true);
173 expect(
174 decodes(TerminalEvent, {
175 type: "output",
176 threadId: "thread-1",
177 terminalId: DEFAULT_TERMINAL_ID,
178 createdAt: new Date().toISOString(),
179 data: "line\n",
180 }),
181 ).toBe(true);
159182 });
160183
161184 it("accepts exited events", () => {
162 const result = terminalEventSchema.safeParse({
163 type: "exited",
164 threadId: "thread-1",
165 terminalId: DEFAULT_TERMINAL_ID,
166 createdAt: new Date().toISOString(),
167 exitCode: 0,
168 exitSignal: null,
169 });
170 expect(result.success).toBe(true);
185 expect(
186 decodes(TerminalEvent, {
187 type: "exited",
188 threadId: "thread-1",
189 terminalId: DEFAULT_TERMINAL_ID,
190 createdAt: new Date().toISOString(),
191 exitCode: 0,
192 exitSignal: null,
193 }),
194 ).toBe(true);
171195 });
172196
173197 it("accepts activity events", () => {
174 const result = terminalEventSchema.safeParse({
175 type: "activity",
176 threadId: "thread-1",
177 terminalId: DEFAULT_TERMINAL_ID,
178 createdAt: new Date().toISOString(),
179 hasRunningSubprocess: true,
180 });
181 expect(result.success).toBe(true);
198 expect(
199 decodes(TerminalEvent, {
200 type: "activity",
201 threadId: "thread-1",
202 terminalId: DEFAULT_TERMINAL_ID,
203 createdAt: new Date().toISOString(),
204 hasRunningSubprocess: true,
205 }),
206 ).toBe(true);
182207 });
183208});
packages/contracts/src/terminal.ts+120-84
......@@ -1,119 +1,155 @@
1import { z } from "zod";
1import { Schema } from "effect";
22
33export const DEFAULT_TERMINAL_ID = "default";
44
5const terminalColsSchema = z.number().int().min(20).max(400);
6const terminalRowsSchema = z.number().int().min(5).max(200);
7const terminalIdSchema = z.string().trim().min(1).max(128);
8const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128);
9const terminalEnvValueSchema = z.string().max(8_192);
10const terminalEnvSchema = z
11 .record(terminalEnvKeySchema, terminalEnvValueSchema)
12 .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars");
13
14export const terminalThreadInputSchema = z.object({
15 threadId: z.string().trim().min(1),
5const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty());
6const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
7 Schema.isLessThanOrEqualTo(400),
8);
9const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
10 Schema.isLessThanOrEqualTo(200),
11);
12const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128));
13const TerminalEnvKeySchema = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check(
14 Schema.isMaxLength(128),
15);
16const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
17const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
18 Schema.makeFilter<Record<string, string>>((env) => {
19 if (Object.keys(env).length > 128) {
20 return { path: [], message: "Too many terminal env vars" };
21 }
22 return true;
23 }),
24);
25
26const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
27 Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID),
28);
29
30export const TerminalThreadInput = Schema.Struct({
31 threadId: TrimmedNonEmptyStringSchema,
1632});
33export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>;
1734
18export const terminalSessionInputSchema = terminalThreadInputSchema.extend({
19 terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID),
35export const TerminalSessionInput = Schema.Struct({
36 ...TerminalThreadInput.fields,
37 terminalId: TerminalIdWithDefaultSchema,
2038});
21
22export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
23 cwd: z.string().trim().min(1),
24 cols: terminalColsSchema.optional(),
25 rows: terminalRowsSchema.optional(),
26 env: terminalEnvSchema.optional(),
39export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
40
41export const TerminalOpenInput = Schema.Struct({
42 ...TerminalSessionInput.fields,
43 cwd: TrimmedNonEmptyStringSchema,
44 cols: Schema.optional(TerminalColsSchema),
45 rows: Schema.optional(TerminalRowsSchema),
46 env: Schema.optional(TerminalEnvSchema),
2747});
48export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;
2849
29export const terminalWriteInputSchema = terminalSessionInputSchema.extend({
30 data: z.string().min(1).max(65_536),
50export const TerminalWriteInput = Schema.Struct({
51 ...TerminalSessionInput.fields,
52 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
3153});
54export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>;
3255
33export const terminalResizeInputSchema = terminalSessionInputSchema.extend({
34 cols: terminalColsSchema,
35 rows: terminalRowsSchema,
56export const TerminalResizeInput = Schema.Struct({
57 ...TerminalSessionInput.fields,
58 cols: TerminalColsSchema,
59 rows: TerminalRowsSchema,
3660});
61export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>;
3762
38export const terminalClearInputSchema = terminalSessionInputSchema;
63export const TerminalClearInput = TerminalSessionInput;
64export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>;
3965
40export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
41 terminalId: terminalIdSchema.optional(),
42 deleteHistory: z.boolean().optional(),
66export const TerminalRestartInput = Schema.Struct({
67 ...TerminalThreadInput.fields,
68 cwd: TrimmedNonEmptyStringSchema,
69 cols: TerminalColsSchema,
70 rows: TerminalRowsSchema,
71 env: Schema.optional(TerminalEnvSchema),
4372});
4473
45export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]);
46
47export const terminalSessionSnapshotSchema = z.object({
48 threadId: z.string().min(1),
49 terminalId: z.string().min(1),
50 cwd: z.string().min(1),
51 status: terminalSessionStatusSchema,
52 pid: z.number().int().positive().nullable(),
53 history: z.string(),
54 exitCode: z.number().int().nullable(),
55 exitSignal: z.number().int().nullable(),
56 updatedAt: z.string().datetime(),
74export const TerminalCloseInput = Schema.Struct({
75 ...TerminalThreadInput.fields,
76 terminalId: Schema.optional(TerminalIdSchema),
77 deleteHistory: Schema.optional(Schema.Boolean),
78});
79export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>;
80
81export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
82export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
83
84export const TerminalSessionSnapshot = Schema.Struct({
85 threadId: Schema.String.check(Schema.isNonEmpty()),
86 terminalId: Schema.String.check(Schema.isNonEmpty()),
87 cwd: Schema.String.check(Schema.isNonEmpty()),
88 status: TerminalSessionStatus,
89 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
90 history: Schema.String,
91 exitCode: Schema.NullOr(Schema.Int),
92 exitSignal: Schema.NullOr(Schema.Int),
93 updatedAt: Schema.String,
5794});
95export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
5896
59const terminalEventBaseSchema = z.object({
60 threadId: z.string().min(1),
61 terminalId: z.string().min(1),
62 createdAt: z.string().datetime(),
97const TerminalEventBaseSchema = Schema.Struct({
98 threadId: Schema.String.check(Schema.isNonEmpty()),
99 terminalId: Schema.String.check(Schema.isNonEmpty()),
100 createdAt: Schema.String,
63101});
64102
65export const terminalStartedEventSchema = terminalEventBaseSchema.extend({
66 type: z.literal("started"),
67 snapshot: terminalSessionSnapshotSchema,
103export const TerminalStartedEvent = Schema.Struct({
104 ...TerminalEventBaseSchema.fields,
105 type: Schema.Literal("started"),
106 snapshot: TerminalSessionSnapshot,
68107});
69108
70export const terminalOutputEventSchema = terminalEventBaseSchema.extend({
71 type: z.literal("output"),
72 data: z.string(),
109export const TerminalOutputEvent = Schema.Struct({
110 ...TerminalEventBaseSchema.fields,
111 type: Schema.Literal("output"),
112 data: Schema.String,
73113});
74114
75export const terminalExitedEventSchema = terminalEventBaseSchema.extend({
76 type: z.literal("exited"),
77 exitCode: z.number().int().nullable(),
78 exitSignal: z.number().int().nullable(),
115export const TerminalExitedEvent = Schema.Struct({
116 ...TerminalEventBaseSchema.fields,
117 type: Schema.Literal("exited"),
118 exitCode: Schema.NullOr(Schema.Int),
119 exitSignal: Schema.NullOr(Schema.Int),
79120});
80121
81export const terminalErrorEventSchema = terminalEventBaseSchema.extend({
82 type: z.literal("error"),
83 message: z.string().min(1),
122export const TerminalErrorEvent = Schema.Struct({
123 ...TerminalEventBaseSchema.fields,
124 type: Schema.Literal("error"),
125 message: Schema.String.check(Schema.isNonEmpty()),
84126});
85127
86export const terminalClearedEventSchema = terminalEventBaseSchema.extend({
87 type: z.literal("cleared"),
128export const TerminalClearedEvent = Schema.Struct({
129 ...TerminalEventBaseSchema.fields,
130 type: Schema.Literal("cleared"),
88131});
89132
90export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({
91 type: z.literal("restarted"),
92 snapshot: terminalSessionSnapshotSchema,
133export const TerminalRestartedEvent = Schema.Struct({
134 ...TerminalEventBaseSchema.fields,
135 type: Schema.Literal("restarted"),
136 snapshot: TerminalSessionSnapshot,
93137});
94138
95export const terminalActivityEventSchema = terminalEventBaseSchema.extend({
96 type: z.literal("activity"),
97 hasRunningSubprocess: z.boolean(),
139export const TerminalActivityEvent = Schema.Struct({
140 ...TerminalEventBaseSchema.fields,
141 type: Schema.Literal("activity"),
142 hasRunningSubprocess: Schema.Boolean,
98143});
99144
100export const terminalEventSchema = z.discriminatedUnion("type", [
101 terminalStartedEventSchema,
102 terminalOutputEventSchema,
103 terminalExitedEventSchema,
104 terminalErrorEventSchema,
105 terminalClearedEventSchema,
106 terminalRestartedEventSchema,
107 terminalActivityEventSchema,
145export const TerminalEvent = Schema.Union([
146 TerminalStartedEvent,
147 TerminalOutputEvent,
148 TerminalExitedEvent,
149 TerminalErrorEvent,
150 TerminalClearedEvent,
151 TerminalRestartedEvent,
152 TerminalActivityEvent,
108153]);
154export type TerminalEvent = typeof TerminalEvent.Type;
109155
110export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
111export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>;
112export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
113export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
114export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
115export type TerminalClearInput = z.input<typeof terminalClearInputSchema>;
116export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
117export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
118export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;
119export type TerminalEvent = z.infer<typeof terminalEventSchema>;
packages/contracts/src/todo.test.ts deleted-52
......@@ -1,52 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { newTodoInputSchema, todoSchema } from "./todo";
4
5describe("todoSchema", () => {
6 it("accepts a valid todo", () => {
7 const result = todoSchema.safeParse({
8 id: "abc-123",
9 title: "Buy milk",
10 completed: false,
11 createdAt: "2025-01-01T00:00:00.000Z",
12 });
13 expect(result.success).toBe(true);
14 });
15
16 it("rejects a todo with empty id", () => {
17 const result = todoSchema.safeParse({
18 id: "",
19 title: "Buy milk",
20 completed: false,
21 createdAt: "2025-01-01T00:00:00.000Z",
22 });
23 expect(result.success).toBe(false);
24 });
25
26 it("rejects a todo with empty title", () => {
27 const result = todoSchema.safeParse({
28 id: "abc-123",
29 title: "",
30 completed: false,
31 createdAt: "2025-01-01T00:00:00.000Z",
32 });
33 expect(result.success).toBe(false);
34 });
35});
36
37describe("newTodoInputSchema", () => {
38 it("accepts valid input", () => {
39 const result = newTodoInputSchema.safeParse({ title: "Buy milk" });
40 expect(result.success).toBe(true);
41 });
42
43 it("trims whitespace", () => {
44 const result = newTodoInputSchema.parse({ title: " Buy milk " });
45 expect(result.title).toBe("Buy milk");
46 });
47
48 it("rejects empty title after trimming", () => {
49 const result = newTodoInputSchema.safeParse({ title: " " });
50 expect(result.success).toBe(false);
51 });
52});
packages/contracts/src/todo.ts deleted-19
......@@ -1,19 +0,0 @@
1import { z } from "zod";
2
3export const todoSchema = z.object({
4 id: z.string().min(1),
5 title: z.string().min(1).max(280),
6 completed: z.boolean(),
7 createdAt: z.string().datetime(),
8});
9
10export const todoListSchema = z.array(todoSchema);
11
12export const newTodoInputSchema = z.object({
13 title: z.string().trim().min(1).max(280),
14});
15
16export const todoIdSchema = z.string().min(1);
17
18export type Todo = z.infer<typeof todoSchema>;
19export type NewTodoInput = z.infer<typeof newTodoInputSchema>;
packages/contracts/src/ws.ts+89-43
......@@ -1,24 +1,43 @@
1import { z } from "zod";
1import { Schema } from "effect";
2
3import {
4 ClientOrchestrationCommand,
5 ORCHESTRATION_WS_METHODS,
6 OrchestrationGetSnapshotInput,
7 OrchestrationGetTurnDiffInput,
8 OrchestrationReplayEventsInput,
9} from "./orchestration";
10import {
11 GitCheckoutInput,
12 GitCreateBranchInput,
13 GitCreateWorktreeInput,
14 GitInitInput,
15 GitListBranchesInput,
16 GitPullInput,
17 GitRemoveWorktreeInput,
18 GitRunStackedActionInput,
19 GitStatusInput,
20} from "./git";
21import {
22 TerminalClearInput,
23 TerminalCloseInput,
24 TerminalOpenInput,
25 TerminalResizeInput,
26 TerminalRestartInput,
27 TerminalWriteInput,
28} from "./terminal";
29import { KeybindingRule } from "./keybindings";
30import { ProjectSearchEntriesInput } from "./project";
31import { OpenInEditorInput } from "./editor";
232
333// ── WebSocket RPC Method Names ───────────────────────────────────────
434
535export const WS_METHODS = {
6 // Provider methods (mirrors NativeApi.providers)
7 providersStartSession: "providers.startSession",
8 providersSendTurn: "providers.sendTurn",
9 providersInterruptTurn: "providers.interruptTurn",
10 providersRespondToRequest: "providers.respondToRequest",
11 providersStopSession: "providers.stopSession",
12 providersListCheckpoints: "providers.listCheckpoints",
13 providersGetCheckpointDiff: "providers.getCheckpointDiff",
14 providersRevertToCheckpoint: "providers.revertToCheckpoint",
15
1636 // Project registry methods
1737 projectsList: "projects.list",
1838 projectsAdd: "projects.add",
1939 projectsRemove: "projects.remove",
2040 projectsSearchEntries: "projects.searchEntries",
21 projectsUpdateScripts: "projects.updateScripts",
2241
2342 // Shell methods
2443 shellOpenInEditor: "shell.openInEditor",
......@@ -54,49 +73,76 @@ export const WS_CHANNELS = {
5473 serverWelcome: "server.welcome",
5574} as const;
5675
57// ── Client → Server (request) ────────────────────────────────────────
76// -- Tagged Union of all request body schemas ─────────────────────────
5877
59export const wsRequestSchema = z.object({
60 id: z.string().min(1),
61 method: z.string().min(1),
62 params: z.unknown().optional(),
63});
78export const WebSocketRequestBody = Schema.TaggedUnion({
79 // Orchestration methods
80 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
81 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
82 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,
83 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,
6484
65export type WsRequest = z.infer<typeof wsRequestSchema>;
85 // Project Search
86 [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields,
6687
67// ── Server → Client (response to request) ────────────────────────────
88 // Shell methods
89 [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields,
6890
69export const wsResponseSchema = z.object({
70 id: z.string().min(1),
71 result: z.unknown().optional(),
72 error: z
73 .object({
74 message: z.string(),
75 })
76 .optional(),
77});
91 // Git methods
92 [WS_METHODS.gitPull]: GitPullInput.fields,
93 [WS_METHODS.gitStatus]: GitStatusInput.fields,
94 [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields,
95 [WS_METHODS.gitListBranches]: GitListBranchesInput.fields,
96 [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields,
97 [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields,
98 [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields,
99 [WS_METHODS.gitCheckout]: GitCheckoutInput.fields,
100 [WS_METHODS.gitInit]: GitInitInput.fields,
78101
79export type WsResponse = z.infer<typeof wsResponseSchema>;
102 // Terminal methods
103 [WS_METHODS.terminalOpen]: TerminalOpenInput.fields,
104 [WS_METHODS.terminalWrite]: TerminalWriteInput.fields,
105 [WS_METHODS.terminalResize]: TerminalResizeInput.fields,
106 [WS_METHODS.terminalClear]: TerminalClearInput.fields,
107 [WS_METHODS.terminalRestart]: TerminalRestartInput.fields,
108 [WS_METHODS.terminalClose]: TerminalCloseInput.fields,
80109
81// ── Server → Client (push event) ─────────────────────────────────────
110 // Server meta
111 [WS_METHODS.serverGetConfig]: {},
112 [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields,
113});
82114
83export const wsPushSchema = z.object({
84 type: z.literal("push"),
85 channel: z.string().min(1),
86 data: z.unknown(),
115export const WebSocketRequest = Schema.Struct({
116 id: Schema.String,
117 body: WebSocketRequestBody,
87118});
119export type WebSocketRequest = typeof WebSocketRequest.Type;
120
88121
89export type WsPush = z.infer<typeof wsPushSchema>;
122export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocketResponse")({
123 id: Schema.String,
124 result: Schema.optional(Schema.Unknown),
125 error: Schema.optional(
126 Schema.Struct({
127 message: Schema.String,
128 }),
129 ),
130}) {}
131
132export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({
133 type: Schema.Literal("push"),
134 channel: Schema.String,
135 data: Schema.Unknown,
136}) {}
90137
91138// ── Union of all server → client messages ─────────────────────────────
92139
93export type WsServerMessage = WsResponse | WsPush;
140export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
141export type WsResponse = typeof WsResponse.Type;
94142
95143// ── Server welcome payload ───────────────────────────────────────────
96144
97export const wsWelcomePayloadSchema = z.object({
98 cwd: z.string().min(1),
99 projectName: z.string().min(1),
100});
101
102export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>;
145export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({
146 cwd: Schema.String,
147 projectName: Schema.String,
148}) {}