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 }`....@@ -55,7 +55,7 @@ Output for every command: `DispatchResultSchema = { sequence: number }`.
55- Input:55- Input:
56 - `commandId: CommandId`56 - `commandId: CommandId`
57 - `projectId: ProjectId`57 - `projectId: ProjectId`
58 - optional `{ title, workspaceRoot, defaultModel }`58 - optional `{ title, workspaceRoot, defaultModel, scripts }`
59- Output: `DispatchResult`59- Output: `DispatchResult`
6060
613. `project.delete`613. `project.delete`
...@@ -427,6 +427,7 @@ Rules:...@@ -427,6 +427,7 @@ Rules:
427- `title: string`427- `title: string`
428- `workspaceRoot: string`428- `workspaceRoot: string`
429- `defaultModel: string | null`429- `defaultModel: string | null`
430- `scripts: ProjectScript[]`
430- `createdAt: IsoDateTime`431- `createdAt: IsoDateTime`
431- `updatedAt: IsoDateTime`432- `updatedAt: IsoDateTime`
432- `deletedAt: IsoDateTime | null`433- `deletedAt: IsoDateTime | null`
apps/server/integration/providerService.integration.test.ts+24-116
...@@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node";...@@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node";
3import { it, assert } from "@effect/vitest";3import { it, assert } from "@effect/vitest";
4import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";4import { 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";
9import { ProviderUnsupportedError } from "../src/provider/Errors.ts";6import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
10import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";7import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
11import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";8import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
12import { ProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";9import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
13import {10import {
14 ProviderService,11 ProviderService,
15 type ProviderServiceShape,12 type ProviderServiceShape,
16} from "../src/provider/Services/ProviderService.ts";13} from "../src/provider/Services/ProviderService.ts";
17import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";14import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts";
18import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts";15import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
19import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts";
2016
21import {17import {
22 makeTestProviderAdapterHarness,18 makeTestProviderAdapterHarness,
...@@ -29,34 +25,11 @@ import {...@@ -29,34 +25,11 @@ import {
29 codexTurnTextFixture,25 codexTurnTextFixture,
30} from "./fixtures/providerRuntime.ts";26} from "./fixtures/providerRuntime.ts";
3127
32const runGit = (cwd: string, args: ReadonlyArray<string>, allowNonZeroExit = false) =>28const makeWorkspaceDirectory = Effect.gen(function* () {
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* () {
49 const fs = yield* FileSystem.FileSystem;29 const fs = yield* FileSystem.FileSystem;
50 const pathService = yield* Path.Path;30 const pathService = yield* Path.Path;
51 const cwd = yield* fs.makeTempDirectory();31 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"]);
56 yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n");32 yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n");
57 yield* runGit(cwd, ["add", "."]);
58 yield* runGit(cwd, ["commit", "-m", "Initial"]);
59
60 return cwd;33 return cwd;
61}).pipe(Effect.provide(NodeServices.layer));34}).pipe(Effect.provide(NodeServices.layer));
6235
...@@ -67,7 +40,7 @@ interface IntegrationFixture {...@@ -67,7 +40,7 @@ interface IntegrationFixture {
67}40}
6841
69const makeIntegrationFixture = Effect.gen(function* () {42const makeIntegrationFixture = Effect.gen(function* () {
70 const cwd = yield* makeGitRepository;43 const cwd = yield* makeWorkspaceDirectory;
71 const harness = yield* makeTestProviderAdapterHarness;44 const harness = yield* makeTestProviderAdapterHarness;
7245
73 const registry: typeof ProviderAdapterRegistry.Service = {46 const registry: typeof ProviderAdapterRegistry.Service = {
...@@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () {...@@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () {
79 };52 };
8053
81 const directoryLayer = ProviderSessionDirectoryLive.pipe(54 const directoryLayer = ProviderSessionDirectoryLive.pipe(
82 Layer.provide(ProviderSessionRepositoryLive),55 Layer.provide(ProviderSessionRuntimeRepositoryLive),
83 );56 );
8457
85 const shared = Layer.mergeAll(58 const shared = Layer.mergeAll(
86 directoryLayer,59 directoryLayer,
87 Layer.succeed(ProviderAdapterRegistry, registry),60 Layer.succeed(ProviderAdapterRegistry, registry),
88 Layer.provide(CheckpointStoreLive, NodeServices.layer),
89 CheckpointRepositoryLive,
90 ).pipe(Layer.provide(SqlitePersistenceMemory));61 ).pipe(Layer.provide(SqlitePersistenceMemory));
9162
92 const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared));63 const layer = makeProviderServiceLive().pipe(Layer.provide(shared));
93
94 const layer = ProviderServiceLive.pipe(
95 Layer.provide(Layer.mergeAll(shared, checkpointLayer)),
96 Layer.merge(NodeServices.layer),
97 );
9864
99 return {65 return {
100 cwd,66 cwd,
...@@ -135,7 +101,7 @@ const runTurn = (input: {...@@ -135,7 +101,7 @@ const runTurn = (input: {
135101
136 return yield* collectEventsDuring(102 return yield* collectEventsDuring(
137 input.provider.streamEvents,103 input.provider.streamEvents,
138 input.response.events.length + 1,104 input.response.events.length,
139 input.provider.sendTurn({105 input.provider.sendTurn({
140 sessionId: input.sessionId,106 sessionId: input.sessionId,
141 input: input.userText,107 input: input.userText,
...@@ -144,7 +110,7 @@ const runTurn = (input: {...@@ -144,7 +110,7 @@ const runTurn = (input: {
144 );110 );
145 });111 });
146112
147it.effect("replays typed runtime fixture and emits checkpoint.captured", () =>113it.effect("replays typed runtime fixture events", () =>
148 Effect.gen(function* () {114 Effect.gen(function* () {
149 const fixture = yield* makeIntegrationFixture;115 const fixture = yield* makeIntegrationFixture;
150116
...@@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () =>...@@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () =>
154 provider: "codex",120 provider: "codex",
155 cwd: fixture.cwd,121 cwd: fixture.cwd,
156 });122 });
157 const threadId = session.threadId ?? "";123 assert.equal((session.threadId ?? "").length > 0, true);
158 assert.equal(threadId.length > 0, true);
159124
160 const observedEvents = yield* runTurn({125 const observedEvents = yield* runTurn({
161 provider,126 provider,
162 harness: fixture.harness,127 harness: fixture.harness,
163 sessionId: session.sessionId,128 sessionId: session.sessionId,
164 userText: "hello",129 userText: "hello",
165 response: { events: codexTurnTextFixture },130 response: { events: codexTurnTextFixture },
166 });131 });
167132
168 assert.deepEqual(133 assert.deepEqual(
169 observedEvents.map((event) => event.type),134 observedEvents.map((event) => event.type),
170 [...codexTurnTextFixture.map((event) => event.type), "checkpoint.captured"],135 codexTurnTextFixture.map((event) => event.type),
171 );136 );
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);
188 }).pipe(Effect.provide(fixture.layer));137 }).pipe(Effect.provide(fixture.layer));
189 }),138 }).pipe(Effect.provide(NodeServices.layer)),
190);139);
191140
192it.effect("captures file-changing fixture turn and produces a non-empty diff", () =>141it.effect("replays file-changing fixture turn events", () =>
193 Effect.gen(function* () {142 Effect.gen(function* () {
194 const fixture = yield* makeIntegrationFixture;143 const fixture = yield* makeIntegrationFixture;
195 const { join } = yield* Path.Path;144 const { join } = yield* Path.Path;
...@@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (...@@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (
201 provider: "codex",150 provider: "codex",
202 cwd: fixture.cwd,151 cwd: fixture.cwd,
203 });152 });
204 const threadId = session.threadId ?? "";153 assert.equal((session.threadId ?? "").length > 0, true);
205 assert.equal(threadId.length > 0, true);
206154
207 const observedEvents = yield* runTurn({155 const observedEvents = yield* runTurn({
208 provider,156 provider,
...@@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (...@@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", (
218166
219 assert.deepEqual(167 assert.deepEqual(
220 observedEvents.map((event) => event.type),168 observedEvents.map((event) => event.type),
221 [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"],169 codexTurnToolFixture.map((event) => event.type),
222 );170 );
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);
233 }).pipe(Effect.provide(fixture.layer));171 }).pipe(Effect.provide(fixture.layer));
234 }).pipe(Effect.provide(NodeServices.layer)),172 }).pipe(Effect.provide(NodeServices.layer)),
235);173);
236174
237it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =>175it.effect("runs multi-turn tool/approval flow", () =>
238 Effect.gen(function* () {176 Effect.gen(function* () {
239 const fixture = yield* makeIntegrationFixture;177 const fixture = yield* makeIntegrationFixture;
240 const { join } = yield* Path.Path;178 const { join } = yield* Path.Path;
...@@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =...@@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
246 provider: "codex",184 provider: "codex",
247 cwd: fixture.cwd,185 cwd: fixture.cwd,
248 });186 });
249 const threadId = session.threadId ?? "";187 assert.equal((session.threadId ?? "").length > 0, true);
250 assert.equal(threadId.length > 0, true);
251188
252 const firstTurnEvents = yield* runTurn({189 const firstTurnEvents = yield* runTurn({
253 provider,190 provider,
...@@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =...@@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
262 });199 });
263 assert.deepEqual(200 assert.deepEqual(
264 firstTurnEvents.map((event) => event.type),201 firstTurnEvents.map((event) => event.type),
265 [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"],202 codexTurnToolFixture.map((event) => event.type),
266 );203 );
267204
268 const secondTurnEvents = yield* runTurn({205 const secondTurnEvents = yield* runTurn({
...@@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =...@@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () =
278 });215 });
279 assert.deepEqual(216 assert.deepEqual(
280 secondTurnEvents.map((event) => event.type),217 secondTurnEvents.map((event) => event.type),
281 [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"],218 codexTurnApprovalFixture.map((event) => event.type),
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],
290 );219 );
291 const current =
292 turnStateBeforeRevert.checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
293 turnStateBeforeRevert.checkpoints[turnStateBeforeRevert.checkpoints.length - 1];
294 assert.equal(current?.turnCount, 2);
295 }).pipe(Effect.provide(fixture.layer));220 }).pipe(Effect.provide(fixture.layer));
296 }).pipe(Effect.provide(NodeServices.layer)),221 }).pipe(Effect.provide(NodeServices.layer)),
297);222);
298223
299it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>224it.effect("rolls back provider conversation state only", () =>
300 Effect.gen(function* () {225 Effect.gen(function* () {
301 const fixture = yield* makeIntegrationFixture;226 const fixture = yield* makeIntegrationFixture;
302 const { join } = yield* Path.Path;227 const { join } = yield* Path.Path;
...@@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>...@@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>
308 provider: "codex",233 provider: "codex",
309 cwd: fixture.cwd,234 cwd: fixture.cwd,
310 });235 });
311 const threadId = session.threadId ?? "";236 assert.equal((session.threadId ?? "").length > 0, true);
312 assert.equal(threadId.length > 0, true);
313237
314 yield* runTurn({238 yield* runTurn({
315 provider,239 provider,
...@@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>...@@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () =>
335 },259 },
336 });260 });
337261
338 const turnTwoRef = checkpointRefForTurn(threadId, 2);262 yield* provider.rollbackConversation({
339 const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef);
340 assert.equal(turnTwoRefBefore, true);
341
342 const revert = yield* provider.revertToCheckpoint({
343 sessionId: session.sessionId,263 sessionId: session.sessionId,
344 turnCount: 1,264 numTurns: 1,
345 });265 });
346 assert.equal(revert.rolledBackTurns, 1);
347266
348 const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId);267 const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId);
349 assert.deepEqual(rollbackCalls, [1]);268 assert.deepEqual(rollbackCalls, [1]);
350269
351 const readme = yield* readFileString(join(fixture.cwd, "README.md"));270 const readme = yield* readFileString(join(fixture.cwd, "README.md"));
352 assert.equal(readme, "v2\n");271 assert.equal(readme, "v3\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);
364 }).pipe(Effect.provide(fixture.layer));272 }).pipe(Effect.provide(fixture.layer));
365 }).pipe(Effect.provide(NodeServices.layer)),273 }).pipe(Effect.provide(NodeServices.layer)),
366);274);
apps/server/src/checkpointing/Errors.ts+5-54
...@@ -1,39 +1,6 @@...@@ -1,39 +1,6 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts";2import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
33import { GitCommandError } from "../git/Errors.ts";
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}
374
38/**5/**
39 * CheckpointUnavailableError - Expected checkpoint does not exist.6 * CheckpointUnavailableError - Expected checkpoint does not exist.
...@@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin...@@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin
68 }35 }
69}36}
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
87/**38/**
88 * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow.39 * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow.
89 */40 */
...@@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint...@@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint
116}67}
11768
118export type CheckpointStoreError =69export type CheckpointStoreError =
119 | CheckpointGitCommandError70 | GitCommandError
71 | CheckpointInvariantError
120 | CheckpointUnavailableError72 | CheckpointUnavailableError
121 | CheckpointRepositoryError;73 | CheckpointRepositoryError;
12274
123export type CheckpointServiceError =75export type CheckpointServiceError =
124 | CheckpointStoreError76 | CheckpointStoreError
125 | CheckpointMetadataRepositoryError77 | ProjectionRepositoryError
126 | CheckpointServiceValidationError
127 | CheckpointSessionNotFoundError78 | CheckpointSessionNotFoundError
128 | CheckpointInvariantError;79 | 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 @@...@@ -9,162 +9,83 @@
9 *9 *
10 * @module CheckpointStoreLive10 * @module CheckpointStoreLive
11 */11 */
12import { mkdtemp, rm } from "node:fs/promises";
13import { randomUUID } from "node:crypto";12import { randomUUID } from "node:crypto";
14import os from "node:os";13
15import path from "node:path";14import { Effect, Layer, FileSystem, Path } from "effect";
1615
17import { Effect, Layer } from "effect";16import { CheckpointInvariantError } from "../Errors.ts";
18import { ChildProcessSpawner } from "effect/unstable/process";
19
20import {
21 CheckpointGitCommandError,
22 CheckpointRepositoryError,
23 CheckpointUnavailableError,
24 type CheckpointStoreError,
25} from "../Errors.ts";
26import { GitCommandError } from "../../git/Errors.ts";17import { GitCommandError } from "../../git/Errors.ts";
27import { runGit, RunGitInput } from "../../git/Process.ts";18import { GitServiceLive } from "../../git/Layer.ts";
19import { GitService } from "../../git/Service.ts";
28import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";20import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2921import { CheckpointRef } from "@t3tools/contracts";
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}
13622
137const makeCheckpointStore = Effect.gen(function* () {23const makeCheckpointStore = Effect.gen(function* () {
138 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;24 const fs = yield* FileSystem.FileSystem;
139 const runGitWithSpawner = (input: RunGitInput) =>25 const path = yield* Path.Path;
140 runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));26 const git = yield* GitService;
141 const resolveHeadCommitWithSpawner = (cwd: string) =>27
142 resolveHeadCommit(cwd).pipe(28 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
143 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),29 git
144 );30 .execute({
145 const hasHeadCommitWithSpawner = (cwd: string) =>31 operation: "CheckpointStore.resolveHeadCommit",
146 hasHeadCommit(cwd).pipe(32 cwd,
147 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),33 args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"],
148 );34 allowNonZeroExit: true,
149 const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) =>35 })
150 resolveCheckpointCommit(cwd, checkpointRef).pipe(36 .pipe(
151 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),37 Effect.map((result) => {
152 );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) =>46 const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
155 Effect.gen(function* () {47 git
156 const normalizedCwd = cwd.trim();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({
159 operation: "CheckpointStore.isGitRepository",80 operation: "CheckpointStore.isGitRepository",
160 cwd: normalizedCwd,81 cwd: cwd.trim(),
161 args: ["rev-parse", "--is-inside-work-tree"],82 args: ["rev-parse", "--is-inside-work-tree"],
162 allowNonZeroExit: true,83 allowNonZeroExit: true,
163 }).pipe(84 })
85 .pipe(
164 Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"),86 Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"),
165 Effect.catch(() => Effect.succeed(false)),87 Effect.catch(() => Effect.succeed(false)),
166 );88 );
167 });
16889
169 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>90 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>
170 Effect.gen(function* () {91 Effect.gen(function* () {
...@@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () {
173 const checkpointRef = input.checkpointRef.trim();94 const checkpointRef = input.checkpointRef.trim();
17495
175 yield* Effect.acquireUseRelease(96 yield* Effect.acquireUseRelease(
176 Effect.tryPromise({97 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
177 try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")),
178 catch: (cause) => cause,
179 }),
180 (tempDir) =>98 (tempDir) =>
181 Effect.gen(function* () {99 Effect.gen(function* () {
182 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);100 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
...@@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () {
184 ...process.env,102 ...process.env,
185 GIT_INDEX_FILE: tempIndexPath,103 GIT_INDEX_FILE: tempIndexPath,
186 GIT_AUTHOR_NAME: "T3 Code",104 GIT_AUTHOR_NAME: "T3 Code",
187 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",105 GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com",
188 GIT_COMMITTER_NAME: "T3 Code",106 GIT_COMMITTER_NAME: "T3 Code",
189 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",107 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
190 };108 };
191109
192 const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd);110 const headExists = yield* hasHeadCommit(normalizedCwd);
193 if (headExists) {111 if (headExists) {
194 yield* runGitWithSpawner({112 yield* git.execute({
195 operation,113 operation,
196 cwd: normalizedCwd,114 cwd: normalizedCwd,
197 args: ["read-tree", "HEAD"],115 args: ["read-tree", "HEAD"],
...@@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {
199 });117 });
200 }118 }
201119
202 yield* runGitWithSpawner({120 yield* git.execute({
203 operation,121 operation,
204 cwd: normalizedCwd,122 cwd: normalizedCwd,
205 args: ["add", "-A", "--", "."],123 args: ["add", "-A", "--", "."],
206 env: commitEnv,124 env: commitEnv,
207 });125 });
208126
209 const writeTreeResult = yield* runGitWithSpawner({127 const writeTreeResult = yield* git.execute({
210 operation,128 operation,
211 cwd: normalizedCwd,129 cwd: normalizedCwd,
212 args: ["write-tree"],130 args: ["write-tree"],
...@@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () {
215 const treeOid = writeTreeResult.stdout.trim();133 const treeOid = writeTreeResult.stdout.trim();
216 if (treeOid.length === 0) {134 if (treeOid.length === 0) {
217 return yield* Effect.fail(135 return yield* Effect.fail(
218 new CheckpointGitCommandError({136 new GitCommandError({
219 operation,137 operation,
220 command: "git write-tree",138 command: "git write-tree",
221 cwd: normalizedCwd,139 cwd: normalizedCwd,
...@@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () {
225 }143 }
226144
227 const message = `t3 checkpoint ref=${checkpointRef}`;145 const message = `t3 checkpoint ref=${checkpointRef}`;
228 const commitTreeResult = yield* runGitWithSpawner({146 const commitTreeResult = yield* git.execute({
229 operation,147 operation,
230 cwd: normalizedCwd,148 cwd: normalizedCwd,
231 args: ["commit-tree", treeOid, "-m", message],149 args: ["commit-tree", treeOid, "-m", message],
...@@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () {
234 const commitOid = commitTreeResult.stdout.trim();152 const commitOid = commitTreeResult.stdout.trim();
235 if (commitOid.length === 0) {153 if (commitOid.length === 0) {
236 return yield* Effect.fail(154 return yield* Effect.fail(
237 new CheckpointGitCommandError({155 new GitCommandError({
238 operation,156 operation,
239 command: "git commit-tree",157 command: "git commit-tree",
240 cwd: normalizedCwd,158 cwd: normalizedCwd,
...@@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () {
243 );161 );
244 }162 }
245163
246 yield* runGitWithSpawner({164 yield* git.execute({
247 operation,165 operation,
248 cwd: normalizedCwd,166 cwd: normalizedCwd,
249 args: ["update-ref", checkpointRef, commitOid],167 args: ["update-ref", checkpointRef, commitOid],
250 });168 });
251 }),169 }),
252 removeDirectory,170 (tempDir) => fs.remove(tempDir, { recursive: true }),
253 ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));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 );
254 });183 });
255184
256 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>185 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
257 Effect.gen(function* () {186 Effect.gen(function* () {
258 const operation = "CheckpointStore.hasCheckpointRef";187 return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
259 const normalizedCwd = input.cwd.trim();
260 const checkpointRef = input.checkpointRef.trim();
261
262 return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe(
263 Effect.map((commit) => commit !== null),188 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)),
276 );189 );
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;
294 });190 });
295191
296 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>192 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>
297 Effect.gen(function* () {193 Effect.gen(function* () {
298 const operation = "CheckpointStore.restoreCheckpoint";194 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(196 let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef);
303 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
304 );
305197
306 if (!commitOid && input.fallbackToHead === true) {198 if (!commitOid && input.fallbackToHead === true) {
307 commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe(199 commitOid = yield* resolveHeadCommit(input.cwd);
308 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
309 );
310 }200 }
311201
312 if (!commitOid) {202 if (!commitOid) {
313 return false;203 return false;
314 }204 }
315205
316 yield* runGitWithSpawner({206 yield* git.execute({
317 operation,207 operation,
318 cwd: normalizedCwd,208 cwd: input.cwd,
319 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],209 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
320 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));210 });
321 yield* runGitWithSpawner({211 yield* git.execute({
322 operation,212 operation,
323 cwd: normalizedCwd,213 cwd: input.cwd,
324 args: ["clean", "-fd", "--", "."],214 args: ["clean", "-fd", "--", "."],
325 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));215 });
326216
327 const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe(217 const headExists = yield* hasHeadCommit(input.cwd);
328 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
329 );
330 if (headExists) {218 if (headExists) {
331 yield* runGitWithSpawner({219 yield* git.execute({
332 operation,220 operation,
333 cwd: normalizedCwd,221 cwd: input.cwd,
334 args: ["reset", "--quiet", "--", "."],222 args: ["reset", "--quiet", "--", "."],
335 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));223 });
336 }224 }
337225
338 return true;226 return true;
...@@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () {
341 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) =>229 const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) =>
342 Effect.gen(function* () {230 Effect.gen(function* () {
343 const operation = "CheckpointStore.diffCheckpoints";231 const operation = "CheckpointStore.diffCheckpoints";
344 const normalizedCwd = input.cwd.trim();232
345 const fromCheckpointRef = input.fromCheckpointRef.trim();233 let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef);
346 const toCheckpointRef = input.toCheckpointRef.trim();234 const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef);
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)));
356235
357 if (!fromCommitOid && input.fallbackFromToHead === true) {236 if (!fromCommitOid && input.fallbackFromToHead === true) {
358 const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe(237 const headCommit = yield* resolveHeadCommit(input.cwd);
359 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
360 );
361 if (headCommit) {238 if (headCommit) {
362 fromCommitOid = headCommit;239 fromCommitOid = headCommit;
363 }240 }
...@@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () {
365242
366 if (!fromCommitOid || !toCommitOid) {243 if (!fromCommitOid || !toCommitOid) {
367 return yield* Effect.fail(244 return yield* Effect.fail(
368 new CheckpointGitCommandError({245 new GitCommandError({
369 operation,246 operation,
370 command: "git diff",247 command: "git diff",
371 cwd: normalizedCwd,248 cwd: input.cwd,
372 detail: "Checkpoint ref is unavailable for diff operation.",249 detail: "Checkpoint ref is unavailable for diff operation.",
373 }),250 }),
374 );251 );
375 }252 }
376253
377 const result = yield* runGitWithSpawner({254 const result = yield* git.execute({
378 operation,255 operation,
379 cwd: normalizedCwd,256 cwd: input.cwd,
380 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],257 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
381 }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)));258 });
382259
383 return result.stdout;260 return result.stdout;
384 });261 });
...@@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () {
386 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) =>263 const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) =>
387 Effect.gen(function* () {264 Effect.gen(function* () {
388 const operation = "CheckpointStore.deleteCheckpointRefs";265 const operation = "CheckpointStore.deleteCheckpointRefs";
389 const normalizedCwd = input.cwd.trim();
390 const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim());
391266
392 yield* Effect.forEach(267 yield* Effect.forEach(
393 checkpointRefs,268 input.checkpointRefs,
394 (checkpointRef) =>269 (checkpointRef) =>
395 runGitWithSpawner({270 git.execute({
396 operation,271 operation,
397 cwd: normalizedCwd,272 cwd: input.cwd,
398 args: ["update-ref", "-d", checkpointRef],273 args: ["update-ref", "-d", checkpointRef],
399 allowNonZeroExit: true,274 allowNonZeroExit: true,
400 }).pipe(275 }),
401 Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)),
402 ),
403 { discard: true },276 { discard: true },
404 );277 );
405 });278 });
...@@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () {
408 isGitRepository,281 isGitRepository,
409 captureCheckpoint,282 captureCheckpoint,
410 hasCheckpointRef,283 hasCheckpointRef,
411 ensureRootCheckpoint,
412 restoreCheckpoint,284 restoreCheckpoint,
413 diffCheckpoints,285 diffCheckpoints,
414 deleteCheckpointRefs,286 deleteCheckpointRefs,
415 } satisfies CheckpointStoreShape;287 } satisfies CheckpointStoreShape;
416});288});
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";...@@ -14,33 +14,29 @@ import { ServiceMap } from "effect";
14import type { Effect } from "effect";14import type { Effect } from "effect";
1515
16import type { CheckpointStoreError } from "../Errors.ts";16import type { CheckpointStoreError } from "../Errors.ts";
17import { CheckpointRef } from "@t3tools/contracts";
1718
18export interface CaptureCheckpointInput {19export interface CaptureCheckpointInput {
19 readonly cwd: string;20 readonly cwd: string;
20 readonly checkpointRef: string;21 readonly checkpointRef: CheckpointRef;
21}
22
23export interface EnsureRootCheckpointInput {
24 readonly cwd: string;
25 readonly checkpointRef: string;
26}22}
2723
28export interface RestoreCheckpointInput {24export interface RestoreCheckpointInput {
29 readonly cwd: string;25 readonly cwd: string;
30 readonly checkpointRef: string;26 readonly checkpointRef: CheckpointRef;
31 readonly fallbackToHead?: boolean;27 readonly fallbackToHead?: boolean;
32}28}
3329
34export interface DiffCheckpointsInput {30export interface DiffCheckpointsInput {
35 readonly cwd: string;31 readonly cwd: string;
36 readonly fromCheckpointRef: string;32 readonly fromCheckpointRef: CheckpointRef;
37 readonly toCheckpointRef: string;33 readonly toCheckpointRef: CheckpointRef;
38 readonly fallbackFromToHead?: boolean;34 readonly fallbackFromToHead?: boolean;
39}35}
4036
41export interface DeleteCheckpointRefsInput {37export interface DeleteCheckpointRefsInput {
42 readonly cwd: string;38 readonly cwd: string;
43 readonly checkpointRefs: ReadonlyArray<string>;39 readonly checkpointRefs: ReadonlyArray<CheckpointRef>;
44}40}
4541
46export interface CheckpointStoreShape {42export interface CheckpointStoreShape {
...@@ -63,13 +59,6 @@ export interface CheckpointStoreShape {...@@ -63,13 +59,6 @@ export interface CheckpointStoreShape {
63 input: Omit<RestoreCheckpointInput, "fallbackToHead">,59 input: Omit<RestoreCheckpointInput, "fallbackToHead">,
64 ) => Effect.Effect<boolean, CheckpointStoreError>;60 ) => 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
73 /**62 /**
74 * Restore workspace/staging state to a checkpoint.63 * Restore workspace/staging state to a checkpoint.
75 */64 */
apps/server/src/codexAppServerManager.ts+96-55
...@@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto";...@@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto";
3import { EventEmitter } from "node:events";3import { EventEmitter } from "node:events";
4import readline from "node:readline";4import readline from "node:readline";
55
6import type {6import {
7 ProviderApprovalDecision,7 ApprovalRequestId,
8 ProviderEvent,8 EventId,
9 ProviderItemId,
9 ProviderRequestKind,10 ProviderRequestKind,
10 ProviderSendTurnInput,11 ProviderSessionId,
11 ProviderSession,12 ProviderThreadId,
12 ProviderSessionStartInput,13 ProviderTurnId,
13 ProviderTurnStartResult,14 normalizeModelSlug,
15 type ProviderApprovalDecision,
16 type ProviderEvent,
17 type ProviderSendTurnInput,
18 type ProviderSession,
19 type ProviderSessionStartInput,
20 type ProviderTurnStartResult,
14} from "@t3tools/contracts";21} from "@t3tools/contracts";
15import { normalizeModelSlug } from "@t3tools/contracts";
1622
17type PendingRequestKey = string;23type PendingRequestKey = string;
1824
...@@ -24,13 +30,13 @@ interface PendingRequest {...@@ -24,13 +30,13 @@ interface PendingRequest {
24}30}
2531
26interface PendingApprovalRequest {32interface PendingApprovalRequest {
27 requestId: string;33 requestId: ApprovalRequestId;
28 jsonRpcId: string | number;34 jsonRpcId: string | number;
29 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";35 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
30 requestKind: ProviderRequestKind;36 requestKind: ProviderRequestKind;
31 threadId?: string;37 threadId?: ProviderThreadId;
32 turnId?: string;38 turnId?: ProviderTurnId;
33 itemId?: string;39 itemId?: ProviderItemId;
34}40}
3541
36interface CodexSessionContext {42interface CodexSessionContext {
...@@ -38,7 +44,7 @@ interface CodexSessionContext {...@@ -38,7 +44,7 @@ interface CodexSessionContext {
38 child: ChildProcessWithoutNullStreams;44 child: ChildProcessWithoutNullStreams;
39 output: readline.Interface;45 output: readline.Interface;
40 pending: Map<PendingRequestKey, PendingRequest>;46 pending: Map<PendingRequestKey, PendingRequest>;
41 pendingApprovals: Map<string, PendingApprovalRequest>;47 pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>;
42 nextRequestId: number;48 nextRequestId: number;
43 stopping: boolean;49 stopping: boolean;
44}50}
...@@ -66,12 +72,12 @@ interface JsonRpcNotification {...@@ -66,12 +72,12 @@ interface JsonRpcNotification {
66}72}
6773
68export interface CodexThreadTurnSnapshot {74export interface CodexThreadTurnSnapshot {
69 id: string;75 id: ProviderTurnId;
70 items: unknown[];76 items: unknown[];
71}77}
7278
73export interface CodexThreadSnapshot {79export interface CodexThreadSnapshot {
74 threadId: string;80 threadId: ProviderThreadId;
75 turns: CodexThreadTurnSnapshot[];81 turns: CodexThreadTurnSnapshot[];
76}82}
7783
...@@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents {...@@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents {
143}149}
144150
145export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {151export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> {
146 private readonly sessions = new Map<string, CodexSessionContext>();152 private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>();
147153
148 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {154 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
149 const sessionId = randomUUID();155 const sessionId = ProviderSessionId.makeUnsafe(randomUUID());
150 const now = new Date().toISOString();156 const now = new Date().toISOString();
151 let context: CodexSessionContext | undefined;157 let context: CodexSessionContext | undefined;
152158
...@@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
244 }250 }
245251
246 const threadOpenRecord = this.readObject(threadOpenResponse);252 const threadOpenRecord = this.readObject(threadOpenResponse);
247 const threadId =253 const threadIdRaw =
248 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??254 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249 this.readString(threadOpenRecord, "threadId");255 this.readString(threadOpenRecord, "threadId");
250 if (!threadId) {256 if (!threadIdRaw) {
251 throw new Error(`${threadOpenMethod} response did not include a thread id.`);257 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
252 }258 }
259 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
253260
254 this.updateSession(context, {261 this.updateSession(context, {
255 status: "ready",262 status: "ready",
...@@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
268 this.stopSession(sessionId);275 this.stopSession(sessionId);
269 } else {276 } else {
270 this.emitEvent({277 this.emitEvent({
271 id: randomUUID(),278 id: EventId.makeUnsafe(randomUUID()),
272 kind: "error",279 kind: "error",
273 provider: "codex",280 provider: "codex",
274 sessionId,281 sessionId,
...@@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
310 }317 }
311318
312 const turnStartParams: {319 const turnStartParams: {
313 threadId: string;320 threadId: ProviderThreadId;
314 input: Array<321 input: Array<
315 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }322 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
316 >;323 >;
...@@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
331 const response = await this.sendRequest(context, "turn/start", turnStartParams);338 const response = await this.sendRequest(context, "turn/start", turnStartParams);
332339
333 const turn = this.readObject(this.readObject(response), "turn");340 const turn = this.readObject(this.readObject(response), "turn");
334 const turnId = this.readString(turn, "id");341 const turnIdRaw = this.readString(turn, "id");
335 if (!turnId) {342 if (!turnIdRaw) {
336 throw new Error("turn/start response did not include a turn id.");343 throw new Error("turn/start response did not include a turn id.");
337 }344 }
345 const turnId = ProviderTurnId.makeUnsafe(turnIdRaw);
338346
339 this.updateSession(context, {347 this.updateSession(context, {
340 status: "running",348 status: "running",
...@@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
347 };355 };
348 }356 }
349357
350 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {358 async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
351 const context = this.requireSession(sessionId);359 const context = this.requireSession(sessionId);
352 const effectiveTurnId = turnId ?? context.session.activeTurnId;360 const effectiveTurnId = turnId ?? context.session.activeTurnId;
353361
...@@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
361 });369 });
362 }370 }
363371
364 async readThread(sessionId: string): Promise<CodexThreadSnapshot> {372 async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> {
365 const context = this.requireSession(sessionId);373 const context = this.requireSession(sessionId);
366 const threadId = context.session.threadId;374 const threadId = context.session.threadId;
367 if (!threadId) {375 if (!threadId) {
...@@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
375 return this.parseThreadSnapshot("thread/read", response);383 return this.parseThreadSnapshot("thread/read", response);
376 }384 }
377385
378 async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> {386 async rollbackThread(sessionId: ProviderSessionId, numTurns: number): Promise<CodexThreadSnapshot> {
379 const context = this.requireSession(sessionId);387 const context = this.requireSession(sessionId);
380 const threadId = context.session.threadId;388 const threadId = context.session.threadId;
381 if (!threadId) {389 if (!threadId) {
...@@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
397 }405 }
398406
399 async respondToRequest(407 async respondToRequest(
400 sessionId: string,408 sessionId: ProviderSessionId,
401 requestId: string,409 requestId: ApprovalRequestId,
402 decision: ProviderApprovalDecision,410 decision: ProviderApprovalDecision,
403 ): Promise<void> {411 ): Promise<void> {
404 const context = this.requireSession(sessionId);412 const context = this.requireSession(sessionId);
...@@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
416 });424 });
417425
418 this.emitEvent({426 this.emitEvent({
419 id: randomUUID(),427 id: EventId.makeUnsafe(randomUUID()),
420 kind: "notification",428 kind: "notification",
421 provider: "codex",429 provider: "codex",
422 sessionId: context.session.sessionId,430 sessionId: context.session.sessionId,
...@@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
435 });443 });
436 }444 }
437445
438 stopSession(sessionId: string): void {446 stopSession(sessionId: ProviderSessionId): void {
439 const context = this.sessions.get(sessionId);447 const context = this.sessions.get(sessionId);
440 if (!context) {448 if (!context) {
441 return;449 return;
...@@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
470 }));478 }));
471 }479 }
472480
473 hasSession(sessionId: string): boolean {481 hasSession(sessionId: ProviderSessionId): boolean {
474 return this.sessions.has(sessionId);482 return this.sessions.has(sessionId);
475 }483 }
476484
...@@ -480,7 +488,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -480,7 +488,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
480 }488 }
481 }489 }
482490
483 private requireSession(sessionId: string): CodexSessionContext {491 private requireSession(sessionId: ProviderSessionId): CodexSessionContext {
484 const context = this.sessions.get(sessionId);492 const context = this.sessions.get(sessionId);
485 if (!context) {493 if (!context) {
486 throw new Error(`Unknown session: ${sessionId}`);494 throw new Error(`Unknown session: ${sessionId}`);
...@@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
591 : undefined;599 : undefined;
592600
593 this.emitEvent({601 this.emitEvent({
594 id: randomUUID(),602 id: EventId.makeUnsafe(randomUUID()),
595 kind: "notification",603 kind: "notification",
596 provider: "codex",604 provider: "codex",
597 sessionId: context.session.sessionId,605 sessionId: context.session.sessionId,
...@@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
605 });613 });
606614
607 if (notification.method === "thread/started") {615 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 );
609 if (threadId) {619 if (threadId) {
610 this.updateSession(context, { threadId });620 this.updateSession(context, { threadId });
611 }621 }
...@@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
613 }623 }
614624
615 if (notification.method === "turn/started") {625 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 );
617 this.updateSession(context, {629 this.updateSession(context, {
618 status: "running",630 status: "running",
619 activeTurnId: turnId,631 activeTurnId: turnId,
...@@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
647 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {659 private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void {
648 const route = this.readRouteFields(request.params);660 const route = this.readRouteFields(request.params);
649 const requestKind = this.requestKindForMethod(request.method);661 const requestKind = this.requestKindForMethod(request.method);
650 let requestId: string | undefined;662 let requestId: ApprovalRequestId | undefined;
651 if (requestKind) {663 if (requestKind) {
652 requestId = randomUUID();664 requestId = ApprovalRequestId.makeUnsafe(randomUUID());
653 const pendingRequest: PendingApprovalRequest = {665 const pendingRequest: PendingApprovalRequest = {
654 requestId,666 requestId,
655 jsonRpcId: request.id,667 jsonRpcId: request.id,
...@@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
666 }678 }
667679
668 this.emitEvent({680 this.emitEvent({
669 id: randomUUID(),681 id: EventId.makeUnsafe(randomUUID()),
670 kind: "request",682 kind: "request",
671 provider: "codex",683 provider: "codex",
672 sessionId: context.session.sessionId,684 sessionId: context.session.sessionId,
...@@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
761773
762 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {774 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {
763 this.emitEvent({775 this.emitEvent({
764 id: randomUUID(),776 id: EventId.makeUnsafe(randomUUID()),
765 kind: "session",777 kind: "session",
766 provider: "codex",778 provider: "codex",
767 sessionId: context.session.sessionId,779 sessionId: context.session.sessionId,
...@@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
773785
774 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {786 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {
775 this.emitEvent({787 this.emitEvent({
776 id: randomUUID(),788 id: EventId.makeUnsafe(randomUUID()),
777 kind: "error",789 kind: "error",
778 provider: "codex",790 provider: "codex",
779 sessionId: context.session.sessionId,791 sessionId: context.session.sessionId,
...@@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
810 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {822 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {
811 const responseRecord = this.readObject(response);823 const responseRecord = this.readObject(response);
812 const thread = this.readObject(responseRecord, "thread");824 const thread = this.readObject(responseRecord, "thread");
813 const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId");825 const threadIdRaw = this.readString(thread, "id") ??
814 if (!threadId) {826 this.readString(responseRecord, "threadId");
827 if (!threadIdRaw) {
815 throw new Error(`${method} response did not include a thread id.`);828 throw new Error(`${method} response did not include a thread id.`);
816 }829 }
830 const threadId = ProviderThreadId.makeUnsafe(threadIdRaw);
817831
818 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];832 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
819 const turns = turnsRaw.map((turnValue, index) => {833 const turns = turnsRaw.map((turnValue, index) => {
820 const turn = this.readObject(turnValue);834 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);
822 const items = this.readArray(turn, "items") ?? [];839 const items = this.readArray(turn, "items") ?? [];
823 return {840 return {
824 id: turnId,841 id: turnId,
...@@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
865 }882 }
866883
867 private readRouteFields(params: unknown): {884 private readRouteFields(params: unknown): {
868 threadId?: string;885 threadId?: ProviderThreadId;
869 turnId?: string;886 turnId?: ProviderTurnId;
870 itemId?: string;887 itemId?: ProviderItemId;
871 } {888 } {
872 const route: {889 const route: {
873 threadId?: string;890 threadId?: ProviderThreadId;
874 turnId?: string;891 turnId?: ProviderTurnId;
875 itemId?: string;892 itemId?: ProviderItemId;
876 } = {};893 } = {};
877894
878 const threadId =895 const threadId = toProviderThreadId(
879 this.readString(params, "threadId") ??896 this.readString(params, "threadId") ??
880 this.readString(this.readObject(params, "thread"), "id");897 this.readString(this.readObject(params, "thread"), "id"),
881 const turnId =898 );
882 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id");899 const turnId = toProviderTurnId(
883 const itemId =900 this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"),
884 this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id");901 );
902 const itemId = toProviderItemId(
903 this.readString(params, "itemId") ??
904 this.readString(this.readObject(params, "item"), "id"),
905 );
885906
886 if (threadId) {907 if (threadId) {
887 route.threadId = threadId;908 route.threadId = threadId;
...@@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
941 return typeof candidate === "boolean" ? candidate : undefined;962 return typeof candidate === "boolean" ? candidate : undefined;
942 }963 }
943}964}
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";...@@ -3,10 +3,10 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import {5import {
6 gitPullInputSchema,6 GitPullInput,
7 gitPullResultSchema,7 GitPullResult,
8 gitStatusInputSchema,8 GitStatusInput,
9 gitStatusResultSchema,9 GitStatusResult,
10 type GitCheckoutInput,10 type GitCheckoutInput,
11 type GitCreateBranchInput,11 type GitCreateBranchInput,
12 type GitCreateWorktreeInput,12 type GitCreateWorktreeInput,
...@@ -14,11 +14,7 @@ import {...@@ -14,11 +14,7 @@ import {
14 type GitInitInput,14 type GitInitInput,
15 type GitListBranchesInput,15 type GitListBranchesInput,
16 type GitListBranchesResult,16 type GitListBranchesResult,
17 type GitPullInput,
18 type GitPullResult,
19 type GitRemoveWorktreeInput,17 type GitRemoveWorktreeInput,
20 type GitStatusInput,
21 type GitStatusResult,
22} from "@t3tools/contracts";18} from "@t3tools/contracts";
23import { type ProcessRunResult, runProcess } from "./processRunner";19import { type ProcessRunResult, runProcess } from "./processRunner";
2420
...@@ -29,8 +25,6 @@ export interface TerminalCommandInput {...@@ -29,8 +25,6 @@ export interface TerminalCommandInput {
29 maxOutputBytes?: number;25 maxOutputBytes?: number;
30}26}
3127
32
33
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {28export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
35 upstreamRef: string | null;29 upstreamRef: string | null;
36}30}
...@@ -220,10 +214,9 @@ async function executeGit(...@@ -220,10 +214,9 @@ async function executeGit(
220export class GitCoreService {214export class GitCoreService {
221 private readonly lastUpstreamRefreshAt = new Map<string, number>();215 private readonly lastUpstreamRefreshAt = new Map<string, number>();
222216
223 async status(raw: GitStatusInput): Promise<GitStatusResult> {217 async status(input: GitStatusInput): Promise<GitStatusResult> {
224 const input = gitStatusInputSchema.parse(raw);
225 const details = await this.statusDetails(input.cwd);218 const details = await this.statusDetails(input.cwd);
226 return gitStatusResultSchema.parse({219 return GitStatusResult.makeUnsafe({
227 branch: details.branch,220 branch: details.branch,
228 hasWorkingTreeChanges: details.hasWorkingTreeChanges,221 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
229 workingTree: details.workingTree,222 workingTree: details.workingTree,
...@@ -392,10 +385,10 @@ export class GitCoreService {...@@ -392,10 +385,10 @@ export class GitCoreService {
392 });385 });
393 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));386 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
394 const refreshed = await this.statusDetails(cwd);387 const refreshed = await this.statusDetails(cwd);
395 return gitPullResultSchema.parse({388 return GitPullResult.makeUnsafe({
396 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",389 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
397 branch,390 branch,
398 ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}),391 upstreamBranch: refreshed.upstreamRef,
399 });392 });
400 }393 }
401394
...@@ -678,9 +671,7 @@ export class GitCoreService {...@@ -678,9 +671,7 @@ export class GitCoreService {
678671
679const defaultGitCoreService = new GitCoreService();672const defaultGitCoreService = new GitCoreService();
680673
681export async function runTerminalCommand(674export async function runTerminalCommand(input: TerminalCommandInput): Promise<ProcessRunResult> {
682 input: TerminalCommandInput,
683): Promise<ProcessRunResult> {
684 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;675 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
685 const shellPath =676 const shellPath =
686 process.platform === "win32"677 process.platform === "win32"
...@@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
712 return defaultGitCoreService.listBranches(input);703 return defaultGitCoreService.listBranches(input);
713}704}
714705
715export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> {706export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> {
716 const input = gitPullInputSchema.parse(raw);
717 return defaultGitCoreService.pullCurrentBranch(input.cwd);707 return defaultGitCoreService.pullCurrentBranch(input.cwd);
718}708}
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";...@@ -4,13 +4,10 @@ import os from "node:os";
4import path from "node:path";4import path from "node:path";
55
6import {6import {
7 gitRunStackedActionInputSchema,7 GitRunStackedActionInput,
8 gitRunStackedActionResultSchema,8 GitRunStackedActionResult,
9 gitStatusInputSchema,9 GitStatusInput,
10 type GitRunStackedActionInput,10 GitStatusResult,
11 type GitRunStackedActionResult,
12 type GitStatusInput,
13 type GitStatusResult,
14} from "@t3tools/contracts";11} from "@t3tools/contracts";
1512
16import type {13import type {
...@@ -187,8 +184,7 @@ export class GitManager {...@@ -187,8 +184,7 @@ export class GitManager {
187 this.gitCore = deps.gitCore ?? new GitCoreService();184 this.gitCore = deps.gitCore ?? new GitCoreService();
188 }185 }
189186
190 async status(raw: GitStatusInput): Promise<GitStatusResult> {187 async status(input: GitStatusInput): Promise<GitStatusResult> {
191 const input = gitStatusInputSchema.parse(raw);
192 const details = await this.gitCore.statusDetails(input.cwd);188 const details = await this.gitCore.statusDetails(input.cwd);
193189
194 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;190 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
...@@ -214,8 +210,7 @@ export class GitManager {...@@ -214,8 +210,7 @@ export class GitManager {
214 };210 };
215 }211 }
216212
217 async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> {213 async runStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> {
218 const input = gitRunStackedActionInputSchema.parse(raw);
219 const wantsPush = input.action !== "commit";214 const wantsPush = input.action !== "commit";
220 const wantsPr = input.action === "commit_push_pr";215 const wantsPr = input.action === "commit_push_pr";
221216
...@@ -237,12 +232,12 @@ export class GitManager {...@@ -237,12 +232,12 @@ export class GitManager {
237 ? await this.runPrStep(input.cwd, initialStatus.branch)232 ? await this.runPrStep(input.cwd, initialStatus.branch)
238 : { status: "skipped_not_requested" as const };233 : { status: "skipped_not_requested" as const };
239234
240 return gitRunStackedActionResultSchema.parse({235 return {
241 action: input.action,236 action: input.action,
242 commit,237 commit,
243 push,238 push,
244 pr,239 pr,
245 });240 };
246 }241 }
247242
248 private async runCommitStep(243 private async runCommitStep(
apps/server/src/keybindings.ts+11-15
...@@ -2,7 +2,7 @@ import fs from "node:fs";...@@ -2,7 +2,7 @@ import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type {5import {
6 KeybindingRule,6 KeybindingRule,
7 KeybindingsConfig,7 KeybindingsConfig,
8 KeybindingShortcut,8 KeybindingShortcut,
...@@ -10,7 +10,8 @@ import type {...@@ -10,7 +10,8 @@ import type {
10 ResolvedKeybindingRule,10 ResolvedKeybindingRule,
11 ResolvedKeybindingsConfig,11 ResolvedKeybindingsConfig,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";13import { Mutable } from "effect/Types";
14import { Schema } from "effect";
1415
15interface KeybindingsLogger {16interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;17 warn(message: string, context?: Record<string, unknown>): void;
...@@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey...@@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey
267export function compileResolvedKeybindingsConfig(268export function compileResolvedKeybindingsConfig(
268 config: KeybindingsConfig,269 config: KeybindingsConfig,
269): ResolvedKeybindingsConfig {270): ResolvedKeybindingsConfig {
270 const compiled: ResolvedKeybindingsConfig = [];271 const compiled: Mutable<ResolvedKeybindingsConfig> = [];
271 for (const rule of config) {272 for (const rule of config) {
272 const resolved = compileResolvedKeybindingRule(rule);273 const resolved = compileResolvedKeybindingRule(rule);
273 if (!resolved) continue;274 if (!resolved) continue;
...@@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig(...@@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig(
277}278}
278279
279function compileDefaultKeybindings(): ResolvedKeybindingsConfig {280function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
280 const resolved: ResolvedKeybindingsConfig = [];281 const resolved: Mutable<ResolvedKeybindingsConfig> = [];
281 for (const rule of DEFAULT_KEYBINDINGS) {282 for (const rule of DEFAULT_KEYBINDINGS) {
282 const compiled = compileResolvedKeybindingRule(rule);283 const compiled = compileResolvedKeybindingRule(rule);
283 if (!compiled) {284 if (!compiled) {
...@@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig(...@@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig(
322 return [];323 return [];
323 }324 }
324325
325 const sanitized: KeybindingsConfig = [];326 const sanitized: Mutable<KeybindingsConfig> = [];
326 let invalidEntries = 0;327 let invalidEntries = 0;
327 for (const entry of parsed) {328 for (const entry of parsed) {
328 const result = keybindingRuleSchema.safeParse(entry);329 const result = Schema.decodeUnknownExit(KeybindingRule)(entry);
329 if (result.success) {330 if (result._tag === "Success") {
330 if (!compileResolvedKeybindingRule(result.data)) {331 if (!compileResolvedKeybindingRule(result.value)) {
331 invalidEntries += 1;332 invalidEntries += 1;
332 continue;333 continue;
333 }334 }
334 sanitized.push(result.data);335 sanitized.push(result.value);
335 continue;336 continue;
336 }337 }
337 invalidEntries += 1;338 invalidEntries += 1;
...@@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig(...@@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig(
417418
418export function upsertKeybindingRule(419export function upsertKeybindingRule(
419 logger: KeybindingsLogger,420 logger: KeybindingsLogger,
420 rawRule: unknown,421 rule: KeybindingRule,
421): ResolvedKeybindingsConfig {422): ResolvedKeybindingsConfig {
422 const rule = keybindingRuleSchema.parse(rawRule);
423 if (!compileResolvedKeybindingRule(rule)) {
424 throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);
425 }
426
427 const configPath = resolveKeybindingsConfigPath();423 const configPath = resolveKeybindingsConfigPath();
428 const customConfig = loadCustomKeybindingsConfig(logger, {424 const customConfig = loadCustomKeybindingsConfig(logger, {
429 throwOnUnreadableConfig: true,425 throwOnUnreadableConfig: true,
apps/server/src/orchestration/Errors.ts+21-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { SchemaIssue, Schema } from "effect";1import { 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
5export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()(9export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()(
6 "OrchestrationCommandJsonParseError",10 "OrchestrationCommandJsonParseError",
...@@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<...@@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<
39 }43 }
40}44}
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
42export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()(59export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()(
43 "OrchestrationProjectorDecodeError",60 "OrchestrationProjectorDecodeError",
44 {61 {
...@@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<...@@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<
6784
68export type OrchestrationDispatchError =85export type OrchestrationDispatchError =
69 | OrchestrationEventStoreError86 | OrchestrationEventStoreError
87 | OrchestrationCommandReceiptRepositoryError
88 | ProjectionRepositoryError
70 | OrchestrationCommandInvariantError89 | OrchestrationCommandInvariantError
90 | OrchestrationCommandPreviouslyRejectedError
71 | OrchestrationProjectorDecodeError91 | OrchestrationProjectorDecodeError
72 | OrchestrationListenerCallbackError;92 | OrchestrationListenerCallbackError;
7393
apps/server/src/orchestration/Layer.test.ts+155-203
...@@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts";...@@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";2import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
3import { describe, expect, it } from "vitest";3import { 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";
5import {9import {
6 OrchestrationEventStore,10 OrchestrationEventStore,
7 type OrchestrationEventStoreShape,11 type OrchestrationEventStoreShape,
8} from "../persistence/Services/OrchestrationEventStore.ts";12} from "../persistence/Services/OrchestrationEventStore.ts";
9import { PersistenceSqlError } from "../persistence/Errors.ts";
10import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
11import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";13import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
14import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
12import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";15import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
13import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts";
1416
15export async function createOrchestrationSystem() {17async function createOrchestrationSystem() {
16 const orchestrationLayer = OrchestrationEngineLive.pipe(18 const orchestrationLayer = OrchestrationEngineLive.pipe(
19 Layer.provide(OrchestrationProjectionPipelineLive),
17 Layer.provide(OrchestrationEventStoreLive),20 Layer.provide(OrchestrationEventStoreLive),
21 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
18 Layer.provide(SqlitePersistenceMemory),22 Layer.provide(SqlitePersistenceMemory),
19 );23 );
20 const runtime = ManagedRuntime.make(orchestrationLayer);24 const runtime = ManagedRuntime.make(orchestrationLayer);
...@@ -26,20 +30,33 @@ export async function createOrchestrationSystem() {...@@ -26,20 +30,33 @@ export async function createOrchestrationSystem() {
26 };30 };
27}31}
2832
33function now() {
34 return new Date().toISOString();
35}
36
29describe("OrchestrationEngine", () => {37describe("OrchestrationEngine", () => {
30 it("returns deterministic read models for repeated reads", async () => {38 it("returns deterministic read models for repeated reads", async () => {
31 const createdAt = new Date().toISOString();39 const createdAt = now();
32 const projectId = "project-1";
33 const threadId = "thread-1";
34
35 const system = await createOrchestrationSystem();40 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 );
37 await system.run(54 await system.run(
38 engine.dispatch({55 engine.dispatch({
39 type: "thread.create",56 type: "thread.create",
40 commandId: "cmd-1",57 commandId: "cmd-thread-1-create",
41 threadId,58 threadId: "thread-1",
42 projectId,59 projectId: "project-1",
43 title: "Thread",60 title: "Thread",
44 model: "gpt-5-codex",61 model: "gpt-5-codex",
45 branch: null,62 branch: null,
...@@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => {...@@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => {
49 );66 );
50 await system.run(67 await system.run(
51 engine.dispatch({68 engine.dispatch({
52 type: "message.send",69 type: "thread.turn.start",
53 commandId: "cmd-3",70 commandId: "cmd-turn-start-1",
54 threadId,71 threadId: "thread-1",
55 messageId: "msg-1",72 message: {
56 role: "user",73 messageId: "msg-1",
57 text: "hello",74 role: "user",
58 streaming: false,75 text: "hello",
76 attachments: [],
77 },
59 createdAt,78 createdAt,
60 }),79 }),
61 );80 );
81
62 const readModelA = await system.run(engine.getReadModel());82 const readModelA = await system.run(engine.getReadModel());
63 const readModelB = await system.run(engine.getReadModel());83 const readModelB = await system.run(engine.getReadModel());
64 expect(readModelB).toEqual(readModelA);84 expect(readModelB).toEqual(readModelA);
...@@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => {...@@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => {
6787
68 it("replays append-only events from sequence", async () => {88 it("replays append-only events from sequence", async () => {
69 const system = await createOrchestrationSystem();89 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 );
71 await system.run(104 await system.run(
72 engine.dispatch({105 engine.dispatch({
73 type: "thread.create",106 type: "thread.create",
74 commandId: "cmd-a",107 commandId: "cmd-thread-replay-create",
75 threadId: "thread-replay",108 threadId: "thread-replay",
76 projectId: "project-replay",109 projectId: "project-replay",
77 title: "replay",110 title: "replay",
78 model: "gpt-5-codex",111 model: "gpt-5-codex",
79 branch: null,112 branch: null,
80 worktreePath: null,113 worktreePath: null,
81 createdAt: new Date().toISOString(),114 createdAt,
82 }),115 }),
83 );116 );
84 await system.run(117 await system.run(
85 engine.dispatch({118 engine.dispatch({
86 type: "thread.delete",119 type: "thread.delete",
87 commandId: "cmd-b",120 commandId: "cmd-thread-replay-delete",
88 threadId: "thread-replay",121 threadId: "thread-replay",
89 createdAt: new Date().toISOString(),
90 }),122 }),
91 );123 );
124
92 const events = await system.run(125 const events = await system.run(
93 Stream.runCollect(engine.readEvents(0)).pipe(126 Stream.runCollect(engine.readEvents(0)).pipe(
94 Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)),127 Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)),
95 ),128 ),
96 );129 );
97 expect(events.length).toBe(2);130 expect(events.map((event) => event.type)).toEqual([
98 expect(events[0]?.type).toBe("thread.created");131 "project.created",
99 expect(events[1]?.type).toBe("thread.deleted");132 "thread.created",
133 "thread.deleted",
134 ]);
100 await system.dispose();135 await system.dispose();
101 });136 });
102137
103 it("streams persisted domain events in order", async () => {138 it("streams persisted domain events in order", async () => {
104 const system = await createOrchestrationSystem();139 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
106 const eventTypes: string[] = [];155 const eventTypes: string[] = [];
107 await system.run(156 await system.run(
108 Effect.gen(function* () {157 Effect.gen(function* () {
...@@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => {...@@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => {
113 ),162 ),
114 );163 );
115 yield* Effect.sleep("10 millis");164 yield* Effect.sleep("10 millis");
116 const createdAt = new Date().toISOString();
117 yield* engine.dispatch({165 yield* engine.dispatch({
118 type: "thread.create",166 type: "thread.create",
119 commandId: "cmd-stream-domain-a",167 commandId: "cmd-stream-thread-create",
120 threadId: "thread-stream-domain",168 threadId: "thread-stream",
121 projectId: "project-stream-domain",169 projectId: "project-stream",
122 title: "domain-stream",170 title: "domain-stream",
123 model: "gpt-5-codex",171 model: "gpt-5-codex",
124 branch: null,172 branch: null,
...@@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => {...@@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => {
127 });175 });
128 yield* engine.dispatch({176 yield* engine.dispatch({
129 type: "thread.meta.update",177 type: "thread.meta.update",
130 commandId: "cmd-stream-domain-b",178 commandId: "cmd-stream-thread-update",
131 threadId: "thread-stream-domain",179 threadId: "thread-stream",
132 title: "domain-stream-updated",180 title: "domain-stream-updated",
133 createdAt,
134 });181 });
135 eventTypes.push((yield* Queue.take(eventQueue)).type);182 eventTypes.push((yield* Queue.take(eventQueue)).type);
136 eventTypes.push((yield* Queue.take(eventQueue)).type);183 eventTypes.push((yield* Queue.take(eventQueue)).type);
137 }).pipe(Effect.scoped),184 }).pipe(Effect.scoped),
138 );185 );
186
139 expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]);187 expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]);
140 await system.dispose();188 await system.dispose();
141 });189 });
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 () => {
144 const system = await createOrchestrationSystem();192 const system = await createOrchestrationSystem();
145 const engine = system.engine;193 const { engine } = system;
146 const completedAt = new Date().toISOString();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 );
147 await system.run(207 await system.run(
148 engine.dispatch({208 engine.dispatch({
149 type: "thread.create",209 type: "thread.create",
150 commandId: "cmd-thread-turn-diff",210 commandId: "cmd-thread-turn-diff-create",
151 threadId: "thread-turn-diff",211 threadId: "thread-turn-diff",
152 projectId: "project-turn-diff",212 projectId: "project-turn-diff",
153 title: "Turn diff thread",213 title: "Turn diff thread",
154 model: "gpt-5-codex",214 model: "gpt-5-codex",
155 branch: null,215 branch: null,
156 worktreePath: null,216 worktreePath: null,
157 createdAt: completedAt,217 createdAt,
158 }),218 }),
159 );219 );
160 await system.run(220 await system.run(
161 engine.dispatch({221 engine.dispatch({
162 type: "thread.turnDiff.complete",222 type: "thread.turn.diff.complete",
163 commandId: "cmd-turn-diff-complete",223 commandId: "cmd-turn-diff-complete",
164 threadId: "thread-turn-diff",224 threadId: "thread-turn-diff",
165 turnId: "turn-1",225 turnId: "turn-1",
166 completedAt,226 completedAt: createdAt,
167 status: "completed",227 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
228 status: "ready",
168 files: [],229 files: [],
169 checkpointTurnCount: 1,230 checkpointTurnCount: 1,
170 createdAt: completedAt,231 createdAt,
171 }),232 }),
172 );233 );
173234
174 const firstThread = (await system.run(engine.getReadModel())).threads.find(235 const thread = (await system.run(engine.getReadModel())).threads.find(
175 (thread) => thread.id === "thread-turn-diff",236 (entry) => entry.id === "thread-turn-diff",
176 );237 );
177 expect(firstThread?.turnDiffSummaries).toEqual([238 expect(thread?.checkpoints).toEqual([
178 {239 {
179 turnId: "turn-1",240 turnId: "turn-1",
180 completedAt,
181 status: "completed",
182 files: [],
183 checkpointTurnCount: 1,241 checkpointTurnCount: 1,
242 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
243 status: "ready",
244 files: [],
245 assistantMessageId: null,
246 completedAt: createdAt,
184 },247 },
185 ]);248 ]);
186 await system.dispose();249 await system.dispose();
187 });250 });
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
327 it("keeps processing queued commands after a storage failure", async () => {252 it("keeps processing queued commands after a storage failure", async () => {
328 const events: OrchestrationEvent[] = [];253 const events: OrchestrationEvent[] = [];
329 let nextSequence = 1;254 let nextSequence = 1;
...@@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => {...@@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => {
331256
332 const flakyStore: OrchestrationEventStoreShape = {257 const flakyStore: OrchestrationEventStoreShape = {
333 append(event) {258 append(event) {
334 if (shouldFailFirstAppend) {259 if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") {
335 shouldFailFirstAppend = false;260 shouldFailFirstAppend = false;
336 return Effect.fail(261 return Effect.fail(
337 new PersistenceSqlError({262 new PersistenceSqlError({
...@@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => {...@@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => {
358283
359 const runtime = ManagedRuntime.make(284 const runtime = ManagedRuntime.make(
360 OrchestrationEngineLive.pipe(285 OrchestrationEngineLive.pipe(
286 Layer.provide(OrchestrationProjectionPipelineLive),
361 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),287 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
288 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
289 Layer.provide(SqlitePersistenceMemory),
362 ),290 ),
363 );291 );
364 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));292 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
367 await expect(307 await expect(
368 runtime.runPromise(308 runtime.runPromise(
...@@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => {...@@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => {
394 }),334 }),
395 );335 );
396336
397 expect(result.sequence).toBe(1);337 expect(result.sequence).toBe(2);
398 expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1);338 expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);
399
400 await runtime.dispose();339 await runtime.dispose();
401 });340 });
402341
403 it("fails command dispatch when command invariants are violated", async () => {342 it("fails command dispatch when command invariants are violated", async () => {
404 const system = await createOrchestrationSystem();343 const system = await createOrchestrationSystem();
405 const engine = system.engine;344 const { engine } = system;
406 const createdAt = new Date().toISOString();
407345
408 await expect(346 await expect(
409 system.run(347 system.run(
410 engine.dispatch({348 engine.dispatch({
411 type: "message.send",349 type: "thread.turn.start",
412 commandId: "cmd-invariant-missing-thread",350 commandId: "cmd-invariant-missing-thread",
413 threadId: "thread-missing",351 threadId: "thread-missing",
414 messageId: "msg-missing",352 message: {
415 role: "user",353 messageId: "msg-missing",
416 text: "hello",354 role: "user",
417 streaming: false,355 text: "hello",
418 createdAt,356 attachments: [],
357 },
358 createdAt: now(),
419 }),359 }),
420 ),360 ),
421 ).rejects.toThrow("Thread 'thread-missing' does not exist");361 ).rejects.toThrow("Thread 'thread-missing' does not exist");
...@@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => {...@@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => {
425365
426 it("rejects duplicate thread creation", async () => {366 it("rejects duplicate thread creation", async () => {
427 const system = await createOrchestrationSystem();367 const system = await createOrchestrationSystem();
428 const engine = system.engine;368 const { engine } = system;
429 const createdAt = new Date().toISOString();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
431 await system.run(383 await system.run(
432 engine.dispatch({384 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 @@...@@ -1,20 +1,24 @@
1import type {1import type {
2 OrchestrationCommand,
3 OrchestrationEvent,2 OrchestrationEvent,
4 OrchestrationReadModel,3 OrchestrationReadModel,
4 ProjectId,
5 ThreadId,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { OrchestrationCommandSchema } from "@t3tools/contracts";7import { OrchestrationCommand } from "@t3tools/contracts";
7import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect";8import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect";
89
9import { createLogger } from "../../logger.ts";
10import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";10import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
11import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
11import {12import {
12 toOrchestrationCommandDecodeError,13 toOrchestrationCommandDecodeError,
13 toOrchestrationJsonParseError,14 toOrchestrationJsonParseError,
15 OrchestrationCommandInvariantError,
16 OrchestrationCommandPreviouslyRejectedError,
14 type OrchestrationDispatchError,17 type OrchestrationDispatchError,
15} from "../Errors.ts";18} from "../Errors.ts";
16import { decideOrchestrationCommand } from "../decider.ts";19import { decideOrchestrationCommand } from "../decider.ts";
17import { createEmptyReadModel, projectEvent } from "../projector.ts";20import { createEmptyReadModel, projectEvent } from "../projector.ts";
21import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
18import {22import {
19 OrchestrationEngineService,23 OrchestrationEngineService,
20 type OrchestrationEngineShape,24 type OrchestrationEngineShape,
...@@ -25,11 +29,39 @@ interface CommandEnvelope {...@@ -25,11 +29,39 @@ interface CommandEnvelope {
25 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;29 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
26}30}
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
30const makeOrchestrationEngine = Effect.gen(function* () {61const makeOrchestrationEngine = Effect.gen(function* () {
31 const logger = createLogger("orchestration");
32 const eventStore = yield* OrchestrationEventStore;62 const eventStore = yield* OrchestrationEventStore;
63 const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository;
64 const projectionPipeline = yield* OrchestrationProjectionPipeline;
3365
34 let readModel = createEmptyReadModel(new Date().toISOString());66 let readModel = createEmptyReadModel(new Date().toISOString());
3567
...@@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () {
3870
39 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>71 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>
40 Effect.gen(function* () {72 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
41 const eventBase = yield* decideOrchestrationCommand({93 const eventBase = yield* decideOrchestrationCommand({
42 command: envelope.command,94 command: envelope.command,
43 readModel,95 readModel,
44 });96 });
45 const savedEvent = yield* eventStore.append(eventBase);97 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
46 readModel = yield* projectEvent(readModel, savedEvent);98 let lastSavedEvent = null as OrchestrationEvent | null;
47 yield* PubSub.publish(eventPubSub, savedEvent);99
48100 for (const nextEvent of eventBases) {
49 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });101 const savedEvent = yield* eventStore.append(nextEvent);
50 }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)));102 yield* projectionPipeline.projectEvent(savedEvent);
51103 readModel = yield* projectEvent(readModel, savedEvent);
52 // bootstrap read model from event store104 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
53 yield* Stream.runForEach(eventStore.readAll(), (event) =>153 yield* Stream.runForEach(eventStore.readAll(), (event) =>
54 Effect.gen(function* () {154 Effect.gen(function* () {
55 readModel = yield* projectEvent(readModel, event);155 readModel = yield* projectEvent(readModel, event);
...@@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () {
58158
59 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));159 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
60 yield* Effect.forkScoped(worker);160 yield* Effect.forkScoped(worker);
61 logger.info("orchestration engine started", {161 yield* Effect.log("orchestration engine started").pipe(
62 sequence: readModel.sequence,162 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
63 });163 );
64164
65 const getReadModel: OrchestrationEngineShape["getReadModel"] = () =>165 const getReadModel: OrchestrationEngineShape["getReadModel"] = () =>
66 Effect.sync((): OrchestrationReadModel => readModel);166 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 @@...@@ -1,14 +1,26 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import 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";
2import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";12import { 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
5import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";15import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
16import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
6import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";17import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7import {18import {
8 ProviderService,19 ProviderService,
9 type ProviderServiceShape,20 type ProviderServiceShape,
10} from "../../provider/Services/ProviderService.ts";21} from "../../provider/Services/ProviderService.ts";
11import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";22import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
23import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
12import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";24import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
13import {25import {
14 OrchestrationEngineService,26 OrchestrationEngineService,
...@@ -16,17 +28,16 @@ import {...@@ -16,17 +28,16 @@ import {
16} from "../Services/OrchestrationEngine.ts";28} from "../Services/OrchestrationEngine.ts";
17import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";29import { 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
19function createProviderServiceHarness() {39function createProviderServiceHarness() {
20 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());40 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
31 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;42 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
32 const service: ProviderServiceShape = {43 const service: ProviderServiceShape = {
...@@ -36,9 +47,7 @@ function createProviderServiceHarness() {...@@ -36,9 +47,7 @@ function createProviderServiceHarness() {
36 respondToRequest: () => unsupported(),47 respondToRequest: () => unsupported(),
37 stopSession: () => unsupported(),48 stopSession: () => unsupported(),
38 listSessions: () => Effect.succeed([]),49 listSessions: () => Effect.succeed([]),
39 listCheckpoints: () => unsupported(),50 rollbackConversation: () => unsupported(),
40 getCheckpointDiff,
41 revertToCheckpoint: () => unsupported(),
42 stopAll: () => Effect.void,51 stopAll: () => Effect.void,
43 streamEvents: Stream.fromPubSub(runtimeEventPubSub),52 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
44 };53 };
...@@ -50,7 +59,6 @@ function createProviderServiceHarness() {...@@ -50,7 +59,6 @@ function createProviderServiceHarness() {
50 return {59 return {
51 service,60 service,
52 emit,61 emit,
53 getCheckpointDiff,
54 };62 };
55}63}
5664
...@@ -59,6 +67,7 @@ async function waitForThread(...@@ -59,6 +67,7 @@ async function waitForThread(
59 predicate: (thread: {67 predicate: (thread: {
60 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;68 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;
61 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;69 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;
70 activities: ReadonlyArray<{ kind: string }>;
62 }) => boolean,71 }) => boolean,
63 timeoutMs = 2000,72 timeoutMs = 2000,
64) {73) {
...@@ -66,9 +75,10 @@ async function waitForThread(...@@ -66,9 +75,10 @@ async function waitForThread(
66 const poll = async (): Promise<{75 const poll = async (): Promise<{
67 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;76 session: { status: string; activeTurnId: string | null; lastError: string | null } | null;
68 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;77 messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>;
78 activities: ReadonlyArray<{ kind: string }>;
69 }> => {79 }> => {
70 const readModel = await Effect.runPromise(engine.getReadModel());80 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"));
72 if (thread && predicate(thread)) {82 if (thread && predicate(thread)) {
73 return thread;83 return thread;
74 }84 }
...@@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => {
102 async function createHarness() {112 async function createHarness() {
103 const provider = createProviderServiceHarness();113 const provider = createProviderServiceHarness();
104 const orchestrationLayer = OrchestrationEngineLive.pipe(114 const orchestrationLayer = OrchestrationEngineLive.pipe(
115 Layer.provide(OrchestrationProjectionPipelineLive),
105 Layer.provide(OrchestrationEventStoreLive),116 Layer.provide(OrchestrationEventStoreLive),
117 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
106 Layer.provide(SqlitePersistenceMemory),118 Layer.provide(SqlitePersistenceMemory),
107 );119 );
108 const layer = ProviderRuntimeIngestionLive.pipe(120 const layer = ProviderRuntimeIngestionLive.pipe(
...@@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => {...@@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => {
117 await Effect.runPromise(Effect.sleep("10 millis"));129 await Effect.runPromise(Effect.sleep("10 millis"));
118130
119 const createdAt = new Date().toISOString();131 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 );
120 await Effect.runPromise(143 await Effect.runPromise(
121 engine.dispatch({144 engine.dispatch({
122 type: "thread.create",145 type: "thread.create",
123 commandId: "cmd-thread-create",146 commandId: asCommandId("cmd-thread-create"),
124 threadId: "thread-1",147 threadId: asThreadId("thread-1"),
125 projectId: "project-1",148 projectId: asProjectId("project-1"),
126 title: "Thread",149 title: "Thread",
127 model: "gpt-5-codex",150 model: "gpt-5-codex",
128 branch: null,151 branch: null,
...@@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => {...@@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => {
132 );155 );
133 await Effect.runPromise(156 await Effect.runPromise(
134 engine.dispatch({157 engine.dispatch({
135 type: "thread.session",158 type: "thread.session.set",
136 commandId: "cmd-session-seed",159 commandId: asCommandId("cmd-session-seed"),
137 threadId: "thread-1",160 threadId: asThreadId("thread-1"),
138 session: {161 session: {
139 sessionId: "sess-1",162 threadId: asThreadId("thread-1"),
140 provider: "codex",
141 status: "ready",163 status: "ready",
142 threadId: "thread-1",164 providerName: "codex",
165 providerSessionId: asSessionId("sess-1"),
166 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
143 activeTurnId: null,167 activeTurnId: null,
144 createdAt,
145 updatedAt: createdAt,168 updatedAt: createdAt,
146 lastError: null,169 lastError: null,
147 },170 },
...@@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => {
161184
162 harness.emit({185 harness.emit({
163 type: "turn.started",186 type: "turn.started",
164 eventId: "evt-turn-started",187 eventId: asEventId("evt-turn-started"),
165 provider: "codex",188 provider: "codex",
166 sessionId: "sess-1",189 sessionId: asSessionId("sess-1"),
167 createdAt: now,190 createdAt: now,
168 turnId: "turn-1",191 turnId: asProviderTurnId("turn-1"),
169 });192 });
170193
171 await waitForThread(194 await waitForThread(
...@@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => {
175198
176 harness.emit({199 harness.emit({
177 type: "turn.completed",200 type: "turn.completed",
178 eventId: "evt-turn-completed",201 eventId: asEventId("evt-turn-completed"),
179 provider: "codex",202 provider: "codex",
180 sessionId: "sess-1",203 sessionId: asSessionId("sess-1"),
181 createdAt: new Date().toISOString(),204 createdAt: new Date().toISOString(),
182 turnId: "turn-1",205 turnId: asProviderTurnId("turn-1"),
183 status: "failed",206 status: "failed",
184 errorMessage: "turn failed",207 errorMessage: "turn failed",
185 });208 });
...@@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => {
187 const thread = await waitForThread(210 const thread = await waitForThread(
188 harness.engine,211 harness.engine,
189 (entry) =>212 (entry) =>
190 entry.session?.status === "ready" &&213 entry.session?.status === "error" &&
191 entry.session?.activeTurnId === null &&214 entry.session?.activeTurnId === null &&
192 entry.session?.lastError === "turn failed",215 entry.session?.lastError === "turn failed",
193 );216 );
194 expect(thread.session?.status).toBe("ready");217 expect(thread.session?.status).toBe("error");
195 expect(thread.session?.lastError).toBe("turn failed");218 expect(thread.session?.lastError).toBe("turn failed");
196 });219 });
197220
...@@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => {...@@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => {
201224
202 harness.emit({225 harness.emit({
203 type: "message.delta",226 type: "message.delta",
204 eventId: "evt-message-delta-1",227 eventId: asEventId("evt-message-delta-1"),
205 provider: "codex",228 provider: "codex",
206 sessionId: "sess-1",229 sessionId: asSessionId("sess-1"),
207 createdAt: now,230 createdAt: now,
208 turnId: "turn-2",231 turnId: asProviderTurnId("turn-2"),
209 itemId: "item-1",232 itemId: asItemId("item-1"),
210 delta: "hello",233 delta: "hello",
211 });234 });
212 harness.emit({235 harness.emit({
213 type: "message.delta",236 type: "message.delta",
214 eventId: "evt-message-delta-2",237 eventId: asEventId("evt-message-delta-2"),
215 provider: "codex",238 provider: "codex",
216 sessionId: "sess-1",239 sessionId: asSessionId("sess-1"),
217 createdAt: now,240 createdAt: now,
218 turnId: "turn-2",241 turnId: asProviderTurnId("turn-2"),
219 itemId: "item-1",242 itemId: asItemId("item-1"),
220 delta: " world",243 delta: " world",
221 });244 });
222 harness.emit({245 harness.emit({
223 type: "message.completed",246 type: "message.completed",
224 eventId: "evt-message-completed",247 eventId: asEventId("evt-message-completed"),
225 provider: "codex",248 provider: "codex",
226 sessionId: "sess-1",249 sessionId: asSessionId("sess-1"),
227 createdAt: now,250 createdAt: now,
228 turnId: "turn-2",251 turnId: asProviderTurnId("turn-2"),
229 itemId: "item-1",252 itemId: asItemId("item-1"),
230 });253 });
231254
232 const thread = await waitForThread(harness.engine, (entry) =>255 const thread = await waitForThread(harness.engine, (entry) =>
...@@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => {...@@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => {
243266
244 harness.emit({267 harness.emit({
245 type: "runtime.error",268 type: "runtime.error",
246 eventId: "evt-runtime-error",269 eventId: asEventId("evt-runtime-error"),
247 provider: "codex",270 provider: "codex",
248 sessionId: "sess-1",271 sessionId: asSessionId("sess-1"),
249 createdAt: now,272 createdAt: now,
250 turnId: "turn-3",273 turnId: asProviderTurnId("turn-3"),
251 message: "runtime exploded",274 message: "runtime exploded",
252 });275 });
253276
...@@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => {...@@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => {
261 expect(thread.session?.status).toBe("error");284 expect(thread.session?.status).toBe("error");
262 expect(thread.session?.lastError).toBe("runtime exploded");285 expect(thread.session?.lastError).toBe("runtime exploded");
263 });286 });
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 });
264});332});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+168-151
...@@ -1,11 +1,14 @@...@@ -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";
2import { Effect, Layer, Queue, Stream } from "effect";10import { Effect, Layer, Queue, Stream } from "effect";
311
4import {
5 parseTurnDiffFilesFromUnifiedDiff,
6 type TurnDiffFileSummary,
7} from "../../checkpointing/Diffs.ts";
8import { createLogger } from "../../logger.ts";
9import { ProviderService } from "../../provider/Services/ProviderService.ts";12import { ProviderService } from "../../provider/Services/ProviderService.ts";
10import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";13import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
11import {14import {
...@@ -13,87 +16,108 @@ import {...@@ -13,87 +16,108 @@ import {
13 type ProviderRuntimeIngestionShape,16 type ProviderRuntimeIngestionShape,
14} from "../Services/ProviderRuntimeIngestion.ts";17} from "../Services/ProviderRuntimeIngestion.ts";
1518
16const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`;19const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`;
1720const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
18type ActivityTone = "thinking" | "tool" | "info" | "error";21 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
1922
20interface ThreadActivityInput {23function toTurnId(value: string | undefined): TurnId | undefined {
21 readonly id: string;24 const normalized = value?.trim();
22 readonly createdAt: string;25 return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined;
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";
29}26}
3027
31function truncateDetail(value: string, limit = 180): string {28function truncateDetail(value: string, limit = 180): string {
32 return value.length > limit ? `${value.slice(0, limit - 1)}...` : value;29 return value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
33}30}
3431
35function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<ThreadActivityInput> {32function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<OrchestrationThreadActivity> {
36 switch (event.type) {33 switch (event.type) {
37 case "approval.requested":34 case "approval.requested": {
38 return [35 return [
39 {36 {
40 id: event.eventId,37 id: event.eventId,
41 createdAt: event.createdAt,38 createdAt: event.createdAt,
42 label:39 tone: "approval",
40 kind: "approval.requested",
41 summary:
43 event.requestKind === "command"42 event.requestKind === "command"
44 ? "Command approval requested"43 ? "Command approval requested"
45 : "File-change approval requested",44 : "File-change approval requested",
46 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),45 payload: {
47 tone: "tool",46 requestId: event.requestId,
48 ...(event.turnId ? { turnId: event.turnId } : {}),47 requestKind: event.requestKind,
49 requestId: event.requestId,48 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
50 requestKind: event.requestKind,49 },
50 turnId: toTurnId(event.turnId) ?? null,
51 },51 },
52 ];52 ];
53 case "approval.resolved":53 }
54
55 case "approval.resolved": {
54 return [56 return [
55 {57 {
56 id: event.eventId,58 id: event.eventId,
57 createdAt: event.createdAt,59 createdAt: event.createdAt,
58 label: "Approval resolved",60 tone: "approval",
59 tone: "info",61 kind: "approval.resolved",
60 ...(event.turnId ? { turnId: event.turnId } : {}),62 summary: "Approval resolved",
61 requestId: event.requestId,63 payload: {
62 ...(event.requestKind ? { requestKind: event.requestKind } : {}),64 requestId: event.requestId,
65 ...(event.requestKind ? { requestKind: event.requestKind } : {}),
66 ...(event.decision ? { decision: event.decision } : {}),
67 },
68 turnId: toTurnId(event.turnId) ?? null,
63 },69 },
64 ];70 ];
65 case "runtime.error":71 }
72
73 case "runtime.error": {
66 return [74 return [
67 {75 {
68 id: event.eventId,76 id: event.eventId,
69 createdAt: event.createdAt,77 createdAt: event.createdAt,
70 label: "Runtime error",
71 detail: truncateDetail(event.message),
72 tone: "error",78 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,
74 },85 },
75 ];86 ];
76 case "checkpoint.captured":87 }
88 case "tool.completed": {
77 return [89 return [
78 {90 {
79 id: event.eventId,91 id: event.eventId,
80 createdAt: event.createdAt,92 createdAt: event.createdAt,
81 label: "Checkpoint captured",93 tone: "tool",
82 tone: "info",94 kind: "tool.completed",
83 ...(event.turnId ? { turnId: event.turnId } : {}),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,
84 },101 },
85 ];102 ];
86 case "tool.completed":103 }
104 case "tool.started": {
87 return [105 return [
88 {106 {
89 id: event.eventId,107 id: event.eventId,
90 createdAt: event.createdAt,108 createdAt: event.createdAt,
91 label: `${event.title} complete`,
92 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
93 tone: "tool",109 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,
95 },117 },
96 ];118 ];
119 }
120
97 default:121 default:
98 return [];122 return [];
99 }123 }
...@@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th...@@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th
102const make = Effect.gen(function* () {126const make = Effect.gen(function* () {
103 const orchestrationEngine = yield* OrchestrationEngineService;127 const orchestrationEngine = yield* OrchestrationEngineService;
104 const providerService = yield* ProviderService;128 const providerService = yield* ProviderService;
105 const logger = createLogger("provider-runtime-ingestion");
106129
107 const turnMessageIdsByTurnKey = new Map<string, Set<string>>();130 const turnMessageIdsByTurnKey = new Map<string, Set<MessageId>>();
108 const latestMessageIdByTurnKey = new Map<string, string>();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 ) => {
111 const key = providerTurnKey(sessionId, turnId);138 const key = providerTurnKey(sessionId, turnId);
112 const existingIds = turnMessageIdsByTurnKey.get(key);139 const existingIds = turnMessageIdsByTurnKey.get(key);
113 if (existingIds) {140 if (existingIds) {
...@@ -118,19 +145,15 @@ const make = Effect.gen(function* () {...@@ -118,19 +145,15 @@ const make = Effect.gen(function* () {
118 latestMessageIdByTurnKey.set(key, messageId);145 latestMessageIdByTurnKey.set(key, messageId);
119 };146 };
120147
121 const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => {148 const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => {
122 return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<string>();149 return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<MessageId>();
123 };150 };
124151
125 const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => {152 const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => {
126 turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId));153 turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId));
127 };154 };
128155
129 const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => {156 const clearTurnStateForSession = (sessionId: ProviderSessionId) => {
130 return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId));
131 };
132
133 const clearTurnStateForSession = (sessionId: string) => {
134 const prefix = `${sessionId}:`;157 const prefix = `${sessionId}:`;
135 for (const key of turnMessageIdsByTurnKey.keys()) {158 for (const key of turnMessageIdsByTurnKey.keys()) {
136 if (key.startsWith(prefix)) {159 if (key.startsWith(prefix)) {
...@@ -148,144 +171,138 @@ const make = Effect.gen(function* () {...@@ -148,144 +171,138 @@ const make = Effect.gen(function* () {
148 Effect.gen(function* () {171 Effect.gen(function* () {
149 const readModel = yield* orchestrationEngine.getReadModel();172 const readModel = yield* orchestrationEngine.getReadModel();
150 const thread = readModel.threads.find(173 const thread = readModel.threads.find(
151 (entry) => entry.session?.sessionId === event.sessionId,174 (entry) => entry.session?.providerSessionId === event.sessionId,
152 );175 );
153 if (!thread) return;176 if (!thread) return;
154177
155 const now = event.createdAt;178 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
157 yield* orchestrationEngine.dispatch({210 yield* orchestrationEngine.dispatch({
158 type: "thread.session",211 type: "thread.session.set",
159 commandId: crypto.randomUUID(),212 commandId: providerCommandId(event, "thread-session-set"),
160 threadId: thread.id,213 threadId: thread.id,
161 session: {214 session: {
162 sessionId: event.sessionId,
163 provider: event.provider,
164 status: event.type === "turn.started" ? "running" : "ready",
165 threadId: thread.id,215 threadId: thread.id,
166 activeTurnId: event.type === "turn.started" ? event.turnId : null,216 status,
167 createdAt: thread.session?.createdAt ?? now,217 providerName: event.provider,
218 providerSessionId: event.sessionId,
219 providerThreadId,
220 activeTurnId,
221 lastError,
168 updatedAt: now,222 updatedAt: now,
169 lastError:
170 event.type === "turn.completed" && event.status === "failed"
171 ? (event.errorMessage ?? null)
172 : null,
173 },223 },
174 createdAt: now,224 createdAt: now,
175 });225 });
176 }226 }
177227
178 if (event.type === "message.delta" && event.delta.length > 0) {228 if (event.type === "message.delta" && event.delta.length > 0) {
179 const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`;229 const assistantMessageId = MessageId.makeUnsafe(
180 if (event.turnId) {230 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
181 rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId);231 );
232 const turnId = toTurnId(event.turnId);
233 if (turnId) {
234 rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
182 }235 }
236
183 yield* orchestrationEngine.dispatch({237 yield* orchestrationEngine.dispatch({
184 type: "message.send",238 type: "thread.message.assistant.delta",
185 commandId: crypto.randomUUID(),239 commandId: providerCommandId(event, "assistant-delta"),
186 threadId: thread.id,240 threadId: thread.id,
187 messageId: assistantMessageId,241 messageId: assistantMessageId,
188 role: "assistant",242 delta: event.delta,
189 text: event.delta,243 ...(turnId ? { turnId } : {}),
190 streaming: true,
191 createdAt: now,244 createdAt: now,
192 });245 });
193 }246 }
194247
195 if (event.type === "message.completed") {248 if (event.type === "message.completed") {
196 const assistantMessageId = `assistant:${event.itemId}`;249 const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`);
197 if (event.turnId) {250 const turnId = toTurnId(event.turnId);
198 rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId);251 if (turnId) {
252 rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
199 }253 }
254
200 yield* orchestrationEngine.dispatch({255 yield* orchestrationEngine.dispatch({
201 type: "message.send",256 type: "thread.message.assistant.complete",
202 commandId: crypto.randomUUID(),257 commandId: providerCommandId(event, "assistant-complete"),
203 threadId: thread.id,258 threadId: thread.id,
204 messageId: assistantMessageId,259 messageId: assistantMessageId,
205 role: "assistant",260 ...(turnId ? { turnId } : {}),
206 text: "",
207 streaming: false,
208 createdAt: now,261 createdAt: now,
209 });262 });
210 }263 }
211264
212 if (event.type === "turn.completed" && event.turnId) {265 if (event.type === "turn.completed") {
213 const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId);266 const turnId = toTurnId(event.turnId);
214 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>267 if (turnId) {
215 orchestrationEngine.dispatch({268 const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, turnId);
216 type: "message.send",269 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>
217 commandId: crypto.randomUUID(),270 orchestrationEngine.dispatch({
218 threadId: thread.id,271 type: "thread.message.assistant.complete",
219 messageId: assistantMessageId,272 commandId: providerCommandId(event, "assistant-complete-finalize"),
220 role: "assistant",273 threadId: thread.id,
221 text: "",274 messageId: assistantMessageId,
222 streaming: false,275 turnId,
223 createdAt: now,276 createdAt: now,
224 }),277 }),
225 ).pipe(Effect.asVoid);278 ).pipe(Effect.asVoid);
226 clearAssistantMessageIdsForTurn(event.sessionId, event.turnId);279 clearAssistantMessageIdsForTurn(event.sessionId, turnId);
280 }
227 }281 }
228282
229 if (event.type === "session.exited") {283 if (event.type === "session.exited") {
230 clearTurnStateForSession(event.sessionId);284 clearTurnStateForSession(event.sessionId);
231 }285 }
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
275 if (event.type === "runtime.error") {287 if (event.type === "runtime.error") {
288 const providerThreadId =
289 event.threadId !== undefined
290 ? ProviderThreadId.makeUnsafe(event.threadId)
291 : (thread.session?.providerThreadId ?? null);
292
276 yield* orchestrationEngine.dispatch({293 yield* orchestrationEngine.dispatch({
277 type: "thread.session",294 type: "thread.session.set",
278 commandId: crypto.randomUUID(),295 commandId: providerCommandId(event, "runtime-error-session-set"),
279 threadId: thread.id,296 threadId: thread.id,
280 session: {297 session: {
281 sessionId: event.sessionId,
282 provider: event.provider,
283 status: "error",
284 threadId: thread.id,298 threadId: thread.id,
285 activeTurnId: event.turnId ?? null,299 status: "error",
286 createdAt: thread.session?.createdAt ?? now,300 providerName: event.provider,
287 updatedAt: now,301 providerSessionId: event.sessionId,
302 providerThreadId,
303 activeTurnId: toTurnId(event.turnId) ?? null,
288 lastError: event.message,304 lastError: event.message,
305 updatedAt: now,
289 },306 },
290 createdAt: now,307 createdAt: now,
291 });308 });
...@@ -295,7 +312,7 @@ const make = Effect.gen(function* () {...@@ -295,7 +312,7 @@ const make = Effect.gen(function* () {
295 yield* Effect.forEach(activities, (activity) =>312 yield* Effect.forEach(activities, (activity) =>
296 orchestrationEngine.dispatch({313 orchestrationEngine.dispatch({
297 type: "thread.activity.append",314 type: "thread.activity.append",
298 commandId: crypto.randomUUID(),315 commandId: providerCommandId(event, "thread-activity-append"),
299 threadId: thread.id,316 threadId: thread.id,
300 activity,317 activity,
301 createdAt: activity.createdAt,318 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 {...@@ -13,9 +13,30 @@ import {
13const now = new Date().toISOString();13const now = new Date().toISOString();
1414
15const readModel: OrchestrationReadModel = {15const readModel: OrchestrationReadModel = {
16 sequence: 2,16 snapshotSequence: 2,
17 updatedAt: now,17 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 ],
19 threads: [40 threads: [
20 {41 {
21 id: "thread-1",42 id: "thread-1",
...@@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = {...@@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = {
27 createdAt: now,48 createdAt: now,
28 updatedAt: now,49 updatedAt: now,
29 latestTurnId: null,50 latestTurnId: null,
30 latestTurnStartedAt: null,
31 latestTurnCompletedAt: null,
32 latestTurnDurationMs: null,
33 messages: [],51 messages: [],
34 session: null,52 session: null,
35 turnDiffSummaries: [],
36 activities: [],53 activities: [],
37 error: null,54 checkpoints: [],
38 },55 },
39 {56 {
40 id: "thread-2",57 id: "thread-2",
...@@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = {...@@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = {
46 createdAt: now,63 createdAt: now,
47 updatedAt: now,64 updatedAt: now,
48 latestTurnId: null,65 latestTurnId: null,
49 latestTurnStartedAt: null,
50 latestTurnCompletedAt: null,
51 latestTurnDurationMs: null,
52 messages: [],66 messages: [],
53 session: null,67 session: null,
54 turnDiffSummaries: [],
55 activities: [],68 activities: [],
56 error: null,69 checkpoints: [],
57 },70 },
58 ],71 ],
59};72};
6073
61const messageSendCommand: OrchestrationCommand = {74const messageSendCommand: OrchestrationCommand = {
62 type: "message.send",75 type: "thread.turn.start",
63 commandId: "cmd-1",76 commandId: "cmd-1",
64 threadId: "thread-1",77 threadId: "thread-1",
65 messageId: "msg-1",78 message: {
66 role: "user",79 messageId: "msg-1",
67 text: "hello",80 role: "user",
68 streaming: false,81 text: "hello",
82 attachments: [],
83 },
69 createdAt: now,84 createdAt: now,
70};85};
7186
apps/server/src/orchestration/commandInvariants.ts+56-5
...@@ -1,4 +1,11 @@...@@ -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";
2import { Effect } from "effect";9import { Effect } from "effect";
310
4import { OrchestrationCommandInvariantError } from "./Errors.ts";11import { OrchestrationCommandInvariantError } from "./Errors.ts";
...@@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma...@@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma
1219
13export function findThreadById(20export function findThreadById(
14 readModel: OrchestrationReadModel,21 readModel: OrchestrationReadModel,
15 threadId: string,22 threadId: ThreadId,
16): OrchestrationThread | undefined {23): OrchestrationThread | undefined {
17 return readModel.threads.find((thread) => thread.id === threadId);24 return readModel.threads.find((thread) => thread.id === threadId);
18}25}
1926
27export function findProjectById(
28 readModel: OrchestrationReadModel,
29 projectId: ProjectId,
30): OrchestrationProject | undefined {
31 return readModel.projects.find((project) => project.id === projectId);
32}
33
20export function listThreadsByProjectId(34export function listThreadsByProjectId(
21 readModel: OrchestrationReadModel,35 readModel: OrchestrationReadModel,
22 projectId: string,36 projectId: ProjectId,
23): ReadonlyArray<OrchestrationThread> {37): ReadonlyArray<OrchestrationThread> {
24 return readModel.threads.filter((thread) => thread.projectId === projectId);38 return readModel.threads.filter((thread) => thread.projectId === projectId);
25}39}
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
27export function requireThread(78export function requireThread(
28 input: {79 input: {
29 readonly readModel: OrchestrationReadModel;80 readonly readModel: OrchestrationReadModel;
30 readonly command: OrchestrationCommand;81 readonly command: OrchestrationCommand;
31 readonly threadId: string;82 readonly threadId: ThreadId;
32 },83 },
33): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {84): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> {
34 const thread = findThreadById(input.readModel, input.threadId);85 const thread = findThreadById(input.readModel, input.threadId);
...@@ -47,7 +98,7 @@ export function requireThreadAbsent(...@@ -47,7 +98,7 @@ export function requireThreadAbsent(
47 input: {98 input: {
48 readonly readModel: OrchestrationReadModel;99 readonly readModel: OrchestrationReadModel;
49 readonly command: OrchestrationCommand;100 readonly command: OrchestrationCommand;
50 readonly threadId: string;101 readonly threadId: ThreadId;
51 },102 },
52): Effect.Effect<void, OrchestrationCommandInvariantError> {103): Effect.Effect<void, OrchestrationCommandInvariantError> {
53 if (!findThreadById(input.readModel, input.threadId)) {104 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";...@@ -7,36 +7,152 @@ import { Effect } from "effect";
77
8import { OrchestrationCommandInvariantError } from "./Errors.ts";8import { OrchestrationCommandInvariantError } from "./Errors.ts";
9import {9import {
10 requireNonNegativeInteger,10 requireProject,
11 requireProjectAbsent,
11 requireThread,12 requireThread,
12 requireThreadAbsent,13 requireThreadAbsent,
13} from "./commandInvariants.ts";14} 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
15export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({49export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
16 command,50 command,
17 readModel,51 readModel,
18}: {52}: {
19 readonly command: OrchestrationCommand;53 readonly command: OrchestrationCommand;
20 readonly readModel: OrchestrationReadModel;54 readonly readModel: OrchestrationReadModel;
21}): Effect.fn.Return<Omit<OrchestrationEvent, "sequence">, OrchestrationCommandInvariantError> {55}): Effect.fn.Return<
22 const eventId = crypto.randomUUID();56 Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>,
2357 OrchestrationCommandInvariantError
58> {
24 switch (command.type) {59 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 });
26 yield* requireThreadAbsent({141 yield* requireThreadAbsent({
27 readModel,142 readModel,
28 command,143 command,
29 threadId: command.threadId,144 threadId: command.threadId,
30 });145 });
31 return {146 return {
32 eventId,147 ...withEventBase({
148 aggregateKind: "thread",
149 aggregateId: command.threadId,
150 occurredAt: command.createdAt,
151 commandId: command.commandId,
152 }),
33 type: "thread.created",153 type: "thread.created",
34 aggregateType: "thread",
35 aggregateId: command.threadId,
36 occurredAt: command.createdAt,
37 commandId: command.commandId,
38 payload: {154 payload: {
39 id: command.threadId,155 threadId: command.threadId,
40 projectId: command.projectId,156 projectId: command.projectId,
41 title: command.title,157 title: command.title,
42 model: command.model,158 model: command.model,
...@@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
46 updatedAt: command.createdAt,162 updatedAt: command.createdAt,
47 },163 },
48 };164 };
49 case "thread.delete":165 }
166
167 case "thread.delete": {
50 yield* requireThread({168 yield* requireThread({
51 readModel,169 readModel,
52 command,170 command,
53 threadId: command.threadId,171 threadId: command.threadId,
54 });172 });
173 const occurredAt = nowIso();
55 return {174 return {
56 eventId,175 ...withEventBase({
176 aggregateKind: "thread",
177 aggregateId: command.threadId,
178 occurredAt,
179 commandId: command.commandId,
180 }),
57 type: "thread.deleted",181 type: "thread.deleted",
58 aggregateType: "thread",
59 aggregateId: command.threadId,
60 occurredAt: command.createdAt,
61 commandId: command.commandId,
62 payload: {182 payload: {
63 id: command.threadId,183 threadId: command.threadId,
64 deletedAt: command.createdAt,184 deletedAt: occurredAt,
65 },185 },
66 };186 };
67 case "thread.meta.update":187 }
188
189 case "thread.meta.update": {
68 yield* requireThread({190 yield* requireThread({
69 readModel,191 readModel,
70 command,192 command,
71 threadId: command.threadId,193 threadId: command.threadId,
72 });194 });
195 const occurredAt = nowIso();
73 return {196 return {
74 eventId,197 ...withEventBase({
198 aggregateKind: "thread",
199 aggregateId: command.threadId,
200 occurredAt,
201 commandId: command.commandId,
202 }),
75 type: "thread.meta-updated",203 type: "thread.meta-updated",
76 aggregateType: "thread",
77 aggregateId: command.threadId,
78 occurredAt: command.createdAt,
79 commandId: command.commandId,
80 payload: {204 payload: {
81 threadId: command.threadId,205 threadId: command.threadId,
82 ...(command.title !== undefined ? { title: command.title } : {}),206 ...(command.title !== undefined ? { title: command.title } : {}),
83 ...(command.model !== undefined ? { model: command.model } : {}),207 ...(command.model !== undefined ? { model: command.model } : {}),
84 ...(command.branch !== undefined ? { branch: command.branch } : {}),208 ...(command.branch !== undefined ? { branch: command.branch } : {}),
85 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),209 ...(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,
86 updatedAt: command.createdAt,238 updatedAt: command.createdAt,
87 },239 },
88 };240 };
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": {
90 yield* requireThread({262 yield* requireThread({
91 readModel,263 readModel,
92 command,264 command,
93 threadId: command.threadId,265 threadId: command.threadId,
94 });266 });
95 return {267 return {
96 eventId,268 ...withEventBase({
97 type: "message.sent",269 aggregateKind: "thread",
98 aggregateType: "thread",270 aggregateId: command.threadId,
99 aggregateId: command.threadId,271 occurredAt: command.createdAt,
100 occurredAt: command.createdAt,272 commandId: command.commandId,
101 commandId: command.commandId,273 }),
274 type: "thread.turn-interrupt-requested",
102 payload: {275 payload: {
103 id: command.messageId,
104 role: command.role,
105 text: command.text,
106 threadId: command.threadId,276 threadId: command.threadId,
277 ...(command.turnId !== undefined ? { turnId: command.turnId } : {}),
107 createdAt: command.createdAt,278 createdAt: command.createdAt,
108 streaming: command.streaming === true,
109 },279 },
110 };280 };
111 case "thread.session":281 }
282
283 case "thread.approval.respond": {
112 yield* requireThread({284 yield* requireThread({
113 readModel,285 readModel,
114 command,286 command,
115 threadId: command.threadId,287 threadId: command.threadId,
116 });288 });
117 return {289 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 }),
119 type: "thread.session-set",373 type: "thread.session-set",
120 aggregateType: "thread",
121 aggregateId: command.threadId,
122 occurredAt: command.createdAt,
123 commandId: command.commandId,
124 payload: {374 payload: {
125 threadId: command.threadId,375 threadId: command.threadId,
126 session: command.session,376 session: command.session,
127 },377 },
128 };378 };
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 });
130 return {387 return {
131 eventId,388 ...withEventBase({
132 type: "git.read-model-upsert",389 aggregateKind: "thread",
133 aggregateType: "project",390 aggregateId: command.threadId,
134 aggregateId: command.projectId,391 occurredAt: command.createdAt,
135 occurredAt: command.createdAt,392 commandId: command.commandId,
136 commandId: command.commandId,393 }),
394 type: "thread.message-sent",
137 payload: {395 payload: {
138 projectId: command.projectId,396 threadId: command.threadId,
139 branch: command.branch,397 messageId: command.messageId,
140 hasWorkingTreeChanges: command.hasWorkingTreeChanges,398 role: "assistant",
141 aheadCount: command.aheadCount,399 text: command.delta,
142 behindCount: command.behindCount,400 turnId: command.turnId ?? null,
401 streaming: true,
402 createdAt: command.createdAt,
143 updatedAt: command.createdAt,403 updatedAt: command.createdAt,
144 },404 },
145 };405 };
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": {
147 yield* requireThread({436 yield* requireThread({
148 readModel,437 readModel,
149 command,438 command,
150 threadId: command.threadId,439 threadId: command.threadId,
151 });440 });
152 return {441 return {
153 eventId,442 ...withEventBase({
443 aggregateKind: "thread",
444 aggregateId: command.threadId,
445 occurredAt: command.createdAt,
446 commandId: command.commandId,
447 }),
154 type: "thread.turn-diff-completed",448 type: "thread.turn-diff-completed",
155 aggregateType: "thread",
156 aggregateId: command.threadId,
157 occurredAt: command.createdAt,
158 commandId: command.commandId,
159 payload: {449 payload: {
160 threadId: command.threadId,450 threadId: command.threadId,
161 turnId: command.turnId,451 turnId: command.turnId,
162 completedAt: command.completedAt,452 checkpointTurnCount: command.checkpointTurnCount,
163 ...(command.status !== undefined ? { status: command.status } : {}),453 checkpointRef: command.checkpointRef,
454 status: command.status,
164 files: command.files,455 files: command.files,
165 ...(command.assistantMessageId !== undefined456 assistantMessageId: command.assistantMessageId ?? null,
166 ? { assistantMessageId: command.assistantMessageId }457 completedAt: command.completedAt,
167 : {}),
168 ...(command.checkpointTurnCount !== undefined
169 ? { checkpointTurnCount: command.checkpointTurnCount }
170 : {}),
171 },458 },
172 };459 };
173 case "thread.activity.append":460 }
461
462 case "thread.revert.complete": {
174 yield* requireThread({463 yield* requireThread({
175 readModel,464 readModel,
176 command,465 command,
177 threadId: command.threadId,466 threadId: command.threadId,
178 });467 });
179 return {468 return {
180 eventId,469 ...withEventBase({
181 type: "thread.activity-appended",470 aggregateKind: "thread",
182 aggregateType: "thread",471 aggregateId: command.threadId,
183 aggregateId: command.threadId,472 occurredAt: command.createdAt,
184 occurredAt: command.createdAt,473 commandId: command.commandId,
185 commandId: command.commandId,474 }),
475 type: "thread.reverted",
186 payload: {476 payload: {
187 threadId: command.threadId,477 threadId: command.threadId,
188 activity: command.activity,478 turnCount: command.turnCount,
189 },479 },
190 };480 };
191 case "thread.revert":481 }
482
483 case "thread.activity.append": {
192 yield* requireThread({484 yield* requireThread({
193 readModel,485 readModel,
194 command,486 command,
195 threadId: command.threadId,487 threadId: command.threadId,
196 });488 });
197 yield* requireNonNegativeInteger({489 const requestId =
198 commandType: command.type,490 typeof command.activity.payload === "object" &&
199 field: "turnCount",491 command.activity.payload !== null &&
200 value: command.turnCount,492 "requestId" in command.activity.payload &&
201 });493 typeof (command.activity.payload as { requestId?: unknown }).requestId === "string"
202 yield* requireNonNegativeInteger({494 ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"])
203 commandType: command.type,495 : undefined;
204 field: "messageCount",
205 value: command.messageCount,
206 });
207 return {496 return {
208 eventId,497 ...withEventBase({
209 type: "thread.reverted",498 aggregateKind: "thread",
210 aggregateType: "thread",499 aggregateId: command.threadId,
211 aggregateId: command.threadId,500 occurredAt: command.createdAt,
212 occurredAt: command.createdAt,501 commandId: command.commandId,
213 commandId: command.commandId,502 ...(requestId !== undefined ? { metadata: { requestId } } : {}),
503 }),
504 type: "thread.activity-appended",
214 payload: {505 payload: {
215 threadId: command.threadId,506 threadId: command.threadId,
216 turnCount: command.turnCount,507 activity: command.activity,
217 messageCount: command.messageCount,
218 },508 },
219 };509 };
510 }
511
220 default: {512 default: {
221 command satisfies never;513 command satisfies never;
222 const fallback = command as any;514 const fallback = command as never as { type: string };
223 return yield* new OrchestrationCommandInvariantError({515 return yield* new OrchestrationCommandInvariantError({
224 commandType: fallback.type,516 commandType: fallback.type,
225 detail: `Unknown command type: ${fallback.type}`,517 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";...@@ -4,32 +4,60 @@ import { describe, expect, it } from "vitest";
44
5import { createEmptyReadModel, projectEvent } from "./projector.ts";5import { 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
7describe("orchestration projector", () => {31describe("orchestration projector", () => {
8 it("applies thread.created events using runtime schema decoding", async () => {32 it("applies thread.created events", async () => {
9 const now = new Date().toISOString();33 const now = new Date().toISOString();
10 const model = createEmptyReadModel(now);34 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));36 const next = await Effect.runPromise(
32 expect(next.sequence).toBe(1);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);
33 expect(next.threads).toEqual([61 expect(next.threads).toEqual([
34 {62 {
35 id: "thread-1",63 id: "thread-1",
...@@ -38,17 +66,14 @@ describe("orchestration projector", () => {...@@ -38,17 +66,14 @@ describe("orchestration projector", () => {
38 model: "gpt-5-codex",66 model: "gpt-5-codex",
39 branch: null,67 branch: null,
40 worktreePath: null,68 worktreePath: null,
69 latestTurnId: null,
41 createdAt: now,70 createdAt: now,
42 updatedAt: now,71 updatedAt: now,
43 latestTurnId: null,72 deletedAt: null,
44 latestTurnStartedAt: null,
45 latestTurnCompletedAt: null,
46 latestTurnDurationMs: null,
47 messages: [],73 messages: [],
48 session: null,
49 activities: [],74 activities: [],
50 turnDiffSummaries: [],75 checkpoints: [],
51 error: null,76 session: null,
52 },77 },
53 ]);78 ]);
54 });79 });
...@@ -56,203 +81,205 @@ describe("orchestration projector", () => {...@@ -56,203 +81,205 @@ describe("orchestration projector", () => {
56 it("fails when event payload cannot be decoded by runtime schema", async () => {81 it("fails when event payload cannot be decoded by runtime schema", async () => {
57 const now = new Date().toISOString();82 const now = new Date().toISOString();
58 const model = createEmptyReadModel(now);83 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();
77 });110 });
78111
79 it("keeps projector forward-compatible for unknown event types", async () => {112 it("keeps projector forward-compatible for unhandled event types", async () => {
80 const now = new Date().toISOString();113 const now = new Date().toISOString();
81 const model = createEmptyReadModel(now);114 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));116 const next = await Effect.runPromise(
94 expect(next.sequence).toBe(7);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);
95 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");136 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");
96 expect(next.threads).toEqual([]);137 expect(next.threads).toEqual([]);
97 });138 });
98139
99 it("tracks latest turn timing from session lifecycle events", async () => {140 it("tracks latest turn id from session lifecycle events", async () => {
100 const createdAt = "2026-02-23T08:00:00.000Z";141 const createdAt = "2026-02-23T08:00:00.000Z";
101 const startedAt = "2026-02-23T08:00:05.000Z";142 const startedAt = "2026-02-23T08:00:05.000Z";
102 const completedAt = "2026-02-23T08:00:11.250Z";
103 const model = createEmptyReadModel(createdAt);143 const model = createEmptyReadModel(createdAt);
104144
105 const createdEvent: OrchestrationEvent = {145 const afterCreate = await Effect.runPromise(
106 sequence: 1,146 projectEvent(
107 eventId: "thread-created",147 model,
108 type: "thread.created",148 makeEvent({
109 aggregateType: "thread",149 sequence: 1,
110 aggregateId: "thread-1",150 type: "thread.created",
111 occurredAt: createdAt,151 aggregateType: "thread",
112 commandId: "cmd-create",152 aggregateId: "thread-1",
113 payload: {153 occurredAt: createdAt,
114 id: "thread-1",154 commandId: "cmd-create",
115 projectId: "project-1",155 payload: {
116 title: "demo",156 threadId: "thread-1",
117 model: "gpt-5.3-codex",157 projectId: "project-1",
118 branch: null,158 title: "demo",
119 worktreePath: null,159 model: "gpt-5.3-codex",
120 createdAt,160 branch: null,
121 updatedAt: createdAt,161 worktreePath: null,
122 },162 createdAt,
123 };163 updatedAt: createdAt,
124164 },
125 const runningSessionEvent: OrchestrationEvent = {165 }),
126 sequence: 2,166 ),
127 eventId: "session-running",167 );
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 };
170168
171 const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent));169 const afterRunning = await Effect.runPromise(
172 const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent));170 projectEvent(
173 const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent));171 afterCreate,
174 const thread = afterReady.threads[0];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];
176 expect(thread?.latestTurnId).toBe("turn-1");197 expect(thread?.latestTurnId).toBe("turn-1");
177 expect(thread?.latestTurnStartedAt).toBe(startedAt);198 expect(thread?.session?.status).toBe("running");
178 expect(thread?.latestTurnCompletedAt).toBe(completedAt);
179 expect(thread?.latestTurnDurationMs).toBe(6250);
180 });199 });
181200
182 it("marks assistant messages completed using non-streaming updates", async () => {201 it("marks assistant messages completed with non-streaming updates", async () => {
183 const createdAt = "2026-02-23T09:00:00.000Z";202 const createdAt = "2026-02-23T09:00:00.000Z";
184 const deltaAt = "2026-02-23T09:00:01.000Z";203 const deltaAt = "2026-02-23T09:00:01.000Z";
185 const completeAt = "2026-02-23T09:00:03.500Z";204 const completeAt = "2026-02-23T09:00:03.500Z";
186 const model = createEmptyReadModel(createdAt);205 const model = createEmptyReadModel(createdAt);
187206
188 const createdEvent: OrchestrationEvent = {207 const afterCreate = await Effect.runPromise(
189 sequence: 1,208 projectEvent(
190 eventId: "thread-created",209 model,
191 type: "thread.created",210 makeEvent({
192 aggregateType: "thread",211 sequence: 1,
193 aggregateId: "thread-1",212 type: "thread.created",
194 occurredAt: createdAt,213 aggregateType: "thread",
195 commandId: "cmd-create",214 aggregateId: "thread-1",
196 payload: {215 occurredAt: createdAt,
197 id: "thread-1",216 commandId: "cmd-create",
198 projectId: "project-1",217 payload: {
199 title: "demo",218 threadId: "thread-1",
200 model: "gpt-5.3-codex",219 projectId: "project-1",
201 branch: null,220 title: "demo",
202 worktreePath: null,221 model: "gpt-5.3-codex",
203 createdAt,222 branch: null,
204 updatedAt: createdAt,223 worktreePath: null,
205 },224 createdAt,
206 };225 updatedAt: createdAt,
226 },
227 }),
228 ),
229 );
207230
208 const deltaEvent: OrchestrationEvent = {231 const afterDelta = await Effect.runPromise(
209 sequence: 2,232 projectEvent(
210 eventId: "assistant-delta",233 afterCreate,
211 type: "message.sent",234 makeEvent({
212 aggregateType: "thread",235 sequence: 2,
213 aggregateId: "thread-1",236 type: "thread.message-sent",
214 occurredAt: deltaAt,237 aggregateType: "thread",
215 commandId: "cmd-delta",238 aggregateId: "thread-1",
216 payload: {239 occurredAt: deltaAt,
217 id: "assistant:msg-1",240 commandId: "cmd-delta",
218 role: "assistant",241 payload: {
219 text: "hello",242 threadId: "thread-1",
220 threadId: "thread-1",243 messageId: "assistant:msg-1",
221 createdAt: deltaAt,244 role: "assistant",
222 streaming: true,245 text: "hello",
223 },246 turnId: "turn-1",
224 };247 streaming: true,
248 createdAt: deltaAt,
249 updatedAt: deltaAt,
250 },
251 }),
252 ),
253 );
225254
226 const completionEvent: OrchestrationEvent = {255 const afterComplete = await Effect.runPromise(
227 sequence: 3,256 projectEvent(
228 eventId: "assistant-complete",257 afterDelta,
229 type: "message.sent",258 makeEvent({
230 aggregateType: "thread",259 sequence: 3,
231 aggregateId: "thread-1",260 type: "thread.message-sent",
232 occurredAt: completeAt,261 aggregateType: "thread",
233 commandId: "cmd-complete",262 aggregateId: "thread-1",
234 payload: {263 occurredAt: completeAt,
235 id: "assistant:msg-1",264 commandId: "cmd-complete",
236 role: "assistant",265 payload: {
237 text: "",266 threadId: "thread-1",
238 threadId: "thread-1",267 messageId: "assistant:msg-1",
239 createdAt: completeAt,268 role: "assistant",
240 streaming: false,269 text: "",
241 },270 turnId: "turn-1",
242 };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));
247 const message = afterComplete.threads[0]?.messages[0];279 const message = afterComplete.threads[0]?.messages[0];
248280 expect(message?.id).toBe("assistant:msg-1");
249 expect(message).toEqual({281 expect(message?.text).toBe("hello");
250 id: "assistant:msg-1",282 expect(message?.streaming).toBe(false);
251 role: "assistant",283 expect(message?.updatedAt).toBe(completeAt);
252 text: "hello",
253 createdAt: deltaAt,
254 completedAt: completeAt,
255 streaming: false,
256 });
257 });284 });
258});285});
apps/server/src/orchestration/projector.ts+176-253
...@@ -1,29 +1,36 @@...@@ -1,29 +1,36 @@
1import type {1import type {
2 OrchestrationEvent,2 OrchestrationEvent,
3 OrchestrationGitReadModel,
4 OrchestrationMessage,
5 OrchestrationReadModel,3 OrchestrationReadModel,
6 OrchestrationSession,4 ThreadId,
7 OrchestrationThread,
8} from "@t3tools/contracts";5} from "@t3tools/contracts";
9import {6import {
10 OrchestrationThreadActivitySchema,7 OrchestrationCheckpointSummary,
11 OrchestrationGitReadModelSchema,8 OrchestrationMessage,
12 OrchestrationMessageSchema,9 OrchestrationSession,
13 OrchestrationSessionSchema,10 OrchestrationThread,
14 OrchestrationThreadSchema,
15 OrchestrationTurnDiffFileSchema,
16 OrchestrationTurnDiffSummarySchema,
17} from "@t3tools/contracts";11} from "@t3tools/contracts";
18import { Effect, Schema } from "effect";12import { Effect, Schema } from "effect";
1913
20import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";14import { 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
22type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;29type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
2330
24function updateThread(31function updateThread(
25 threads: ReadonlyArray<OrchestrationThread>,32 threads: ReadonlyArray<OrchestrationThread>,
26 threadId: string,33 threadId: ThreadId,
27 patch: ThreadPatch,34 patch: ThreadPatch,
28): OrchestrationThread[] {35): OrchestrationThread[] {
29 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));36 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
...@@ -32,7 +39,7 @@ function updateThread(...@@ -32,7 +39,7 @@ function updateThread(
32function decodeForEvent<A>(39function decodeForEvent<A>(
33 schema: Schema.Schema<A>,40 schema: Schema.Schema<A>,
34 value: unknown,41 value: unknown,
35 eventType: string,42 eventType: OrchestrationEvent["type"],
36 field: string,43 field: string,
37): Effect.Effect<A, OrchestrationProjectorDecodeError> {44): Effect.Effect<A, OrchestrationProjectorDecodeError> {
38 return Schema.decodeUnknownEffect(schema)(value).pipe(45 return Schema.decodeUnknownEffect(schema)(value).pipe(
...@@ -40,66 +47,11 @@ function decodeForEvent<A>(...@@ -40,66 +47,11 @@ function decodeForEvent<A>(
40 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;47 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;
41}48}
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
98export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {50export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
99 return {51 return {
100 sequence: 0,52 snapshotSequence: 0,
53 projects: [],
101 threads: [],54 threads: [],
102 gitByProjectId: {},
103 updatedAt: nowIso,55 updatedAt: nowIso,
104 };56 };
105}57}
...@@ -110,39 +62,104 @@ export function projectEvent(...@@ -110,39 +62,104 @@ export function projectEvent(
110): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> {62): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> {
111 const nextBase: OrchestrationReadModel = {63 const nextBase: OrchestrationReadModel = {
112 ...model,64 ...model,
113 sequence: event.sequence,65 snapshotSequence: event.sequence,
114 updatedAt: event.occurredAt,66 updatedAt: event.occurredAt,
115 };67 };
11668
117 switch (event.type) {69 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
118 case "thread.created":138 case "thread.created":
119 return Effect.gen(function* () {139 return Effect.gen(function* () {
120 const payload = yield* decodeForEvent(140 const payload = yield* decodeForEvent(
121 ThreadCreatedPayloadSchema,141 ThreadCreatedPayload,
122 event.payload,142 event.payload,
123 event.type,143 event.type,
124 "payload",144 "payload",
125 );145 );
126 const thread: OrchestrationThread = yield* decodeForEvent(146 const thread: OrchestrationThread = yield* decodeForEvent(
127 OrchestrationThreadSchema,147 OrchestrationThread,
128 {148 {
129 id: payload.id,149 id: payload.threadId,
130 projectId: payload.projectId,150 projectId: payload.projectId,
131 title: payload.title,151 title: payload.title,
132 model: payload.model,152 model: payload.model,
133 branch: payload.branch,153 branch: payload.branch,
134 worktreePath: payload.worktreePath,154 worktreePath: payload.worktreePath,
155 latestTurnId: null,
135 createdAt: payload.createdAt,156 createdAt: payload.createdAt,
136 updatedAt: payload.updatedAt,157 updatedAt: payload.updatedAt,
137 latestTurnId: null,158 deletedAt: null,
138 latestTurnStartedAt: null,
139 latestTurnCompletedAt: null,
140 latestTurnDurationMs: null,
141 messages: [],159 messages: [],
142 session: null,
143 turnDiffSummaries: [],
144 activities: [],160 activities: [],
145 error: null,161 checkpoints: [],
162 session: null,
146 },163 },
147 event.type,164 event.type,
148 "thread",165 "thread",
...@@ -155,36 +172,38 @@ export function projectEvent(...@@ -155,36 +172,38 @@ export function projectEvent(
155 : [...nextBase.threads, thread],172 : [...nextBase.threads, thread],
156 };173 };
157 });174 });
175
158 case "thread.deleted":176 case "thread.deleted":
159 return Effect.succeed({177 return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe(
160 ...nextBase,178 Effect.map((payload) => ({
161 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),179 ...nextBase,
162 });180 threads: updateThread(nextBase.threads, payload.threadId, {
181 deletedAt: payload.deletedAt,
182 updatedAt: payload.deletedAt,
183 }),
184 })),
185 );
186
163 case "thread.meta-updated":187 case "thread.meta-updated":
164 return decodeForEvent(188 return decodeForEvent(
165 ThreadMetaUpdatedPayloadSchema,189 ThreadMetaUpdatedPayload,
166 event.payload,190 event.payload,
167 event.type,191 event.type,
168 "payload",192 "payload",
169 ).pipe(193 ).pipe(
170 Effect.map((payload) => {194 Effect.map((payload) => ({
171 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);195 ...nextBase,
172 if (!thread) {196 threads: updateThread(nextBase.threads, payload.threadId, {
173 return nextBase;197 ...(payload.title !== undefined ? { title: payload.title } : {}),
174 }198 ...(payload.model !== undefined ? { model: payload.model } : {}),
175 return {199 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
176 ...nextBase,200 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
177 threads: updateThread(nextBase.threads, payload.threadId, {201 updatedAt: payload.updatedAt,
178 ...(payload.title !== undefined ? { title: payload.title } : {}),202 }),
179 ...(payload.model !== undefined ? { model: payload.model } : {}),203 })),
180 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
181 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
182 updatedAt: payload.updatedAt,
183 }),
184 };
185 }),
186 );204 );
187 case "message.sent":205
206 case "thread.message-sent":
188 return Effect.gen(function* () {207 return Effect.gen(function* () {
189 const payload = yield* decodeForEvent(208 const payload = yield* decodeForEvent(
190 MessageSentPayloadSchema,209 MessageSentPayloadSchema,
...@@ -192,27 +211,30 @@ export function projectEvent(...@@ -192,27 +211,30 @@ export function projectEvent(
192 event.type,211 event.type,
193 "payload",212 "payload",
194 );213 );
195 const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId);214 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
196 if (!targetThread) {215 if (!thread) {
197 return nextBase;216 return nextBase;
198 }217 }
199218
200 const message: OrchestrationMessage = yield* decodeForEvent(219 const message: OrchestrationMessage = yield* decodeForEvent(
201 OrchestrationMessageSchema,220 OrchestrationMessage,
202 {221 {
203 id: payload.id,222 id: payload.messageId,
204 role: payload.role,223 role: payload.role,
205 text: payload.text,224 text: payload.text,
206 createdAt: payload.createdAt,225 ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}),
207 ...(payload.streaming ? {} : { completedAt: event.occurredAt }),226 turnId: payload.turnId,
208 streaming: payload.streaming,227 streaming: payload.streaming,
228 createdAt: payload.createdAt,
229 updatedAt: payload.updatedAt,
209 },230 },
210 event.type,231 event.type,
211 "message",232 "message",
212 );233 );
213 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);234
214 const nextMessages = existingMessage235 const existingMessage = thread.messages.find((entry) => entry.id === message.id);
215 ? targetThread.messages.map((entry) =>236 const messages = existingMessage
237 ? thread.messages.map((entry) =>
216 entry.id === message.id238 entry.id === message.id
217 ? {239 ? {
218 ...entry,240 ...entry,
...@@ -222,29 +244,29 @@ export function projectEvent(...@@ -222,29 +244,29 @@ export function projectEvent(
222 ? message.text244 ? message.text
223 : entry.text,245 : entry.text,
224 streaming: message.streaming,246 streaming: message.streaming,
225 createdAt: entry.createdAt,247 updatedAt: message.updatedAt,
226 ...(message.streaming248 turnId: message.turnId,
227 ? { completedAt: undefined }249 ...(message.attachments !== undefined
228 : { completedAt: message.completedAt ?? event.occurredAt }),250 ? { attachments: message.attachments }
251 : {}),
229 }252 }
230 : entry,253 : entry,
231 )254 )
232 : !message.streaming && message.text.length === 0255 : [...thread.messages, message];
233 ? targetThread.messages
234 : [...targetThread.messages, message];
235256
236 return {257 return {
237 ...nextBase,258 ...nextBase,
238 threads: updateThread(nextBase.threads, payload.threadId, {259 threads: updateThread(nextBase.threads, payload.threadId, {
239 messages: nextMessages,260 messages,
240 updatedAt: event.occurredAt,261 updatedAt: event.occurredAt,
241 }),262 }),
242 };263 };
243 });264 });
265
244 case "thread.session-set":266 case "thread.session-set":
245 return Effect.gen(function* () {267 return Effect.gen(function* () {
246 const payload = yield* decodeForEvent(268 const payload = yield* decodeForEvent(
247 ThreadSessionSetPayloadSchema,269 ThreadSessionSetPayload,
248 event.payload,270 event.payload,
249 event.type,271 event.type,
250 "payload",272 "payload",
...@@ -255,67 +277,26 @@ export function projectEvent(...@@ -255,67 +277,26 @@ export function projectEvent(
255 }277 }
256278
257 const session: OrchestrationSession = yield* decodeForEvent(279 const session: OrchestrationSession = yield* decodeForEvent(
258 OrchestrationSessionSchema,280 OrchestrationSession,
259 {281 payload.session,
260 ...payload.session,
261 threadId: payload.threadId,
262 },
263 event.type,282 event.type,
264 "session",283 "session",
265 );284 );
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
305 return {286 return {
306 ...nextBase,287 ...nextBase,
307 threads: updateThread(nextBase.threads, payload.threadId, {288 threads: updateThread(nextBase.threads, payload.threadId, {
308 session,289 session,
290 latestTurnId: session.activeTurnId,
309 updatedAt: event.occurredAt,291 updatedAt: event.occurredAt,
310 error: session.lastError,
311 ...turnTimingPatch,
312 }),292 }),
313 };293 };
314 });294 });
295
315 case "thread.turn-diff-completed":296 case "thread.turn-diff-completed":
316 return Effect.gen(function* () {297 return Effect.gen(function* () {
317 const payload = yield* decodeForEvent(298 const payload = yield* decodeForEvent(
318 ThreadTurnDiffCompletedPayloadSchema,299 ThreadTurnDiffCompletedPayload,
319 event.payload,300 event.payload,
320 event.type,301 event.type,
321 "payload",302 "payload",
...@@ -325,110 +306,65 @@ export function projectEvent(...@@ -325,110 +306,65 @@ export function projectEvent(
325 return nextBase;306 return nextBase;
326 }307 }
327308
328 const nextSummary = yield* decodeForEvent(309 const checkpoint = yield* decodeForEvent(
329 OrchestrationTurnDiffSummarySchema,310 OrchestrationCheckpointSummary,
330 {311 {
331 turnId: payload.turnId,312 turnId: payload.turnId,
332 completedAt: payload.completedAt,313 checkpointTurnCount: payload.checkpointTurnCount,
333 ...(payload.status !== undefined ? { status: payload.status } : {}),314 checkpointRef: payload.checkpointRef,
315 status: payload.status,
334 files: payload.files,316 files: payload.files,
335 ...(payload.assistantMessageId !== undefined317 assistantMessageId: payload.assistantMessageId,
336 ? { assistantMessageId: payload.assistantMessageId }318 completedAt: payload.completedAt,
337 : {}),
338 ...(payload.checkpointTurnCount !== undefined
339 ? { checkpointTurnCount: payload.checkpointTurnCount }
340 : {}),
341 },319 },
342 event.type,320 event.type,
343 "turnDiffSummary",321 "checkpoint",
344 );322 );
345 const turnDiffSummaries = [323
346 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),324 const checkpoints = [
347 nextSummary,325 ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId),
348 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));326 checkpoint,
349 const startedAtMs = thread.latestTurnStartedAt327 ].toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
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;
359328
360 return {329 return {
361 ...nextBase,330 ...nextBase,
362 threads: updateThread(nextBase.threads, payload.threadId, {331 threads: updateThread(nextBase.threads, payload.threadId, {
332 checkpoints,
363 latestTurnId: payload.turnId,333 latestTurnId: payload.turnId,
364 latestTurnCompletedAt: nextSummary.completedAt,
365 latestTurnDurationMs,
366 turnDiffSummaries,
367 updatedAt: event.occurredAt,334 updatedAt: event.occurredAt,
368 }),335 }),
369 };336 };
370 });337 });
338
371 case "thread.reverted":339 case "thread.reverted":
372 return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe(340 return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe(
373 Effect.map((payload) => {341 Effect.map((payload) => {
374 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);342 const thread = nextBase.threads.find((entry) => entry.id === payload.threadId);
375 if (!thread) {343 if (!thread) {
376 return nextBase;344 return nextBase;
377 }345 }
378346
379 const targetTurnCount = Math.max(0, Math.floor(payload.turnCount));347 const checkpoints = thread.checkpoints
380 const targetMessageCount = Math.max(0, Math.floor(payload.messageCount));348 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)
381 const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) =>349 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
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 }
390350
391 const turnDiffSummaries = thread.turnDiffSummaries351 const latestTurnId =
392 .filter((summary) => {352 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;
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 });
410353
411 return {354 return {
412 ...nextBase,355 ...nextBase,
413 threads: updateThread(nextBase.threads, payload.threadId, {356 threads: updateThread(nextBase.threads, payload.threadId, {
414 messages: thread.messages.slice(357 checkpoints,
415 0,358 latestTurnId,
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,
424 updatedAt: event.occurredAt,359 updatedAt: event.occurredAt,
425 }),360 }),
426 };361 };
427 }),362 }),
428 );363 );
364
429 case "thread.activity-appended":365 case "thread.activity-appended":
430 return decodeForEvent(366 return decodeForEvent(
431 ThreadActivityAppendedPayloadSchema,367 ThreadActivityAppendedPayload,
432 event.payload,368 event.payload,
433 event.type,369 event.type,
434 "payload",370 "payload",
...@@ -438,8 +374,9 @@ export function projectEvent(...@@ -438,8 +374,9 @@ export function projectEvent(
438 if (!thread) {374 if (!thread) {
439 return nextBase;375 return nextBase;
440 }376 }
377
441 const activities = [378 const activities = [
442 ...thread.activities.filter((activity) => activity.id !== payload.activity.id),379 ...thread.activities.filter((entry) => entry.id !== payload.activity.id),
443 payload.activity,380 payload.activity,
444 ]381 ]
445 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt))382 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt))
...@@ -454,21 +391,7 @@ export function projectEvent(...@@ -454,21 +391,7 @@ export function projectEvent(
454 };391 };
455 }),392 }),
456 );393 );
457 case "git.read-model-upsert":394
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 );
472 default:395 default:
473 return Effect.succeed(nextBase);396 return Effect.succeed(nextBase);
474 }397 }
apps/server/src/persistence/Errors.ts+16
...@@ -200,3 +200,19 @@ export type CheckpointRepositoryError =...@@ -200,3 +200,19 @@ export type CheckpointRepositoryError =
200export type ProviderSessionRepositoryError =200export type ProviderSessionRepositoryError =
201 | ProviderSessionRepositoryValidationError201 | ProviderSessionRepositoryValidationError
202 | ProviderSessionRepositoryPersistenceError;202 | 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 @@...@@ -1,39 +1,119 @@
1import { Effect, Layer, Stream } from "effect";1import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";2import { 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";
4import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts";7import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts";
5import { SqlitePersistenceMemory } from "./Sqlite.ts";
6import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts";8import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts";
7import { NodeServices } from "@effect/platform-node";9import { SqlitePersistenceMemory } from "./Sqlite.ts";
810
9const layer = it.layer(11const layer = it.layer(
10 Layer.mergeAll(12 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
11 Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory),
12 NodeServices.layer,
13 ),
14);13);
1514
16layer("OrchestrationEventStore", (it) => {15layer("OrchestrationEventStore", (it) => {
17 it.effect("stores and replays events", () =>16 it.effect("stores json columns as strings and replays decoded events", () =>
18 Effect.gen(function* () {17 Effect.gen(function* () {
19 const createdAt = new Date().toISOString();
20
21 const eventStore = yield* OrchestrationEventStore;18 const eventStore = yield* OrchestrationEventStore;
22 const saved = yield* eventStore.append({19 const sql = yield* SqlClient.SqlClient;
23 eventId: "event-1",20 const now = new Date().toISOString();
24 type: "thread.created",21
25 aggregateType: "thread",22 const appended = yield* eventStore.append({
26 aggregateId: "thread-1",23 type: "project.created",
27 occurredAt: createdAt,24 eventId: EventId.makeUnsafe("evt-store-roundtrip"),
28 commandId: "cmd-1",25 aggregateKind: "project",
29 payload: { id: "thread-1", projectId: "project-1", title: "demo" },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 },
30 });43 });
31 assert.equal(saved.sequence, 1);
3244
33 const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe(45 const storedRows = yield* sql<{
34 Effect.map(Array.from),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)),
35 );107 );
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 }
37 }),117 }),
38 );118 );
39});119});
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+149-87
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";1import {
2import { OrchestrationEventSchema } from "@t3tools/contracts";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";
3import * as SqlClient from "effect/unstable/sql/SqlClient";14import * as SqlClient from "effect/unstable/sql/SqlClient";
4import * as SqlSchema from "effect/unstable/sql/SqlSchema";15import * as SqlSchema from "effect/unstable/sql/SqlSchema";
5import { Effect, Layer, Schema, Stream } from "effect";16import { Effect, Layer, Schema, Stream } from "effect";
617
7import {18import {
8 toPersistenceDecodeCauseError,
9 toPersistenceDecodeError,19 toPersistenceDecodeError,
10 toPersistenceSerializationError,
11 toPersistenceSqlError,20 toPersistenceSqlError,
12 type OrchestrationEventStoreError,21 type OrchestrationEventStoreError,
13} from "../Errors.ts";22} from "../Errors.ts";
...@@ -16,57 +25,77 @@ import {...@@ -16,57 +25,77 @@ import {
16 type OrchestrationEventStoreShape,25 type OrchestrationEventStoreShape,
17} from "../Services/OrchestrationEventStore.ts";26} 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({32const AppendEventRequestSchema = Schema.Struct({
22 sequence: Schema.Number,33 eventId: EventId,
23 eventId: Schema.String,34 aggregateKind: OrchestrationAggregateKind,
24 type: Schema.String,35 streamId: Schema.Union([ProjectId, ThreadId]),
25 aggregateType: Schema.String,36 type: OrchestrationEventType,
26 aggregateId: Schema.String,37 causationEventId: Schema.NullOr(EventId),
27 occurredAt: Schema.String,38 correlationId: Schema.NullOr(CommandId),
28 commandId: Schema.NullOr(Schema.String),39 actorKind: OrchestrationActorKind,
29 payloadJson: Schema.String,40 occurredAt: IsoDateTime,
41 commandId: Schema.NullOr(CommandId),
42 payloadJson: UnknownFromJsonString,
43 metadataJson: EventMetadataFromJsonString,
30});44});
3145
32const AppendEventRequestSchema = Schema.Struct({46const OrchestrationEventPersistedRowSchema = Schema.Struct({
33 eventId: Schema.String,47 sequence: NonNegativeInt,
34 type: Schema.String,48 eventId: EventId,
35 aggregateType: Schema.String,49 type: OrchestrationEventType,
36 aggregateId: Schema.String,50 aggregateKind: OrchestrationAggregateKind,
37 occurredAt: Schema.String,51 aggregateId: Schema.Union([ProjectId, ThreadId]),
38 commandId: Schema.NullOr(Schema.String),52 occurredAt: IsoDateTime,
39 payloadJson: Schema.String,53 commandId: Schema.NullOr(CommandId),
54 causationEventId: Schema.NullOr(EventId),
55 correlationId: Schema.NullOr(CommandId),
56 payload: UnknownFromJsonString,
57 metadata: EventMetadataFromJsonString,
40});58});
4159
42const ReadFromSequenceRequestSchema = Schema.Struct({60const ReadFromSequenceRequestSchema = Schema.Struct({
43 sequenceExclusive: Schema.Number,61 sequenceExclusive: NonNegativeInt,
44 limit: Schema.Number,62 limit: Schema.Number,
45});63});
46const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;64const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;
47const READ_PAGE_SIZE = 500;65const READ_PAGE_SIZE = 500;
4866
49function eventRowToOrchestrationEvent(67function inferActorKind(
50 row: Schema.Schema.Type<typeof EventRowSchema>,68 event: Omit<OrchestrationEvent, "sequence">,
51 operation: string,69): Schema.Schema.Type<typeof OrchestrationActorKind> {
52): Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError> {70 if (event.commandId !== null && event.commandId.startsWith("provider:")) {
53 return Effect.try({71 return "provider";
54 try: () => JSON.parse(row.payloadJson) as unknown,72 }
55 catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`),73 if (event.commandId !== null && event.commandId.startsWith("server:")) {
56 }).pipe(74 return "server";
57 Effect.flatMap((payload) =>75 }
58 decodeEvent({76 if (
59 sequence: row.sequence,77 event.metadata.providerSessionId !== undefined ||
60 eventId: row.eventId,78 event.metadata.providerThreadId !== undefined ||
61 type: row.type,79 event.metadata.providerTurnId !== undefined ||
62 aggregateType: row.aggregateType,80 event.metadata.providerItemId !== undefined ||
63 aggregateId: row.aggregateId,81 event.metadata.adapterKey !== undefined
64 occurredAt: row.occurredAt,82 ) {
65 commandId: row.commandId,83 return "provider";
66 payload,84 }
67 }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))),85 if (event.commandId === null) {
68 ),86 return "server";
69 );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);
70}99}
71100
72const makeEventStore = Effect.gen(function* () {101const makeEventStore = Effect.gen(function* () {
...@@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () {...@@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () {
74103
75 const appendEventRow = SqlSchema.findOne({104 const appendEventRow = SqlSchema.findOne({
76 Request: AppendEventRequestSchema,105 Request: AppendEventRequestSchema,
77 Result: EventRowSchema,106 Result: OrchestrationEventPersistedRowSchema,
78 execute: (request) =>107 execute: (request) =>
79 sql`108 sql`
80 INSERT INTO orchestration_events (109 INSERT INTO orchestration_events (
81 event_id,110 event_id,
111 aggregate_kind,
112 stream_id,
113 stream_version,
82 event_type,114 event_type,
83 aggregate_type,
84 aggregate_id,
85 occurred_at,115 occurred_at,
86 command_id,116 command_id,
87 payload_json117 causation_event_id,
118 correlation_id,
119 actor_kind,
120 payload_json,
121 metadata_json
88 )122 )
89 VALUES (123 VALUES (
90 ${request.eventId},124 ${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 ),
91 ${request.type},138 ${request.type},
92 ${request.aggregateType},
93 ${request.aggregateId},
94 ${request.occurredAt},139 ${request.occurredAt},
95 ${request.commandId},140 ${request.commandId},
96 ${request.payloadJson}141 ${request.causationEventId},
142 ${request.correlationId},
143 ${request.actorKind},
144 ${request.payloadJson},
145 ${request.metadataJson}
97 )146 )
98 RETURNING147 RETURNING
99 sequence,148 sequence,
100 event_id AS "eventId",149 event_id AS "eventId",
101 event_type AS "type",150 event_type AS "type",
102 aggregate_type AS "aggregateType",151 aggregate_kind AS "aggregateKind",
103 aggregate_id AS "aggregateId",152 stream_id AS "aggregateId",
104 occurred_at AS "occurredAt",153 occurred_at AS "occurredAt",
105 command_id AS "commandId",154 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"
107 `,159 `,
108 });160 });
109161
110 const readEventRowsFromSequence = SqlSchema.findAll({162 const readEventRowsFromSequence = SqlSchema.findAll({
111 Request: ReadFromSequenceRequestSchema,163 Request: ReadFromSequenceRequestSchema,
112 Result: EventRowSchema,164 Result: OrchestrationEventPersistedRowSchema,
113 execute: (request) =>165 execute: (request) =>
114 sql`166 sql`
115 SELECT167 SELECT
116 sequence,168 sequence,
117 event_id AS "eventId",169 event_id AS "eventId",
118 event_type AS "type",170 event_type AS "type",
119 aggregate_type AS "aggregateType",171 aggregate_kind AS "aggregateKind",
120 aggregate_id AS "aggregateId",172 stream_id AS "aggregateId",
121 occurred_at AS "occurredAt",173 occurred_at AS "occurredAt",
122 command_id AS "commandId",174 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"
124 FROM orchestration_events179 FROM orchestration_events
125 WHERE sequence > ${request.sequenceExclusive}180 WHERE sequence > ${request.sequenceExclusive}
126 ORDER BY sequence ASC181 ORDER BY sequence ASC
...@@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () {...@@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () {
129 });184 });
130185
131 const append: OrchestrationEventStoreShape["append"] = (event) =>186 const append: OrchestrationEventStoreShape["append"] = (event) =>
132 Effect.try({187 appendEventRow({
133 try: () => JSON.stringify(event.payload),188 eventId: event.eventId,
134 catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"),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,
135 }).pipe(199 }).pipe(
136 Effect.flatMap((payloadJson) =>200 Effect.mapError(
137 appendEventRow({201 toPersistenceSqlOrDecodeError(
138 eventId: event.eventId,202 "OrchestrationEventStore.append:insert",
139 type: event.type,203 "OrchestrationEventStore.append:decodeRow",
140 aggregateType: event.aggregateType,204 ),
141 aggregateId: event.aggregateId,205 ),
142 occurredAt: event.occurredAt,206 Effect.flatMap((row) =>
143 commandId: event.commandId,207 decodeEvent(row).pipe(
144 payloadJson,208 Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")),
145 }).pipe(
146 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")),
147 Effect.flatMap((row) =>
148 eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"),
149 ),
150 ),209 ),
151 ),210 ),
152 );211 );
...@@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () {...@@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () {
159 if (normalizedLimit === 0) {218 if (normalizedLimit === 0) {
160 return Stream.empty;219 return Stream.empty;
161 }220 }
162 const readPage = (cursor: number, remaining: number): Stream.Stream<221 const readPage = (
163 OrchestrationEvent,222 cursor: number,
164 OrchestrationEventStoreError223 remaining: number,
165 > =>224 ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> =>
166 Stream.fromEffect(225 Stream.fromEffect(
167 readEventRowsFromSequence({226 readEventRowsFromSequence({
168 sequenceExclusive: cursor,227 sequenceExclusive: cursor,
169 limit: Math.min(remaining, READ_PAGE_SIZE),228 limit: Math.min(remaining, READ_PAGE_SIZE),
170 }).pipe(229 }).pipe(
171 Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readFromSequence:query")),230 Effect.mapError(
231 toPersistenceSqlOrDecodeError(
232 "OrchestrationEventStore.readFromSequence:query",
233 "OrchestrationEventStore.readFromSequence:decodeRows",
234 ),
235 ),
172 Effect.flatMap((rows) =>236 Effect.flatMap((rows) =>
173 Effect.forEach(rows, (row) =>237 Effect.forEach(rows, (row) =>
174 eventRowToOrchestrationEvent(238 decodeEvent(row).pipe(
175 row,239 Effect.mapError(
176 "OrchestrationEventStore.readFromSequence:rowToEvent",240 toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"),
241 ),
177 ),242 ),
178 ),243 ),
179 ),244 ),
...@@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () {...@@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () {
204 } satisfies OrchestrationEventStoreShape;269 } satisfies OrchestrationEventStoreShape;
205});270});
206271
207export const OrchestrationEventStoreLive = Layer.effect(272export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore);
208 OrchestrationEventStore,
209 makeEventStore,
210);
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 @@...@@ -1,3 +1,4 @@
1import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
1import * as SqlClient from "effect/unstable/sql/SqlClient";2import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Option, Schema } from "effect";4import { Effect, Layer, Option, Schema } from "effect";
...@@ -12,26 +13,24 @@ import {...@@ -12,26 +13,24 @@ import {
12 type ProviderSessionRepositoryShape,13 type ProviderSessionRepositoryShape,
13} from "../Services/ProviderSessions.ts";14} from "../Services/ProviderSessions.ts";
1415
15const NonEmptyString = Schema.NonEmptyString;16const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
16
17const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]);
1817
19const ProviderSessionRowSchema = Schema.Struct({18const ProviderSessionRowSchema = Schema.Struct({
20 sessionId: Schema.String,19 sessionId: ProviderSessionId,
21 provider: ProviderKindSchema,20 provider: ProviderKind,
22 threadId: Schema.NullOr(Schema.String),21 threadId: Schema.NullOr(ThreadId),
23 createdAt: Schema.String,22 createdAt: Schema.String,
24 updatedAt: Schema.String,23 updatedAt: Schema.String,
25});24});
2625
27const SessionIdRequestSchema = Schema.Struct({26const SessionIdRequestSchema = Schema.Struct({
28 sessionId: NonEmptyString,27 sessionId: ProviderSessionId,
29});28});
3029
31const UpsertSessionRequestSchema = Schema.Struct({30const UpsertSessionRequestSchema = Schema.Struct({
32 sessionId: NonEmptyString,31 sessionId: ProviderSessionId,
33 provider: ProviderKindSchema,32 provider: ProviderKind,
34 threadId: Schema.NullOr(NonEmptyString),33 threadId: Schema.NullOr(ThreadId),
35});34});
3635
37function errorMessage(cause: unknown, fallback: string): string {36function errorMessage(cause: unknown, fallback: string): string {
...@@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () {...@@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () {
163 const parsed = yield* decodeInput(162 const parsed = yield* decodeInput(
164 UpsertSessionRequestSchema,163 UpsertSessionRequestSchema,
165 {164 {
166 sessionId: input.sessionId.trim(),165 sessionId: input.sessionId,
167 provider: input.provider,166 provider: input.provider,
168 threadId: input.threadId?.trim() || null,167 threadId: input.threadId ?? null,
169 },168 },
170 "ProviderSessionRepository.upsertSession",169 "ProviderSessionRepository.upsertSession",
171 );170 );
...@@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {...@@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {
182 Effect.gen(function* () {181 Effect.gen(function* () {
183 const parsed = yield* decodeInput(182 const parsed = yield* decodeInput(
184 SessionIdRequestSchema,183 SessionIdRequestSchema,
185 { sessionId: input.sessionId.trim() },184 { sessionId: input.sessionId },
186 "ProviderSessionRepository.getSession",185 "ProviderSessionRepository.getSession",
187 );186 );
188187
...@@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {...@@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () {
207 Effect.gen(function* () {206 Effect.gen(function* () {
208 const parsed = yield* decodeInput(207 const parsed = yield* decodeInput(
209 SessionIdRequestSchema,208 SessionIdRequestSchema,
210 { sessionId: input.sessionId.trim() },209 { sessionId: input.sessionId },
211 "ProviderSessionRepository.deleteSession",210 "ProviderSessionRepository.deleteSession",
212 );211 );
213212
apps/server/src/persistence/Migrations.ts+8-6
...@@ -13,9 +13,10 @@ import * as Layer from "effect/Layer";...@@ -13,9 +13,10 @@ import * as Layer from "effect/Layer";
1313
14// Import all migrations statically14// Import all migrations statically
15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
16import Migration0002 from "./Migrations/002_Projects.ts";16import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts";
17import Migration0003 from "./Migrations/003_Checkpoints.ts";17import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
18import Migration0004 from "./Migrations/004_ProviderSessions.ts";18import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
19import Migration0005 from "./Migrations/005_Projections.ts";
1920
20/**21/**
21 * Migration loader with all migrations defined inline.22 * Migration loader with all migrations defined inline.
...@@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts";...@@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts";
29 */30 */
30const loader = Migrator.fromRecord({31const loader = Migrator.fromRecord({
31 "1_OrchestrationEvents": Migration0001,32 "1_OrchestrationEvents": Migration0001,
32 "2_Projects": Migration0002,33 "2_OrchestrationCommandReceipts": Migration0002,
33 "3_CheckpointCatalog": Migration0003,34 "3_CheckpointDiffBlobs": Migration0003,
34 "4_ProviderSessions": Migration0004,35 "4_ProviderSessionRuntime": Migration0004,
36 "5_Projections": Migration0005,
35});37});
3638
37/**39/**
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+25-5
...@@ -8,17 +8,37 @@ export default Effect.gen(function* () {...@@ -8,17 +8,37 @@ export default Effect.gen(function* () {
8 CREATE TABLE IF NOT EXISTS orchestration_events (8 CREATE TABLE IF NOT EXISTS orchestration_events (
9 sequence INTEGER PRIMARY KEY AUTOINCREMENT,9 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
10 event_id TEXT NOT NULL UNIQUE,10 event_id TEXT NOT NULL UNIQUE,
11 aggregate_kind TEXT NOT NULL,
12 stream_id TEXT NOT NULL,
13 stream_version INTEGER NOT NULL,
11 event_type TEXT NOT NULL,14 event_type TEXT NOT NULL,
12 aggregate_type TEXT NOT NULL,
13 aggregate_id TEXT NOT NULL,
14 occurred_at TEXT NOT NULL,15 occurred_at TEXT NOT NULL,
15 command_id TEXT,16 command_id TEXT,
16 payload_json TEXT NOT NULL17 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
17 )22 )
18 `;23 `;
1924
20 yield* sql`25 yield* sql`
21 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate26 CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version
22 ON orchestration_events(aggregate_type, aggregate_id, sequence)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)
23 `;43 `;
24});44});
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 @@...@@ -9,7 +9,7 @@
9 *9 *
10 * @module OrchestrationEventStore10 * @module OrchestrationEventStore
11 */11 */
12import type { OrchestrationEvent } from "@t3tools/contracts";12import { OrchestrationEvent } from "@t3tools/contracts";
13import { ServiceMap } from "effect";13import { ServiceMap } from "effect";
14import type { Effect, Stream } from "effect";14import 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 @@...@@ -1,29 +1,29 @@
1import type { ProviderKind } from "@t3tools/contracts";1import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts";
2import { Option, ServiceMap } from "effect";2import { Option, ServiceMap } from "effect";
3import type { Effect } from "effect";3import type { Effect } from "effect";
44
5import type { ProviderSessionRepositoryError } from "../Errors.ts";5import type { ProviderSessionRepositoryError } from "../Errors.ts";
66
7export interface ProviderSessionEntry {7export interface ProviderSessionEntry {
8 readonly sessionId: string;8 readonly sessionId: ProviderSessionId;
9 readonly provider: ProviderKind;9 readonly provider: ProviderKind;
10 readonly threadId?: string;10 readonly threadId?: ThreadId;
11 readonly createdAt: string;11 readonly createdAt: string;
12 readonly updatedAt: string;12 readonly updatedAt: string;
13}13}
1414
15export interface UpsertProviderSessionInput {15export interface UpsertProviderSessionInput {
16 readonly sessionId: string;16 readonly sessionId: ProviderSessionId;
17 readonly provider: ProviderKind;17 readonly provider: ProviderKind;
18 readonly threadId?: string;18 readonly threadId?: ThreadId;
19}19}
2020
21export interface DeleteProviderSessionInput {21export interface DeleteProviderSessionInput {
22 readonly sessionId: string;22 readonly sessionId: ProviderSessionId;
23}23}
2424
25export interface GetProviderSessionInput {25export interface GetProviderSessionInput {
26 readonly sessionId: string;26 readonly sessionId: ProviderSessionId;
27}27}
2828
29export interface ProviderSessionRepositoryShape {29export interface ProviderSessionRepositoryShape {
apps/server/src/provider/Layers/CodexAdapter.ts+26-12
...@@ -6,13 +6,18 @@...@@ -6,13 +6,18 @@
6 *6 *
7 * @module CodexAdapterLive7 * @module CodexAdapterLive
8 */8 */
9import type {9import {
10 ProviderEvent,10 type ProviderEvent,
11 ProviderRuntimeEvent,11 type ProviderRuntimeEvent,
12 ProviderRuntimeTurnStatus,12 type ProviderRuntimeTurnStatus,
13 ProviderRuntimeToolKind,13 type ProviderRuntimeToolKind,
14 ProviderItemId,
15 ProviderApprovalDecision,
16 ProviderSessionId,
17 ProviderThreadId,
18 ProviderTurnId,
14} from "@t3tools/contracts";19} from "@t3tools/contracts";
15import { Effect, Layer, Queue, Stream } from "effect";20import { Effect, Layer, Queue, Schema, Stream } from "effect";
1621
17import {22import {
18 ProviderAdapterProcessError,23 ProviderAdapterProcessError,
...@@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string {...@@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string {
42}47}
4348
44function toSessionError(49function toSessionError(
45 sessionId: string,50 sessionId: ProviderSessionId,
46 cause: unknown,51 cause: unknown,
47): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {52): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined {
48 const normalized = toMessage(cause, "").toLowerCase();53 const normalized = toMessage(cause, "").toLowerCase();
...@@ -63,7 +68,11 @@ function toSessionError(...@@ -63,7 +68,11 @@ function toSessionError(
63 return undefined;68 return undefined;
64}69}
6570
66function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError {71function toRequestError(
72 sessionId: ProviderSessionId,
73 method: string,
74 cause: unknown,
75): ProviderAdapterError {
67 const sessionError = toSessionError(sessionId, cause);76 const sessionError = toSessionError(sessionId, cause);
68 if (sessionError) {77 if (sessionError) {
69 return sessionError;78 return sessionError;
...@@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent |...@@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent |
188 provider: event.provider,197 provider: event.provider,
189 sessionId: event.sessionId,198 sessionId: event.sessionId,
190 createdAt: event.createdAt,199 createdAt: event.createdAt,
191 itemId,200 itemId: ProviderItemId.makeUnsafe(itemId),
192 ...(event.threadId ? { threadId: event.threadId } : {}),201 ...(event.threadId ? { threadId: event.threadId } : {}),
193 ...(event.turnId ? { turnId: event.turnId } : {}),202 ...(event.turnId ? { turnId: event.turnId } : {}),
194 };203 };
...@@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
282 }291 }
283292
284 if (event.method === "item/requestApproval/decision" && event.requestId) {293 if (event.method === "item/requestApproval/decision" && event.requestId) {
285 const decision = asString(payload?.decision);294 const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision);
286 return [295 return [
287 {296 {
288 type: "approval.resolved",297 type: "approval.resolved",
...@@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
339 }348 }
340349
341 if (event.method === "thread/started") {350 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);
343 if (!threadId) {355 if (!threadId) {
344 return [];356 return [];
345 }357 }
...@@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
356 }368 }
357369
358 if (event.method === "turn/started") {370 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);
360 if (!turnId) {374 if (!turnId) {
361 return [];375 return [];
362 }376 }
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-5
...@@ -7,7 +7,6 @@...@@ -7,7 +7,6 @@
7 *7 *
8 * @module ProviderAdapterRegistryLive8 * @module ProviderAdapterRegistryLive
9 */9 */
10import type { ProviderKind } from "@t3tools/contracts";
11import { Effect, Layer } from "effect";10import { Effect, Layer } from "effect";
1211
13import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";12import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts";
...@@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions {...@@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions {
25const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>24const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) =>
26 Effect.gen(function* () {25 Effect.gen(function* () {
27 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];26 const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter];
28 const byProvider = new Map<ProviderKind, ProviderAdapterShape<ProviderAdapterError>>();27 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
29 for (const adapter of adapters) {
30 byProvider.set(adapter.provider, adapter);
31 }
3228
33 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {29 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
34 const adapter = byProvider.get(provider);30 const adapter = byProvider.get(provider);
apps/server/src/provider/Layers/ProviderService.test.ts+216-234
...@@ -4,25 +4,26 @@ import path from "node:path";...@@ -4,25 +4,26 @@ import path from "node:path";
44
5import type {5import type {
6 ProviderApprovalDecision,6 ProviderApprovalDecision,
7 ProviderGetCheckpointDiffInput,
8 ProviderGetCheckpointDiffResult,
9 ProviderListCheckpointsInput,
10 ProviderListCheckpointsResult,
11 ProviderRevertToCheckpointInput,
12 ProviderRuntimeEvent,7 ProviderRuntimeEvent,
13 ProviderRevertToCheckpointResult,
14 ProviderSendTurnInput,8 ProviderSendTurnInput,
15 ProviderSession,9 ProviderSession,
16 ProviderSessionStartInput,
17 ProviderTurnStartResult,10 ProviderTurnStartResult,
18} from "@t3tools/contracts";11} 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";
20import { it, assert, vi } from "@effect/vitest";21import { it, assert, vi } from "@effect/vitest";
21import { assertFailure } from "@effect/vitest/utils";22import { 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";
26import {27import {
27 ProviderAdapterSessionNotFoundError,28 ProviderAdapterSessionNotFoundError,
28 ProviderSessionNotFoundError,29 ProviderSessionNotFoundError,
...@@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";...@@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
34import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";35import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
35import { ProviderService } from "../Services/ProviderService.ts";36import { ProviderService } from "../Services/ProviderService.ts";
36import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";37import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
37import { ProviderServiceLive } from "./ProviderService.ts";38import { makeProviderServiceLive } from "./ProviderService.ts";
38import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";39import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
39import { NodeServices } from "@effect/platform-node";40import { 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";
41import {43import {
42 makeSqlitePersistenceLive,44 makeSqlitePersistenceLive,
43 SqlitePersistenceMemory,45 SqlitePersistenceMemory,
44} from "../../persistence/Layers/Sqlite.ts";46} 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
46function makeFakeCodexAdapter() {65function makeFakeCodexAdapter() {
47 const sessions = new Map<string, ProviderSession>();66 const sessions = new Map<ProviderSessionId, ProviderSession>();
48 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());67 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
49 let nextSession = 1;68 let nextSession = 1;
5069
51 const startSession = vi.fn((input: ProviderSessionStartInput) =>70 const startSession = vi.fn((input: ProviderSessionStartInput) =>
52 Effect.sync(() => {71 Effect.sync(() => {
53 const now = new Date().toISOString();72 const now = new Date().toISOString();
73 const next = nextSession;
54 const session: ProviderSession = {74 const session: ProviderSession = {
55 sessionId: `sess-${nextSession}`,75 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
56 provider: "codex",76 provider: "codex",
57 status: "ready",77 status: "ready",
58 threadId: input.resumeThreadId ?? `thread-${nextSession}`,78 threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`),
59 cwd: input.cwd ?? process.cwd(),79 cwd: input.cwd ?? process.cwd(),
60 createdAt: now,80 createdAt: now,
61 updatedAt: now,81 updatedAt: now,
...@@ -80,8 +100,8 @@ function makeFakeCodexAdapter() {...@@ -80,8 +100,8 @@ function makeFakeCodexAdapter() {
80 }100 }
81101
82 return Effect.succeed({102 return Effect.succeed({
83 threadId: "thread-1",103 threadId: ProviderThreadId.makeUnsafe("thread-1"),
84 turnId: "turn-1",104 turnId: ProviderTurnId.makeUnsafe("turn-1"),
85 });105 });
86 },106 },
87 );107 );
...@@ -102,7 +122,7 @@ function makeFakeCodexAdapter() {...@@ -102,7 +122,7 @@ function makeFakeCodexAdapter() {
102 const stopSession = vi.fn(122 const stopSession = vi.fn(
103 (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>123 (sessionId: string): Effect.Effect<void, ProviderAdapterError> =>
104 Effect.sync(() => {124 Effect.sync(() => {
105 sessions.delete(sessionId);125 sessions.delete(ProviderSessionId.makeUnsafe(sessionId));
106 }),126 }),
107 );127 );
108128
...@@ -112,22 +132,29 @@ function makeFakeCodexAdapter() {...@@ -112,22 +132,29 @@ function makeFakeCodexAdapter() {
112 );132 );
113133
114 const hasSession = vi.fn(134 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))),
116 );137 );
117138
118 const readThread = vi.fn(139 const readThread = vi.fn(
119 (140 (
120 _sessionId: string,141 _sessionId: ProviderSessionId,
121 ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> =>142 ): Effect.Effect<
122 Effect.succeed({ threadId: "thread-1", turns: [] }),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 }),
123 );150 );
124151
125 const rollbackThread = vi.fn(152 const rollbackThread = vi.fn(
126 (153 (
127 _sessionId: string,154 _sessionId: ProviderSessionId,
128 _numTurns: number,155 _numTurns: number,
129 ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> =>156 ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> =>
130 Effect.succeed({ threadId: "thread-1", turns: [] }),157 Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }),
131 );158 );
132159
133 const stopAll = vi.fn(160 const stopAll = vi.fn(
...@@ -172,80 +199,11 @@ function makeFakeCodexAdapter() {...@@ -172,80 +199,11 @@ function makeFakeCodexAdapter() {
172 };199 };
173}200}
174201
175function makeCheckpointServiceDouble() {202const sleep = (ms: number) =>
176 const initializeForSession = vi.fn(203 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
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}
245204
246function makeProviderServiceLayer() {205function makeProviderServiceLayer() {
247 const codex = makeFakeCodexAdapter();206 const codex = makeFakeCodexAdapter();
248 const checkpoint = makeCheckpointServiceDouble();
249 const registry: typeof ProviderAdapterRegistry.Service = {207 const registry: typeof ProviderAdapterRegistry.Service = {
250 getByProvider: (provider) =>208 getByProvider: (provider) =>
251 provider === "codex"209 provider === "codex"
...@@ -254,28 +212,26 @@ function makeProviderServiceLayer() {...@@ -254,28 +212,26 @@ function makeProviderServiceLayer() {
254 listProviders: () => Effect.succeed(["codex"]),212 listProviders: () => Effect.succeed(["codex"]),
255 };213 };
256214
257 const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service);
258 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);215 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
259 const directoryLayer = ProviderSessionDirectoryLive.pipe(216 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
260 Layer.provide(ProviderSessionRepositoryLive),
261 Layer.provide(SqlitePersistenceMemory),217 Layer.provide(SqlitePersistenceMemory),
262 );218 );
219 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
263220
264 const layer = it.layer(221 const layer = it.layer(
265 Layer.mergeAll(222 Layer.mergeAll(
266 ProviderServiceLive.pipe(223 makeProviderServiceLive().pipe(
267 Layer.provide(providerAdapterLayer),224 Layer.provide(providerAdapterLayer),
268 Layer.provide(directoryLayer),225 Layer.provide(directoryLayer),
269 Layer.provide(checkpointLayer),
270 ),226 ),
271 directoryLayer,227 directoryLayer,
228 runtimeRepositoryLayer,
272 NodeServices.layer,229 NodeServices.layer,
273 ),230 ),
274 );231 );
275232
276 return {233 return {
277 codex,234 codex,
278 checkpoint,
279 layer,235 layer,
280 };236 };
281}237}
...@@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
287 const dbPath = path.join(tempDir, "orchestration.sqlite");243 const dbPath = path.join(tempDir, "orchestration.sqlite");
288244
289 const codex = makeFakeCodexAdapter();245 const codex = makeFakeCodexAdapter();
290 const checkpoint = makeCheckpointServiceDouble();
291 const registry: typeof ProviderAdapterRegistry.Service = {246 const registry: typeof ProviderAdapterRegistry.Service = {
292 getByProvider: (provider) =>247 getByProvider: (provider) =>
293 provider === "codex"248 provider === "codex"
...@@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
297 };252 };
298253
299 const persistenceLayer = makeSqlitePersistenceLive(dbPath);254 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
300 const directoryLayer = ProviderSessionDirectoryLive.pipe(255 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
301 Layer.provide(ProviderSessionRepositoryLive),
302 Layer.provide(persistenceLayer),256 Layer.provide(persistenceLayer),
303 );257 );
258 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
304259
305 yield* Effect.gen(function* () {260 yield* Effect.gen(function* () {
306 const directory = yield* ProviderSessionDirectory;261 const directory = yield* ProviderSessionDirectory;
307 yield* directory.upsert({262 yield* directory.upsert({
308 sessionId: "sess-stale",263 sessionId: asSessionId("sess-stale"),
309 provider: "codex",264 provider: "codex",
310 threadId: "thread-stale",265 threadId: ThreadId.makeUnsafe("thread-stale"),
311 });266 });
312 }).pipe(Effect.provide(directoryLayer));267 }).pipe(Effect.provide(directoryLayer));
313268
314 const providerLayer = ProviderServiceLive.pipe(269 const providerLayer = makeProviderServiceLive().pipe(
315 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),270 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
316 Layer.provide(directoryLayer),271 Layer.provide(directoryLayer),
317 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
318 );272 );
319273
320 checkpoint.releaseSession.mockClear();
321 yield* Effect.gen(function* () {274 yield* Effect.gen(function* () {
322 yield* ProviderService;275 yield* ProviderService;
323 }).pipe(Effect.provide(providerLayer));276 }).pipe(Effect.provide(providerLayer));
324277
325 assert.deepEqual(checkpoint.releaseSession.mock.calls, []);
326 const persistedProvider = yield* Effect.gen(function* () {278 const persistedProvider = yield* Effect.gen(function* () {
327 const directory = yield* ProviderSessionDirectory;279 const directory = yield* ProviderSessionDirectory;
328 return yield* directory.getProvider("sess-stale");280 return yield* directory.getProvider(asSessionId("sess-stale"));
329 }).pipe(Effect.provide(directoryLayer));281 }).pipe(Effect.provide(directoryLayer));
330 assert.equal(persistedProvider, "codex");282 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
332 fs.rmSync(tempDir, { recursive: true, force: true });300 fs.rmSync(tempDir, { recursive: true, force: true });
333 }).pipe(Effect.provide(NodeServices.layer)),301 }).pipe(Effect.provide(NodeServices.layer)),
334);302);
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", () =>
337 Effect.gen(function* () {305 Effect.gen(function* () {
338 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-"));306 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-"));
339 const dbPath = path.join(tempDir, "orchestration.sqlite");307 const dbPath = path.join(tempDir, "orchestration.sqlite");
340 const persistenceLayer = makeSqlitePersistenceLive(dbPath);308 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
309 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
310 Layer.provide(persistenceLayer),
311 );
341312
342 const checkpoint = makeCheckpointServiceDouble();
343 const firstCodex = makeFakeCodexAdapter();313 const firstCodex = makeFakeCodexAdapter();
344 const firstRegistry: typeof ProviderAdapterRegistry.Service = {314 const firstRegistry: typeof ProviderAdapterRegistry.Service = {
345 getByProvider: (provider) =>315 getByProvider: (provider) =>
...@@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi...@@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
350 };320 };
351321
352 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(322 const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe(
353 Layer.provide(ProviderSessionRepositoryLive),323 Layer.provide(runtimeRepositoryLayer),
354 Layer.provide(persistenceLayer),
355 );324 );
356 const firstProviderLayer = ProviderServiceLive.pipe(325 const firstProviderLayer = makeProviderServiceLive().pipe(
357 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),326 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
358 Layer.provide(firstDirectoryLayer),327 Layer.provide(firstDirectoryLayer),
359 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
360 );328 );
361329
362 const startedSession = yield* Effect.gen(function* () {330 const startedSession = yield* Effect.gen(function* () {
...@@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi...@@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
372 yield* provider.stopAll();340 yield* provider.stopAll();
373 }).pipe(Effect.provide(firstProviderLayer));341 }).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
375 const secondCodex = makeFakeCodexAdapter();357 const secondCodex = makeFakeCodexAdapter();
376 const secondRegistry: typeof ProviderAdapterRegistry.Service = {358 const secondRegistry: typeof ProviderAdapterRegistry.Service = {
377 getByProvider: (provider) =>359 getByProvider: (provider) =>
...@@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi...@@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
381 listProviders: () => Effect.succeed(["codex"]),363 listProviders: () => Effect.succeed(["codex"]),
382 };364 };
383 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(365 const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe(
384 Layer.provide(ProviderSessionRepositoryLive),366 Layer.provide(runtimeRepositoryLayer),
385 Layer.provide(persistenceLayer),
386 );367 );
387 const secondProviderLayer = ProviderServiceLive.pipe(368 const secondProviderLayer = makeProviderServiceLive().pipe(
388 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),369 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
389 Layer.provide(secondDirectoryLayer),370 Layer.provide(secondDirectoryLayer),
390 Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)),
391 );371 );
392372
393 checkpoint.getCheckpointDiff.mockClear();
394 secondCodex.startSession.mockClear();373 secondCodex.startSession.mockClear();
374 secondCodex.rollbackThread.mockClear();
395375
396 yield* Effect.gen(function* () {376 yield* Effect.gen(function* () {
397 const provider = yield* ProviderService;377 const provider = yield* ProviderService;
398 yield* provider.getCheckpointDiff({378 yield* provider.rollbackConversation({
399 sessionId: startedSession.sessionId,379 sessionId: startedSession.sessionId,
400 fromTurnCount: 0,380 numTurns: 1,
401 toTurnCount: 0,
402 });381 });
403 }).pipe(Effect.provide(secondProviderLayer));382 }).pipe(Effect.provide(secondProviderLayer));
404383
...@@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi...@@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi
410 },389 },
411 ],390 ],
412 ]);391 ]);
413 assert.equal(checkpoint.getCheckpointDiff.mock.calls.length, 1);392 assert.equal(secondCodex.rollbackThread.mock.calls.length, 1);
414 const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0];393 const rollbackCall = secondCodex.rollbackThread.mock.calls[0];
415 assert.equal(typeof recoveredDiffInput?.sessionId, "string");394 assert.equal(typeof rollbackCall?.[0], "string");
416 assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true);395 assert.equal(rollbackCall?.[1], 1);
417 assert.equal(recoveredDiffInput?.fromTurnCount, 0);
418 assert.equal(recoveredDiffInput?.toTurnCount, 0);
419396
420 fs.rmSync(tempDir, { recursive: true, force: true });397 fs.rmSync(tempDir, { recursive: true, force: true });
421 }).pipe(Effect.provide(NodeServices.layer)),398 }).pipe(Effect.provide(NodeServices.layer)),
422);399);
423400
424routing.layer("ProviderServiceLive routing", (it) => {401routing.layer("ProviderServiceLive routing", (it) => {
425 it.effect("routes provider operations and delegates checkpoint workflows", () =>402 it.effect("routes provider operations and rollback conversation", () =>
426 Effect.gen(function* () {403 Effect.gen(function* () {
427 const provider = yield* ProviderService;404 const provider = yield* ProviderService;
428405
...@@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => {
431 cwd: "/tmp/project",408 cwd: "/tmp/project",
432 });409 });
433 assert.equal(session.provider, "codex");410 assert.equal(session.provider, "codex");
434 assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [
435 [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }],
436 ]);
437411
438 const sessions = yield* provider.listSessions();412 const sessions = yield* provider.listSessions();
439 assert.equal(sessions.length, 1);413 assert.equal(sessions.length, 1);
...@@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => {
450424
451 yield* provider.respondToRequest({425 yield* provider.respondToRequest({
452 sessionId: session.sessionId,426 sessionId: session.sessionId,
453 requestId: "req-1",427 requestId: asRequestId("req-1"),
454 decision: "accept",428 decision: "accept",
455 });429 });
456 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [430 assert.deepEqual(routing.codex.respondToRequest.mock.calls, [
457 [session.sessionId, "req-1", "accept"],431 [session.sessionId, asRequestId("req-1"), "accept"],
458 ]);432 ]);
459433
460 yield* provider.listCheckpoints({ sessionId: session.sessionId });434 yield* provider.rollbackConversation({
461 yield* provider.getCheckpointDiff({
462 sessionId: session.sessionId,435 sessionId: session.sessionId,
463 fromTurnCount: 0,436 numTurns: 0,
464 toTurnCount: 0,
465 });437 });
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
481 yield* provider.stopSession({ sessionId: session.sessionId });439 yield* provider.stopSession({ sessionId: session.sessionId });
482 assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [
483 [{ providerSessionId: session.sessionId }],
484 ]);
485 const sendAfterStop = yield* Effect.result(440 const sendAfterStop = yield* Effect.result(
486 provider.sendTurn({441 provider.sendTurn({
487 sessionId: session.sessionId,442 sessionId: session.sessionId,
...@@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
496 }),451 }),
497 );452 );
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", () =>
500 Effect.gen(function* () {455 Effect.gen(function* () {
501 const provider = yield* ProviderService;456 const provider = yield* ProviderService;
502457
...@@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => {
505 cwd: "/tmp/project",460 cwd: "/tmp/project",
506 });461 });
507 yield* routing.codex.stopSession(initial.sessionId);462 yield* routing.codex.stopSession(initial.sessionId);
508
509 routing.checkpoint.getCheckpointDiff.mockClear();
510 routing.checkpoint.revertToCheckpoint.mockClear();
511 routing.codex.startSession.mockClear();463 routing.codex.startSession.mockClear();
464 routing.codex.rollbackThread.mockClear();
512465
513 yield* provider.getCheckpointDiff({466 yield* provider.rollbackConversation({
514 sessionId: initial.sessionId,467 sessionId: initial.sessionId,
515 fromTurnCount: 0,468 numTurns: 1,
516 toTurnCount: 0,
517 });
518 yield* provider.revertToCheckpoint({
519 sessionId: initial.sessionId,
520 turnCount: 0,
521 });469 });
522470
523 assert.deepEqual(routing.codex.startSession.mock.calls, [471 assert.deepEqual(routing.codex.startSession.mock.calls, [
...@@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => {
528 },476 },
529 ],477 ],
530 ]);478 ]);
531 assert.equal(routing.checkpoint.getCheckpointDiff.mock.calls.length, 1);479 assert.equal(routing.codex.rollbackThread.mock.calls.length, 1);
532 assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1);480 const rollbackCall = routing.codex.rollbackThread.mock.calls[0];
533481 assert.equal(rollbackCall?.[1], 1);
534 const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0];
535 const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0];
536 assert.equal(diffInput?.fromTurnCount, 0);
537 assert.equal(diffInput?.toTurnCount, 0);
538 assert.equal(diffInput?.sessionId === initial.sessionId, false);
539 assert.equal(revertInput?.sessionId, diffInput?.sessionId);
540 assert.equal(revertInput?.turnCount, 0);
541 }),482 }),
542 );483 );
543484
544 it.effect("releases checkpoint session state when stopping all sessions", () =>485 it.effect("stops all sessions and clears adapter runtime", () =>
545 Effect.gen(function* () {486 Effect.gen(function* () {
546 const provider = yield* ProviderService;487 const provider = yield* ProviderService;
547488
548 const first = yield* provider.startSession({489 yield* provider.startSession({
549 provider: "codex",490 provider: "codex",
550 });491 });
551 const second = yield* provider.startSession({492 yield* provider.startSession({
552 provider: "codex",493 provider: "codex",
553 });494 });
554495
555 yield* provider.stopAll();496 yield* provider.stopAll();
556497
557 assert.deepEqual(routing.checkpoint.releaseSession.mock.calls.slice(-2), [
558 [{ providerSessionId: first.sessionId }],
559 [{ providerSessionId: second.sessionId }],
560 ]);
561
562 const remaining = yield* provider.listSessions();498 const remaining = yield* provider.listSessions();
563 assert.equal(remaining.length, 0);499 assert.equal(remaining.length, 0);
564 }),500 }),
565 );501 );
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 );
566});547});
567548
568const fanout = makeProviderServiceLayer();549const fanout = makeProviderServiceLayer();
569fanout.layer("ProviderServiceLive fanout", (it) => {550fanout.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", () =>
571 Effect.gen(function* () {552 Effect.gen(function* () {
572 const provider = yield* ProviderService;553 const provider = yield* ProviderService;
573 const session = yield* provider.startSession({554 const session = yield* provider.startSession({
...@@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
577 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);558 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
578 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>559 const consumer = yield* Stream.runForEach(provider.streamEvents, (event) =>
579 Ref.update(eventsRef, (current) => [...current, event]),560 Ref.update(eventsRef, (current) => [...current, event]),
580 ).pipe(561 ).pipe(Effect.forkChild);
581 Effect.forkChild,562 yield* sleep(20);
582 );
583 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
584563
585 const completedEvent: ProviderRuntimeEvent = {564 const completedEvent: ProviderRuntimeEvent = {
586 type: "turn.completed",565 type: "turn.completed",
587 eventId: "evt-1",566 eventId: asEventId("evt-1"),
588 provider: "codex",567 provider: "codex",
589 sessionId: session.sessionId,568 sessionId: session.sessionId,
590 createdAt: new Date().toISOString(),569 createdAt: new Date().toISOString(),
591 threadId: "thread-1",570 threadId: asThreadId("thread-1"),
592 turnId: "turn-1",571 turnId: asTurnId("turn-1"),
593 status: "completed",572 status: "completed",
594 };573 };
595574
596 fanout.codex.emit(completedEvent);575 fanout.codex.emit(completedEvent);
597 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));576 yield* sleep(20);
598577
599 const events = yield* Ref.get(eventsRef);578 const events = yield* Ref.get(eventsRef);
600 yield* Fiber.interrupt(consumer);579 yield* Fiber.interrupt(consumer);
601580
602 assert.equal(events.length >= 2, true);581 assert.equal(events.some((entry) => entry.type === "turn.completed"), 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 ]);
608 }),582 }),
609 );583 );
610584
...@@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
614 const session = yield* provider.startSession({588 const session = yield* provider.startSession({
615 provider: "codex",589 provider: "codex",
616 });590 });
617 fanout.checkpoint.captureCurrentTurn.mockClear();
618591
619 const receivedByHealthy: string[] = [];592 const receivedByHealthy: string[] = [];
593 const expectedEventIds = new Set<string>([
594 "evt-ordered-1",
595 "evt-ordered-2",
596 "evt-ordered-3",
597 ]);
620 const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe(598 const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe(
621 Stream.runForEach((event) =>599 Stream.runForEach((event) =>
622 Effect.sync(() => {600 Effect.sync(() => {
...@@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
629 Stream.runForEach(() => Effect.fail("listener crash")),607 Stream.runForEach(() => Effect.fail("listener crash")),
630 Effect.forkChild,608 Effect.forkChild,
631 );609 );
632 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));610 yield* sleep(20);
633611
634 const events: ReadonlyArray<ProviderRuntimeEvent> = [612 const events: ReadonlyArray<ProviderRuntimeEvent> = [
635 {613 {
636 type: "tool.completed",614 type: "tool.completed",
637 eventId: "evt-ordered-1",615 eventId: asEventId("evt-ordered-1"),
638 provider: "codex",616 provider: "codex",
639 sessionId: session.sessionId,617 sessionId: session.sessionId,
640 createdAt: new Date().toISOString(),618 createdAt: new Date().toISOString(),
641 threadId: "thread-1",619 threadId: asThreadId("thread-1"),
642 turnId: "turn-1",620 turnId: asTurnId("turn-1"),
643 toolKind: "command",621 toolKind: "command",
644 title: "Command run",622 title: "Command run",
645 detail: "echo one",623 detail: "echo one",
646 },624 },
647 {625 {
648 type: "message.delta",626 type: "message.delta",
649 eventId: "evt-ordered-2",627 eventId: asEventId("evt-ordered-2"),
650 provider: "codex",628 provider: "codex",
651 sessionId: session.sessionId,629 sessionId: session.sessionId,
652 createdAt: new Date().toISOString(),630 createdAt: new Date().toISOString(),
653 threadId: "thread-1",631 threadId: asThreadId("thread-1"),
654 turnId: "turn-1",632 turnId: asTurnId("turn-1"),
655 delta: "hello",633 delta: "hello",
656 },634 },
657 {635 {
658 type: "turn.completed",636 type: "turn.completed",
659 eventId: "evt-ordered-3",637 eventId: asEventId("evt-ordered-3"),
660 provider: "codex",638 provider: "codex",
661 sessionId: session.sessionId,639 sessionId: session.sessionId,
662 createdAt: new Date().toISOString(),640 createdAt: new Date().toISOString(),
663 threadId: "thread-1",641 threadId: asThreadId("thread-1"),
664 turnId: "turn-1",642 turnId: asTurnId("turn-1"),
665 status: "completed",643 status: "completed",
666 },644 },
667 ];645 ];
...@@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
673 assert.equal(failingResult._tag, "Failure");651 assert.equal(failingResult._tag, "Failure");
674 yield* Fiber.join(healthyFiber);652 yield* Fiber.join(healthyFiber);
675653
676 assert.deepEqual(receivedByHealthy.slice(0, 3), [654 assert.deepEqual(
677 "evt-ordered-1",655 receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3),
678 "evt-ordered-2",656 [
679 "evt-ordered-3",657 "evt-ordered-1",
680 ]);658 "evt-ordered-2",
681 assert.equal(receivedByHealthy.length, 3);659 "evt-ordered-3",
660 ],
661 );
682 }),662 }),
683 );663 );
684});664});
...@@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => {
689 Effect.gen(function* () {669 Effect.gen(function* () {
690 const provider = yield* ProviderService;670 const provider = yield* ProviderService;
691671
692 const parse = providerSessionStartInputSchema.safeParse({672 let parseCause: unknown;
693 provider: "invalid-provider",673 let decodeFailed = false;
694 });674 try {
695 assert.equal(parse.success, false);675 Schema.decodeUnknownSync(ProviderSessionStartInput)({
696 if (parse.success) {676 provider: "invalid-provider",
697 return;677 });
678 } catch (cause) {
679 parseCause = cause;
680 decodeFailed = true;
698 }681 }
682 assert.equal(decodeFailed, true);
699683
700 const failure = yield* Effect.result(684 const failure = yield* Effect.result(
701 provider.startSession({685 provider.startSession({
...@@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => {
703 } as never),687 } as never),
704 );688 );
705689
706 const cause = (parse.error as { cause?: unknown }).cause;
707 assertFailure(690 assertFailure(
708 failure,691 failure,
709 new ProviderValidationError({692 new ProviderValidationError({
710 operation: "ProviderService.startSession",693 operation: "ProviderService.startSession",
711 issue: parse.error.message,694 issue: decodeIssueMessage(parseCause),
712 ...(cause !== undefined ? { cause } : {}),695 cause: parseCause,
713 }),696 }),
714 );697 );
715 }),698 }),
...@@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
723 Effect.sync(() => {706 Effect.sync(() => {
724 const now = new Date().toISOString();707 const now = new Date().toISOString();
725 return {708 return {
726 sessionId: "sess-missing-thread",709 sessionId: asSessionId("sess-missing-thread"),
727 provider: "codex",710 provider: "codex",
728 status: "ready",711 status: "ready",
729 cwd: input.cwd ?? process.cwd(),712 cwd: input.cwd ?? process.cwd(),
...@@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => {
743 assertFailure(726 assertFailure(
744 failure,727 failure,
745 new ProviderValidationError({728 new ProviderValidationError({
746 operation: "ProviderService.startSession",729 operation: "ProviderService.startSession",
747 issue:730 issue: "Provider 'codex' returned a session without threadId.",
748 "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.",731 }),
749 }),
750 );732 );
751 }),733 }),
752 );734 );
apps/server/src/provider/Layers/ProviderService.ts+190-237
...@@ -5,29 +5,25 @@...@@ -5,29 +5,25 @@
5 * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a5 * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a
6 * unified provider event stream for subscribers.6 * unified provider event stream for subscribers.
7 *7 *
8 * It does not implement provider protocol details (adapter concern) and does8 * It does not implement provider protocol details (adapter concern).
9 * not implement checkpoint persistence mechanics (checkpointing concern).
10 *9 *
11 * @module ProviderServiceLive10 * @module ProviderServiceLive
12 */11 */
13import { randomUUID } from "node:crypto";
14
15import {12import {
16 providerGetCheckpointDiffInputSchema,13 NonNegativeInt,
17 providerInterruptTurnInputSchema,14 ProviderSessionId,
18 providerListCheckpointsInputSchema,15 ProviderThreadId,
19 providerRespondToRequestInputSchema,16 ThreadId,
20 providerRevertToCheckpointInputSchema,17 ProviderInterruptTurnInput,
21 providerSendTurnInputSchema,18 ProviderRespondToRequestInput,
22 providerSessionStartInputSchema,19 ProviderSendTurnInput,
23 providerStopSessionInputSchema,20 ProviderSessionStartInput,
21 ProviderStopSessionInput,
24 type ProviderRuntimeEvent,22 type ProviderRuntimeEvent,
25 type ProviderRuntimeTurnCompletedEvent,
26 type ProviderSession,23 type ProviderSession,
27} from "@t3tools/contracts";24} 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";
31import { ProviderValidationError } from "../Errors.ts";27import { ProviderValidationError } from "../Errors.ts";
32import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";28import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
33import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";29import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
...@@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions {...@@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions {
38 readonly canonicalEventLogPath?: string;34 readonly canonicalEventLogPath?: string;
39}35}
4036
41function toCheckpointCaptureErrorEvent(37const ProviderRollbackConversationInput = Schema.Struct({
42 event: ProviderRuntimeTurnCompletedEvent,38 sessionId: ProviderSessionId,
43 error: { readonly message: string },39 numTurns: NonNegativeInt,
44): ProviderRuntimeEvent {40});
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}
7541
76function toValidationError(42function toValidationError(
77 operation: string,43 operation: string,
...@@ -85,6 +51,63 @@ function toValidationError(...@@ -85,6 +51,63 @@ function toValidationError(
85 });51 });
86}52}
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
88const makeProviderService = (options?: ProviderServiceLiveOptions) =>111const makeProviderService = (options?: ProviderServiceLiveOptions) =>
89 Effect.gen(function* () {112 Effect.gen(function* () {
90 const canonicalEventLogger =113 const canonicalEventLogger =
...@@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
94117
95 const registry = yield* ProviderAdapterRegistry;118 const registry = yield* ProviderAdapterRegistry;
96 const directory = yield* ProviderSessionDirectory;119 const directory = yield* ProviderSessionDirectory;
97 const checkpointService = yield* CheckpointService;
98
99 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();120 const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
100 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();121 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
103 const canonicalizeRuntimeEventSession = (126 const canonicalizeRuntimeEventSession = (
104 event: ProviderRuntimeEvent,127 event: ProviderRuntimeEvent,
...@@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
109 if (liveSessionId === event.sessionId) {132 if (liveSessionId === event.sessionId) {
110 return {133 return {
111 ...event,134 ...event,
112 sessionId: staleSessionId,135 sessionId: ProviderSessionId.makeUnsafe(staleSessionId),
113 } satisfies ProviderRuntimeEvent;136 } satisfies ProviderRuntimeEvent;
114 }137 }
115 }138 }
...@@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
131 Effect.asVoid,154 Effect.asVoid,
132 );155 );
133156
134 const upsertSessionBinding = (157 const upsertSessionBinding = (session: ProviderSession, operation: string) =>
135 session: ProviderSession,
136 operation: string,
137 ) =>
138 Effect.gen(function* () {158 Effect.gen(function* () {
139 const threadId = session.threadId?.trim();159 const providerThreadId = session.threadId;
140 if (!threadId) {160 if (!providerThreadId) {
141 return yield* toValidationError(161 return yield* toValidationError(
142 operation,162 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.`,
144 );164 );
145 }165 }
146166
167 const providerSessionId = asProviderSessionId(session.sessionId);
168 const brandedThreadId = ThreadId.makeUnsafe(providerThreadId);
147 yield* directory.upsert({169 yield* directory.upsert({
148 sessionId: session.sessionId,170 sessionId: providerSessionId,
149 provider: session.provider,171 provider: session.provider,
150 threadId,172 threadId: brandedThreadId,
173 providerThreadId,
174 status: toRuntimeStatus(session),
175 resumeCursor: { resumeThreadId: providerThreadId },
176 runtimePayload: toRuntimePayloadFromSession(session),
151 });177 });
152178
153 return threadId;179 return brandedThreadId;
154 });180 });
155181
156 const clearAliasKey = (staleSessionId: string) =>182 const clearAliasKey = (staleSessionId: ProviderSessionId) =>
157 Ref.update(routedSessionAliasesRef, (current) => {183 Ref.update(routedSessionAliasesRef, (current) => {
158 if (!current.has(staleSessionId)) {184 if (!current.has(staleSessionId)) {
159 return current;185 return current;
...@@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
163 return next;189 return next;
164 });190 });
165191
166 const clearAliasesReferencing = (sessionId: string) =>192 const clearAliasesReferencing = (sessionId: ProviderSessionId) =>
167 Ref.update(routedSessionAliasesRef, (current) => {193 Ref.update(routedSessionAliasesRef, (current) => {
168 let changed = false;194 let changed = false;
169 const next = new Map<string, string>();195 const next = new Map<ProviderSessionId, ProviderSessionId>();
170 for (const [key, value] of current) {196 for (const [key, value] of current) {
171 if (key === sessionId || value === sessionId) {197 if (key === sessionId || value === sessionId) {
172 changed = true;198 changed = true;
...@@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
177 return changed ? next : current;203 return changed ? next : current;
178 });204 });
179205
180 const setAlias = (staleSessionId: string, liveSessionId: string) =>206 const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) =>
181 Ref.update(routedSessionAliasesRef, (current) => {207 Ref.update(routedSessionAliasesRef, (current) => {
182 const existing = current.get(staleSessionId);208 const existing = current.get(staleSessionId);
183 if (existing === liveSessionId) {209 if (existing === liveSessionId) {
...@@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
193 registry.getByProvider(provider),219 registry.getByProvider(provider),
194 );220 );
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
224 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>222 const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
225 publishRuntimeEvent(event).pipe(223 publishRuntimeEvent(event);
226 Effect.flatMap(() => {
227 if (event.type !== "turn.completed") {
228 return Effect.void;
229 }
230 return onTurnCompleted(event);
231 }),
232 );
233224
234 const worker = Effect.forever(225 const worker = Effect.forever(
235 Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)),226 Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)),
...@@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
243 ).pipe(Effect.asVoid);234 ).pipe(Effect.asVoid);
244235
245 const recoverSessionForThread = (input: {236 const recoverSessionForThread = (input: {
246 readonly staleSessionId: string;237 readonly staleSessionId: ProviderSessionId;
247 readonly provider: ProviderSession["provider"];238 readonly provider: ProviderSession["provider"];
248 readonly threadId: string;239 readonly threadId: ThreadId;
249 readonly operation: string;240 readonly operation: string;
250 }) =>241 }) =>
251 Effect.gen(function* () {242 Effect.gen(function* () {
252 const adapter = yield* registry.getByProvider(input.provider);243 const adapter = yield* registry.getByProvider(input.provider);
253 const activeSessions = yield* adapter.listSessions();244 const activeSessions = yield* adapter.listSessions();
254 const existing = activeSessions.find(245 const existing = activeSessions.find(
255 (session) => session.threadId?.trim() === input.threadId,246 (session) => session.threadId?.trim() === input.threadId.trim(),
256 );247 );
257 if (existing) {248 if (existing) {
258 const existingThreadId = yield* upsertSessionBinding(249 const existingThreadId = yield* upsertSessionBinding(
...@@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
277268
278 const resumed = yield* adapter.startSession({269 const resumed = yield* adapter.startSession({
279 provider: input.provider,270 provider: input.provider,
280 resumeThreadId: input.threadId,271 resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId),
281 });272 });
282 if (resumed.provider !== adapter.provider) {273 if (resumed.provider !== adapter.provider) {
283 return yield* toValidationError(274 return yield* toValidationError(
...@@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
303 threadId: resumedThreadId,294 threadId: resumedThreadId,
304 });295 });
305296
306 const checkpointCwd = resumed.cwd ?? process.cwd();
307 yield* checkpointService.initializeForSession({
308 providerSessionId: resumed.sessionId,
309 cwd: checkpointCwd,
310 });
311
312 if (resumed.sessionId !== input.staleSessionId) {297 if (resumed.sessionId !== input.staleSessionId) {
313 yield* setAlias(input.staleSessionId, resumed.sessionId);298 yield* setAlias(input.staleSessionId, resumed.sessionId);
314 } else {299 } else {
...@@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
322 });307 });
323308
324 const resolveRoutableSession = (input: {309 const resolveRoutableSession = (input: {
325 readonly sessionId: string;310 readonly sessionId: ProviderSessionId;
326 readonly operation: string;311 readonly operation: string;
327 readonly allowRecovery: boolean;312 readonly allowRecovery: boolean;
328 }) =>313 }) =>
...@@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
364 }349 }
365350
366 const threadIdOption = yield* directory.getThreadId(input.sessionId);351 const threadIdOption = yield* directory.getThreadId(input.sessionId);
367 const threadId = Option.getOrUndefined(threadIdOption)?.trim();352 const threadId = Option.getOrUndefined(threadIdOption);
368 if (!threadId) {353 if (!threadId) {
369 return yield* toValidationError(354 return yield* toValidationError(
370 input.operation,355 input.operation,
...@@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
388373
389 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>374 const startSession: ProviderServiceShape["startSession"] = (rawInput) =>
390 Effect.gen(function* () {375 Effect.gen(function* () {
391 const parsed = providerSessionStartInputSchema.safeParse(rawInput);376 const parsed = yield* decodeInputOrValidationError({
392 if (!parsed.success) {377 operation: "ProviderService.startSession",
393 return yield* toValidationError(378 schema: ProviderSessionStartInput,
394 "ProviderService.startSession",379 payload: rawInput,
395 parsed.error.message,380 });
396 parsed.error.cause,
397 );
398 }
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 };
401 const adapter = yield* registry.getByProvider(input.provider);388 const adapter = yield* registry.getByProvider(input.provider);
402 const session = yield* adapter.startSession(input);389 const session = yield* adapter.startSession(input);
403390
...@@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
410397
411 yield* upsertSessionBinding(session, "ProviderService.startSession");398 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
419 return session;400 return session;
420 });401 });
421402
422 const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) =>403 const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) =>
423 Effect.gen(function* () {404 Effect.gen(function* () {
424 const parsed = providerSendTurnInputSchema.safeParse(rawInput);405 const parsed = yield* decodeInputOrValidationError({
425 if (!parsed.success) {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) {
426 return yield* toValidationError(416 return yield* toValidationError(
427 "ProviderService.sendTurn",417 "ProviderService.sendTurn",
428 parsed.error.message,418 "Either input text or at least one attachment is required",
429 parsed.error.cause,
430 );419 );
431 }420 }
432
433 const input = parsed.data;
434 const routed = yield* resolveRoutableSession({421 const routed = yield* resolveRoutableSession({
435 sessionId: input.sessionId,422 sessionId: input.sessionId,
436 operation: "ProviderService.sendTurn",423 operation: "ProviderService.sendTurn",
437 allowRecovery: true,424 allowRecovery: true,
438 });425 });
439 return yield* routed.adapter.sendTurn({426 const turn = yield* routed.adapter.sendTurn({
440 ...input,427 ...input,
441 sessionId: routed.sessionId,428 sessionId: routed.sessionId,
442 });429 });
443 });430 const threadId = yield* directory
444431 .getThreadId(asProviderSessionId(input.sessionId))
445 const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) =>432 .pipe(Effect.map(Option.getOrUndefined));
446 Effect.gen(function* () {433 if (!threadId) {
447 const parsed = providerInterruptTurnInputSchema.safeParse(rawInput);
448 if (!parsed.success) {
449 return yield* toValidationError(434 return yield* toValidationError(
450 "ProviderService.interruptTurn",435 "ProviderService.sendTurn",
451 parsed.error.message,436 `No thread id is tracked for provider session '${input.sessionId}'.`,
452 parsed.error.cause,
453 );437 );
454 }438 }
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 });
457 const routed = yield* resolveRoutableSession({462 const routed = yield* resolveRoutableSession({
458 sessionId: input.sessionId,463 sessionId: input.sessionId,
459 operation: "ProviderService.interruptTurn",464 operation: "ProviderService.interruptTurn",
...@@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
464469
465 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>470 const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) =>
466 Effect.gen(function* () {471 Effect.gen(function* () {
467 const parsed = providerRespondToRequestInputSchema.safeParse(rawInput);472 const input = yield* decodeInputOrValidationError({
468 if (!parsed.success) {473 operation: "ProviderService.respondToRequest",
469 return yield* toValidationError(474 schema: ProviderRespondToRequestInput,
470 "ProviderService.respondToRequest",475 payload: rawInput,
471 parsed.error.message,476 });
472 parsed.error.cause,
473 );
474 }
475
476 const input = parsed.data;
477 const routed = yield* resolveRoutableSession({477 const routed = yield* resolveRoutableSession({
478 sessionId: input.sessionId,478 sessionId: input.sessionId,
479 operation: "ProviderService.respondToRequest",479 operation: "ProviderService.respondToRequest",
...@@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
484484
485 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>485 const stopSession: ProviderServiceShape["stopSession"] = (rawInput) =>
486 Effect.gen(function* () {486 Effect.gen(function* () {
487 const parsed = providerStopSessionInputSchema.safeParse(rawInput);487 const input = yield* decodeInputOrValidationError({
488 if (!parsed.success) {488 operation: "ProviderService.stopSession",
489 return yield* toValidationError(489 schema: ProviderStopSessionInput,
490 "ProviderService.stopSession",490 payload: rawInput,
491 parsed.error.message,491 });
492 parsed.error.cause,
493 );
494 }
495
496 const input = parsed.data;
497 const routed = yield* resolveRoutableSession({492 const routed = yield* resolveRoutableSession({
498 sessionId: input.sessionId,493 sessionId: input.sessionId,
499 operation: "ProviderService.stopSession",494 operation: "ProviderService.stopSession",
...@@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
503 yield* routed.adapter.stopSession(routed.sessionId);498 yield* routed.adapter.stopSession(routed.sessionId);
504 }499 }
505 if (routed.sessionId !== input.sessionId) {500 if (routed.sessionId !== input.sessionId) {
506 yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId });501 yield* directory.remove(asProviderSessionId(routed.sessionId));
507 yield* directory.remove(routed.sessionId);
508 yield* clearAliasesReferencing(routed.sessionId);502 yield* clearAliasesReferencing(routed.sessionId);
509 }503 }
510 yield* checkpointService.releaseSession({ providerSessionId: input.sessionId });504 yield* directory.remove(asProviderSessionId(input.sessionId));
511 yield* directory.remove(input.sessionId);
512 yield* clearAliasesReferencing(input.sessionId);505 yield* clearAliasesReferencing(input.sessionId);
513 });506 });
514507
...@@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
517 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),510 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
518 );511 );
519512
520 const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) =>513 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (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) =>
544 Effect.gen(function* () {514 Effect.gen(function* () {
545 const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput);515 const input = yield* decodeInputOrValidationError({
546 if (!parsed.success) {516 operation: "ProviderService.rollbackConversation",
547 return yield* toValidationError(517 schema: ProviderRollbackConversationInput,
548 "ProviderService.getCheckpointDiff",518 payload: rawInput,
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,
563 });519 });
564 });520 if (input.numTurns === 0) {
565521 return;
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 );
575 }522 }
576
577 const input = parsed.data;
578 const routed = yield* resolveRoutableSession({523 const routed = yield* resolveRoutableSession({
579 sessionId: input.sessionId,524 sessionId: input.sessionId,
580 operation: "ProviderService.revertToCheckpoint",525 operation: "ProviderService.rollbackConversation",
581 allowRecovery: true,526 allowRecovery: true,
582 });527 });
583 return yield* checkpointService.revertToCheckpoint({528 yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns);
584 ...input,
585 sessionId: routed.sessionId,
586 });
587 });529 });
588530
589 const stopAll: ProviderServiceShape["stopAll"] = () =>531 const stopAll: ProviderServiceShape["stopAll"] = () =>
590 Effect.gen(function* () {532 Effect.gen(function* () {
591 const sessionIds = yield* directory.listSessionIds();533 const sessionIds = yield* directory.listSessionIds();
592 yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid);534 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.
595 yield* Effect.forEach(sessionIds, (sessionId) =>535 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 ),
597 ).pipe(Effect.asVoid);550 ).pipe(Effect.asVoid);
551 // Keep persisted session bindings so stale sessions can be resumed after
552 // process restart via resumeThreadId.
598 yield* Ref.set(routedSessionAliasesRef, new Map());553 yield* Ref.set(routedSessionAliasesRef, new Map());
599 });554 });
600555
...@@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
605 respondToRequest,560 respondToRequest,
606 stopSession,561 stopSession,
607 listSessions,562 listSessions,
608 listCheckpoints,563 rollbackConversation,
609 getCheckpointDiff,
610 revertToCheckpoint,
611 stopAll,564 stopAll,
612 streamEvents: Stream.fromPubSub(runtimeEventPubSub),565 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
613 } satisfies ProviderServiceShape;566 } satisfies ProviderServiceShape;
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+103-22
...@@ -3,22 +3,35 @@ import os from "node:os";...@@ -3,22 +3,35 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import { NodeServices } from "@effect/platform-node";5import { NodeServices } from "@effect/platform-node";
6import {
7 ProviderSessionId,
8 ProviderThreadId,
9 ThreadId,
10} from "@t3tools/contracts";
6import { it, assert } from "@effect/vitest";11import { it, assert } from "@effect/vitest";
7import { assertFailure, assertSome } from "@effect/vitest/utils";12import { assertFailure, assertSome } from "@effect/vitest/utils";
8import { Effect, Layer } from "effect";13import { Effect, Layer, Option } from "effect";
9import * as SqlClient from "effect/unstable/sql/SqlClient";14import * as SqlClient from "effect/unstable/sql/SqlClient";
1015
11import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";16import { 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";
13import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";19import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts";
14import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";20import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
15import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";21import { 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
17function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {27function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) {
18 return ProviderSessionDirectoryLive.pipe(28 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
19 Layer.provide(ProviderSessionRepositoryLive),
20 Layer.provide(persistenceLayer),29 Layer.provide(persistenceLayer),
21 );30 );
31 return Layer.mergeAll(
32 runtimeRepositoryLayer,
33 ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)),
34 );
22}35}
2336
24const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));37const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory));
...@@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => {
27 it("upserts, reads, and removes session bindings", () =>40 it("upserts, reads, and removes session bindings", () =>
28 Effect.gen(function* () {41 Effect.gen(function* () {
29 const directory = yield* ProviderSessionDirectory;42 const directory = yield* ProviderSessionDirectory;
43 const runtimeRepository = yield* ProviderSessionRuntimeRepository;
3044
31 yield* directory.upsert({45 yield* directory.upsert({
32 sessionId: "sess-1",46 sessionId: sessionId("sess-1"),
33 provider: "codex",47 provider: "codex",
34 threadId: "thread-1",48 threadId: threadId("thread-1"),
35 });49 });
3650
37 const provider = yield* directory.getProvider("sess-1");51 const provider = yield* directory.getProvider(sessionId("sess-1"));
38 assert.equal(provider, "codex");52 assert.equal(provider, "codex");
39 const threadId = yield* directory.getThreadId("sess-1");53 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1"));
40 assertSome(threadId, "thread-1");54 assertSome(resolvedThreadId, threadId("thread-1"));
4155
42 yield* directory.upsert({56 yield* directory.upsert({
43 sessionId: "sess-1",57 sessionId: sessionId("sess-1"),
44 provider: "codex",58 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"),
46 });66 });
47 const updatedThreadId = yield* directory.getThreadId("sess-1");67 assert.equal(Option.isSome(runtime), true);
48 assertSome(updatedThreadId, "thread-2");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
50 const sessionIds = yield* directory.listSessionIds();75 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");78 yield* directory.remove(sessionId("sess-1"));
54 const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result);79 const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result);
55 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));80 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
56 }));81 }));
5782
...@@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
60 const directory = yield* ProviderSessionDirectory;85 const directory = yield* ProviderSessionDirectory;
61 const result = yield* Effect.result(86 const result = yield* Effect.result(
62 directory.upsert({87 directory.upsert({
63 sessionId: " ",88 sessionId: sessionId(" "),
64 provider: "codex",89 provider: "codex",
65 }),90 }),
66 );91 );
...@@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => {
73 );98 );
74 }));99 }));
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
76 it("rehydrates persisted mappings across layer restart", () =>149 it("rehydrates persisted mappings across layer restart", () =>
77 Effect.gen(function* () {150 Effect.gen(function* () {
78 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));151 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-"));
...@@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => {
82 yield* Effect.gen(function* () {155 yield* Effect.gen(function* () {
83 const directory = yield* ProviderSessionDirectory;156 const directory = yield* ProviderSessionDirectory;
84 yield* directory.upsert({157 yield* directory.upsert({
85 sessionId: "sess-restart",158 sessionId: sessionId("sess-restart"),
86 provider: "codex",159 provider: "codex",
87 threadId: "thread-restart",160 threadId: threadId("thread-restart"),
88 });161 });
89 }).pipe(Effect.provide(directoryLayer));162 }).pipe(Effect.provide(directoryLayer));
90163
91 yield* Effect.gen(function* () {164 yield* Effect.gen(function* () {
92 const directory = yield* ProviderSessionDirectory;165 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"));
94 assert.equal(provider, "codex");168 assert.equal(provider, "codex");
95169
96 const threadId = yield* directory.getThreadId("sess-restart");170 const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart"));
97 assertSome(threadId, "thread-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);
98 }).pipe(Effect.provide(directoryLayer));179 }).pipe(Effect.provide(directoryLayer));
99180
100 fs.rmSync(tempDir, { recursive: true, force: true });181 fs.rmSync(tempDir, { recursive: true, force: true });
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+142-33
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1import {
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 type ProviderKind,
6} from "@t3tools/contracts";
1import { Effect, Layer, Option } from "effect";7import { Effect, Layer, Option } from "effect";
28
3import { ProviderSessionRepository } from "../../persistence/Services/ProviderSessions.ts";9import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
4import {10import {
5 ProviderSessionDirectoryPersistenceError,11 ProviderSessionDirectoryPersistenceError,
6 ProviderSessionNotFoundError,12 ProviderSessionNotFoundError,
...@@ -22,51 +28,154 @@ function toPersistenceError(operation: string) {...@@ -22,51 +28,154 @@ function toPersistenceError(operation: string) {
22}28}
2329
24function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding {30function 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
25 return {37 return {
26 sessionId: binding.sessionId.trim(),38 sessionId: ProviderSessionId.makeUnsafe(sessionId),
27 provider: binding.provider,39 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 } : {}),
29 };52 };
30}53}
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
32const makeProviderSessionDirectory = Effect.gen(function* () {90const makeProviderSessionDirectory = Effect.gen(function* () {
33 const repository = yield* ProviderSessionRepository;91 const repository = yield* ProviderSessionRuntimeRepository;
3492
35 const persistUpsert = (binding: ProviderSessionBinding) =>93 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) =>
36 repository137 repository
37 .upsertSession({138 .deleteBySessionId({ providerSessionId: sessionId })
38 sessionId: binding.sessionId,139 .pipe(
39 provider: binding.provider,140 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
40 ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}),141 );
41 })142
42 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession")));143 const getBinding = (sessionId: ProviderSessionId) =>
43144 repository.getBySessionId({ providerSessionId: sessionId }).pipe(
44 const persistDelete = (sessionId: string) =>145 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),
45 repository146 Effect.flatMap((runtime) =>
46 .deleteSession({ sessionId })147 Option.match(runtime, {
47 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession")));148 onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()),
48149 onSome: (value) =>
49 const getBinding = (sessionId: string) =>150 decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe(
50 repository.getSession({ sessionId }).pipe(151 Effect.map((provider) =>
51 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")),152 Option.some({
52 Effect.map((entry) =>153 sessionId: value.providerSessionId,
53 Option.map(entry, (value) => ({154 provider,
54 sessionId: value.sessionId,155 threadId: value.threadId,
55 provider: value.provider,156 }),
56 ...(value.threadId !== undefined ? { threadId: value.threadId } : {}),157 ),
57 })),158 ),
159 }),
58 ),160 ),
59 );161 );
60162
61 const listBindings = () =>163 const listBindings = () =>
62 repository.listSessions().pipe(164 repository.list().pipe(
63 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")),165 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),
64 Effect.map((rows) =>166 Effect.flatMap((rows) =>
65 rows.map((row) => ({167 Effect.forEach(
66 sessionId: row.sessionId,168 rows,
67 provider: row.provider,169 (row) =>
68 ...(row.threadId !== undefined ? { threadId: row.threadId } : {}),170 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(
69 })),171 Effect.map((provider) => ({
172 sessionId: row.providerSessionId,
173 provider,
174 threadId: row.threadId,
175 })),
176 ),
177 { concurrency: "unbounded" },
178 ),
70 ),179 ),
71 );180 );
72181
apps/server/src/provider/Services/ProviderAdapter.ts+18-9
...@@ -8,24 +8,28 @@...@@ -8,24 +8,28 @@
8 * @module ProviderAdapter8 * @module ProviderAdapter
9 */9 */
10import type {10import type {
11 ApprovalRequestId,
11 ProviderApprovalDecision,12 ProviderApprovalDecision,
12 ProviderKind,13 ProviderKind,
13 ProviderRuntimeEvent,14 ProviderRuntimeEvent,
14 ProviderSendTurnInput,15 ProviderSendTurnInput,
15 ProviderSession,16 ProviderSession,
17 ProviderSessionId,
16 ProviderSessionStartInput,18 ProviderSessionStartInput,
19 ProviderThreadId,
20 ProviderTurnId,
17 ProviderTurnStartResult,21 ProviderTurnStartResult,
18} from "@t3tools/contracts";22} from "@t3tools/contracts";
19import type { Effect } from "effect";23import type { Effect } from "effect";
20import type { Stream } from "effect";24import type { Stream } from "effect";
2125
22export interface ProviderThreadTurnSnapshot {26export interface ProviderThreadTurnSnapshot {
23 readonly id: string;27 readonly id: ProviderTurnId;
24 readonly items: ReadonlyArray<unknown>;28 readonly items: ReadonlyArray<unknown>;
25}29}
2630
27export interface ProviderThreadSnapshot {31export interface ProviderThreadSnapshot {
28 readonly threadId: string;32 readonly threadId: ProviderThreadId;
29 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;33 readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>;
30}34}
3135
...@@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> {...@@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> {
52 /**56 /**
53 * Interrupt an active turn.57 * Interrupt an active turn.
54 */58 */
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
57 /**64 /**
58 * Respond to an interactive approval request.65 * Respond to an interactive approval request.
59 */66 */
60 readonly respondToRequest: (67 readonly respondToRequest: (
61 sessionId: string,68 sessionId: ProviderSessionId,
62 requestId: string,69 requestId: ApprovalRequestId,
63 decision: ProviderApprovalDecision,70 decision: ProviderApprovalDecision,
64 ) => Effect.Effect<void, TError>;71 ) => Effect.Effect<void, TError>;
6572
66 /**73 /**
67 * Stop one provider session.74 * Stop one provider session.
68 */75 */
69 readonly stopSession: (sessionId: string) => Effect.Effect<void, TError>;76 readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>;
7077
71 /**78 /**
72 * List currently active provider sessions for this adapter.79 * List currently active provider sessions for this adapter.
...@@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> {...@@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> {
76 /**83 /**
77 * Check whether this adapter owns an active session id.84 * Check whether this adapter owns an active session id.
78 */85 */
79 readonly hasSession: (sessionId: string) => Effect.Effect<boolean>;86 readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>;
8087
81 /**88 /**
82 * Read a provider thread snapshot.89 * Read a provider thread snapshot.
83 */90 */
84 readonly readThread: (sessionId: string) => Effect.Effect<ProviderThreadSnapshot, TError>;91 readonly readThread: (
92 sessionId: ProviderSessionId,
93 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
8594
86 /**95 /**
87 * Roll back a provider thread by N turns.96 * Roll back a provider thread by N turns.
88 */97 */
89 readonly rollbackThread: (98 readonly rollbackThread: (
90 sessionId: string,99 sessionId: ProviderSessionId,
91 numTurns: number,100 numTurns: number,
92 ) => Effect.Effect<ProviderThreadSnapshot, TError>;101 ) => Effect.Effect<ProviderThreadSnapshot, TError>;
93102
apps/server/src/provider/Services/ProviderService.ts+7-25
...@@ -12,20 +12,15 @@...@@ -12,20 +12,15 @@
12 * @module ProviderService12 * @module ProviderService
13 */13 */
14import type {14import type {
15 ProviderGetCheckpointDiffInput,
16 ProviderGetCheckpointDiffResult,
17 ProviderInterruptTurnInput,15 ProviderInterruptTurnInput,
18 ProviderListCheckpointsInput,
19 ProviderListCheckpointsResult,
20 ProviderRespondToRequestInput,16 ProviderRespondToRequestInput,
21 ProviderRevertToCheckpointInput,17 ProviderRuntimeEvent,
22 ProviderRevertToCheckpointResult,
23 ProviderSendTurnInput,18 ProviderSendTurnInput,
24 ProviderSession,19 ProviderSession,
20 ProviderSessionId,
25 ProviderSessionStartInput,21 ProviderSessionStartInput,
26 ProviderStopSessionInput,22 ProviderStopSessionInput,
27 ProviderTurnStartResult,23 ProviderTurnStartResult,
28 ProviderRuntimeEvent,
29} from "@t3tools/contracts";24} from "@t3tools/contracts";
30import { ServiceMap } from "effect";25import { ServiceMap } from "effect";
31import type { Effect, Stream } from "effect";26import type { Effect, Stream } from "effect";
...@@ -74,25 +69,12 @@ export interface ProviderServiceShape {...@@ -74,25 +69,12 @@ export interface ProviderServiceShape {
74 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;69 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;
7570
76 /**71 /**
77 * List checkpoints for a provider session.72 * Roll back provider conversation state by a number of turns.
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.
92 */73 */
93 readonly revertToCheckpoint: (74 readonly rollbackConversation: (input: {
94 input: ProviderRevertToCheckpointInput,75 readonly sessionId: ProviderSessionId;
95 ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>;76 readonly numTurns: number;
77 }) => Effect.Effect<void, ProviderServiceError>;
9678
97 /**79 /**
98 * Stop all active provider sessions.80 * Stop all active provider sessions.
apps/server/src/provider/Services/ProviderSessionDirectory.ts+19-8
...@@ -7,7 +7,13 @@...@@ -7,7 +7,13 @@
7 *7 *
8 * @module ProviderSessionDirectory8 * @module ProviderSessionDirectory
9 */9 */
10import type { ProviderKind } from "@t3tools/contracts";10import type {
11 ProviderKind,
12 ProviderSessionId,
13 ProviderSessionRuntimeStatus,
14 ProviderThreadId,
15 ThreadId,
16} from "@t3tools/contracts";
11import { Option, ServiceMap } from "effect";17import { Option, ServiceMap } from "effect";
12import type { Effect } from "effect";18import type { Effect } from "effect";
1319
...@@ -18,9 +24,14 @@ import type {...@@ -18,9 +24,14 @@ import type {
18} from "../Errors.ts";24} from "../Errors.ts";
1925
20export interface ProviderSessionBinding {26export interface ProviderSessionBinding {
21 readonly sessionId: string;27 readonly sessionId: ProviderSessionId;
22 readonly provider: ProviderKind;28 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;
24}35}
2536
26export type ProviderSessionDirectoryReadError =37export type ProviderSessionDirectoryReadError =
...@@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape {...@@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape {
43 * Resolve the provider owner for a session id.54 * Resolve the provider owner for a session id.
44 */55 */
45 readonly getProvider: (56 readonly getProvider: (
46 sessionId: string,57 sessionId: ProviderSessionId,
47 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;58 ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>;
4859
49 /**60 /**
50 * Resolve the tracked thread id for a session, if known.61 * Resolve the tracked thread id for a session, if known.
51 */62 */
52 readonly getThreadId: (63 readonly getThreadId: (
53 sessionId: string,64 sessionId: ProviderSessionId,
54 ) => Effect.Effect<Option.Option<string>, ProviderSessionDirectoryReadError>;65 ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>;
5566
56 /**67 /**
57 * Remove a session binding.68 * Remove a session binding.
58 */69 */
59 readonly remove: (70 readonly remove: (
60 sessionId: string,71 sessionId: ProviderSessionId,
61 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;72 ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;
6273
63 /**74 /**
64 * List tracked session ids.75 * List tracked session ids.
65 */76 */
66 readonly listSessionIds: () => Effect.Effect<77 readonly listSessionIds: () => Effect.Effect<
67 ReadonlyArray<string>,78 ReadonlyArray<ProviderSessionId>,
68 ProviderSessionDirectoryPersistenceError79 ProviderSessionDirectoryPersistenceError
69 >;80 >;
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";...@@ -4,20 +4,16 @@ import path from "node:path";
44
5import {5import {
6 DEFAULT_TERMINAL_ID,6 DEFAULT_TERMINAL_ID,
7 type TerminalClearInput,7 TerminalClearInput,
8 type TerminalCloseInput,8 TerminalCloseInput,
9 TerminalOpenInput,
10 TerminalResizeInput,
11 TerminalWriteInput,
9 type TerminalEvent,12 type TerminalEvent,
10 type TerminalOpenInput,
11 type TerminalResizeInput,
12 type TerminalSessionSnapshot,13 type TerminalSessionSnapshot,
13 type TerminalSessionStatus,14 type TerminalSessionStatus,
14 type TerminalWriteInput,
15 terminalClearInputSchema,
16 terminalCloseInputSchema,
17 terminalOpenInputSchema,
18 terminalResizeInputSchema,
19 terminalWriteInputSchema,
20} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { Schema } from "effect";
2117
22import { createLogger } from "./logger";18import { createLogger } from "./logger";
23import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";19import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
...@@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120;...@@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120;
30const DEFAULT_OPEN_ROWS = 30;26const DEFAULT_OPEN_ROWS = 30;
31const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);27const 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
33type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;35type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
3436
35export interface TerminalManagerEvents {37export interface TerminalManagerEvents {
...@@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
332 }334 }
333335
334 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {336 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
335 const input = terminalOpenInputSchema.parse(raw);337 const input = decodeTerminalOpenInput(raw);
336 return this.runWithThreadLock(input.threadId, async () => {338 return this.runWithThreadLock(input.threadId, async () => {
337 await this.assertValidCwd(input.cwd);339 await this.assertValidCwd(input.cwd);
338340
...@@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
404 }406 }
405407
406 async write(raw: TerminalWriteInput): Promise<void> {408 async write(raw: TerminalWriteInput): Promise<void> {
407 const input = terminalWriteInputSchema.parse(raw);409 const input = decodeTerminalWriteInput(raw);
408 const session = this.requireSession(input.threadId, input.terminalId);410 const session = this.requireSession(input.threadId, input.terminalId);
409 if (!session.process || session.status !== "running") {411 if (!session.process || session.status !== "running") {
410 throw new Error(412 throw new Error(
...@@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
415 }417 }
416418
417 async resize(raw: TerminalResizeInput): Promise<void> {419 async resize(raw: TerminalResizeInput): Promise<void> {
418 const input = terminalResizeInputSchema.parse(raw);420 const input = decodeTerminalResizeInput(raw);
419 const session = this.requireSession(input.threadId, input.terminalId);421 const session = this.requireSession(input.threadId, input.terminalId);
420 if (!session.process || session.status !== "running") {422 if (!session.process || session.status !== "running") {
421 throw new Error(423 throw new Error(
...@@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
429 }431 }
430432
431 async clear(raw: TerminalClearInput): Promise<void> {433 async clear(raw: TerminalClearInput): Promise<void> {
432 const input = terminalClearInputSchema.parse(raw);434 const input = decodeTerminalClearInput(raw);
433 await this.runWithThreadLock(input.threadId, async () => {435 await this.runWithThreadLock(input.threadId, async () => {
434 const session = this.requireSession(input.threadId, input.terminalId);436 const session = this.requireSession(input.threadId, input.terminalId);
435 session.history = "";437 session.history = "";
...@@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
445 }447 }
446448
447 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {449 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
448 const input = terminalOpenInputSchema.parse(raw);450 const input = decodeTerminalOpenInput(raw);
449 return this.runWithThreadLock(input.threadId, async () => {451 return this.runWithThreadLock(input.threadId, async () => {
450 await this.assertValidCwd(input.cwd);452 await this.assertValidCwd(input.cwd);
451453
...@@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {...@@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
490 }492 }
491493
492 async close(raw: TerminalCloseInput): Promise<void> {494 async close(raw: TerminalCloseInput): Promise<void> {
493 const input = terminalCloseInputSchema.parse(raw);495 const input = decodeTerminalCloseInput(raw);
494 await this.runWithThreadLock(input.threadId, async () => {496 await this.runWithThreadLock(input.threadId, async () => {
495 if (input.terminalId) {497 if (input.terminalId) {
496 await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true);498 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";...@@ -4,10 +4,9 @@ import path from "node:path";
4import { runProcess } from "./processRunner";4import { runProcess } from "./processRunner";
55
6import {6import {
7 projectSearchEntriesInputSchema,7 ProjectEntry,
8 type ProjectEntry,8 ProjectSearchEntriesInput,
9 type ProjectSearchEntriesInput,9 ProjectSearchEntriesResult,
10 type ProjectSearchEntriesResult,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
1211
13const WORKSPACE_CACHE_TTL_MS = 15_000;12const WORKSPACE_CACHE_TTL_MS = 15_000;
...@@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {...@@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
413export async function searchWorkspaceEntries(412export async function searchWorkspaceEntries(
414 input: ProjectSearchEntriesInput,413 input: ProjectSearchEntriesInput,
415): Promise<ProjectSearchEntriesResult> {414): Promise<ProjectSearchEntriesResult> {
416 const parsed = projectSearchEntriesInputSchema.parse(input);415 const index = await getWorkspaceIndex(input.cwd);
417 const index = await getWorkspaceIndex(parsed.cwd);416 const normalizedQuery = normalizeQuery(input.query);
418 const normalizedQuery = normalizeQuery(parsed.query);
419 const candidates = normalizedQuery417 const candidates = normalizedQuery
420 ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))418 ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
421 : index.entries;419 : index.entries;
...@@ -427,7 +425,7 @@ export async function searchWorkspaceEntries(...@@ -427,7 +425,7 @@ export async function searchWorkspaceEntries(
427 });425 });
428426
429 return {427 return {
430 entries: ranked.slice(0, parsed.limit),428 entries: ranked.slice(0, input.limit),
431 truncated: index.truncated || ranked.length > parsed.limit,429 truncated: index.truncated || ranked.length > input.limit,
432 };430 };
433}431}
apps/server/src/wsServer.test.ts+30-168
...@@ -3,7 +3,7 @@ import os from "node:os";...@@ -3,7 +3,7 @@ import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { EventEmitter } from "node:events";4import { EventEmitter } from "node:events";
55
6import { Effect, Layer, ManagedRuntime, PubSub, Stream } from "effect";6import { Effect, Layer, PubSub, Stream } from "effect";
7import { describe, expect, it, afterEach, vi } from "vitest";7import { describe, expect, it, afterEach, vi } from "vitest";
8import { createServer } from "./wsServer";8import { createServer } from "./wsServer";
9import WebSocket from "ws";9import WebSocket from "ws";
...@@ -21,7 +21,6 @@ import {...@@ -21,7 +21,6 @@ import {
21 type WsResponse,21 type WsResponse,
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";23import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
24import { ProjectRepository } from "./persistence/Services/Projects";
25import type {24import type {
26 TerminalClearInput,25 TerminalClearInput,
27 TerminalCloseInput,26 TerminalCloseInput,
...@@ -32,9 +31,7 @@ import type {...@@ -32,9 +31,7 @@ import type {
32 TerminalWriteInput,31 TerminalWriteInput,
33} from "@t3tools/contracts";32} from "@t3tools/contracts";
34import type { TerminalManager } from "./terminalManager";33import type { TerminalManager } from "./terminalManager";
35import { ProjectRepositoryLive } from "./persistence/Layers/Projects";34import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
36import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
37import { NodeServices } from "@effect/platform-node";
38import { SqlClient } from "effect/unstable/sql";35import { SqlClient } from "effect/unstable/sql";
39import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";36import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
4037
...@@ -664,38 +661,6 @@ describe("WebSocket Server", () => {...@@ -664,38 +661,6 @@ describe("WebSocket Server", () => {
664 expect(response.error!.message).toContain("Unknown method");661 expect(response.error!.message).toContain("Unknown method");
665 });662 });
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
699 it("keeps orchestration domain push behavior for provider runtime events", async () => {664 it("keeps orchestration domain push behavior for provider runtime events", async () => {
700 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());665 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
701 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {666 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
...@@ -709,15 +674,7 @@ describe("WebSocket Server", () => {...@@ -709,15 +674,7 @@ describe("WebSocket Server", () => {
709 respondToRequest: () => unsupported(),674 respondToRequest: () => unsupported(),
710 stopSession: () => unsupported(),675 stopSession: () => unsupported(),
711 listSessions: () => Effect.succeed([]),676 listSessions: () => Effect.succeed([]),
712 listCheckpoints: () => unsupported(),677 rollbackConversation: () => 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(),
721 stopAll: () => Effect.void,678 stopAll: () => Effect.void,
722 streamEvents: Stream.fromPubSub(runtimeEventPubSub),679 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
723 };680 };
...@@ -736,6 +693,16 @@ describe("WebSocket Server", () => {...@@ -736,6 +693,16 @@ describe("WebSocket Server", () => {
736 await waitForMessage(ws);693 await waitForMessage(ws);
737694
738 const createdAt = new Date().toISOString();695 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();
739 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {706 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
740 type: "thread.create",707 type: "thread.create",
741 commandId: "cmd-ws-runtime-thread-create",708 commandId: "cmd-ws-runtime-thread-create",
...@@ -928,11 +895,8 @@ describe("WebSocket Server", () => {...@@ -928,11 +895,8 @@ describe("WebSocket Server", () => {
928 expect(response!.error!.message).toContain("Invalid request format");895 expect(response!.error!.message).toContain("Invalid request format");
929 });896 });
930897
931 it("supports projects list/add/dedupe/remove", async () => {898 it("returns unknown method for removed projects CRUD methods", async () => {
932 const stateDir = makeTempDir("t3code-ws-projects-state-");899 server = createTestServer({ cwd: "/test" });
933 const firstProjectCwd = makeTempDir("t3code-ws-project-a-");
934
935 server = createTestServer({ stateDir, cwd: "/test" });
936 await server.start();900 await server.start();
937 const addr = server.httpServer.address();901 const addr = server.httpServer.address();
938 const port = typeof addr === "object" && addr !== null ? addr.port : 0;902 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
...@@ -941,75 +905,21 @@ describe("WebSocket Server", () => {...@@ -941,75 +905,21 @@ describe("WebSocket Server", () => {
941 connections.push(ws);905 connections.push(ws);
942 await waitForMessage(ws);906 await waitForMessage(ws);
943907
944 const emptyList = await sendRequest(ws, WS_METHODS.projectsList);908 const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
945 expect(emptyList.error).toBeUndefined();909 expect(listResponse.result).toBeUndefined();
946 expect(emptyList.result).toEqual([]);910 expect(listResponse.error?.message).toContain("Unknown method");
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);
953911
954 const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, {912 const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
955 cwd: firstProjectCwd,913 cwd: "/tmp/project-a",
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 ],
980 });914 });
981 expect(updatedScripts.error).toBeUndefined();915 expect(addResponse.result).toBeUndefined();
982 const scriptPayload = (916 expect(addResponse.error?.message).toContain("Unknown method");
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 ]);
1004917
1005 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {918 const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
1006 id: projectId,919 id: "project-a",
1007 });920 });
1008 expect(removed.error).toBeUndefined();921 expect(removeResponse.result).toBeUndefined();
1009922 expect(removeResponse.error?.message).toContain("Unknown method");
1010 const afterRemove = await sendRequest(ws, WS_METHODS.projectsList);
1011 expect(afterRemove.error).toBeUndefined();
1012 expect(afterRemove.result).toEqual([]);
1013 });923 });
1014924
1015 it("supports projects.searchEntries", async () => {925 it("supports projects.searchEntries", async () => {
...@@ -1079,7 +989,7 @@ describe("WebSocket Server", () => {...@@ -1079,7 +989,7 @@ describe("WebSocket Server", () => {
1079 expect(pullResponse.error?.message).not.toContain("Unknown method");989 expect(pullResponse.error?.message).not.toContain("Unknown method");
1080 });990 });
1081991
1082 it("responds to git.status via the git manager", async () => {992 it("returns unknown method for git.status", async () => {
1083 const gitManager = {993 const gitManager = {
1084 status: vi.fn().mockResolvedValue({994 status: vi.fn().mockResolvedValue({
1085 branch: "feature/test",995 branch: "feature/test",
...@@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => {...@@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => {
1109 const response = await sendRequest(ws, WS_METHODS.gitStatus, {1019 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
1110 cwd: "/test",1020 cwd: "/test",
1111 });1021 });
1112 expect(response.error).toBeUndefined();1022 expect(response.result).toBeUndefined();
1113 expect(response.result).toEqual({1023 expect(response.error?.message).toContain("Unknown method");
1114 branch: "feature/test",1024 expect(gitManager.status).not.toHaveBeenCalled();
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" });
1127 });1025 });
11281026
1129 it("returns errors from git.runStackedAction", async () => {1027 it("returns errors from git.runStackedAction", async () => {
...@@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => {...@@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => {
1153 });1051 });
1154 });1052 });
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
1192 it("rejects websocket connections without a valid auth token", async () => {1054 it("rejects websocket connections without a valid auth token", async () => {
1193 server = createTestServer({ cwd: "/test", authToken: "secret-token" });1055 server = createTestServer({ cwd: "/test", authToken: "secret-token" });
1194 await server.start();1056 await server.start();
apps/server/src/wsServer.ts+161-197
...@@ -9,16 +9,15 @@ import {...@@ -9,16 +9,15 @@ import {
9 EDITORS,9 EDITORS,
10 ORCHESTRATION_WS_CHANNELS,10 ORCHESTRATION_WS_CHANNELS,
11 ORCHESTRATION_WS_METHODS,11 ORCHESTRATION_WS_METHODS,
12 TerminalEvent,
12 WS_CHANNELS,13 WS_CHANNELS,
13 WS_METHODS,14 WS_METHODS,
14 type TerminalEvent,15 WebSocketRequest,
15 type WsPush,16 WsPush,
16 type WsRequest,17 WsResponse,
17 type WsResponse,
18 wsRequestSchema,
19} from "@t3tools/contracts";18} from "@t3tools/contracts";
20import { NodeServices } from "@effect/platform-node";19import { 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";
22import { WebSocketServer, type WebSocket } from "ws";21import { WebSocketServer, type WebSocket } from "ws";
2322
24import { createLogger } from "./logger";23import { createLogger } from "./logger";
...@@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager";...@@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager";
36import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";35import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
37import { searchWorkspaceEntries } from "./workspaceEntries";36import { searchWorkspaceEntries } from "./workspaceEntries";
38import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";37import { 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";
39import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";43import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
40import {44import {
41 OrchestrationEngineService,45 OrchestrationEngineService,
42 type OrchestrationEngineShape,46 type OrchestrationEngineShape,
43} from "./orchestration/Services/OrchestrationEngine";47} from "./orchestration/Services/OrchestrationEngine";
44import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion";48import {
45import { ProjectRepositoryLive } from "./persistence/Layers/Projects";49 ProjectionSnapshotQuery,
50 type ProjectionSnapshotQueryShape,
51} from "./orchestration/Services/ProjectionSnapshotQuery";
52import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
46import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";53import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
47import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects";
48import assert from "node:assert";54import assert from "node:assert";
55import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs";
49import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";56import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
57import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
50import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";58import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
51import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";59import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
52import { makeProviderServiceLive } from "./provider/Layers/ProviderService";60import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
53import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";61import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
54import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";62import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
55import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";63import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
56import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService";64import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
57import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints";65import {
58import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions";66 CheckpointDiffBlobRepository,
67 type CheckpointDiffBlobRepositoryShape,
68} from "./persistence/Services/CheckpointDiffBlobs";
59import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";69import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
60import * as SqlClient from "effect/unstable/sql/SqlClient";70import * as SqlClient from "effect/unstable/sql/SqlClient";
61import * as SqlError from "effect/unstable/sql/SqlError";71import * as SqlError from "effect/unstable/sql/SqlError";
62import * as Migrator from "effect/unstable/sql/Migrator";72import * as Migrator from "effect/unstable/sql/Migrator";
73import { clamp } from "effect/Number";
6374
64const MIME_TYPES: Record<string, string> = {75const MIME_TYPES: Record<string, string> = {
65 ".html": "text/html; charset=utf-8",76 ".html": "text/html; charset=utf-8",
...@@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {...@@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
122}133}
123134
124type EffectRuntime = ManagedRuntime.ManagedRuntime<135type EffectRuntime = ManagedRuntime.ManagedRuntime<
125 | ProjectRepository
126 | OrchestrationEngineService136 | OrchestrationEngineService
137 | ProjectionSnapshotQuery
127 | ProviderService138 | ProviderService
128 | ProviderRuntimeIngestionService,139 | CheckpointDiffBlobRepository
140 | OrchestrationReactor,
129 unknown141 unknown
130>;142>;
131143
...@@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) {...@@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) {
147159
148 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");160 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
149 let effectRuntime: EffectRuntime | null = null;161 let effectRuntime: EffectRuntime | null = null;
150 let projectRepository: ProjectRepositoryShape | null = null;
151 let orchestrationEngine: OrchestrationEngineShape | null = null;162 let orchestrationEngine: OrchestrationEngineShape | null = null;
163 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
152 let providerService: ProviderServiceShape | null = null;164 let providerService: ProviderServiceShape | null = null;
165 let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null;
153 const clients = new Set<WebSocket>();166 const clients = new Set<WebSocket>();
154 const logger = createLogger("ws");167 const logger = createLogger("ws");
155 const logWebSocketEvents =168 const logWebSocketEvents =
...@@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) {...@@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) {
173 return orchestrationEngine;186 return orchestrationEngine;
174 };187 };
175188
176 const getProjectRepository = () => {
177 assert(projectRepository, "Project repository is not started");
178 return projectRepository;
179 };
180
181 const getEffectRuntime = () => {189 const getEffectRuntime = () => {
182 assert(effectRuntime, "Effect runtime is not started");190 assert(effectRuntime, "Effect runtime is not started");
183 return effectRuntime;191 return effectRuntime;
184 };192 };
185193
186 const getProviderService = () => {194 const getProjectionSnapshotQuery = () => {
187 assert(providerService, "Provider service is not started");195 assert(projectionSnapshotQuery, "Projection snapshot query is not started");
188 return providerService;196 return projectionSnapshotQuery;
197 };
198
199 const getCheckpointDiffBlobRepository = () => {
200 assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started");
201 return checkpointDiffBlobRepository;
189 };202 };
190203
191 const attachSubscriptionss = Effect.gen(function* () {204 const attachSubscriptionss = Effect.gen(function* () {
192 const orchestrationEngine = yield* OrchestrationEngineService;205 const orchestrationEngine = yield* OrchestrationEngineService;
193 const runtimeIngestion = yield* ProviderRuntimeIngestionService;206 const orchestrationReactor = yield* OrchestrationReactor;
194 subscriptionsScope = yield* Scope.make("sequential");207 subscriptionsScope = yield* Scope.make("sequential");
195208
196 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>209 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
...@@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) {...@@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) {
216 }),229 }),
217 ).pipe(Effect.forkIn(subscriptionsScope));230 ).pipe(Effect.forkIn(subscriptionsScope));
218231
219 yield* runtimeIngestion.start.pipe(Scope.provide(subscriptionsScope));232 yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope));
220 });233 });
221234
222 const onTerminalEvent = (event: TerminalEvent) => {235 const onTerminalEvent = (event: TerminalEvent) => {
...@@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) {...@@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) {
347 });360 });
348361
349 async function handleMessage(ws: WebSocket, raw: unknown) {362 async function handleMessage(ws: WebSocket, raw: unknown) {
350 let request: WsRequest;363 let request: WebSocketRequest;
351 try {364 try {
352 const parsed = JSON.parse(String(raw));365 request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw);
353 request = wsRequestSchema.parse(parsed);
354 } catch {366 } catch {
355 const errorResponse: WsResponse = {367 const errorResponse: WsResponse = {
356 id: "unknown",368 id: "unknown",
...@@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) {...@@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) {
374 }386 }
375 }387 }
376388
377 async function routeRequest(request: WsRequest): Promise<unknown> {389 async function routeRequest(request: WebSocketRequest): Promise<unknown> {
378 const orchestrationEngine = getOrchestrationEngine();390 const orchestrationEngine = getOrchestrationEngine();
379 const projectRepository = getProjectRepository();391 const projectionReadModelQuery = getProjectionSnapshotQuery();
380 const effectRuntime = getEffectRuntime();392 const effectRuntime = getEffectRuntime();
381 const providerService = getProviderService();393 const diffBlobRepository = getCheckpointDiffBlobRepository();
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));
389394
390 case WS_METHODS.providersInterruptTurn:395 switch (request.body._tag) {
391 return effectRuntime.runPromise(providerService.interruptTurn(request.params as never));396 case ORCHESTRATION_WS_METHODS.getSnapshot: {
392397 return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
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;
399 }398 }
400399
401 case WS_METHODS.providersListCheckpoints:400 case ORCHESTRATION_WS_METHODS.dispatchCommand: {
402 return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never));401 orchestrationCommandLogger?.write({
403402 observedAt: new Date().toISOString(),
404 case WS_METHODS.providersGetCheckpointDiff:403 command: request.body.command,
405 return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never));404 });
406405 return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command));
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;
448 }406 }
449407
450 case WS_METHODS.projectsList:408 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
451 return effectRuntime.runPromise(projectRepository.list());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:416 return {
454 return effectRuntime.runPromise(projectRepository.add(request.params as never));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:424 case ORCHESTRATION_WS_METHODS.replayEvents:
457 return effectRuntime.runPromise(projectRepository.remove(request.params as never));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
459 case WS_METHODS.projectsSearchEntries:436 case WS_METHODS.projectsSearchEntries:
460 return searchWorkspaceEntries(request.params as never);437 return searchWorkspaceEntries(request.body);
461 case WS_METHODS.projectsUpdateScripts:
462 return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never));
463438
464 case WS_METHODS.shellOpenInEditor: {439 case WS_METHODS.shellOpenInEditor: {
465 const params = request.params as {440 const body = request.body;
466 cwd: string;441 const editorDef = EDITORS.find((e) => e.id === body.editor);
467 editor: string;442 if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`);
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}`);
472443
473 let command: string;444 let command: string;
474 let args: string[];445 let args: string[];
475446
476 if (editorDef.command) {447 if (editorDef.command) {
477 command = editorDef.command;448 command = editorDef.command;
478 args = [params.cwd];449 args = [body.cwd];
479 } else if (editorDef.id === "file-manager") {450 } else if (editorDef.id === "file-manager") {
480 // Use platform-specific file manager command451 // Use platform-specific file manager command
481 switch (process.platform) {452 switch (process.platform) {
...@@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) {...@@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) {
489 command = "xdg-open";460 command = "xdg-open";
490 break;461 break;
491 }462 }
492 args = [params.cwd];463 args = [body.cwd];
493 } else {464 } else {
494 return undefined;465 return undefined;
495 }466 }
...@@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) {...@@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) {
505 return undefined;476 return undefined;
506 }477 }
507478
508 case WS_METHODS.gitStatus: {479 // TODO: Bring back
509 const params = request.params as { cwd: string };480 // case WS_METHODS.gitStatus: {
510 const status = await gitManager.status(request.params as never);481 // const params = request.params as { cwd: string };
511 const projects = await effectRuntime.runPromise(projectRepository.list());482 // const status = await gitManager.status(request.params as never);
512 const project = projects.find((entry) => entry.cwd === params.cwd);483 // const projects = await effectRuntime.runPromise(projectRepository.list());
513 if (project) {484 // const project = projects.find((entry) => entry.cwd === params.cwd);
514 await effectRuntime.runPromise(485 // if (project) {
515 orchestrationEngine.dispatch({486 // await effectRuntime.runPromise(
516 type: "git.readModel.upsert",487 // orchestrationEngine.dispatch({
517 commandId: crypto.randomUUID(),488 // type: "git.readModel.upsert",
518 projectId: project.id,489 // commandId: crypto.randomUUID(),
519 branch: status.branch,490 // projectId: project.id,
520 hasWorkingTreeChanges: status.hasWorkingTreeChanges,491 // branch: status.branch,
521 aheadCount: status.aheadCount,492 // hasWorkingTreeChanges: status.hasWorkingTreeChanges,
522 behindCount: status.behindCount,493 // aheadCount: status.aheadCount,
523 createdAt: new Date().toISOString(),494 // behindCount: status.behindCount,
524 }),495 // createdAt: new Date().toISOString(),
525 );496 // }),
526 }497 // );
527 return status;498 // }
528 }499 // return status;
500 // }
529501
530 case WS_METHODS.gitPull:502 case WS_METHODS.gitPull:
531 return pullGitBranch(request.params as never);503 return pullGitBranch(request.body);
532504
533 case WS_METHODS.gitRunStackedAction:505 case WS_METHODS.gitRunStackedAction:
534 return gitManager.runStackedAction(request.params as never);506 return gitManager.runStackedAction(request.body);
535 case WS_METHODS.gitListBranches:507 case WS_METHODS.gitListBranches:
536 return listGitBranches(request.params as never);508 return listGitBranches(request.body);
537509
538 case WS_METHODS.gitCreateWorktree:510 case WS_METHODS.gitCreateWorktree:
539 return createGitWorktree(request.params as never);511 return createGitWorktree(request.body);
540512
541 case WS_METHODS.gitRemoveWorktree:513 case WS_METHODS.gitRemoveWorktree:
542 return removeGitWorktree(request.params as never);514 return removeGitWorktree(request.body);
543515
544 case WS_METHODS.gitCreateBranch:516 case WS_METHODS.gitCreateBranch:
545 return createGitBranch(request.params as never);517 return createGitBranch(request.body);
546518
547 case WS_METHODS.gitCheckout:519 case WS_METHODS.gitCheckout:
548 return checkoutGitBranch(request.params as never);520 return checkoutGitBranch(request.body);
549521
550 case WS_METHODS.gitInit:522 case WS_METHODS.gitInit:
551 return initGitRepo(request.params as never);523 return initGitRepo(request.body);
552524
553 case WS_METHODS.terminalOpen:525 case WS_METHODS.terminalOpen:
554 return terminalManager.open(request.params as never);526 return terminalManager.open(request.body);
555527
556 case WS_METHODS.terminalWrite:528 case WS_METHODS.terminalWrite:
557 await terminalManager.write(request.params as never);529 await terminalManager.write(request.body);
558 return undefined;530 return undefined;
559531
560 case WS_METHODS.terminalResize:532 case WS_METHODS.terminalResize:
561 await terminalManager.resize(request.params as never);533 await terminalManager.resize(request.body);
562 return undefined;534 return undefined;
563535
564 case WS_METHODS.terminalClear:536 case WS_METHODS.terminalClear:
565 await terminalManager.clear(request.params as never);537 await terminalManager.clear(request.body);
566 return undefined;538 return undefined;
567539
568 case WS_METHODS.terminalRestart:540 case WS_METHODS.terminalRestart:
569 return terminalManager.restart(request.params as never);541 return terminalManager.restart(request.body);
570542
571 case WS_METHODS.terminalClose:543 case WS_METHODS.terminalClose:
572 await terminalManager.close(request.params as never);544 await terminalManager.close(request.body);
573 return undefined;545 return undefined;
574546
575 case WS_METHODS.serverGetConfig:547 case WS_METHODS.serverGetConfig:
...@@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) {...@@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) {
579 };551 };
580552
581 case WS_METHODS.serverUpsertKeybinding:553 case WS_METHODS.serverUpsertKeybinding:
582 keybindingsConfig = upsertKeybindingRule(logger, request.params);554 keybindingsConfig = upsertKeybindingRule(logger, request.body);
583 return {555 return {
584 keybindings: keybindingsConfig,556 keybindings: keybindingsConfig,
585 };557 };
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
615 default:559 default:
616 throw new Error(`Unknown method: ${request.method}`);560 throw new Error(`Unknown method: ${request.body._tag}`);
617 }561 }
618 }562 }
619563
...@@ -628,7 +572,12 @@ export function createServer(options: ServerOptions) {...@@ -628,7 +572,12 @@ export function createServer(options: ServerOptions) {
628 path.join(orchestrationLogsDir, "orchestration-command.ndjson"),572 path.join(orchestrationLogsDir, "orchestration-command.ndjson"),
629 );573 );
630 const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath);574 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));
632 const providerLayer =581 const providerLayer =
633 customProviderLayer ??582 customProviderLayer ??
634 (() => {583 (() => {
...@@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) {...@@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) {
639 Layer.provide(codexAdapterLayer),588 Layer.provide(codexAdapterLayer),
640 );589 );
641 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(590 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
642 Layer.provide(ProviderSessionRepositoryLive),591 Layer.provide(ProviderSessionRuntimeRepositoryLive),
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),
653 );592 );
654 return makeProviderServiceLive({593 return makeProviderServiceLive({
655 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),594 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
656 }).pipe(Layer.provideMerge(checkpointServiceLayer));595 }).pipe(
596 Layer.provide(adapterRegistryLayer),
597 Layer.provide(providerSessionDirectoryLayer),
598 );
657 })();599 })();
658600
659 const runtimeServicesLayer = Layer.mergeAll(601 const runtimeServicesLayer = Layer.mergeAll(
660 orchestrationLayer,602 orchestrationLayer,
661 ProjectRepositoryLive,603 OrchestrationProjectionSnapshotQueryLive,
604 CheckpointDiffBlobRepositoryLive,
605 checkpointStoreLayer,
662 providerLayer,606 providerLayer,
663 );607 );
664 const layer = ProviderRuntimeIngestionLive.pipe(608 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
609 Layer.provideMerge(runtimeServicesLayer),
610 );
611 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
665 Layer.provideMerge(runtimeServicesLayer),612 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(
666 Layer.provide(persistenceLayer),623 Layer.provide(persistenceLayer),
667 Layer.provideMerge(NodeServices.layer),624 Layer.provideMerge(NodeServices.layer),
668 );625 );
669 const runtime = ManagedRuntime.make(layer);626 const runtime = ManagedRuntime.make(layer);
670627
671 try {628 try {
672 const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([629 const [
630 nextOrchestrationEngine,
631 nextProjectionSnapshotQuery,
632 nextCheckpointDiffBlobRepository,
633 nextProviderService,
634 ] = await Promise.all([
673 runtime.runPromise(Effect.service(OrchestrationEngineService)),635 runtime.runPromise(Effect.service(OrchestrationEngineService)),
674 runtime.runPromise(Effect.service(ProjectRepository)),636 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
637 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
675 runtime.runPromise(Effect.service(ProviderService)),638 runtime.runPromise(Effect.service(ProviderService)),
676 ]);639 ]);
677 orchestrationEngine = nextOrchestrationEngine;640 orchestrationEngine = nextOrchestrationEngine;
678 projectRepository = repository;641 projectionSnapshotQuery = nextProjectionSnapshotQuery;
642 checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository;
679 providerService = nextProviderService;643 providerService = nextProviderService;
680 await runtime.runPromise(repository.pruneMissing());
681 await runtime.runPromise(attachSubscriptionss);644 await runtime.runPromise(attachSubscriptionss);
682 } catch (error) {645 } catch (error) {
683 await runtime.dispose().catch(() => undefined);646 await runtime.dispose().catch(() => undefined);
...@@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) {...@@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) {
701 const liveProviderService = providerService;664 const liveProviderService = providerService;
702 effectRuntime = null;665 effectRuntime = null;
703 orchestrationEngine = null;666 orchestrationEngine = null;
704 projectRepository = null;667 projectionSnapshotQuery = null;
668 checkpointDiffBlobRepository = null;
705 providerService = null;669 providerService = null;
706670
707 if (!runtime) {671 if (!runtime) {
apps/server/tsconfig.json+2-1
...@@ -12,5 +12,6 @@...@@ -12,5 +12,6 @@
12 }12 }
13 ]13 ]
14 },14 },
15 "include": ["src", "integration", "tsdown.config.ts", "scripts"]15 "include": ["src", "tsdown.config.ts", "scripts"],
16 "exclude": ["src/**/*.test.ts", "integration"]
16}17}
packages/contracts/package.json+1-2
...@@ -24,8 +24,7 @@...@@ -24,8 +24,7 @@
24 "test": "vitest run"24 "test": "vitest run"
25 },25 },
26 "dependencies": {26 "dependencies": {
27 "effect": "4.0.0-beta.10",27 "effect": "4.0.0-beta.10"
28 "zod": "^3.24.1"
29 },28 },
30 "devDependencies": {29 "devDependencies": {
31 "@effect/language-service": "0.75.1",30 "@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 @@...@@ -1,152 +1,159 @@
1import { z } from "zod";1import { Schema } from "effect";
22
3// ── Existing branch/worktree Git API ─────────────────────────────────3const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
44
5export const gitListBranchesInputSchema = z.object({5// Domain Types
6 cwd: z.string().min(1),6
7});7export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
88export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
9export const gitCreateWorktreeInputSchema = z.object({9export const GitPushStepStatus = Schema.Literals([
10 cwd: z.string().min(1),10 "pushed",
11 branch: z.string().min(1),11 "skipped_not_requested",
12 newBranch: z.string().min(1),12 "skipped_up_to_date",
13 path: z.string().min(1).optional(),13]);
14});14export const GitPrStepStatus = Schema.Literals([
1515 "created",
16export const gitRemoveWorktreeInputSchema = z.object({16 "opened_existing",
17 cwd: z.string().min(1),17 "skipped_not_requested",
18 path: z.string().min(1),18]);
19 force: z.boolean().optional(),19
20});20export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({
2121 name: TrimmedNonEmptyString,
22export const gitCreateBranchInputSchema = z.object({22 current: Schema.Boolean,
23 cwd: z.string().min(1),23 isDefault: Schema.Boolean,
24 branch: z.string().min(1),24 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),
25});25}) {}
2626
27export const gitCheckoutInputSchema = z.object({27export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({
28 cwd: z.string().min(1),28 path: TrimmedNonEmptyString,
29 branch: z.string().min(1),29 branch: TrimmedNonEmptyString,
30});30}) {}
3131
32export const gitInitInputSchema = z.object({32// RPC Inputs
33 cwd: z.string().min(1),33
34});34export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({
3535 cwd: TrimmedNonEmptyString,
36export const gitBranchSchema = z.object({36}) {}
37 name: z.string().min(1),37
38 current: z.boolean(),38export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({
39 isDefault: z.boolean(),39 cwd: TrimmedNonEmptyString,
40 worktreePath: z.string().min(1).nullable(),40}) {}
41});41
4242export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>(
43export const gitWorktreeSchema = z.object({43 "GitRunStackedActionInput",
44 path: z.string().min(1),44)({
45 branch: z.string().min(1),45 cwd: TrimmedNonEmptyString,
46});46 action: GitStackedAction,
4747 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),
48export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;48}) {}
49export type GitBranch = z.infer<typeof gitBranchSchema>;49
50export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;50export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>(
51export type GitWorktree = z.infer<typeof gitWorktreeSchema>;51 "GitListBranchesInput",
52export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>;52)({
53export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;53 cwd: TrimmedNonEmptyString,
54export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;54}) {}
55export type GitInitInput = z.infer<typeof gitInitInputSchema>;55
5656export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>(
57export interface GitListBranchesResult {57 "GitCreateWorktreeInput",
58 branches: GitBranch[];58)({
59 isRepo: boolean;59 cwd: TrimmedNonEmptyString,
60}60 branch: TrimmedNonEmptyString,
6161 newBranch: TrimmedNonEmptyString,
62export interface GitCreateWorktreeResult {62 path: TrimmedNonEmptyString.pipe(Schema.NullOr),
63 worktree: GitWorktree;63}) {}
64}64
6565export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>(
66// ── Stacked action Git API ───────────────────────────────────────────66 "GitRemoveWorktreeInput",
6767)({
68export const gitStatusInputSchema = z.object({68 cwd: TrimmedNonEmptyString,
69 cwd: z.string().trim().min(1),69 path: TrimmedNonEmptyString,
70});70 force: Schema.Boolean.pipe(Schema.NullOr),
7171}) {}
72export const gitPullInputSchema = z.object({72
73 cwd: z.string().trim().min(1),73export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(
74});74 "GitCreateBranchInput",
7575)({
76export const gitStatusResultSchema = z.object({76 cwd: TrimmedNonEmptyString,
77 branch: z.string().min(1).nullable(),77 branch: TrimmedNonEmptyString,
78 hasWorkingTreeChanges: z.boolean(),78}) {}
79 workingTree: z.object({79
80 files: z.array(80export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({
81 z.object({81 cwd: TrimmedNonEmptyString,
82 path: z.string().min(1),82 branch: TrimmedNonEmptyString,
83 insertions: z.number().int().nonnegative(),83}) {}
84 deletions: z.number().int().nonnegative(),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)),
85 }),100 }),
86 ),101 ),
87 insertions: z.number().int().nonnegative(),102 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
88 deletions: z.number().int().nonnegative(),103 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
89 }),104 }),
90 hasUpstream: z.boolean(),105 hasUpstream: Schema.Boolean,
91 aheadCount: z.number().int().nonnegative(),106 aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
92 behindCount: z.number().int().nonnegative(),107 behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
93 openPr: z108 openPr: Schema.Struct({
94 .object({109 number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
95 number: z.number().int().positive(),110 title: TrimmedNonEmptyString,
96 title: z.string().min(1),111 url: Schema.String,
97 url: z.string().url(),112 baseBranch: TrimmedNonEmptyString,
98 baseBranch: z.string().min(1),113 headBranch: TrimmedNonEmptyString,
99 headBranch: z.string().min(1),114 }).pipe(Schema.NullOr),
100 })115}) {}
101 .nullable(),116
102});117export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>(
103118 "GitListBranchesResult",
104export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);119)({
105120 branches: Schema.Array(GitBranch),
106const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]);121 isRepo: Schema.Boolean,
107const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]);122}) {}
108const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]);123
109124export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>(
110export const gitRunStackedActionInputSchema = z.object({125 "GitCreateWorktreeResult",
111 cwd: z.string().trim().min(1),126)({
112 action: gitStackedActionSchema,127 worktree: GitWorktree,
113 commitMessage: z.string().trim().min(1).max(10_000).optional(),128}) {}
114});129
115130export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>(
116export const gitRunStackedActionResultSchema = z.object({131 "GitRunStackedActionResult",
117 action: gitStackedActionSchema,132)({
118 commit: z.object({133 action: GitStackedAction,
119 status: gitCommitStepStatusSchema,134 commit: Schema.Struct({
120 commitSha: z.string().min(1).optional(),135 status: GitCommitStepStatus,
121 subject: z.string().min(1).optional(),136 commitSha: Schema.optional(TrimmedNonEmptyString),
137 subject: Schema.optional(TrimmedNonEmptyString),
122 }),138 }),
123 push: z.object({139 push: Schema.Struct({
124 status: gitPushStepStatusSchema,140 status: GitPushStepStatus,
125 branch: z.string().min(1).optional(),141 branch: Schema.optional(TrimmedNonEmptyString),
126 upstreamBranch: z.string().min(1).optional(),142 upstreamBranch: Schema.optional(TrimmedNonEmptyString),
127 setUpstream: z.boolean().optional(),143 setUpstream: Schema.optional(Schema.Boolean),
128 }),144 }),
129 pr: z.object({145 pr: Schema.Struct({
130 status: gitPrStepStatusSchema,146 status: GitPrStepStatus,
131 url: z.string().url().optional(),147 url: Schema.optional(Schema.String),
132 number: z.number().int().positive().optional(),148 number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
133 baseBranch: z.string().min(1).optional(),149 baseBranch: Schema.optional(TrimmedNonEmptyString),
134 headBranch: z.string().min(1).optional(),150 headBranch: Schema.optional(TrimmedNonEmptyString),
135 title: z.string().min(1).optional(),151 title: Schema.optional(TrimmedNonEmptyString),
136 }),152 }),
137});153}) {}
138154
139export type GitStatusInput = z.input<typeof gitStatusInputSchema>;155export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({
140export type GitPullInput = z.input<typeof gitPullInputSchema>;156 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
141export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;157 branch: TrimmedNonEmptyString,
142export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;158 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),
143export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;159}) {}
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>;
packages/contracts/src/index.ts+2-3
...@@ -1,6 +1,4 @@...@@ -1,6 +1,4 @@
1export * from "./todo";
2export * from "./ipc";1export * from "./ipc";
3export * from "./agent";
4export * from "./terminal";2export * from "./terminal";
5export * from "./provider";3export * from "./provider";
6export * from "./providerRuntime";4export * from "./providerRuntime";
...@@ -8,6 +6,7 @@ export * from "./model";...@@ -8,6 +6,7 @@ export * from "./model";
8export * from "./ws";6export * from "./ws";
9export * from "./keybindings";7export * from "./keybindings";
10export * from "./server";8export * from "./server";
11export * from "./project";
12export * from "./git";9export * from "./git";
13export * from "./orchestration";10export * from "./orchestration";
11export * from "./editor";
12export * from "./project";
packages/contracts/src/ipc.ts+7-69
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type {1import type {
3 GitCheckoutInput,2 GitCheckoutInput,
4 GitCreateBranchInput,3 GitCreateBranchInput,
...@@ -15,31 +14,7 @@ import type {...@@ -15,31 +14,7 @@ import type {
15 GitStatusInput,14 GitStatusInput,
16 GitStatusResult,15 GitStatusResult,
17} from "./git";16} from "./git";
18import type {17import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project";
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";
43import type { ServerConfig } from "./server";18import type { ServerConfig } from "./server";
44import type {19import type {
45 TerminalClearInput,20 TerminalClearInput,
...@@ -50,28 +25,17 @@ import type {...@@ -50,28 +25,17 @@ import type {
50 TerminalSessionSnapshot,25 TerminalSessionSnapshot,
51 TerminalWriteInput,26 TerminalWriteInput,
52} from "./terminal";27} from "./terminal";
53import type { NewTodoInput, Todo } from "./todo";
54import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";28import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
55import type {29import type {
56 OrchestrationCommand,30 ClientOrchestrationCommand,
31 OrchestrationGetTurnDiffInput,
32 OrchestrationGetTurnDiffResult,
57 OrchestrationEvent,33 OrchestrationEvent,
58 OrchestrationReadModel,34 OrchestrationReadModel,
59} from "./orchestration";35} from "./orchestration";
6036import { EditorId } from "./editor";
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"];
6737
68export interface NativeApi {38export 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 };
75 dialogs: {39 dialogs: {
76 pickFolder: () => Promise<string | null>;40 pickFolder: () => Promise<string | null>;
77 confirm: (message: string) => Promise<boolean>;41 confirm: (message: string) => Promise<boolean>;
...@@ -85,35 +49,8 @@ export interface NativeApi {...@@ -85,35 +49,8 @@ export interface NativeApi {
85 close: (input: TerminalCloseInput) => Promise<void>;49 close: (input: TerminalCloseInput) => Promise<void>;
86 onEvent: (callback: (event: TerminalEvent) => void) => () => void;50 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
87 };51 };
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 };
111 projects: {52 projects: {
112 list: () => Promise<ProjectListResult>;
113 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
114 remove: (input: ProjectRemoveInput) => Promise<void>;
115 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;53 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
116 updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
117 };54 };
118 shell: {55 shell: {
119 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;56 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
...@@ -144,7 +81,8 @@ export interface NativeApi {...@@ -144,7 +81,8 @@ export interface NativeApi {
144 };81 };
145 orchestration: {82 orchestration: {
146 getSnapshot: () => Promise<OrchestrationReadModel>;83 getSnapshot: () => Promise<OrchestrationReadModel>;
147 dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>;84 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
85 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
148 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;86 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
149 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;87 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
150 };88 };
packages/contracts/src/keybindings.test.ts+22-13
...@@ -1,27 +1,36 @@...@@ -1,27 +1,36 @@
1import { Schema } from "effect";
1import { assert, describe, it } from "vitest";2import { assert, describe, it } from "vitest";
23
3import {4import {
4 keybindingRuleSchema,5 KeybindingsConfig,
5 keybindingsConfigSchema,6 KeybindingRule,
6 resolvedKeybindingRuleSchema,7 ResolvedKeybindingRule,
7 resolvedKeybindingsConfigSchema,8 ResolvedKeybindingsConfig,
8} from "./keybindings";9} 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
10describe("keybindings contracts", () => {19describe("keybindings contracts", () => {
11 it("parses keybinding rules", () => {20 it("parses keybinding rules", () => {
12 const parsed = keybindingRuleSchema.parse({21 const parsed = decode(KeybindingRule, {
13 key: "mod+j",22 key: "mod+j",
14 command: "terminal.toggle",23 command: "terminal.toggle",
15 });24 });
16 assert.strictEqual(parsed.command, "terminal.toggle");25 assert.strictEqual(parsed.command, "terminal.toggle");
1726
18 const parsedClose = keybindingRuleSchema.parse({27 const parsedClose = decode(KeybindingRule, {
19 key: "mod+w",28 key: "mod+w",
20 command: "terminal.close",29 command: "terminal.close",
21 });30 });
22 assert.strictEqual(parsedClose.command, "terminal.close");31 assert.strictEqual(parsedClose.command, "terminal.close");
2332
24 const parsedLocal = keybindingRuleSchema.parse({33 const parsedLocal = decode(KeybindingRule, {
25 key: "mod+shift+n",34 key: "mod+shift+n",
26 command: "chat.newLocal",35 command: "chat.newLocal",
27 });36 });
...@@ -30,7 +39,7 @@ describe("keybindings contracts", () => {...@@ -30,7 +39,7 @@ describe("keybindings contracts", () => {
3039
31 it("rejects invalid command values", () => {40 it("rejects invalid command values", () => {
32 assert.throws(() =>41 assert.throws(() =>
33 keybindingRuleSchema.parse({42 decode(KeybindingRule, {
34 key: "mod+j",43 key: "mod+j",
35 command: "script.Test.run",44 command: "script.Test.run",
36 }),45 }),
...@@ -38,7 +47,7 @@ describe("keybindings contracts", () => {...@@ -38,7 +47,7 @@ describe("keybindings contracts", () => {
38 });47 });
3948
40 it("accepts dynamic script run commands", () => {49 it("accepts dynamic script run commands", () => {
41 const parsed = keybindingRuleSchema.parse({50 const parsed = decode(KeybindingRule, {
42 key: "mod+r",51 key: "mod+r",
43 command: "script.setup.run",52 command: "script.setup.run",
44 });53 });
...@@ -46,7 +55,7 @@ describe("keybindings contracts", () => {...@@ -46,7 +55,7 @@ describe("keybindings contracts", () => {
46 });55 });
4756
48 it("parses keybindings array payload", () => {57 it("parses keybindings array payload", () => {
49 const parsed = keybindingsConfigSchema.parse([58 const parsed = decode(KeybindingsConfig, [
50 { key: "mod+j", command: "terminal.toggle" },59 { key: "mod+j", command: "terminal.toggle" },
51 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },60 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
52 ]);61 ]);
...@@ -54,7 +63,7 @@ describe("keybindings contracts", () => {...@@ -54,7 +63,7 @@ describe("keybindings contracts", () => {
54 });63 });
5564
56 it("parses resolved keybinding rules", () => {65 it("parses resolved keybinding rules", () => {
57 const parsed = resolvedKeybindingRuleSchema.parse({66 const parsed = decode(ResolvedKeybindingRule, {
58 command: "terminal.split",67 command: "terminal.split",
59 shortcut: {68 shortcut: {
60 key: "d",69 key: "d",
...@@ -77,7 +86,7 @@ describe("keybindings contracts", () => {...@@ -77,7 +86,7 @@ describe("keybindings contracts", () => {
77 });86 });
7887
79 it("parses resolved keybindings arrays", () => {88 it("parses resolved keybindings arrays", () => {
80 const parsed = resolvedKeybindingsConfigSchema.parse([89 const parsed = decode(ResolvedKeybindingsConfig, [
81 {90 {
82 command: "terminal.toggle",91 command: "terminal.toggle",
83 shortcut: {92 shortcut: {
...@@ -95,7 +104,7 @@ describe("keybindings contracts", () => {...@@ -95,7 +104,7 @@ describe("keybindings contracts", () => {
95104
96 it("rejects unknown fields in resolved keybinding rules", () => {105 it("rejects unknown fields in resolved keybinding rules", () => {
97 assert.throws(() =>106 assert.throws(() =>
98 resolvedKeybindingRuleSchema.parse({107 decodeResolvedRuleStrict({
99 command: "terminal.toggle",108 command: "terminal.toggle",
100 shortcut: {109 shortcut: {
101 key: "j",110 key: "j",
packages/contracts/src/keybindings.ts+58-65
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { z } from "zod";1import { Schema } from "effect";
22
3export const STATIC_KEYBINDING_COMMANDS = [3export const STATIC_KEYBINDING_COMMANDS = [
4 "terminal.toggle",4 "terminal.toggle",
...@@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [
10 "editor.openFavorite",10 "editor.openFavorite",
11] as const;11] as const;
1212
13export const keybindingCommandSchema = z.union([13export const KeybindingCommand = Schema.Union([
14 z.enum(STATIC_KEYBINDING_COMMANDS),14 Schema.Literals(STATIC_KEYBINDING_COMMANDS),
15 z15 Schema.NonEmptyString.check(
16 .string()16 Schema.isMaxLength(96),
17 .trim()17 Schema.isPattern(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
18 .min(1)18 ),
19 .max(96)
20 .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
21]);19]);
20export type KeybindingCommand = typeof KeybindingCommand.Type;
2221
23const keybindingValueSchema = z.string().trim().min(1).max(64);22export const KeybindingValue = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(64));
24const keybindingWhenSchema = z.string().trim().min(1).max(256);23export type KeybindingValue = typeof KeybindingValue.Type;
2524
26export const keybindingRuleSchema = z.object({25export const KeybindingWhen = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(256));
27 key: keybindingValueSchema,26export type KeybindingWhen = typeof KeybindingWhen.Type;
28 command: keybindingCommandSchema,27export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({
29 when: keybindingWhenSchema.optional(),28 key: KeybindingValue,
30});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 = z36export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({
35 .object({37 key: KeybindingValue,
36 key: z.string().trim().min(1).max(32),38 metaKey: Schema.Boolean,
37 metaKey: z.boolean(),39 ctrlKey: Schema.Boolean,
38 ctrlKey: z.boolean(),40 shiftKey: Schema.Boolean,
39 shiftKey: z.boolean(),41 altKey: Schema.Boolean,
40 altKey: z.boolean(),42 modKey: Schema.Boolean,
41 modKey: z.boolean(),43}) {}
42 })
43 .strict();
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]);
45export type KeybindingWhenNode =65export type KeybindingWhenNode =
46 | { type: "identifier"; name: string }66 | { type: "identifier"; name: string }
47 | { type: "not"; node: KeybindingWhenNode }67 | { type: "not"; node: KeybindingWhenNode }
48 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }68 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
49 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };69 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
5070
51export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>71export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(
52 z.union([72 "ResolvedKeybindingRule",
53 z.object({73)({
54 type: z.literal("identifier"),74 command: KeybindingCommand,
55 name: z.string().min(1),75 shortcut: KeybindingShortcut,
56 }),76 whenAst: Schema.optional(KeybindingWhenNode),
57 z.object({77}) {}
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();
8178
82export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);79export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
8380 Schema.isMaxLength(256),
84export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;81);
85export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;82export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
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>;
packages/contracts/src/orchestration.ts+798-177
...@@ -3,6 +3,7 @@ import { Schema } from "effect";...@@ -3,6 +3,7 @@ import { Schema } from "effect";
3export const ORCHESTRATION_WS_METHODS = {3export const ORCHESTRATION_WS_METHODS = {
4 getSnapshot: "orchestration.getSnapshot",4 getSnapshot: "orchestration.getSnapshot",
5 dispatchCommand: "orchestration.dispatchCommand",5 dispatchCommand: "orchestration.dispatchCommand",
6 getTurnDiff: "orchestration.getTurnDiff",
6 replayEvents: "orchestration.replayEvents",7 replayEvents: "orchestration.replayEvents",
7} as const;8} as const;
89
...@@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = {...@@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = {
10 domainEvent: "orchestration.domainEvent",11 domainEvent: "orchestration.domainEvent",
11} as const;12} as const;
1213
13const IsoDateTimeSchema = Schema.String;14export const IsoDateTime = Schema.String;
14const IdSchema = 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({68export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
17 id: IdSchema,69export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
18 role: Schema.Literals(["user", "assistant"]),70export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
19 text: Schema.String,71export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
20 createdAt: IsoDateTimeSchema,
21 completedAt: Schema.optional(IsoDateTimeSchema),
22 streaming: Schema.Boolean,
23});
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({77export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(
28 sessionId: IdSchema,78 Schema.isGreaterThanOrEqualTo(0),
29 status: Schema.Literals(["connecting", "ready", "running", "error", "closed"]),79);
30 provider: Schema.Literals(["codex", "claudeCode"]),80
31 threadId: IdSchema,81const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
32 activeTurnId: Schema.NullOr(Schema.String),82
33 createdAt: IsoDateTimeSchema,83export const ChatImageAttachment = Schema.Struct({
34 updatedAt: IsoDateTimeSchema,84 type: Schema.Literal("image"),
35 lastError: Schema.NullOr(Schema.String),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 ),
36});94});
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({118export const OrchestrationProject = Schema.Struct({
41 path: Schema.String,119 id: ProjectId,
42 kind: Schema.optional(Schema.String),120 title: Schema.String,
43 additions: Schema.optional(Schema.Number),121 workspaceRoot: Schema.String,
44 deletions: Schema.optional(Schema.Number),122 defaultModel: Schema.NullOr(Schema.String),
123 scripts: Schema.Array(ProjectScript),
124 createdAt: IsoDateTime,
125 updatedAt: IsoDateTime,
126 deletedAt: Schema.NullOr(IsoDateTime),
45});127});
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({133export const OrchestrationMessage = Schema.Struct({
50 turnId: IdSchema,134 id: MessageId,
51 completedAt: IsoDateTimeSchema,135 role: OrchestrationMessageRole,
52 status: Schema.optional(Schema.String),136 text: Schema.String,
53 files: Schema.Array(OrchestrationTurnDiffFileSchema),137 attachments: Schema.optional(Schema.Array(ChatAttachment)),
54 assistantMessageId: Schema.optional(Schema.String),138 turnId: Schema.NullOr(TurnId),
55 checkpointTurnCount: Schema.optional(Schema.Number),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,
56});165});
166export type OrchestrationSession = typeof OrchestrationSession.Type;
57167
58export type OrchestrationTurnDiffSummary = Schema.Schema.Type<168export const OrchestrationCheckpointFile = Schema.Struct({
59 typeof OrchestrationTurnDiffSummarySchema169 path: Schema.String,
60>;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([190export const OrchestrationThreadActivityTone = Schema.Literals([
63 "thinking",
64 "tool",
65 "info",191 "info",
192 "tool",
193 "approval",
66 "error",194 "error",
67]);195]);
196export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type;
68197
69export type OrchestrationThreadActivityTone = Schema.Schema.Type<198export const OrchestrationThreadActivity = Schema.Struct({
70 typeof OrchestrationThreadActivityToneSchema199 id: EventId,
71>;200 tone: OrchestrationThreadActivityTone,
72201 kind: Schema.String,
73export const OrchestrationThreadActivitySchema = Schema.Struct({202 summary: Schema.String,
74 id: IdSchema,203 payload: Schema.Unknown,
75 createdAt: IsoDateTimeSchema,204 turnId: Schema.NullOr(TurnId),
76 label: Schema.String,205 createdAt: IsoDateTime,
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"])),
82});206});
207export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
83208
84export type OrchestrationThreadActivity = Schema.Schema.Type<typeof OrchestrationThreadActivitySchema>;209export const OrchestrationThread = Schema.Struct({
85210 id: ThreadId,
86export const OrchestrationThreadSchema = Schema.Struct({211 projectId: ProjectId,
87 id: IdSchema,
88 projectId: IdSchema,
89 title: Schema.String,212 title: Schema.String,
90 model: Schema.String,213 model: Schema.String,
91 branch: Schema.NullOr(Schema.String),214 branch: Schema.NullOr(Schema.String),
92 worktreePath: Schema.NullOr(Schema.String),215 worktreePath: Schema.NullOr(Schema.String),
93 createdAt: IsoDateTimeSchema,216 latestTurnId: Schema.NullOr(TurnId),
94 updatedAt: IsoDateTimeSchema,217 createdAt: IsoDateTime,
95 latestTurnId: Schema.NullOr(Schema.String),218 updatedAt: IsoDateTime,
96 latestTurnStartedAt: Schema.NullOr(Schema.String),219 deletedAt: Schema.NullOr(IsoDateTime),
97 latestTurnCompletedAt: Schema.NullOr(Schema.String),220 messages: Schema.Array(OrchestrationMessage),
98 latestTurnDurationMs: Schema.NullOr(Schema.Number),221 activities: Schema.Array(OrchestrationThreadActivity),
99 messages: Schema.Array(OrchestrationMessageSchema),222 checkpoints: Schema.Array(OrchestrationCheckpointSummary),
100 session: Schema.NullOr(OrchestrationSessionSchema),223 session: Schema.NullOr(OrchestrationSession),
101 turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema),224});
102 activities: Schema.Array(OrchestrationThreadActivitySchema),225export type OrchestrationThread = typeof OrchestrationThread.Type;
103 error: Schema.NullOr(Schema.String),226
104});227export const OrchestrationReadModel = Schema.Struct({
105228 snapshotSequence: NonNegativeInt,
106export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>;229 projects: Schema.Array(OrchestrationProject),
107230 threads: Schema.Array(OrchestrationThread),
108export const OrchestrationGitReadModelSchema = Schema.Struct({231 updatedAt: IsoDateTime,
109 projectId: IdSchema,
110 branch: Schema.NullOr(Schema.String),
111 hasWorkingTreeChanges: Schema.Boolean,
112 aheadCount: Schema.Number,
113 behindCount: Schema.Number,
114 updatedAt: IsoDateTimeSchema,
115});232});
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({245export const ProjectMetaUpdateCommand = Schema.Struct({
120 sequence: Schema.Number,246 type: Schema.Literal("project.meta.update"),
121 threads: Schema.Array(OrchestrationThreadSchema),247 commandId: CommandId,
122 gitByProjectId: Schema.Record( Schema.String, OrchestrationGitReadModelSchema),248 projectId: ProjectId,
123 updatedAt: IsoDateTimeSchema,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)),
124});253});
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({
129 type: Schema.Literal("thread.create"),262 type: Schema.Literal("thread.create"),
130 commandId: IdSchema,263 commandId: CommandId,
131 threadId: IdSchema,264 threadId: ThreadId,
132 projectId: IdSchema,265 projectId: ProjectId,
133 title: Schema.String,266 title: Schema.String,
134 model: Schema.String,267 model: Schema.String,
135 branch: Schema.NullOr(Schema.String),268 branch: Schema.NullOr(Schema.String),
136 worktreePath: Schema.NullOr(Schema.String),269 worktreePath: Schema.NullOr(Schema.String),
137 createdAt: IsoDateTimeSchema,270 createdAt: IsoDateTime,
138});271});
139272
140export const DeleteThreadCommandSchema = Schema.Struct({273export const ThreadDeleteCommand = Schema.Struct({
141 type: Schema.Literal("thread.delete"),274 type: Schema.Literal("thread.delete"),
142 commandId: IdSchema,275 commandId: CommandId,
143 threadId: IdSchema,276 threadId: ThreadId,
144 createdAt: IsoDateTimeSchema,
145});277});
146278
147export const UpdateThreadMetaCommandSchema = Schema.Struct({279export const ThreadMetaUpdateCommand = Schema.Struct({
148 type: Schema.Literal("thread.meta.update"),280 type: Schema.Literal("thread.meta.update"),
149 commandId: IdSchema,281 commandId: CommandId,
150 threadId: IdSchema,282 threadId: ThreadId,
151 title: Schema.optional(Schema.String),283 title: Schema.optional(Schema.String),
152 model: Schema.optional(Schema.String),284 model: Schema.optional(Schema.String),
153 branch: Schema.optional(Schema.NullOr(Schema.String)),285 branch: Schema.optional(Schema.NullOr(Schema.String)),
154 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),286 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
155 createdAt: IsoDateTimeSchema,
156});287});
157288
158export const SendMessageCommandSchema = Schema.Struct({289export const ThreadTurnStartCommand = Schema.Struct({
159 type: Schema.Literal("message.send"),290 type: Schema.Literal("thread.turn.start"),
160 commandId: IdSchema,291 commandId: CommandId,
161 threadId: IdSchema,292 threadId: ThreadId,
162 messageId: IdSchema,293 message: Schema.Struct({
163 role: Schema.Literals(["user", "assistant"]),294 messageId: MessageId,
164 text: Schema.String,295 role: Schema.Literal("user"),
165 streaming: Schema.optional(Schema.Boolean),296 text: Schema.String,
166 createdAt: IsoDateTimeSchema,297 attachments: Schema.Array(ChatAttachment),
298 }),
299 model: Schema.optional(Schema.String),
300 effort: Schema.optional(Schema.String),
301 createdAt: IsoDateTime,
167});302});
168303
169export const SetThreadSessionSchema = Schema.Struct({304export const ThreadTurnInterruptCommand = Schema.Struct({
170 type: Schema.Literal("thread.session"),305 type: Schema.Literal("thread.turn.interrupt"),
171 commandId: IdSchema,306 commandId: CommandId,
172 threadId: IdSchema,307 threadId: ThreadId,
173 session: OrchestrationSessionSchema,308 turnId: Schema.optional(TurnId),
174 createdAt: IsoDateTimeSchema,309 createdAt: IsoDateTime,
175});310});
176311
177export const UpsertGitReadModelCommandSchema = Schema.Struct({312export const ThreadApprovalRespondCommand = Schema.Struct({
178 type: Schema.Literal("git.readModel.upsert"),313 type: Schema.Literal("thread.approval.respond"),
179 commandId: IdSchema,314 commandId: CommandId,
180 projectId: IdSchema,315 threadId: ThreadId,
181 branch: Schema.NullOr(Schema.String),316 requestId: ApprovalRequestId,
182 hasWorkingTreeChanges: Schema.Boolean,317 decision: ProviderApprovalDecision,
183 aheadCount: Schema.Number,318 createdAt: IsoDateTime,
184 behindCount: Schema.Number,319});
185 createdAt: IsoDateTimeSchema,320
186});321export const ThreadCheckpointRevertCommand = Schema.Struct({
187322 type: Schema.Literal("thread.checkpoint.revert"),
188export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({323 commandId: CommandId,
189 type: Schema.Literal("thread.turnDiff.complete"),324 threadId: ThreadId,
190 commandId: IdSchema,325 turnCount: NonNegativeInt,
191 threadId: IdSchema,326 createdAt: IsoDateTime,
192 turnId: IdSchema,327});
193 completedAt: IsoDateTimeSchema,328
194 status: Schema.optional(Schema.String),329export const ThreadSessionStopCommand = Schema.Struct({
195 files: Schema.Array(OrchestrationTurnDiffFileSchema),330 type: Schema.Literal("thread.session.stop"),
196 assistantMessageId: Schema.optional(Schema.String),331 commandId: CommandId,
197 checkpointTurnCount: Schema.optional(Schema.Number),332 threadId: ThreadId,
198 createdAt: IsoDateTimeSchema,333 createdAt: IsoDateTime,
199});334});
200335
201export const RevertThreadCommandSchema = Schema.Struct({336export const ClientOrchestrationCommand = Schema.Union([
202 type: Schema.Literal("thread.revert"),337 ProjectCreateCommand,
203 commandId: IdSchema,338 ProjectMetaUpdateCommand,
204 threadId: IdSchema,339 ProjectDeleteCommand,
205 turnCount: Schema.Number,340 ThreadCreateCommand,
206 messageCount: Schema.Number,341 ThreadDeleteCommand,
207 createdAt: IsoDateTimeSchema,342 ThreadMetaUpdateCommand,
208});343 ThreadTurnStartCommand,
209344 ThreadTurnInterruptCommand,
210export const AppendThreadActivityCommandSchema = Schema.Struct({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({
211 type: Schema.Literal("thread.activity.append"),393 type: Schema.Literal("thread.activity.append"),
212 commandId: IdSchema,394 commandId: CommandId,
213 threadId: IdSchema,395 threadId: ThreadId,
214 activity: OrchestrationThreadActivitySchema,396 activity: OrchestrationThreadActivity,
215 createdAt: IsoDateTimeSchema,397 createdAt: IsoDateTime,
216});398});
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);
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({408export const InternalOrchestrationCommand = Schema.Union([
233 sequence: Schema.Number,409 ThreadSessionSetCommand,
234 eventId: IdSchema,410 ThreadMessageAssistantDeltaCommand,
235 type: Schema.String,411 ThreadMessageAssistantCompleteCommand,
236 aggregateType: Schema.String,412 ThreadTurnDiffCompleteCommand,
237 aggregateId: Schema.String,413 ThreadActivityAppendCommand,
238 occurredAt: IsoDateTimeSchema,414 ThreadRevertCompleteCommand,
239 commandId: Schema.NullOr(Schema.String),415]);
240 payload: Schema.Unknown,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,
241});470});
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 @@...@@ -1,134 +1,30 @@
1import { z } from "zod";1import { Schema } from "effect";
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});
542
55export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;3export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
564const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
57export const projectSearchEntriesInputSchema = z.object({5
58 cwd: z.string().trim().min(1),6export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>(
59 query: z.string().trim().max(256).default(""),7 "ProjectSearchEntriesInput",
60 limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80),8)({
61});9 cwd: TrimmedNonEmptyString,
6210 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
63export const projectEntryKindSchema = z.enum(["file", "directory"]);11 limit: Schema.Int.check(
6412 Schema.isGreaterThanOrEqualTo(1),
65export const projectEntrySchema = z.object({13 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
66 path: z.string().min(1),14 ),
67 kind: projectEntryKindSchema,15}) {}
68 parentPath: z.string().optional(),16
69});17export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
7018
71export const projectSearchEntriesResultSchema = z.object({19export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({
72 entries: z.array(projectEntrySchema),20 path: TrimmedNonEmptyString,
73 truncated: z.boolean(),21 kind: ProjectEntryKind,
74});22 parentPath: Schema.optional(TrimmedNonEmptyString),
7523}) {}
76export const projectUpdateScriptsInputSchema = z.object({24
77 id: z.string().min(1),25export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>(
78 scripts: projectScriptsSchema,26 "ProjectSearchEntriesResult",
79});27)({
8028 entries: Schema.Array(ProjectEntry),
81export const projectUpdateScriptsResultSchema = z.object({29 truncated: Schema.Boolean,
82 project: projectRecordSchema,30}) {}
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>;
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 @@...@@ -1,221 +1,169 @@
1import { z } from "zod";1import { Schema } from "effect";
22
3export const providerKindSchema = z.enum(["codex", "claudeCode"]);3import {
44 ApprovalRequestId,
5export const providerApprovalPolicySchema = z.enum([5 ChatAttachment,
6 "untrusted",6 EventId,
7 "on-failure",7 IsoDateTime,
8 "on-request",8 NonNegativeInt,
9 "never",9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10]);10 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1111 ProviderApprovalDecision,
12export const providerSandboxModeSchema = z.enum([12 ProviderApprovalPolicy,
13 "read-only",13 ProviderKind,
14 "workspace-write",14 ProviderItemId,
15 "danger-full-access",15 ProviderRequestKind,
16]);16 ProviderSandboxMode,
1717 ProviderSessionId,
18export const providerRequestKindSchema = z.enum(["command", "file-change"]);18 ProviderThreadId,
1919 ProviderTurnId,
20export const providerApprovalDecisionSchema = z.enum([20 ThreadId,
21 "accept",21 TurnCountRange,
22 "acceptForSession",22} from "./orchestration";
23 "decline",23
24 "cancel",24const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
25]);25
2626export const ProviderSessionStatus = Schema.Literals([
27export const providerSessionStatusSchema = z.enum([
28 "connecting",27 "connecting",
29 "ready",28 "ready",
30 "running",29 "running",
31 "error",30 "error",
32 "closed",31 "closed",
33]);32]);
3433export type ProviderSessionStatus = typeof ProviderSessionStatus.Type;
35export const providerSessionSchema = z.object({34
36 sessionId: z.string().min(1),35export const ProviderSession = Schema.Struct({
37 provider: providerKindSchema,36 sessionId: ProviderSessionId,
38 status: providerSessionStatusSchema,37 provider: ProviderKind,
39 cwd: z.string().min(1).optional(),38 status: ProviderSessionStatus,
40 model: z.string().min(1).optional(),39 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
41 threadId: z.string().min(1).optional(),40 model: Schema.optional(TrimmedNonEmptyStringSchema),
42 activeTurnId: z.string().min(1).optional(),41 threadId: Schema.optional(ProviderThreadId),
43 createdAt: z.string().datetime(),42 activeTurnId: Schema.optional(ProviderTurnId),
44 updatedAt: z.string().datetime(),43 createdAt: IsoDateTime,
45 lastError: z.string().min(1).optional(),44 updatedAt: IsoDateTime,
45 lastError: Schema.optional(TrimmedNonEmptyStringSchema),
46});46});
4747export type ProviderSession = typeof ProviderSession.Type;
48export const providerSessionStartInputSchema = z.object({48
49 provider: providerKindSchema.default("codex"),49export const ProviderSessionStartInput = Schema.Struct({
50 cwd: z.string().min(1).optional(),50 provider: Schema.optional(ProviderKind),
51 model: z.string().trim().min(1).optional(),51 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
52 resumeThreadId: z.string().trim().min(1).optional(),52 model: Schema.optional(TrimmedNonEmptyStringSchema),
53 codexBinaryPath: z.string().trim().min(1).optional(),53 resumeThreadId: Schema.optional(ProviderThreadId),
54 codexHomePath: z.string().trim().min(1).optional(),54 codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
55 approvalPolicy: providerApprovalPolicySchema.default("never"),55 codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema),
56 sandboxMode: providerSandboxModeSchema.default("workspace-write"),56 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
57 sandboxMode: Schema.optional(ProviderSandboxMode),
57});58});
5859export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
59export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;60
60export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;61export const ProviderSendTurnInput = Schema.Struct({
61export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;62 sessionId: ProviderSessionId,
62export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;63 input: Schema.optional(
6364 TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
64export const providerSendTurnImageAttachmentSchema = z.object({65 ),
65 type: z.literal("image"),66 attachments: Schema.optional(
66 name: z.string().trim().min(1).max(255),67 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
67 mimeType: z68 ),
68 .string()69 model: Schema.optional(TrimmedNonEmptyStringSchema),
69 .trim()70 effort: Schema.optional(TrimmedNonEmptyStringSchema),
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),
75});71});
72export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
7673
77export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [74export const ProviderTurnStartResult = Schema.Struct({
78 providerSendTurnImageAttachmentSchema,75 threadId: ProviderThreadId,
79]);76 turnId: ProviderTurnId,
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),
105});77});
78export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type;
10679
107export const providerInterruptTurnInputSchema = z.object({80export const ProviderInterruptTurnInput = Schema.Struct({
108 sessionId: z.string().min(1),81 sessionId: ProviderSessionId,
109 turnId: z.string().min(1).optional(),82 turnId: Schema.optional(ProviderTurnId),
110});83});
84export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type;
11185
112export const providerStopSessionInputSchema = z.object({86export const ProviderStopSessionInput = Schema.Struct({
113 sessionId: z.string().min(1),87 sessionId: ProviderSessionId,
114});88});
89export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type;
11590
116export const providerListCheckpointsInputSchema = z.object({91export const ProviderListCheckpointsInput = Schema.Struct({
117 sessionId: z.string().min(1),92 sessionId: ProviderSessionId,
118});93});
11994export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type;
120export const providerCheckpointSchema = z.object({95
121 id: z.string().min(1),96export const ProviderCheckpoint = Schema.Struct({
122 turnCount: z.number().int().min(0),97 id: TrimmedNonEmptyStringSchema,
123 messageCount: z.number().int().min(0),98 turnCount: NonNegativeInt,
124 label: z.string().min(1),99 messageCount: NonNegativeInt,
125 preview: z.string().min(1).optional(),100 label: TrimmedNonEmptyStringSchema,
126 isCurrent: z.boolean(),101 preview: Schema.optional(TrimmedNonEmptyStringSchema),
102 isCurrent: Schema.Boolean,
127});103});
104export type ProviderCheckpoint = typeof ProviderCheckpoint.Type;
128105
129export const providerListCheckpointsResultSchema = z.object({106export const ProviderListCheckpointsResult = Schema.Struct({
130 threadId: z.string().min(1),107 threadId: ThreadId,
131 checkpoints: z.array(providerCheckpointSchema),108 checkpoints: Schema.Array(ProviderCheckpoint),
132});109});
110export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type;
133111
134export const providerRevertToCheckpointInputSchema = z.object({112export const ProviderRevertToCheckpointInput = Schema.Struct({
135 sessionId: z.string().min(1),113 sessionId: ProviderSessionId,
136 turnCount: z.number().int().min(0),114 turnCount: NonNegativeInt,
137});115});
138116export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type;
139export const providerRevertToCheckpointResultSchema = z.object({117
140 threadId: z.string().min(1),118export const ProviderRevertToCheckpointResult = Schema.Struct({
141 turnCount: z.number().int().min(0),119 threadId: ThreadId,
142 messageCount: z.number().int().min(0),120 turnCount: NonNegativeInt,
143 rolledBackTurns: z.number().int().min(0),121 messageCount: NonNegativeInt,
144 checkpoints: z.array(providerCheckpointSchema),122 rolledBackTurns: NonNegativeInt,
123 checkpoints: Schema.Array(ProviderCheckpoint),
145});124});
125export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type;
146126
147export const providerGetCheckpointDiffInputSchema = z127export const ProviderGetCheckpointDiffInput = Schema.Struct({
148 .object({128 sessionId: ProviderSessionId,
149 sessionId: z.string().min(1),129 ...TurnCountRange.fields,
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(),
168});130});
131export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type;
169132
170export const providerRespondToRequestInputSchema = z.object({133export const ProviderGetCheckpointDiffResult = Schema.Struct({
171 sessionId: z.string().min(1),134 threadId: ThreadId,
172 requestId: z.string().min(1),135 ...TurnCountRange.fields,
173 decision: providerApprovalDecisionSchema,136 diff: Schema.String,
174});137});
138export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type;
175139
176export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);140export const ProviderRespondToRequestInput = Schema.Struct({
177141 sessionId: ProviderSessionId,
178export const providerEventSchema = z.object({142 requestId: ApprovalRequestId,
179 id: z.string().min(1),143 decision: ProviderApprovalDecision,
180 kind: providerEventKindSchema,144});
181 provider: providerKindSchema,145export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type;
182 sessionId: z.string().min(1),146
183 createdAt: z.string().datetime(),147export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]);
184 method: z.string().min(1),148export type ProviderEventKind = typeof ProviderEventKind.Type;
185 message: z.string().min(1).optional(),149
186 threadId: z.string().min(1).optional(),150export const ProviderEvent = Schema.Struct({
187 turnId: z.string().min(1).optional(),151 id: EventId,
188 itemId: z.string().min(1).optional(),152 kind: ProviderEventKind,
189 requestId: z.string().min(1).optional(),153 provider: ProviderKind,
190 requestKind: providerRequestKindSchema.optional(),154 sessionId: ProviderSessionId,
191 textDelta: z.string().optional(),155 createdAt: IsoDateTime,
192 payload: z.unknown().optional(),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),
193});165});
166export type ProviderEvent = typeof ProviderEvent.Type;
194167
195export type ProviderKind = z.infer<typeof providerKindSchema>;168export type ProviderSendTurnAttachment = typeof ChatAttachment.Type;
196export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;169export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type;
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>;
packages/contracts/src/providerRuntime.ts+160-173
...@@ -1,232 +1,219 @@...@@ -1,232 +1,219 @@
1import { Schema } from "effect";1import { Schema } from "effect";
22
3const IsoDateTimeSchema = Schema.String;3import {
4const IdSchema = Schema.String;4 ApprovalRequestId,
55 EventId,
6export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]);6 IsoDateTime,
7export type ProviderRuntimeProvider = Schema.Schema.Type<typeof ProviderRuntimeProviderSchema>;7 NonNegativeInt,
88 ProviderApprovalDecision,
9export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]);9 ProviderItemId,
10export type ProviderRuntimeApprovalKind = Schema.Schema.Type<10 ProviderKind,
11 typeof ProviderRuntimeApprovalKindSchema11 ProviderRequestKind,
12>;12 ProviderSessionId,
1313 ProviderThreadId,
14export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]);14 ProviderTurnId,
15export type ProviderRuntimeToolKind = Schema.Schema.Type<typeof ProviderRuntimeToolKindSchema>;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([
18 "completed",29 "completed",
19 "failed",30 "failed",
20 "cancelled",31 "cancelled",
21 "interrupted",32 "interrupted",
22]);33]);
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({
26 type: Schema.Literal("session.started"),37 type: Schema.Literal("session.started"),
27 eventId: IdSchema,38 eventId: EventId,
28 provider: ProviderRuntimeProviderSchema,39 provider: ProviderKind,
29 sessionId: IdSchema,40 sessionId: ProviderSessionId,
30 createdAt: IsoDateTimeSchema,41 createdAt: IsoDateTime,
31 threadId: Schema.optional(Schema.String),42 threadId: Schema.optional(RuntimeThreadIdSchema),
32 message: Schema.optional(Schema.String),43 message: Schema.optional(Schema.String),
33});44});
45export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;
3446
35export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({47export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
36 type: Schema.Literal("session.exited"),48 type: Schema.Literal("session.exited"),
37 eventId: IdSchema,49 eventId: EventId,
38 provider: ProviderRuntimeProviderSchema,50 provider: ProviderKind,
39 sessionId: IdSchema,51 sessionId: ProviderSessionId,
40 createdAt: IsoDateTimeSchema,52 createdAt: IsoDateTime,
41 threadId: Schema.optional(Schema.String),53 threadId: Schema.optional(RuntimeThreadIdSchema),
42 message: Schema.optional(Schema.String),54 message: Schema.optional(Schema.String),
43});55});
56export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;
4457
45export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({58export const ProviderRuntimeThreadStartedEvent = Schema.Struct({
46 type: Schema.Literal("thread.started"),59 type: Schema.Literal("thread.started"),
47 eventId: IdSchema,60 eventId: EventId,
48 provider: ProviderRuntimeProviderSchema,61 provider: ProviderKind,
49 sessionId: IdSchema,62 sessionId: ProviderSessionId,
50 createdAt: IsoDateTimeSchema,63 createdAt: IsoDateTime,
51 threadId: IdSchema,64 threadId: RuntimeThreadIdSchema,
52});65});
66export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type;
5367
54export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({68export const ProviderRuntimeTurnStartedEvent = Schema.Struct({
55 type: Schema.Literal("turn.started"),69 type: Schema.Literal("turn.started"),
56 eventId: IdSchema,70 eventId: EventId,
57 provider: ProviderRuntimeProviderSchema,71 provider: ProviderKind,
58 sessionId: IdSchema,72 sessionId: ProviderSessionId,
59 createdAt: IsoDateTimeSchema,73 createdAt: IsoDateTime,
60 threadId: Schema.optional(Schema.String),74 threadId: Schema.optional(RuntimeThreadIdSchema),
61 turnId: IdSchema,75 turnId: RuntimeTurnIdSchema,
62});76});
77export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type;
6378
64export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({79export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
65 type: Schema.Literal("turn.completed"),80 type: Schema.Literal("turn.completed"),
66 eventId: IdSchema,81 eventId: EventId,
67 provider: ProviderRuntimeProviderSchema,82 provider: ProviderKind,
68 sessionId: IdSchema,83 sessionId: ProviderSessionId,
69 createdAt: IsoDateTimeSchema,84 createdAt: IsoDateTime,
70 threadId: Schema.optional(Schema.String),85 threadId: Schema.optional(RuntimeThreadIdSchema),
71 turnId: Schema.optional(Schema.String),86 turnId: Schema.optional(RuntimeTurnIdSchema),
72 status: Schema.optional(ProviderRuntimeTurnStatusSchema),87 status: Schema.optional(ProviderRuntimeTurnStatus),
73 errorMessage: Schema.optional(Schema.String),88 errorMessage: Schema.optional(Schema.String),
74});89});
90export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;
7591
76export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({92export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({
77 type: Schema.Literal("message.delta"),93 type: Schema.Literal("message.delta"),
78 eventId: IdSchema,94 eventId: EventId,
79 provider: ProviderRuntimeProviderSchema,95 provider: ProviderKind,
80 sessionId: IdSchema,96 sessionId: ProviderSessionId,
81 createdAt: IsoDateTimeSchema,97 createdAt: IsoDateTime,
82 threadId: Schema.optional(Schema.String),98 threadId: Schema.optional(RuntimeThreadIdSchema),
83 turnId: Schema.optional(Schema.String),99 turnId: Schema.optional(RuntimeTurnIdSchema),
84 itemId: Schema.optional(Schema.String),100 itemId: Schema.optional(ProviderItemId),
85 delta: Schema.String,101 delta: Schema.String,
86});102});
103export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type;
87104
88export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({105export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({
89 type: Schema.Literal("message.completed"),106 type: Schema.Literal("message.completed"),
90 eventId: IdSchema,107 eventId: EventId,
91 provider: ProviderRuntimeProviderSchema,108 provider: ProviderKind,
92 sessionId: IdSchema,109 sessionId: ProviderSessionId,
93 createdAt: IsoDateTimeSchema,110 createdAt: IsoDateTime,
94 itemId: IdSchema,111 itemId: ProviderItemId,
95 threadId: Schema.optional(Schema.String),112 threadId: Schema.optional(RuntimeThreadIdSchema),
96 turnId: Schema.optional(Schema.String),113 turnId: Schema.optional(RuntimeTurnIdSchema),
97});114});
115export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type;
98116
99export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({117export const ProviderRuntimeToolStartedEvent = Schema.Struct({
100 type: Schema.Literal("tool.started"),118 type: Schema.Literal("tool.started"),
101 eventId: IdSchema,119 eventId: EventId,
102 provider: ProviderRuntimeProviderSchema,120 provider: ProviderKind,
103 sessionId: IdSchema,121 sessionId: ProviderSessionId,
104 createdAt: IsoDateTimeSchema,122 createdAt: IsoDateTime,
105 threadId: Schema.optional(Schema.String),123 threadId: Schema.optional(RuntimeThreadIdSchema),
106 turnId: Schema.optional(Schema.String),124 turnId: Schema.optional(RuntimeTurnIdSchema),
107 itemId: Schema.optional(Schema.String),125 itemId: Schema.optional(ProviderItemId),
108 toolKind: ProviderRuntimeToolKindSchema,126 toolKind: ProviderRuntimeToolKind,
109 title: Schema.String,127 title: Schema.String,
110 detail: Schema.optional(Schema.String),128 detail: Schema.optional(Schema.String),
111});129});
130export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;
112131
113export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({132export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
114 type: Schema.Literal("tool.completed"),133 type: Schema.Literal("tool.completed"),
115 eventId: IdSchema,134 eventId: EventId,
116 provider: ProviderRuntimeProviderSchema,135 provider: ProviderKind,
117 sessionId: IdSchema,136 sessionId: ProviderSessionId,
118 createdAt: IsoDateTimeSchema,137 createdAt: IsoDateTime,
119 threadId: Schema.optional(Schema.String),138 threadId: Schema.optional(RuntimeThreadIdSchema),
120 turnId: Schema.optional(Schema.String),139 turnId: Schema.optional(RuntimeTurnIdSchema),
121 itemId: Schema.optional(Schema.String),140 itemId: Schema.optional(ProviderItemId),
122 toolKind: ProviderRuntimeToolKindSchema,141 toolKind: ProviderRuntimeToolKind,
123 title: Schema.String,142 title: Schema.String,
124 detail: Schema.optional(Schema.String),143 detail: Schema.optional(Schema.String),
125});144});
145export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;
126146
127export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({147export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
128 type: Schema.Literal("approval.requested"),148 type: Schema.Literal("approval.requested"),
129 eventId: IdSchema,149 eventId: EventId,
130 provider: ProviderRuntimeProviderSchema,150 provider: ProviderKind,
131 sessionId: IdSchema,151 sessionId: ProviderSessionId,
132 createdAt: IsoDateTimeSchema,152 createdAt: IsoDateTime,
133 threadId: Schema.optional(Schema.String),153 threadId: Schema.optional(RuntimeThreadIdSchema),
134 turnId: Schema.optional(Schema.String),154 turnId: Schema.optional(RuntimeTurnIdSchema),
135 itemId: Schema.optional(Schema.String),155 itemId: Schema.optional(ProviderItemId),
136 requestId: IdSchema,156 requestId: ApprovalRequestId,
137 requestKind: ProviderRuntimeApprovalKindSchema,157 requestKind: ProviderRequestKind,
138 detail: Schema.optional(Schema.String),158 detail: Schema.optional(Schema.String),
139});159});
160export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type;
140161
141export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({162export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({
142 type: Schema.Literal("approval.resolved"),163 type: Schema.Literal("approval.resolved"),
143 eventId: IdSchema,164 eventId: EventId,
144 provider: ProviderRuntimeProviderSchema,165 provider: ProviderKind,
145 sessionId: IdSchema,166 sessionId: ProviderSessionId,
146 createdAt: IsoDateTimeSchema,167 createdAt: IsoDateTime,
147 threadId: Schema.optional(Schema.String),168 threadId: Schema.optional(RuntimeThreadIdSchema),
148 turnId: Schema.optional(Schema.String),169 turnId: Schema.optional(RuntimeTurnIdSchema),
149 itemId: Schema.optional(Schema.String),170 itemId: Schema.optional(ProviderItemId),
150 requestId: IdSchema,171 requestId: ApprovalRequestId,
151 requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema),172 requestKind: Schema.optional(ProviderRequestKind),
152 decision: Schema.optional(Schema.String),173 decision: Schema.optional(ProviderApprovalDecision),
153});174});
175export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type;
154176
155export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({177export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({
156 type: Schema.Literal("checkpoint.captured"),178 type: Schema.Literal("checkpoint.captured"),
157 eventId: IdSchema,179 eventId: EventId,
158 provider: ProviderRuntimeProviderSchema,180 provider: ProviderKind,
159 sessionId: IdSchema,181 sessionId: ProviderSessionId,
160 createdAt: IsoDateTimeSchema,182 createdAt: IsoDateTime,
161 threadId: IdSchema,183 threadId: RuntimeThreadIdSchema,
162 turnId: Schema.optional(Schema.String),184 turnId: Schema.optional(RuntimeTurnIdSchema),
163 turnCount: Schema.Number,185 turnCount: NonNegativeInt,
164 status: Schema.optional(ProviderRuntimeTurnStatusSchema),186 status: Schema.optional(ProviderRuntimeTurnStatus),
165});187});
188export type ProviderRuntimeCheckpointCapturedEvent = typeof ProviderRuntimeCheckpointCapturedEvent.Type;
166189
167export const ProviderRuntimeErrorEventSchema = Schema.Struct({190export const ProviderRuntimeErrorEvent = Schema.Struct({
168 type: Schema.Literal("runtime.error"),191 type: Schema.Literal("runtime.error"),
169 eventId: IdSchema,192 eventId: EventId,
170 provider: ProviderRuntimeProviderSchema,193 provider: ProviderKind,
171 sessionId: IdSchema,194 sessionId: ProviderSessionId,
172 createdAt: IsoDateTimeSchema,195 createdAt: IsoDateTime,
173 threadId: Schema.optional(Schema.String),196 threadId: Schema.optional(RuntimeThreadIdSchema),
174 turnId: Schema.optional(Schema.String),197 turnId: Schema.optional(RuntimeTurnIdSchema),
175 itemId: Schema.optional(Schema.String),198 itemId: Schema.optional(ProviderItemId),
176 message: Schema.String,199 message: Schema.String,
177});200});
178201export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
179export const ProviderRuntimeEventSchema = Schema.Union([202
180 ProviderRuntimeSessionStartedEventSchema,203export const ProviderRuntimeEvent = Schema.Union([
181 ProviderRuntimeSessionExitedEventSchema,204 ProviderRuntimeSessionStartedEvent,
182 ProviderRuntimeThreadStartedEventSchema,205 ProviderRuntimeSessionExitedEvent,
183 ProviderRuntimeTurnStartedEventSchema,206 ProviderRuntimeThreadStartedEvent,
184 ProviderRuntimeTurnCompletedEventSchema,207 ProviderRuntimeTurnStartedEvent,
185 ProviderRuntimeMessageDeltaEventSchema,208 ProviderRuntimeTurnCompletedEvent,
186 ProviderRuntimeMessageCompletedEventSchema,209 ProviderRuntimeMessageDeltaEvent,
187 ProviderRuntimeToolStartedEventSchema,210 ProviderRuntimeMessageCompletedEvent,
188 ProviderRuntimeToolCompletedEventSchema,211 ProviderRuntimeToolStartedEvent,
189 ProviderRuntimeApprovalRequestedEventSchema,212 ProviderRuntimeToolCompletedEvent,
190 ProviderRuntimeApprovalResolvedEventSchema,213 ProviderRuntimeApprovalRequestedEvent,
191 ProviderRuntimeCheckpointCapturedEventSchema,214 ProviderRuntimeApprovalResolvedEvent,
192 ProviderRuntimeErrorEventSchema,215 ProviderRuntimeCheckpointCapturedEvent,
216 ProviderRuntimeErrorEvent,
193]);217]);
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 @@...@@ -1,17 +1,21 @@
1import { z } from "zod";1import { Schema } from "effect";
2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";2import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
33
4export const serverConfigSchema = z.object({4export const ServerConfig = Schema.Struct({
5 cwd: z.string().min(1),5 cwd: Schema.NonEmptyString,
6 keybindings: resolvedKeybindingsConfigSchema.default([]),6 keybindings: ResolvedKeybindingsConfig,
7});7});
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({15export const ServerUpsertKeybindingResult = Schema.Struct({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),16 keybindings: ResolvedKeybindingsConfig,
13});17});
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 @@...@@ -1,40 +1,56 @@
1import { Schema } from "effect";
1import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
23
3import {4import {
4 DEFAULT_TERMINAL_ID,5 DEFAULT_TERMINAL_ID,
5 terminalClearInputSchema,6 TerminalClearInput,
6 terminalCloseInputSchema,7 TerminalCloseInput,
7 terminalEventSchema,8 TerminalEvent,
8 terminalOpenInputSchema,9 TerminalOpenInput,
9 terminalResizeInputSchema,10 TerminalResizeInput,
10 terminalSessionSnapshotSchema,11 TerminalSessionSnapshot,
11 terminalThreadInputSchema,12 TerminalThreadInput,
12 terminalWriteInputSchema,13 TerminalWriteInput,
13} from "./terminal";14} 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", () => {
16 it("accepts valid open input", () => {30 it("accepts valid open input", () => {
17 const result = terminalOpenInputSchema.safeParse({31 expect(
18 threadId: "thread-1",32 decodes(TerminalOpenInput, {
19 cwd: "/tmp/project",33 threadId: "thread-1",
20 cols: 120,34 cwd: "/tmp/project",
21 rows: 40,35 cols: 120,
22 });36 rows: 40,
23 expect(result.success).toBe(true);37 }),
38 ).toBe(true);
24 });39 });
2540
26 it("rejects invalid bounds", () => {41 it("rejects invalid bounds", () => {
27 const result = terminalOpenInputSchema.safeParse({42 expect(
28 threadId: "thread-1",43 decodes(TerminalOpenInput, {
29 cwd: "/tmp/project",44 threadId: "thread-1",
30 cols: 10,45 cwd: "/tmp/project",
31 rows: 2,46 cols: 10,
32 });47 rows: 2,
33 expect(result.success).toBe(false);48 }),
49 ).toBe(false);
34 });50 });
3551
36 it("defaults terminalId when missing", () => {52 it("defaults terminalId when missing", () => {
37 const parsed = terminalOpenInputSchema.parse({53 const parsed = decodeSync(TerminalOpenInput, {
38 threadId: "thread-1",54 threadId: "thread-1",
39 cwd: "/tmp/project",55 cwd: "/tmp/project",
40 cols: 100,56 cols: 100,
...@@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => {...@@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => {
44 });60 });
4561
46 it("accepts optional env overrides", () => {62 it("accepts optional env overrides", () => {
47 const parsed = terminalOpenInputSchema.parse({63 const parsed = decodeSync(TerminalOpenInput, {
48 threadId: "thread-1",64 threadId: "thread-1",
49 cwd: "/tmp/project",65 cwd: "/tmp/project",
50 cols: 100,66 cols: 100,
...@@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => {...@@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => {
61 });77 });
6278
63 it("rejects invalid env keys", () => {79 it("rejects invalid env keys", () => {
64 const result = terminalOpenInputSchema.safeParse({80 expect(
65 threadId: "thread-1",81 decodes(TerminalOpenInput, {
66 cwd: "/tmp/project",82 threadId: "thread-1",
67 cols: 100,83 cwd: "/tmp/project",
68 rows: 24,84 cols: 100,
69 env: {85 rows: 24,
70 "bad-key": "1",86 env: {
71 },87 "bad-key": "1",
72 });88 },
73 expect(result.success).toBe(false);89 }),
90 ).toBe(false);
74 });91 });
75});92});
7693
77describe("terminalWriteInputSchema", () => {94describe("TerminalWriteInput", () => {
78 it("accepts non-empty data", () => {95 it("accepts non-empty data", () => {
79 const result = terminalWriteInputSchema.safeParse({96 expect(
80 threadId: "thread-1",97 decodes(TerminalWriteInput, {
81 data: "echo hello\n",98 threadId: "thread-1",
82 });99 data: "echo hello\n",
83 expect(result.success).toBe(true);100 }),
101 ).toBe(true);
84 });102 });
85103
86 it("rejects empty data", () => {104 it("rejects empty data", () => {
87 const result = terminalWriteInputSchema.safeParse({105 expect(
88 threadId: "thread-1",106 decodes(TerminalWriteInput, {
89 data: "",107 threadId: "thread-1",
90 });108 data: "",
91 expect(result.success).toBe(false);109 }),
110 ).toBe(false);
92 });111 });
93});112});
94113
95describe("terminalThreadInputSchema", () => {114describe("TerminalThreadInput", () => {
96 it("trims thread ids", () => {115 it("trims thread ids", () => {
97 const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " });116 const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " });
98 expect(parsed.threadId).toBe("thread-1");117 expect(parsed.threadId).toBe("thread-1");
99 });118 });
100});119});
101120
102describe("terminalResizeInputSchema", () => {121describe("TerminalResizeInput", () => {
103 it("accepts valid size", () => {122 it("accepts valid size", () => {
104 const result = terminalResizeInputSchema.safeParse({123 expect(
105 threadId: "thread-1",124 decodes(TerminalResizeInput, {
106 cols: 80,125 threadId: "thread-1",
107 rows: 24,126 cols: 80,
108 });127 rows: 24,
109 expect(result.success).toBe(true);128 }),
129 ).toBe(true);
110 });130 });
111});131});
112132
113describe("terminalClearInputSchema", () => {133describe("TerminalClearInput", () => {
114 it("defaults terminal id", () => {134 it("defaults terminal id", () => {
115 const parsed = terminalClearInputSchema.parse({135 const parsed = decodeSync(TerminalClearInput, {
116 threadId: "thread-1",136 threadId: "thread-1",
117 });137 });
118 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);138 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
119 });139 });
120});140});
121141
122describe("terminalCloseInputSchema", () => {142describe("TerminalCloseInput", () => {
123 it("accepts optional deleteHistory", () => {143 it("accepts optional deleteHistory", () => {
124 const result = terminalCloseInputSchema.safeParse({144 expect(
125 threadId: "thread-1",145 decodes(TerminalCloseInput, {
126 deleteHistory: true,146 threadId: "thread-1",
127 });147 deleteHistory: true,
128 expect(result.success).toBe(true);148 }),
149 ).toBe(true);
129 });150 });
130});151});
131152
132describe("terminalSessionSnapshotSchema", () => {153describe("TerminalSessionSnapshot", () => {
133 it("accepts running snapshots", () => {154 it("accepts running snapshots", () => {
134 const result = terminalSessionSnapshotSchema.safeParse({155 expect(
135 threadId: "thread-1",156 decodes(TerminalSessionSnapshot, {
136 terminalId: DEFAULT_TERMINAL_ID,157 threadId: "thread-1",
137 cwd: "/tmp/project",158 terminalId: DEFAULT_TERMINAL_ID,
138 status: "running",159 cwd: "/tmp/project",
139 pid: 1234,160 status: "running",
140 history: "hello\n",161 pid: 1234,
141 exitCode: null,162 history: "hello\n",
142 exitSignal: null,163 exitCode: null,
143 updatedAt: new Date().toISOString(),164 exitSignal: null,
144 });165 updatedAt: new Date().toISOString(),
145 expect(result.success).toBe(true);166 }),
167 ).toBe(true);
146 });168 });
147});169});
148170
149describe("terminalEventSchema", () => {171describe("TerminalEvent", () => {
150 it("accepts output events", () => {172 it("accepts output events", () => {
151 const result = terminalEventSchema.safeParse({173 expect(
152 type: "output",174 decodes(TerminalEvent, {
153 threadId: "thread-1",175 type: "output",
154 terminalId: DEFAULT_TERMINAL_ID,176 threadId: "thread-1",
155 createdAt: new Date().toISOString(),177 terminalId: DEFAULT_TERMINAL_ID,
156 data: "line\n",178 createdAt: new Date().toISOString(),
157 });179 data: "line\n",
158 expect(result.success).toBe(true);180 }),
181 ).toBe(true);
159 });182 });
160183
161 it("accepts exited events", () => {184 it("accepts exited events", () => {
162 const result = terminalEventSchema.safeParse({185 expect(
163 type: "exited",186 decodes(TerminalEvent, {
164 threadId: "thread-1",187 type: "exited",
165 terminalId: DEFAULT_TERMINAL_ID,188 threadId: "thread-1",
166 createdAt: new Date().toISOString(),189 terminalId: DEFAULT_TERMINAL_ID,
167 exitCode: 0,190 createdAt: new Date().toISOString(),
168 exitSignal: null,191 exitCode: 0,
169 });192 exitSignal: null,
170 expect(result.success).toBe(true);193 }),
194 ).toBe(true);
171 });195 });
172196
173 it("accepts activity events", () => {197 it("accepts activity events", () => {
174 const result = terminalEventSchema.safeParse({198 expect(
175 type: "activity",199 decodes(TerminalEvent, {
176 threadId: "thread-1",200 type: "activity",
177 terminalId: DEFAULT_TERMINAL_ID,201 threadId: "thread-1",
178 createdAt: new Date().toISOString(),202 terminalId: DEFAULT_TERMINAL_ID,
179 hasRunningSubprocess: true,203 createdAt: new Date().toISOString(),
180 });204 hasRunningSubprocess: true,
181 expect(result.success).toBe(true);205 }),
206 ).toBe(true);
182 });207 });
183});208});
packages/contracts/src/terminal.ts+120-84
...@@ -1,119 +1,155 @@...@@ -1,119 +1,155 @@
1import { z } from "zod";1import { Schema } from "effect";
22
3export const DEFAULT_TERMINAL_ID = "default";3export const DEFAULT_TERMINAL_ID = "default";
44
5const terminalColsSchema = z.number().int().min(20).max(400);5const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty());
6const terminalRowsSchema = z.number().int().min(5).max(200);6const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
7const terminalIdSchema = z.string().trim().min(1).max(128);7 Schema.isLessThanOrEqualTo(400),
8const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128);8);
9const terminalEnvValueSchema = z.string().max(8_192);9const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
10const terminalEnvSchema = z10 Schema.isLessThanOrEqualTo(200),
11 .record(terminalEnvKeySchema, terminalEnvValueSchema)11);
12 .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars");12const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128));
1313const TerminalEnvKeySchema = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check(
14export const terminalThreadInputSchema = z.object({14 Schema.isMaxLength(128),
15 threadId: z.string().trim().min(1),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,
16});32});
33export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>;
1734
18export const terminalSessionInputSchema = terminalThreadInputSchema.extend({35export const TerminalSessionInput = Schema.Struct({
19 terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID),36 ...TerminalThreadInput.fields,
37 terminalId: TerminalIdWithDefaultSchema,
20});38});
2139export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>;
22export const terminalOpenInputSchema = terminalSessionInputSchema.extend({40
23 cwd: z.string().trim().min(1),41export const TerminalOpenInput = Schema.Struct({
24 cols: terminalColsSchema.optional(),42 ...TerminalSessionInput.fields,
25 rows: terminalRowsSchema.optional(),43 cwd: TrimmedNonEmptyStringSchema,
26 env: terminalEnvSchema.optional(),44 cols: Schema.optional(TerminalColsSchema),
45 rows: Schema.optional(TerminalRowsSchema),
46 env: Schema.optional(TerminalEnvSchema),
27});47});
48export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>;
2849
29export const terminalWriteInputSchema = terminalSessionInputSchema.extend({50export const TerminalWriteInput = Schema.Struct({
30 data: z.string().min(1).max(65_536),51 ...TerminalSessionInput.fields,
52 data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)),
31});53});
54export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>;
3255
33export const terminalResizeInputSchema = terminalSessionInputSchema.extend({56export const TerminalResizeInput = Schema.Struct({
34 cols: terminalColsSchema,57 ...TerminalSessionInput.fields,
35 rows: terminalRowsSchema,58 cols: TerminalColsSchema,
59 rows: TerminalRowsSchema,
36});60});
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({66export const TerminalRestartInput = Schema.Struct({
41 terminalId: terminalIdSchema.optional(),67 ...TerminalThreadInput.fields,
42 deleteHistory: z.boolean().optional(),68 cwd: TrimmedNonEmptyStringSchema,
69 cols: TerminalColsSchema,
70 rows: TerminalRowsSchema,
71 env: Schema.optional(TerminalEnvSchema),
43});72});
4473
45export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]);74export const TerminalCloseInput = Schema.Struct({
4675 ...TerminalThreadInput.fields,
47export const terminalSessionSnapshotSchema = z.object({76 terminalId: Schema.optional(TerminalIdSchema),
48 threadId: z.string().min(1),77 deleteHistory: Schema.optional(Schema.Boolean),
49 terminalId: z.string().min(1),78});
50 cwd: z.string().min(1),79export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>;
51 status: terminalSessionStatusSchema,80
52 pid: z.number().int().positive().nullable(),81export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]);
53 history: z.string(),82export type TerminalSessionStatus = typeof TerminalSessionStatus.Type;
54 exitCode: z.number().int().nullable(),83
55 exitSignal: z.number().int().nullable(),84export const TerminalSessionSnapshot = Schema.Struct({
56 updatedAt: z.string().datetime(),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,
57});94});
95export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
5896
59const terminalEventBaseSchema = z.object({97const TerminalEventBaseSchema = Schema.Struct({
60 threadId: z.string().min(1),98 threadId: Schema.String.check(Schema.isNonEmpty()),
61 terminalId: z.string().min(1),99 terminalId: Schema.String.check(Schema.isNonEmpty()),
62 createdAt: z.string().datetime(),100 createdAt: Schema.String,
63});101});
64102
65export const terminalStartedEventSchema = terminalEventBaseSchema.extend({103export const TerminalStartedEvent = Schema.Struct({
66 type: z.literal("started"),104 ...TerminalEventBaseSchema.fields,
67 snapshot: terminalSessionSnapshotSchema,105 type: Schema.Literal("started"),
106 snapshot: TerminalSessionSnapshot,
68});107});
69108
70export const terminalOutputEventSchema = terminalEventBaseSchema.extend({109export const TerminalOutputEvent = Schema.Struct({
71 type: z.literal("output"),110 ...TerminalEventBaseSchema.fields,
72 data: z.string(),111 type: Schema.Literal("output"),
112 data: Schema.String,
73});113});
74114
75export const terminalExitedEventSchema = terminalEventBaseSchema.extend({115export const TerminalExitedEvent = Schema.Struct({
76 type: z.literal("exited"),116 ...TerminalEventBaseSchema.fields,
77 exitCode: z.number().int().nullable(),117 type: Schema.Literal("exited"),
78 exitSignal: z.number().int().nullable(),118 exitCode: Schema.NullOr(Schema.Int),
119 exitSignal: Schema.NullOr(Schema.Int),
79});120});
80121
81export const terminalErrorEventSchema = terminalEventBaseSchema.extend({122export const TerminalErrorEvent = Schema.Struct({
82 type: z.literal("error"),123 ...TerminalEventBaseSchema.fields,
83 message: z.string().min(1),124 type: Schema.Literal("error"),
125 message: Schema.String.check(Schema.isNonEmpty()),
84});126});
85127
86export const terminalClearedEventSchema = terminalEventBaseSchema.extend({128export const TerminalClearedEvent = Schema.Struct({
87 type: z.literal("cleared"),129 ...TerminalEventBaseSchema.fields,
130 type: Schema.Literal("cleared"),
88});131});
89132
90export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({133export const TerminalRestartedEvent = Schema.Struct({
91 type: z.literal("restarted"),134 ...TerminalEventBaseSchema.fields,
92 snapshot: terminalSessionSnapshotSchema,135 type: Schema.Literal("restarted"),
136 snapshot: TerminalSessionSnapshot,
93});137});
94138
95export const terminalActivityEventSchema = terminalEventBaseSchema.extend({139export const TerminalActivityEvent = Schema.Struct({
96 type: z.literal("activity"),140 ...TerminalEventBaseSchema.fields,
97 hasRunningSubprocess: z.boolean(),141 type: Schema.Literal("activity"),
142 hasRunningSubprocess: Schema.Boolean,
98});143});
99144
100export const terminalEventSchema = z.discriminatedUnion("type", [145export const TerminalEvent = Schema.Union([
101 terminalStartedEventSchema,146 TerminalStartedEvent,
102 terminalOutputEventSchema,147 TerminalOutputEvent,
103 terminalExitedEventSchema,148 TerminalExitedEvent,
104 terminalErrorEventSchema,149 TerminalErrorEvent,
105 terminalClearedEventSchema,150 TerminalClearedEvent,
106 terminalRestartedEventSchema,151 TerminalRestartedEvent,
107 terminalActivityEventSchema,152 TerminalActivityEvent,
108]);153]);
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 @@...@@ -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
3// ── WebSocket RPC Method Names ───────────────────────────────────────33// ── WebSocket RPC Method Names ───────────────────────────────────────
434
5export const WS_METHODS = {35export 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
16 // Project registry methods36 // Project registry methods
17 projectsList: "projects.list",37 projectsList: "projects.list",
18 projectsAdd: "projects.add",38 projectsAdd: "projects.add",
19 projectsRemove: "projects.remove",39 projectsRemove: "projects.remove",
20 projectsSearchEntries: "projects.searchEntries",40 projectsSearchEntries: "projects.searchEntries",
21 projectsUpdateScripts: "projects.updateScripts",
2241
23 // Shell methods42 // Shell methods
24 shellOpenInEditor: "shell.openInEditor",43 shellOpenInEditor: "shell.openInEditor",
...@@ -54,49 +73,76 @@ export const WS_CHANNELS = {...@@ -54,49 +73,76 @@ export const WS_CHANNELS = {
54 serverWelcome: "server.welcome",73 serverWelcome: "server.welcome",
55} as const;74} as const;
5675
57// ── Client → Server (request) ────────────────────────────────────────76// -- Tagged Union of all request body schemas ─────────────────────────
5877
59export const wsRequestSchema = z.object({78export const WebSocketRequestBody = Schema.TaggedUnion({
60 id: z.string().min(1),79 // Orchestration methods
61 method: z.string().min(1),80 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
62 params: z.unknown().optional(),81 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
63});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({91 // Git methods
70 id: z.string().min(1),92 [WS_METHODS.gitPull]: GitPullInput.fields,
71 result: z.unknown().optional(),93 [WS_METHODS.gitStatus]: GitStatusInput.fields,
72 error: z94 [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields,
73 .object({95 [WS_METHODS.gitListBranches]: GitListBranchesInput.fields,
74 message: z.string(),96 [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields,
75 })97 [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields,
76 .optional(),98 [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields,
77});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({115export const WebSocketRequest = Schema.Struct({
84 type: z.literal("push"),116 id: Schema.String,
85 channel: z.string().min(1),117 body: WebSocketRequestBody,
86 data: z.unknown(),
87});118});
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
91// ── Union of all server → client messages ─────────────────────────────138// ── 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
95// ── Server welcome payload ───────────────────────────────────────────143// ── Server welcome payload ───────────────────────────────────────────
96144
97export const wsWelcomePayloadSchema = z.object({145export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({
98 cwd: z.string().min(1),146 cwd: Schema.String,
99 projectName: z.string().min(1),147 projectName: Schema.String,
100});148}) {}
101
102export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>;