| author | |
| committer | |
| log | 25d20ab7928c6aed5f613f4c3319394635869250 |
| tree | b0054e64c06aae2f971c8c88b975febdb24b81d7 |
| parent | 7307079d4063a5c546a4c277dcc3b6594b41a846 |
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 | ||
| 3 | Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. | |
| 4 | ||
| 5 | Execution 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 | |
| 12 | Work 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 | ||
| 19 | Deliverables: | |
| 20 | - Complete matrix file with no unclassified rows. | |
| 21 | - Single source checklist used by all later steps. | |
| 22 | ||
| 23 | Breakage allowed: | |
| 24 | - No code changes required yet. | |
| 25 | ||
| 26 | Exit criteria: | |
| 27 | - Every requirement in `7.1`-`7.4` has exactly one matrix row. | |
| 28 | ||
| 29 | ## 2. Hard cutover migrations (replace current migration set) | |
| 30 | Work 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 | ||
| 49 | Deliverables: | |
| 50 | - New 5-file migration chain. | |
| 51 | - Updated migration loader references only new migrations. | |
| 52 | ||
| 53 | Breakage allowed: | |
| 54 | - Repositories/services can be temporarily broken due to removed old tables. | |
| 55 | ||
| 56 | Exit criteria: | |
| 57 | - Fresh DB initializes with only canonical tables plus migration bookkeeping. | |
| 58 | ||
| 59 | ## 3. Align persistence row/request schemas to DB 1:1 | |
| 60 | Work 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 | ||
| 67 | Deliverables: | |
| 68 | - Canonical row/request schemas committed. | |
| 69 | - Zero references to legacy row schemas in active code paths. | |
| 70 | ||
| 71 | Breakage allowed: | |
| 72 | - Runtime can still fail while query layers are being rewired. | |
| 73 | ||
| 74 | Exit 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 | |
| 78 | Work 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 | ||
| 86 | Deliverables: | |
| 87 | - Event store append/replay fully aligned with canonical envelope. | |
| 88 | ||
| 89 | Breakage allowed: | |
| 90 | - Command dispatch flow can be partially broken until receipts/projectors are updated. | |
| 91 | ||
| 92 | Exit criteria: | |
| 93 | - Event store no longer depends on legacy event table shape. | |
| 94 | ||
| 95 | ## 5. Add command receipt idempotency | |
| 96 | Work 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 | ||
| 104 | Deliverables: | |
| 105 | - Dispatch path with idempotency behavior wired through receipts. | |
| 106 | ||
| 107 | Breakage allowed: | |
| 108 | - Snapshot/read model may still be inconsistent until projectors are fully wired. | |
| 109 | ||
| 110 | Exit criteria: | |
| 111 | - Duplicate command IDs no longer create duplicate events. | |
| 112 | ||
| 113 | ## 6. Build DB-backed projection pipeline | |
| 114 | Work 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 | ||
| 122 | Deliverables: | |
| 123 | - Persistent projector pipeline writing all `projection_*` tables. | |
| 124 | ||
| 125 | Breakage allowed: | |
| 126 | - Web/API layer may still read old in-memory model until step 7. | |
| 127 | ||
| 128 | Exit criteria: | |
| 129 | - Events drive projection rows in DB; projection state advances transactionally. | |
| 130 | ||
| 131 | ## 7. Move RPC reads to projections and diff blobs | |
| 132 | Work 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 | ||
| 140 | Deliverables: | |
| 141 | - `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. | |
| 142 | ||
| 143 | Breakage allowed: | |
| 144 | - Provider runtime persistence may still be partially legacy until step 8. | |
| 145 | ||
| 146 | Exit 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 | |
| 150 | Work 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 | ||
| 157 | Deliverables: | |
| 158 | - Provider runtime state entirely backed by `provider_session_runtime`. | |
| 159 | ||
| 160 | Breakage allowed: | |
| 161 | - Some legacy interfaces may still exist but should be disconnected. | |
| 162 | ||
| 163 | Exit criteria: | |
| 164 | - Runtime restore no longer reads/writes legacy provider session persistence. | |
| 165 | ||
| 166 | ## 9. Remove old cruft aggressively | |
| 167 | Work 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 | ||
| 174 | Deliverables: | |
| 175 | - Legacy persistence and translation layers removed from active codebase. | |
| 176 | ||
| 177 | Breakage allowed: | |
| 178 | - Temporary compile failures acceptable while deletion/refactor is in progress. | |
| 179 | ||
| 180 | Exit criteria: | |
| 181 | - No production code path references deleted legacy tables/services. | |
| 182 | ||
| 183 | ## 10. Final verification gate (first point where green is required) | |
| 184 | Work 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 | ||
| 194 | Deliverables: | |
| 195 | - Green checks with canonical schema + persistence model in place. | |
| 196 | ||
| 197 | Breakage allowed: | |
| 198 | - None at end of step. | |
| 199 | ||
| 200 | Exit 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 | ||
| 3 | Status 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 | 55 | - Input: |
| 56 | 56 | - `commandId: CommandId` |
| 57 | 57 | - `projectId: ProjectId` |
| 58 | - optional `{ title, workspaceRoot, defaultModel }` | |
| 58 | - optional `{ title, workspaceRoot, defaultModel, scripts }` | |
| 59 | 59 | - Output: `DispatchResult` |
| 60 | 60 | |
| 61 | 61 | 3. `project.delete` |
| ... | ... | @@ -427,6 +427,7 @@ Rules: |
| 427 | 427 | - `title: string` |
| 428 | 428 | - `workspaceRoot: string` |
| 429 | 429 | - `defaultModel: string | null` |
| 430 | - `scripts: ProjectScript[]` | |
| 430 | 431 | - `createdAt: IsoDateTime` |
| 431 | 432 | - `updatedAt: IsoDateTime` |
| 432 | 433 | - `deletedAt: IsoDateTime | null` |
apps/server/integration/providerService.integration.test.ts+24-116| ... | ... | @@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node"; |
| 3 | 3 | import { it, assert } from "@effect/vitest"; |
| 4 | 4 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| 5 | 5 | |
| 6 | import { runGit as runGitProcess } from "../src/git/Process.ts"; | |
| 7 | import { CheckpointServiceLive } from "../src/checkpointing/Layers/CheckpointService.ts"; | |
| 8 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; | |
| 9 | 6 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; |
| 10 | 7 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 11 | 8 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 12 | import { ProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | |
| 9 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | |
| 13 | 10 | import { |
| 14 | 11 | ProviderService, |
| 15 | 12 | type ProviderServiceShape, |
| 16 | 13 | } from "../src/provider/Services/ProviderService.ts"; |
| 17 | 14 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; |
| 18 | import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts"; | |
| 19 | import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts"; | |
| 15 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; | |
| 20 | 16 | |
| 21 | 17 | import { |
| 22 | 18 | makeTestProviderAdapterHarness, |
| ... | ... | @@ -29,34 +25,11 @@ import { |
| 29 | 25 | codexTurnTextFixture, |
| 30 | 26 | } from "./fixtures/providerRuntime.ts"; |
| 31 | 27 | |
| 32 | const runGit = (cwd: string, args: ReadonlyArray<string>, allowNonZeroExit = false) => | |
| 33 | runGitProcess({ | |
| 34 | operation: "providerService.integration.git", | |
| 35 | cwd, | |
| 36 | args, | |
| 37 | allowNonZeroExit, | |
| 38 | }).pipe(Effect.provide(NodeServices.layer)); | |
| 39 | ||
| 40 | const gitRefExists = (cwd: string, ref: string) => | |
| 41 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref], true).pipe( | |
| 42 | Effect.map((result) => result.code === 0), | |
| 43 | ); | |
| 44 | ||
| 45 | const checkpointRefForTurn = (threadId: string, turnCount: number) => | |
| 46 | `refs/t3/checkpoints/${Buffer.from(threadId, "utf8").toString("base64url")}/turn/${turnCount}`; | |
| 47 | ||
| 48 | const makeGitRepository = Effect.gen(function* () { | |
| 28 | const makeWorkspaceDirectory = Effect.gen(function* () { | |
| 49 | 29 | const fs = yield* FileSystem.FileSystem; |
| 50 | 30 | const pathService = yield* Path.Path; |
| 51 | 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 | 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 | 33 | return cwd; |
| 61 | 34 | }).pipe(Effect.provide(NodeServices.layer)); |
| 62 | 35 | |
| ... | ... | @@ -67,7 +40,7 @@ interface IntegrationFixture { |
| 67 | 40 | } |
| 68 | 41 | |
| 69 | 42 | const makeIntegrationFixture = Effect.gen(function* () { |
| 70 | const cwd = yield* makeGitRepository; | |
| 43 | const cwd = yield* makeWorkspaceDirectory; | |
| 71 | 44 | const harness = yield* makeTestProviderAdapterHarness; |
| 72 | 45 | |
| 73 | 46 | const registry: typeof ProviderAdapterRegistry.Service = { |
| ... | ... | @@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 79 | 52 | }; |
| 80 | 53 | |
| 81 | 54 | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 82 | Layer.provide(ProviderSessionRepositoryLive), | |
| 55 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 83 | 56 | ); |
| 84 | 57 | |
| 85 | 58 | const shared = Layer.mergeAll( |
| 86 | 59 | directoryLayer, |
| 87 | 60 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 88 | Layer.provide(CheckpointStoreLive, NodeServices.layer), | |
| 89 | CheckpointRepositoryLive, | |
| 90 | 61 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 91 | 62 | |
| 92 | const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared)); | |
| 93 | ||
| 94 | const layer = ProviderServiceLive.pipe( | |
| 95 | Layer.provide(Layer.mergeAll(shared, checkpointLayer)), | |
| 96 | Layer.merge(NodeServices.layer), | |
| 97 | ); | |
| 63 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); | |
| 98 | 64 | |
| 99 | 65 | return { |
| 100 | 66 | cwd, |
| ... | ... | @@ -135,7 +101,7 @@ const runTurn = (input: { |
| 135 | 101 | |
| 136 | 102 | return yield* collectEventsDuring( |
| 137 | 103 | input.provider.streamEvents, |
| 138 | input.response.events.length + 1, | |
| 104 | input.response.events.length, | |
| 139 | 105 | input.provider.sendTurn({ |
| 140 | 106 | sessionId: input.sessionId, |
| 141 | 107 | input: input.userText, |
| ... | ... | @@ -144,7 +110,7 @@ const runTurn = (input: { |
| 144 | 110 | ); |
| 145 | 111 | }); |
| 146 | 112 | |
| 147 | it.effect("replays typed runtime fixture and emits checkpoint.captured", () => | |
| 113 | it.effect("replays typed runtime fixture events", () => | |
| 148 | 114 | Effect.gen(function* () { |
| 149 | 115 | const fixture = yield* makeIntegrationFixture; |
| 150 | 116 | |
| ... | ... | @@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () => |
| 154 | 120 | provider: "codex", |
| 155 | 121 | cwd: fixture.cwd, |
| 156 | 122 | }); |
| 157 | const threadId = session.threadId ?? ""; | |
| 158 | assert.equal(threadId.length > 0, true); | |
| 123 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 159 | 124 | |
| 160 | 125 | const observedEvents = yield* runTurn({ |
| 161 | 126 | provider, |
| 162 | 127 | harness: fixture.harness, |
| 163 | 128 | sessionId: session.sessionId, |
| 164 | 129 | userText: "hello", |
| 165 | response: { events: codexTurnTextFixture }, | |
| 130 | response: { events: codexTurnTextFixture }, | |
| 166 | 131 | }); |
| 167 | 132 | |
| 168 | 133 | assert.deepEqual( |
| 169 | 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 | 137 | }).pipe(Effect.provide(fixture.layer)); |
| 189 | }), | |
| 138 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 190 | 139 | ); |
| 191 | 140 | |
| 192 | it.effect("captures file-changing fixture turn and produces a non-empty diff", () => | |
| 141 | it.effect("replays file-changing fixture turn events", () => | |
| 193 | 142 | Effect.gen(function* () { |
| 194 | 143 | const fixture = yield* makeIntegrationFixture; |
| 195 | 144 | const { join } = yield* Path.Path; |
| ... | ... | @@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( |
| 201 | 150 | provider: "codex", |
| 202 | 151 | cwd: fixture.cwd, |
| 203 | 152 | }); |
| 204 | const threadId = session.threadId ?? ""; | |
| 205 | assert.equal(threadId.length > 0, true); | |
| 153 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 206 | 154 | |
| 207 | 155 | const observedEvents = yield* runTurn({ |
| 208 | 156 | provider, |
| ... | ... | @@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( |
| 218 | 166 | |
| 219 | 167 | assert.deepEqual( |
| 220 | 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 | 171 | }).pipe(Effect.provide(fixture.layer)); |
| 234 | 172 | }).pipe(Effect.provide(NodeServices.layer)), |
| 235 | 173 | ); |
| 236 | 174 | |
| 237 | it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () => | |
| 175 | it.effect("runs multi-turn tool/approval flow", () => | |
| 238 | 176 | Effect.gen(function* () { |
| 239 | 177 | const fixture = yield* makeIntegrationFixture; |
| 240 | 178 | const { join } = yield* Path.Path; |
| ... | ... | @@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 246 | 184 | provider: "codex", |
| 247 | 185 | cwd: fixture.cwd, |
| 248 | 186 | }); |
| 249 | const threadId = session.threadId ?? ""; | |
| 250 | assert.equal(threadId.length > 0, true); | |
| 187 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 251 | 188 | |
| 252 | 189 | const firstTurnEvents = yield* runTurn({ |
| 253 | 190 | provider, |
| ... | ... | @@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 262 | 199 | }); |
| 263 | 200 | assert.deepEqual( |
| 264 | 201 | firstTurnEvents.map((event) => event.type), |
| 265 | [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], | |
| 202 | codexTurnToolFixture.map((event) => event.type), | |
| 266 | 203 | ); |
| 267 | 204 | |
| 268 | 205 | const secondTurnEvents = yield* runTurn({ |
| ... | ... | @@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 278 | 215 | }); |
| 279 | 216 | assert.deepEqual( |
| 280 | 217 | secondTurnEvents.map((event) => event.type), |
| 281 | [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"], | |
| 282 | ); | |
| 283 | ||
| 284 | const turnStateBeforeRevert = yield* provider.listCheckpoints({ | |
| 285 | sessionId: session.sessionId, | |
| 286 | }); | |
| 287 | assert.deepEqual( | |
| 288 | turnStateBeforeRevert.checkpoints.map((checkpoint) => checkpoint.turnCount), | |
| 289 | [0, 1, 2], | |
| 218 | codexTurnApprovalFixture.map((event) => event.type), | |
| 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 | 220 | }).pipe(Effect.provide(fixture.layer)); |
| 296 | 221 | }).pipe(Effect.provide(NodeServices.layer)), |
| 297 | 222 | ); |
| 298 | 223 | |
| 299 | it.effect("reverts to turn 1 and prunes later checkpoint refs", () => | |
| 224 | it.effect("rolls back provider conversation state only", () => | |
| 300 | 225 | Effect.gen(function* () { |
| 301 | 226 | const fixture = yield* makeIntegrationFixture; |
| 302 | 227 | const { join } = yield* Path.Path; |
| ... | ... | @@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => |
| 308 | 233 | provider: "codex", |
| 309 | 234 | cwd: fixture.cwd, |
| 310 | 235 | }); |
| 311 | const threadId = session.threadId ?? ""; | |
| 312 | assert.equal(threadId.length > 0, true); | |
| 236 | assert.equal((session.threadId ?? "").length > 0, true); | |
| 313 | 237 | |
| 314 | 238 | yield* runTurn({ |
| 315 | 239 | provider, |
| ... | ... | @@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => |
| 335 | 259 | }, |
| 336 | 260 | }); |
| 337 | 261 | |
| 338 | const turnTwoRef = checkpointRefForTurn(threadId, 2); | |
| 339 | const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef); | |
| 340 | assert.equal(turnTwoRefBefore, true); | |
| 341 | ||
| 342 | const revert = yield* provider.revertToCheckpoint({ | |
| 262 | yield* provider.rollbackConversation({ | |
| 343 | 263 | sessionId: session.sessionId, |
| 344 | turnCount: 1, | |
| 264 | numTurns: 1, | |
| 345 | 265 | }); |
| 346 | assert.equal(revert.rolledBackTurns, 1); | |
| 347 | 266 | |
| 348 | 267 | const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); |
| 349 | 268 | assert.deepEqual(rollbackCalls, [1]); |
| 350 | 269 | |
| 351 | 270 | const readme = yield* readFileString(join(fixture.cwd, "README.md")); |
| 352 | assert.equal(readme, "v2\n"); | |
| 353 | ||
| 354 | const afterRevertCheckpoints = yield* provider.listCheckpoints({ | |
| 355 | sessionId: session.sessionId, | |
| 356 | }); | |
| 357 | assert.deepEqual( | |
| 358 | afterRevertCheckpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), | |
| 359 | [0, 1], | |
| 360 | ); | |
| 361 | ||
| 362 | const turnTwoRefAfter = yield* gitRefExists(fixture.cwd, turnTwoRef); | |
| 363 | assert.equal(turnTwoRefAfter, false); | |
| 271 | assert.equal(readme, "v3\n"); | |
| 364 | 272 | }).pipe(Effect.provide(fixture.layer)); |
| 365 | 273 | }).pipe(Effect.provide(NodeServices.layer)), |
| 366 | 274 | ); |
apps/server/src/checkpointing/Errors.ts+5-54| ... | ... | @@ -1,39 +1,6 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts"; | |
| 3 | ||
| 4 | /** | |
| 5 | * CheckpointValidationError - Invalid checkpoint API input. | |
| 6 | */ | |
| 7 | export 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 | */ | |
| 23 | export 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 | } | |
| 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; | |
| 3 | import { GitCommandError } from "../git/Errors.ts"; | |
| 37 | 4 | |
| 38 | 5 | /** |
| 39 | 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. |
| ... | ... | @@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin |
| 68 | 35 | } |
| 69 | 36 | } |
| 70 | 37 | |
| 71 | /** | |
| 72 | * CheckpointServiceValidationError - Invalid application-level checkpoint request. | |
| 73 | */ | |
| 74 | export 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 | 39 | * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. |
| 89 | 40 | */ |
| ... | ... | @@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint |
| 116 | 67 | } |
| 117 | 68 | |
| 118 | 69 | export type CheckpointStoreError = |
| 119 | | CheckpointGitCommandError | |
| 70 | | GitCommandError | |
| 71 | | CheckpointInvariantError | |
| 120 | 72 | | CheckpointUnavailableError |
| 121 | 73 | | CheckpointRepositoryError; |
| 122 | 74 | |
| 123 | 75 | export type CheckpointServiceError = |
| 124 | 76 | | CheckpointStoreError |
| 125 | | CheckpointMetadataRepositoryError | |
| 126 | | CheckpointServiceValidationError | |
| 77 | | ProjectionRepositoryError | |
| 127 | 78 | | CheckpointSessionNotFoundError |
| 128 | 79 | | CheckpointInvariantError; |
apps/server/src/checkpointing/Layers/CheckpointService.test.ts deleted-549| ... | ... | @@ -1,549 +0,0 @@ |
| 1 | import type { | |
| 2 | ProviderApprovalDecision, | |
| 3 | ProviderKind, | |
| 4 | ProviderSendTurnInput, | |
| 5 | ProviderSession, | |
| 6 | ProviderSessionStartInput, | |
| 7 | ProviderTurnStartResult, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { NodeServices } from "@effect/platform-node"; | |
| 10 | import { it, assert } from "@effect/vitest"; | |
| 11 | import { Effect, FileSystem, Layer, Path, Stream } from "effect"; | |
| 12 | ||
| 13 | import { runGit as runGitProcess } from "../../git/Process.ts"; | |
| 14 | import { CheckpointServiceLive } from "./CheckpointService.ts"; | |
| 15 | import { CheckpointStoreLive } from "./CheckpointStore.ts"; | |
| 16 | import { CheckpointService } from "../Services/CheckpointService.ts"; | |
| 17 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 18 | import { | |
| 19 | ProviderAdapterSessionNotFoundError, | |
| 20 | ProviderAdapterValidationError, | |
| 21 | ProviderUnsupportedError, | |
| 22 | type ProviderAdapterError, | |
| 23 | } from "../../provider/Errors.ts"; | |
| 24 | import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; | |
| 25 | import type { | |
| 26 | ProviderAdapterShape, | |
| 27 | ProviderThreadSnapshot, | |
| 28 | ProviderThreadTurnSnapshot, | |
| 29 | } from "../../provider/Services/ProviderAdapter.ts"; | |
| 30 | import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; | |
| 31 | import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; | |
| 32 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 33 | import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; | |
| 34 | import { CheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; | |
| 35 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 36 | ||
| 37 | const 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 | ||
| 47 | const 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 | ||
| 60 | class 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 | ||
| 226 | function 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 | ||
| 264 | it.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 | ||
| 344 | it.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 | ||
| 426 | it.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 | ||
| 448 | it.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 | ||
| 512 | it.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 | */ | |
| 14 | import type { | |
| 15 | ProviderCheckpoint, | |
| 16 | ProviderGetCheckpointDiffInput, | |
| 17 | ProviderListCheckpointsInput, | |
| 18 | ProviderRevertToCheckpointInput, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { | |
| 21 | providerGetCheckpointDiffInputSchema, | |
| 22 | providerListCheckpointsInputSchema, | |
| 23 | providerRevertToCheckpointInputSchema, | |
| 24 | } from "@t3tools/contracts"; | |
| 25 | import { Effect, Layer, Option, Ref, Semaphore } from "effect"; | |
| 26 | ||
| 27 | import { | |
| 28 | CheckpointInvariantError, | |
| 29 | CheckpointRepositoryError, | |
| 30 | CheckpointServiceValidationError, | |
| 31 | CheckpointSessionNotFoundError, | |
| 32 | CheckpointUnavailableError, | |
| 33 | type CheckpointServiceError, | |
| 34 | } from "../Errors.ts"; | |
| 35 | import { | |
| 36 | CheckpointService, | |
| 37 | type CaptureCurrentTurnInput, | |
| 38 | type CheckpointServiceShape, | |
| 39 | type InitializeCheckpointSessionInput, | |
| 40 | } from "../Services/CheckpointService.ts"; | |
| 41 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | |
| 42 | import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; | |
| 43 | import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; | |
| 44 | import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; | |
| 45 | import type { | |
| 46 | ProviderAdapterShape, | |
| 47 | ProviderThreadSnapshot, | |
| 48 | ProviderThreadTurnSnapshot, | |
| 49 | } from "../../provider/Services/ProviderAdapter.ts"; | |
| 50 | import type { ProviderAdapterError } from "../../provider/Errors.ts"; | |
| 51 | ||
| 52 | interface CheckpointCwdResolution { | |
| 53 | readonly candidateCwd: string; | |
| 54 | readonly checkpointCwd: Option.Option<string>; | |
| 55 | } | |
| 56 | ||
| 57 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 58 | ||
| 59 | function checkpointRefThreadSegment(threadId: string): string { | |
| 60 | return Buffer.from(threadId, "utf8").toString("base64url"); | |
| 61 | } | |
| 62 | ||
| 63 | function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { | |
| 64 | return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; | |
| 65 | } | |
| 66 | ||
| 67 | function 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 | ||
| 74 | function asString(value: unknown): string | undefined { | |
| 75 | return typeof value === "string" ? value : undefined; | |
| 76 | } | |
| 77 | ||
| 78 | function asArray(value: unknown): unknown[] { | |
| 79 | return Array.isArray(value) ? value : []; | |
| 80 | } | |
| 81 | ||
| 82 | function 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 | ||
| 90 | function 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 | ||
| 124 | function 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 | ||
| 164 | function 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 | ||
| 198 | function rootCheckpoint(): ProviderCheckpoint { | |
| 199 | return { | |
| 200 | id: "root", | |
| 201 | turnCount: 0, | |
| 202 | messageCount: 0, | |
| 203 | label: "Start of conversation", | |
| 204 | isCurrent: true, | |
| 205 | }; | |
| 206 | } | |
| 207 | ||
| 208 | function 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 | ||
| 220 | function 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 | ||
| 232 | function 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 | ||
| 244 | function 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 | ||
| 262 | function 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 | ||
| 283 | function 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 | ||
| 299 | function 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 | ||
| 315 | function 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 | ||
| 331 | const 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 | ||
| 1108 | export const CheckpointServiceLive = Layer.effect(CheckpointService, makeCheckpointService); |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+117-243| ... | ... | @@ -9,162 +9,83 @@ |
| 9 | 9 | * |
| 10 | 10 | * @module CheckpointStoreLive |
| 11 | 11 | */ |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | 12 | import { randomUUID } from "node:crypto"; |
| 14 | import os from "node:os"; | |
| 15 | import path from "node:path"; | |
| 16 | ||
| 17 | import { Effect, Layer } from "effect"; | |
| 18 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 19 | ||
| 20 | import { | |
| 21 | CheckpointGitCommandError, | |
| 22 | CheckpointRepositoryError, | |
| 23 | CheckpointUnavailableError, | |
| 24 | type CheckpointStoreError, | |
| 25 | } from "../Errors.ts"; | |
| 13 | ||
| 14 | import { Effect, Layer, FileSystem, Path } from "effect"; | |
| 15 | ||
| 16 | import { CheckpointInvariantError } from "../Errors.ts"; | |
| 26 | 17 | import { GitCommandError } from "../../git/Errors.ts"; |
| 27 | import { runGit, RunGitInput } from "../../git/Process.ts"; | |
| 18 | import { GitServiceLive } from "../../git/Layer.ts"; | |
| 19 | import { GitService } from "../../git/Service.ts"; | |
| 28 | 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 29 | ||
| 30 | function 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 | ||
| 40 | function 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 | ||
| 80 | function 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 | ||
| 99 | function 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 | ||
| 110 | function 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 | ||
| 130 | function 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 | } | |
| 21 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 136 | 22 | |
| 137 | 23 | const makeCheckpointStore = Effect.gen(function* () { |
| 138 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 139 | const runGitWithSpawner = (input: RunGitInput) => | |
| 140 | runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | |
| 141 | const resolveHeadCommitWithSpawner = (cwd: string) => | |
| 142 | resolveHeadCommit(cwd).pipe( | |
| 143 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 144 | ); | |
| 145 | const hasHeadCommitWithSpawner = (cwd: string) => | |
| 146 | hasHeadCommit(cwd).pipe( | |
| 147 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 148 | ); | |
| 149 | const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) => | |
| 150 | resolveCheckpointCommit(cwd, checkpointRef).pipe( | |
| 151 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 152 | ); | |
| 24 | const fs = yield* FileSystem.FileSystem; | |
| 25 | const path = yield* Path.Path; | |
| 26 | const git = yield* GitService; | |
| 27 | ||
| 28 | const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> => | |
| 29 | git | |
| 30 | .execute({ | |
| 31 | operation: "CheckpointStore.resolveHeadCommit", | |
| 32 | cwd, | |
| 33 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], | |
| 34 | allowNonZeroExit: true, | |
| 35 | }) | |
| 36 | .pipe( | |
| 37 | Effect.map((result) => { | |
| 38 | if (result.code !== 0) { | |
| 39 | return null; | |
| 40 | } | |
| 41 | const commit = result.stdout.trim(); | |
| 42 | return commit.length > 0 ? commit : null; | |
| 43 | }), | |
| 44 | ); | |
| 153 | 45 | |
| 154 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | |
| 155 | Effect.gen(function* () { | |
| 156 | const normalizedCwd = cwd.trim(); | |
| 46 | const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> => | |
| 47 | git | |
| 48 | .execute({ | |
| 49 | operation: "CheckpointStore.hasHeadCommit", | |
| 50 | cwd, | |
| 51 | args: ["rev-parse", "--verify", "HEAD"], | |
| 52 | allowNonZeroExit: true, | |
| 53 | }) | |
| 54 | .pipe(Effect.map((result) => result.code === 0)); | |
| 55 | ||
| 56 | const resolveCheckpointCommit = ( | |
| 57 | cwd: string, | |
| 58 | checkpointRef: CheckpointRef, | |
| 59 | ): Effect.Effect<string | null, GitCommandError> => | |
| 60 | git | |
| 61 | .execute({ | |
| 62 | operation: "CheckpointStore.resolveCheckpointCommit", | |
| 63 | cwd, | |
| 64 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | |
| 65 | allowNonZeroExit: true, | |
| 66 | }) | |
| 67 | .pipe( | |
| 68 | Effect.map((result) => { | |
| 69 | if (result.code !== 0) { | |
| 70 | return null; | |
| 71 | } | |
| 72 | const commit = result.stdout.trim(); | |
| 73 | return commit.length > 0 ? commit : null; | |
| 74 | }), | |
| 75 | ); | |
| 157 | 76 | |
| 158 | return yield* runGitWithSpawner({ | |
| 77 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | |
| 78 | git | |
| 79 | .execute({ | |
| 159 | 80 | operation: "CheckpointStore.isGitRepository", |
| 160 | cwd: normalizedCwd, | |
| 81 | cwd: cwd.trim(), | |
| 161 | 82 | args: ["rev-parse", "--is-inside-work-tree"], |
| 162 | 83 | allowNonZeroExit: true, |
| 163 | }).pipe( | |
| 84 | }) | |
| 85 | .pipe( | |
| 164 | 86 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), |
| 165 | 87 | Effect.catch(() => Effect.succeed(false)), |
| 166 | 88 | ); |
| 167 | }); | |
| 168 | 89 | |
| 169 | 90 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => |
| 170 | 91 | Effect.gen(function* () { |
| ... | ... | @@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 173 | 94 | const checkpointRef = input.checkpointRef.trim(); |
| 174 | 95 | |
| 175 | 96 | yield* Effect.acquireUseRelease( |
| 176 | Effect.tryPromise({ | |
| 177 | try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")), | |
| 178 | catch: (cause) => cause, | |
| 179 | }), | |
| 97 | fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), | |
| 180 | 98 | (tempDir) => |
| 181 | 99 | Effect.gen(function* () { |
| 182 | 100 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); |
| ... | ... | @@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 184 | 102 | ...process.env, |
| 185 | 103 | GIT_INDEX_FILE: tempIndexPath, |
| 186 | 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 | 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 | }; |
| 191 | 109 | |
| 192 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd); | |
| 110 | const headExists = yield* hasHeadCommit(normalizedCwd); | |
| 193 | 111 | if (headExists) { |
| 194 | yield* runGitWithSpawner({ | |
| 112 | yield* git.execute({ | |
| 195 | 113 | operation, |
| 196 | 114 | cwd: normalizedCwd, |
| 197 | 115 | args: ["read-tree", "HEAD"], |
| ... | ... | @@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 199 | 117 | }); |
| 200 | 118 | } |
| 201 | 119 | |
| 202 | yield* runGitWithSpawner({ | |
| 120 | yield* git.execute({ | |
| 203 | 121 | operation, |
| 204 | 122 | cwd: normalizedCwd, |
| 205 | 123 | args: ["add", "-A", "--", "."], |
| 206 | 124 | env: commitEnv, |
| 207 | 125 | }); |
| 208 | 126 | |
| 209 | const writeTreeResult = yield* runGitWithSpawner({ | |
| 127 | const writeTreeResult = yield* git.execute({ | |
| 210 | 128 | operation, |
| 211 | 129 | cwd: normalizedCwd, |
| 212 | 130 | args: ["write-tree"], |
| ... | ... | @@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 215 | 133 | const treeOid = writeTreeResult.stdout.trim(); |
| 216 | 134 | if (treeOid.length === 0) { |
| 217 | 135 | return yield* Effect.fail( |
| 218 | new CheckpointGitCommandError({ | |
| 136 | new GitCommandError({ | |
| 219 | 137 | operation, |
| 220 | 138 | command: "git write-tree", |
| 221 | 139 | cwd: normalizedCwd, |
| ... | ... | @@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 225 | 143 | } |
| 226 | 144 | |
| 227 | 145 | const message = `t3 checkpoint ref=${checkpointRef}`; |
| 228 | const commitTreeResult = yield* runGitWithSpawner({ | |
| 146 | const commitTreeResult = yield* git.execute({ | |
| 229 | 147 | operation, |
| 230 | 148 | cwd: normalizedCwd, |
| 231 | 149 | args: ["commit-tree", treeOid, "-m", message], |
| ... | ... | @@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 234 | 152 | const commitOid = commitTreeResult.stdout.trim(); |
| 235 | 153 | if (commitOid.length === 0) { |
| 236 | 154 | return yield* Effect.fail( |
| 237 | new CheckpointGitCommandError({ | |
| 155 | new GitCommandError({ | |
| 238 | 156 | operation, |
| 239 | 157 | command: "git commit-tree", |
| 240 | 158 | cwd: normalizedCwd, |
| ... | ... | @@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 243 | 161 | ); |
| 244 | 162 | } |
| 245 | 163 | |
| 246 | yield* runGitWithSpawner({ | |
| 164 | yield* git.execute({ | |
| 247 | 165 | operation, |
| 248 | 166 | cwd: normalizedCwd, |
| 249 | 167 | args: ["update-ref", checkpointRef, commitOid], |
| 250 | 168 | }); |
| 251 | 169 | }), |
| 252 | removeDirectory, | |
| 253 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 170 | (tempDir) => fs.remove(tempDir, { recursive: true }), | |
| 171 | ).pipe( | |
| 172 | Effect.catchTags({ | |
| 173 | PlatformError: (error) => | |
| 174 | Effect.fail( | |
| 175 | new CheckpointInvariantError({ | |
| 176 | operation: "CheckpointStore.captureCheckpoint", | |
| 177 | detail: "Failed to capture checkpoint.", | |
| 178 | cause: error, | |
| 179 | }), | |
| 180 | ), | |
| 181 | }), | |
| 182 | ); | |
| 254 | 183 | }); |
| 255 | 184 | |
| 256 | 185 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => |
| 257 | 186 | Effect.gen(function* () { |
| 258 | const operation = "CheckpointStore.hasCheckpointRef"; | |
| 259 | const normalizedCwd = input.cwd.trim(); | |
| 260 | const checkpointRef = input.checkpointRef.trim(); | |
| 261 | ||
| 262 | return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | |
| 187 | return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | |
| 263 | 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 | }); |
| 295 | 191 | |
| 296 | 192 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => |
| 297 | 193 | Effect.gen(function* () { |
| 298 | 194 | const operation = "CheckpointStore.restoreCheckpoint"; |
| 299 | const normalizedCwd = input.cwd.trim(); | |
| 300 | const checkpointRef = input.checkpointRef.trim(); | |
| 301 | 195 | |
| 302 | let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | |
| 303 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | |
| 304 | ); | |
| 196 | let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); | |
| 305 | 197 | |
| 306 | 198 | if (!commitOid && input.fallbackToHead === true) { |
| 307 | commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | |
| 308 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | |
| 309 | ); | |
| 199 | commitOid = yield* resolveHeadCommit(input.cwd); | |
| 310 | 200 | } |
| 311 | 201 | |
| 312 | 202 | if (!commitOid) { |
| 313 | 203 | return false; |
| 314 | 204 | } |
| 315 | 205 | |
| 316 | yield* runGitWithSpawner({ | |
| 206 | yield* git.execute({ | |
| 317 | 207 | operation, |
| 318 | cwd: normalizedCwd, | |
| 208 | cwd: input.cwd, | |
| 319 | 209 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], |
| 320 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 321 | yield* runGitWithSpawner({ | |
| 210 | }); | |
| 211 | yield* git.execute({ | |
| 322 | 212 | operation, |
| 323 | cwd: normalizedCwd, | |
| 213 | cwd: input.cwd, | |
| 324 | 214 | args: ["clean", "-fd", "--", "."], |
| 325 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 215 | }); | |
| 326 | 216 | |
| 327 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe( | |
| 328 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | |
| 329 | ); | |
| 217 | const headExists = yield* hasHeadCommit(input.cwd); | |
| 330 | 218 | if (headExists) { |
| 331 | yield* runGitWithSpawner({ | |
| 219 | yield* git.execute({ | |
| 332 | 220 | operation, |
| 333 | cwd: normalizedCwd, | |
| 221 | cwd: input.cwd, | |
| 334 | 222 | args: ["reset", "--quiet", "--", "."], |
| 335 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 223 | }); | |
| 336 | 224 | } |
| 337 | 225 | |
| 338 | 226 | return true; |
| ... | ... | @@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 341 | 229 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => |
| 342 | 230 | Effect.gen(function* () { |
| 343 | 231 | const operation = "CheckpointStore.diffCheckpoints"; |
| 344 | const normalizedCwd = input.cwd.trim(); | |
| 345 | const fromCheckpointRef = input.fromCheckpointRef.trim(); | |
| 346 | const toCheckpointRef = input.toCheckpointRef.trim(); | |
| 347 | ||
| 348 | let fromCommitOid = yield* resolveCheckpointCommitWithSpawner( | |
| 349 | normalizedCwd, | |
| 350 | fromCheckpointRef, | |
| 351 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 352 | const toCommitOid = yield* resolveCheckpointCommitWithSpawner( | |
| 353 | normalizedCwd, | |
| 354 | toCheckpointRef, | |
| 355 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 232 | ||
| 233 | let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); | |
| 234 | const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); | |
| 356 | 235 | |
| 357 | 236 | if (!fromCommitOid && input.fallbackFromToHead === true) { |
| 358 | const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | |
| 359 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | |
| 360 | ); | |
| 237 | const headCommit = yield* resolveHeadCommit(input.cwd); | |
| 361 | 238 | if (headCommit) { |
| 362 | 239 | fromCommitOid = headCommit; |
| 363 | 240 | } |
| ... | ... | @@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 365 | 242 | |
| 366 | 243 | if (!fromCommitOid || !toCommitOid) { |
| 367 | 244 | return yield* Effect.fail( |
| 368 | new CheckpointGitCommandError({ | |
| 245 | new GitCommandError({ | |
| 369 | 246 | operation, |
| 370 | 247 | command: "git diff", |
| 371 | cwd: normalizedCwd, | |
| 248 | cwd: input.cwd, | |
| 372 | 249 | detail: "Checkpoint ref is unavailable for diff operation.", |
| 373 | 250 | }), |
| 374 | 251 | ); |
| 375 | 252 | } |
| 376 | 253 | |
| 377 | const result = yield* runGitWithSpawner({ | |
| 254 | const result = yield* git.execute({ | |
| 378 | 255 | operation, |
| 379 | cwd: normalizedCwd, | |
| 256 | cwd: input.cwd, | |
| 380 | 257 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], |
| 381 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | |
| 258 | }); | |
| 382 | 259 | |
| 383 | 260 | return result.stdout; |
| 384 | 261 | }); |
| ... | ... | @@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 386 | 263 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => |
| 387 | 264 | Effect.gen(function* () { |
| 388 | 265 | const operation = "CheckpointStore.deleteCheckpointRefs"; |
| 389 | const normalizedCwd = input.cwd.trim(); | |
| 390 | const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim()); | |
| 391 | 266 | |
| 392 | 267 | yield* Effect.forEach( |
| 393 | checkpointRefs, | |
| 268 | input.checkpointRefs, | |
| 394 | 269 | (checkpointRef) => |
| 395 | runGitWithSpawner({ | |
| 270 | git.execute({ | |
| 396 | 271 | operation, |
| 397 | cwd: normalizedCwd, | |
| 272 | cwd: input.cwd, | |
| 398 | 273 | args: ["update-ref", "-d", checkpointRef], |
| 399 | 274 | allowNonZeroExit: true, |
| 400 | }).pipe( | |
| 401 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | |
| 402 | ), | |
| 275 | }), | |
| 403 | 276 | { discard: true }, |
| 404 | 277 | ); |
| 405 | 278 | }); |
| ... | ... | @@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 408 | 281 | isGitRepository, |
| 409 | 282 | captureCheckpoint, |
| 410 | 283 | hasCheckpointRef, |
| 411 | ensureRootCheckpoint, | |
| 412 | 284 | restoreCheckpoint, |
| 413 | 285 | diffCheckpoints, |
| 414 | 286 | deleteCheckpointRefs, |
| 415 | 287 | } satisfies CheckpointStoreShape; |
| 416 | 288 | }); |
| 417 | 289 | |
| 418 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); | |
| 290 | export 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 | */ | |
| 13 | import type { | |
| 14 | ProviderGetCheckpointDiffInput, | |
| 15 | ProviderGetCheckpointDiffResult, | |
| 16 | ProviderListCheckpointsInput, | |
| 17 | ProviderListCheckpointsResult, | |
| 18 | ProviderRevertToCheckpointInput, | |
| 19 | ProviderRevertToCheckpointResult, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | import { ServiceMap } from "effect"; | |
| 22 | import type { Effect } from "effect"; | |
| 23 | ||
| 24 | import type { CheckpointServiceError } from "../Errors.ts"; | |
| 25 | ||
| 26 | export interface InitializeCheckpointSessionInput { | |
| 27 | readonly providerSessionId: string; | |
| 28 | readonly cwd: string; | |
| 29 | } | |
| 30 | ||
| 31 | export interface CaptureCurrentTurnInput { | |
| 32 | readonly providerSessionId: string; | |
| 33 | readonly turnId?: string; | |
| 34 | readonly status?: string; | |
| 35 | } | |
| 36 | ||
| 37 | export 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 | */ | |
| 87 | export 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 | 14 | import type { Effect } from "effect"; |
| 15 | 15 | |
| 16 | 16 | import type { CheckpointStoreError } from "../Errors.ts"; |
| 17 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 17 | 18 | |
| 18 | 19 | export interface CaptureCheckpointInput { |
| 19 | 20 | readonly cwd: string; |
| 20 | readonly checkpointRef: string; | |
| 21 | } | |
| 22 | ||
| 23 | export interface EnsureRootCheckpointInput { | |
| 24 | readonly cwd: string; | |
| 25 | readonly checkpointRef: string; | |
| 21 | readonly checkpointRef: CheckpointRef; | |
| 26 | 22 | } |
| 27 | 23 | |
| 28 | 24 | export interface RestoreCheckpointInput { |
| 29 | 25 | readonly cwd: string; |
| 30 | readonly checkpointRef: string; | |
| 26 | readonly checkpointRef: CheckpointRef; | |
| 31 | 27 | readonly fallbackToHead?: boolean; |
| 32 | 28 | } |
| 33 | 29 | |
| 34 | 30 | export interface DiffCheckpointsInput { |
| 35 | 31 | readonly cwd: string; |
| 36 | readonly fromCheckpointRef: string; | |
| 37 | readonly toCheckpointRef: string; | |
| 32 | readonly fromCheckpointRef: CheckpointRef; | |
| 33 | readonly toCheckpointRef: CheckpointRef; | |
| 38 | 34 | readonly fallbackFromToHead?: boolean; |
| 39 | 35 | } |
| 40 | 36 | |
| 41 | 37 | export interface DeleteCheckpointRefsInput { |
| 42 | 38 | readonly cwd: string; |
| 43 | readonly checkpointRefs: ReadonlyArray<string>; | |
| 39 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; | |
| 44 | 40 | } |
| 45 | 41 | |
| 46 | 42 | export interface CheckpointStoreShape { |
| ... | ... | @@ -63,13 +59,6 @@ export interface CheckpointStoreShape { |
| 63 | 59 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, |
| 64 | 60 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 65 | 61 | |
| 66 | /** | |
| 67 | * Ensure a root checkpoint ref exists. | |
| 68 | */ | |
| 69 | readonly ensureRootCheckpoint: ( | |
| 70 | input: EnsureRootCheckpointInput, | |
| 71 | ) => Effect.Effect<boolean, CheckpointStoreError>; | |
| 72 | ||
| 73 | 62 | /** |
| 74 | 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 | 3 | import { EventEmitter } from "node:events"; |
| 4 | 4 | import readline from "node:readline"; |
| 5 | 5 | |
| 6 | import type { | |
| 7 | ProviderApprovalDecision, | |
| 8 | ProviderEvent, | |
| 6 | import { | |
| 7 | ApprovalRequestId, | |
| 8 | EventId, | |
| 9 | ProviderItemId, | |
| 9 | 10 | ProviderRequestKind, |
| 10 | ProviderSendTurnInput, | |
| 11 | ProviderSession, | |
| 12 | ProviderSessionStartInput, | |
| 13 | ProviderTurnStartResult, | |
| 11 | ProviderSessionId, | |
| 12 | ProviderThreadId, | |
| 13 | ProviderTurnId, | |
| 14 | normalizeModelSlug, | |
| 15 | type ProviderApprovalDecision, | |
| 16 | type ProviderEvent, | |
| 17 | type ProviderSendTurnInput, | |
| 18 | type ProviderSession, | |
| 19 | type ProviderSessionStartInput, | |
| 20 | type ProviderTurnStartResult, | |
| 14 | 21 | } from "@t3tools/contracts"; |
| 15 | import { normalizeModelSlug } from "@t3tools/contracts"; | |
| 16 | 22 | |
| 17 | 23 | type PendingRequestKey = string; |
| 18 | 24 | |
| ... | ... | @@ -24,13 +30,13 @@ interface PendingRequest { |
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | interface PendingApprovalRequest { |
| 27 | requestId: string; | |
| 33 | requestId: ApprovalRequestId; | |
| 28 | 34 | jsonRpcId: string | number; |
| 29 | 35 | method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; |
| 30 | 36 | requestKind: ProviderRequestKind; |
| 31 | threadId?: string; | |
| 32 | turnId?: string; | |
| 33 | itemId?: string; | |
| 37 | threadId?: ProviderThreadId; | |
| 38 | turnId?: ProviderTurnId; | |
| 39 | itemId?: ProviderItemId; | |
| 34 | 40 | } |
| 35 | 41 | |
| 36 | 42 | interface CodexSessionContext { |
| ... | ... | @@ -38,7 +44,7 @@ interface CodexSessionContext { |
| 38 | 44 | child: ChildProcessWithoutNullStreams; |
| 39 | 45 | output: readline.Interface; |
| 40 | 46 | pending: Map<PendingRequestKey, PendingRequest>; |
| 41 | pendingApprovals: Map<string, PendingApprovalRequest>; | |
| 47 | pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>; | |
| 42 | 48 | nextRequestId: number; |
| 43 | 49 | stopping: boolean; |
| 44 | 50 | } |
| ... | ... | @@ -66,12 +72,12 @@ interface JsonRpcNotification { |
| 66 | 72 | } |
| 67 | 73 | |
| 68 | 74 | export interface CodexThreadTurnSnapshot { |
| 69 | id: string; | |
| 75 | id: ProviderTurnId; | |
| 70 | 76 | items: unknown[]; |
| 71 | 77 | } |
| 72 | 78 | |
| 73 | 79 | export interface CodexThreadSnapshot { |
| 74 | threadId: string; | |
| 80 | threadId: ProviderThreadId; | |
| 75 | 81 | turns: CodexThreadTurnSnapshot[]; |
| 76 | 82 | } |
| 77 | 83 | |
| ... | ... | @@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents { |
| 143 | 149 | } |
| 144 | 150 | |
| 145 | 151 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { |
| 146 | private readonly sessions = new Map<string, CodexSessionContext>(); | |
| 152 | private readonly sessions = new Map<ProviderSessionId, CodexSessionContext>(); | |
| 147 | 153 | |
| 148 | 154 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 149 | const sessionId = randomUUID(); | |
| 155 | const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); | |
| 150 | 156 | const now = new Date().toISOString(); |
| 151 | 157 | let context: CodexSessionContext | undefined; |
| 152 | 158 | |
| ... | ... | @@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 244 | 250 | } |
| 245 | 251 | |
| 246 | 252 | const threadOpenRecord = this.readObject(threadOpenResponse); |
| 247 | const threadId = | |
| 253 | const threadIdRaw = | |
| 248 | 254 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? |
| 249 | 255 | this.readString(threadOpenRecord, "threadId"); |
| 250 | if (!threadId) { | |
| 256 | if (!threadIdRaw) { | |
| 251 | 257 | throw new Error(`${threadOpenMethod} response did not include a thread id.`); |
| 252 | 258 | } |
| 259 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 253 | 260 | |
| 254 | 261 | this.updateSession(context, { |
| 255 | 262 | status: "ready", |
| ... | ... | @@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 268 | 275 | this.stopSession(sessionId); |
| 269 | 276 | } else { |
| 270 | 277 | this.emitEvent({ |
| 271 | id: randomUUID(), | |
| 278 | id: EventId.makeUnsafe(randomUUID()), | |
| 272 | 279 | kind: "error", |
| 273 | 280 | provider: "codex", |
| 274 | 281 | sessionId, |
| ... | ... | @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 310 | 317 | } |
| 311 | 318 | |
| 312 | 319 | const turnStartParams: { |
| 313 | threadId: string; | |
| 320 | threadId: ProviderThreadId; | |
| 314 | 321 | input: Array< |
| 315 | 322 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| 316 | 323 | >; |
| ... | ... | @@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 331 | 338 | const response = await this.sendRequest(context, "turn/start", turnStartParams); |
| 332 | 339 | |
| 333 | 340 | const turn = this.readObject(this.readObject(response), "turn"); |
| 334 | const turnId = this.readString(turn, "id"); | |
| 335 | if (!turnId) { | |
| 341 | const turnIdRaw = this.readString(turn, "id"); | |
| 342 | if (!turnIdRaw) { | |
| 336 | 343 | throw new Error("turn/start response did not include a turn id."); |
| 337 | 344 | } |
| 345 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | |
| 338 | 346 | |
| 339 | 347 | this.updateSession(context, { |
| 340 | 348 | status: "running", |
| ... | ... | @@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 347 | 355 | }; |
| 348 | 356 | } |
| 349 | 357 | |
| 350 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 358 | async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> { | |
| 351 | 359 | const context = this.requireSession(sessionId); |
| 352 | 360 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| 353 | 361 | |
| ... | ... | @@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 361 | 369 | }); |
| 362 | 370 | } |
| 363 | 371 | |
| 364 | async readThread(sessionId: string): Promise<CodexThreadSnapshot> { | |
| 372 | async readThread(sessionId: ProviderSessionId): Promise<CodexThreadSnapshot> { | |
| 365 | 373 | const context = this.requireSession(sessionId); |
| 366 | 374 | const threadId = context.session.threadId; |
| 367 | 375 | if (!threadId) { |
| ... | ... | @@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 375 | 383 | return this.parseThreadSnapshot("thread/read", response); |
| 376 | 384 | } |
| 377 | 385 | |
| 378 | async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> { | |
| 386 | async rollbackThread(sessionId: ProviderSessionId, numTurns: number): Promise<CodexThreadSnapshot> { | |
| 379 | 387 | const context = this.requireSession(sessionId); |
| 380 | 388 | const threadId = context.session.threadId; |
| 381 | 389 | if (!threadId) { |
| ... | ... | @@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 397 | 405 | } |
| 398 | 406 | |
| 399 | 407 | async respondToRequest( |
| 400 | sessionId: string, | |
| 401 | requestId: string, | |
| 408 | sessionId: ProviderSessionId, | |
| 409 | requestId: ApprovalRequestId, | |
| 402 | 410 | decision: ProviderApprovalDecision, |
| 403 | 411 | ): Promise<void> { |
| 404 | 412 | const context = this.requireSession(sessionId); |
| ... | ... | @@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 416 | 424 | }); |
| 417 | 425 | |
| 418 | 426 | this.emitEvent({ |
| 419 | id: randomUUID(), | |
| 427 | id: EventId.makeUnsafe(randomUUID()), | |
| 420 | 428 | kind: "notification", |
| 421 | 429 | provider: "codex", |
| 422 | 430 | sessionId: context.session.sessionId, |
| ... | ... | @@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 435 | 443 | }); |
| 436 | 444 | } |
| 437 | 445 | |
| 438 | stopSession(sessionId: string): void { | |
| 446 | stopSession(sessionId: ProviderSessionId): void { | |
| 439 | 447 | const context = this.sessions.get(sessionId); |
| 440 | 448 | if (!context) { |
| 441 | 449 | return; |
| ... | ... | @@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 470 | 478 | })); |
| 471 | 479 | } |
| 472 | 480 | |
| 473 | hasSession(sessionId: string): boolean { | |
| 481 | hasSession(sessionId: ProviderSessionId): boolean { | |
| 474 | 482 | return this.sessions.has(sessionId); |
| 475 | 483 | } |
| 476 | 484 | |
| ... | ... | @@ -480,7 +488,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 480 | 488 | } |
| 481 | 489 | } |
| 482 | 490 | |
| 483 | private requireSession(sessionId: string): CodexSessionContext { | |
| 491 | private requireSession(sessionId: ProviderSessionId): CodexSessionContext { | |
| 484 | 492 | const context = this.sessions.get(sessionId); |
| 485 | 493 | if (!context) { |
| 486 | 494 | throw new Error(`Unknown session: ${sessionId}`); |
| ... | ... | @@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 591 | 599 | : undefined; |
| 592 | 600 | |
| 593 | 601 | this.emitEvent({ |
| 594 | id: randomUUID(), | |
| 602 | id: EventId.makeUnsafe(randomUUID()), | |
| 595 | 603 | kind: "notification", |
| 596 | 604 | provider: "codex", |
| 597 | 605 | sessionId: context.session.sessionId, |
| ... | ... | @@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 605 | 613 | }); |
| 606 | 614 | |
| 607 | 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 | 619 | if (threadId) { |
| 610 | 620 | this.updateSession(context, { threadId }); |
| 611 | 621 | } |
| ... | ... | @@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 613 | 623 | } |
| 614 | 624 | |
| 615 | 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 | 629 | this.updateSession(context, { |
| 618 | 630 | status: "running", |
| 619 | 631 | activeTurnId: turnId, |
| ... | ... | @@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 647 | 659 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { |
| 648 | 660 | const route = this.readRouteFields(request.params); |
| 649 | 661 | const requestKind = this.requestKindForMethod(request.method); |
| 650 | let requestId: string | undefined; | |
| 662 | let requestId: ApprovalRequestId | undefined; | |
| 651 | 663 | if (requestKind) { |
| 652 | requestId = randomUUID(); | |
| 664 | requestId = ApprovalRequestId.makeUnsafe(randomUUID()); | |
| 653 | 665 | const pendingRequest: PendingApprovalRequest = { |
| 654 | 666 | requestId, |
| 655 | 667 | jsonRpcId: request.id, |
| ... | ... | @@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 666 | 678 | } |
| 667 | 679 | |
| 668 | 680 | this.emitEvent({ |
| 669 | id: randomUUID(), | |
| 681 | id: EventId.makeUnsafe(randomUUID()), | |
| 670 | 682 | kind: "request", |
| 671 | 683 | provider: "codex", |
| 672 | 684 | sessionId: context.session.sessionId, |
| ... | ... | @@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 761 | 773 | |
| 762 | 774 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { |
| 763 | 775 | this.emitEvent({ |
| 764 | id: randomUUID(), | |
| 776 | id: EventId.makeUnsafe(randomUUID()), | |
| 765 | 777 | kind: "session", |
| 766 | 778 | provider: "codex", |
| 767 | 779 | sessionId: context.session.sessionId, |
| ... | ... | @@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 773 | 785 | |
| 774 | 786 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { |
| 775 | 787 | this.emitEvent({ |
| 776 | id: randomUUID(), | |
| 788 | id: EventId.makeUnsafe(randomUUID()), | |
| 777 | 789 | kind: "error", |
| 778 | 790 | provider: "codex", |
| 779 | 791 | sessionId: context.session.sessionId, |
| ... | ... | @@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 810 | 822 | private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { |
| 811 | 823 | const responseRecord = this.readObject(response); |
| 812 | 824 | const thread = this.readObject(responseRecord, "thread"); |
| 813 | const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); | |
| 814 | if (!threadId) { | |
| 825 | const threadIdRaw = this.readString(thread, "id") ?? | |
| 826 | this.readString(responseRecord, "threadId"); | |
| 827 | if (!threadIdRaw) { | |
| 815 | 828 | throw new Error(`${method} response did not include a thread id.`); |
| 816 | 829 | } |
| 830 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | |
| 817 | 831 | |
| 818 | 832 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; |
| 819 | 833 | const turns = turnsRaw.map((turnValue, index) => { |
| 820 | 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 | 839 | const items = this.readArray(turn, "items") ?? []; |
| 823 | 840 | return { |
| 824 | 841 | id: turnId, |
| ... | ... | @@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 865 | 882 | } |
| 866 | 883 | |
| 867 | 884 | private readRouteFields(params: unknown): { |
| 868 | threadId?: string; | |
| 869 | turnId?: string; | |
| 870 | itemId?: string; | |
| 885 | threadId?: ProviderThreadId; | |
| 886 | turnId?: ProviderTurnId; | |
| 887 | itemId?: ProviderItemId; | |
| 871 | 888 | } { |
| 872 | 889 | const route: { |
| 873 | threadId?: string; | |
| 874 | turnId?: string; | |
| 875 | itemId?: string; | |
| 890 | threadId?: ProviderThreadId; | |
| 891 | turnId?: ProviderTurnId; | |
| 892 | itemId?: ProviderItemId; | |
| 876 | 893 | } = {}; |
| 877 | 894 | |
| 878 | const threadId = | |
| 895 | const threadId = toProviderThreadId( | |
| 879 | 896 | this.readString(params, "threadId") ?? |
| 880 | this.readString(this.readObject(params, "thread"), "id"); | |
| 881 | const turnId = | |
| 882 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); | |
| 883 | const itemId = | |
| 884 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); | |
| 897 | this.readString(this.readObject(params, "thread"), "id"), | |
| 898 | ); | |
| 899 | const turnId = toProviderTurnId( | |
| 900 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), | |
| 901 | ); | |
| 902 | const itemId = toProviderItemId( | |
| 903 | this.readString(params, "itemId") ?? | |
| 904 | this.readString(this.readObject(params, "item"), "id"), | |
| 905 | ); | |
| 885 | 906 | |
| 886 | 907 | if (threadId) { |
| 887 | 908 | route.threadId = threadId; |
| ... | ... | @@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 941 | 962 | return typeof candidate === "boolean" ? candidate : undefined; |
| 942 | 963 | } |
| 943 | 964 | } |
| 965 | ||
| 966 | function 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 | ||
| 974 | function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { | |
| 975 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); | |
| 976 | } | |
| 977 | ||
| 978 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { | |
| 979 | return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); | |
| 980 | } | |
| 981 | ||
| 982 | function 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 @@ |
| 1 | import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | ||
| 6 | import { describe, expect, it } from "vitest"; | |
| 7 | ||
| 8 | import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 9 | import { runProcess } from "./processRunner"; | |
| 10 | ||
| 11 | async 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 | ||
| 22 | function exists(filePath: string): boolean { | |
| 23 | return fs.existsSync(filePath); | |
| 24 | } | |
| 25 | ||
| 26 | async 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 | ||
| 38 | async 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 | ||
| 44 | describe("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 @@ |
| 1 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { randomUUID } from "node:crypto"; | |
| 5 | ||
| 6 | import { runProcess } from "./processRunner"; | |
| 7 | ||
| 8 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 9 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 10 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 11 | ||
| 12 | function quoteGitCommand(args: readonly string[]): string { | |
| 13 | return `git ${args.join(" ")}`; | |
| 14 | } | |
| 15 | ||
| 16 | function checkpointRefThreadSegment(threadId: string): string { | |
| 17 | return Buffer.from(threadId, "utf8").toString("base64url"); | |
| 18 | } | |
| 19 | ||
| 20 | export 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 | ||
| 27 | interface GitOptions { | |
| 28 | env?: NodeJS.ProcessEnv; | |
| 29 | stdin?: string; | |
| 30 | allowNonZeroExit?: boolean; | |
| 31 | } | |
| 32 | ||
| 33 | export 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 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | gitPullInputSchema, | |
| 7 | gitPullResultSchema, | |
| 8 | gitStatusInputSchema, | |
| 9 | gitStatusResultSchema, | |
| 6 | GitPullInput, | |
| 7 | GitPullResult, | |
| 8 | GitStatusInput, | |
| 9 | GitStatusResult, | |
| 10 | 10 | type GitCheckoutInput, |
| 11 | 11 | type GitCreateBranchInput, |
| 12 | 12 | type GitCreateWorktreeInput, |
| ... | ... | @@ -14,11 +14,7 @@ import { |
| 14 | 14 | type GitInitInput, |
| 15 | 15 | type GitListBranchesInput, |
| 16 | 16 | type GitListBranchesResult, |
| 17 | type GitPullInput, | |
| 18 | type GitPullResult, | |
| 19 | 17 | type GitRemoveWorktreeInput, |
| 20 | type GitStatusInput, | |
| 21 | type GitStatusResult, | |
| 22 | 18 | } from "@t3tools/contracts"; |
| 23 | 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; |
| 24 | 20 | |
| ... | ... | @@ -29,8 +25,6 @@ export interface TerminalCommandInput { |
| 29 | 25 | maxOutputBytes?: number; |
| 30 | 26 | } |
| 31 | 27 | |
| 32 | ||
| 33 | ||
| 34 | 28 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { |
| 35 | 29 | upstreamRef: string | null; |
| 36 | 30 | } |
| ... | ... | @@ -220,10 +214,9 @@ async function executeGit( |
| 220 | 214 | export class GitCoreService { |
| 221 | 215 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); |
| 222 | 216 | |
| 223 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | |
| 224 | const input = gitStatusInputSchema.parse(raw); | |
| 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { | |
| 225 | 218 | const details = await this.statusDetails(input.cwd); |
| 226 | return gitStatusResultSchema.parse({ | |
| 219 | return GitStatusResult.makeUnsafe({ | |
| 227 | 220 | branch: details.branch, |
| 228 | 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 229 | 222 | workingTree: details.workingTree, |
| ... | ... | @@ -392,10 +385,10 @@ export class GitCoreService { |
| 392 | 385 | }); |
| 393 | 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); |
| 394 | 387 | const refreshed = await this.statusDetails(cwd); |
| 395 | return gitPullResultSchema.parse({ | |
| 388 | return GitPullResult.makeUnsafe({ | |
| 396 | 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 397 | 390 | branch, |
| 398 | ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), | |
| 391 | upstreamBranch: refreshed.upstreamRef, | |
| 399 | 392 | }); |
| 400 | 393 | } |
| 401 | 394 | |
| ... | ... | @@ -678,9 +671,7 @@ export class GitCoreService { |
| 678 | 671 | |
| 679 | 672 | const defaultGitCoreService = new GitCoreService(); |
| 680 | 673 | |
| 681 | export async function runTerminalCommand( | |
| 682 | input: TerminalCommandInput, | |
| 683 | ): Promise<ProcessRunResult> { | |
| 674 | export async function runTerminalCommand(input: TerminalCommandInput): Promise<ProcessRunResult> { | |
| 684 | 675 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 685 | 676 | const shellPath = |
| 686 | 677 | process.platform === "win32" |
| ... | ... | @@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 712 | 703 | return defaultGitCoreService.listBranches(input); |
| 713 | 704 | } |
| 714 | 705 | |
| 715 | export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> { | |
| 716 | const input = gitPullInputSchema.parse(raw); | |
| 706 | export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> { | |
| 717 | 707 | return defaultGitCoreService.pullCurrentBranch(input.cwd); |
| 718 | 708 | } |
| 719 | 709 |
apps/server/src/git/Layer.test.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "./Errors.ts"; | |
| 6 | import { GitServiceLive } from "./Layer.ts"; | |
| 7 | import { GitService } from "./Service.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | |
| 10 | ||
| 11 | layer("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 | */ | |
| 9 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "./Errors.ts"; | |
| 12 | import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts"; | |
| 13 | ||
| 14 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 15 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 16 | ||
| 17 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 18 | return `git ${args.join(" ")}`; | |
| 19 | } | |
| 20 | ||
| 21 | function 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 | ||
| 37 | const 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 | ||
| 65 | const 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 | ||
| 141 | export 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 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { GitCommandError } from "./Errors.ts"; | |
| 13 | ||
| 14 | export 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 | ||
| 24 | export interface ExecuteGitResult { | |
| 25 | readonly code: number; | |
| 26 | readonly stdout: string; | |
| 27 | readonly stderr: string; | |
| 28 | } | |
| 29 | ||
| 30 | export 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 | */ | |
| 40 | export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()( | |
| 41 | "git/GitService", | |
| 42 | ) {} |
apps/server/src/git/process.test.ts deleted-51| ... | ... | @@ -1,51 +0,0 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "./Errors.ts"; | |
| 6 | import { runGit } from "./Process.ts"; | |
| 7 | ||
| 8 | it.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 | ||
| 21 | it.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 | ||
| 34 | it.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 | */ | |
| 9 | import { Effect, Option, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "./Errors.ts"; | |
| 12 | ||
| 13 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 14 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 15 | ||
| 16 | export 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 | ||
| 26 | export interface GitRunResult { | |
| 27 | readonly code: number; | |
| 28 | readonly stdout: string; | |
| 29 | readonly stderr: string; | |
| 30 | } | |
| 31 | ||
| 32 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 33 | return `git ${args.join(" ")}`; | |
| 34 | } | |
| 35 | ||
| 36 | function 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 | ||
| 43 | function 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 | ||
| 57 | function 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 | ||
| 95 | export 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 | 4 | import path from "node:path"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | gitRunStackedActionInputSchema, | |
| 8 | gitRunStackedActionResultSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | type GitRunStackedActionInput, | |
| 11 | type GitRunStackedActionResult, | |
| 12 | type GitStatusInput, | |
| 13 | type GitStatusResult, | |
| 7 | GitRunStackedActionInput, | |
| 8 | GitRunStackedActionResult, | |
| 9 | GitStatusInput, | |
| 10 | GitStatusResult, | |
| 14 | 11 | } from "@t3tools/contracts"; |
| 15 | 12 | |
| 16 | 13 | import type { |
| ... | ... | @@ -187,8 +184,7 @@ export class GitManager { |
| 187 | 184 | this.gitCore = deps.gitCore ?? new GitCoreService(); |
| 188 | 185 | } |
| 189 | 186 | |
| 190 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | |
| 191 | const input = gitStatusInputSchema.parse(raw); | |
| 187 | async status(input: GitStatusInput): Promise<GitStatusResult> { | |
| 192 | 188 | const details = await this.gitCore.statusDetails(input.cwd); |
| 193 | 189 | |
| 194 | 190 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; |
| ... | ... | @@ -214,8 +210,7 @@ export class GitManager { |
| 214 | 210 | }; |
| 215 | 211 | } |
| 216 | 212 | |
| 217 | async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | |
| 218 | const input = gitRunStackedActionInputSchema.parse(raw); | |
| 213 | async runStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | |
| 219 | 214 | const wantsPush = input.action !== "commit"; |
| 220 | 215 | const wantsPr = input.action === "commit_push_pr"; |
| 221 | 216 | |
| ... | ... | @@ -237,12 +232,12 @@ export class GitManager { |
| 237 | 232 | ? await this.runPrStep(input.cwd, initialStatus.branch) |
| 238 | 233 | : { status: "skipped_not_requested" as const }; |
| 239 | 234 | |
| 240 | return gitRunStackedActionResultSchema.parse({ | |
| 235 | return { | |
| 241 | 236 | action: input.action, |
| 242 | 237 | commit, |
| 243 | 238 | push, |
| 244 | 239 | pr, |
| 245 | }); | |
| 240 | }; | |
| 246 | 241 | } |
| 247 | 242 | |
| 248 | 243 | private async runCommitStep( |
apps/server/src/keybindings.ts+11-15| ... | ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | import type { | |
| 5 | import { | |
| 6 | 6 | KeybindingRule, |
| 7 | 7 | KeybindingsConfig, |
| 8 | 8 | KeybindingShortcut, |
| ... | ... | @@ -10,7 +10,8 @@ import type { |
| 10 | 10 | ResolvedKeybindingRule, |
| 11 | 11 | ResolvedKeybindingsConfig, |
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | |
| 13 | import { Mutable } from "effect/Types"; | |
| 14 | import { Schema } from "effect"; | |
| 14 | 15 | |
| 15 | 16 | interface KeybindingsLogger { |
| 16 | 17 | warn(message: string, context?: Record<string, unknown>): void; |
| ... | ... | @@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey |
| 267 | 268 | export function compileResolvedKeybindingsConfig( |
| 268 | 269 | config: KeybindingsConfig, |
| 269 | 270 | ): ResolvedKeybindingsConfig { |
| 270 | const compiled: ResolvedKeybindingsConfig = []; | |
| 271 | const compiled: Mutable<ResolvedKeybindingsConfig> = []; | |
| 271 | 272 | for (const rule of config) { |
| 272 | 273 | const resolved = compileResolvedKeybindingRule(rule); |
| 273 | 274 | if (!resolved) continue; |
| ... | ... | @@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig( |
| 277 | 278 | } |
| 278 | 279 | |
| 279 | 280 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { |
| 280 | const resolved: ResolvedKeybindingsConfig = []; | |
| 281 | const resolved: Mutable<ResolvedKeybindingsConfig> = []; | |
| 281 | 282 | for (const rule of DEFAULT_KEYBINDINGS) { |
| 282 | 283 | const compiled = compileResolvedKeybindingRule(rule); |
| 283 | 284 | if (!compiled) { |
| ... | ... | @@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig( |
| 322 | 323 | return []; |
| 323 | 324 | } |
| 324 | 325 | |
| 325 | const sanitized: KeybindingsConfig = []; | |
| 326 | const sanitized: Mutable<KeybindingsConfig> = []; | |
| 326 | 327 | let invalidEntries = 0; |
| 327 | 328 | for (const entry of parsed) { |
| 328 | const result = keybindingRuleSchema.safeParse(entry); | |
| 329 | if (result.success) { | |
| 330 | if (!compileResolvedKeybindingRule(result.data)) { | |
| 329 | const result = Schema.decodeUnknownExit(KeybindingRule)(entry); | |
| 330 | if (result._tag === "Success") { | |
| 331 | if (!compileResolvedKeybindingRule(result.value)) { | |
| 331 | 332 | invalidEntries += 1; |
| 332 | 333 | continue; |
| 333 | 334 | } |
| 334 | sanitized.push(result.data); | |
| 335 | sanitized.push(result.value); | |
| 335 | 336 | continue; |
| 336 | 337 | } |
| 337 | 338 | invalidEntries += 1; |
| ... | ... | @@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig( |
| 417 | 418 | |
| 418 | 419 | export function upsertKeybindingRule( |
| 419 | 420 | logger: KeybindingsLogger, |
| 420 | rawRule: unknown, | |
| 421 | rule: KeybindingRule, | |
| 421 | 422 | ): ResolvedKeybindingsConfig { |
| 422 | const rule = keybindingRuleSchema.parse(rawRule); | |
| 423 | if (!compileResolvedKeybindingRule(rule)) { | |
| 424 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | |
| 425 | } | |
| 426 | ||
| 427 | 423 | const configPath = resolveKeybindingsConfigPath(); |
| 428 | 424 | const customConfig = loadCustomKeybindingsConfig(logger, { |
| 429 | 425 | throwOnUnreadableConfig: true, |
apps/server/src/orchestration/Errors.ts+21-1| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { SchemaIssue, Schema } from "effect"; |
| 2 | 2 | |
| 3 | import type { OrchestrationEventStoreError } from "../persistence/Errors.ts"; | |
| 3 | import type { | |
| 4 | OrchestrationCommandReceiptRepositoryError, | |
| 5 | OrchestrationEventStoreError, | |
| 6 | ProjectionRepositoryError, | |
| 7 | } from "../persistence/Errors.ts"; | |
| 4 | 8 | |
| 5 | 9 | export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()( |
| 6 | 10 | "OrchestrationCommandJsonParseError", |
| ... | ... | @@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< |
| 39 | 43 | } |
| 40 | 44 | } |
| 41 | 45 | |
| 46 | export 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 | ||
| 42 | 59 | export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()( |
| 43 | 60 | "OrchestrationProjectorDecodeError", |
| 44 | 61 | { |
| ... | ... | @@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< |
| 67 | 84 | |
| 68 | 85 | export type OrchestrationDispatchError = |
| 69 | 86 | | OrchestrationEventStoreError |
| 87 | | OrchestrationCommandReceiptRepositoryError | |
| 88 | | ProjectionRepositoryError | |
| 70 | 89 | | OrchestrationCommandInvariantError |
| 90 | | OrchestrationCommandPreviouslyRejectedError | |
| 71 | 91 | | OrchestrationProjectorDecodeError |
| 72 | 92 | | OrchestrationListenerCallbackError; |
| 73 | 93 |
apps/server/src/orchestration/Layer.test.ts+155-203| ... | ... | @@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 2 | 2 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; |
| 3 | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | |
| 5 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | |
| 6 | import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 7 | import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; | |
| 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 5 | 9 | import { |
| 6 | 10 | OrchestrationEventStore, |
| 7 | 11 | type OrchestrationEventStoreShape, |
| 8 | 12 | } from "../persistence/Services/OrchestrationEventStore.ts"; |
| 9 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | |
| 10 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 11 | 13 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 14 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; | |
| 12 | 15 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 13 | import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; | |
| 14 | 16 | |
| 15 | export async function createOrchestrationSystem() { | |
| 17 | async function createOrchestrationSystem() { | |
| 16 | 18 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 19 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 17 | 20 | Layer.provide(OrchestrationEventStoreLive), |
| 21 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 18 | 22 | Layer.provide(SqlitePersistenceMemory), |
| 19 | 23 | ); |
| 20 | 24 | const runtime = ManagedRuntime.make(orchestrationLayer); |
| ... | ... | @@ -26,20 +30,33 @@ export async function createOrchestrationSystem() { |
| 26 | 30 | }; |
| 27 | 31 | } |
| 28 | 32 | |
| 33 | function now() { | |
| 34 | return new Date().toISOString(); | |
| 35 | } | |
| 36 | ||
| 29 | 37 | describe("OrchestrationEngine", () => { |
| 30 | 38 | it("returns deterministic read models for repeated reads", async () => { |
| 31 | const createdAt = new Date().toISOString(); | |
| 32 | const projectId = "project-1"; | |
| 33 | const threadId = "thread-1"; | |
| 34 | ||
| 39 | const createdAt = now(); | |
| 35 | 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 | 54 | await system.run( |
| 38 | 55 | engine.dispatch({ |
| 39 | 56 | type: "thread.create", |
| 40 | commandId: "cmd-1", | |
| 41 | threadId, | |
| 42 | projectId, | |
| 57 | commandId: "cmd-thread-1-create", | |
| 58 | threadId: "thread-1", | |
| 59 | projectId: "project-1", | |
| 43 | 60 | title: "Thread", |
| 44 | 61 | model: "gpt-5-codex", |
| 45 | 62 | branch: null, |
| ... | ... | @@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => { |
| 49 | 66 | ); |
| 50 | 67 | await system.run( |
| 51 | 68 | engine.dispatch({ |
| 52 | type: "message.send", | |
| 53 | commandId: "cmd-3", | |
| 54 | threadId, | |
| 55 | messageId: "msg-1", | |
| 56 | role: "user", | |
| 57 | text: "hello", | |
| 58 | streaming: false, | |
| 69 | type: "thread.turn.start", | |
| 70 | commandId: "cmd-turn-start-1", | |
| 71 | threadId: "thread-1", | |
| 72 | message: { | |
| 73 | messageId: "msg-1", | |
| 74 | role: "user", | |
| 75 | text: "hello", | |
| 76 | attachments: [], | |
| 77 | }, | |
| 59 | 78 | createdAt, |
| 60 | 79 | }), |
| 61 | 80 | ); |
| 81 | ||
| 62 | 82 | const readModelA = await system.run(engine.getReadModel()); |
| 63 | 83 | const readModelB = await system.run(engine.getReadModel()); |
| 64 | 84 | expect(readModelB).toEqual(readModelA); |
| ... | ... | @@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => { |
| 67 | 87 | |
| 68 | 88 | it("replays append-only events from sequence", async () => { |
| 69 | 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 | 104 | await system.run( |
| 72 | 105 | engine.dispatch({ |
| 73 | 106 | type: "thread.create", |
| 74 | commandId: "cmd-a", | |
| 107 | commandId: "cmd-thread-replay-create", | |
| 75 | 108 | threadId: "thread-replay", |
| 76 | 109 | projectId: "project-replay", |
| 77 | 110 | title: "replay", |
| 78 | 111 | model: "gpt-5-codex", |
| 79 | 112 | branch: null, |
| 80 | 113 | worktreePath: null, |
| 81 | createdAt: new Date().toISOString(), | |
| 114 | createdAt, | |
| 82 | 115 | }), |
| 83 | 116 | ); |
| 84 | 117 | await system.run( |
| 85 | 118 | engine.dispatch({ |
| 86 | 119 | type: "thread.delete", |
| 87 | commandId: "cmd-b", | |
| 120 | commandId: "cmd-thread-replay-delete", | |
| 88 | 121 | threadId: "thread-replay", |
| 89 | createdAt: new Date().toISOString(), | |
| 90 | 122 | }), |
| 91 | 123 | ); |
| 124 | ||
| 92 | 125 | const events = await system.run( |
| 93 | 126 | Stream.runCollect(engine.readEvents(0)).pipe( |
| 94 | 127 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), |
| 95 | 128 | ), |
| 96 | 129 | ); |
| 97 | expect(events.length).toBe(2); | |
| 98 | expect(events[0]?.type).toBe("thread.created"); | |
| 99 | expect(events[1]?.type).toBe("thread.deleted"); | |
| 130 | expect(events.map((event) => event.type)).toEqual([ | |
| 131 | "project.created", | |
| 132 | "thread.created", | |
| 133 | "thread.deleted", | |
| 134 | ]); | |
| 100 | 135 | await system.dispose(); |
| 101 | 136 | }); |
| 102 | 137 | |
| 103 | 138 | it("streams persisted domain events in order", async () => { |
| 104 | 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 | 155 | const eventTypes: string[] = []; |
| 107 | 156 | await system.run( |
| 108 | 157 | Effect.gen(function* () { |
| ... | ... | @@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => { |
| 113 | 162 | ), |
| 114 | 163 | ); |
| 115 | 164 | yield* Effect.sleep("10 millis"); |
| 116 | const createdAt = new Date().toISOString(); | |
| 117 | 165 | yield* engine.dispatch({ |
| 118 | 166 | type: "thread.create", |
| 119 | commandId: "cmd-stream-domain-a", | |
| 120 | threadId: "thread-stream-domain", | |
| 121 | projectId: "project-stream-domain", | |
| 167 | commandId: "cmd-stream-thread-create", | |
| 168 | threadId: "thread-stream", | |
| 169 | projectId: "project-stream", | |
| 122 | 170 | title: "domain-stream", |
| 123 | 171 | model: "gpt-5-codex", |
| 124 | 172 | branch: null, |
| ... | ... | @@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => { |
| 127 | 175 | }); |
| 128 | 176 | yield* engine.dispatch({ |
| 129 | 177 | type: "thread.meta.update", |
| 130 | commandId: "cmd-stream-domain-b", | |
| 131 | threadId: "thread-stream-domain", | |
| 178 | commandId: "cmd-stream-thread-update", | |
| 179 | threadId: "thread-stream", | |
| 132 | 180 | title: "domain-stream-updated", |
| 133 | createdAt, | |
| 134 | 181 | }); |
| 135 | 182 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| 136 | 183 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| 137 | 184 | }).pipe(Effect.scoped), |
| 138 | 185 | ); |
| 186 | ||
| 139 | 187 | expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); |
| 140 | 188 | await system.dispose(); |
| 141 | 189 | }); |
| 142 | 190 | |
| 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 | 192 | const system = await createOrchestrationSystem(); |
| 145 | const engine = system.engine; | |
| 146 | const completedAt = new Date().toISOString(); | |
| 193 | const { engine } = system; | |
| 194 | const createdAt = now(); | |
| 195 | ||
| 196 | await system.run( | |
| 197 | engine.dispatch({ | |
| 198 | type: "project.create", | |
| 199 | commandId: "cmd-project-turn-diff-create", | |
| 200 | projectId: "project-turn-diff", | |
| 201 | title: "Turn Diff Project", | |
| 202 | workspaceRoot: "/tmp/project-turn-diff", | |
| 203 | defaultModel: "gpt-5-codex", | |
| 204 | createdAt, | |
| 205 | }), | |
| 206 | ); | |
| 147 | 207 | await system.run( |
| 148 | 208 | engine.dispatch({ |
| 149 | 209 | type: "thread.create", |
| 150 | commandId: "cmd-thread-turn-diff", | |
| 210 | commandId: "cmd-thread-turn-diff-create", | |
| 151 | 211 | threadId: "thread-turn-diff", |
| 152 | 212 | projectId: "project-turn-diff", |
| 153 | 213 | title: "Turn diff thread", |
| 154 | 214 | model: "gpt-5-codex", |
| 155 | 215 | branch: null, |
| 156 | 216 | worktreePath: null, |
| 157 | createdAt: completedAt, | |
| 217 | createdAt, | |
| 158 | 218 | }), |
| 159 | 219 | ); |
| 160 | 220 | await system.run( |
| 161 | 221 | engine.dispatch({ |
| 162 | type: "thread.turnDiff.complete", | |
| 222 | type: "thread.turn.diff.complete", | |
| 163 | 223 | commandId: "cmd-turn-diff-complete", |
| 164 | 224 | threadId: "thread-turn-diff", |
| 165 | 225 | turnId: "turn-1", |
| 166 | completedAt, | |
| 167 | status: "completed", | |
| 226 | completedAt: createdAt, | |
| 227 | checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", | |
| 228 | status: "ready", | |
| 168 | 229 | files: [], |
| 169 | 230 | checkpointTurnCount: 1, |
| 170 | createdAt: completedAt, | |
| 231 | createdAt, | |
| 171 | 232 | }), |
| 172 | 233 | ); |
| 173 | 234 | |
| 174 | const firstThread = (await system.run(engine.getReadModel())).threads.find( | |
| 175 | (thread) => thread.id === "thread-turn-diff", | |
| 235 | const thread = (await system.run(engine.getReadModel())).threads.find( | |
| 236 | (entry) => entry.id === "thread-turn-diff", | |
| 176 | 237 | ); |
| 177 | expect(firstThread?.turnDiffSummaries).toEqual([ | |
| 238 | expect(thread?.checkpoints).toEqual([ | |
| 178 | 239 | { |
| 179 | 240 | turnId: "turn-1", |
| 180 | completedAt, | |
| 181 | status: "completed", | |
| 182 | files: [], | |
| 183 | 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 | 249 | await system.dispose(); |
| 187 | 250 | }); |
| 188 | 251 | |
| 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 | 252 | it("keeps processing queued commands after a storage failure", async () => { |
| 328 | 253 | const events: OrchestrationEvent[] = []; |
| 329 | 254 | let nextSequence = 1; |
| ... | ... | @@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => { |
| 331 | 256 | |
| 332 | 257 | const flakyStore: OrchestrationEventStoreShape = { |
| 333 | 258 | append(event) { |
| 334 | if (shouldFailFirstAppend) { | |
| 259 | if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") { | |
| 335 | 260 | shouldFailFirstAppend = false; |
| 336 | 261 | return Effect.fail( |
| 337 | 262 | new PersistenceSqlError({ |
| ... | ... | @@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => { |
| 358 | 283 | |
| 359 | 284 | const runtime = ManagedRuntime.make( |
| 360 | 285 | OrchestrationEngineLive.pipe( |
| 286 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 361 | 287 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 288 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 289 | Layer.provide(SqlitePersistenceMemory), | |
| 362 | 290 | ), |
| 363 | 291 | ); |
| 364 | 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 | ); | |
| 366 | 306 | |
| 367 | 307 | await expect( |
| 368 | 308 | runtime.runPromise( |
| ... | ... | @@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => { |
| 394 | 334 | }), |
| 395 | 335 | ); |
| 396 | 336 | |
| 397 | expect(result.sequence).toBe(1); | |
| 398 | expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1); | |
| 399 | ||
| 337 | expect(result.sequence).toBe(2); | |
| 338 | expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); | |
| 400 | 339 | await runtime.dispose(); |
| 401 | 340 | }); |
| 402 | 341 | |
| 403 | 342 | it("fails command dispatch when command invariants are violated", async () => { |
| 404 | 343 | const system = await createOrchestrationSystem(); |
| 405 | const engine = system.engine; | |
| 406 | const createdAt = new Date().toISOString(); | |
| 344 | const { engine } = system; | |
| 407 | 345 | |
| 408 | 346 | await expect( |
| 409 | 347 | system.run( |
| 410 | 348 | engine.dispatch({ |
| 411 | type: "message.send", | |
| 349 | type: "thread.turn.start", | |
| 412 | 350 | commandId: "cmd-invariant-missing-thread", |
| 413 | 351 | threadId: "thread-missing", |
| 414 | messageId: "msg-missing", | |
| 415 | role: "user", | |
| 416 | text: "hello", | |
| 417 | streaming: false, | |
| 418 | createdAt, | |
| 352 | message: { | |
| 353 | messageId: "msg-missing", | |
| 354 | role: "user", | |
| 355 | text: "hello", | |
| 356 | attachments: [], | |
| 357 | }, | |
| 358 | createdAt: now(), | |
| 419 | 359 | }), |
| 420 | 360 | ), |
| 421 | 361 | ).rejects.toThrow("Thread 'thread-missing' does not exist"); |
| ... | ... | @@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => { |
| 425 | 365 | |
| 426 | 366 | it("rejects duplicate thread creation", async () => { |
| 427 | 367 | const system = await createOrchestrationSystem(); |
| 428 | const engine = system.engine; | |
| 429 | const createdAt = new Date().toISOString(); | |
| 368 | const { engine } = system; | |
| 369 | const createdAt = now(); | |
| 370 | ||
| 371 | await system.run( | |
| 372 | engine.dispatch({ | |
| 373 | type: "project.create", | |
| 374 | commandId: "cmd-project-duplicate-create", | |
| 375 | projectId: "project-duplicate", | |
| 376 | title: "Duplicate Project", | |
| 377 | workspaceRoot: "/tmp/project-duplicate", | |
| 378 | defaultModel: "gpt-5-codex", | |
| 379 | createdAt, | |
| 380 | }), | |
| 381 | ); | |
| 430 | 382 | |
| 431 | 383 | await system.run( |
| 432 | 384 | engine.dispatch({ |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts created+610| ... | ... | @@ -0,0 +1,610 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { execFileSync } from "node:child_process"; | |
| 5 | ||
| 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | CheckpointRef, | |
| 9 | CommandId, | |
| 10 | EventId, | |
| 11 | MessageId, | |
| 12 | ProjectId, | |
| 13 | ProviderSessionId, | |
| 14 | ProviderThreadId, | |
| 15 | ThreadId, | |
| 16 | TurnId, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { NodeServices } from "@effect/platform-node"; | |
| 19 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | |
| 20 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 21 | ||
| 22 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; | |
| 23 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 24 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | |
| 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 27 | import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts"; | |
| 28 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 29 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 30 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 31 | import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts"; | |
| 32 | import { | |
| 33 | OrchestrationEngineService, | |
| 34 | type OrchestrationEngineShape, | |
| 35 | } from "../Services/OrchestrationEngine.ts"; | |
| 36 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 37 | import { | |
| 38 | ProviderService, | |
| 39 | type ProviderServiceShape, | |
| 40 | } from "../../provider/Services/ProviderService.ts"; | |
| 41 | ||
| 42 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 43 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 44 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 45 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 46 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 47 | ||
| 48 | function 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 | ||
| 53 | function 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 | ||
| 97 | async 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 | ||
| 126 | async 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 | ||
| 148 | async 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 | ||
| 168 | function runGit(cwd: string, args: ReadonlyArray<string>) { | |
| 169 | return execFileSync("git", args, { | |
| 170 | cwd, | |
| 171 | stdio: ["ignore", "pipe", "pipe"], | |
| 172 | encoding: "utf8", | |
| 173 | }); | |
| 174 | } | |
| 175 | ||
| 176 | function 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 | ||
| 187 | function 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 | ||
| 196 | function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { | |
| 197 | return runGit(cwd, ["show", `${ref}:${filePath}`]); | |
| 198 | } | |
| 199 | ||
| 200 | async 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 | ||
| 215 | describe("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 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | CommandId, | |
| 4 | EventId, | |
| 5 | MessageId, | |
| 6 | ProviderSessionId, | |
| 7 | ThreadId, | |
| 8 | TurnId, | |
| 9 | type OrchestrationEvent, | |
| 10 | type ProviderRuntimeEvent, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { Effect, Layer, Option, Queue, Stream } from "effect"; | |
| 13 | ||
| 14 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | |
| 15 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | |
| 16 | import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts"; | |
| 17 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 18 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; | |
| 19 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 20 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | |
| 21 | import { OrchestrationDispatchError } from "../Errors.ts"; | |
| 22 | ||
| 23 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| 24 | ||
| 25 | type ReactorInput = | |
| 26 | | { | |
| 27 | readonly source: "runtime"; | |
| 28 | readonly event: ProviderRuntimeEvent; | |
| 29 | } | |
| 30 | | { | |
| 31 | readonly source: "domain"; | |
| 32 | readonly event: OrchestrationEvent; | |
| 33 | }; | |
| 34 | ||
| 35 | function 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 | ||
| 40 | function toTurnId(value: string | undefined): TurnId | null { | |
| 41 | const normalized = value?.trim(); | |
| 42 | return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; | |
| 43 | } | |
| 44 | ||
| 45 | function trimToNonEmpty(value: string | null | undefined): string | undefined { | |
| 46 | const normalized = value?.trim(); | |
| 47 | return normalized && normalized.length > 0 ? normalized : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | function 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 | ||
| 63 | const serverCommandId = (tag: string): CommandId => | |
| 64 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 65 | ||
| 66 | const 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 | ||
| 745 | export const CheckpointReactorLive = Layer.effect(CheckpointReactor, make); |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+117-17| ... | ... | @@ -1,20 +1,24 @@ |
| 1 | 1 | import type { |
| 2 | OrchestrationCommand, | |
| 3 | 2 | OrchestrationEvent, |
| 4 | 3 | OrchestrationReadModel, |
| 4 | ProjectId, | |
| 5 | ThreadId, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; | |
| 7 | import { OrchestrationCommand } from "@t3tools/contracts"; | |
| 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; | |
| 8 | 9 | |
| 9 | import { createLogger } from "../../logger.ts"; | |
| 10 | 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 11 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | toOrchestrationCommandDecodeError, |
| 13 | 14 | toOrchestrationJsonParseError, |
| 15 | OrchestrationCommandInvariantError, | |
| 16 | OrchestrationCommandPreviouslyRejectedError, | |
| 14 | 17 | type OrchestrationDispatchError, |
| 15 | 18 | } from "../Errors.ts"; |
| 16 | 19 | import { decideOrchestrationCommand } from "../decider.ts"; |
| 17 | 20 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; |
| 21 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | |
| 18 | 22 | import { |
| 19 | 23 | OrchestrationEngineService, |
| 20 | 24 | type OrchestrationEngineShape, |
| ... | ... | @@ -25,11 +29,39 @@ interface CommandEnvelope { |
| 25 | 29 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema); | |
| 32 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | |
| 33 | ||
| 34 | function 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 | ||
| 54 | function formatDispatchError(error: OrchestrationDispatchError): string { | |
| 55 | if (error instanceof Error && error.message.length > 0) { | |
| 56 | return error.message; | |
| 57 | } | |
| 58 | return String(error); | |
| 59 | } | |
| 29 | 60 | |
| 30 | 61 | const makeOrchestrationEngine = Effect.gen(function* () { |
| 31 | const logger = createLogger("orchestration"); | |
| 32 | 62 | const eventStore = yield* OrchestrationEventStore; |
| 63 | const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; | |
| 64 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 33 | 65 | |
| 34 | 66 | let readModel = createEmptyReadModel(new Date().toISOString()); |
| 35 | 67 | |
| ... | ... | @@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 38 | 70 | |
| 39 | 71 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => |
| 40 | 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 | 93 | const eventBase = yield* decideOrchestrationCommand({ |
| 42 | 94 | command: envelope.command, |
| 43 | 95 | readModel, |
| 44 | 96 | }); |
| 45 | const savedEvent = yield* eventStore.append(eventBase); | |
| 46 | readModel = yield* projectEvent(readModel, savedEvent); | |
| 47 | yield* PubSub.publish(eventPubSub, savedEvent); | |
| 48 | ||
| 49 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | |
| 50 | }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); | |
| 51 | ||
| 52 | // bootstrap read model from event store | |
| 97 | const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; | |
| 98 | let lastSavedEvent = null as OrchestrationEvent | null; | |
| 99 | ||
| 100 | for (const nextEvent of eventBases) { | |
| 101 | const savedEvent = yield* eventStore.append(nextEvent); | |
| 102 | yield* projectionPipeline.projectEvent(savedEvent); | |
| 103 | readModel = yield* projectEvent(readModel, savedEvent); | |
| 104 | yield* PubSub.publish(eventPubSub, savedEvent); | |
| 105 | lastSavedEvent = savedEvent; | |
| 106 | } | |
| 107 | ||
| 108 | if (lastSavedEvent === null) { | |
| 109 | return yield* Deferred.fail( | |
| 110 | envelope.result, | |
| 111 | new OrchestrationCommandInvariantError({ | |
| 112 | commandType: envelope.command.type, | |
| 113 | detail: "Command produced no events.", | |
| 114 | }), | |
| 115 | ); | |
| 116 | } | |
| 117 | ||
| 118 | yield* commandReceiptRepository.upsert({ | |
| 119 | commandId: envelope.command.commandId, | |
| 120 | aggregateKind: lastSavedEvent.aggregateKind, | |
| 121 | aggregateId: lastSavedEvent.aggregateId, | |
| 122 | acceptedAt: lastSavedEvent.occurredAt, | |
| 123 | resultSequence: lastSavedEvent.sequence, | |
| 124 | status: "accepted", | |
| 125 | error: null, | |
| 126 | }); | |
| 127 | yield* Deferred.succeed(envelope.result, { sequence: lastSavedEvent.sequence }); | |
| 128 | }).pipe( | |
| 129 | Effect.catch((error) => | |
| 130 | Effect.gen(function* () { | |
| 131 | if (error instanceof OrchestrationCommandInvariantError) { | |
| 132 | const aggregateRef = commandToAggregateRef(envelope.command); | |
| 133 | yield* commandReceiptRepository | |
| 134 | .upsert({ | |
| 135 | commandId: envelope.command.commandId, | |
| 136 | aggregateKind: aggregateRef.aggregateKind, | |
| 137 | aggregateId: aggregateRef.aggregateId, | |
| 138 | acceptedAt: new Date().toISOString(), | |
| 139 | resultSequence: readModel.snapshotSequence, | |
| 140 | status: "rejected", | |
| 141 | error: formatDispatchError(error), | |
| 142 | }) | |
| 143 | .pipe(Effect.catch(() => Effect.void)); | |
| 144 | } | |
| 145 | yield* Deferred.fail(envelope.result, error); | |
| 146 | }), | |
| 147 | ), | |
| 148 | ); | |
| 149 | ||
| 150 | yield* projectionPipeline.bootstrap; | |
| 151 | ||
| 152 | // bootstrap in-memory read model from event store | |
| 53 | 153 | yield* Stream.runForEach(eventStore.readAll(), (event) => |
| 54 | 154 | Effect.gen(function* () { |
| 55 | 155 | readModel = yield* projectEvent(readModel, event); |
| ... | ... | @@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 58 | 158 | |
| 59 | 159 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); |
| 60 | 160 | yield* Effect.forkScoped(worker); |
| 61 | logger.info("orchestration engine started", { | |
| 62 | sequence: readModel.sequence, | |
| 63 | }); | |
| 161 | yield* Effect.log("orchestration engine started").pipe( | |
| 162 | Effect.annotateLogs({ sequence: readModel.snapshotSequence }), | |
| 163 | ); | |
| 64 | 164 | |
| 65 | 165 | const getReadModel: OrchestrationEngineShape["getReadModel"] = () => |
| 66 | 166 | Effect.sync((): OrchestrationReadModel => readModel); |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; | |
| 2 | import { afterEach, describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 5 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 6 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 7 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | |
| 8 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | |
| 9 | ||
| 10 | describe("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 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | OrchestrationReactor, | |
| 5 | type OrchestrationReactorShape, | |
| 6 | } from "../Services/OrchestrationReactor.ts"; | |
| 7 | import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | |
| 8 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 9 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | |
| 10 | ||
| 11 | export 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 | ||
| 27 | export const OrchestrationReactorLive = Layer.effect(OrchestrationReactor, makeOrchestrationReactor); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts created+367| ... | ... | @@ -0,0 +1,367 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | CorrelationId, | |
| 4 | EventId, | |
| 5 | MessageId, | |
| 6 | ProjectId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { assert, it } from "@effect/vitest"; | |
| 10 | import { Effect, Layer } from "effect"; | |
| 11 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 12 | ||
| 13 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 14 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 15 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 16 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 17 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 18 | import { | |
| 19 | ORCHESTRATION_PROJECTOR_NAMES, | |
| 20 | OrchestrationProjectionPipelineLive, | |
| 21 | } from "./ProjectionPipeline.ts"; | |
| 22 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 23 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | |
| 24 | ||
| 25 | const projectionLayer = it.layer( | |
| 26 | OrchestrationProjectionPipelineLive.pipe( | |
| 27 | Layer.provideMerge(OrchestrationEventStoreLive), | |
| 28 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 29 | ), | |
| 30 | ); | |
| 31 | ||
| 32 | projectionLayer("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 | ||
| 274 | const 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 | ||
| 283 | engineLayer("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 @@ |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | type OrchestrationEvent, | |
| 4 | } from "@t3tools/contracts"; | |
| 5 | import { Effect, Layer, Option, Stream } from "effect"; | |
| 6 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 7 | ||
| 8 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 9 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 10 | import { ProjectionCheckpointRepository } from "../../persistence/Services/ProjectionCheckpoints.ts"; | |
| 11 | import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; | |
| 12 | import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; | |
| 13 | import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; | |
| 14 | import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; | |
| 15 | import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; | |
| 16 | import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; | |
| 17 | import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts"; | |
| 18 | import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts"; | |
| 19 | import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; | |
| 20 | import { ProjectionCheckpointRepositoryLive } from "../../persistence/Layers/ProjectionCheckpoints.ts"; | |
| 21 | import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; | |
| 22 | import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; | |
| 23 | import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; | |
| 24 | import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; | |
| 25 | import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; | |
| 26 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; | |
| 27 | import { ProjectionThreadTurnRepositoryLive } from "../../persistence/Layers/ProjectionThreadTurns.ts"; | |
| 28 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; | |
| 29 | import { | |
| 30 | OrchestrationProjectionPipeline, | |
| 31 | type OrchestrationProjectionPipelineShape, | |
| 32 | } from "../Services/ProjectionPipeline.ts"; | |
| 33 | ||
| 34 | export 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 | ||
| 45 | type ProjectorName = | |
| 46 | (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; | |
| 47 | ||
| 48 | interface ProjectorDefinition { | |
| 49 | readonly name: ProjectorName; | |
| 50 | readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 51 | } | |
| 52 | ||
| 53 | function 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 | ||
| 61 | function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number { | |
| 62 | return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1; | |
| 63 | } | |
| 64 | ||
| 65 | const 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 | ||
| 708 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import { Effect, Layer } from "effect"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | ||
| 5 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 6 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | |
| 7 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; | |
| 8 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | |
| 9 | ||
| 10 | const projectionSnapshotLayer = it.layer( | |
| 11 | OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 12 | ); | |
| 13 | ||
| 14 | projectionSnapshotLayer("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 @@ |
| 1 | import { | |
| 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"; | |
| 12 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 13 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 14 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 15 | ||
| 16 | import { | |
| 17 | PersistenceDecodeError, | |
| 18 | PersistenceSerializationError, | |
| 19 | PersistenceSqlError, | |
| 20 | toPersistenceDecodeError, | |
| 21 | toPersistenceSqlError, | |
| 22 | type ProjectionRepositoryError, | |
| 23 | } from "../../persistence/Errors.ts"; | |
| 24 | import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; | |
| 25 | import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; | |
| 26 | import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; | |
| 27 | import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; | |
| 28 | import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; | |
| 29 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; | |
| 30 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; | |
| 31 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; | |
| 32 | import { | |
| 33 | ProjectionSnapshotQuery, | |
| 34 | type ProjectionSnapshotQueryShape, | |
| 35 | } from "../Services/ProjectionSnapshotQuery.ts"; | |
| 36 | ||
| 37 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); | |
| 38 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | |
| 39 | Struct.assign({ | |
| 40 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | |
| 41 | }), | |
| 42 | ); | |
| 43 | const ProjectionThreadDbRowSchema = ProjectionThread; | |
| 44 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 45 | Struct.assign({ | |
| 46 | isStreaming: Schema.Number, | |
| 47 | }), | |
| 48 | ); | |
| 49 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | |
| 50 | Struct.assign({ | |
| 51 | payload: Schema.fromJsonString(Schema.Unknown), | |
| 52 | }), | |
| 53 | ); | |
| 54 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; | |
| 55 | const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( | |
| 56 | Struct.assign({ | |
| 57 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 58 | }), | |
| 59 | ); | |
| 60 | const ProjectionStateDbRowSchema = ProjectionState; | |
| 61 | ||
| 62 | const REQUIRED_SNAPSHOT_PROJECTORS = Object.values(ORCHESTRATION_PROJECTOR_NAMES); | |
| 63 | ||
| 64 | function maxIso(left: string | null, right: string): string { | |
| 65 | if (left === null) { | |
| 66 | return right; | |
| 67 | } | |
| 68 | return left > right ? left : right; | |
| 69 | } | |
| 70 | ||
| 71 | function 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 | ||
| 95 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 96 | return (cause: unknown): ProjectionRepositoryError => | |
| 97 | Schema.isSchemaError(cause) | |
| 98 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 99 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 100 | } | |
| 101 | ||
| 102 | function 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 | ||
| 115 | const 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 | ||
| 432 | export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( | |
| 433 | ProjectionSnapshotQuery, | |
| 434 | makeProjectionSnapshotQuery, | |
| 435 | ); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts created+308| ... | ... | @@ -0,0 +1,308 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | ApprovalRequestId, | |
| 4 | CommandId, | |
| 5 | ProjectId, | |
| 6 | ProviderSessionId, | |
| 7 | ProviderThreadId, | |
| 8 | ProviderTurnId, | |
| 9 | ThreadId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | |
| 12 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 13 | ||
| 14 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | |
| 15 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 16 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 17 | import { | |
| 18 | ProviderService, | |
| 19 | type ProviderServiceShape, | |
| 20 | } from "../../provider/Services/ProviderService.ts"; | |
| 21 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | |
| 22 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 23 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; | |
| 24 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 25 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | |
| 26 | ||
| 27 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 28 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 29 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 31 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 32 | const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | |
| 33 | ||
| 34 | async 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 | ||
| 53 | describe("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 @@ |
| 1 | import { | |
| 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"; | |
| 12 | import { Effect, Layer, Queue, Stream } from "effect"; | |
| 13 | ||
| 14 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | |
| 15 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | |
| 16 | import { | |
| 17 | ProviderCommandReactor, | |
| 18 | type ProviderCommandReactorShape, | |
| 19 | } from "../Services/ProviderCommandReactor.ts"; | |
| 20 | ||
| 21 | type 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 | ||
| 32 | function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined { | |
| 33 | const normalized = value?.trim(); | |
| 34 | return normalized && normalized.length > 0 ? normalized : undefined; | |
| 35 | } | |
| 36 | ||
| 37 | function 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 | ||
| 55 | const turnStartKeyForEvent = (event: ProviderIntentEvent): string => | |
| 56 | event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; | |
| 57 | ||
| 58 | const serverCommandId = (tag: string): CommandId => | |
| 59 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 60 | ||
| 61 | const 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 | ||
| 347 | export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+116-48| ... | ... | @@ -1,14 +1,26 @@ |
| 1 | 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | import { | |
| 3 | CommandId, | |
| 4 | EventId, | |
| 5 | ProjectId, | |
| 6 | ProviderItemId, | |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 9 | ProviderTurnId, | |
| 10 | ThreadId, | |
| 11 | } from "@t3tools/contracts"; | |
| 2 | 12 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 13 | import { afterEach, describe, expect, it } from "vitest"; | |
| 4 | 14 | |
| 5 | 15 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 16 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 6 | 17 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | 18 | import { |
| 8 | 19 | ProviderService, |
| 9 | 20 | type ProviderServiceShape, |
| 10 | 21 | } from "../../provider/Services/ProviderService.ts"; |
| 11 | 22 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | |
| 12 | 24 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; |
| 13 | 25 | import { |
| 14 | 26 | OrchestrationEngineService, |
| ... | ... | @@ -16,17 +28,16 @@ import { |
| 16 | 28 | } from "../Services/OrchestrationEngine.ts"; |
| 17 | 29 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 18 | 30 | |
| 31 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 32 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 33 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 34 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 35 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 36 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 37 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 38 | ||
| 19 | 39 | function createProviderServiceHarness() { |
| 20 | 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 | ); | |
| 30 | 41 | |
| 31 | 42 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 32 | 43 | const service: ProviderServiceShape = { |
| ... | ... | @@ -36,9 +47,7 @@ function createProviderServiceHarness() { |
| 36 | 47 | respondToRequest: () => unsupported(), |
| 37 | 48 | stopSession: () => unsupported(), |
| 38 | 49 | listSessions: () => Effect.succeed([]), |
| 39 | listCheckpoints: () => unsupported(), | |
| 40 | getCheckpointDiff, | |
| 41 | revertToCheckpoint: () => unsupported(), | |
| 50 | rollbackConversation: () => unsupported(), | |
| 42 | 51 | stopAll: () => Effect.void, |
| 43 | 52 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 44 | 53 | }; |
| ... | ... | @@ -50,7 +59,6 @@ function createProviderServiceHarness() { |
| 50 | 59 | return { |
| 51 | 60 | service, |
| 52 | 61 | emit, |
| 53 | getCheckpointDiff, | |
| 54 | 62 | }; |
| 55 | 63 | } |
| 56 | 64 | |
| ... | ... | @@ -59,6 +67,7 @@ async function waitForThread( |
| 59 | 67 | predicate: (thread: { |
| 60 | 68 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; |
| 61 | 69 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; |
| 70 | activities: ReadonlyArray<{ kind: string }>; | |
| 62 | 71 | }) => boolean, |
| 63 | 72 | timeoutMs = 2000, |
| 64 | 73 | ) { |
| ... | ... | @@ -66,9 +75,10 @@ async function waitForThread( |
| 66 | 75 | const poll = async (): Promise<{ |
| 67 | 76 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; |
| 68 | 77 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; |
| 78 | activities: ReadonlyArray<{ kind: string }>; | |
| 69 | 79 | }> => { |
| 70 | 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 | 82 | if (thread && predicate(thread)) { |
| 73 | 83 | return thread; |
| 74 | 84 | } |
| ... | ... | @@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 102 | 112 | async function createHarness() { |
| 103 | 113 | const provider = createProviderServiceHarness(); |
| 104 | 114 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 115 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 105 | 116 | Layer.provide(OrchestrationEventStoreLive), |
| 117 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 106 | 118 | Layer.provide(SqlitePersistenceMemory), |
| 107 | 119 | ); |
| 108 | 120 | const layer = ProviderRuntimeIngestionLive.pipe( |
| ... | ... | @@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => { |
| 117 | 129 | await Effect.runPromise(Effect.sleep("10 millis")); |
| 118 | 130 | |
| 119 | 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 | 143 | await Effect.runPromise( |
| 121 | 144 | engine.dispatch({ |
| 122 | 145 | type: "thread.create", |
| 123 | commandId: "cmd-thread-create", | |
| 124 | threadId: "thread-1", | |
| 125 | projectId: "project-1", | |
| 146 | commandId: asCommandId("cmd-thread-create"), | |
| 147 | threadId: asThreadId("thread-1"), | |
| 148 | projectId: asProjectId("project-1"), | |
| 126 | 149 | title: "Thread", |
| 127 | 150 | model: "gpt-5-codex", |
| 128 | 151 | branch: null, |
| ... | ... | @@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => { |
| 132 | 155 | ); |
| 133 | 156 | await Effect.runPromise( |
| 134 | 157 | engine.dispatch({ |
| 135 | type: "thread.session", | |
| 136 | commandId: "cmd-session-seed", | |
| 137 | threadId: "thread-1", | |
| 158 | type: "thread.session.set", | |
| 159 | commandId: asCommandId("cmd-session-seed"), | |
| 160 | threadId: asThreadId("thread-1"), | |
| 138 | 161 | session: { |
| 139 | sessionId: "sess-1", | |
| 140 | provider: "codex", | |
| 162 | threadId: asThreadId("thread-1"), | |
| 141 | 163 | status: "ready", |
| 142 | threadId: "thread-1", | |
| 164 | providerName: "codex", | |
| 165 | providerSessionId: asSessionId("sess-1"), | |
| 166 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 143 | 167 | activeTurnId: null, |
| 144 | createdAt, | |
| 145 | 168 | updatedAt: createdAt, |
| 146 | 169 | lastError: null, |
| 147 | 170 | }, |
| ... | ... | @@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 161 | 184 | |
| 162 | 185 | harness.emit({ |
| 163 | 186 | type: "turn.started", |
| 164 | eventId: "evt-turn-started", | |
| 187 | eventId: asEventId("evt-turn-started"), | |
| 165 | 188 | provider: "codex", |
| 166 | sessionId: "sess-1", | |
| 189 | sessionId: asSessionId("sess-1"), | |
| 167 | 190 | createdAt: now, |
| 168 | turnId: "turn-1", | |
| 191 | turnId: asProviderTurnId("turn-1"), | |
| 169 | 192 | }); |
| 170 | 193 | |
| 171 | 194 | await waitForThread( |
| ... | ... | @@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 175 | 198 | |
| 176 | 199 | harness.emit({ |
| 177 | 200 | type: "turn.completed", |
| 178 | eventId: "evt-turn-completed", | |
| 201 | eventId: asEventId("evt-turn-completed"), | |
| 179 | 202 | provider: "codex", |
| 180 | sessionId: "sess-1", | |
| 203 | sessionId: asSessionId("sess-1"), | |
| 181 | 204 | createdAt: new Date().toISOString(), |
| 182 | turnId: "turn-1", | |
| 205 | turnId: asProviderTurnId("turn-1"), | |
| 183 | 206 | status: "failed", |
| 184 | 207 | errorMessage: "turn failed", |
| 185 | 208 | }); |
| ... | ... | @@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 187 | 210 | const thread = await waitForThread( |
| 188 | 211 | harness.engine, |
| 189 | 212 | (entry) => |
| 190 | entry.session?.status === "ready" && | |
| 213 | entry.session?.status === "error" && | |
| 191 | 214 | entry.session?.activeTurnId === null && |
| 192 | 215 | entry.session?.lastError === "turn failed", |
| 193 | 216 | ); |
| 194 | expect(thread.session?.status).toBe("ready"); | |
| 217 | expect(thread.session?.status).toBe("error"); | |
| 195 | 218 | expect(thread.session?.lastError).toBe("turn failed"); |
| 196 | 219 | }); |
| 197 | 220 | |
| ... | ... | @@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => { |
| 201 | 224 | |
| 202 | 225 | harness.emit({ |
| 203 | 226 | type: "message.delta", |
| 204 | eventId: "evt-message-delta-1", | |
| 227 | eventId: asEventId("evt-message-delta-1"), | |
| 205 | 228 | provider: "codex", |
| 206 | sessionId: "sess-1", | |
| 229 | sessionId: asSessionId("sess-1"), | |
| 207 | 230 | createdAt: now, |
| 208 | turnId: "turn-2", | |
| 209 | itemId: "item-1", | |
| 231 | turnId: asProviderTurnId("turn-2"), | |
| 232 | itemId: asItemId("item-1"), | |
| 210 | 233 | delta: "hello", |
| 211 | 234 | }); |
| 212 | 235 | harness.emit({ |
| 213 | 236 | type: "message.delta", |
| 214 | eventId: "evt-message-delta-2", | |
| 237 | eventId: asEventId("evt-message-delta-2"), | |
| 215 | 238 | provider: "codex", |
| 216 | sessionId: "sess-1", | |
| 239 | sessionId: asSessionId("sess-1"), | |
| 217 | 240 | createdAt: now, |
| 218 | turnId: "turn-2", | |
| 219 | itemId: "item-1", | |
| 241 | turnId: asProviderTurnId("turn-2"), | |
| 242 | itemId: asItemId("item-1"), | |
| 220 | 243 | delta: " world", |
| 221 | 244 | }); |
| 222 | 245 | harness.emit({ |
| 223 | 246 | type: "message.completed", |
| 224 | eventId: "evt-message-completed", | |
| 247 | eventId: asEventId("evt-message-completed"), | |
| 225 | 248 | provider: "codex", |
| 226 | sessionId: "sess-1", | |
| 249 | sessionId: asSessionId("sess-1"), | |
| 227 | 250 | createdAt: now, |
| 228 | turnId: "turn-2", | |
| 229 | itemId: "item-1", | |
| 251 | turnId: asProviderTurnId("turn-2"), | |
| 252 | itemId: asItemId("item-1"), | |
| 230 | 253 | }); |
| 231 | 254 | |
| 232 | 255 | const thread = await waitForThread(harness.engine, (entry) => |
| ... | ... | @@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 243 | 266 | |
| 244 | 267 | harness.emit({ |
| 245 | 268 | type: "runtime.error", |
| 246 | eventId: "evt-runtime-error", | |
| 269 | eventId: asEventId("evt-runtime-error"), | |
| 247 | 270 | provider: "codex", |
| 248 | sessionId: "sess-1", | |
| 271 | sessionId: asSessionId("sess-1"), | |
| 249 | 272 | createdAt: now, |
| 250 | turnId: "turn-3", | |
| 273 | turnId: asProviderTurnId("turn-3"), | |
| 251 | 274 | message: "runtime exploded", |
| 252 | 275 | }); |
| 253 | 276 | |
| ... | ... | @@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => { |
| 261 | 284 | expect(thread.session?.status).toBe("error"); |
| 262 | 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 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | MessageId, | |
| 4 | ProviderThreadId, | |
| 5 | TurnId, | |
| 6 | type OrchestrationThreadActivity, | |
| 7 | type ProviderRuntimeEvent, | |
| 8 | type ProviderSessionId, | |
| 9 | } from "@t3tools/contracts"; | |
| 2 | 10 | import { Effect, Layer, Queue, Stream } from "effect"; |
| 3 | 11 | |
| 4 | import { | |
| 5 | parseTurnDiffFilesFromUnifiedDiff, | |
| 6 | type TurnDiffFileSummary, | |
| 7 | } from "../../checkpointing/Diffs.ts"; | |
| 8 | import { createLogger } from "../../logger.ts"; | |
| 9 | 12 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 10 | 13 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 11 | 14 | import { |
| ... | ... | @@ -13,87 +16,108 @@ import { |
| 13 | 16 | type ProviderRuntimeIngestionShape, |
| 14 | 17 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 15 | 18 | |
| 16 | const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`; | |
| 17 | ||
| 18 | type ActivityTone = "thinking" | "tool" | "info" | "error"; | |
| 19 | const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; | |
| 20 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => | |
| 21 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); | |
| 19 | 22 | |
| 20 | interface ThreadActivityInput { | |
| 21 | readonly id: string; | |
| 22 | readonly createdAt: string; | |
| 23 | readonly label: string; | |
| 24 | readonly detail?: string; | |
| 25 | readonly tone: ActivityTone; | |
| 26 | readonly turnId?: string; | |
| 27 | readonly requestId?: string; | |
| 28 | readonly requestKind?: "command" | "file-change"; | |
| 23 | function toTurnId(value: string | undefined): TurnId | undefined { | |
| 24 | const normalized = value?.trim(); | |
| 25 | return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined; | |
| 29 | 26 | } |
| 30 | 27 | |
| 31 | 28 | function truncateDetail(value: string, limit = 180): string { |
| 32 | 29 | return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; |
| 33 | 30 | } |
| 34 | 31 | |
| 35 | function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<ThreadActivityInput> { | |
| 32 | function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<OrchestrationThreadActivity> { | |
| 36 | 33 | switch (event.type) { |
| 37 | case "approval.requested": | |
| 34 | case "approval.requested": { | |
| 38 | 35 | return [ |
| 39 | 36 | { |
| 40 | 37 | id: event.eventId, |
| 41 | 38 | createdAt: event.createdAt, |
| 42 | label: | |
| 39 | tone: "approval", | |
| 40 | kind: "approval.requested", | |
| 41 | summary: | |
| 43 | 42 | event.requestKind === "command" |
| 44 | 43 | ? "Command approval requested" |
| 45 | 44 | : "File-change approval requested", |
| 46 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 47 | tone: "tool", | |
| 48 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 49 | requestId: event.requestId, | |
| 50 | requestKind: event.requestKind, | |
| 45 | payload: { | |
| 46 | requestId: event.requestId, | |
| 47 | requestKind: event.requestKind, | |
| 48 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 49 | }, | |
| 50 | turnId: toTurnId(event.turnId) ?? null, | |
| 51 | 51 | }, |
| 52 | 52 | ]; |
| 53 | case "approval.resolved": | |
| 53 | } | |
| 54 | ||
| 55 | case "approval.resolved": { | |
| 54 | 56 | return [ |
| 55 | 57 | { |
| 56 | 58 | id: event.eventId, |
| 57 | 59 | createdAt: event.createdAt, |
| 58 | label: "Approval resolved", | |
| 59 | tone: "info", | |
| 60 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 61 | requestId: event.requestId, | |
| 62 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | |
| 60 | tone: "approval", | |
| 61 | kind: "approval.resolved", | |
| 62 | summary: "Approval resolved", | |
| 63 | payload: { | |
| 64 | requestId: event.requestId, | |
| 65 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | |
| 66 | ...(event.decision ? { decision: event.decision } : {}), | |
| 67 | }, | |
| 68 | turnId: toTurnId(event.turnId) ?? null, | |
| 63 | 69 | }, |
| 64 | 70 | ]; |
| 65 | case "runtime.error": | |
| 71 | } | |
| 72 | ||
| 73 | case "runtime.error": { | |
| 66 | 74 | return [ |
| 67 | 75 | { |
| 68 | 76 | id: event.eventId, |
| 69 | 77 | createdAt: event.createdAt, |
| 70 | label: "Runtime error", | |
| 71 | detail: truncateDetail(event.message), | |
| 72 | 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 | 89 | return [ |
| 78 | 90 | { |
| 79 | 91 | id: event.eventId, |
| 80 | 92 | createdAt: event.createdAt, |
| 81 | label: "Checkpoint captured", | |
| 82 | tone: "info", | |
| 83 | ...(event.turnId ? { turnId: event.turnId } : {}), | |
| 93 | tone: "tool", | |
| 94 | kind: "tool.completed", | |
| 95 | summary: `${event.title} complete`, | |
| 96 | payload: { | |
| 97 | toolKind: event.toolKind, | |
| 98 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 99 | }, | |
| 100 | turnId: toTurnId(event.turnId) ?? null, | |
| 84 | 101 | }, |
| 85 | 102 | ]; |
| 86 | case "tool.completed": | |
| 103 | } | |
| 104 | case "tool.started": { | |
| 87 | 105 | return [ |
| 88 | 106 | { |
| 89 | 107 | id: event.eventId, |
| 90 | 108 | createdAt: event.createdAt, |
| 91 | label: `${event.title} complete`, | |
| 92 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | |
| 93 | 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 | 121 | default: |
| 98 | 122 | return []; |
| 99 | 123 | } |
| ... | ... | @@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th |
| 102 | 126 | const make = Effect.gen(function* () { |
| 103 | 127 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 104 | 128 | const providerService = yield* ProviderService; |
| 105 | const logger = createLogger("provider-runtime-ingestion"); | |
| 106 | 129 | |
| 107 | const turnMessageIdsByTurnKey = new Map<string, Set<string>>(); | |
| 108 | const latestMessageIdByTurnKey = new Map<string, string>(); | |
| 130 | const turnMessageIdsByTurnKey = new Map<string, Set<MessageId>>(); | |
| 131 | const latestMessageIdByTurnKey = new Map<string, MessageId>(); | |
| 109 | 132 | |
| 110 | const rememberAssistantMessageId = (sessionId: string, turnId: string, messageId: string) => { | |
| 133 | const rememberAssistantMessageId = ( | |
| 134 | sessionId: ProviderSessionId, | |
| 135 | turnId: TurnId, | |
| 136 | messageId: MessageId, | |
| 137 | ) => { | |
| 111 | 138 | const key = providerTurnKey(sessionId, turnId); |
| 112 | 139 | const existingIds = turnMessageIdsByTurnKey.get(key); |
| 113 | 140 | if (existingIds) { |
| ... | ... | @@ -118,19 +145,15 @@ const make = Effect.gen(function* () { |
| 118 | 145 | latestMessageIdByTurnKey.set(key, messageId); |
| 119 | 146 | }; |
| 120 | 147 | |
| 121 | const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { | |
| 122 | return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<string>(); | |
| 148 | const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { | |
| 149 | return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<MessageId>(); | |
| 123 | 150 | }; |
| 124 | 151 | |
| 125 | const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { | |
| 152 | const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { | |
| 126 | 153 | turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId)); |
| 127 | 154 | }; |
| 128 | 155 | |
| 129 | const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => { | |
| 130 | return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId)); | |
| 131 | }; | |
| 132 | ||
| 133 | const clearTurnStateForSession = (sessionId: string) => { | |
| 156 | const clearTurnStateForSession = (sessionId: ProviderSessionId) => { | |
| 134 | 157 | const prefix = `${sessionId}:`; |
| 135 | 158 | for (const key of turnMessageIdsByTurnKey.keys()) { |
| 136 | 159 | if (key.startsWith(prefix)) { |
| ... | ... | @@ -148,144 +171,138 @@ const make = Effect.gen(function* () { |
| 148 | 171 | Effect.gen(function* () { |
| 149 | 172 | const readModel = yield* orchestrationEngine.getReadModel(); |
| 150 | 173 | const thread = readModel.threads.find( |
| 151 | (entry) => entry.session?.sessionId === event.sessionId, | |
| 174 | (entry) => entry.session?.providerSessionId === event.sessionId, | |
| 152 | 175 | ); |
| 153 | 176 | if (!thread) return; |
| 154 | 177 | |
| 155 | 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 | 210 | yield* orchestrationEngine.dispatch({ |
| 158 | type: "thread.session", | |
| 159 | commandId: crypto.randomUUID(), | |
| 211 | type: "thread.session.set", | |
| 212 | commandId: providerCommandId(event, "thread-session-set"), | |
| 160 | 213 | threadId: thread.id, |
| 161 | 214 | session: { |
| 162 | sessionId: event.sessionId, | |
| 163 | provider: event.provider, | |
| 164 | status: event.type === "turn.started" ? "running" : "ready", | |
| 165 | 215 | threadId: thread.id, |
| 166 | activeTurnId: event.type === "turn.started" ? event.turnId : null, | |
| 167 | createdAt: thread.session?.createdAt ?? now, | |
| 216 | status, | |
| 217 | providerName: event.provider, | |
| 218 | providerSessionId: event.sessionId, | |
| 219 | providerThreadId, | |
| 220 | activeTurnId, | |
| 221 | lastError, | |
| 168 | 222 | updatedAt: now, |
| 169 | lastError: | |
| 170 | event.type === "turn.completed" && event.status === "failed" | |
| 171 | ? (event.errorMessage ?? null) | |
| 172 | : null, | |
| 173 | 223 | }, |
| 174 | 224 | createdAt: now, |
| 175 | 225 | }); |
| 176 | 226 | } |
| 177 | 227 | |
| 178 | 228 | if (event.type === "message.delta" && event.delta.length > 0) { |
| 179 | const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`; | |
| 180 | if (event.turnId) { | |
| 181 | rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); | |
| 229 | const assistantMessageId = MessageId.makeUnsafe( | |
| 230 | `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, | |
| 231 | ); | |
| 232 | const turnId = toTurnId(event.turnId); | |
| 233 | if (turnId) { | |
| 234 | rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | |
| 182 | 235 | } |
| 236 | ||
| 183 | 237 | yield* orchestrationEngine.dispatch({ |
| 184 | type: "message.send", | |
| 185 | commandId: crypto.randomUUID(), | |
| 238 | type: "thread.message.assistant.delta", | |
| 239 | commandId: providerCommandId(event, "assistant-delta"), | |
| 186 | 240 | threadId: thread.id, |
| 187 | 241 | messageId: assistantMessageId, |
| 188 | role: "assistant", | |
| 189 | text: event.delta, | |
| 190 | streaming: true, | |
| 242 | delta: event.delta, | |
| 243 | ...(turnId ? { turnId } : {}), | |
| 191 | 244 | createdAt: now, |
| 192 | 245 | }); |
| 193 | 246 | } |
| 194 | 247 | |
| 195 | 248 | if (event.type === "message.completed") { |
| 196 | const assistantMessageId = `assistant:${event.itemId}`; | |
| 197 | if (event.turnId) { | |
| 198 | rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); | |
| 249 | const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); | |
| 250 | const turnId = toTurnId(event.turnId); | |
| 251 | if (turnId) { | |
| 252 | rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | |
| 199 | 253 | } |
| 254 | ||
| 200 | 255 | yield* orchestrationEngine.dispatch({ |
| 201 | type: "message.send", | |
| 202 | commandId: crypto.randomUUID(), | |
| 256 | type: "thread.message.assistant.complete", | |
| 257 | commandId: providerCommandId(event, "assistant-complete"), | |
| 203 | 258 | threadId: thread.id, |
| 204 | 259 | messageId: assistantMessageId, |
| 205 | role: "assistant", | |
| 206 | text: "", | |
| 207 | streaming: false, | |
| 260 | ...(turnId ? { turnId } : {}), | |
| 208 | 261 | createdAt: now, |
| 209 | 262 | }); |
| 210 | 263 | } |
| 211 | 264 | |
| 212 | if (event.type === "turn.completed" && event.turnId) { | |
| 213 | const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); | |
| 214 | yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => | |
| 215 | orchestrationEngine.dispatch({ | |
| 216 | type: "message.send", | |
| 217 | commandId: crypto.randomUUID(), | |
| 218 | threadId: thread.id, | |
| 219 | messageId: assistantMessageId, | |
| 220 | role: "assistant", | |
| 221 | text: "", | |
| 222 | streaming: false, | |
| 223 | createdAt: now, | |
| 224 | }), | |
| 225 | ).pipe(Effect.asVoid); | |
| 226 | clearAssistantMessageIdsForTurn(event.sessionId, event.turnId); | |
| 265 | if (event.type === "turn.completed") { | |
| 266 | const turnId = toTurnId(event.turnId); | |
| 267 | if (turnId) { | |
| 268 | const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, turnId); | |
| 269 | yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => | |
| 270 | orchestrationEngine.dispatch({ | |
| 271 | type: "thread.message.assistant.complete", | |
| 272 | commandId: providerCommandId(event, "assistant-complete-finalize"), | |
| 273 | threadId: thread.id, | |
| 274 | messageId: assistantMessageId, | |
| 275 | turnId, | |
| 276 | createdAt: now, | |
| 277 | }), | |
| 278 | ).pipe(Effect.asVoid); | |
| 279 | clearAssistantMessageIdsForTurn(event.sessionId, turnId); | |
| 280 | } | |
| 227 | 281 | } |
| 228 | 282 | |
| 229 | 283 | if (event.type === "session.exited") { |
| 230 | 284 | clearTurnStateForSession(event.sessionId); |
| 231 | 285 | } |
| 232 | 286 | |
| 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 | 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 | 293 | yield* orchestrationEngine.dispatch({ |
| 277 | type: "thread.session", | |
| 278 | commandId: crypto.randomUUID(), | |
| 294 | type: "thread.session.set", | |
| 295 | commandId: providerCommandId(event, "runtime-error-session-set"), | |
| 279 | 296 | threadId: thread.id, |
| 280 | 297 | session: { |
| 281 | sessionId: event.sessionId, | |
| 282 | provider: event.provider, | |
| 283 | status: "error", | |
| 284 | 298 | threadId: thread.id, |
| 285 | activeTurnId: event.turnId ?? null, | |
| 286 | createdAt: thread.session?.createdAt ?? now, | |
| 287 | updatedAt: now, | |
| 299 | status: "error", | |
| 300 | providerName: event.provider, | |
| 301 | providerSessionId: event.sessionId, | |
| 302 | providerThreadId, | |
| 303 | activeTurnId: toTurnId(event.turnId) ?? null, | |
| 288 | 304 | lastError: event.message, |
| 305 | updatedAt: now, | |
| 289 | 306 | }, |
| 290 | 307 | createdAt: now, |
| 291 | 308 | }); |
| ... | ... | @@ -295,7 +312,7 @@ const make = Effect.gen(function* () { |
| 295 | 312 | yield* Effect.forEach(activities, (activity) => |
| 296 | 313 | orchestrationEngine.dispatch({ |
| 297 | 314 | type: "thread.activity.append", |
| 298 | commandId: crypto.randomUUID(), | |
| 315 | commandId: providerCommandId(event, "thread-activity-append"), | |
| 299 | 316 | threadId: thread.id, |
| 300 | 317 | activity, |
| 301 | 318 | createdAt: activity.createdAt, |
apps/server/src/orchestration/Schemas.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { | |
| 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. | |
| 21 | export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; | |
| 22 | export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; | |
| 23 | export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; | |
| 24 | ||
| 25 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; | |
| 26 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; | |
| 27 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; | |
| 28 | ||
| 29 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | |
| 30 | export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; | |
| 31 | export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; | |
| 32 | export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; | |
| 33 | export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; | |
| 34 | ||
| 35 | export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; | |
| 36 | export const ThreadTurnInterruptRequestedPayload = ContractsThreadTurnInterruptRequestedPayloadSchema; | |
| 37 | export const ThreadApprovalResponseRequestedPayload = | |
| 38 | ContractsThreadApprovalResponseRequestedPayloadSchema; | |
| 39 | export const ThreadCheckpointRevertRequestedPayload = | |
| 40 | ContractsThreadCheckpointRevertRequestedPayloadSchema; | |
| 41 | export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopRequestedPayloadSchema; |
apps/server/src/orchestration/Services/CheckpointReactor.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | import type { Effect, Scope } from "effect"; | |
| 3 | ||
| 4 | export 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 | ||
| 14 | export 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 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | import type { Effect, Scope } from "effect"; | |
| 3 | ||
| 4 | export 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 | ||
| 14 | export 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 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { OrchestrationEventStoreError, ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 6 | ||
| 7 | export type OrchestrationProjectionPipelineError = | |
| 8 | | OrchestrationEventStoreError | |
| 9 | | ProjectionRepositoryError; | |
| 10 | ||
| 11 | export interface OrchestrationProjectionPipelineShape { | |
| 12 | readonly bootstrap: Effect.Effect<void, OrchestrationProjectionPipelineError>; | |
| 13 | ||
| 14 | readonly projectEvent: ( | |
| 15 | event: OrchestrationEvent, | |
| 16 | ) => Effect.Effect<void, ProjectionRepositoryError>; | |
| 17 | } | |
| 18 | ||
| 19 | export 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 @@ |
| 1 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | |
| 2 | import { ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; | |
| 6 | ||
| 7 | export interface ProjectionSnapshotQueryShape { | |
| 8 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>; | |
| 9 | } | |
| 10 | ||
| 11 | export 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 @@ |
| 1 | import { ServiceMap } from "effect"; | |
| 2 | import type { Effect, Scope } from "effect"; | |
| 3 | ||
| 4 | export 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 | ||
| 14 | export 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 | 13 | const now = new Date().toISOString(); |
| 14 | 14 | |
| 15 | 15 | const readModel: OrchestrationReadModel = { |
| 16 | sequence: 2, | |
| 16 | snapshotSequence: 2, | |
| 17 | 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 | 40 | threads: [ |
| 20 | 41 | { |
| 21 | 42 | id: "thread-1", |
| ... | ... | @@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = { |
| 27 | 48 | createdAt: now, |
| 28 | 49 | updatedAt: now, |
| 29 | 50 | latestTurnId: null, |
| 30 | latestTurnStartedAt: null, | |
| 31 | latestTurnCompletedAt: null, | |
| 32 | latestTurnDurationMs: null, | |
| 33 | 51 | messages: [], |
| 34 | 52 | session: null, |
| 35 | turnDiffSummaries: [], | |
| 36 | 53 | activities: [], |
| 37 | error: null, | |
| 54 | checkpoints: [], | |
| 38 | 55 | }, |
| 39 | 56 | { |
| 40 | 57 | id: "thread-2", |
| ... | ... | @@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = { |
| 46 | 63 | createdAt: now, |
| 47 | 64 | updatedAt: now, |
| 48 | 65 | latestTurnId: null, |
| 49 | latestTurnStartedAt: null, | |
| 50 | latestTurnCompletedAt: null, | |
| 51 | latestTurnDurationMs: null, | |
| 52 | 66 | messages: [], |
| 53 | 67 | session: null, |
| 54 | turnDiffSummaries: [], | |
| 55 | 68 | activities: [], |
| 56 | error: null, | |
| 69 | checkpoints: [], | |
| 57 | 70 | }, |
| 58 | 71 | ], |
| 59 | 72 | }; |
| 60 | 73 | |
| 61 | 74 | const messageSendCommand: OrchestrationCommand = { |
| 62 | type: "message.send", | |
| 75 | type: "thread.turn.start", | |
| 63 | 76 | commandId: "cmd-1", |
| 64 | 77 | threadId: "thread-1", |
| 65 | messageId: "msg-1", | |
| 66 | role: "user", | |
| 67 | text: "hello", | |
| 68 | streaming: false, | |
| 78 | message: { | |
| 79 | messageId: "msg-1", | |
| 80 | role: "user", | |
| 81 | text: "hello", | |
| 82 | attachments: [], | |
| 83 | }, | |
| 69 | 84 | createdAt: now, |
| 70 | 85 | }; |
| 71 | 86 |
apps/server/src/orchestration/commandInvariants.ts+56-5| ... | ... | @@ -1,4 +1,11 @@ |
| 1 | import type { OrchestrationCommand, OrchestrationReadModel, OrchestrationThread } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationProject, | |
| 4 | OrchestrationReadModel, | |
| 5 | OrchestrationThread, | |
| 6 | ProjectId, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | import { Effect } from "effect"; |
| 3 | 10 | |
| 4 | 11 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| ... | ... | @@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma |
| 12 | 19 | |
| 13 | 20 | export function findThreadById( |
| 14 | 21 | readModel: OrchestrationReadModel, |
| 15 | threadId: string, | |
| 22 | threadId: ThreadId, | |
| 16 | 23 | ): OrchestrationThread | undefined { |
| 17 | 24 | return readModel.threads.find((thread) => thread.id === threadId); |
| 18 | 25 | } |
| 19 | 26 | |
| 27 | export function findProjectById( | |
| 28 | readModel: OrchestrationReadModel, | |
| 29 | projectId: ProjectId, | |
| 30 | ): OrchestrationProject | undefined { | |
| 31 | return readModel.projects.find((project) => project.id === projectId); | |
| 32 | } | |
| 33 | ||
| 20 | 34 | export function listThreadsByProjectId( |
| 21 | 35 | readModel: OrchestrationReadModel, |
| 22 | projectId: string, | |
| 36 | projectId: ProjectId, | |
| 23 | 37 | ): ReadonlyArray<OrchestrationThread> { |
| 24 | 38 | return readModel.threads.filter((thread) => thread.projectId === projectId); |
| 25 | 39 | } |
| 26 | 40 | |
| 41 | export 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 | ||
| 60 | export 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 | ||
| 27 | 78 | export function requireThread( |
| 28 | 79 | input: { |
| 29 | 80 | readonly readModel: OrchestrationReadModel; |
| 30 | 81 | readonly command: OrchestrationCommand; |
| 31 | readonly threadId: string; | |
| 82 | readonly threadId: ThreadId; | |
| 32 | 83 | }, |
| 33 | 84 | ): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> { |
| 34 | 85 | const thread = findThreadById(input.readModel, input.threadId); |
| ... | ... | @@ -47,7 +98,7 @@ export function requireThreadAbsent( |
| 47 | 98 | input: { |
| 48 | 99 | readonly readModel: OrchestrationReadModel; |
| 49 | 100 | readonly command: OrchestrationCommand; |
| 50 | readonly threadId: string; | |
| 101 | readonly threadId: ThreadId; | |
| 51 | 102 | }, |
| 52 | 103 | ): Effect.Effect<void, OrchestrationCommandInvariantError> { |
| 53 | 104 | if (!findThreadById(input.readModel, input.threadId)) { |
apps/server/src/orchestration/decider.projectScripts.test.ts created+163| ... | ... | @@ -0,0 +1,163 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | ||
| 4 | import { decideOrchestrationCommand } from "./decider.ts"; | |
| 5 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 6 | ||
| 7 | describe("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 | 7 | |
| 8 | 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 9 | 9 | import { |
| 10 | requireNonNegativeInteger, | |
| 10 | requireProject, | |
| 11 | requireProjectAbsent, | |
| 11 | 12 | requireThread, |
| 12 | 13 | requireThreadAbsent, |
| 13 | 14 | } from "./commandInvariants.ts"; |
| 14 | 15 | |
| 16 | const nowIso = () => new Date().toISOString(); | |
| 17 | ||
| 18 | const 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 | ||
| 29 | function 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 | ||
| 15 | 49 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ |
| 16 | 50 | command, |
| 17 | 51 | readModel, |
| 18 | 52 | }: { |
| 19 | 53 | readonly command: OrchestrationCommand; |
| 20 | 54 | readonly readModel: OrchestrationReadModel; |
| 21 | }): Effect.fn.Return<Omit<OrchestrationEvent, "sequence">, OrchestrationCommandInvariantError> { | |
| 22 | const eventId = crypto.randomUUID(); | |
| 23 | ||
| 55 | }): Effect.fn.Return< | |
| 56 | Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>, | |
| 57 | OrchestrationCommandInvariantError | |
| 58 | > { | |
| 24 | 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 | 141 | yield* requireThreadAbsent({ |
| 27 | 142 | readModel, |
| 28 | 143 | command, |
| 29 | 144 | threadId: command.threadId, |
| 30 | 145 | }); |
| 31 | 146 | return { |
| 32 | eventId, | |
| 147 | ...withEventBase({ | |
| 148 | aggregateKind: "thread", | |
| 149 | aggregateId: command.threadId, | |
| 150 | occurredAt: command.createdAt, | |
| 151 | commandId: command.commandId, | |
| 152 | }), | |
| 33 | 153 | type: "thread.created", |
| 34 | aggregateType: "thread", | |
| 35 | aggregateId: command.threadId, | |
| 36 | occurredAt: command.createdAt, | |
| 37 | commandId: command.commandId, | |
| 38 | 154 | payload: { |
| 39 | id: command.threadId, | |
| 155 | threadId: command.threadId, | |
| 40 | 156 | projectId: command.projectId, |
| 41 | 157 | title: command.title, |
| 42 | 158 | model: command.model, |
| ... | ... | @@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 46 | 162 | updatedAt: command.createdAt, |
| 47 | 163 | }, |
| 48 | 164 | }; |
| 49 | case "thread.delete": | |
| 165 | } | |
| 166 | ||
| 167 | case "thread.delete": { | |
| 50 | 168 | yield* requireThread({ |
| 51 | 169 | readModel, |
| 52 | 170 | command, |
| 53 | 171 | threadId: command.threadId, |
| 54 | 172 | }); |
| 173 | const occurredAt = nowIso(); | |
| 55 | 174 | return { |
| 56 | eventId, | |
| 175 | ...withEventBase({ | |
| 176 | aggregateKind: "thread", | |
| 177 | aggregateId: command.threadId, | |
| 178 | occurredAt, | |
| 179 | commandId: command.commandId, | |
| 180 | }), | |
| 57 | 181 | type: "thread.deleted", |
| 58 | aggregateType: "thread", | |
| 59 | aggregateId: command.threadId, | |
| 60 | occurredAt: command.createdAt, | |
| 61 | commandId: command.commandId, | |
| 62 | 182 | payload: { |
| 63 | id: command.threadId, | |
| 64 | deletedAt: command.createdAt, | |
| 183 | threadId: command.threadId, | |
| 184 | deletedAt: occurredAt, | |
| 65 | 185 | }, |
| 66 | 186 | }; |
| 67 | case "thread.meta.update": | |
| 187 | } | |
| 188 | ||
| 189 | case "thread.meta.update": { | |
| 68 | 190 | yield* requireThread({ |
| 69 | 191 | readModel, |
| 70 | 192 | command, |
| 71 | 193 | threadId: command.threadId, |
| 72 | 194 | }); |
| 195 | const occurredAt = nowIso(); | |
| 73 | 196 | return { |
| 74 | eventId, | |
| 197 | ...withEventBase({ | |
| 198 | aggregateKind: "thread", | |
| 199 | aggregateId: command.threadId, | |
| 200 | occurredAt, | |
| 201 | commandId: command.commandId, | |
| 202 | }), | |
| 75 | 203 | type: "thread.meta-updated", |
| 76 | aggregateType: "thread", | |
| 77 | aggregateId: command.threadId, | |
| 78 | occurredAt: command.createdAt, | |
| 79 | commandId: command.commandId, | |
| 80 | 204 | payload: { |
| 81 | 205 | threadId: command.threadId, |
| 82 | 206 | ...(command.title !== undefined ? { title: command.title } : {}), |
| 83 | 207 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 84 | 208 | ...(command.branch !== undefined ? { branch: command.branch } : {}), |
| 85 | 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 | 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 | 262 | yield* requireThread({ |
| 91 | 263 | readModel, |
| 92 | 264 | command, |
| 93 | 265 | threadId: command.threadId, |
| 94 | 266 | }); |
| 95 | 267 | return { |
| 96 | eventId, | |
| 97 | type: "message.sent", | |
| 98 | aggregateType: "thread", | |
| 99 | aggregateId: command.threadId, | |
| 100 | occurredAt: command.createdAt, | |
| 101 | commandId: command.commandId, | |
| 268 | ...withEventBase({ | |
| 269 | aggregateKind: "thread", | |
| 270 | aggregateId: command.threadId, | |
| 271 | occurredAt: command.createdAt, | |
| 272 | commandId: command.commandId, | |
| 273 | }), | |
| 274 | type: "thread.turn-interrupt-requested", | |
| 102 | 275 | payload: { |
| 103 | id: command.messageId, | |
| 104 | role: command.role, | |
| 105 | text: command.text, | |
| 106 | 276 | threadId: command.threadId, |
| 277 | ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), | |
| 107 | 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 | 284 | yield* requireThread({ |
| 113 | 285 | readModel, |
| 114 | 286 | command, |
| 115 | 287 | threadId: command.threadId, |
| 116 | 288 | }); |
| 117 | 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 | 373 | type: "thread.session-set", |
| 120 | aggregateType: "thread", | |
| 121 | aggregateId: command.threadId, | |
| 122 | occurredAt: command.createdAt, | |
| 123 | commandId: command.commandId, | |
| 124 | 374 | payload: { |
| 125 | 375 | threadId: command.threadId, |
| 126 | 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 | 387 | return { |
| 131 | eventId, | |
| 132 | type: "git.read-model-upsert", | |
| 133 | aggregateType: "project", | |
| 134 | aggregateId: command.projectId, | |
| 135 | occurredAt: command.createdAt, | |
| 136 | commandId: command.commandId, | |
| 388 | ...withEventBase({ | |
| 389 | aggregateKind: "thread", | |
| 390 | aggregateId: command.threadId, | |
| 391 | occurredAt: command.createdAt, | |
| 392 | commandId: command.commandId, | |
| 393 | }), | |
| 394 | type: "thread.message-sent", | |
| 137 | 395 | payload: { |
| 138 | projectId: command.projectId, | |
| 139 | branch: command.branch, | |
| 140 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | |
| 141 | aheadCount: command.aheadCount, | |
| 142 | behindCount: command.behindCount, | |
| 396 | threadId: command.threadId, | |
| 397 | messageId: command.messageId, | |
| 398 | role: "assistant", | |
| 399 | text: command.delta, | |
| 400 | turnId: command.turnId ?? null, | |
| 401 | streaming: true, | |
| 402 | createdAt: command.createdAt, | |
| 143 | 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 | 436 | yield* requireThread({ |
| 148 | 437 | readModel, |
| 149 | 438 | command, |
| 150 | 439 | threadId: command.threadId, |
| 151 | 440 | }); |
| 152 | 441 | return { |
| 153 | eventId, | |
| 442 | ...withEventBase({ | |
| 443 | aggregateKind: "thread", | |
| 444 | aggregateId: command.threadId, | |
| 445 | occurredAt: command.createdAt, | |
| 446 | commandId: command.commandId, | |
| 447 | }), | |
| 154 | 448 | type: "thread.turn-diff-completed", |
| 155 | aggregateType: "thread", | |
| 156 | aggregateId: command.threadId, | |
| 157 | occurredAt: command.createdAt, | |
| 158 | commandId: command.commandId, | |
| 159 | 449 | payload: { |
| 160 | 450 | threadId: command.threadId, |
| 161 | 451 | turnId: command.turnId, |
| 162 | completedAt: command.completedAt, | |
| 163 | ...(command.status !== undefined ? { status: command.status } : {}), | |
| 452 | checkpointTurnCount: command.checkpointTurnCount, | |
| 453 | checkpointRef: command.checkpointRef, | |
| 454 | status: command.status, | |
| 164 | 455 | files: command.files, |
| 165 | ...(command.assistantMessageId !== undefined | |
| 166 | ? { assistantMessageId: command.assistantMessageId } | |
| 167 | : {}), | |
| 168 | ...(command.checkpointTurnCount !== undefined | |
| 169 | ? { checkpointTurnCount: command.checkpointTurnCount } | |
| 170 | : {}), | |
| 456 | assistantMessageId: command.assistantMessageId ?? null, | |
| 457 | completedAt: command.completedAt, | |
| 171 | 458 | }, |
| 172 | 459 | }; |
| 173 | case "thread.activity.append": | |
| 460 | } | |
| 461 | ||
| 462 | case "thread.revert.complete": { | |
| 174 | 463 | yield* requireThread({ |
| 175 | 464 | readModel, |
| 176 | 465 | command, |
| 177 | 466 | threadId: command.threadId, |
| 178 | 467 | }); |
| 179 | 468 | return { |
| 180 | eventId, | |
| 181 | type: "thread.activity-appended", | |
| 182 | aggregateType: "thread", | |
| 183 | aggregateId: command.threadId, | |
| 184 | occurredAt: command.createdAt, | |
| 185 | commandId: command.commandId, | |
| 469 | ...withEventBase({ | |
| 470 | aggregateKind: "thread", | |
| 471 | aggregateId: command.threadId, | |
| 472 | occurredAt: command.createdAt, | |
| 473 | commandId: command.commandId, | |
| 474 | }), | |
| 475 | type: "thread.reverted", | |
| 186 | 476 | payload: { |
| 187 | 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 | 484 | yield* requireThread({ |
| 193 | 485 | readModel, |
| 194 | 486 | command, |
| 195 | 487 | threadId: command.threadId, |
| 196 | 488 | }); |
| 197 | yield* requireNonNegativeInteger({ | |
| 198 | commandType: command.type, | |
| 199 | field: "turnCount", | |
| 200 | value: command.turnCount, | |
| 201 | }); | |
| 202 | yield* requireNonNegativeInteger({ | |
| 203 | commandType: command.type, | |
| 204 | field: "messageCount", | |
| 205 | value: command.messageCount, | |
| 206 | }); | |
| 489 | const requestId = | |
| 490 | typeof command.activity.payload === "object" && | |
| 491 | command.activity.payload !== null && | |
| 492 | "requestId" in command.activity.payload && | |
| 493 | typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" | |
| 494 | ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"]) | |
| 495 | : undefined; | |
| 207 | 496 | return { |
| 208 | eventId, | |
| 209 | type: "thread.reverted", | |
| 210 | aggregateType: "thread", | |
| 211 | aggregateId: command.threadId, | |
| 212 | occurredAt: command.createdAt, | |
| 213 | commandId: command.commandId, | |
| 497 | ...withEventBase({ | |
| 498 | aggregateKind: "thread", | |
| 499 | aggregateId: command.threadId, | |
| 500 | occurredAt: command.createdAt, | |
| 501 | commandId: command.commandId, | |
| 502 | ...(requestId !== undefined ? { metadata: { requestId } } : {}), | |
| 503 | }), | |
| 504 | type: "thread.activity-appended", | |
| 214 | 505 | payload: { |
| 215 | 506 | threadId: command.threadId, |
| 216 | turnCount: command.turnCount, | |
| 217 | messageCount: command.messageCount, | |
| 507 | activity: command.activity, | |
| 218 | 508 | }, |
| 219 | 509 | }; |
| 510 | } | |
| 511 | ||
| 220 | 512 | default: { |
| 221 | 513 | command satisfies never; |
| 222 | const fallback = command as any; | |
| 514 | const fallback = command as never as { type: string }; | |
| 223 | 515 | return yield* new OrchestrationCommandInvariantError({ |
| 224 | 516 | commandType: fallback.type, |
| 225 | 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 | 4 | |
| 5 | 5 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; |
| 6 | 6 | |
| 7 | function 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 | ||
| 7 | 31 | describe("orchestration projector", () => { |
| 8 | it("applies thread.created events using runtime schema decoding", async () => { | |
| 32 | it("applies thread.created events", async () => { | |
| 9 | 33 | const now = new Date().toISOString(); |
| 10 | 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 | }; | |
| 30 | 35 | |
| 31 | const next = await Effect.runPromise(projectEvent(model, event)); | |
| 32 | expect(next.sequence).toBe(1); | |
| 36 | const next = await Effect.runPromise( | |
| 37 | projectEvent( | |
| 38 | model, | |
| 39 | makeEvent({ | |
| 40 | sequence: 1, | |
| 41 | type: "thread.created", | |
| 42 | aggregateType: "thread", | |
| 43 | aggregateId: "thread-1", | |
| 44 | occurredAt: now, | |
| 45 | commandId: "cmd-thread-create", | |
| 46 | payload: { | |
| 47 | threadId: "thread-1", | |
| 48 | projectId: "project-1", | |
| 49 | title: "demo", | |
| 50 | model: "gpt-5-codex", | |
| 51 | branch: null, | |
| 52 | worktreePath: null, | |
| 53 | createdAt: now, | |
| 54 | updatedAt: now, | |
| 55 | }, | |
| 56 | }), | |
| 57 | ), | |
| 58 | ); | |
| 59 | ||
| 60 | expect(next.snapshotSequence).toBe(1); | |
| 33 | 61 | expect(next.threads).toEqual([ |
| 34 | 62 | { |
| 35 | 63 | id: "thread-1", |
| ... | ... | @@ -38,17 +66,14 @@ describe("orchestration projector", () => { |
| 38 | 66 | model: "gpt-5-codex", |
| 39 | 67 | branch: null, |
| 40 | 68 | worktreePath: null, |
| 69 | latestTurnId: null, | |
| 41 | 70 | createdAt: now, |
| 42 | 71 | updatedAt: now, |
| 43 | latestTurnId: null, | |
| 44 | latestTurnStartedAt: null, | |
| 45 | latestTurnCompletedAt: null, | |
| 46 | latestTurnDurationMs: null, | |
| 72 | deletedAt: null, | |
| 47 | 73 | messages: [], |
| 48 | session: null, | |
| 49 | 74 | activities: [], |
| 50 | turnDiffSummaries: [], | |
| 51 | error: null, | |
| 75 | checkpoints: [], | |
| 76 | session: null, | |
| 52 | 77 | }, |
| 53 | 78 | ]); |
| 54 | 79 | }); |
| ... | ... | @@ -56,203 +81,205 @@ describe("orchestration projector", () => { |
| 56 | 81 | it("fails when event payload cannot be decoded by runtime schema", async () => { |
| 57 | 82 | const now = new Date().toISOString(); |
| 58 | 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 | }; | |
| 75 | 84 | |
| 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 | }); |
| 78 | 111 | |
| 79 | it("keeps projector forward-compatible for unknown event types", async () => { | |
| 112 | it("keeps projector forward-compatible for unhandled event types", async () => { | |
| 80 | 113 | const now = new Date().toISOString(); |
| 81 | 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 | }; | |
| 92 | 115 | |
| 93 | const next = await Effect.runPromise(projectEvent(model, event)); | |
| 94 | expect(next.sequence).toBe(7); | |
| 116 | const next = await Effect.runPromise( | |
| 117 | projectEvent( | |
| 118 | model, | |
| 119 | makeEvent({ | |
| 120 | sequence: 7, | |
| 121 | type: "thread.turn-start-requested", | |
| 122 | aggregateType: "thread", | |
| 123 | aggregateId: "thread-1", | |
| 124 | occurredAt: "2026-01-01T00:00:00.000Z", | |
| 125 | commandId: "cmd-unhandled", | |
| 126 | payload: { | |
| 127 | threadId: "thread-1", | |
| 128 | messageId: "message-1", | |
| 129 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 130 | }, | |
| 131 | }), | |
| 132 | ), | |
| 133 | ); | |
| 134 | ||
| 135 | expect(next.snapshotSequence).toBe(7); | |
| 95 | 136 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); |
| 96 | 137 | expect(next.threads).toEqual([]); |
| 97 | 138 | }); |
| 98 | 139 | |
| 99 | it("tracks latest turn timing from session lifecycle events", async () => { | |
| 140 | it("tracks latest turn id from session lifecycle events", async () => { | |
| 100 | 141 | const createdAt = "2026-02-23T08:00:00.000Z"; |
| 101 | 142 | const startedAt = "2026-02-23T08:00:05.000Z"; |
| 102 | const completedAt = "2026-02-23T08:00:11.250Z"; | |
| 103 | 143 | const model = createEmptyReadModel(createdAt); |
| 104 | 144 | |
| 105 | const createdEvent: OrchestrationEvent = { | |
| 106 | sequence: 1, | |
| 107 | eventId: "thread-created", | |
| 108 | type: "thread.created", | |
| 109 | aggregateType: "thread", | |
| 110 | aggregateId: "thread-1", | |
| 111 | occurredAt: createdAt, | |
| 112 | commandId: "cmd-create", | |
| 113 | payload: { | |
| 114 | id: "thread-1", | |
| 115 | projectId: "project-1", | |
| 116 | title: "demo", | |
| 117 | model: "gpt-5.3-codex", | |
| 118 | branch: null, | |
| 119 | worktreePath: null, | |
| 120 | createdAt, | |
| 121 | updatedAt: createdAt, | |
| 122 | }, | |
| 123 | }; | |
| 124 | ||
| 125 | const runningSessionEvent: OrchestrationEvent = { | |
| 126 | sequence: 2, | |
| 127 | eventId: "session-running", | |
| 128 | type: "thread.session-set", | |
| 129 | aggregateType: "thread", | |
| 130 | aggregateId: "thread-1", | |
| 131 | occurredAt: startedAt, | |
| 132 | commandId: "cmd-running", | |
| 133 | payload: { | |
| 134 | threadId: "thread-1", | |
| 135 | session: { | |
| 136 | sessionId: "session-1", | |
| 137 | status: "running", | |
| 138 | provider: "codex", | |
| 139 | threadId: "thread-1", | |
| 140 | activeTurnId: "turn-1", | |
| 141 | createdAt, | |
| 142 | updatedAt: startedAt, | |
| 143 | lastError: null, | |
| 144 | }, | |
| 145 | }, | |
| 146 | }; | |
| 147 | ||
| 148 | const readySessionEvent: OrchestrationEvent = { | |
| 149 | sequence: 3, | |
| 150 | eventId: "session-ready", | |
| 151 | type: "thread.session-set", | |
| 152 | aggregateType: "thread", | |
| 153 | aggregateId: "thread-1", | |
| 154 | occurredAt: completedAt, | |
| 155 | commandId: "cmd-ready", | |
| 156 | payload: { | |
| 157 | threadId: "thread-1", | |
| 158 | session: { | |
| 159 | sessionId: "session-1", | |
| 160 | status: "ready", | |
| 161 | provider: "codex", | |
| 162 | threadId: "thread-1", | |
| 163 | activeTurnId: null, | |
| 164 | createdAt, | |
| 165 | updatedAt: completedAt, | |
| 166 | lastError: null, | |
| 167 | }, | |
| 168 | }, | |
| 169 | }; | |
| 145 | const afterCreate = await Effect.runPromise( | |
| 146 | projectEvent( | |
| 147 | model, | |
| 148 | makeEvent({ | |
| 149 | sequence: 1, | |
| 150 | type: "thread.created", | |
| 151 | aggregateType: "thread", | |
| 152 | aggregateId: "thread-1", | |
| 153 | occurredAt: createdAt, | |
| 154 | commandId: "cmd-create", | |
| 155 | payload: { | |
| 156 | threadId: "thread-1", | |
| 157 | projectId: "project-1", | |
| 158 | title: "demo", | |
| 159 | model: "gpt-5.3-codex", | |
| 160 | branch: null, | |
| 161 | worktreePath: null, | |
| 162 | createdAt, | |
| 163 | updatedAt: createdAt, | |
| 164 | }, | |
| 165 | }), | |
| 166 | ), | |
| 167 | ); | |
| 170 | 168 | |
| 171 | const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); | |
| 172 | const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent)); | |
| 173 | const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent)); | |
| 174 | const thread = afterReady.threads[0]; | |
| 169 | const afterRunning = await Effect.runPromise( | |
| 170 | projectEvent( | |
| 171 | afterCreate, | |
| 172 | makeEvent({ | |
| 173 | sequence: 2, | |
| 174 | type: "thread.session-set", | |
| 175 | aggregateType: "thread", | |
| 176 | aggregateId: "thread-1", | |
| 177 | occurredAt: startedAt, | |
| 178 | commandId: "cmd-running", | |
| 179 | payload: { | |
| 180 | threadId: "thread-1", | |
| 181 | session: { | |
| 182 | threadId: "thread-1", | |
| 183 | status: "running", | |
| 184 | providerName: "codex", | |
| 185 | providerSessionId: "session-1", | |
| 186 | providerThreadId: "provider-thread-1", | |
| 187 | activeTurnId: "turn-1", | |
| 188 | lastError: null, | |
| 189 | updatedAt: startedAt, | |
| 190 | }, | |
| 191 | }, | |
| 192 | }), | |
| 193 | ), | |
| 194 | ); | |
| 175 | 195 | |
| 196 | const thread = afterRunning.threads[0]; | |
| 176 | 197 | expect(thread?.latestTurnId).toBe("turn-1"); |
| 177 | expect(thread?.latestTurnStartedAt).toBe(startedAt); | |
| 178 | expect(thread?.latestTurnCompletedAt).toBe(completedAt); | |
| 179 | expect(thread?.latestTurnDurationMs).toBe(6250); | |
| 198 | expect(thread?.session?.status).toBe("running"); | |
| 180 | 199 | }); |
| 181 | 200 | |
| 182 | it("marks assistant messages completed using non-streaming updates", async () => { | |
| 201 | it("marks assistant messages completed with non-streaming updates", async () => { | |
| 183 | 202 | const createdAt = "2026-02-23T09:00:00.000Z"; |
| 184 | 203 | const deltaAt = "2026-02-23T09:00:01.000Z"; |
| 185 | 204 | const completeAt = "2026-02-23T09:00:03.500Z"; |
| 186 | 205 | const model = createEmptyReadModel(createdAt); |
| 187 | 206 | |
| 188 | const createdEvent: OrchestrationEvent = { | |
| 189 | sequence: 1, | |
| 190 | eventId: "thread-created", | |
| 191 | type: "thread.created", | |
| 192 | aggregateType: "thread", | |
| 193 | aggregateId: "thread-1", | |
| 194 | occurredAt: createdAt, | |
| 195 | commandId: "cmd-create", | |
| 196 | payload: { | |
| 197 | id: "thread-1", | |
| 198 | projectId: "project-1", | |
| 199 | title: "demo", | |
| 200 | model: "gpt-5.3-codex", | |
| 201 | branch: null, | |
| 202 | worktreePath: null, | |
| 203 | createdAt, | |
| 204 | updatedAt: createdAt, | |
| 205 | }, | |
| 206 | }; | |
| 207 | const afterCreate = await Effect.runPromise( | |
| 208 | projectEvent( | |
| 209 | model, | |
| 210 | makeEvent({ | |
| 211 | sequence: 1, | |
| 212 | type: "thread.created", | |
| 213 | aggregateType: "thread", | |
| 214 | aggregateId: "thread-1", | |
| 215 | occurredAt: createdAt, | |
| 216 | commandId: "cmd-create", | |
| 217 | payload: { | |
| 218 | threadId: "thread-1", | |
| 219 | projectId: "project-1", | |
| 220 | title: "demo", | |
| 221 | model: "gpt-5.3-codex", | |
| 222 | branch: null, | |
| 223 | worktreePath: null, | |
| 224 | createdAt, | |
| 225 | updatedAt: createdAt, | |
| 226 | }, | |
| 227 | }), | |
| 228 | ), | |
| 229 | ); | |
| 207 | 230 | |
| 208 | const deltaEvent: OrchestrationEvent = { | |
| 209 | sequence: 2, | |
| 210 | eventId: "assistant-delta", | |
| 211 | type: "message.sent", | |
| 212 | aggregateType: "thread", | |
| 213 | aggregateId: "thread-1", | |
| 214 | occurredAt: deltaAt, | |
| 215 | commandId: "cmd-delta", | |
| 216 | payload: { | |
| 217 | id: "assistant:msg-1", | |
| 218 | role: "assistant", | |
| 219 | text: "hello", | |
| 220 | threadId: "thread-1", | |
| 221 | createdAt: deltaAt, | |
| 222 | streaming: true, | |
| 223 | }, | |
| 224 | }; | |
| 231 | const afterDelta = await Effect.runPromise( | |
| 232 | projectEvent( | |
| 233 | afterCreate, | |
| 234 | makeEvent({ | |
| 235 | sequence: 2, | |
| 236 | type: "thread.message-sent", | |
| 237 | aggregateType: "thread", | |
| 238 | aggregateId: "thread-1", | |
| 239 | occurredAt: deltaAt, | |
| 240 | commandId: "cmd-delta", | |
| 241 | payload: { | |
| 242 | threadId: "thread-1", | |
| 243 | messageId: "assistant:msg-1", | |
| 244 | role: "assistant", | |
| 245 | text: "hello", | |
| 246 | turnId: "turn-1", | |
| 247 | streaming: true, | |
| 248 | createdAt: deltaAt, | |
| 249 | updatedAt: deltaAt, | |
| 250 | }, | |
| 251 | }), | |
| 252 | ), | |
| 253 | ); | |
| 225 | 254 | |
| 226 | const completionEvent: OrchestrationEvent = { | |
| 227 | sequence: 3, | |
| 228 | eventId: "assistant-complete", | |
| 229 | type: "message.sent", | |
| 230 | aggregateType: "thread", | |
| 231 | aggregateId: "thread-1", | |
| 232 | occurredAt: completeAt, | |
| 233 | commandId: "cmd-complete", | |
| 234 | payload: { | |
| 235 | id: "assistant:msg-1", | |
| 236 | role: "assistant", | |
| 237 | text: "", | |
| 238 | threadId: "thread-1", | |
| 239 | createdAt: completeAt, | |
| 240 | streaming: false, | |
| 241 | }, | |
| 242 | }; | |
| 255 | const afterComplete = await Effect.runPromise( | |
| 256 | projectEvent( | |
| 257 | afterDelta, | |
| 258 | makeEvent({ | |
| 259 | sequence: 3, | |
| 260 | type: "thread.message-sent", | |
| 261 | aggregateType: "thread", | |
| 262 | aggregateId: "thread-1", | |
| 263 | occurredAt: completeAt, | |
| 264 | commandId: "cmd-complete", | |
| 265 | payload: { | |
| 266 | threadId: "thread-1", | |
| 267 | messageId: "assistant:msg-1", | |
| 268 | role: "assistant", | |
| 269 | text: "", | |
| 270 | turnId: "turn-1", | |
| 271 | streaming: false, | |
| 272 | createdAt: completeAt, | |
| 273 | updatedAt: completeAt, | |
| 274 | }, | |
| 275 | }), | |
| 276 | ), | |
| 277 | ); | |
| 243 | 278 | |
| 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 | 279 | const message = afterComplete.threads[0]?.messages[0]; |
| 248 | ||
| 249 | expect(message).toEqual({ | |
| 250 | id: "assistant:msg-1", | |
| 251 | role: "assistant", | |
| 252 | text: "hello", | |
| 253 | createdAt: deltaAt, | |
| 254 | completedAt: completeAt, | |
| 255 | streaming: false, | |
| 256 | }); | |
| 280 | expect(message?.id).toBe("assistant:msg-1"); | |
| 281 | expect(message?.text).toBe("hello"); | |
| 282 | expect(message?.streaming).toBe(false); | |
| 283 | expect(message?.updatedAt).toBe(completeAt); | |
| 257 | 284 | }); |
| 258 | 285 | }); |
apps/server/src/orchestration/projector.ts+176-253| ... | ... | @@ -1,29 +1,36 @@ |
| 1 | 1 | import type { |
| 2 | 2 | OrchestrationEvent, |
| 3 | OrchestrationGitReadModel, | |
| 4 | OrchestrationMessage, | |
| 5 | 3 | OrchestrationReadModel, |
| 6 | OrchestrationSession, | |
| 7 | OrchestrationThread, | |
| 4 | ThreadId, | |
| 8 | 5 | } from "@t3tools/contracts"; |
| 9 | 6 | import { |
| 10 | OrchestrationThreadActivitySchema, | |
| 11 | OrchestrationGitReadModelSchema, | |
| 12 | OrchestrationMessageSchema, | |
| 13 | OrchestrationSessionSchema, | |
| 14 | OrchestrationThreadSchema, | |
| 15 | OrchestrationTurnDiffFileSchema, | |
| 16 | OrchestrationTurnDiffSummarySchema, | |
| 7 | OrchestrationCheckpointSummary, | |
| 8 | OrchestrationMessage, | |
| 9 | OrchestrationSession, | |
| 10 | OrchestrationThread, | |
| 17 | 11 | } from "@t3tools/contracts"; |
| 18 | 12 | import { Effect, Schema } from "effect"; |
| 19 | 13 | |
| 20 | 14 | import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; |
| 15 | import { | |
| 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"; | |
| 21 | 28 | |
| 22 | 29 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 23 | 30 | |
| 24 | 31 | function updateThread( |
| 25 | 32 | threads: ReadonlyArray<OrchestrationThread>, |
| 26 | threadId: string, | |
| 33 | threadId: ThreadId, | |
| 27 | 34 | patch: ThreadPatch, |
| 28 | 35 | ): OrchestrationThread[] { |
| 29 | 36 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); |
| ... | ... | @@ -32,7 +39,7 @@ function updateThread( |
| 32 | 39 | function decodeForEvent<A>( |
| 33 | 40 | schema: Schema.Schema<A>, |
| 34 | 41 | value: unknown, |
| 35 | eventType: string, | |
| 42 | eventType: OrchestrationEvent["type"], | |
| 36 | 43 | field: string, |
| 37 | 44 | ): Effect.Effect<A, OrchestrationProjectorDecodeError> { |
| 38 | 45 | return Schema.decodeUnknownEffect(schema)(value).pipe( |
| ... | ... | @@ -40,66 +47,11 @@ function decodeForEvent<A>( |
| 40 | 47 | ) as Effect.Effect<A, OrchestrationProjectorDecodeError>; |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | const 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 | ||
| 54 | const 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 | ||
| 63 | const 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 | ||
| 72 | const ThreadSessionSetPayloadSchema = Schema.Struct({ | |
| 73 | threadId: Schema.String, | |
| 74 | session: OrchestrationSessionSchema, | |
| 75 | }); | |
| 76 | ||
| 77 | const 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 | ||
| 87 | const ThreadRevertedPayloadSchema = Schema.Struct({ | |
| 88 | threadId: Schema.String, | |
| 89 | turnCount: Schema.Number, | |
| 90 | messageCount: Schema.Number, | |
| 91 | }); | |
| 92 | ||
| 93 | const ThreadActivityAppendedPayloadSchema = Schema.Struct({ | |
| 94 | threadId: Schema.String, | |
| 95 | activity: OrchestrationThreadActivitySchema, | |
| 96 | }); | |
| 97 | ||
| 98 | 50 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { |
| 99 | 51 | return { |
| 100 | sequence: 0, | |
| 52 | snapshotSequence: 0, | |
| 53 | projects: [], | |
| 101 | 54 | threads: [], |
| 102 | gitByProjectId: {}, | |
| 103 | 55 | updatedAt: nowIso, |
| 104 | 56 | }; |
| 105 | 57 | } |
| ... | ... | @@ -110,39 +62,104 @@ export function projectEvent( |
| 110 | 62 | ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> { |
| 111 | 63 | const nextBase: OrchestrationReadModel = { |
| 112 | 64 | ...model, |
| 113 | sequence: event.sequence, | |
| 65 | snapshotSequence: event.sequence, | |
| 114 | 66 | updatedAt: event.occurredAt, |
| 115 | 67 | }; |
| 116 | 68 | |
| 117 | 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 | 138 | case "thread.created": |
| 119 | 139 | return Effect.gen(function* () { |
| 120 | 140 | const payload = yield* decodeForEvent( |
| 121 | ThreadCreatedPayloadSchema, | |
| 141 | ThreadCreatedPayload, | |
| 122 | 142 | event.payload, |
| 123 | 143 | event.type, |
| 124 | 144 | "payload", |
| 125 | 145 | ); |
| 126 | 146 | const thread: OrchestrationThread = yield* decodeForEvent( |
| 127 | OrchestrationThreadSchema, | |
| 147 | OrchestrationThread, | |
| 128 | 148 | { |
| 129 | id: payload.id, | |
| 149 | id: payload.threadId, | |
| 130 | 150 | projectId: payload.projectId, |
| 131 | 151 | title: payload.title, |
| 132 | 152 | model: payload.model, |
| 133 | 153 | branch: payload.branch, |
| 134 | 154 | worktreePath: payload.worktreePath, |
| 155 | latestTurnId: null, | |
| 135 | 156 | createdAt: payload.createdAt, |
| 136 | 157 | updatedAt: payload.updatedAt, |
| 137 | latestTurnId: null, | |
| 138 | latestTurnStartedAt: null, | |
| 139 | latestTurnCompletedAt: null, | |
| 140 | latestTurnDurationMs: null, | |
| 158 | deletedAt: null, | |
| 141 | 159 | messages: [], |
| 142 | session: null, | |
| 143 | turnDiffSummaries: [], | |
| 144 | 160 | activities: [], |
| 145 | error: null, | |
| 161 | checkpoints: [], | |
| 162 | session: null, | |
| 146 | 163 | }, |
| 147 | 164 | event.type, |
| 148 | 165 | "thread", |
| ... | ... | @@ -155,36 +172,38 @@ export function projectEvent( |
| 155 | 172 | : [...nextBase.threads, thread], |
| 156 | 173 | }; |
| 157 | 174 | }); |
| 175 | ||
| 158 | 176 | case "thread.deleted": |
| 159 | return Effect.succeed({ | |
| 160 | ...nextBase, | |
| 161 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), | |
| 162 | }); | |
| 177 | return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( | |
| 178 | Effect.map((payload) => ({ | |
| 179 | ...nextBase, | |
| 180 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 181 | deletedAt: payload.deletedAt, | |
| 182 | updatedAt: payload.deletedAt, | |
| 183 | }), | |
| 184 | })), | |
| 185 | ); | |
| 186 | ||
| 163 | 187 | case "thread.meta-updated": |
| 164 | 188 | return decodeForEvent( |
| 165 | ThreadMetaUpdatedPayloadSchema, | |
| 189 | ThreadMetaUpdatedPayload, | |
| 166 | 190 | event.payload, |
| 167 | 191 | event.type, |
| 168 | 192 | "payload", |
| 169 | 193 | ).pipe( |
| 170 | Effect.map((payload) => { | |
| 171 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 172 | if (!thread) { | |
| 173 | return nextBase; | |
| 174 | } | |
| 175 | return { | |
| 176 | ...nextBase, | |
| 177 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 178 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 179 | ...(payload.model !== undefined ? { model: payload.model } : {}), | |
| 180 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | |
| 181 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | |
| 182 | updatedAt: payload.updatedAt, | |
| 183 | }), | |
| 184 | }; | |
| 185 | }), | |
| 194 | Effect.map((payload) => ({ | |
| 195 | ...nextBase, | |
| 196 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 197 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 198 | ...(payload.model !== undefined ? { model: payload.model } : {}), | |
| 199 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | |
| 200 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | |
| 201 | updatedAt: payload.updatedAt, | |
| 202 | }), | |
| 203 | })), | |
| 186 | 204 | ); |
| 187 | case "message.sent": | |
| 205 | ||
| 206 | case "thread.message-sent": | |
| 188 | 207 | return Effect.gen(function* () { |
| 189 | 208 | const payload = yield* decodeForEvent( |
| 190 | 209 | MessageSentPayloadSchema, |
| ... | ... | @@ -192,27 +211,30 @@ export function projectEvent( |
| 192 | 211 | event.type, |
| 193 | 212 | "payload", |
| 194 | 213 | ); |
| 195 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); | |
| 196 | if (!targetThread) { | |
| 214 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 215 | if (!thread) { | |
| 197 | 216 | return nextBase; |
| 198 | 217 | } |
| 199 | 218 | |
| 200 | 219 | const message: OrchestrationMessage = yield* decodeForEvent( |
| 201 | OrchestrationMessageSchema, | |
| 220 | OrchestrationMessage, | |
| 202 | 221 | { |
| 203 | id: payload.id, | |
| 222 | id: payload.messageId, | |
| 204 | 223 | role: payload.role, |
| 205 | 224 | text: payload.text, |
| 206 | createdAt: payload.createdAt, | |
| 207 | ...(payload.streaming ? {} : { completedAt: event.occurredAt }), | |
| 225 | ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), | |
| 226 | turnId: payload.turnId, | |
| 208 | 227 | streaming: payload.streaming, |
| 228 | createdAt: payload.createdAt, | |
| 229 | updatedAt: payload.updatedAt, | |
| 209 | 230 | }, |
| 210 | 231 | event.type, |
| 211 | 232 | "message", |
| 212 | 233 | ); |
| 213 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); | |
| 214 | const nextMessages = existingMessage | |
| 215 | ? targetThread.messages.map((entry) => | |
| 234 | ||
| 235 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | |
| 236 | const messages = existingMessage | |
| 237 | ? thread.messages.map((entry) => | |
| 216 | 238 | entry.id === message.id |
| 217 | 239 | ? { |
| 218 | 240 | ...entry, |
| ... | ... | @@ -222,29 +244,29 @@ export function projectEvent( |
| 222 | 244 | ? message.text |
| 223 | 245 | : entry.text, |
| 224 | 246 | streaming: message.streaming, |
| 225 | createdAt: entry.createdAt, | |
| 226 | ...(message.streaming | |
| 227 | ? { completedAt: undefined } | |
| 228 | : { completedAt: message.completedAt ?? event.occurredAt }), | |
| 247 | updatedAt: message.updatedAt, | |
| 248 | turnId: message.turnId, | |
| 249 | ...(message.attachments !== undefined | |
| 250 | ? { attachments: message.attachments } | |
| 251 | : {}), | |
| 229 | 252 | } |
| 230 | 253 | : entry, |
| 231 | 254 | ) |
| 232 | : !message.streaming && message.text.length === 0 | |
| 233 | ? targetThread.messages | |
| 234 | : [...targetThread.messages, message]; | |
| 255 | : [...thread.messages, message]; | |
| 235 | 256 | |
| 236 | 257 | return { |
| 237 | 258 | ...nextBase, |
| 238 | 259 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 239 | messages: nextMessages, | |
| 260 | messages, | |
| 240 | 261 | updatedAt: event.occurredAt, |
| 241 | 262 | }), |
| 242 | 263 | }; |
| 243 | 264 | }); |
| 265 | ||
| 244 | 266 | case "thread.session-set": |
| 245 | 267 | return Effect.gen(function* () { |
| 246 | 268 | const payload = yield* decodeForEvent( |
| 247 | ThreadSessionSetPayloadSchema, | |
| 269 | ThreadSessionSetPayload, | |
| 248 | 270 | event.payload, |
| 249 | 271 | event.type, |
| 250 | 272 | "payload", |
| ... | ... | @@ -255,67 +277,26 @@ export function projectEvent( |
| 255 | 277 | } |
| 256 | 278 | |
| 257 | 279 | const session: OrchestrationSession = yield* decodeForEvent( |
| 258 | OrchestrationSessionSchema, | |
| 259 | { | |
| 260 | ...payload.session, | |
| 261 | threadId: payload.threadId, | |
| 262 | }, | |
| 280 | OrchestrationSession, | |
| 281 | payload.session, | |
| 263 | 282 | event.type, |
| 264 | 283 | "session", |
| 265 | 284 | ); |
| 266 | 285 | |
| 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 | 286 | return { |
| 306 | 287 | ...nextBase, |
| 307 | 288 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 308 | 289 | session, |
| 290 | latestTurnId: session.activeTurnId, | |
| 309 | 291 | updatedAt: event.occurredAt, |
| 310 | error: session.lastError, | |
| 311 | ...turnTimingPatch, | |
| 312 | 292 | }), |
| 313 | 293 | }; |
| 314 | 294 | }); |
| 295 | ||
| 315 | 296 | case "thread.turn-diff-completed": |
| 316 | 297 | return Effect.gen(function* () { |
| 317 | 298 | const payload = yield* decodeForEvent( |
| 318 | ThreadTurnDiffCompletedPayloadSchema, | |
| 299 | ThreadTurnDiffCompletedPayload, | |
| 319 | 300 | event.payload, |
| 320 | 301 | event.type, |
| 321 | 302 | "payload", |
| ... | ... | @@ -325,110 +306,65 @@ export function projectEvent( |
| 325 | 306 | return nextBase; |
| 326 | 307 | } |
| 327 | 308 | |
| 328 | const nextSummary = yield* decodeForEvent( | |
| 329 | OrchestrationTurnDiffSummarySchema, | |
| 309 | const checkpoint = yield* decodeForEvent( | |
| 310 | OrchestrationCheckpointSummary, | |
| 330 | 311 | { |
| 331 | 312 | turnId: payload.turnId, |
| 332 | completedAt: payload.completedAt, | |
| 333 | ...(payload.status !== undefined ? { status: payload.status } : {}), | |
| 313 | checkpointTurnCount: payload.checkpointTurnCount, | |
| 314 | checkpointRef: payload.checkpointRef, | |
| 315 | status: payload.status, | |
| 334 | 316 | files: payload.files, |
| 335 | ...(payload.assistantMessageId !== undefined | |
| 336 | ? { assistantMessageId: payload.assistantMessageId } | |
| 337 | : {}), | |
| 338 | ...(payload.checkpointTurnCount !== undefined | |
| 339 | ? { checkpointTurnCount: payload.checkpointTurnCount } | |
| 340 | : {}), | |
| 317 | assistantMessageId: payload.assistantMessageId, | |
| 318 | completedAt: payload.completedAt, | |
| 341 | 319 | }, |
| 342 | 320 | event.type, |
| 343 | "turnDiffSummary", | |
| 321 | "checkpoint", | |
| 344 | 322 | ); |
| 345 | const turnDiffSummaries = [ | |
| 346 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), | |
| 347 | nextSummary, | |
| 348 | ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 349 | const startedAtMs = thread.latestTurnStartedAt | |
| 350 | ? Date.parse(thread.latestTurnStartedAt) | |
| 351 | : Number.NaN; | |
| 352 | const completedAtMs = Date.parse(nextSummary.completedAt); | |
| 353 | const latestTurnDurationMs = | |
| 354 | !Number.isNaN(startedAtMs) && | |
| 355 | !Number.isNaN(completedAtMs) && | |
| 356 | completedAtMs >= startedAtMs | |
| 357 | ? completedAtMs - startedAtMs | |
| 358 | : thread.latestTurnDurationMs; | |
| 323 | ||
| 324 | const checkpoints = [ | |
| 325 | ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId), | |
| 326 | checkpoint, | |
| 327 | ].toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); | |
| 359 | 328 | |
| 360 | 329 | return { |
| 361 | 330 | ...nextBase, |
| 362 | 331 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 332 | checkpoints, | |
| 363 | 333 | latestTurnId: payload.turnId, |
| 364 | latestTurnCompletedAt: nextSummary.completedAt, | |
| 365 | latestTurnDurationMs, | |
| 366 | turnDiffSummaries, | |
| 367 | 334 | updatedAt: event.occurredAt, |
| 368 | 335 | }), |
| 369 | 336 | }; |
| 370 | 337 | }); |
| 338 | ||
| 371 | 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 | 341 | Effect.map((payload) => { |
| 374 | 342 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 375 | 343 | if (!thread) { |
| 376 | 344 | return nextBase; |
| 377 | 345 | } |
| 378 | 346 | |
| 379 | const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); | |
| 380 | const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); | |
| 381 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => | |
| 382 | left.completedAt.localeCompare(right.completedAt), | |
| 383 | ); | |
| 384 | const inferredTurnCountByTurnId = new Map<string, number>(); | |
| 385 | for (let index = 0; index < sortedSummaries.length; index += 1) { | |
| 386 | const summary = sortedSummaries[index]; | |
| 387 | if (!summary) continue; | |
| 388 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); | |
| 389 | } | |
| 347 | const checkpoints = thread.checkpoints | |
| 348 | .filter((entry) => entry.checkpointTurnCount <= payload.turnCount) | |
| 349 | .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); | |
| 390 | 350 | |
| 391 | const turnDiffSummaries = thread.turnDiffSummaries | |
| 392 | .filter((summary) => { | |
| 393 | const checkpointTurnCount = | |
| 394 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; | |
| 395 | return checkpointTurnCount <= targetTurnCount; | |
| 396 | }) | |
| 397 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 398 | const latestSummary = | |
| 399 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; | |
| 400 | const activities = thread.activities.filter((activity) => { | |
| 401 | if (!activity.turnId) { | |
| 402 | return true; | |
| 403 | } | |
| 404 | const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); | |
| 405 | if (checkpointTurnCount === undefined) { | |
| 406 | return true; | |
| 407 | } | |
| 408 | return checkpointTurnCount <= targetTurnCount; | |
| 409 | }); | |
| 351 | const latestTurnId = | |
| 352 | checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null; | |
| 410 | 353 | |
| 411 | 354 | return { |
| 412 | 355 | ...nextBase, |
| 413 | 356 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 414 | messages: thread.messages.slice( | |
| 415 | 0, | |
| 416 | Math.min(targetMessageCount, thread.messages.length), | |
| 417 | ), | |
| 418 | turnDiffSummaries, | |
| 419 | latestTurnId: latestSummary?.turnId ?? null, | |
| 420 | latestTurnStartedAt: null, | |
| 421 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | |
| 422 | latestTurnDurationMs: null, | |
| 423 | activities, | |
| 357 | checkpoints, | |
| 358 | latestTurnId, | |
| 424 | 359 | updatedAt: event.occurredAt, |
| 425 | 360 | }), |
| 426 | 361 | }; |
| 427 | 362 | }), |
| 428 | 363 | ); |
| 364 | ||
| 429 | 365 | case "thread.activity-appended": |
| 430 | 366 | return decodeForEvent( |
| 431 | ThreadActivityAppendedPayloadSchema, | |
| 367 | ThreadActivityAppendedPayload, | |
| 432 | 368 | event.payload, |
| 433 | 369 | event.type, |
| 434 | 370 | "payload", |
| ... | ... | @@ -438,8 +374,9 @@ export function projectEvent( |
| 438 | 374 | if (!thread) { |
| 439 | 375 | return nextBase; |
| 440 | 376 | } |
| 377 | ||
| 441 | 378 | const activities = [ |
| 442 | ...thread.activities.filter((activity) => activity.id !== payload.activity.id), | |
| 379 | ...thread.activities.filter((entry) => entry.id !== payload.activity.id), | |
| 443 | 380 | payload.activity, |
| 444 | 381 | ] |
| 445 | 382 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) |
| ... | ... | @@ -454,21 +391,7 @@ export function projectEvent( |
| 454 | 391 | }; |
| 455 | 392 | }), |
| 456 | 393 | ); |
| 457 | case "git.read-model-upsert": | |
| 458 | return decodeForEvent( | |
| 459 | OrchestrationGitReadModelSchema, | |
| 460 | event.payload, | |
| 461 | event.type, | |
| 462 | "payload", | |
| 463 | ).pipe( | |
| 464 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ | |
| 465 | ...nextBase, | |
| 466 | gitByProjectId: { | |
| 467 | ...nextBase.gitByProjectId, | |
| 468 | [gitReadModel.projectId]: gitReadModel, | |
| 469 | }, | |
| 470 | })), | |
| 471 | ); | |
| 394 | ||
| 472 | 395 | default: |
| 473 | 396 | return Effect.succeed(nextBase); |
| 474 | 397 | } |
apps/server/src/persistence/Errors.ts+16| ... | ... | @@ -200,3 +200,19 @@ export type CheckpointRepositoryError = |
| 200 | 200 | export type ProviderSessionRepositoryError = |
| 201 | 201 | | ProviderSessionRepositoryValidationError |
| 202 | 202 | | ProviderSessionRepositoryPersistenceError; |
| 203 | ||
| 204 | export type OrchestrationCommandReceiptRepositoryError = | |
| 205 | | PersistenceSqlError | |
| 206 | | PersistenceDecodeError; | |
| 207 | ||
| 208 | export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError; | |
| 209 | ||
| 210 | export type ProviderSessionRuntimeRepositoryError = | |
| 211 | | PersistenceSqlError | |
| 212 | | PersistenceDecodeError | |
| 213 | | PersistenceSerializationError; | |
| 214 | ||
| 215 | export type ProjectionRepositoryError = | |
| 216 | | PersistenceSqlError | |
| 217 | | PersistenceDecodeError | |
| 218 | | PersistenceSerializationError; |
apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts created+79| ... | ... | @@ -0,0 +1,79 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | CheckpointDiffBlobRepository, | |
| 9 | CheckpointDiffBlob, | |
| 10 | GetCheckpointDiffBlobInput, | |
| 11 | type CheckpointDiffBlobRepositoryShape, | |
| 12 | } from "../Services/CheckpointDiffBlobs.ts"; | |
| 13 | ||
| 14 | const 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 | ||
| 76 | export const CheckpointDiffBlobRepositoryLive = Layer.effect( | |
| 77 | CheckpointDiffBlobRepository, | |
| 78 | makeCheckpointDiffBlobRepository, | |
| 79 | ); |
apps/server/src/persistence/Layers/Checkpoints.test.ts deleted-115| ... | ... | @@ -1,115 +0,0 @@ |
| 1 | import { it, assert } from "@effect/vitest"; | |
| 2 | import { Effect, Layer, Option } from "effect"; | |
| 3 | ||
| 4 | import { CheckpointRepository } from "../Services/Checkpoints.ts"; | |
| 5 | import { CheckpointRepositoryLive } from "./Checkpoints.ts"; | |
| 6 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 7 | ||
| 8 | function makeCheckpointRepositoryLayer() { | |
| 9 | return CheckpointRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); | |
| 10 | } | |
| 11 | ||
| 12 | it.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 | */ | |
| 13 | import type { ProviderCheckpoint } from "@t3tools/contracts"; | |
| 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 16 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 17 | ||
| 18 | import { | |
| 19 | CheckpointRepositoryPersistenceError, | |
| 20 | CheckpointRepositoryValidationError, | |
| 21 | type CheckpointRepositoryError, | |
| 22 | } from "../Errors.ts"; | |
| 23 | import { | |
| 24 | CheckpointRepository, | |
| 25 | type CheckpointRepositoryEntry, | |
| 26 | type CheckpointRepositoryShape, | |
| 27 | } from "../Services/Checkpoints.ts"; | |
| 28 | ||
| 29 | const NonEmptyString = Schema.NonEmptyString; | |
| 30 | ||
| 31 | const 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 | ||
| 43 | const ThreadRequestSchema = Schema.Struct({ | |
| 44 | threadId: NonEmptyString, | |
| 45 | }); | |
| 46 | ||
| 47 | const 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 | ||
| 59 | const GetCheckpointRequestSchema = Schema.Struct({ | |
| 60 | threadId: NonEmptyString, | |
| 61 | turnCount: Schema.Int, | |
| 62 | }); | |
| 63 | ||
| 64 | const DeleteAfterTurnRequestSchema = Schema.Struct({ | |
| 65 | threadId: NonEmptyString, | |
| 66 | maxTurnCount: Schema.Int, | |
| 67 | }); | |
| 68 | ||
| 69 | function 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 | ||
| 76 | function 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 | ||
| 84 | function 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 | ||
| 94 | function 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 | ||
| 110 | function 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 | ||
| 121 | function 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 | ||
| 138 | function 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 | ||
| 149 | const 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 | ||
| 356 | export const CheckpointRepositoryLive = Layer.effect( | |
| 357 | CheckpointRepository, | |
| 358 | makeCheckpointRepository, | |
| 359 | ); |
apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | GetByCommandIdInput, | |
| 9 | OrchestrationCommandReceipt, | |
| 10 | OrchestrationCommandReceiptRepository, | |
| 11 | type OrchestrationCommandReceiptRepositoryShape, | |
| 12 | } from "../Services/OrchestrationCommandReceipts.ts"; | |
| 13 | ||
| 14 | const 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 | ||
| 86 | export const OrchestrationCommandReceiptRepositoryLive = Layer.effect( | |
| 87 | OrchestrationCommandReceiptRepository, | |
| 88 | makeOrchestrationCommandReceiptRepository, | |
| 89 | ); |
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+102-22| ... | ... | @@ -1,39 +1,119 @@ |
| 1 | import { Effect, Layer, Stream } from "effect"; | |
| 1 | import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { Effect, Layer, Stream } from "effect"; | |
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | 5 | |
| 6 | import { PersistenceDecodeError } from "../Errors.ts"; | |
| 4 | 7 | import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; |
| 5 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 6 | 8 | import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; |
| 7 | import { NodeServices } from "@effect/platform-node"; | |
| 9 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 8 | 10 | |
| 9 | 11 | const layer = it.layer( |
| 10 | Layer.mergeAll( | |
| 11 | Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory), | |
| 12 | NodeServices.layer, | |
| 13 | ), | |
| 12 | OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 14 | 13 | ); |
| 15 | 14 | |
| 16 | 15 | layer("OrchestrationEventStore", (it) => { |
| 17 | it.effect("stores and replays events", () => | |
| 16 | it.effect("stores json columns as strings and replays decoded events", () => | |
| 18 | 17 | Effect.gen(function* () { |
| 19 | const createdAt = new Date().toISOString(); | |
| 20 | ||
| 21 | 18 | const eventStore = yield* OrchestrationEventStore; |
| 22 | const saved = yield* eventStore.append({ | |
| 23 | eventId: "event-1", | |
| 24 | type: "thread.created", | |
| 25 | aggregateType: "thread", | |
| 26 | aggregateId: "thread-1", | |
| 27 | occurredAt: createdAt, | |
| 28 | commandId: "cmd-1", | |
| 29 | payload: { id: "thread-1", projectId: "project-1", title: "demo" }, | |
| 19 | const sql = yield* SqlClient.SqlClient; | |
| 20 | const now = new Date().toISOString(); | |
| 21 | ||
| 22 | const appended = yield* eventStore.append({ | |
| 23 | type: "project.created", | |
| 24 | eventId: EventId.makeUnsafe("evt-store-roundtrip"), | |
| 25 | aggregateKind: "project", | |
| 26 | aggregateId: ProjectId.makeUnsafe("project-roundtrip"), | |
| 27 | occurredAt: now, | |
| 28 | commandId: CommandId.makeUnsafe("cmd-store-roundtrip"), | |
| 29 | causationEventId: null, | |
| 30 | correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"), | |
| 31 | metadata: { | |
| 32 | adapterKey: "codex", | |
| 33 | }, | |
| 34 | payload: { | |
| 35 | projectId: ProjectId.makeUnsafe("project-roundtrip"), | |
| 36 | title: "Roundtrip Project", | |
| 37 | workspaceRoot: "/tmp/project-roundtrip", | |
| 38 | defaultModel: null, | |
| 39 | scripts: [], | |
| 40 | createdAt: now, | |
| 41 | updatedAt: now, | |
| 42 | }, | |
| 30 | 43 | }); |
| 31 | assert.equal(saved.sequence, 1); | |
| 32 | 44 | |
| 33 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe( | |
| 34 | Effect.map(Array.from), | |
| 45 | const storedRows = yield* sql<{ | |
| 46 | readonly payloadJson: string; | |
| 47 | readonly metadataJson: string; | |
| 48 | }>` | |
| 49 | SELECT | |
| 50 | payload_json AS "payloadJson", | |
| 51 | metadata_json AS "metadataJson" | |
| 52 | FROM orchestration_events | |
| 53 | WHERE event_id = ${appended.eventId} | |
| 54 | `; | |
| 55 | assert.equal(storedRows.length, 1); | |
| 56 | assert.equal(typeof storedRows[0]?.payloadJson, "string"); | |
| 57 | assert.equal(typeof storedRows[0]?.metadataJson, "string"); | |
| 58 | ||
| 59 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe( | |
| 60 | Effect.map((chunk) => Array.from(chunk)), | |
| 61 | ); | |
| 62 | assert.equal(replayed.length, 1); | |
| 63 | assert.equal(replayed[0]?.type, "project.created"); | |
| 64 | assert.equal(replayed[0]?.metadata.adapterKey, "codex"); | |
| 65 | }), | |
| 66 | ); | |
| 67 | ||
| 68 | it.effect("fails with PersistenceDecodeError when stored json is invalid", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | const eventStore = yield* OrchestrationEventStore; | |
| 71 | const sql = yield* SqlClient.SqlClient; | |
| 72 | const now = new Date().toISOString(); | |
| 73 | ||
| 74 | yield* sql` | |
| 75 | INSERT INTO orchestration_events ( | |
| 76 | event_id, | |
| 77 | aggregate_kind, | |
| 78 | stream_id, | |
| 79 | stream_version, | |
| 80 | event_type, | |
| 81 | occurred_at, | |
| 82 | command_id, | |
| 83 | causation_event_id, | |
| 84 | correlation_id, | |
| 85 | actor_kind, | |
| 86 | payload_json, | |
| 87 | metadata_json | |
| 88 | ) | |
| 89 | VALUES ( | |
| 90 | ${EventId.makeUnsafe("evt-store-invalid-json")}, | |
| 91 | ${"project"}, | |
| 92 | ${ProjectId.makeUnsafe("project-invalid-json")}, | |
| 93 | ${0}, | |
| 94 | ${"project.created"}, | |
| 95 | ${now}, | |
| 96 | ${CommandId.makeUnsafe("cmd-store-invalid-json")}, | |
| 97 | ${null}, | |
| 98 | ${null}, | |
| 99 | ${"server"}, | |
| 100 | ${"{"}, | |
| 101 | ${"{}"} | |
| 102 | ) | |
| 103 | `; | |
| 104 | ||
| 105 | const replayResult = yield* Effect.result( | |
| 106 | Stream.runCollect(eventStore.readFromSequence(0, 10)), | |
| 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 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | |
| 1 | import { | |
| 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"; | |
| 3 | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 4 | 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 5 | 16 | import { Effect, Layer, Schema, Stream } from "effect"; |
| 6 | 17 | |
| 7 | 18 | import { |
| 8 | toPersistenceDecodeCauseError, | |
| 9 | 19 | toPersistenceDecodeError, |
| 10 | toPersistenceSerializationError, | |
| 11 | 20 | toPersistenceSqlError, |
| 12 | 21 | type OrchestrationEventStoreError, |
| 13 | 22 | } from "../Errors.ts"; |
| ... | ... | @@ -16,57 +25,77 @@ import { |
| 16 | 25 | type OrchestrationEventStoreShape, |
| 17 | 26 | } from "../Services/OrchestrationEventStore.ts"; |
| 18 | 27 | |
| 19 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema); | |
| 28 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); | |
| 29 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | |
| 30 | const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); | |
| 20 | 31 | |
| 21 | const EventRowSchema = Schema.Struct({ | |
| 22 | sequence: Schema.Number, | |
| 23 | eventId: Schema.String, | |
| 24 | type: Schema.String, | |
| 25 | aggregateType: Schema.String, | |
| 26 | aggregateId: Schema.String, | |
| 27 | occurredAt: Schema.String, | |
| 28 | commandId: Schema.NullOr(Schema.String), | |
| 29 | payloadJson: Schema.String, | |
| 32 | const AppendEventRequestSchema = Schema.Struct({ | |
| 33 | eventId: EventId, | |
| 34 | aggregateKind: OrchestrationAggregateKind, | |
| 35 | streamId: Schema.Union([ProjectId, ThreadId]), | |
| 36 | type: OrchestrationEventType, | |
| 37 | causationEventId: Schema.NullOr(EventId), | |
| 38 | correlationId: Schema.NullOr(CommandId), | |
| 39 | actorKind: OrchestrationActorKind, | |
| 40 | occurredAt: IsoDateTime, | |
| 41 | commandId: Schema.NullOr(CommandId), | |
| 42 | payloadJson: UnknownFromJsonString, | |
| 43 | metadataJson: EventMetadataFromJsonString, | |
| 30 | 44 | }); |
| 31 | 45 | |
| 32 | const AppendEventRequestSchema = Schema.Struct({ | |
| 33 | eventId: Schema.String, | |
| 34 | type: Schema.String, | |
| 35 | aggregateType: Schema.String, | |
| 36 | aggregateId: Schema.String, | |
| 37 | occurredAt: Schema.String, | |
| 38 | commandId: Schema.NullOr(Schema.String), | |
| 39 | payloadJson: Schema.String, | |
| 46 | const OrchestrationEventPersistedRowSchema = Schema.Struct({ | |
| 47 | sequence: NonNegativeInt, | |
| 48 | eventId: EventId, | |
| 49 | type: OrchestrationEventType, | |
| 50 | aggregateKind: OrchestrationAggregateKind, | |
| 51 | aggregateId: Schema.Union([ProjectId, ThreadId]), | |
| 52 | occurredAt: IsoDateTime, | |
| 53 | commandId: Schema.NullOr(CommandId), | |
| 54 | causationEventId: Schema.NullOr(EventId), | |
| 55 | correlationId: Schema.NullOr(CommandId), | |
| 56 | payload: UnknownFromJsonString, | |
| 57 | metadata: EventMetadataFromJsonString, | |
| 40 | 58 | }); |
| 41 | 59 | |
| 42 | 60 | const ReadFromSequenceRequestSchema = Schema.Struct({ |
| 43 | sequenceExclusive: Schema.Number, | |
| 61 | sequenceExclusive: NonNegativeInt, | |
| 44 | 62 | limit: Schema.Number, |
| 45 | 63 | }); |
| 46 | 64 | const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; |
| 47 | 65 | const READ_PAGE_SIZE = 500; |
| 48 | 66 | |
| 49 | function eventRowToOrchestrationEvent( | |
| 50 | row: Schema.Schema.Type<typeof EventRowSchema>, | |
| 51 | operation: string, | |
| 52 | ): Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError> { | |
| 53 | return Effect.try({ | |
| 54 | try: () => JSON.parse(row.payloadJson) as unknown, | |
| 55 | catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), | |
| 56 | }).pipe( | |
| 57 | Effect.flatMap((payload) => | |
| 58 | decodeEvent({ | |
| 59 | sequence: row.sequence, | |
| 60 | eventId: row.eventId, | |
| 61 | type: row.type, | |
| 62 | aggregateType: row.aggregateType, | |
| 63 | aggregateId: row.aggregateId, | |
| 64 | occurredAt: row.occurredAt, | |
| 65 | commandId: row.commandId, | |
| 66 | payload, | |
| 67 | }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), | |
| 68 | ), | |
| 69 | ); | |
| 67 | function inferActorKind( | |
| 68 | event: Omit<OrchestrationEvent, "sequence">, | |
| 69 | ): Schema.Schema.Type<typeof OrchestrationActorKind> { | |
| 70 | if (event.commandId !== null && event.commandId.startsWith("provider:")) { | |
| 71 | return "provider"; | |
| 72 | } | |
| 73 | if (event.commandId !== null && event.commandId.startsWith("server:")) { | |
| 74 | return "server"; | |
| 75 | } | |
| 76 | if ( | |
| 77 | event.metadata.providerSessionId !== undefined || | |
| 78 | event.metadata.providerThreadId !== undefined || | |
| 79 | event.metadata.providerTurnId !== undefined || | |
| 80 | event.metadata.providerItemId !== undefined || | |
| 81 | event.metadata.adapterKey !== undefined | |
| 82 | ) { | |
| 83 | return "provider"; | |
| 84 | } | |
| 85 | if (event.commandId === null) { | |
| 86 | return "server"; | |
| 87 | } | |
| 88 | return "client"; | |
| 89 | } | |
| 90 | ||
| 91 | function 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 | } |
| 71 | 100 | |
| 72 | 101 | const makeEventStore = Effect.gen(function* () { |
| ... | ... | @@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () { |
| 74 | 103 | |
| 75 | 104 | const appendEventRow = SqlSchema.findOne({ |
| 76 | 105 | Request: AppendEventRequestSchema, |
| 77 | Result: EventRowSchema, | |
| 106 | Result: OrchestrationEventPersistedRowSchema, | |
| 78 | 107 | execute: (request) => |
| 79 | 108 | sql` |
| 80 | 109 | INSERT INTO orchestration_events ( |
| 81 | 110 | event_id, |
| 111 | aggregate_kind, | |
| 112 | stream_id, | |
| 113 | stream_version, | |
| 82 | 114 | event_type, |
| 83 | aggregate_type, | |
| 84 | aggregate_id, | |
| 85 | 115 | occurred_at, |
| 86 | 116 | command_id, |
| 87 | payload_json | |
| 117 | causation_event_id, | |
| 118 | correlation_id, | |
| 119 | actor_kind, | |
| 120 | payload_json, | |
| 121 | metadata_json | |
| 88 | 122 | ) |
| 89 | 123 | VALUES ( |
| 90 | 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 | 138 | ${request.type}, |
| 92 | ${request.aggregateType}, | |
| 93 | ${request.aggregateId}, | |
| 94 | 139 | ${request.occurredAt}, |
| 95 | 140 | ${request.commandId}, |
| 96 | ${request.payloadJson} | |
| 141 | ${request.causationEventId}, | |
| 142 | ${request.correlationId}, | |
| 143 | ${request.actorKind}, | |
| 144 | ${request.payloadJson}, | |
| 145 | ${request.metadataJson} | |
| 97 | 146 | ) |
| 98 | 147 | RETURNING |
| 99 | 148 | sequence, |
| 100 | 149 | event_id AS "eventId", |
| 101 | 150 | event_type AS "type", |
| 102 | aggregate_type AS "aggregateType", | |
| 103 | aggregate_id AS "aggregateId", | |
| 151 | aggregate_kind AS "aggregateKind", | |
| 152 | stream_id AS "aggregateId", | |
| 104 | 153 | occurred_at AS "occurredAt", |
| 105 | 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 | }); |
| 109 | 161 | |
| 110 | 162 | const readEventRowsFromSequence = SqlSchema.findAll({ |
| 111 | 163 | Request: ReadFromSequenceRequestSchema, |
| 112 | Result: EventRowSchema, | |
| 164 | Result: OrchestrationEventPersistedRowSchema, | |
| 113 | 165 | execute: (request) => |
| 114 | 166 | sql` |
| 115 | 167 | SELECT |
| 116 | 168 | sequence, |
| 117 | 169 | event_id AS "eventId", |
| 118 | 170 | event_type AS "type", |
| 119 | aggregate_type AS "aggregateType", | |
| 120 | aggregate_id AS "aggregateId", | |
| 171 | aggregate_kind AS "aggregateKind", | |
| 172 | stream_id AS "aggregateId", | |
| 121 | 173 | occurred_at AS "occurredAt", |
| 122 | 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 | 179 | FROM orchestration_events |
| 125 | 180 | WHERE sequence > ${request.sequenceExclusive} |
| 126 | 181 | ORDER BY sequence ASC |
| ... | ... | @@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () { |
| 129 | 184 | }); |
| 130 | 185 | |
| 131 | 186 | const append: OrchestrationEventStoreShape["append"] = (event) => |
| 132 | Effect.try({ | |
| 133 | try: () => JSON.stringify(event.payload), | |
| 134 | catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"), | |
| 187 | appendEventRow({ | |
| 188 | eventId: event.eventId, | |
| 189 | aggregateKind: event.aggregateKind, | |
| 190 | streamId: event.aggregateId, | |
| 191 | type: event.type, | |
| 192 | causationEventId: event.causationEventId, | |
| 193 | correlationId: event.correlationId, | |
| 194 | actorKind: inferActorKind(event), | |
| 195 | occurredAt: event.occurredAt, | |
| 196 | commandId: event.commandId, | |
| 197 | payloadJson: event.payload, | |
| 198 | metadataJson: event.metadata, | |
| 135 | 199 | }).pipe( |
| 136 | Effect.flatMap((payloadJson) => | |
| 137 | appendEventRow({ | |
| 138 | eventId: event.eventId, | |
| 139 | type: event.type, | |
| 140 | aggregateType: event.aggregateType, | |
| 141 | aggregateId: event.aggregateId, | |
| 142 | occurredAt: event.occurredAt, | |
| 143 | commandId: event.commandId, | |
| 144 | payloadJson, | |
| 145 | }).pipe( | |
| 146 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")), | |
| 147 | Effect.flatMap((row) => | |
| 148 | eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"), | |
| 149 | ), | |
| 200 | Effect.mapError( | |
| 201 | toPersistenceSqlOrDecodeError( | |
| 202 | "OrchestrationEventStore.append:insert", | |
| 203 | "OrchestrationEventStore.append:decodeRow", | |
| 204 | ), | |
| 205 | ), | |
| 206 | Effect.flatMap((row) => | |
| 207 | decodeEvent(row).pipe( | |
| 208 | Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), | |
| 150 | 209 | ), |
| 151 | 210 | ), |
| 152 | 211 | ); |
| ... | ... | @@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () { |
| 159 | 218 | if (normalizedLimit === 0) { |
| 160 | 219 | return Stream.empty; |
| 161 | 220 | } |
| 162 | const readPage = (cursor: number, remaining: number): Stream.Stream< | |
| 163 | OrchestrationEvent, | |
| 164 | OrchestrationEventStoreError | |
| 165 | > => | |
| 221 | const readPage = ( | |
| 222 | cursor: number, | |
| 223 | remaining: number, | |
| 224 | ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> => | |
| 166 | 225 | Stream.fromEffect( |
| 167 | 226 | readEventRowsFromSequence({ |
| 168 | 227 | sequenceExclusive: cursor, |
| 169 | 228 | limit: Math.min(remaining, READ_PAGE_SIZE), |
| 170 | 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 | 236 | Effect.flatMap((rows) => |
| 173 | 237 | Effect.forEach(rows, (row) => |
| 174 | eventRowToOrchestrationEvent( | |
| 175 | row, | |
| 176 | "OrchestrationEventStore.readFromSequence:rowToEvent", | |
| 238 | decodeEvent(row).pipe( | |
| 239 | Effect.mapError( | |
| 240 | toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), | |
| 241 | ), | |
| 177 | 242 | ), |
| 178 | 243 | ), |
| 179 | 244 | ), |
| ... | ... | @@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () { |
| 204 | 269 | } satisfies OrchestrationEventStoreShape; |
| 205 | 270 | }); |
| 206 | 271 | |
| 207 | export const OrchestrationEventStoreLive = Layer.effect( | |
| 208 | OrchestrationEventStore, | |
| 209 | makeEventStore, | |
| 210 | ); | |
| 272 | export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); |
apps/server/src/persistence/Layers/ProjectionCheckpoints.ts created+177| ... | ... | @@ -0,0 +1,177 @@ |
| 1 | import { OrchestrationCheckpointFile } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | toPersistenceDecodeError, | |
| 8 | toPersistenceSqlError, | |
| 9 | } from "../Errors.ts"; | |
| 10 | import { | |
| 11 | DeleteByThreadIdInput, | |
| 12 | GetByThreadAndTurnCountInput, | |
| 13 | ListByThreadIdInput, | |
| 14 | ProjectionCheckpoint, | |
| 15 | ProjectionCheckpointRepository, | |
| 16 | type ProjectionCheckpointRepositoryShape, | |
| 17 | } from "../Services/ProjectionCheckpoints.ts"; | |
| 18 | ||
| 19 | const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( | |
| 20 | Struct.assign({ | |
| 21 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | |
| 22 | }), | |
| 23 | ); | |
| 24 | ||
| 25 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 26 | return (cause: unknown) => | |
| 27 | Schema.isSchemaError(cause) | |
| 28 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 29 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 30 | } | |
| 31 | ||
| 32 | const 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 | ||
| 174 | export const ProjectionCheckpointRepositoryLive = Layer.effect( | |
| 175 | ProjectionCheckpointRepository, | |
| 176 | makeProjectionCheckpointRepository, | |
| 177 | ); |
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts created+136| ... | ... | @@ -0,0 +1,136 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | GetProjectionPendingApprovalInput, | |
| 8 | DeleteProjectionPendingApprovalInput, | |
| 9 | ListProjectionPendingApprovalsInput, | |
| 10 | ProjectionPendingApproval, | |
| 11 | ProjectionPendingApprovalRepository, | |
| 12 | type ProjectionPendingApprovalRepositoryShape, | |
| 13 | } from "../Services/ProjectionPendingApprovals.ts"; | |
| 14 | ||
| 15 | const 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 | ||
| 133 | export const ProjectionPendingApprovalRepositoryLive = Layer.effect( | |
| 134 | ProjectionPendingApprovalRepository, | |
| 135 | makeProjectionPendingApprovalRepository, | |
| 136 | ); |
apps/server/src/persistence/Layers/ProjectionProjects.ts created+168| ... | ... | @@ -0,0 +1,168 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | DeleteProjectionProjectInput, | |
| 9 | GetProjectionProjectInput, | |
| 10 | ProjectionProject, | |
| 11 | ProjectionProjectRepository, | |
| 12 | type ProjectionProjectRepositoryShape, | |
| 13 | } from "../Services/ProjectionProjects.ts"; | |
| 14 | import { ProjectScript } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | // Makes sure that the scripts are parsed from the JSON string the DB returns | |
| 17 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | |
| 18 | Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), | |
| 19 | ); | |
| 20 | ||
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 22 | return (cause: unknown) => | |
| 23 | Schema.isSchemaError(cause) | |
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 26 | } | |
| 27 | ||
| 28 | const 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 | ||
| 165 | export const ProjectionProjectRepositoryLive = Layer.effect( | |
| 166 | ProjectionProjectRepository, | |
| 167 | makeProjectionProjectRepository, | |
| 168 | ); |
apps/server/src/persistence/Layers/ProjectionState.ts created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | import { NonNegativeInt } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Schema } from "effect"; | |
| 5 | ||
| 6 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 7 | ||
| 8 | import { | |
| 9 | ProjectionStateRepository, | |
| 10 | type ProjectionStateRepositoryShape, | |
| 11 | GetProjectionStateInput, | |
| 12 | ProjectionState, | |
| 13 | } from "../Services/ProjectionState.ts"; | |
| 14 | ||
| 15 | const MinLastAppliedSequenceRowSchema = Schema.Struct({ | |
| 16 | minLastAppliedSequence: Schema.NullOr(NonNegativeInt), | |
| 17 | }); | |
| 18 | ||
| 19 | const 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 | ||
| 113 | export const ProjectionStateRepositoryLive = Layer.effect( | |
| 114 | ProjectionStateRepository, | |
| 115 | makeProjectionStateRepository, | |
| 116 | ); |
apps/server/src/persistence/Layers/ProjectionThreadActivities.ts created+134| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | DeleteProjectionThreadActivitiesInput, | |
| 9 | ListProjectionThreadActivitiesInput, | |
| 10 | ProjectionThreadActivity, | |
| 11 | ProjectionThreadActivityRepository, | |
| 12 | type ProjectionThreadActivityRepositoryShape, | |
| 13 | } from "../Services/ProjectionThreadActivities.ts"; | |
| 14 | ||
| 15 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | |
| 16 | Struct.assign({ | |
| 17 | payload: Schema.fromJsonString(Schema.Unknown), | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 22 | return (cause: unknown) => | |
| 23 | Schema.isSchemaError(cause) | |
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 26 | } | |
| 27 | ||
| 28 | const 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 | ||
| 131 | export const ProjectionThreadActivityRepositoryLive = Layer.effect( | |
| 132 | ProjectionThreadActivityRepository, | |
| 133 | makeProjectionThreadActivityRepository, | |
| 134 | ); |
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts created+123| ... | ... | @@ -0,0 +1,123 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | ProjectionThreadMessageRepository, | |
| 8 | type ProjectionThreadMessageRepositoryShape, | |
| 9 | DeleteProjectionThreadMessagesInput, | |
| 10 | ListProjectionThreadMessagesInput, | |
| 11 | ProjectionThreadMessage, | |
| 12 | } from "../Services/ProjectionThreadMessages.ts"; | |
| 13 | ||
| 14 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | |
| 15 | Struct.assign({ | |
| 16 | isStreaming: Schema.Number, | |
| 17 | }), | |
| 18 | ); | |
| 19 | ||
| 20 | const 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 | ||
| 120 | export const ProjectionThreadMessageRepositoryLive = Layer.effect( | |
| 121 | ProjectionThreadMessageRepository, | |
| 122 | makeProjectionThreadMessageRepository, | |
| 123 | ); |
apps/server/src/persistence/Layers/ProjectionThreadSessions.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | ||
| 7 | import { | |
| 8 | ProjectionThreadSession, | |
| 9 | ProjectionThreadSessionRepository, | |
| 10 | type ProjectionThreadSessionRepositoryShape, | |
| 11 | DeleteProjectionThreadSessionInput, | |
| 12 | GetProjectionThreadSessionInput, | |
| 13 | } from "../Services/ProjectionThreadSessions.ts"; | |
| 14 | ||
| 15 | const 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 | ||
| 108 | export const ProjectionThreadSessionRepositoryLive = Layer.effect( | |
| 109 | ProjectionThreadSessionRepository, | |
| 110 | makeProjectionThreadSessionRepository, | |
| 111 | ); |
apps/server/src/persistence/Layers/ProjectionThreadTurns.ts created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | ProjectionThreadTurn, | |
| 8 | ProjectionThreadTurnRepository, | |
| 9 | type ProjectionThreadTurnRepositoryShape, | |
| 10 | DeleteProjectionThreadTurnsInput, | |
| 11 | GetProjectionThreadTurnInput, | |
| 12 | ListProjectionThreadTurnsInput, | |
| 13 | } from "../Services/ProjectionThreadTurns.ts"; | |
| 14 | ||
| 15 | const 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 | ||
| 132 | export const ProjectionThreadTurnRepositoryLive = Layer.effect( | |
| 133 | ProjectionThreadTurnRepository, | |
| 134 | makeProjectionThreadTurnRepository, | |
| 135 | ); |
apps/server/src/persistence/Layers/ProjectionThreads.ts created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | |
| 6 | import { | |
| 7 | DeleteProjectionThreadInput, | |
| 8 | GetProjectionThreadInput, | |
| 9 | ListProjectionThreadsByProjectInput, | |
| 10 | ProjectionThread, | |
| 11 | ProjectionThreadRepository, | |
| 12 | type ProjectionThreadRepositoryShape, | |
| 13 | } from "../Services/ProjectionThreads.ts"; | |
| 14 | ||
| 15 | const 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 | ||
| 140 | export const ProjectionThreadRepositoryLive = Layer.effect( | |
| 141 | ProjectionThreadRepository, | |
| 142 | makeProjectionThreadRepository, | |
| 143 | ); |
apps/server/src/persistence/Layers/Projects.test.ts deleted-56| ... | ... | @@ -1,56 +0,0 @@ |
| 1 | import { Effect, Layer, FileSystem } from "effect"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | ||
| 4 | import { ProjectRepository } from "../Services/Projects.ts"; | |
| 5 | import { ProjectRepositoryLive } from "./Projects.ts"; | |
| 6 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 7 | import { NodeServices } from "@effect/platform-node"; | |
| 8 | ||
| 9 | const layer = it.layer( | |
| 10 | Layer.mergeAll(Layer.provide(ProjectRepositoryLive, SqlitePersistenceMemory), NodeServices.layer), | |
| 11 | ); | |
| 12 | ||
| 13 | layer("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 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import type { | |
| 6 | ProjectAddInput, | |
| 7 | ProjectAddResult, | |
| 8 | ProjectListResult, | |
| 9 | ProjectRecord, | |
| 10 | ProjectScript, | |
| 11 | ProjectUpdateScriptsInput, | |
| 12 | ProjectUpdateScriptsResult, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import { | |
| 15 | normalizeProjectScripts, | |
| 16 | projectAddInputSchema, | |
| 17 | projectRecordSchema, | |
| 18 | projectRemoveInputSchema, | |
| 19 | projectScriptsSchema, | |
| 20 | projectUpdateScriptsInputSchema, | |
| 21 | } from "@t3tools/contracts"; | |
| 22 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 23 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 24 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 25 | ||
| 26 | import { | |
| 27 | PersistenceDecodeError, | |
| 28 | ProjectNotFoundError, | |
| 29 | ProjectPathMissingError, | |
| 30 | ProjectValidationError, | |
| 31 | toPersistenceDecodeCauseError, | |
| 32 | toPersistenceSerializationError, | |
| 33 | toPersistenceSqlError, | |
| 34 | type ProjectRepositoryError, | |
| 35 | } from "../Errors.ts"; | |
| 36 | import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects.ts"; | |
| 37 | ||
| 38 | const 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 | ||
| 47 | const FindByIdRequestSchema = Schema.Struct({ | |
| 48 | id: Schema.String, | |
| 49 | }); | |
| 50 | ||
| 51 | const FindByCwdRequestSchema = Schema.Struct({ | |
| 52 | cwd: Schema.String, | |
| 53 | }); | |
| 54 | ||
| 55 | const 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 | ||
| 64 | const UpdateScriptsRequestSchema = Schema.Struct({ | |
| 65 | id: Schema.String, | |
| 66 | scriptsJson: Schema.String, | |
| 67 | updatedAt: Schema.String, | |
| 68 | }); | |
| 69 | ||
| 70 | const DeleteProjectRequestSchema = Schema.Struct({ | |
| 71 | id: Schema.String, | |
| 72 | }); | |
| 73 | ||
| 74 | function 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 | ||
| 88 | function 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 | ||
| 97 | function isDirectory(cwd: string): boolean { | |
| 98 | try { | |
| 99 | return fs.statSync(cwd).isDirectory(); | |
| 100 | } catch { | |
| 101 | return false; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | function inferProjectName(cwd: string): string { | |
| 106 | const name = path.basename(cwd); | |
| 107 | return name.length > 0 ? name : "project"; | |
| 108 | } | |
| 109 | ||
| 110 | function 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 | ||
| 127 | function 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 | ||
| 137 | function 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 | ||
| 165 | function 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 | ||
| 179 | function 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 | ||
| 193 | function 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 | ||
| 207 | const 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 | ||
| 454 | export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository); |
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts created+204| ... | ... | @@ -0,0 +1,204 @@ |
| 1 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | toPersistenceDecodeError, | |
| 8 | toPersistenceSqlError, | |
| 9 | type ProviderSessionRuntimeRepositoryError, | |
| 10 | } from "../Errors.ts"; | |
| 11 | import { | |
| 12 | ProviderSessionRuntime, | |
| 13 | ProviderSessionRuntimeRepository, | |
| 14 | type ProviderSessionRuntimeRepositoryShape, | |
| 15 | } from "../Services/ProviderSessionRuntime.ts"; | |
| 16 | ||
| 17 | const 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 | ||
| 24 | const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); | |
| 25 | ||
| 26 | const GetRuntimeRequestSchema = Schema.Struct({ | |
| 27 | providerSessionId: ProviderSessionId, | |
| 28 | }); | |
| 29 | ||
| 30 | const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; | |
| 31 | ||
| 32 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 33 | return (cause: unknown): ProviderSessionRuntimeRepositoryError => | |
| 34 | Schema.isSchemaError(cause) | |
| 35 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 36 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 37 | } | |
| 38 | ||
| 39 | const 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 | ||
| 201 | export const ProviderSessionRuntimeRepositoryLive = Layer.effect( | |
| 202 | ProviderSessionRuntimeRepository, | |
| 203 | makeProviderSessionRuntimeRepository, | |
| 204 | ); |
apps/server/src/persistence/Layers/ProviderSessions.test.ts deleted-81| ... | ... | @@ -1,81 +0,0 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { NodeServices } from "@effect/platform-node"; | |
| 6 | import { it, assert } from "@effect/vitest"; | |
| 7 | import { Effect, Layer, Option } from "effect"; | |
| 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 9 | ||
| 10 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 11 | import { ProviderSessionRepository } from "../Services/ProviderSessions.ts"; | |
| 12 | import { ProviderSessionRepositoryLive } from "./ProviderSessions.ts"; | |
| 13 | ||
| 14 | function makeRepositoryLayer<E, R>( | |
| 15 | persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>, | |
| 16 | ) { | |
| 17 | return ProviderSessionRepositoryLive.pipe(Layer.provide(persistenceLayer)); | |
| 18 | } | |
| 19 | ||
| 20 | it.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 | ||
| 52 | it.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 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | 4 | import { Effect, Layer, Option, Schema } from "effect"; |
| ... | ... | @@ -12,26 +13,24 @@ import { |
| 12 | 13 | type ProviderSessionRepositoryShape, |
| 13 | 14 | } from "../Services/ProviderSessions.ts"; |
| 14 | 15 | |
| 15 | const NonEmptyString = Schema.NonEmptyString; | |
| 16 | ||
| 17 | const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]); | |
| 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 18 | 17 | |
| 19 | 18 | const ProviderSessionRowSchema = Schema.Struct({ |
| 20 | sessionId: Schema.String, | |
| 21 | provider: ProviderKindSchema, | |
| 22 | threadId: Schema.NullOr(Schema.String), | |
| 19 | sessionId: ProviderSessionId, | |
| 20 | provider: ProviderKind, | |
| 21 | threadId: Schema.NullOr(ThreadId), | |
| 23 | 22 | createdAt: Schema.String, |
| 24 | 23 | updatedAt: Schema.String, |
| 25 | 24 | }); |
| 26 | 25 | |
| 27 | 26 | const SessionIdRequestSchema = Schema.Struct({ |
| 28 | sessionId: NonEmptyString, | |
| 27 | sessionId: ProviderSessionId, | |
| 29 | 28 | }); |
| 30 | 29 | |
| 31 | 30 | const UpsertSessionRequestSchema = Schema.Struct({ |
| 32 | sessionId: NonEmptyString, | |
| 33 | provider: ProviderKindSchema, | |
| 34 | threadId: Schema.NullOr(NonEmptyString), | |
| 31 | sessionId: ProviderSessionId, | |
| 32 | provider: ProviderKind, | |
| 33 | threadId: Schema.NullOr(ThreadId), | |
| 35 | 34 | }); |
| 36 | 35 | |
| 37 | 36 | function errorMessage(cause: unknown, fallback: string): string { |
| ... | ... | @@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 163 | 162 | const parsed = yield* decodeInput( |
| 164 | 163 | UpsertSessionRequestSchema, |
| 165 | 164 | { |
| 166 | sessionId: input.sessionId.trim(), | |
| 165 | sessionId: input.sessionId, | |
| 167 | 166 | provider: input.provider, |
| 168 | threadId: input.threadId?.trim() || null, | |
| 167 | threadId: input.threadId ?? null, | |
| 169 | 168 | }, |
| 170 | 169 | "ProviderSessionRepository.upsertSession", |
| 171 | 170 | ); |
| ... | ... | @@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 182 | 181 | Effect.gen(function* () { |
| 183 | 182 | const parsed = yield* decodeInput( |
| 184 | 183 | SessionIdRequestSchema, |
| 185 | { sessionId: input.sessionId.trim() }, | |
| 184 | { sessionId: input.sessionId }, | |
| 186 | 185 | "ProviderSessionRepository.getSession", |
| 187 | 186 | ); |
| 188 | 187 | |
| ... | ... | @@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 207 | 206 | Effect.gen(function* () { |
| 208 | 207 | const parsed = yield* decodeInput( |
| 209 | 208 | SessionIdRequestSchema, |
| 210 | { sessionId: input.sessionId.trim() }, | |
| 209 | { sessionId: input.sessionId }, | |
| 211 | 210 | "ProviderSessionRepository.deleteSession", |
| 212 | 211 | ); |
| 213 | 212 |
apps/server/src/persistence/Migrations.ts+8-6| ... | ... | @@ -13,9 +13,10 @@ import * as Layer from "effect/Layer"; |
| 13 | 13 | |
| 14 | 14 | // Import all migrations statically |
| 15 | 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 16 | import Migration0002 from "./Migrations/002_Projects.ts"; | |
| 17 | import Migration0003 from "./Migrations/003_Checkpoints.ts"; | |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessions.ts"; | |
| 16 | import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; | |
| 17 | import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; | |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; | |
| 19 | import Migration0005 from "./Migrations/005_Projections.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** |
| 21 | 22 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts"; |
| 29 | 30 | */ |
| 30 | 31 | const loader = Migrator.fromRecord({ |
| 31 | 32 | "1_OrchestrationEvents": Migration0001, |
| 32 | "2_Projects": Migration0002, | |
| 33 | "3_CheckpointCatalog": Migration0003, | |
| 34 | "4_ProviderSessions": Migration0004, | |
| 33 | "2_OrchestrationCommandReceipts": Migration0002, | |
| 34 | "3_CheckpointDiffBlobs": Migration0003, | |
| 35 | "4_ProviderSessionRuntime": Migration0004, | |
| 36 | "5_Projections": Migration0005, | |
| 35 | 37 | }); |
| 36 | 38 | |
| 37 | 39 | /** |
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+25-5| ... | ... | @@ -8,17 +8,37 @@ export default Effect.gen(function* () { |
| 8 | 8 | CREATE TABLE IF NOT EXISTS orchestration_events ( |
| 9 | 9 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, |
| 10 | 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 | 14 | event_type TEXT NOT NULL, |
| 12 | aggregate_type TEXT NOT NULL, | |
| 13 | aggregate_id TEXT NOT NULL, | |
| 14 | 15 | occurred_at TEXT NOT NULL, |
| 15 | 16 | command_id TEXT, |
| 16 | payload_json TEXT NOT NULL | |
| 17 | causation_event_id TEXT, | |
| 18 | correlation_id TEXT, | |
| 19 | actor_kind TEXT NOT NULL, | |
| 20 | payload_json TEXT NOT NULL, | |
| 21 | metadata_json TEXT NOT NULL | |
| 17 | 22 | ) |
| 18 | 23 | `; |
| 19 | 24 | |
| 20 | 25 | yield* sql` |
| 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | |
| 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | |
| 26 | CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version | |
| 27 | ON orchestration_events(aggregate_kind, stream_id, stream_version) | |
| 28 | `; | |
| 29 | ||
| 30 | yield* sql` | |
| 31 | CREATE INDEX IF NOT EXISTS idx_orch_events_stream_sequence | |
| 32 | ON orchestration_events(aggregate_kind, stream_id, sequence) | |
| 33 | `; | |
| 34 | ||
| 35 | yield* sql` | |
| 36 | CREATE INDEX IF NOT EXISTS idx_orch_events_command_id | |
| 37 | ON orchestration_events(command_id) | |
| 38 | `; | |
| 39 | ||
| 40 | yield* sql` | |
| 41 | CREATE INDEX IF NOT EXISTS idx_orch_events_correlation_id | |
| 42 | ON orchestration_events(correlation_id) | |
| 23 | 43 | `; |
| 24 | 44 | }); |
apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import { IsoDateTime, NonNegativeInt, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { CheckpointDiffBlobRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export const CheckpointDiffBlob = Schema.Struct({ | |
| 8 | threadId: ThreadId, | |
| 9 | fromTurnCount: NonNegativeInt, | |
| 10 | toTurnCount: NonNegativeInt, | |
| 11 | diff: Schema.String, | |
| 12 | createdAt: IsoDateTime, | |
| 13 | }); | |
| 14 | export type CheckpointDiffBlob = typeof CheckpointDiffBlob.Type; | |
| 15 | ||
| 16 | export const GetCheckpointDiffBlobInput = Schema.Struct({ | |
| 17 | threadId: ThreadId, | |
| 18 | fromTurnCount: NonNegativeInt, | |
| 19 | toTurnCount: NonNegativeInt, | |
| 20 | }); | |
| 21 | export type GetCheckpointDiffBlobInput = typeof GetCheckpointDiffBlobInput.Type; | |
| 22 | ||
| 23 | export 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 | ||
| 33 | export 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 | */ | |
| 10 | import type { ProviderCheckpoint } from "@t3tools/contracts"; | |
| 11 | import { Option, ServiceMap } from "effect"; | |
| 12 | import type { Effect } from "effect"; | |
| 13 | ||
| 14 | import type { CheckpointRepositoryError } from "../Errors.ts"; | |
| 15 | ||
| 16 | export 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 | ||
| 28 | export interface CheckpointRepositoryEntry extends ProviderCheckpoint { | |
| 29 | readonly threadId: string; | |
| 30 | readonly checkpointRef: string; | |
| 31 | readonly createdAt: string; | |
| 32 | } | |
| 33 | ||
| 34 | export interface ListThreadCheckpointsInput { | |
| 35 | readonly threadId: string; | |
| 36 | } | |
| 37 | ||
| 38 | export interface GetCheckpointInput { | |
| 39 | readonly threadId: string; | |
| 40 | readonly turnCount: number; | |
| 41 | } | |
| 42 | ||
| 43 | export interface DeleteAfterTurnInput { | |
| 44 | readonly threadId: string; | |
| 45 | readonly maxTurnCount: number; | |
| 46 | } | |
| 47 | ||
| 48 | export interface DeleteAllForThreadInput { | |
| 49 | readonly threadId: string; | |
| 50 | } | |
| 51 | ||
| 52 | export 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 | */ | |
| 92 | export 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 @@ |
| 1 | import { | |
| 2 | CommandId, | |
| 3 | IsoDateTime, | |
| 4 | NonNegativeInt, | |
| 5 | OrchestrationAggregateKind, | |
| 6 | OrchestrationCommandReceiptStatus, | |
| 7 | ProjectId, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | |
| 11 | import type { Effect } from "effect"; | |
| 12 | ||
| 13 | import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; | |
| 14 | ||
| 15 | export 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 | }); | |
| 24 | export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; | |
| 25 | ||
| 26 | export const GetByCommandIdInput = Schema.Struct({ | |
| 27 | commandId: CommandId, | |
| 28 | }); | |
| 29 | export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; | |
| 30 | ||
| 31 | export 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 | ||
| 44 | export 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 | 9 | * |
| 10 | 10 | * @module OrchestrationEventStore |
| 11 | 11 | */ |
| 12 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 12 | import { OrchestrationEvent } from "@t3tools/contracts"; | |
| 13 | 13 | import { ServiceMap } from "effect"; |
| 14 | 14 | import type { Effect, Stream } from "effect"; |
| 15 | 15 |
apps/server/src/persistence/Services/ProjectionCheckpoints.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | IsoDateTime, | |
| 4 | MessageId, | |
| 5 | NonNegativeInt, | |
| 6 | OrchestrationCheckpointFile, | |
| 7 | OrchestrationCheckpointStatus, | |
| 8 | ThreadId, | |
| 9 | TurnId, | |
| 10 | } from "@t3tools/contracts"; | |
| 11 | import { Option, ServiceMap, Schema } from "effect"; | |
| 12 | import type { Effect } from "effect"; | |
| 13 | ||
| 14 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 15 | ||
| 16 | export 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 | }); | |
| 26 | export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; | |
| 27 | ||
| 28 | export const ListByThreadIdInput = Schema.Struct({ | |
| 29 | threadId: ThreadId, | |
| 30 | }); | |
| 31 | export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; | |
| 32 | ||
| 33 | export const GetByThreadAndTurnCountInput = Schema.Struct({ | |
| 34 | threadId: ThreadId, | |
| 35 | checkpointTurnCount: NonNegativeInt, | |
| 36 | }); | |
| 37 | export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; | |
| 38 | ||
| 39 | export const DeleteByThreadIdInput = Schema.Struct({ | |
| 40 | threadId: ThreadId, | |
| 41 | }); | |
| 42 | export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; | |
| 43 | ||
| 44 | export 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 | ||
| 60 | export 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 @@ |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | IsoDateTime, | |
| 4 | ProjectionPendingApprovalDecision, | |
| 5 | ProjectionPendingApprovalStatus, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { Option, Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export 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 | }); | |
| 23 | export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; | |
| 24 | ||
| 25 | export const ListProjectionPendingApprovalsInput = Schema.Struct({ | |
| 26 | threadId: ThreadId, | |
| 27 | }); | |
| 28 | export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; | |
| 29 | ||
| 30 | export const GetProjectionPendingApprovalInput = Schema.Struct({ | |
| 31 | requestId: ApprovalRequestId, | |
| 32 | }); | |
| 33 | export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; | |
| 34 | ||
| 35 | export const DeleteProjectionPendingApprovalInput = Schema.Struct({ | |
| 36 | requestId: ApprovalRequestId, | |
| 37 | }); | |
| 38 | export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; | |
| 39 | ||
| 40 | export 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 | ||
| 58 | export 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 @@ |
| 1 | import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export 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 | }); | |
| 17 | export type ProjectionProject = typeof ProjectionProject.Type; | |
| 18 | ||
| 19 | export const GetProjectionProjectInput = Schema.Struct({ | |
| 20 | projectId: ProjectId, | |
| 21 | }); | |
| 22 | export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; | |
| 23 | ||
| 24 | export const DeleteProjectionProjectInput = Schema.Struct({ | |
| 25 | projectId: ProjectId, | |
| 26 | }); | |
| 27 | export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; | |
| 28 | ||
| 29 | export 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 | ||
| 46 | export 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 @@ |
| 1 | import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export const ProjectionState = Schema.Struct({ | |
| 8 | projector: Schema.String, | |
| 9 | lastAppliedSequence: NonNegativeInt, | |
| 10 | updatedAt: IsoDateTime, | |
| 11 | }); | |
| 12 | export type ProjectionState = typeof ProjectionState.Type; | |
| 13 | ||
| 14 | export const GetProjectionStateInput = Schema.Struct({ | |
| 15 | projector: Schema.String, | |
| 16 | }); | |
| 17 | export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; | |
| 18 | ||
| 19 | export 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 | ||
| 31 | export 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 @@ |
| 1 | import { | |
| 2 | EventId, | |
| 3 | IsoDateTime, | |
| 4 | OrchestrationThreadActivityTone, | |
| 5 | ThreadId, | |
| 6 | TurnId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Schema, ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 12 | ||
| 13 | export 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 | }); | |
| 23 | export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; | |
| 24 | ||
| 25 | export const ListProjectionThreadActivitiesInput = Schema.Struct({ | |
| 26 | threadId: ThreadId, | |
| 27 | }); | |
| 28 | export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; | |
| 29 | ||
| 30 | export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ | |
| 31 | threadId: ThreadId, | |
| 32 | }); | |
| 33 | export type DeleteProjectionThreadActivitiesInput = | |
| 34 | typeof DeleteProjectionThreadActivitiesInput.Type; | |
| 35 | ||
| 36 | export 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 | ||
| 50 | export 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 @@ |
| 1 | import { | |
| 2 | OrchestrationMessageRole, | |
| 3 | MessageId, | |
| 4 | ThreadId, | |
| 5 | TurnId, | |
| 6 | IsoDateTime, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Schema, ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 12 | ||
| 13 | export 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 | }); | |
| 23 | export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; | |
| 24 | ||
| 25 | export const ListProjectionThreadMessagesInput = Schema.Struct({ | |
| 26 | threadId: ThreadId, | |
| 27 | }); | |
| 28 | export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; | |
| 29 | ||
| 30 | export const DeleteProjectionThreadMessagesInput = Schema.Struct({ | |
| 31 | threadId: ThreadId, | |
| 32 | }); | |
| 33 | export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; | |
| 34 | ||
| 35 | export 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 | ||
| 49 | export 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 @@ |
| 1 | import { | |
| 2 | IsoDateTime, | |
| 3 | OrchestrationSessionStatus, | |
| 4 | ProviderSessionId, | |
| 5 | ProviderThreadId, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { Option, Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export 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 | }); | |
| 24 | export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; | |
| 25 | ||
| 26 | export const GetProjectionThreadSessionInput = Schema.Struct({ | |
| 27 | threadId: ThreadId, | |
| 28 | }); | |
| 29 | export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; | |
| 30 | ||
| 31 | export const DeleteProjectionThreadSessionInput = Schema.Struct({ | |
| 32 | threadId: ThreadId, | |
| 33 | }); | |
| 34 | export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; | |
| 35 | ||
| 36 | export 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 | ||
| 48 | export 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 @@ |
| 1 | import { | |
| 2 | IsoDateTime, | |
| 3 | MessageId, | |
| 4 | NonNegativeInt, | |
| 5 | ProjectionThreadTurnStatus, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { Option, Schema, ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export 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 | }); | |
| 24 | export type ProjectionThreadTurn = typeof ProjectionThreadTurn.Type; | |
| 25 | ||
| 26 | export const ListProjectionThreadTurnsInput = Schema.Struct({ | |
| 27 | threadId: ThreadId, | |
| 28 | }); | |
| 29 | export type ListProjectionThreadTurnsInput = typeof ListProjectionThreadTurnsInput.Type; | |
| 30 | ||
| 31 | export const GetProjectionThreadTurnInput = Schema.Struct({ | |
| 32 | turnId: TurnId, | |
| 33 | }); | |
| 34 | export type GetProjectionThreadTurnInput = typeof GetProjectionThreadTurnInput.Type; | |
| 35 | ||
| 36 | export const DeleteProjectionThreadTurnsInput = Schema.Struct({ | |
| 37 | threadId: ThreadId, | |
| 38 | }); | |
| 39 | export type DeleteProjectionThreadTurnsInput = typeof DeleteProjectionThreadTurnsInput.Type; | |
| 40 | ||
| 41 | export 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 | ||
| 57 | export 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 @@ |
| 1 | import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { ProjectionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export 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 | }); | |
| 19 | export type ProjectionThread = typeof ProjectionThread.Type; | |
| 20 | ||
| 21 | export const GetProjectionThreadInput = Schema.Struct({ | |
| 22 | threadId: ThreadId, | |
| 23 | }); | |
| 24 | export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; | |
| 25 | ||
| 26 | export const DeleteProjectionThreadInput = Schema.Struct({ | |
| 27 | threadId: ThreadId, | |
| 28 | }); | |
| 29 | export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; | |
| 30 | ||
| 31 | export const ListProjectionThreadsByProjectInput = Schema.Struct({ | |
| 32 | projectId: ProjectId, | |
| 33 | }); | |
| 34 | export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; | |
| 35 | ||
| 36 | export 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 | ||
| 52 | export 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 | */ | |
| 12 | import type { | |
| 13 | ProjectAddInput, | |
| 14 | ProjectAddResult, | |
| 15 | ProjectListResult, | |
| 16 | ProjectRemoveInput, | |
| 17 | ProjectUpdateScriptsInput, | |
| 18 | ProjectUpdateScriptsResult, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { ServiceMap } from "effect"; | |
| 21 | import type { Effect } from "effect"; | |
| 22 | ||
| 23 | import type { ProjectRepositoryError } from "../Errors.ts"; | |
| 24 | ||
| 25 | export 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 | */ | |
| 78 | export 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 @@ |
| 1 | import { | |
| 2 | IsoDateTime, | |
| 3 | ProviderSessionId, | |
| 4 | ProviderSessionRuntimeStatus, | |
| 5 | ProviderThreadId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { Option, Schema, ServiceMap } from "effect"; | |
| 9 | import type { Effect } from "effect"; | |
| 10 | ||
| 11 | import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; | |
| 12 | ||
| 13 | export 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 | }); | |
| 24 | export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; | |
| 25 | ||
| 26 | export const GetProviderSessionRuntimeInput = Schema.Struct({ | |
| 27 | providerSessionId: ProviderSessionId, | |
| 28 | }); | |
| 29 | export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; | |
| 30 | ||
| 31 | export const DeleteProviderSessionRuntimeInput = Schema.Struct({ | |
| 32 | providerSessionId: ProviderSessionId, | |
| 33 | }); | |
| 34 | export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; | |
| 35 | ||
| 36 | export 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 | ||
| 55 | export 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 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 1 | import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { Option, ServiceMap } from "effect"; |
| 3 | 3 | import type { Effect } from "effect"; |
| 4 | 4 | |
| 5 | 5 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; |
| 6 | 6 | |
| 7 | 7 | export interface ProviderSessionEntry { |
| 8 | readonly sessionId: string; | |
| 8 | readonly sessionId: ProviderSessionId; | |
| 9 | 9 | readonly provider: ProviderKind; |
| 10 | readonly threadId?: string; | |
| 10 | readonly threadId?: ThreadId; | |
| 11 | 11 | readonly createdAt: string; |
| 12 | 12 | readonly updatedAt: string; |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | export interface UpsertProviderSessionInput { |
| 16 | readonly sessionId: string; | |
| 16 | readonly sessionId: ProviderSessionId; | |
| 17 | 17 | readonly provider: ProviderKind; |
| 18 | readonly threadId?: string; | |
| 18 | readonly threadId?: ThreadId; | |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | export interface DeleteProviderSessionInput { |
| 22 | readonly sessionId: string; | |
| 22 | readonly sessionId: ProviderSessionId; | |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | export interface GetProviderSessionInput { |
| 26 | readonly sessionId: string; | |
| 26 | readonly sessionId: ProviderSessionId; | |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | export interface ProviderSessionRepositoryShape { |
apps/server/src/provider/Layers/CodexAdapter.ts+26-12| ... | ... | @@ -6,13 +6,18 @@ |
| 6 | 6 | * |
| 7 | 7 | * @module CodexAdapterLive |
| 8 | 8 | */ |
| 9 | import type { | |
| 10 | ProviderEvent, | |
| 11 | ProviderRuntimeEvent, | |
| 12 | ProviderRuntimeTurnStatus, | |
| 13 | ProviderRuntimeToolKind, | |
| 9 | import { | |
| 10 | type ProviderEvent, | |
| 11 | type ProviderRuntimeEvent, | |
| 12 | type ProviderRuntimeTurnStatus, | |
| 13 | type ProviderRuntimeToolKind, | |
| 14 | ProviderItemId, | |
| 15 | ProviderApprovalDecision, | |
| 16 | ProviderSessionId, | |
| 17 | ProviderThreadId, | |
| 18 | ProviderTurnId, | |
| 14 | 19 | } from "@t3tools/contracts"; |
| 15 | import { Effect, Layer, Queue, Stream } from "effect"; | |
| 20 | import { Effect, Layer, Queue, Schema, Stream } from "effect"; | |
| 16 | 21 | |
| 17 | 22 | import { |
| 18 | 23 | ProviderAdapterProcessError, |
| ... | ... | @@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string { |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | 49 | function toSessionError( |
| 45 | sessionId: string, | |
| 50 | sessionId: ProviderSessionId, | |
| 46 | 51 | cause: unknown, |
| 47 | 52 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { |
| 48 | 53 | const normalized = toMessage(cause, "").toLowerCase(); |
| ... | ... | @@ -63,7 +68,11 @@ function toSessionError( |
| 63 | 68 | return undefined; |
| 64 | 69 | } |
| 65 | 70 | |
| 66 | function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError { | |
| 71 | function toRequestError( | |
| 72 | sessionId: ProviderSessionId, | |
| 73 | method: string, | |
| 74 | cause: unknown, | |
| 75 | ): ProviderAdapterError { | |
| 67 | 76 | const sessionError = toSessionError(sessionId, cause); |
| 68 | 77 | if (sessionError) { |
| 69 | 78 | return sessionError; |
| ... | ... | @@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | |
| 188 | 197 | provider: event.provider, |
| 189 | 198 | sessionId: event.sessionId, |
| 190 | 199 | createdAt: event.createdAt, |
| 191 | itemId, | |
| 200 | itemId: ProviderItemId.makeUnsafe(itemId), | |
| 192 | 201 | ...(event.threadId ? { threadId: event.threadId } : {}), |
| 193 | 202 | ...(event.turnId ? { turnId: event.turnId } : {}), |
| 194 | 203 | }; |
| ... | ... | @@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 282 | 291 | } |
| 283 | 292 | |
| 284 | 293 | if (event.method === "item/requestApproval/decision" && event.requestId) { |
| 285 | const decision = asString(payload?.decision); | |
| 294 | const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision); | |
| 286 | 295 | return [ |
| 287 | 296 | { |
| 288 | 297 | type: "approval.resolved", |
| ... | ... | @@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 339 | 348 | } |
| 340 | 349 | |
| 341 | 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 | 355 | if (!threadId) { |
| 344 | 356 | return []; |
| 345 | 357 | } |
| ... | ... | @@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 356 | 368 | } |
| 357 | 369 | |
| 358 | 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 | 374 | if (!turnId) { |
| 361 | 375 | return []; |
| 362 | 376 | } |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-5| ... | ... | @@ -7,7 +7,6 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module ProviderAdapterRegistryLive |
| 9 | 9 | */ |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 11 | 10 | import { Effect, Layer } from "effect"; |
| 12 | 11 | |
| 13 | 12 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; |
| ... | ... | @@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions { |
| 25 | 24 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => |
| 26 | 25 | Effect.gen(function* () { |
| 27 | 26 | const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; |
| 28 | const byProvider = new Map<ProviderKind, ProviderAdapterShape<ProviderAdapterError>>(); | |
| 29 | for (const adapter of adapters) { | |
| 30 | byProvider.set(adapter.provider, adapter); | |
| 31 | } | |
| 27 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); | |
| 32 | 28 | |
| 33 | 29 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
| 34 | 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 | 4 | |
| 5 | 5 | import type { |
| 6 | 6 | ProviderApprovalDecision, |
| 7 | ProviderGetCheckpointDiffInput, | |
| 8 | ProviderGetCheckpointDiffResult, | |
| 9 | ProviderListCheckpointsInput, | |
| 10 | ProviderListCheckpointsResult, | |
| 11 | ProviderRevertToCheckpointInput, | |
| 12 | 7 | ProviderRuntimeEvent, |
| 13 | ProviderRevertToCheckpointResult, | |
| 14 | 8 | ProviderSendTurnInput, |
| 15 | 9 | ProviderSession, |
| 16 | ProviderSessionStartInput, | |
| 17 | 10 | ProviderTurnStartResult, |
| 18 | 11 | } from "@t3tools/contracts"; |
| 19 | import { providerSessionStartInputSchema } from "@t3tools/contracts"; | |
| 12 | import { | |
| 13 | ApprovalRequestId, | |
| 14 | EventId, | |
| 15 | ProviderSessionStartInput, | |
| 16 | ProviderSessionId, | |
| 17 | ProviderThreadId, | |
| 18 | ProviderTurnId, | |
| 19 | ThreadId, | |
| 20 | } from "@t3tools/contracts"; | |
| 20 | 21 | import { it, assert, vi } from "@effect/vitest"; |
| 21 | 22 | import { assertFailure } from "@effect/vitest/utils"; |
| 22 | 23 | |
| 23 | import { Effect, Fiber, Layer, PubSub, Ref, Stream } from "effect"; | |
| 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect"; | |
| 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 24 | 26 | |
| 25 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; | |
| 26 | 27 | import { |
| 27 | 28 | ProviderAdapterSessionNotFoundError, |
| 28 | 29 | ProviderSessionNotFoundError, |
| ... | ... | @@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 34 | 35 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 35 | 36 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 36 | 37 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 37 | import { ProviderServiceLive } from "./ProviderService.ts"; | |
| 38 | import { makeProviderServiceLive } from "./ProviderService.ts"; | |
| 38 | 39 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 39 | 40 | import { NodeServices } from "@effect/platform-node"; |
| 40 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 41 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | |
| 42 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 41 | 43 | import { |
| 42 | 44 | makeSqlitePersistenceLive, |
| 43 | 45 | SqlitePersistenceMemory, |
| 44 | 46 | } from "../../persistence/Layers/Sqlite.ts"; |
| 45 | 47 | |
| 48 | function 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 | ||
| 59 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 60 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 61 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 62 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | |
| 63 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 64 | ||
| 46 | 65 | function makeFakeCodexAdapter() { |
| 47 | const sessions = new Map<string, ProviderSession>(); | |
| 66 | const sessions = new Map<ProviderSessionId, ProviderSession>(); | |
| 48 | 67 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 49 | 68 | let nextSession = 1; |
| 50 | 69 | |
| 51 | 70 | const startSession = vi.fn((input: ProviderSessionStartInput) => |
| 52 | 71 | Effect.sync(() => { |
| 53 | 72 | const now = new Date().toISOString(); |
| 73 | const next = nextSession; | |
| 54 | 74 | const session: ProviderSession = { |
| 55 | sessionId: `sess-${nextSession}`, | |
| 75 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), | |
| 56 | 76 | provider: "codex", |
| 57 | 77 | status: "ready", |
| 58 | threadId: input.resumeThreadId ?? `thread-${nextSession}`, | |
| 78 | threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), | |
| 59 | 79 | cwd: input.cwd ?? process.cwd(), |
| 60 | 80 | createdAt: now, |
| 61 | 81 | updatedAt: now, |
| ... | ... | @@ -80,8 +100,8 @@ function makeFakeCodexAdapter() { |
| 80 | 100 | } |
| 81 | 101 | |
| 82 | 102 | return Effect.succeed({ |
| 83 | threadId: "thread-1", | |
| 84 | turnId: "turn-1", | |
| 103 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 104 | turnId: ProviderTurnId.makeUnsafe("turn-1"), | |
| 85 | 105 | }); |
| 86 | 106 | }, |
| 87 | 107 | ); |
| ... | ... | @@ -102,7 +122,7 @@ function makeFakeCodexAdapter() { |
| 102 | 122 | const stopSession = vi.fn( |
| 103 | 123 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => |
| 104 | 124 | Effect.sync(() => { |
| 105 | sessions.delete(sessionId); | |
| 125 | sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); | |
| 106 | 126 | }), |
| 107 | 127 | ); |
| 108 | 128 | |
| ... | ... | @@ -112,22 +132,29 @@ function makeFakeCodexAdapter() { |
| 112 | 132 | ); |
| 113 | 133 | |
| 114 | 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 | ); |
| 117 | 138 | |
| 118 | 139 | const readThread = vi.fn( |
| 119 | 140 | ( |
| 120 | _sessionId: string, | |
| 121 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | |
| 122 | Effect.succeed({ threadId: "thread-1", turns: [] }), | |
| 141 | _sessionId: ProviderSessionId, | |
| 142 | ): Effect.Effect< | |
| 143 | { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> }, | |
| 144 | ProviderAdapterError | |
| 145 | > => | |
| 146 | Effect.succeed({ | |
| 147 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | |
| 148 | turns: [{ id: asTurnId("turn-1"), items: [] }], | |
| 149 | }), | |
| 123 | 150 | ); |
| 124 | 151 | |
| 125 | 152 | const rollbackThread = vi.fn( |
| 126 | 153 | ( |
| 127 | _sessionId: string, | |
| 154 | _sessionId: ProviderSessionId, | |
| 128 | 155 | _numTurns: number, |
| 129 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | |
| 130 | Effect.succeed({ threadId: "thread-1", turns: [] }), | |
| 156 | ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => | |
| 157 | Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), | |
| 131 | 158 | ); |
| 132 | 159 | |
| 133 | 160 | const stopAll = vi.fn( |
| ... | ... | @@ -172,80 +199,11 @@ function makeFakeCodexAdapter() { |
| 172 | 199 | }; |
| 173 | 200 | } |
| 174 | 201 | |
| 175 | function makeCheckpointServiceDouble() { | |
| 176 | const initializeForSession = vi.fn( | |
| 177 | (_: { providerSessionId: string; cwd: string }) => Effect.void, | |
| 178 | ); | |
| 179 | const captureCurrentTurn = vi.fn( | |
| 180 | (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void, | |
| 181 | ); | |
| 182 | const listCheckpoints = vi.fn( | |
| 183 | (_input: ProviderListCheckpointsInput): Effect.Effect<ProviderListCheckpointsResult> => | |
| 184 | Effect.succeed({ | |
| 185 | threadId: "thread-1", | |
| 186 | checkpoints: [ | |
| 187 | { | |
| 188 | id: "root", | |
| 189 | turnCount: 0, | |
| 190 | messageCount: 0, | |
| 191 | label: "Start of conversation", | |
| 192 | isCurrent: true, | |
| 193 | }, | |
| 194 | ], | |
| 195 | }), | |
| 196 | ); | |
| 197 | const getCheckpointDiff = vi.fn( | |
| 198 | (input: ProviderGetCheckpointDiffInput): Effect.Effect<ProviderGetCheckpointDiffResult> => | |
| 199 | Effect.succeed({ | |
| 200 | threadId: "thread-1", | |
| 201 | fromTurnCount: input.fromTurnCount, | |
| 202 | toTurnCount: input.toTurnCount, | |
| 203 | diff: "diff --git a/a.ts b/a.ts", | |
| 204 | }), | |
| 205 | ); | |
| 206 | const revertToCheckpoint = vi.fn( | |
| 207 | (input: ProviderRevertToCheckpointInput): Effect.Effect<ProviderRevertToCheckpointResult> => | |
| 208 | Effect.succeed({ | |
| 209 | threadId: "thread-1", | |
| 210 | turnCount: input.turnCount, | |
| 211 | messageCount: 0, | |
| 212 | rolledBackTurns: 0, | |
| 213 | checkpoints: [ | |
| 214 | { | |
| 215 | id: "root", | |
| 216 | turnCount: 0, | |
| 217 | messageCount: 0, | |
| 218 | label: "Start of conversation", | |
| 219 | isCurrent: input.turnCount === 0, | |
| 220 | }, | |
| 221 | ], | |
| 222 | }), | |
| 223 | ); | |
| 224 | const releaseSession = vi.fn((_input: { providerSessionId: string }) => Effect.void); | |
| 225 | ||
| 226 | const service: typeof CheckpointService.Service = { | |
| 227 | initializeForSession, | |
| 228 | captureCurrentTurn, | |
| 229 | listCheckpoints, | |
| 230 | getCheckpointDiff, | |
| 231 | revertToCheckpoint, | |
| 232 | releaseSession, | |
| 233 | }; | |
| 234 | ||
| 235 | return { | |
| 236 | service, | |
| 237 | initializeForSession, | |
| 238 | captureCurrentTurn, | |
| 239 | listCheckpoints, | |
| 240 | getCheckpointDiff, | |
| 241 | revertToCheckpoint, | |
| 242 | releaseSession, | |
| 243 | }; | |
| 244 | } | |
| 202 | const sleep = (ms: number) => | |
| 203 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | |
| 245 | 204 | |
| 246 | 205 | function makeProviderServiceLayer() { |
| 247 | 206 | const codex = makeFakeCodexAdapter(); |
| 248 | const checkpoint = makeCheckpointServiceDouble(); | |
| 249 | 207 | const registry: typeof ProviderAdapterRegistry.Service = { |
| 250 | 208 | getByProvider: (provider) => |
| 251 | 209 | provider === "codex" |
| ... | ... | @@ -254,28 +212,26 @@ function makeProviderServiceLayer() { |
| 254 | 212 | listProviders: () => Effect.succeed(["codex"]), |
| 255 | 213 | }; |
| 256 | 214 | |
| 257 | const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); | |
| 258 | 215 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| 259 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 260 | Layer.provide(ProviderSessionRepositoryLive), | |
| 216 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 261 | 217 | Layer.provide(SqlitePersistenceMemory), |
| 262 | 218 | ); |
| 219 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 263 | 220 | |
| 264 | 221 | const layer = it.layer( |
| 265 | 222 | Layer.mergeAll( |
| 266 | ProviderServiceLive.pipe( | |
| 223 | makeProviderServiceLive().pipe( | |
| 267 | 224 | Layer.provide(providerAdapterLayer), |
| 268 | 225 | Layer.provide(directoryLayer), |
| 269 | Layer.provide(checkpointLayer), | |
| 270 | 226 | ), |
| 271 | 227 | directoryLayer, |
| 228 | runtimeRepositoryLayer, | |
| 272 | 229 | NodeServices.layer, |
| 273 | 230 | ), |
| 274 | 231 | ); |
| 275 | 232 | |
| 276 | 233 | return { |
| 277 | 234 | codex, |
| 278 | checkpoint, | |
| 279 | 235 | layer, |
| 280 | 236 | }; |
| 281 | 237 | } |
| ... | ... | @@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 287 | 243 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 288 | 244 | |
| 289 | 245 | const codex = makeFakeCodexAdapter(); |
| 290 | const checkpoint = makeCheckpointServiceDouble(); | |
| 291 | 246 | const registry: typeof ProviderAdapterRegistry.Service = { |
| 292 | 247 | getByProvider: (provider) => |
| 293 | 248 | provider === "codex" |
| ... | ... | @@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 297 | 252 | }; |
| 298 | 253 | |
| 299 | 254 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 300 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 301 | Layer.provide(ProviderSessionRepositoryLive), | |
| 255 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 302 | 256 | Layer.provide(persistenceLayer), |
| 303 | 257 | ); |
| 258 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 304 | 259 | |
| 305 | 260 | yield* Effect.gen(function* () { |
| 306 | 261 | const directory = yield* ProviderSessionDirectory; |
| 307 | 262 | yield* directory.upsert({ |
| 308 | sessionId: "sess-stale", | |
| 263 | sessionId: asSessionId("sess-stale"), | |
| 309 | 264 | provider: "codex", |
| 310 | threadId: "thread-stale", | |
| 265 | threadId: ThreadId.makeUnsafe("thread-stale"), | |
| 311 | 266 | }); |
| 312 | 267 | }).pipe(Effect.provide(directoryLayer)); |
| 313 | 268 | |
| 314 | const providerLayer = ProviderServiceLive.pipe( | |
| 269 | const providerLayer = makeProviderServiceLive().pipe( | |
| 315 | 270 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 316 | 271 | Layer.provide(directoryLayer), |
| 317 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | |
| 318 | 272 | ); |
| 319 | 273 | |
| 320 | checkpoint.releaseSession.mockClear(); | |
| 321 | 274 | yield* Effect.gen(function* () { |
| 322 | 275 | yield* ProviderService; |
| 323 | 276 | }).pipe(Effect.provide(providerLayer)); |
| 324 | 277 | |
| 325 | assert.deepEqual(checkpoint.releaseSession.mock.calls, []); | |
| 326 | 278 | const persistedProvider = yield* Effect.gen(function* () { |
| 327 | 279 | const directory = yield* ProviderSessionDirectory; |
| 328 | return yield* directory.getProvider("sess-stale"); | |
| 280 | return yield* directory.getProvider(asSessionId("sess-stale")); | |
| 329 | 281 | }).pipe(Effect.provide(directoryLayer)); |
| 330 | 282 | assert.equal(persistedProvider, "codex"); |
| 331 | 283 | |
| 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 | 300 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 333 | 301 | }).pipe(Effect.provide(NodeServices.layer)), |
| 334 | 302 | ); |
| 335 | 303 | |
| 336 | it.effect("ProviderServiceLive restores checkpoint RPC routing after restart using persisted thread mapping", () => | |
| 304 | it.effect("ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => | |
| 337 | 305 | Effect.gen(function* () { |
| 338 | 306 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); |
| 339 | 307 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 340 | 308 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 309 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 310 | Layer.provide(persistenceLayer), | |
| 311 | ); | |
| 341 | 312 | |
| 342 | const checkpoint = makeCheckpointServiceDouble(); | |
| 343 | 313 | const firstCodex = makeFakeCodexAdapter(); |
| 344 | 314 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { |
| 345 | 315 | getByProvider: (provider) => |
| ... | ... | @@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 350 | 320 | }; |
| 351 | 321 | |
| 352 | 322 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 353 | Layer.provide(ProviderSessionRepositoryLive), | |
| 354 | Layer.provide(persistenceLayer), | |
| 323 | Layer.provide(runtimeRepositoryLayer), | |
| 355 | 324 | ); |
| 356 | const firstProviderLayer = ProviderServiceLive.pipe( | |
| 325 | const firstProviderLayer = makeProviderServiceLive().pipe( | |
| 357 | 326 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 358 | 327 | Layer.provide(firstDirectoryLayer), |
| 359 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | |
| 360 | 328 | ); |
| 361 | 329 | |
| 362 | 330 | const startedSession = yield* Effect.gen(function* () { |
| ... | ... | @@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 372 | 340 | yield* provider.stopAll(); |
| 373 | 341 | }).pipe(Effect.provide(firstProviderLayer)); |
| 374 | 342 | |
| 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 | 357 | const secondCodex = makeFakeCodexAdapter(); |
| 376 | 358 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { |
| 377 | 359 | getByProvider: (provider) => |
| ... | ... | @@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 381 | 363 | listProviders: () => Effect.succeed(["codex"]), |
| 382 | 364 | }; |
| 383 | 365 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 384 | Layer.provide(ProviderSessionRepositoryLive), | |
| 385 | Layer.provide(persistenceLayer), | |
| 366 | Layer.provide(runtimeRepositoryLayer), | |
| 386 | 367 | ); |
| 387 | const secondProviderLayer = ProviderServiceLive.pipe( | |
| 368 | const secondProviderLayer = makeProviderServiceLive().pipe( | |
| 388 | 369 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 389 | 370 | Layer.provide(secondDirectoryLayer), |
| 390 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | |
| 391 | 371 | ); |
| 392 | 372 | |
| 393 | checkpoint.getCheckpointDiff.mockClear(); | |
| 394 | 373 | secondCodex.startSession.mockClear(); |
| 374 | secondCodex.rollbackThread.mockClear(); | |
| 395 | 375 | |
| 396 | 376 | yield* Effect.gen(function* () { |
| 397 | 377 | const provider = yield* ProviderService; |
| 398 | yield* provider.getCheckpointDiff({ | |
| 378 | yield* provider.rollbackConversation({ | |
| 399 | 379 | sessionId: startedSession.sessionId, |
| 400 | fromTurnCount: 0, | |
| 401 | toTurnCount: 0, | |
| 380 | numTurns: 1, | |
| 402 | 381 | }); |
| 403 | 382 | }).pipe(Effect.provide(secondProviderLayer)); |
| 404 | 383 | |
| ... | ... | @@ -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); | |
| 414 | const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | |
| 415 | assert.equal(typeof recoveredDiffInput?.sessionId, "string"); | |
| 416 | assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true); | |
| 417 | assert.equal(recoveredDiffInput?.fromTurnCount, 0); | |
| 418 | assert.equal(recoveredDiffInput?.toTurnCount, 0); | |
| 392 | assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); | |
| 393 | const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; | |
| 394 | assert.equal(typeof rollbackCall?.[0], "string"); | |
| 395 | assert.equal(rollbackCall?.[1], 1); | |
| 419 | 396 | |
| 420 | 397 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 421 | 398 | }).pipe(Effect.provide(NodeServices.layer)), |
| 422 | 399 | ); |
| 423 | 400 | |
| 424 | 401 | routing.layer("ProviderServiceLive routing", (it) => { |
| 425 | it.effect("routes provider operations and delegates checkpoint workflows", () => | |
| 402 | it.effect("routes provider operations and rollback conversation", () => | |
| 426 | 403 | Effect.gen(function* () { |
| 427 | 404 | const provider = yield* ProviderService; |
| 428 | 405 | |
| ... | ... | @@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 431 | 408 | cwd: "/tmp/project", |
| 432 | 409 | }); |
| 433 | 410 | assert.equal(session.provider, "codex"); |
| 434 | assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [ | |
| 435 | [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }], | |
| 436 | ]); | |
| 437 | 411 | |
| 438 | 412 | const sessions = yield* provider.listSessions(); |
| 439 | 413 | assert.equal(sessions.length, 1); |
| ... | ... | @@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 450 | 424 | |
| 451 | 425 | yield* provider.respondToRequest({ |
| 452 | 426 | sessionId: session.sessionId, |
| 453 | requestId: "req-1", | |
| 427 | requestId: asRequestId("req-1"), | |
| 454 | 428 | decision: "accept", |
| 455 | 429 | }); |
| 456 | 430 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ |
| 457 | [session.sessionId, "req-1", "accept"], | |
| 431 | [session.sessionId, asRequestId("req-1"), "accept"], | |
| 458 | 432 | ]); |
| 459 | 433 | |
| 460 | yield* provider.listCheckpoints({ sessionId: session.sessionId }); | |
| 461 | yield* provider.getCheckpointDiff({ | |
| 434 | yield* provider.rollbackConversation({ | |
| 462 | 435 | sessionId: session.sessionId, |
| 463 | fromTurnCount: 0, | |
| 464 | toTurnCount: 0, | |
| 436 | numTurns: 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 | ]); | |
| 480 | 438 | |
| 481 | 439 | yield* provider.stopSession({ sessionId: session.sessionId }); |
| 482 | assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [ | |
| 483 | [{ providerSessionId: session.sessionId }], | |
| 484 | ]); | |
| 485 | 440 | const sendAfterStop = yield* Effect.result( |
| 486 | 441 | provider.sendTurn({ |
| 487 | 442 | sessionId: session.sessionId, |
| ... | ... | @@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 496 | 451 | }), |
| 497 | 452 | ); |
| 498 | 453 | |
| 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 | 455 | Effect.gen(function* () { |
| 501 | 456 | const provider = yield* ProviderService; |
| 502 | 457 | |
| ... | ... | @@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 505 | 460 | cwd: "/tmp/project", |
| 506 | 461 | }); |
| 507 | 462 | yield* routing.codex.stopSession(initial.sessionId); |
| 508 | ||
| 509 | routing.checkpoint.getCheckpointDiff.mockClear(); | |
| 510 | routing.checkpoint.revertToCheckpoint.mockClear(); | |
| 511 | 463 | routing.codex.startSession.mockClear(); |
| 464 | routing.codex.rollbackThread.mockClear(); | |
| 512 | 465 | |
| 513 | yield* provider.getCheckpointDiff({ | |
| 466 | yield* provider.rollbackConversation({ | |
| 514 | 467 | sessionId: initial.sessionId, |
| 515 | fromTurnCount: 0, | |
| 516 | toTurnCount: 0, | |
| 517 | }); | |
| 518 | yield* provider.revertToCheckpoint({ | |
| 519 | sessionId: initial.sessionId, | |
| 520 | turnCount: 0, | |
| 468 | numTurns: 1, | |
| 521 | 469 | }); |
| 522 | 470 | |
| 523 | 471 | assert.deepEqual(routing.codex.startSession.mock.calls, [ |
| ... | ... | @@ -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); | |
| 532 | assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1); | |
| 533 | ||
| 534 | const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | |
| 535 | const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0]; | |
| 536 | assert.equal(diffInput?.fromTurnCount, 0); | |
| 537 | assert.equal(diffInput?.toTurnCount, 0); | |
| 538 | assert.equal(diffInput?.sessionId === initial.sessionId, false); | |
| 539 | assert.equal(revertInput?.sessionId, diffInput?.sessionId); | |
| 540 | assert.equal(revertInput?.turnCount, 0); | |
| 479 | assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); | |
| 480 | const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; | |
| 481 | assert.equal(rollbackCall?.[1], 1); | |
| 541 | 482 | }), |
| 542 | 483 | ); |
| 543 | 484 | |
| 544 | it.effect("releases checkpoint session state when stopping all sessions", () => | |
| 485 | it.effect("stops all sessions and clears adapter runtime", () => | |
| 545 | 486 | Effect.gen(function* () { |
| 546 | 487 | const provider = yield* ProviderService; |
| 547 | 488 | |
| 548 | const first = yield* provider.startSession({ | |
| 489 | yield* provider.startSession({ | |
| 549 | 490 | provider: "codex", |
| 550 | 491 | }); |
| 551 | const second = yield* provider.startSession({ | |
| 492 | yield* provider.startSession({ | |
| 552 | 493 | provider: "codex", |
| 553 | 494 | }); |
| 554 | 495 | |
| 555 | 496 | yield* provider.stopAll(); |
| 556 | 497 | |
| 557 | assert.deepEqual(routing.checkpoint.releaseSession.mock.calls.slice(-2), [ | |
| 558 | [{ providerSessionId: first.sessionId }], | |
| 559 | [{ providerSessionId: second.sessionId }], | |
| 560 | ]); | |
| 561 | ||
| 562 | 498 | const remaining = yield* provider.listSessions(); |
| 563 | 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 | }); |
| 567 | 548 | |
| 568 | 549 | const fanout = makeProviderServiceLayer(); |
| 569 | 550 | fanout.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 | 552 | Effect.gen(function* () { |
| 572 | 553 | const provider = yield* ProviderService; |
| 573 | 554 | const session = yield* provider.startSession({ |
| ... | ... | @@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 577 | 558 | const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]); |
| 578 | 559 | const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => |
| 579 | 560 | Ref.update(eventsRef, (current) => [...current, event]), |
| 580 | ).pipe( | |
| 581 | Effect.forkChild, | |
| 582 | ); | |
| 583 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | |
| 561 | ).pipe(Effect.forkChild); | |
| 562 | yield* sleep(20); | |
| 584 | 563 | |
| 585 | 564 | const completedEvent: ProviderRuntimeEvent = { |
| 586 | 565 | type: "turn.completed", |
| 587 | eventId: "evt-1", | |
| 566 | eventId: asEventId("evt-1"), | |
| 588 | 567 | provider: "codex", |
| 589 | 568 | sessionId: session.sessionId, |
| 590 | 569 | createdAt: new Date().toISOString(), |
| 591 | threadId: "thread-1", | |
| 592 | turnId: "turn-1", | |
| 570 | threadId: asThreadId("thread-1"), | |
| 571 | turnId: asTurnId("turn-1"), | |
| 593 | 572 | status: "completed", |
| 594 | 573 | }; |
| 595 | 574 | |
| 596 | 575 | fanout.codex.emit(completedEvent); |
| 597 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | |
| 576 | yield* sleep(20); | |
| 598 | 577 | |
| 599 | 578 | const events = yield* Ref.get(eventsRef); |
| 600 | 579 | yield* Fiber.interrupt(consumer); |
| 601 | 580 | |
| 602 | assert.equal(events.length >= 2, true); | |
| 603 | assert.equal(events[0]?.type, "turn.completed"); | |
| 604 | assert.equal(events[1]?.type, "checkpoint.captured"); | |
| 605 | assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ | |
| 606 | [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], | |
| 607 | ]); | |
| 581 | assert.equal(events.some((entry) => entry.type === "turn.completed"), true); | |
| 608 | 582 | }), |
| 609 | 583 | ); |
| 610 | 584 | |
| ... | ... | @@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 614 | 588 | const session = yield* provider.startSession({ |
| 615 | 589 | provider: "codex", |
| 616 | 590 | }); |
| 617 | fanout.checkpoint.captureCurrentTurn.mockClear(); | |
| 618 | 591 | |
| 619 | 592 | const receivedByHealthy: string[] = []; |
| 593 | const expectedEventIds = new Set<string>([ | |
| 594 | "evt-ordered-1", | |
| 595 | "evt-ordered-2", | |
| 596 | "evt-ordered-3", | |
| 597 | ]); | |
| 620 | 598 | const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( |
| 621 | 599 | Stream.runForEach((event) => |
| 622 | 600 | Effect.sync(() => { |
| ... | ... | @@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 629 | 607 | Stream.runForEach(() => Effect.fail("listener crash")), |
| 630 | 608 | Effect.forkChild, |
| 631 | 609 | ); |
| 632 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | |
| 610 | yield* sleep(20); | |
| 633 | 611 | |
| 634 | 612 | const events: ReadonlyArray<ProviderRuntimeEvent> = [ |
| 635 | 613 | { |
| 636 | 614 | type: "tool.completed", |
| 637 | eventId: "evt-ordered-1", | |
| 615 | eventId: asEventId("evt-ordered-1"), | |
| 638 | 616 | provider: "codex", |
| 639 | 617 | sessionId: session.sessionId, |
| 640 | 618 | createdAt: new Date().toISOString(), |
| 641 | threadId: "thread-1", | |
| 642 | turnId: "turn-1", | |
| 619 | threadId: asThreadId("thread-1"), | |
| 620 | turnId: asTurnId("turn-1"), | |
| 643 | 621 | toolKind: "command", |
| 644 | 622 | title: "Command run", |
| 645 | 623 | detail: "echo one", |
| 646 | 624 | }, |
| 647 | 625 | { |
| 648 | 626 | type: "message.delta", |
| 649 | eventId: "evt-ordered-2", | |
| 627 | eventId: asEventId("evt-ordered-2"), | |
| 650 | 628 | provider: "codex", |
| 651 | 629 | sessionId: session.sessionId, |
| 652 | 630 | createdAt: new Date().toISOString(), |
| 653 | threadId: "thread-1", | |
| 654 | turnId: "turn-1", | |
| 631 | threadId: asThreadId("thread-1"), | |
| 632 | turnId: asTurnId("turn-1"), | |
| 655 | 633 | delta: "hello", |
| 656 | 634 | }, |
| 657 | 635 | { |
| 658 | 636 | type: "turn.completed", |
| 659 | eventId: "evt-ordered-3", | |
| 637 | eventId: asEventId("evt-ordered-3"), | |
| 660 | 638 | provider: "codex", |
| 661 | 639 | sessionId: session.sessionId, |
| 662 | 640 | createdAt: new Date().toISOString(), |
| 663 | threadId: "thread-1", | |
| 664 | turnId: "turn-1", | |
| 641 | threadId: asThreadId("thread-1"), | |
| 642 | turnId: asTurnId("turn-1"), | |
| 665 | 643 | status: "completed", |
| 666 | 644 | }, |
| 667 | 645 | ]; |
| ... | ... | @@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 673 | 651 | assert.equal(failingResult._tag, "Failure"); |
| 674 | 652 | yield* Fiber.join(healthyFiber); |
| 675 | 653 | |
| 676 | assert.deepEqual(receivedByHealthy.slice(0, 3), [ | |
| 677 | "evt-ordered-1", | |
| 678 | "evt-ordered-2", | |
| 679 | "evt-ordered-3", | |
| 680 | ]); | |
| 681 | assert.equal(receivedByHealthy.length, 3); | |
| 654 | assert.deepEqual( | |
| 655 | receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), | |
| 656 | [ | |
| 657 | "evt-ordered-1", | |
| 658 | "evt-ordered-2", | |
| 659 | "evt-ordered-3", | |
| 660 | ], | |
| 661 | ); | |
| 682 | 662 | }), |
| 683 | 663 | ); |
| 684 | 664 | }); |
| ... | ... | @@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 689 | 669 | Effect.gen(function* () { |
| 690 | 670 | const provider = yield* ProviderService; |
| 691 | 671 | |
| 692 | const parse = providerSessionStartInputSchema.safeParse({ | |
| 693 | provider: "invalid-provider", | |
| 694 | }); | |
| 695 | assert.equal(parse.success, false); | |
| 696 | if (parse.success) { | |
| 697 | return; | |
| 672 | let parseCause: unknown; | |
| 673 | let decodeFailed = false; | |
| 674 | try { | |
| 675 | Schema.decodeUnknownSync(ProviderSessionStartInput)({ | |
| 676 | provider: "invalid-provider", | |
| 677 | }); | |
| 678 | } catch (cause) { | |
| 679 | parseCause = cause; | |
| 680 | decodeFailed = true; | |
| 698 | 681 | } |
| 682 | assert.equal(decodeFailed, true); | |
| 699 | 683 | |
| 700 | 684 | const failure = yield* Effect.result( |
| 701 | 685 | provider.startSession({ |
| ... | ... | @@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 703 | 687 | } as never), |
| 704 | 688 | ); |
| 705 | 689 | |
| 706 | const cause = (parse.error as { cause?: unknown }).cause; | |
| 707 | 690 | assertFailure( |
| 708 | 691 | failure, |
| 709 | 692 | new ProviderValidationError({ |
| 710 | 693 | operation: "ProviderService.startSession", |
| 711 | issue: parse.error.message, | |
| 712 | ...(cause !== undefined ? { cause } : {}), | |
| 694 | issue: decodeIssueMessage(parseCause), | |
| 695 | cause: parseCause, | |
| 713 | 696 | }), |
| 714 | 697 | ); |
| 715 | 698 | }), |
| ... | ... | @@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 723 | 706 | Effect.sync(() => { |
| 724 | 707 | const now = new Date().toISOString(); |
| 725 | 708 | return { |
| 726 | sessionId: "sess-missing-thread", | |
| 709 | sessionId: asSessionId("sess-missing-thread"), | |
| 727 | 710 | provider: "codex", |
| 728 | 711 | status: "ready", |
| 729 | 712 | cwd: input.cwd ?? process.cwd(), |
| ... | ... | @@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 743 | 726 | assertFailure( |
| 744 | 727 | failure, |
| 745 | 728 | new ProviderValidationError({ |
| 746 | operation: "ProviderService.startSession", | |
| 747 | issue: | |
| 748 | "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.", | |
| 749 | }), | |
| 729 | operation: "ProviderService.startSession", | |
| 730 | issue: "Provider 'codex' returned a session without threadId.", | |
| 731 | }), | |
| 750 | 732 | ); |
| 751 | 733 | }), |
| 752 | 734 | ); |
apps/server/src/provider/Layers/ProviderService.ts+190-237| ... | ... | @@ -5,29 +5,25 @@ |
| 5 | 5 | * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a |
| 6 | 6 | * unified provider event stream for subscribers. |
| 7 | 7 | * |
| 8 | * It does not implement provider protocol details (adapter concern) and does | |
| 9 | * not implement checkpoint persistence mechanics (checkpointing concern). | |
| 8 | * It does not implement provider protocol details (adapter concern). | |
| 10 | 9 | * |
| 11 | 10 | * @module ProviderServiceLive |
| 12 | 11 | */ |
| 13 | import { randomUUID } from "node:crypto"; | |
| 14 | ||
| 15 | 12 | import { |
| 16 | providerGetCheckpointDiffInputSchema, | |
| 17 | providerInterruptTurnInputSchema, | |
| 18 | providerListCheckpointsInputSchema, | |
| 19 | providerRespondToRequestInputSchema, | |
| 20 | providerRevertToCheckpointInputSchema, | |
| 21 | providerSendTurnInputSchema, | |
| 22 | providerSessionStartInputSchema, | |
| 23 | providerStopSessionInputSchema, | |
| 13 | NonNegativeInt, | |
| 14 | ProviderSessionId, | |
| 15 | ProviderThreadId, | |
| 16 | ThreadId, | |
| 17 | ProviderInterruptTurnInput, | |
| 18 | ProviderRespondToRequestInput, | |
| 19 | ProviderSendTurnInput, | |
| 20 | ProviderSessionStartInput, | |
| 21 | ProviderStopSessionInput, | |
| 24 | 22 | type ProviderRuntimeEvent, |
| 25 | type ProviderRuntimeTurnCompletedEvent, | |
| 26 | 23 | type ProviderSession, |
| 27 | 24 | } from "@t3tools/contracts"; |
| 28 | import { Effect, Layer, Option, PubSub, Queue, Ref, Stream } from "effect"; | |
| 25 | import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, Stream } from "effect"; | |
| 29 | 26 | |
| 30 | import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; | |
| 31 | 27 | import { ProviderValidationError } from "../Errors.ts"; |
| 32 | 28 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 33 | 29 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; |
| ... | ... | @@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions { |
| 38 | 34 | readonly canonicalEventLogPath?: string; |
| 39 | 35 | } |
| 40 | 36 | |
| 41 | function toCheckpointCaptureErrorEvent( | |
| 42 | event: ProviderRuntimeTurnCompletedEvent, | |
| 43 | error: { readonly message: string }, | |
| 44 | ): ProviderRuntimeEvent { | |
| 45 | return { | |
| 46 | type: "runtime.error", | |
| 47 | eventId: randomUUID(), | |
| 48 | provider: event.provider, | |
| 49 | sessionId: event.sessionId, | |
| 50 | createdAt: new Date().toISOString(), | |
| 51 | ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), | |
| 52 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | |
| 53 | message: error.message, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | function 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 | } | |
| 37 | const ProviderRollbackConversationInput = Schema.Struct({ | |
| 38 | sessionId: ProviderSessionId, | |
| 39 | numTurns: NonNegativeInt, | |
| 40 | }); | |
| 75 | 41 | |
| 76 | 42 | function toValidationError( |
| 77 | 43 | operation: string, |
| ... | ... | @@ -85,6 +51,63 @@ function toValidationError( |
| 85 | 51 | }); |
| 86 | 52 | } |
| 87 | 53 | |
| 54 | function 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 | ||
| 65 | function 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 | ||
| 79 | function 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 | ||
| 94 | function 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 | ||
| 103 | function asProviderSessionId(value: string | ProviderSessionId): ProviderSessionId { | |
| 104 | return typeof value === "string" ? ProviderSessionId.makeUnsafe(value) : value; | |
| 105 | } | |
| 106 | ||
| 107 | function asProviderThreadId(value: string | ProviderThreadId): ProviderThreadId { | |
| 108 | return typeof value === "string" ? ProviderThreadId.makeUnsafe(value) : value; | |
| 109 | } | |
| 110 | ||
| 88 | 111 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 89 | 112 | Effect.gen(function* () { |
| 90 | 113 | const canonicalEventLogger = |
| ... | ... | @@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 94 | 117 | |
| 95 | 118 | const registry = yield* ProviderAdapterRegistry; |
| 96 | 119 | const directory = yield* ProviderSessionDirectory; |
| 97 | const checkpointService = yield* CheckpointService; | |
| 98 | ||
| 99 | 120 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 100 | 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 | ); | |
| 102 | 125 | |
| 103 | 126 | const canonicalizeRuntimeEventSession = ( |
| 104 | 127 | event: ProviderRuntimeEvent, |
| ... | ... | @@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 109 | 132 | if (liveSessionId === event.sessionId) { |
| 110 | 133 | return { |
| 111 | 134 | ...event, |
| 112 | sessionId: staleSessionId, | |
| 135 | sessionId: ProviderSessionId.makeUnsafe(staleSessionId), | |
| 113 | 136 | } satisfies ProviderRuntimeEvent; |
| 114 | 137 | } |
| 115 | 138 | } |
| ... | ... | @@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 131 | 154 | Effect.asVoid, |
| 132 | 155 | ); |
| 133 | 156 | |
| 134 | const upsertSessionBinding = ( | |
| 135 | session: ProviderSession, | |
| 136 | operation: string, | |
| 137 | ) => | |
| 157 | const upsertSessionBinding = (session: ProviderSession, operation: string) => | |
| 138 | 158 | Effect.gen(function* () { |
| 139 | const threadId = session.threadId?.trim(); | |
| 140 | if (!threadId) { | |
| 159 | const providerThreadId = session.threadId; | |
| 160 | if (!providerThreadId) { | |
| 141 | 161 | return yield* toValidationError( |
| 142 | 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 | } |
| 146 | 166 | |
| 167 | const providerSessionId = asProviderSessionId(session.sessionId); | |
| 168 | const brandedThreadId = ThreadId.makeUnsafe(providerThreadId); | |
| 147 | 169 | yield* directory.upsert({ |
| 148 | sessionId: session.sessionId, | |
| 170 | sessionId: providerSessionId, | |
| 149 | 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 | }); |
| 152 | 178 | |
| 153 | return threadId; | |
| 179 | return brandedThreadId; | |
| 154 | 180 | }); |
| 155 | 181 | |
| 156 | const clearAliasKey = (staleSessionId: string) => | |
| 182 | const clearAliasKey = (staleSessionId: ProviderSessionId) => | |
| 157 | 183 | Ref.update(routedSessionAliasesRef, (current) => { |
| 158 | 184 | if (!current.has(staleSessionId)) { |
| 159 | 185 | return current; |
| ... | ... | @@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 163 | 189 | return next; |
| 164 | 190 | }); |
| 165 | 191 | |
| 166 | const clearAliasesReferencing = (sessionId: string) => | |
| 192 | const clearAliasesReferencing = (sessionId: ProviderSessionId) => | |
| 167 | 193 | Ref.update(routedSessionAliasesRef, (current) => { |
| 168 | 194 | let changed = false; |
| 169 | const next = new Map<string, string>(); | |
| 195 | const next = new Map<ProviderSessionId, ProviderSessionId>(); | |
| 170 | 196 | for (const [key, value] of current) { |
| 171 | 197 | if (key === sessionId || value === sessionId) { |
| 172 | 198 | changed = true; |
| ... | ... | @@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 177 | 203 | return changed ? next : current; |
| 178 | 204 | }); |
| 179 | 205 | |
| 180 | const setAlias = (staleSessionId: string, liveSessionId: string) => | |
| 206 | const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => | |
| 181 | 207 | Ref.update(routedSessionAliasesRef, (current) => { |
| 182 | 208 | const existing = current.get(staleSessionId); |
| 183 | 209 | if (existing === liveSessionId) { |
| ... | ... | @@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 193 | 219 | registry.getByProvider(provider), |
| 194 | 220 | ); |
| 195 | 221 | |
| 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 | 222 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 225 | publishRuntimeEvent(event).pipe( | |
| 226 | Effect.flatMap(() => { | |
| 227 | if (event.type !== "turn.completed") { | |
| 228 | return Effect.void; | |
| 229 | } | |
| 230 | return onTurnCompleted(event); | |
| 231 | }), | |
| 232 | ); | |
| 223 | publishRuntimeEvent(event); | |
| 233 | 224 | |
| 234 | 225 | const worker = Effect.forever( |
| 235 | 226 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), |
| ... | ... | @@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 243 | 234 | ).pipe(Effect.asVoid); |
| 244 | 235 | |
| 245 | 236 | const recoverSessionForThread = (input: { |
| 246 | readonly staleSessionId: string; | |
| 237 | readonly staleSessionId: ProviderSessionId; | |
| 247 | 238 | readonly provider: ProviderSession["provider"]; |
| 248 | readonly threadId: string; | |
| 239 | readonly threadId: ThreadId; | |
| 249 | 240 | readonly operation: string; |
| 250 | 241 | }) => |
| 251 | 242 | Effect.gen(function* () { |
| 252 | 243 | const adapter = yield* registry.getByProvider(input.provider); |
| 253 | 244 | const activeSessions = yield* adapter.listSessions(); |
| 254 | 245 | const existing = activeSessions.find( |
| 255 | (session) => session.threadId?.trim() === input.threadId, | |
| 246 | (session) => session.threadId?.trim() === input.threadId.trim(), | |
| 256 | 247 | ); |
| 257 | 248 | if (existing) { |
| 258 | 249 | const existingThreadId = yield* upsertSessionBinding( |
| ... | ... | @@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 277 | 268 | |
| 278 | 269 | const resumed = yield* adapter.startSession({ |
| 279 | 270 | provider: input.provider, |
| 280 | resumeThreadId: input.threadId, | |
| 271 | resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId), | |
| 281 | 272 | }); |
| 282 | 273 | if (resumed.provider !== adapter.provider) { |
| 283 | 274 | return yield* toValidationError( |
| ... | ... | @@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 303 | 294 | threadId: resumedThreadId, |
| 304 | 295 | }); |
| 305 | 296 | |
| 306 | const checkpointCwd = resumed.cwd ?? process.cwd(); | |
| 307 | yield* checkpointService.initializeForSession({ | |
| 308 | providerSessionId: resumed.sessionId, | |
| 309 | cwd: checkpointCwd, | |
| 310 | }); | |
| 311 | ||
| 312 | 297 | if (resumed.sessionId !== input.staleSessionId) { |
| 313 | 298 | yield* setAlias(input.staleSessionId, resumed.sessionId); |
| 314 | 299 | } else { |
| ... | ... | @@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 322 | 307 | }); |
| 323 | 308 | |
| 324 | 309 | const resolveRoutableSession = (input: { |
| 325 | readonly sessionId: string; | |
| 310 | readonly sessionId: ProviderSessionId; | |
| 326 | 311 | readonly operation: string; |
| 327 | 312 | readonly allowRecovery: boolean; |
| 328 | 313 | }) => |
| ... | ... | @@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 364 | 349 | } |
| 365 | 350 | |
| 366 | 351 | const threadIdOption = yield* directory.getThreadId(input.sessionId); |
| 367 | const threadId = Option.getOrUndefined(threadIdOption)?.trim(); | |
| 352 | const threadId = Option.getOrUndefined(threadIdOption); | |
| 368 | 353 | if (!threadId) { |
| 369 | 354 | return yield* toValidationError( |
| 370 | 355 | input.operation, |
| ... | ... | @@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 388 | 373 | |
| 389 | 374 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => |
| 390 | 375 | Effect.gen(function* () { |
| 391 | const parsed = providerSessionStartInputSchema.safeParse(rawInput); | |
| 392 | if (!parsed.success) { | |
| 393 | return yield* toValidationError( | |
| 394 | "ProviderService.startSession", | |
| 395 | parsed.error.message, | |
| 396 | parsed.error.cause, | |
| 397 | ); | |
| 398 | } | |
| 376 | const parsed = yield* decodeInputOrValidationError({ | |
| 377 | operation: "ProviderService.startSession", | |
| 378 | schema: ProviderSessionStartInput, | |
| 379 | payload: rawInput, | |
| 380 | }); | |
| 399 | 381 | |
| 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 | 388 | const adapter = yield* registry.getByProvider(input.provider); |
| 402 | 389 | const session = yield* adapter.startSession(input); |
| 403 | 390 | |
| ... | ... | @@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 410 | 397 | |
| 411 | 398 | yield* upsertSessionBinding(session, "ProviderService.startSession"); |
| 412 | 399 | |
| 413 | const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); | |
| 414 | yield* checkpointService.initializeForSession({ | |
| 415 | providerSessionId: session.sessionId, | |
| 416 | cwd: checkpointCwd, | |
| 417 | }); | |
| 418 | ||
| 419 | 400 | return session; |
| 420 | 401 | }); |
| 421 | 402 | |
| 422 | 403 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => |
| 423 | 404 | Effect.gen(function* () { |
| 424 | const parsed = providerSendTurnInputSchema.safeParse(rawInput); | |
| 425 | if (!parsed.success) { | |
| 405 | const parsed = yield* decodeInputOrValidationError({ | |
| 406 | operation: "ProviderService.sendTurn", | |
| 407 | schema: ProviderSendTurnInput, | |
| 408 | payload: rawInput, | |
| 409 | }); | |
| 410 | ||
| 411 | const input = { | |
| 412 | ...parsed, | |
| 413 | attachments: parsed.attachments ?? [], | |
| 414 | }; | |
| 415 | if (!input.input && input.attachments.length === 0) { | |
| 426 | 416 | return yield* toValidationError( |
| 427 | 417 | "ProviderService.sendTurn", |
| 428 | parsed.error.message, | |
| 429 | parsed.error.cause, | |
| 418 | "Either input text or at least one attachment is required", | |
| 430 | 419 | ); |
| 431 | 420 | } |
| 432 | ||
| 433 | const input = parsed.data; | |
| 434 | 421 | const routed = yield* resolveRoutableSession({ |
| 435 | 422 | sessionId: input.sessionId, |
| 436 | 423 | operation: "ProviderService.sendTurn", |
| 437 | 424 | allowRecovery: true, |
| 438 | 425 | }); |
| 439 | return yield* routed.adapter.sendTurn({ | |
| 426 | const turn = yield* routed.adapter.sendTurn({ | |
| 440 | 427 | ...input, |
| 441 | 428 | sessionId: routed.sessionId, |
| 442 | 429 | }); |
| 443 | }); | |
| 444 | ||
| 445 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => | |
| 446 | Effect.gen(function* () { | |
| 447 | const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); | |
| 448 | if (!parsed.success) { | |
| 430 | const threadId = yield* directory | |
| 431 | .getThreadId(asProviderSessionId(input.sessionId)) | |
| 432 | .pipe(Effect.map(Option.getOrUndefined)); | |
| 433 | if (!threadId) { | |
| 449 | 434 | return yield* toValidationError( |
| 450 | "ProviderService.interruptTurn", | |
| 451 | parsed.error.message, | |
| 452 | parsed.error.cause, | |
| 435 | "ProviderService.sendTurn", | |
| 436 | `No thread id is tracked for provider session '${input.sessionId}'.`, | |
| 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 | }); | |
| 455 | 454 | |
| 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 | 462 | const routed = yield* resolveRoutableSession({ |
| 458 | 463 | sessionId: input.sessionId, |
| 459 | 464 | operation: "ProviderService.interruptTurn", |
| ... | ... | @@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 464 | 469 | |
| 465 | 470 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => |
| 466 | 471 | Effect.gen(function* () { |
| 467 | const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); | |
| 468 | if (!parsed.success) { | |
| 469 | return yield* toValidationError( | |
| 470 | "ProviderService.respondToRequest", | |
| 471 | parsed.error.message, | |
| 472 | parsed.error.cause, | |
| 473 | ); | |
| 474 | } | |
| 475 | ||
| 476 | const input = parsed.data; | |
| 472 | const input = yield* decodeInputOrValidationError({ | |
| 473 | operation: "ProviderService.respondToRequest", | |
| 474 | schema: ProviderRespondToRequestInput, | |
| 475 | payload: rawInput, | |
| 476 | }); | |
| 477 | 477 | const routed = yield* resolveRoutableSession({ |
| 478 | 478 | sessionId: input.sessionId, |
| 479 | 479 | operation: "ProviderService.respondToRequest", |
| ... | ... | @@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 484 | 484 | |
| 485 | 485 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => |
| 486 | 486 | Effect.gen(function* () { |
| 487 | const parsed = providerStopSessionInputSchema.safeParse(rawInput); | |
| 488 | if (!parsed.success) { | |
| 489 | return yield* toValidationError( | |
| 490 | "ProviderService.stopSession", | |
| 491 | parsed.error.message, | |
| 492 | parsed.error.cause, | |
| 493 | ); | |
| 494 | } | |
| 495 | ||
| 496 | const input = parsed.data; | |
| 487 | const input = yield* decodeInputOrValidationError({ | |
| 488 | operation: "ProviderService.stopSession", | |
| 489 | schema: ProviderStopSessionInput, | |
| 490 | payload: rawInput, | |
| 491 | }); | |
| 497 | 492 | const routed = yield* resolveRoutableSession({ |
| 498 | 493 | sessionId: input.sessionId, |
| 499 | 494 | operation: "ProviderService.stopSession", |
| ... | ... | @@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 503 | 498 | yield* routed.adapter.stopSession(routed.sessionId); |
| 504 | 499 | } |
| 505 | 500 | if (routed.sessionId !== input.sessionId) { |
| 506 | yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId }); | |
| 507 | yield* directory.remove(routed.sessionId); | |
| 501 | yield* directory.remove(asProviderSessionId(routed.sessionId)); | |
| 508 | 502 | yield* clearAliasesReferencing(routed.sessionId); |
| 509 | 503 | } |
| 510 | yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); | |
| 511 | yield* directory.remove(input.sessionId); | |
| 504 | yield* directory.remove(asProviderSessionId(input.sessionId)); | |
| 512 | 505 | yield* clearAliasesReferencing(input.sessionId); |
| 513 | 506 | }); |
| 514 | 507 | |
| ... | ... | @@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 517 | 510 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), |
| 518 | 511 | ); |
| 519 | 512 | |
| 520 | const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => | |
| 521 | Effect.gen(function* () { | |
| 522 | const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); | |
| 523 | if (!parsed.success) { | |
| 524 | return yield* toValidationError( | |
| 525 | "ProviderService.listCheckpoints", | |
| 526 | parsed.error.message, | |
| 527 | parsed.error.cause, | |
| 528 | ); | |
| 529 | } | |
| 530 | ||
| 531 | const input = parsed.data; | |
| 532 | const routed = yield* resolveRoutableSession({ | |
| 533 | sessionId: input.sessionId, | |
| 534 | operation: "ProviderService.listCheckpoints", | |
| 535 | allowRecovery: true, | |
| 536 | }); | |
| 537 | return yield* checkpointService.listCheckpoints({ | |
| 538 | ...input, | |
| 539 | sessionId: routed.sessionId, | |
| 540 | }); | |
| 541 | }); | |
| 542 | ||
| 543 | const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => | |
| 513 | const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => | |
| 544 | 514 | Effect.gen(function* () { |
| 545 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); | |
| 546 | if (!parsed.success) { | |
| 547 | return yield* toValidationError( | |
| 548 | "ProviderService.getCheckpointDiff", | |
| 549 | parsed.error.message, | |
| 550 | parsed.error.cause, | |
| 551 | ); | |
| 552 | } | |
| 553 | ||
| 554 | const input = parsed.data; | |
| 555 | const routed = yield* resolveRoutableSession({ | |
| 556 | sessionId: input.sessionId, | |
| 557 | operation: "ProviderService.getCheckpointDiff", | |
| 558 | allowRecovery: true, | |
| 559 | }); | |
| 560 | return yield* checkpointService.getCheckpointDiff({ | |
| 561 | ...input, | |
| 562 | sessionId: routed.sessionId, | |
| 515 | const input = yield* decodeInputOrValidationError({ | |
| 516 | operation: "ProviderService.rollbackConversation", | |
| 517 | schema: ProviderRollbackConversationInput, | |
| 518 | payload: rawInput, | |
| 563 | 519 | }); |
| 564 | }); | |
| 565 | ||
| 566 | const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => | |
| 567 | Effect.gen(function* () { | |
| 568 | const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); | |
| 569 | if (!parsed.success) { | |
| 570 | return yield* toValidationError( | |
| 571 | "ProviderService.revertToCheckpoint", | |
| 572 | parsed.error.message, | |
| 573 | parsed.error.cause, | |
| 574 | ); | |
| 520 | if (input.numTurns === 0) { | |
| 521 | return; | |
| 575 | 522 | } |
| 576 | ||
| 577 | const input = parsed.data; | |
| 578 | 523 | const routed = yield* resolveRoutableSession({ |
| 579 | 524 | sessionId: input.sessionId, |
| 580 | operation: "ProviderService.revertToCheckpoint", | |
| 525 | operation: "ProviderService.rollbackConversation", | |
| 581 | 526 | allowRecovery: true, |
| 582 | 527 | }); |
| 583 | return yield* checkpointService.revertToCheckpoint({ | |
| 584 | ...input, | |
| 585 | sessionId: routed.sessionId, | |
| 586 | }); | |
| 528 | yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); | |
| 587 | 529 | }); |
| 588 | 530 | |
| 589 | 531 | const stopAll: ProviderServiceShape["stopAll"] = () => |
| 590 | 532 | Effect.gen(function* () { |
| 591 | 533 | const sessionIds = yield* directory.listSessionIds(); |
| 592 | 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 | 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 | 550 | ).pipe(Effect.asVoid); |
| 551 | // Keep persisted session bindings so stale sessions can be resumed after | |
| 552 | // process restart via resumeThreadId. | |
| 598 | 553 | yield* Ref.set(routedSessionAliasesRef, new Map()); |
| 599 | 554 | }); |
| 600 | 555 | |
| ... | ... | @@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 605 | 560 | respondToRequest, |
| 606 | 561 | stopSession, |
| 607 | 562 | listSessions, |
| 608 | listCheckpoints, | |
| 609 | getCheckpointDiff, | |
| 610 | revertToCheckpoint, | |
| 563 | rollbackConversation, | |
| 611 | 564 | stopAll, |
| 612 | 565 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 613 | 566 | } satisfies ProviderServiceShape; |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+103-22| ... | ... | @@ -3,22 +3,35 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | |
| 5 | 5 | import { NodeServices } from "@effect/platform-node"; |
| 6 | import { | |
| 7 | ProviderSessionId, | |
| 8 | ProviderThreadId, | |
| 9 | ThreadId, | |
| 10 | } from "@t3tools/contracts"; | |
| 6 | 11 | import { it, assert } from "@effect/vitest"; |
| 7 | 12 | import { assertFailure, assertSome } from "@effect/vitest/utils"; |
| 8 | import { Effect, Layer } from "effect"; | |
| 13 | import { Effect, Layer, Option } from "effect"; | |
| 9 | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | 15 | |
| 11 | 16 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 12 | import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; | |
| 17 | import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; | |
| 18 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 13 | 19 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; |
| 14 | 20 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 15 | 21 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 16 | 22 | |
| 23 | const sessionId = (value: string) => ProviderSessionId.makeUnsafe(value); | |
| 24 | const threadId = (value: string) => ThreadId.makeUnsafe(value); | |
| 25 | const providerThreadId = (value: string) => ProviderThreadId.makeUnsafe(value); | |
| 26 | ||
| 17 | 27 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { |
| 18 | return ProviderSessionDirectoryLive.pipe( | |
| 19 | Layer.provide(ProviderSessionRepositoryLive), | |
| 28 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 20 | 29 | Layer.provide(persistenceLayer), |
| 21 | 30 | ); |
| 31 | return Layer.mergeAll( | |
| 32 | runtimeRepositoryLayer, | |
| 33 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | |
| 34 | ); | |
| 22 | 35 | } |
| 23 | 36 | |
| 24 | 37 | const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); |
| ... | ... | @@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 27 | 40 | it("upserts, reads, and removes session bindings", () => |
| 28 | 41 | Effect.gen(function* () { |
| 29 | 42 | const directory = yield* ProviderSessionDirectory; |
| 43 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | |
| 30 | 44 | |
| 31 | 45 | yield* directory.upsert({ |
| 32 | sessionId: "sess-1", | |
| 46 | sessionId: sessionId("sess-1"), | |
| 33 | 47 | provider: "codex", |
| 34 | threadId: "thread-1", | |
| 48 | threadId: threadId("thread-1"), | |
| 35 | 49 | }); |
| 36 | 50 | |
| 37 | const provider = yield* directory.getProvider("sess-1"); | |
| 51 | const provider = yield* directory.getProvider(sessionId("sess-1")); | |
| 38 | 52 | assert.equal(provider, "codex"); |
| 39 | const threadId = yield* directory.getThreadId("sess-1"); | |
| 40 | assertSome(threadId, "thread-1"); | |
| 53 | const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1")); | |
| 54 | assertSome(resolvedThreadId, threadId("thread-1")); | |
| 41 | 55 | |
| 42 | 56 | yield* directory.upsert({ |
| 43 | sessionId: "sess-1", | |
| 57 | sessionId: sessionId("sess-1"), | |
| 44 | 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"); | |
| 48 | assertSome(updatedThreadId, "thread-2"); | |
| 67 | assert.equal(Option.isSome(runtime), true); | |
| 68 | if (Option.isSome(runtime)) { | |
| 69 | assert.equal(runtime.value.threadId, "thread-2"); | |
| 70 | assert.equal(runtime.value.providerThreadId, "thread-2"); | |
| 71 | assert.equal(runtime.value.status, "running"); | |
| 72 | assert.equal(runtime.value.adapterKey, "codex"); | |
| 73 | } | |
| 49 | 74 | |
| 50 | 75 | const sessionIds = yield* directory.listSessionIds(); |
| 51 | assert.deepEqual(sessionIds, ["sess-1"]); | |
| 76 | assert.deepEqual(sessionIds, [sessionId("sess-1")]); | |
| 52 | 77 | |
| 53 | yield* directory.remove("sess-1"); | |
| 54 | const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result); | |
| 78 | yield* directory.remove(sessionId("sess-1")); | |
| 79 | const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result); | |
| 55 | 80 | assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); |
| 56 | 81 | })); |
| 57 | 82 | |
| ... | ... | @@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 60 | 85 | const directory = yield* ProviderSessionDirectory; |
| 61 | 86 | const result = yield* Effect.result( |
| 62 | 87 | directory.upsert({ |
| 63 | sessionId: " ", | |
| 88 | sessionId: sessionId(" "), | |
| 64 | 89 | provider: "codex", |
| 65 | 90 | }), |
| 66 | 91 | ); |
| ... | ... | @@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 73 | 98 | ); |
| 74 | 99 | })); |
| 75 | 100 | |
| 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 | 149 | it("rehydrates persisted mappings across layer restart", () => |
| 77 | 150 | Effect.gen(function* () { |
| 78 | 151 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); |
| ... | ... | @@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 82 | 155 | yield* Effect.gen(function* () { |
| 83 | 156 | const directory = yield* ProviderSessionDirectory; |
| 84 | 157 | yield* directory.upsert({ |
| 85 | sessionId: "sess-restart", | |
| 158 | sessionId: sessionId("sess-restart"), | |
| 86 | 159 | provider: "codex", |
| 87 | threadId: "thread-restart", | |
| 160 | threadId: threadId("thread-restart"), | |
| 88 | 161 | }); |
| 89 | 162 | }).pipe(Effect.provide(directoryLayer)); |
| 90 | 163 | |
| 91 | 164 | yield* Effect.gen(function* () { |
| 92 | 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 | 168 | assert.equal(provider, "codex"); |
| 95 | 169 | |
| 96 | const threadId = yield* directory.getThreadId("sess-restart"); | |
| 97 | assertSome(threadId, "thread-restart"); | |
| 170 | const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart")); | |
| 171 | assertSome(resolvedThreadId, threadId("thread-restart")); | |
| 172 | ||
| 173 | const legacyTableRows = yield* sql<{ readonly name: string }>` | |
| 174 | SELECT name | |
| 175 | FROM sqlite_master | |
| 176 | WHERE type = 'table' AND name = 'provider_sessions' | |
| 177 | `; | |
| 178 | assert.equal(legacyTableRows.length, 0); | |
| 98 | 179 | }).pipe(Effect.provide(directoryLayer)); |
| 99 | 180 | |
| 100 | 181 | fs.rmSync(tempDir, { recursive: true, force: true }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+142-33| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | import { | |
| 2 | ProviderSessionId, | |
| 3 | ProviderThreadId, | |
| 4 | ThreadId, | |
| 5 | type ProviderKind, | |
| 6 | } from "@t3tools/contracts"; | |
| 1 | 7 | import { Effect, Layer, Option } from "effect"; |
| 2 | 8 | |
| 3 | import { ProviderSessionRepository } from "../../persistence/Services/ProviderSessions.ts"; | |
| 9 | import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; | |
| 4 | 10 | import { |
| 5 | 11 | ProviderSessionDirectoryPersistenceError, |
| 6 | 12 | ProviderSessionNotFoundError, |
| ... | ... | @@ -22,51 +28,154 @@ function toPersistenceError(operation: string) { |
| 22 | 28 | } |
| 23 | 29 | |
| 24 | 30 | function 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 | 37 | return { |
| 26 | sessionId: binding.sessionId.trim(), | |
| 38 | sessionId: ProviderSessionId.makeUnsafe(sessionId), | |
| 27 | 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 | } |
| 31 | 54 | |
| 55 | function 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 | ||
| 70 | function isRecord(value: unknown): value is Record<string, unknown> { | |
| 71 | return value !== null && typeof value === "object" && !Array.isArray(value); | |
| 72 | } | |
| 73 | ||
| 74 | function 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 | ||
| 32 | 90 | const makeProviderSessionDirectory = Effect.gen(function* () { |
| 33 | const repository = yield* ProviderSessionRepository; | |
| 91 | const repository = yield* ProviderSessionRuntimeRepository; | |
| 34 | 92 | |
| 35 | 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 | 137 | repository |
| 37 | .upsertSession({ | |
| 38 | sessionId: binding.sessionId, | |
| 39 | provider: binding.provider, | |
| 40 | ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), | |
| 41 | }) | |
| 42 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession"))); | |
| 43 | ||
| 44 | const persistDelete = (sessionId: string) => | |
| 45 | repository | |
| 46 | .deleteSession({ sessionId }) | |
| 47 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession"))); | |
| 48 | ||
| 49 | const getBinding = (sessionId: string) => | |
| 50 | repository.getSession({ sessionId }).pipe( | |
| 51 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")), | |
| 52 | Effect.map((entry) => | |
| 53 | Option.map(entry, (value) => ({ | |
| 54 | sessionId: value.sessionId, | |
| 55 | provider: value.provider, | |
| 56 | ...(value.threadId !== undefined ? { threadId: value.threadId } : {}), | |
| 57 | })), | |
| 138 | .deleteBySessionId({ providerSessionId: sessionId }) | |
| 139 | .pipe( | |
| 140 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), | |
| 141 | ); | |
| 142 | ||
| 143 | const getBinding = (sessionId: ProviderSessionId) => | |
| 144 | repository.getBySessionId({ providerSessionId: sessionId }).pipe( | |
| 145 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), | |
| 146 | Effect.flatMap((runtime) => | |
| 147 | Option.match(runtime, { | |
| 148 | onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()), | |
| 149 | onSome: (value) => | |
| 150 | decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( | |
| 151 | Effect.map((provider) => | |
| 152 | Option.some({ | |
| 153 | sessionId: value.providerSessionId, | |
| 154 | provider, | |
| 155 | threadId: value.threadId, | |
| 156 | }), | |
| 157 | ), | |
| 158 | ), | |
| 159 | }), | |
| 58 | 160 | ), |
| 59 | 161 | ); |
| 60 | 162 | |
| 61 | 163 | const listBindings = () => |
| 62 | repository.listSessions().pipe( | |
| 63 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")), | |
| 64 | Effect.map((rows) => | |
| 65 | rows.map((row) => ({ | |
| 66 | sessionId: row.sessionId, | |
| 67 | provider: row.provider, | |
| 68 | ...(row.threadId !== undefined ? { threadId: row.threadId } : {}), | |
| 69 | })), | |
| 164 | repository.list().pipe( | |
| 165 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), | |
| 166 | Effect.flatMap((rows) => | |
| 167 | Effect.forEach( | |
| 168 | rows, | |
| 169 | (row) => | |
| 170 | decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( | |
| 171 | Effect.map((provider) => ({ | |
| 172 | sessionId: row.providerSessionId, | |
| 173 | provider, | |
| 174 | threadId: row.threadId, | |
| 175 | })), | |
| 176 | ), | |
| 177 | { concurrency: "unbounded" }, | |
| 178 | ), | |
| 70 | 179 | ), |
| 71 | 180 | ); |
| 72 | 181 |
apps/server/src/provider/Services/ProviderAdapter.ts+18-9| ... | ... | @@ -8,24 +8,28 @@ |
| 8 | 8 | * @module ProviderAdapter |
| 9 | 9 | */ |
| 10 | 10 | import type { |
| 11 | ApprovalRequestId, | |
| 11 | 12 | ProviderApprovalDecision, |
| 12 | 13 | ProviderKind, |
| 13 | 14 | ProviderRuntimeEvent, |
| 14 | 15 | ProviderSendTurnInput, |
| 15 | 16 | ProviderSession, |
| 17 | ProviderSessionId, | |
| 16 | 18 | ProviderSessionStartInput, |
| 19 | ProviderThreadId, | |
| 20 | ProviderTurnId, | |
| 17 | 21 | ProviderTurnStartResult, |
| 18 | 22 | } from "@t3tools/contracts"; |
| 19 | 23 | import type { Effect } from "effect"; |
| 20 | 24 | import type { Stream } from "effect"; |
| 21 | 25 | |
| 22 | 26 | export interface ProviderThreadTurnSnapshot { |
| 23 | readonly id: string; | |
| 27 | readonly id: ProviderTurnId; | |
| 24 | 28 | readonly items: ReadonlyArray<unknown>; |
| 25 | 29 | } |
| 26 | 30 | |
| 27 | 31 | export interface ProviderThreadSnapshot { |
| 28 | readonly threadId: string; | |
| 32 | readonly threadId: ProviderThreadId; | |
| 29 | 33 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; |
| 30 | 34 | } |
| 31 | 35 | |
| ... | ... | @@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> { |
| 52 | 56 | /** |
| 53 | 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>; | |
| 56 | 63 | |
| 57 | 64 | /** |
| 58 | 65 | * Respond to an interactive approval request. |
| 59 | 66 | */ |
| 60 | 67 | readonly respondToRequest: ( |
| 61 | sessionId: string, | |
| 62 | requestId: string, | |
| 68 | sessionId: ProviderSessionId, | |
| 69 | requestId: ApprovalRequestId, | |
| 63 | 70 | decision: ProviderApprovalDecision, |
| 64 | 71 | ) => Effect.Effect<void, TError>; |
| 65 | 72 | |
| 66 | 73 | /** |
| 67 | 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>; | |
| 70 | 77 | |
| 71 | 78 | /** |
| 72 | 79 | * List currently active provider sessions for this adapter. |
| ... | ... | @@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> { |
| 76 | 83 | /** |
| 77 | 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>; | |
| 80 | 87 | |
| 81 | 88 | /** |
| 82 | 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>; | |
| 85 | 94 | |
| 86 | 95 | /** |
| 87 | 96 | * Roll back a provider thread by N turns. |
| 88 | 97 | */ |
| 89 | 98 | readonly rollbackThread: ( |
| 90 | sessionId: string, | |
| 99 | sessionId: ProviderSessionId, | |
| 91 | 100 | numTurns: number, |
| 92 | 101 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; |
| 93 | 102 |
apps/server/src/provider/Services/ProviderService.ts+7-25| ... | ... | @@ -12,20 +12,15 @@ |
| 12 | 12 | * @module ProviderService |
| 13 | 13 | */ |
| 14 | 14 | import type { |
| 15 | ProviderGetCheckpointDiffInput, | |
| 16 | ProviderGetCheckpointDiffResult, | |
| 17 | 15 | ProviderInterruptTurnInput, |
| 18 | ProviderListCheckpointsInput, | |
| 19 | ProviderListCheckpointsResult, | |
| 20 | 16 | ProviderRespondToRequestInput, |
| 21 | ProviderRevertToCheckpointInput, | |
| 22 | ProviderRevertToCheckpointResult, | |
| 17 | ProviderRuntimeEvent, | |
| 23 | 18 | ProviderSendTurnInput, |
| 24 | 19 | ProviderSession, |
| 20 | ProviderSessionId, | |
| 25 | 21 | ProviderSessionStartInput, |
| 26 | 22 | ProviderStopSessionInput, |
| 27 | 23 | ProviderTurnStartResult, |
| 28 | ProviderRuntimeEvent, | |
| 29 | 24 | } from "@t3tools/contracts"; |
| 30 | 25 | import { ServiceMap } from "effect"; |
| 31 | 26 | import type { Effect, Stream } from "effect"; |
| ... | ... | @@ -74,25 +69,12 @@ export interface ProviderServiceShape { |
| 74 | 69 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; |
| 75 | 70 | |
| 76 | 71 | /** |
| 77 | * List checkpoints for a provider session. | |
| 78 | */ | |
| 79 | readonly listCheckpoints: ( | |
| 80 | input: ProviderListCheckpointsInput, | |
| 81 | ) => Effect.Effect<ProviderListCheckpointsResult, ProviderServiceError>; | |
| 82 | ||
| 83 | /** | |
| 84 | * Diff two checkpoints for a provider session. | |
| 85 | */ | |
| 86 | readonly getCheckpointDiff: ( | |
| 87 | input: ProviderGetCheckpointDiffInput, | |
| 88 | ) => Effect.Effect<ProviderGetCheckpointDiffResult, ProviderServiceError>; | |
| 89 | ||
| 90 | /** | |
| 91 | * Revert a provider session to a checkpoint. | |
| 72 | * Roll back provider conversation state by a number of turns. | |
| 92 | 73 | */ |
| 93 | readonly revertToCheckpoint: ( | |
| 94 | input: ProviderRevertToCheckpointInput, | |
| 95 | ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>; | |
| 74 | readonly rollbackConversation: (input: { | |
| 75 | readonly sessionId: ProviderSessionId; | |
| 76 | readonly numTurns: number; | |
| 77 | }) => Effect.Effect<void, ProviderServiceError>; | |
| 96 | 78 | |
| 97 | 79 | /** |
| 98 | 80 | * Stop all active provider sessions. |
apps/server/src/provider/Services/ProviderSessionDirectory.ts+19-8| ... | ... | @@ -7,7 +7,13 @@ |
| 7 | 7 | * |
| 8 | 8 | * @module ProviderSessionDirectory |
| 9 | 9 | */ |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | |
| 10 | import type { | |
| 11 | ProviderKind, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderSessionRuntimeStatus, | |
| 14 | ProviderThreadId, | |
| 15 | ThreadId, | |
| 16 | } from "@t3tools/contracts"; | |
| 11 | 17 | import { Option, ServiceMap } from "effect"; |
| 12 | 18 | import type { Effect } from "effect"; |
| 13 | 19 | |
| ... | ... | @@ -18,9 +24,14 @@ import type { |
| 18 | 24 | } from "../Errors.ts"; |
| 19 | 25 | |
| 20 | 26 | export interface ProviderSessionBinding { |
| 21 | readonly sessionId: string; | |
| 27 | readonly sessionId: ProviderSessionId; | |
| 22 | 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 | } |
| 25 | 36 | |
| 26 | 37 | export type ProviderSessionDirectoryReadError = |
| ... | ... | @@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape { |
| 43 | 54 | * Resolve the provider owner for a session id. |
| 44 | 55 | */ |
| 45 | 56 | readonly getProvider: ( |
| 46 | sessionId: string, | |
| 57 | sessionId: ProviderSessionId, | |
| 47 | 58 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; |
| 48 | 59 | |
| 49 | 60 | /** |
| 50 | 61 | * Resolve the tracked thread id for a session, if known. |
| 51 | 62 | */ |
| 52 | 63 | readonly getThreadId: ( |
| 53 | sessionId: string, | |
| 54 | ) => Effect.Effect<Option.Option<string>, ProviderSessionDirectoryReadError>; | |
| 64 | sessionId: ProviderSessionId, | |
| 65 | ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>; | |
| 55 | 66 | |
| 56 | 67 | /** |
| 57 | 68 | * Remove a session binding. |
| 58 | 69 | */ |
| 59 | 70 | readonly remove: ( |
| 60 | sessionId: string, | |
| 71 | sessionId: ProviderSessionId, | |
| 61 | 72 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; |
| 62 | 73 | |
| 63 | 74 | /** |
| 64 | 75 | * List tracked session ids. |
| 65 | 76 | */ |
| 66 | 77 | readonly listSessionIds: () => Effect.Effect< |
| 67 | ReadonlyArray<string>, | |
| 78 | ReadonlyArray<ProviderSessionId>, | |
| 68 | 79 | ProviderSessionDirectoryPersistenceError |
| 69 | 80 | >; |
| 70 | 81 |
apps/server/src/providerManager.test.ts deleted-892| ... | ... | @@ -1,892 +0,0 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { ProviderManager } from "./providerManager"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import fs from "node:fs"; | |
| 3 | import path from "node:path"; | |
| 4 | import { randomUUID } from "node:crypto"; | |
| 5 | ||
| 6 | import { | |
| 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"; | |
| 31 | import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; | |
| 32 | import { CodexAppServerManager } from "./codexAppServerManager"; | |
| 33 | import { FilesystemCheckpointStore } from "./filesystemCheckpointStore"; | |
| 34 | ||
| 35 | export interface ProviderManagerEvents { | |
| 36 | event: [event: ProviderEvent]; | |
| 37 | } | |
| 38 | ||
| 39 | function 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 | ||
| 46 | function asString(value: unknown): string | undefined { | |
| 47 | return typeof value === "string" ? value : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | function asArray(value: unknown): unknown[] { | |
| 51 | return Array.isArray(value) ? value : []; | |
| 52 | } | |
| 53 | ||
| 54 | function 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 | ||
| 62 | function 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 | ||
| 95 | function 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 | ||
| 133 | function readTurnCompletionStatus(event: ProviderEvent): string | undefined { | |
| 134 | const payload = asObject(event.payload); | |
| 135 | const turn = asObject(payload?.turn); | |
| 136 | return asString(turn?.status); | |
| 137 | } | |
| 138 | ||
| 139 | function 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 | ||
| 169 | export 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 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | DEFAULT_TERMINAL_ID, |
| 7 | type TerminalClearInput, | |
| 8 | type TerminalCloseInput, | |
| 7 | TerminalClearInput, | |
| 8 | TerminalCloseInput, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalWriteInput, | |
| 9 | 12 | type TerminalEvent, |
| 10 | type TerminalOpenInput, | |
| 11 | type TerminalResizeInput, | |
| 12 | 13 | type TerminalSessionSnapshot, |
| 13 | 14 | type TerminalSessionStatus, |
| 14 | type TerminalWriteInput, | |
| 15 | terminalClearInputSchema, | |
| 16 | terminalCloseInputSchema, | |
| 17 | terminalOpenInputSchema, | |
| 18 | terminalResizeInputSchema, | |
| 19 | terminalWriteInputSchema, | |
| 20 | 15 | } from "@t3tools/contracts"; |
| 16 | import { Schema } from "effect"; | |
| 21 | 17 | |
| 22 | 18 | import { createLogger } from "./logger"; |
| 23 | 19 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| ... | ... | @@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120; |
| 30 | 26 | const DEFAULT_OPEN_ROWS = 30; |
| 31 | 27 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 32 | 28 | |
| 29 | const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); | |
| 30 | const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); | |
| 31 | const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); | |
| 32 | const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); | |
| 33 | const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); | |
| 34 | ||
| 33 | 35 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; |
| 34 | 36 | |
| 35 | 37 | export interface TerminalManagerEvents { |
| ... | ... | @@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 332 | 334 | } |
| 333 | 335 | |
| 334 | 336 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 335 | const input = terminalOpenInputSchema.parse(raw); | |
| 337 | const input = decodeTerminalOpenInput(raw); | |
| 336 | 338 | return this.runWithThreadLock(input.threadId, async () => { |
| 337 | 339 | await this.assertValidCwd(input.cwd); |
| 338 | 340 | |
| ... | ... | @@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 404 | 406 | } |
| 405 | 407 | |
| 406 | 408 | async write(raw: TerminalWriteInput): Promise<void> { |
| 407 | const input = terminalWriteInputSchema.parse(raw); | |
| 409 | const input = decodeTerminalWriteInput(raw); | |
| 408 | 410 | const session = this.requireSession(input.threadId, input.terminalId); |
| 409 | 411 | if (!session.process || session.status !== "running") { |
| 410 | 412 | throw new Error( |
| ... | ... | @@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 415 | 417 | } |
| 416 | 418 | |
| 417 | 419 | async resize(raw: TerminalResizeInput): Promise<void> { |
| 418 | const input = terminalResizeInputSchema.parse(raw); | |
| 420 | const input = decodeTerminalResizeInput(raw); | |
| 419 | 421 | const session = this.requireSession(input.threadId, input.terminalId); |
| 420 | 422 | if (!session.process || session.status !== "running") { |
| 421 | 423 | throw new Error( |
| ... | ... | @@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 429 | 431 | } |
| 430 | 432 | |
| 431 | 433 | async clear(raw: TerminalClearInput): Promise<void> { |
| 432 | const input = terminalClearInputSchema.parse(raw); | |
| 434 | const input = decodeTerminalClearInput(raw); | |
| 433 | 435 | await this.runWithThreadLock(input.threadId, async () => { |
| 434 | 436 | const session = this.requireSession(input.threadId, input.terminalId); |
| 435 | 437 | session.history = ""; |
| ... | ... | @@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 445 | 447 | } |
| 446 | 448 | |
| 447 | 449 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 448 | const input = terminalOpenInputSchema.parse(raw); | |
| 450 | const input = decodeTerminalOpenInput(raw); | |
| 449 | 451 | return this.runWithThreadLock(input.threadId, async () => { |
| 450 | 452 | await this.assertValidCwd(input.cwd); |
| 451 | 453 | |
| ... | ... | @@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 490 | 492 | } |
| 491 | 493 | |
| 492 | 494 | async close(raw: TerminalCloseInput): Promise<void> { |
| 493 | const input = terminalCloseInputSchema.parse(raw); | |
| 495 | const input = decodeTerminalCloseInput(raw); | |
| 494 | 496 | await this.runWithThreadLock(input.threadId, async () => { |
| 495 | 497 | if (input.terminalId) { |
| 496 | 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 | 4 | import { runProcess } from "./processRunner"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | projectSearchEntriesInputSchema, | |
| 8 | type ProjectEntry, | |
| 9 | type ProjectSearchEntriesInput, | |
| 10 | type ProjectSearchEntriesResult, | |
| 7 | ProjectEntry, | |
| 8 | ProjectSearchEntriesInput, | |
| 9 | ProjectSearchEntriesResult, | |
| 11 | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | |
| 13 | 12 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| ... | ... | @@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 413 | 412 | export async function searchWorkspaceEntries( |
| 414 | 413 | input: ProjectSearchEntriesInput, |
| 415 | 414 | ): Promise<ProjectSearchEntriesResult> { |
| 416 | const parsed = projectSearchEntriesInputSchema.parse(input); | |
| 417 | const index = await getWorkspaceIndex(parsed.cwd); | |
| 418 | const normalizedQuery = normalizeQuery(parsed.query); | |
| 415 | const index = await getWorkspaceIndex(input.cwd); | |
| 416 | const normalizedQuery = normalizeQuery(input.query); | |
| 419 | 417 | const candidates = normalizedQuery |
| 420 | 418 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) |
| 421 | 419 | : index.entries; |
| ... | ... | @@ -427,7 +425,7 @@ export async function searchWorkspaceEntries( |
| 427 | 425 | }); |
| 428 | 426 | |
| 429 | 427 | return { |
| 430 | entries: ranked.slice(0, parsed.limit), | |
| 431 | truncated: index.truncated || ranked.length > parsed.limit, | |
| 428 | entries: ranked.slice(0, input.limit), | |
| 429 | truncated: index.truncated || ranked.length > input.limit, | |
| 432 | 430 | }; |
| 433 | 431 | } |
apps/server/src/wsServer.test.ts+30-168| ... | ... | @@ -3,7 +3,7 @@ import os from "node:os"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { EventEmitter } from "node:events"; |
| 5 | 5 | |
| 6 | import { Effect, Layer, ManagedRuntime, PubSub, Stream } from "effect"; | |
| 6 | import { Effect, Layer, PubSub, Stream } from "effect"; | |
| 7 | 7 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 8 | 8 | import { createServer } from "./wsServer"; |
| 9 | 9 | import WebSocket from "ws"; |
| ... | ... | @@ -21,7 +21,6 @@ import { |
| 21 | 21 | type WsResponse, |
| 22 | 22 | } from "@t3tools/contracts"; |
| 23 | 23 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| 24 | import { ProjectRepository } from "./persistence/Services/Projects"; | |
| 25 | 24 | import type { |
| 26 | 25 | TerminalClearInput, |
| 27 | 26 | TerminalCloseInput, |
| ... | ... | @@ -32,9 +31,7 @@ import type { |
| 32 | 31 | TerminalWriteInput, |
| 33 | 32 | } from "@t3tools/contracts"; |
| 34 | 33 | import type { TerminalManager } from "./terminalManager"; |
| 35 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | |
| 36 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | |
| 37 | import { NodeServices } from "@effect/platform-node"; | |
| 34 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | |
| 38 | 35 | import { SqlClient } from "effect/unstable/sql"; |
| 39 | 36 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 40 | 37 | |
| ... | ... | @@ -664,38 +661,6 @@ describe("WebSocket Server", () => { |
| 664 | 661 | expect(response.error!.message).toContain("Unknown method"); |
| 665 | 662 | }); |
| 666 | 663 | |
| 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 | 664 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 700 | 665 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 701 | 666 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| ... | ... | @@ -709,15 +674,7 @@ describe("WebSocket Server", () => { |
| 709 | 674 | respondToRequest: () => unsupported(), |
| 710 | 675 | stopSession: () => unsupported(), |
| 711 | 676 | listSessions: () => Effect.succeed([]), |
| 712 | listCheckpoints: () => unsupported(), | |
| 713 | getCheckpointDiff: (input) => | |
| 714 | Effect.succeed({ | |
| 715 | threadId: "thread-1", | |
| 716 | fromTurnCount: input.fromTurnCount, | |
| 717 | toTurnCount: input.toTurnCount, | |
| 718 | diff: "", | |
| 719 | }), | |
| 720 | revertToCheckpoint: () => unsupported(), | |
| 677 | rollbackConversation: () => unsupported(), | |
| 721 | 678 | stopAll: () => Effect.void, |
| 722 | 679 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 723 | 680 | }; |
| ... | ... | @@ -736,6 +693,16 @@ describe("WebSocket Server", () => { |
| 736 | 693 | await waitForMessage(ws); |
| 737 | 694 | |
| 738 | 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 | 706 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { |
| 740 | 707 | type: "thread.create", |
| 741 | 708 | commandId: "cmd-ws-runtime-thread-create", |
| ... | ... | @@ -928,11 +895,8 @@ describe("WebSocket Server", () => { |
| 928 | 895 | expect(response!.error!.message).toContain("Invalid request format"); |
| 929 | 896 | }); |
| 930 | 897 | |
| 931 | it("supports projects list/add/dedupe/remove", async () => { | |
| 932 | const stateDir = makeTempDir("t3code-ws-projects-state-"); | |
| 933 | const firstProjectCwd = makeTempDir("t3code-ws-project-a-"); | |
| 934 | ||
| 935 | server = createTestServer({ stateDir, cwd: "/test" }); | |
| 898 | it("returns unknown method for removed projects CRUD methods", async () => { | |
| 899 | server = createTestServer({ cwd: "/test" }); | |
| 936 | 900 | await server.start(); |
| 937 | 901 | const addr = server.httpServer.address(); |
| 938 | 902 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | ... | @@ -941,75 +905,21 @@ describe("WebSocket Server", () => { |
| 941 | 905 | connections.push(ws); |
| 942 | 906 | await waitForMessage(ws); |
| 943 | 907 | |
| 944 | const emptyList = await sendRequest(ws, WS_METHODS.projectsList); | |
| 945 | expect(emptyList.error).toBeUndefined(); | |
| 946 | expect(emptyList.result).toEqual([]); | |
| 947 | ||
| 948 | const created = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 949 | cwd: firstProjectCwd, | |
| 950 | }); | |
| 951 | expect(created.error).toBeUndefined(); | |
| 952 | expect((created.result as { created: boolean }).created).toBe(true); | |
| 908 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); | |
| 909 | expect(listResponse.result).toBeUndefined(); | |
| 910 | expect(listResponse.error?.message).toContain("Unknown method"); | |
| 953 | 911 | |
| 954 | const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 955 | cwd: firstProjectCwd, | |
| 956 | }); | |
| 957 | expect(duplicate.error).toBeUndefined(); | |
| 958 | expect((duplicate.result as { created: boolean }).created).toBe(false); | |
| 959 | ||
| 960 | const listed = await sendRequest(ws, WS_METHODS.projectsList); | |
| 961 | expect(listed.error).toBeUndefined(); | |
| 962 | const listedProjects = listed.result as Array<{ id: string; cwd: string }>; | |
| 963 | expect(listedProjects).toHaveLength(1); | |
| 964 | ||
| 965 | const projectId = listedProjects[0]?.id; | |
| 966 | expect(projectId).toBeTruthy(); | |
| 967 | if (!projectId) return; | |
| 968 | ||
| 969 | const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { | |
| 970 | id: projectId, | |
| 971 | scripts: [ | |
| 972 | { | |
| 973 | id: "setup", | |
| 974 | name: "Setup", | |
| 975 | command: "bun install", | |
| 976 | icon: "configure", | |
| 977 | runOnWorktreeCreate: true, | |
| 978 | }, | |
| 979 | ], | |
| 912 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { | |
| 913 | cwd: "/tmp/project-a", | |
| 980 | 914 | }); |
| 981 | expect(updatedScripts.error).toBeUndefined(); | |
| 982 | const scriptPayload = ( | |
| 983 | updatedScripts.result as { | |
| 984 | project: { | |
| 985 | scripts: Array<{ | |
| 986 | id: string; | |
| 987 | name: string; | |
| 988 | command: string; | |
| 989 | icon: string; | |
| 990 | runOnWorktreeCreate: boolean; | |
| 991 | }>; | |
| 992 | }; | |
| 993 | } | |
| 994 | ).project.scripts; | |
| 995 | expect(scriptPayload).toEqual([ | |
| 996 | { | |
| 997 | id: "setup", | |
| 998 | name: "Setup", | |
| 999 | command: "bun install", | |
| 1000 | icon: "configure", | |
| 1001 | runOnWorktreeCreate: true, | |
| 1002 | }, | |
| 1003 | ]); | |
| 915 | expect(addResponse.result).toBeUndefined(); | |
| 916 | expect(addResponse.error?.message).toContain("Unknown method"); | |
| 1004 | 917 | |
| 1005 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 1006 | id: projectId, | |
| 918 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { | |
| 919 | id: "project-a", | |
| 1007 | 920 | }); |
| 1008 | expect(removed.error).toBeUndefined(); | |
| 1009 | ||
| 1010 | const afterRemove = await sendRequest(ws, WS_METHODS.projectsList); | |
| 1011 | expect(afterRemove.error).toBeUndefined(); | |
| 1012 | expect(afterRemove.result).toEqual([]); | |
| 921 | expect(removeResponse.result).toBeUndefined(); | |
| 922 | expect(removeResponse.error?.message).toContain("Unknown method"); | |
| 1013 | 923 | }); |
| 1014 | 924 | |
| 1015 | 925 | it("supports projects.searchEntries", async () => { |
| ... | ... | @@ -1079,7 +989,7 @@ describe("WebSocket Server", () => { |
| 1079 | 989 | expect(pullResponse.error?.message).not.toContain("Unknown method"); |
| 1080 | 990 | }); |
| 1081 | 991 | |
| 1082 | it("responds to git.status via the git manager", async () => { | |
| 992 | it("returns unknown method for git.status", async () => { | |
| 1083 | 993 | const gitManager = { |
| 1084 | 994 | status: vi.fn().mockResolvedValue({ |
| 1085 | 995 | branch: "feature/test", |
| ... | ... | @@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => { |
| 1109 | 1019 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { |
| 1110 | 1020 | cwd: "/test", |
| 1111 | 1021 | }); |
| 1112 | expect(response.error).toBeUndefined(); | |
| 1113 | expect(response.result).toEqual({ | |
| 1114 | branch: "feature/test", | |
| 1115 | hasWorkingTreeChanges: true, | |
| 1116 | workingTree: { | |
| 1117 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | |
| 1118 | insertions: 7, | |
| 1119 | deletions: 2, | |
| 1120 | }, | |
| 1121 | hasUpstream: false, | |
| 1122 | aheadCount: 0, | |
| 1123 | behindCount: 0, | |
| 1124 | openPr: null, | |
| 1125 | }); | |
| 1126 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1022 | expect(response.result).toBeUndefined(); | |
| 1023 | expect(response.error?.message).toContain("Unknown method"); | |
| 1024 | expect(gitManager.status).not.toHaveBeenCalled(); | |
| 1127 | 1025 | }); |
| 1128 | 1026 | |
| 1129 | 1027 | it("returns errors from git.runStackedAction", async () => { |
| ... | ... | @@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => { |
| 1153 | 1051 | }); |
| 1154 | 1052 | }); |
| 1155 | 1053 | |
| 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 | 1054 | it("rejects websocket connections without a valid auth token", async () => { |
| 1193 | 1055 | server = createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1194 | 1056 | await server.start(); |
apps/server/src/wsServer.ts+161-197| ... | ... | @@ -9,16 +9,15 @@ import { |
| 9 | 9 | EDITORS, |
| 10 | 10 | ORCHESTRATION_WS_CHANNELS, |
| 11 | 11 | ORCHESTRATION_WS_METHODS, |
| 12 | TerminalEvent, | |
| 12 | 13 | WS_CHANNELS, |
| 13 | 14 | WS_METHODS, |
| 14 | type TerminalEvent, | |
| 15 | type WsPush, | |
| 16 | type WsRequest, | |
| 17 | type WsResponse, | |
| 18 | wsRequestSchema, | |
| 15 | WebSocketRequest, | |
| 16 | WsPush, | |
| 17 | WsResponse, | |
| 19 | 18 | } from "@t3tools/contracts"; |
| 20 | 19 | import { NodeServices } from "@effect/platform-node"; |
| 21 | import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; | |
| 20 | import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect"; | |
| 22 | 21 | import { WebSocketServer, type WebSocket } from "ws"; |
| 23 | 22 | |
| 24 | 23 | import { createLogger } from "./logger"; |
| ... | ... | @@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager"; |
| 36 | 35 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 37 | 36 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 38 | 37 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; |
| 38 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; | |
| 39 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; | |
| 40 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; | |
| 41 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | |
| 42 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | |
| 39 | 43 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; |
| 40 | 44 | import { |
| 41 | 45 | OrchestrationEngineService, |
| 42 | 46 | type OrchestrationEngineShape, |
| 43 | 47 | } from "./orchestration/Services/OrchestrationEngine"; |
| 44 | import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion"; | |
| 45 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | |
| 48 | import { | |
| 49 | ProjectionSnapshotQuery, | |
| 50 | type ProjectionSnapshotQueryShape, | |
| 51 | } from "./orchestration/Services/ProjectionSnapshotQuery"; | |
| 52 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | |
| 46 | 53 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; |
| 47 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; | |
| 48 | 54 | import assert from "node:assert"; |
| 55 | import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs"; | |
| 49 | 56 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| 57 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | |
| 50 | 58 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 51 | 59 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 52 | 60 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 53 | 61 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 54 | 62 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 55 | 63 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 56 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; | |
| 57 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; | |
| 58 | import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions"; | |
| 64 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; | |
| 65 | import { | |
| 66 | CheckpointDiffBlobRepository, | |
| 67 | type CheckpointDiffBlobRepositoryShape, | |
| 68 | } from "./persistence/Services/CheckpointDiffBlobs"; | |
| 59 | 69 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 60 | 70 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 61 | 71 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| 62 | 72 | import * as Migrator from "effect/unstable/sql/Migrator"; |
| 73 | import { clamp } from "effect/Number"; | |
| 63 | 74 | |
| 64 | 75 | const MIME_TYPES: Record<string, string> = { |
| 65 | 76 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 122 | 133 | } |
| 123 | 134 | |
| 124 | 135 | type EffectRuntime = ManagedRuntime.ManagedRuntime< |
| 125 | | ProjectRepository | |
| 126 | 136 | | OrchestrationEngineService |
| 137 | | ProjectionSnapshotQuery | |
| 127 | 138 | | ProviderService |
| 128 | | ProviderRuntimeIngestionService, | |
| 139 | | CheckpointDiffBlobRepository | |
| 140 | | OrchestrationReactor, | |
| 129 | 141 | unknown |
| 130 | 142 | >; |
| 131 | 143 | |
| ... | ... | @@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) { |
| 147 | 159 | |
| 148 | 160 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); |
| 149 | 161 | let effectRuntime: EffectRuntime | null = null; |
| 150 | let projectRepository: ProjectRepositoryShape | null = null; | |
| 151 | 162 | let orchestrationEngine: OrchestrationEngineShape | null = null; |
| 163 | let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null; | |
| 152 | 164 | let providerService: ProviderServiceShape | null = null; |
| 165 | let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null; | |
| 153 | 166 | const clients = new Set<WebSocket>(); |
| 154 | 167 | const logger = createLogger("ws"); |
| 155 | 168 | const logWebSocketEvents = |
| ... | ... | @@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) { |
| 173 | 186 | return orchestrationEngine; |
| 174 | 187 | }; |
| 175 | 188 | |
| 176 | const getProjectRepository = () => { | |
| 177 | assert(projectRepository, "Project repository is not started"); | |
| 178 | return projectRepository; | |
| 179 | }; | |
| 180 | ||
| 181 | 189 | const getEffectRuntime = () => { |
| 182 | 190 | assert(effectRuntime, "Effect runtime is not started"); |
| 183 | 191 | return effectRuntime; |
| 184 | 192 | }; |
| 185 | 193 | |
| 186 | const getProviderService = () => { | |
| 187 | assert(providerService, "Provider service is not started"); | |
| 188 | return providerService; | |
| 194 | const getProjectionSnapshotQuery = () => { | |
| 195 | assert(projectionSnapshotQuery, "Projection snapshot query is not started"); | |
| 196 | return projectionSnapshotQuery; | |
| 197 | }; | |
| 198 | ||
| 199 | const getCheckpointDiffBlobRepository = () => { | |
| 200 | assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started"); | |
| 201 | return checkpointDiffBlobRepository; | |
| 189 | 202 | }; |
| 190 | 203 | |
| 191 | 204 | const attachSubscriptionss = Effect.gen(function* () { |
| 192 | 205 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 193 | const runtimeIngestion = yield* ProviderRuntimeIngestionService; | |
| 206 | const orchestrationReactor = yield* OrchestrationReactor; | |
| 194 | 207 | subscriptionsScope = yield* Scope.make("sequential"); |
| 195 | 208 | |
| 196 | 209 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => |
| ... | ... | @@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) { |
| 216 | 229 | }), |
| 217 | 230 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 218 | 231 | |
| 219 | yield* runtimeIngestion.start.pipe(Scope.provide(subscriptionsScope)); | |
| 232 | yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope)); | |
| 220 | 233 | }); |
| 221 | 234 | |
| 222 | 235 | const onTerminalEvent = (event: TerminalEvent) => { |
| ... | ... | @@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) { |
| 347 | 360 | }); |
| 348 | 361 | |
| 349 | 362 | async function handleMessage(ws: WebSocket, raw: unknown) { |
| 350 | let request: WsRequest; | |
| 363 | let request: WebSocketRequest; | |
| 351 | 364 | try { |
| 352 | const parsed = JSON.parse(String(raw)); | |
| 353 | request = wsRequestSchema.parse(parsed); | |
| 365 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw); | |
| 354 | 366 | } catch { |
| 355 | 367 | const errorResponse: WsResponse = { |
| 356 | 368 | id: "unknown", |
| ... | ... | @@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) { |
| 374 | 386 | } |
| 375 | 387 | } |
| 376 | 388 | |
| 377 | async function routeRequest(request: WsRequest): Promise<unknown> { | |
| 389 | async function routeRequest(request: WebSocketRequest): Promise<unknown> { | |
| 378 | 390 | const orchestrationEngine = getOrchestrationEngine(); |
| 379 | const projectRepository = getProjectRepository(); | |
| 391 | const projectionReadModelQuery = getProjectionSnapshotQuery(); | |
| 380 | 392 | const effectRuntime = getEffectRuntime(); |
| 381 | const providerService = getProviderService(); | |
| 382 | ||
| 383 | switch (request.method) { | |
| 384 | case WS_METHODS.providersStartSession: | |
| 385 | return effectRuntime.runPromise(providerService.startSession(request.params as never)); | |
| 386 | ||
| 387 | case WS_METHODS.providersSendTurn: | |
| 388 | return effectRuntime.runPromise(providerService.sendTurn(request.params as never)); | |
| 393 | const diffBlobRepository = getCheckpointDiffBlobRepository(); | |
| 389 | 394 | |
| 390 | case WS_METHODS.providersInterruptTurn: | |
| 391 | return effectRuntime.runPromise(providerService.interruptTurn(request.params as never)); | |
| 392 | ||
| 393 | case WS_METHODS.providersRespondToRequest: | |
| 394 | return effectRuntime.runPromise(providerService.respondToRequest(request.params as never)); | |
| 395 | ||
| 396 | case WS_METHODS.providersStopSession: { | |
| 397 | await effectRuntime.runPromise(providerService.stopSession(request.params as never)); | |
| 398 | return undefined; | |
| 395 | switch (request.body._tag) { | |
| 396 | case ORCHESTRATION_WS_METHODS.getSnapshot: { | |
| 397 | return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot()); | |
| 399 | 398 | } |
| 400 | 399 | |
| 401 | case WS_METHODS.providersListCheckpoints: | |
| 402 | return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never)); | |
| 403 | ||
| 404 | case WS_METHODS.providersGetCheckpointDiff: | |
| 405 | return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never)); | |
| 406 | ||
| 407 | case WS_METHODS.providersRevertToCheckpoint: { | |
| 408 | const result = await effectRuntime.runPromise( | |
| 409 | providerService.revertToCheckpoint(request.params as never), | |
| 410 | ); | |
| 411 | const params = request.params as { sessionId?: string }; | |
| 412 | const sessionId = params.sessionId; | |
| 413 | if (typeof sessionId === "string") { | |
| 414 | const readModel = await effectRuntime.runPromise(orchestrationEngine.getReadModel()); | |
| 415 | const thread = readModel.threads.find((entry) => entry.session?.sessionId === sessionId); | |
| 416 | if (thread) { | |
| 417 | const now = new Date().toISOString(); | |
| 418 | await effectRuntime.runPromise( | |
| 419 | orchestrationEngine.dispatch({ | |
| 420 | type: "thread.revert", | |
| 421 | commandId: crypto.randomUUID(), | |
| 422 | threadId: thread.id, | |
| 423 | turnCount: result.turnCount, | |
| 424 | messageCount: result.messageCount, | |
| 425 | createdAt: now, | |
| 426 | }), | |
| 427 | ); | |
| 428 | if (thread.session) { | |
| 429 | await effectRuntime.runPromise( | |
| 430 | orchestrationEngine.dispatch({ | |
| 431 | type: "thread.session", | |
| 432 | commandId: crypto.randomUUID(), | |
| 433 | threadId: thread.id, | |
| 434 | session: { | |
| 435 | ...thread.session, | |
| 436 | status: "ready", | |
| 437 | activeTurnId: null, | |
| 438 | updatedAt: now, | |
| 439 | lastError: null, | |
| 440 | }, | |
| 441 | createdAt: now, | |
| 442 | }), | |
| 443 | ); | |
| 444 | } | |
| 445 | } | |
| 446 | } | |
| 447 | return result; | |
| 400 | case ORCHESTRATION_WS_METHODS.dispatchCommand: { | |
| 401 | orchestrationCommandLogger?.write({ | |
| 402 | observedAt: new Date().toISOString(), | |
| 403 | command: request.body.command, | |
| 404 | }); | |
| 405 | return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command)); | |
| 448 | 406 | } |
| 449 | 407 | |
| 450 | case WS_METHODS.projectsList: | |
| 451 | return effectRuntime.runPromise(projectRepository.list()); | |
| 408 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { | |
| 409 | const blob = await effectRuntime.runPromise(diffBlobRepository.get(request.body)); | |
| 410 | if (Option.isNone(blob)) { | |
| 411 | throw new Error( | |
| 412 | `Turn diff not found for thread '${request.body.threadId}' from ${request.body.fromTurnCount} to ${request.body.toTurnCount}.`, | |
| 413 | ); | |
| 414 | } | |
| 452 | 415 | |
| 453 | case WS_METHODS.projectsAdd: | |
| 454 | return effectRuntime.runPromise(projectRepository.add(request.params as never)); | |
| 416 | return { | |
| 417 | threadId: request.body.threadId, | |
| 418 | fromTurnCount: request.body.fromTurnCount, | |
| 419 | toTurnCount: request.body.toTurnCount, | |
| 420 | diff: blob.value?.diff, | |
| 421 | }; | |
| 422 | } | |
| 455 | 423 | |
| 456 | case WS_METHODS.projectsRemove: | |
| 457 | return effectRuntime.runPromise(projectRepository.remove(request.params as never)); | |
| 424 | case ORCHESTRATION_WS_METHODS.replayEvents: | |
| 425 | return effectRuntime.runPromise( | |
| 426 | Stream.runCollect( | |
| 427 | orchestrationEngine.readEvents( | |
| 428 | clamp(request.body.fromSequenceExclusive, { | |
| 429 | maximum: Number.MAX_SAFE_INTEGER, | |
| 430 | minimum: 0, | |
| 431 | }), | |
| 432 | ), | |
| 433 | ).pipe(Effect.map((events) => Array.from(events))), | |
| 434 | ); | |
| 458 | 435 | |
| 459 | 436 | case WS_METHODS.projectsSearchEntries: |
| 460 | return searchWorkspaceEntries(request.params as never); | |
| 461 | case WS_METHODS.projectsUpdateScripts: | |
| 462 | return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never)); | |
| 437 | return searchWorkspaceEntries(request.body); | |
| 463 | 438 | |
| 464 | 439 | case WS_METHODS.shellOpenInEditor: { |
| 465 | const params = request.params as { | |
| 466 | cwd: string; | |
| 467 | editor: string; | |
| 468 | }; | |
| 469 | if (!params?.cwd) throw new Error("cwd is required"); | |
| 470 | const editorDef = EDITORS.find((e) => e.id === params.editor); | |
| 471 | if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`); | |
| 440 | const body = request.body; | |
| 441 | const editorDef = EDITORS.find((e) => e.id === body.editor); | |
| 442 | if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`); | |
| 472 | 443 | |
| 473 | 444 | let command: string; |
| 474 | 445 | let args: string[]; |
| 475 | 446 | |
| 476 | 447 | if (editorDef.command) { |
| 477 | 448 | command = editorDef.command; |
| 478 | args = [params.cwd]; | |
| 449 | args = [body.cwd]; | |
| 479 | 450 | } else if (editorDef.id === "file-manager") { |
| 480 | 451 | // Use platform-specific file manager command |
| 481 | 452 | switch (process.platform) { |
| ... | ... | @@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) { |
| 489 | 460 | command = "xdg-open"; |
| 490 | 461 | break; |
| 491 | 462 | } |
| 492 | args = [params.cwd]; | |
| 463 | args = [body.cwd]; | |
| 493 | 464 | } else { |
| 494 | 465 | return undefined; |
| 495 | 466 | } |
| ... | ... | @@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) { |
| 505 | 476 | return undefined; |
| 506 | 477 | } |
| 507 | 478 | |
| 508 | case WS_METHODS.gitStatus: { | |
| 509 | const params = request.params as { cwd: string }; | |
| 510 | const status = await gitManager.status(request.params as never); | |
| 511 | const projects = await effectRuntime.runPromise(projectRepository.list()); | |
| 512 | const project = projects.find((entry) => entry.cwd === params.cwd); | |
| 513 | if (project) { | |
| 514 | await effectRuntime.runPromise( | |
| 515 | orchestrationEngine.dispatch({ | |
| 516 | type: "git.readModel.upsert", | |
| 517 | commandId: crypto.randomUUID(), | |
| 518 | projectId: project.id, | |
| 519 | branch: status.branch, | |
| 520 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, | |
| 521 | aheadCount: status.aheadCount, | |
| 522 | behindCount: status.behindCount, | |
| 523 | createdAt: new Date().toISOString(), | |
| 524 | }), | |
| 525 | ); | |
| 526 | } | |
| 527 | return status; | |
| 528 | } | |
| 479 | // TODO: Bring back | |
| 480 | // case WS_METHODS.gitStatus: { | |
| 481 | // const params = request.params as { cwd: string }; | |
| 482 | // const status = await gitManager.status(request.params as never); | |
| 483 | // const projects = await effectRuntime.runPromise(projectRepository.list()); | |
| 484 | // const project = projects.find((entry) => entry.cwd === params.cwd); | |
| 485 | // if (project) { | |
| 486 | // await effectRuntime.runPromise( | |
| 487 | // orchestrationEngine.dispatch({ | |
| 488 | // type: "git.readModel.upsert", | |
| 489 | // commandId: crypto.randomUUID(), | |
| 490 | // projectId: project.id, | |
| 491 | // branch: status.branch, | |
| 492 | // hasWorkingTreeChanges: status.hasWorkingTreeChanges, | |
| 493 | // aheadCount: status.aheadCount, | |
| 494 | // behindCount: status.behindCount, | |
| 495 | // createdAt: new Date().toISOString(), | |
| 496 | // }), | |
| 497 | // ); | |
| 498 | // } | |
| 499 | // return status; | |
| 500 | // } | |
| 529 | 501 | |
| 530 | 502 | case WS_METHODS.gitPull: |
| 531 | return pullGitBranch(request.params as never); | |
| 503 | return pullGitBranch(request.body); | |
| 532 | 504 | |
| 533 | 505 | case WS_METHODS.gitRunStackedAction: |
| 534 | return gitManager.runStackedAction(request.params as never); | |
| 506 | return gitManager.runStackedAction(request.body); | |
| 535 | 507 | case WS_METHODS.gitListBranches: |
| 536 | return listGitBranches(request.params as never); | |
| 508 | return listGitBranches(request.body); | |
| 537 | 509 | |
| 538 | 510 | case WS_METHODS.gitCreateWorktree: |
| 539 | return createGitWorktree(request.params as never); | |
| 511 | return createGitWorktree(request.body); | |
| 540 | 512 | |
| 541 | 513 | case WS_METHODS.gitRemoveWorktree: |
| 542 | return removeGitWorktree(request.params as never); | |
| 514 | return removeGitWorktree(request.body); | |
| 543 | 515 | |
| 544 | 516 | case WS_METHODS.gitCreateBranch: |
| 545 | return createGitBranch(request.params as never); | |
| 517 | return createGitBranch(request.body); | |
| 546 | 518 | |
| 547 | 519 | case WS_METHODS.gitCheckout: |
| 548 | return checkoutGitBranch(request.params as never); | |
| 520 | return checkoutGitBranch(request.body); | |
| 549 | 521 | |
| 550 | 522 | case WS_METHODS.gitInit: |
| 551 | return initGitRepo(request.params as never); | |
| 523 | return initGitRepo(request.body); | |
| 552 | 524 | |
| 553 | 525 | case WS_METHODS.terminalOpen: |
| 554 | return terminalManager.open(request.params as never); | |
| 526 | return terminalManager.open(request.body); | |
| 555 | 527 | |
| 556 | 528 | case WS_METHODS.terminalWrite: |
| 557 | await terminalManager.write(request.params as never); | |
| 529 | await terminalManager.write(request.body); | |
| 558 | 530 | return undefined; |
| 559 | 531 | |
| 560 | 532 | case WS_METHODS.terminalResize: |
| 561 | await terminalManager.resize(request.params as never); | |
| 533 | await terminalManager.resize(request.body); | |
| 562 | 534 | return undefined; |
| 563 | 535 | |
| 564 | 536 | case WS_METHODS.terminalClear: |
| 565 | await terminalManager.clear(request.params as never); | |
| 537 | await terminalManager.clear(request.body); | |
| 566 | 538 | return undefined; |
| 567 | 539 | |
| 568 | 540 | case WS_METHODS.terminalRestart: |
| 569 | return terminalManager.restart(request.params as never); | |
| 541 | return terminalManager.restart(request.body); | |
| 570 | 542 | |
| 571 | 543 | case WS_METHODS.terminalClose: |
| 572 | await terminalManager.close(request.params as never); | |
| 544 | await terminalManager.close(request.body); | |
| 573 | 545 | return undefined; |
| 574 | 546 | |
| 575 | 547 | case WS_METHODS.serverGetConfig: |
| ... | ... | @@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) { |
| 579 | 551 | }; |
| 580 | 552 | |
| 581 | 553 | case WS_METHODS.serverUpsertKeybinding: |
| 582 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | |
| 554 | keybindingsConfig = upsertKeybindingRule(logger, request.body); | |
| 583 | 555 | return { |
| 584 | 556 | keybindings: keybindingsConfig, |
| 585 | 557 | }; |
| 586 | 558 | |
| 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 | 559 | default: |
| 616 | throw new Error(`Unknown method: ${request.method}`); | |
| 560 | throw new Error(`Unknown method: ${request.body._tag}`); | |
| 617 | 561 | } |
| 618 | 562 | } |
| 619 | 563 | |
| ... | ... | @@ -628,7 +572,12 @@ export function createServer(options: ServerOptions) { |
| 628 | 572 | path.join(orchestrationLogsDir, "orchestration-command.ndjson"), |
| 629 | 573 | ); |
| 630 | 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 | 581 | const providerLayer = |
| 633 | 582 | customProviderLayer ?? |
| 634 | 583 | (() => { |
| ... | ... | @@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) { |
| 639 | 588 | Layer.provide(codexAdapterLayer), |
| 640 | 589 | ); |
| 641 | 590 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 642 | Layer.provide(ProviderSessionRepositoryLive), | |
| 643 | ); | |
| 644 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | |
| 645 | const checkpointDependenciesLayer = Layer.mergeAll( | |
| 646 | checkpointStoreLayer, | |
| 647 | CheckpointRepositoryLive, | |
| 648 | adapterRegistryLayer, | |
| 649 | providerSessionDirectoryLayer, | |
| 650 | ); | |
| 651 | const checkpointServiceLayer = CheckpointServiceLive.pipe( | |
| 652 | Layer.provideMerge(checkpointDependenciesLayer), | |
| 591 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 653 | 592 | ); |
| 654 | 593 | return makeProviderServiceLive({ |
| 655 | 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 | })(); |
| 658 | 600 | |
| 659 | 601 | const runtimeServicesLayer = Layer.mergeAll( |
| 660 | 602 | orchestrationLayer, |
| 661 | ProjectRepositoryLive, | |
| 603 | OrchestrationProjectionSnapshotQueryLive, | |
| 604 | CheckpointDiffBlobRepositoryLive, | |
| 605 | checkpointStoreLayer, | |
| 662 | 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 | 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 | 623 | Layer.provide(persistenceLayer), |
| 667 | 624 | Layer.provideMerge(NodeServices.layer), |
| 668 | 625 | ); |
| 669 | 626 | const runtime = ManagedRuntime.make(layer); |
| 670 | 627 | |
| 671 | 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 | 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 | 638 | runtime.runPromise(Effect.service(ProviderService)), |
| 676 | 639 | ]); |
| 677 | 640 | orchestrationEngine = nextOrchestrationEngine; |
| 678 | projectRepository = repository; | |
| 641 | projectionSnapshotQuery = nextProjectionSnapshotQuery; | |
| 642 | checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository; | |
| 679 | 643 | providerService = nextProviderService; |
| 680 | await runtime.runPromise(repository.pruneMissing()); | |
| 681 | 644 | await runtime.runPromise(attachSubscriptionss); |
| 682 | 645 | } catch (error) { |
| 683 | 646 | await runtime.dispose().catch(() => undefined); |
| ... | ... | @@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) { |
| 701 | 664 | const liveProviderService = providerService; |
| 702 | 665 | effectRuntime = null; |
| 703 | 666 | orchestrationEngine = null; |
| 704 | projectRepository = null; | |
| 667 | projectionSnapshotQuery = null; | |
| 668 | checkpointDiffBlobRepository = null; | |
| 705 | 669 | providerService = null; |
| 706 | 670 | |
| 707 | 671 | if (!runtime) { |
apps/server/tsconfig.json+2-1| ... | ... | @@ -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 | 24 | "test": "vitest run" |
| 25 | 25 | }, |
| 26 | 26 | "dependencies": { |
| 27 | "effect": "4.0.0-beta.10", | |
| 28 | "zod": "^3.24.1" | |
| 27 | "effect": "4.0.0-beta.10" | |
| 29 | 28 | }, |
| 30 | 29 | "devDependencies": { |
| 31 | 30 | "@effect/language-service": "0.75.1", |
packages/contracts/src/agent.ts deleted-27| ... | ... | @@ -1,27 +0,0 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const agentSessionIdSchema = z.string().min(1); | |
| 4 | ||
| 5 | export 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 | ||
| 13 | export const outputChunkSchema = z.object({ | |
| 14 | sessionId: z.string(), | |
| 15 | stream: z.enum(["stdout", "stderr"]), | |
| 16 | data: z.string(), | |
| 17 | }); | |
| 18 | ||
| 19 | export const agentExitSchema = z.object({ | |
| 20 | sessionId: z.string(), | |
| 21 | code: z.number().nullable(), | |
| 22 | signal: z.string().nullable(), | |
| 23 | }); | |
| 24 | ||
| 25 | export type AgentConfig = z.infer<typeof agentConfigSchema>; | |
| 26 | export type OutputChunk = z.infer<typeof outputChunkSchema>; | |
| 27 | export type AgentExit = z.infer<typeof agentExitSchema>; |
packages/contracts/src/editor.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | export const EDITORS = [ | |
| 4 | { id: "cursor", label: "Cursor", command: "cursor" }, | |
| 5 | { id: "file-manager", label: "File Manager", command: null }, | |
| 6 | ] as const; | |
| 7 | ||
| 8 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); | |
| 9 | export type EditorId = typeof EditorId.Type; | |
| 10 | ||
| 11 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitPullInputSchema, | |
| 5 | gitPullResultSchema, | |
| 6 | gitRunStackedActionInputSchema, | |
| 7 | gitRunStackedActionResultSchema, | |
| 8 | gitStackedActionSchema, | |
| 9 | gitStatusInputSchema, | |
| 10 | gitStatusResultSchema, | |
| 11 | } from "./git"; | |
| 12 | ||
| 13 | describe("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 | import { z } from "zod"; | |
| 2 | ||
| 3 | // ── Existing branch/worktree Git API ───────────────────────────────── | |
| 4 | ||
| 5 | export const gitListBranchesInputSchema = z.object({ | |
| 6 | cwd: z.string().min(1), | |
| 7 | }); | |
| 8 | ||
| 9 | export const gitCreateWorktreeInputSchema = z.object({ | |
| 10 | cwd: z.string().min(1), | |
| 11 | branch: z.string().min(1), | |
| 12 | newBranch: z.string().min(1), | |
| 13 | path: z.string().min(1).optional(), | |
| 14 | }); | |
| 15 | ||
| 16 | export const gitRemoveWorktreeInputSchema = z.object({ | |
| 17 | cwd: z.string().min(1), | |
| 18 | path: z.string().min(1), | |
| 19 | force: z.boolean().optional(), | |
| 20 | }); | |
| 21 | ||
| 22 | export const gitCreateBranchInputSchema = z.object({ | |
| 23 | cwd: z.string().min(1), | |
| 24 | branch: z.string().min(1), | |
| 25 | }); | |
| 26 | ||
| 27 | export const gitCheckoutInputSchema = z.object({ | |
| 28 | cwd: z.string().min(1), | |
| 29 | branch: z.string().min(1), | |
| 30 | }); | |
| 31 | ||
| 32 | export const gitInitInputSchema = z.object({ | |
| 33 | cwd: z.string().min(1), | |
| 34 | }); | |
| 35 | ||
| 36 | export const gitBranchSchema = z.object({ | |
| 37 | name: z.string().min(1), | |
| 38 | current: z.boolean(), | |
| 39 | isDefault: z.boolean(), | |
| 40 | worktreePath: z.string().min(1).nullable(), | |
| 41 | }); | |
| 42 | ||
| 43 | export const gitWorktreeSchema = z.object({ | |
| 44 | path: z.string().min(1), | |
| 45 | branch: z.string().min(1), | |
| 46 | }); | |
| 47 | ||
| 48 | export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>; | |
| 49 | export type GitBranch = z.infer<typeof gitBranchSchema>; | |
| 50 | export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>; | |
| 51 | export type GitWorktree = z.infer<typeof gitWorktreeSchema>; | |
| 52 | export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>; | |
| 53 | export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>; | |
| 54 | export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>; | |
| 55 | export type GitInitInput = z.infer<typeof gitInitInputSchema>; | |
| 56 | ||
| 57 | export interface GitListBranchesResult { | |
| 58 | branches: GitBranch[]; | |
| 59 | isRepo: boolean; | |
| 60 | } | |
| 61 | ||
| 62 | export interface GitCreateWorktreeResult { | |
| 63 | worktree: GitWorktree; | |
| 64 | } | |
| 65 | ||
| 66 | // ── Stacked action Git API ─────────────────────────────────────────── | |
| 67 | ||
| 68 | export const gitStatusInputSchema = z.object({ | |
| 69 | cwd: z.string().trim().min(1), | |
| 70 | }); | |
| 71 | ||
| 72 | export const gitPullInputSchema = z.object({ | |
| 73 | cwd: z.string().trim().min(1), | |
| 74 | }); | |
| 75 | ||
| 76 | export const gitStatusResultSchema = z.object({ | |
| 77 | branch: z.string().min(1).nullable(), | |
| 78 | hasWorkingTreeChanges: z.boolean(), | |
| 79 | workingTree: z.object({ | |
| 80 | files: z.array( | |
| 81 | z.object({ | |
| 82 | path: z.string().min(1), | |
| 83 | insertions: z.number().int().nonnegative(), | |
| 84 | deletions: z.number().int().nonnegative(), | |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); | |
| 4 | ||
| 5 | // Domain Types | |
| 6 | ||
| 7 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); | |
| 8 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); | |
| 9 | export const GitPushStepStatus = Schema.Literals([ | |
| 10 | "pushed", | |
| 11 | "skipped_not_requested", | |
| 12 | "skipped_up_to_date", | |
| 13 | ]); | |
| 14 | export const GitPrStepStatus = Schema.Literals([ | |
| 15 | "created", | |
| 16 | "opened_existing", | |
| 17 | "skipped_not_requested", | |
| 18 | ]); | |
| 19 | ||
| 20 | export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({ | |
| 21 | name: TrimmedNonEmptyString, | |
| 22 | current: Schema.Boolean, | |
| 23 | isDefault: Schema.Boolean, | |
| 24 | worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), | |
| 25 | }) {} | |
| 26 | ||
| 27 | export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({ | |
| 28 | path: TrimmedNonEmptyString, | |
| 29 | branch: TrimmedNonEmptyString, | |
| 30 | }) {} | |
| 31 | ||
| 32 | // RPC Inputs | |
| 33 | ||
| 34 | export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({ | |
| 35 | cwd: TrimmedNonEmptyString, | |
| 36 | }) {} | |
| 37 | ||
| 38 | export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({ | |
| 39 | cwd: TrimmedNonEmptyString, | |
| 40 | }) {} | |
| 41 | ||
| 42 | export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>( | |
| 43 | "GitRunStackedActionInput", | |
| 44 | )({ | |
| 45 | cwd: TrimmedNonEmptyString, | |
| 46 | action: GitStackedAction, | |
| 47 | commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), | |
| 48 | }) {} | |
| 49 | ||
| 50 | export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>( | |
| 51 | "GitListBranchesInput", | |
| 52 | )({ | |
| 53 | cwd: TrimmedNonEmptyString, | |
| 54 | }) {} | |
| 55 | ||
| 56 | export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>( | |
| 57 | "GitCreateWorktreeInput", | |
| 58 | )({ | |
| 59 | cwd: TrimmedNonEmptyString, | |
| 60 | branch: TrimmedNonEmptyString, | |
| 61 | newBranch: TrimmedNonEmptyString, | |
| 62 | path: TrimmedNonEmptyString.pipe(Schema.NullOr), | |
| 63 | }) {} | |
| 64 | ||
| 65 | export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>( | |
| 66 | "GitRemoveWorktreeInput", | |
| 67 | )({ | |
| 68 | cwd: TrimmedNonEmptyString, | |
| 69 | path: TrimmedNonEmptyString, | |
| 70 | force: Schema.Boolean.pipe(Schema.NullOr), | |
| 71 | }) {} | |
| 72 | ||
| 73 | export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>( | |
| 74 | "GitCreateBranchInput", | |
| 75 | )({ | |
| 76 | cwd: TrimmedNonEmptyString, | |
| 77 | branch: TrimmedNonEmptyString, | |
| 78 | }) {} | |
| 79 | ||
| 80 | export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({ | |
| 81 | cwd: TrimmedNonEmptyString, | |
| 82 | branch: TrimmedNonEmptyString, | |
| 83 | }) {} | |
| 84 | ||
| 85 | export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({ | |
| 86 | cwd: TrimmedNonEmptyString, | |
| 87 | }) {} | |
| 88 | ||
| 89 | // RPC Results | |
| 90 | ||
| 91 | export 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(), | |
| 88 | deletions: z.number().int().nonnegative(), | |
| 102 | insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 103 | deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 89 | 104 | }), |
| 90 | hasUpstream: z.boolean(), | |
| 91 | aheadCount: z.number().int().nonnegative(), | |
| 92 | behindCount: z.number().int().nonnegative(), | |
| 93 | openPr: z | |
| 94 | .object({ | |
| 95 | number: z.number().int().positive(), | |
| 96 | title: z.string().min(1), | |
| 97 | url: z.string().url(), | |
| 98 | baseBranch: z.string().min(1), | |
| 99 | headBranch: z.string().min(1), | |
| 100 | }) | |
| 101 | .nullable(), | |
| 102 | }); | |
| 103 | ||
| 104 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); | |
| 105 | ||
| 106 | const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); | |
| 107 | const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]); | |
| 108 | const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]); | |
| 109 | ||
| 110 | export const gitRunStackedActionInputSchema = z.object({ | |
| 111 | cwd: z.string().trim().min(1), | |
| 112 | action: gitStackedActionSchema, | |
| 113 | commitMessage: z.string().trim().min(1).max(10_000).optional(), | |
| 114 | }); | |
| 115 | ||
| 116 | export const gitRunStackedActionResultSchema = z.object({ | |
| 117 | action: gitStackedActionSchema, | |
| 118 | commit: z.object({ | |
| 119 | status: gitCommitStepStatusSchema, | |
| 120 | commitSha: z.string().min(1).optional(), | |
| 121 | subject: z.string().min(1).optional(), | |
| 105 | hasUpstream: Schema.Boolean, | |
| 106 | aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 107 | behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 108 | openPr: Schema.Struct({ | |
| 109 | number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), | |
| 110 | title: TrimmedNonEmptyString, | |
| 111 | url: Schema.String, | |
| 112 | baseBranch: TrimmedNonEmptyString, | |
| 113 | headBranch: TrimmedNonEmptyString, | |
| 114 | }).pipe(Schema.NullOr), | |
| 115 | }) {} | |
| 116 | ||
| 117 | export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>( | |
| 118 | "GitListBranchesResult", | |
| 119 | )({ | |
| 120 | branches: Schema.Array(GitBranch), | |
| 121 | isRepo: Schema.Boolean, | |
| 122 | }) {} | |
| 123 | ||
| 124 | export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>( | |
| 125 | "GitCreateWorktreeResult", | |
| 126 | )({ | |
| 127 | worktree: GitWorktree, | |
| 128 | }) {} | |
| 129 | ||
| 130 | export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>( | |
| 131 | "GitRunStackedActionResult", | |
| 132 | )({ | |
| 133 | action: GitStackedAction, | |
| 134 | commit: Schema.Struct({ | |
| 135 | status: GitCommitStepStatus, | |
| 136 | commitSha: Schema.optional(TrimmedNonEmptyString), | |
| 137 | subject: Schema.optional(TrimmedNonEmptyString), | |
| 122 | 138 | }), |
| 123 | push: z.object({ | |
| 124 | status: gitPushStepStatusSchema, | |
| 125 | branch: z.string().min(1).optional(), | |
| 126 | upstreamBranch: z.string().min(1).optional(), | |
| 127 | setUpstream: z.boolean().optional(), | |
| 139 | push: Schema.Struct({ | |
| 140 | status: GitPushStepStatus, | |
| 141 | branch: Schema.optional(TrimmedNonEmptyString), | |
| 142 | upstreamBranch: Schema.optional(TrimmedNonEmptyString), | |
| 143 | setUpstream: Schema.optional(Schema.Boolean), | |
| 128 | 144 | }), |
| 129 | pr: z.object({ | |
| 130 | status: gitPrStepStatusSchema, | |
| 131 | url: z.string().url().optional(), | |
| 132 | number: z.number().int().positive().optional(), | |
| 133 | baseBranch: z.string().min(1).optional(), | |
| 134 | headBranch: z.string().min(1).optional(), | |
| 135 | title: z.string().min(1).optional(), | |
| 145 | pr: Schema.Struct({ | |
| 146 | status: GitPrStepStatus, | |
| 147 | url: Schema.optional(Schema.String), | |
| 148 | number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), | |
| 149 | baseBranch: Schema.optional(TrimmedNonEmptyString), | |
| 150 | headBranch: Schema.optional(TrimmedNonEmptyString), | |
| 151 | title: Schema.optional(TrimmedNonEmptyString), | |
| 136 | 152 | }), |
| 137 | }); | |
| 138 | ||
| 139 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | |
| 140 | export type GitPullInput = z.input<typeof gitPullInputSchema>; | |
| 141 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | |
| 142 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | |
| 143 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | |
| 144 | export type GitRunStackedActionResult = z.infer<typeof gitRunStackedActionResultSchema>; | |
| 145 | ||
| 146 | export 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 | ||
| 152 | export type GitPullResult = z.infer<typeof gitPullResultSchema>; | |
| 153 | }) {} | |
| 154 | ||
| 155 | export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({ | |
| 156 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | |
| 157 | branch: TrimmedNonEmptyString, | |
| 158 | upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), | |
| 159 | }) {} |
packages/contracts/src/index.ts+2-3| ... | ... | @@ -1,6 +1,4 @@ |
| 1 | export * from "./todo"; | |
| 2 | 1 | export * from "./ipc"; |
| 3 | export * from "./agent"; | |
| 4 | 2 | export * from "./terminal"; |
| 5 | 3 | export * from "./provider"; |
| 6 | 4 | export * from "./providerRuntime"; |
| ... | ... | @@ -8,6 +6,7 @@ export * from "./model"; |
| 8 | 6 | export * from "./ws"; |
| 9 | 7 | export * from "./keybindings"; |
| 10 | 8 | export * from "./server"; |
| 11 | export * from "./project"; | |
| 12 | 9 | export * from "./git"; |
| 13 | 10 | export * from "./orchestration"; |
| 11 | export * from "./editor"; | |
| 12 | export * from "./project"; |
packages/contracts/src/ipc.ts+7-69| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | |
| 2 | 1 | import type { |
| 3 | 2 | GitCheckoutInput, |
| 4 | 3 | GitCreateBranchInput, |
| ... | ... | @@ -15,31 +14,7 @@ import type { |
| 15 | 14 | GitStatusInput, |
| 16 | 15 | GitStatusResult, |
| 17 | 16 | } from "./git"; |
| 18 | import type { | |
| 19 | ProviderGetCheckpointDiffInput, | |
| 20 | ProviderGetCheckpointDiffResult, | |
| 21 | ProviderInterruptTurnInput, | |
| 22 | ProviderListCheckpointsInput, | |
| 23 | ProviderListCheckpointsResult, | |
| 24 | ProviderRevertToCheckpointInput, | |
| 25 | ProviderRevertToCheckpointResult, | |
| 26 | ProviderRespondToRequestInput, | |
| 27 | ProviderSendTurnInput, | |
| 28 | ProviderSession, | |
| 29 | ProviderSessionStartInput, | |
| 30 | ProviderStopSessionInput, | |
| 31 | ProviderTurnStartResult, | |
| 32 | } from "./provider"; | |
| 33 | import type { | |
| 34 | ProjectAddInput, | |
| 35 | ProjectAddResult, | |
| 36 | ProjectListResult, | |
| 37 | ProjectSearchEntriesInput, | |
| 38 | ProjectSearchEntriesResult, | |
| 39 | ProjectRemoveInput, | |
| 40 | ProjectUpdateScriptsInput, | |
| 41 | ProjectUpdateScriptsResult, | |
| 42 | } from "./project"; | |
| 17 | import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; | |
| 43 | 18 | import type { ServerConfig } from "./server"; |
| 44 | 19 | import type { |
| 45 | 20 | TerminalClearInput, |
| ... | ... | @@ -50,28 +25,17 @@ import type { |
| 50 | 25 | TerminalSessionSnapshot, |
| 51 | 26 | TerminalWriteInput, |
| 52 | 27 | } from "./terminal"; |
| 53 | import type { NewTodoInput, Todo } from "./todo"; | |
| 54 | 28 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; |
| 55 | 29 | import type { |
| 56 | OrchestrationCommand, | |
| 30 | ClientOrchestrationCommand, | |
| 31 | OrchestrationGetTurnDiffInput, | |
| 32 | OrchestrationGetTurnDiffResult, | |
| 57 | 33 | OrchestrationEvent, |
| 58 | 34 | OrchestrationReadModel, |
| 59 | 35 | } from "./orchestration"; |
| 60 | ||
| 61 | export const EDITORS = [ | |
| 62 | { id: "cursor", label: "Cursor", command: "cursor" }, | |
| 63 | { id: "file-manager", label: "File Manager", command: null }, | |
| 64 | ] as const; | |
| 65 | ||
| 66 | export type EditorId = (typeof EDITORS)[number]["id"]; | |
| 36 | import { EditorId } from "./editor"; | |
| 67 | 37 | |
| 68 | 38 | export 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 | 39 | dialogs: { |
| 76 | 40 | pickFolder: () => Promise<string | null>; |
| 77 | 41 | confirm: (message: string) => Promise<boolean>; |
| ... | ... | @@ -85,35 +49,8 @@ export interface NativeApi { |
| 85 | 49 | close: (input: TerminalCloseInput) => Promise<void>; |
| 86 | 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 | 52 | projects: { |
| 112 | list: () => Promise<ProjectListResult>; | |
| 113 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | |
| 114 | remove: (input: ProjectRemoveInput) => Promise<void>; | |
| 115 | 53 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 116 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | |
| 117 | 54 | }; |
| 118 | 55 | shell: { |
| 119 | 56 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| ... | ... | @@ -144,7 +81,8 @@ export interface NativeApi { |
| 144 | 81 | }; |
| 145 | 82 | orchestration: { |
| 146 | 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 | 86 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; |
| 149 | 87 | onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; |
| 150 | 88 | }; |
packages/contracts/src/keybindings.test.ts+22-13| ... | ... | @@ -1,27 +1,36 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | 2 | import { assert, describe, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | keybindingRuleSchema, | |
| 5 | keybindingsConfigSchema, | |
| 6 | resolvedKeybindingRuleSchema, | |
| 7 | resolvedKeybindingsConfigSchema, | |
| 5 | KeybindingsConfig, | |
| 6 | KeybindingRule, | |
| 7 | ResolvedKeybindingRule, | |
| 8 | ResolvedKeybindingsConfig, | |
| 8 | 9 | } from "./keybindings"; |
| 9 | 10 | |
| 11 | const 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 | ||
| 14 | const decodeResolvedRuleStrict = (input: unknown) => | |
| 15 | Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, { | |
| 16 | onExcessProperty: "error", | |
| 17 | }); | |
| 18 | ||
| 10 | 19 | describe("keybindings contracts", () => { |
| 11 | 20 | it("parses keybinding rules", () => { |
| 12 | const parsed = keybindingRuleSchema.parse({ | |
| 21 | const parsed = decode(KeybindingRule, { | |
| 13 | 22 | key: "mod+j", |
| 14 | 23 | command: "terminal.toggle", |
| 15 | 24 | }); |
| 16 | 25 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 17 | 26 | |
| 18 | const parsedClose = keybindingRuleSchema.parse({ | |
| 27 | const parsedClose = decode(KeybindingRule, { | |
| 19 | 28 | key: "mod+w", |
| 20 | 29 | command: "terminal.close", |
| 21 | 30 | }); |
| 22 | 31 | assert.strictEqual(parsedClose.command, "terminal.close"); |
| 23 | 32 | |
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | |
| 33 | const parsedLocal = decode(KeybindingRule, { | |
| 25 | 34 | key: "mod+shift+n", |
| 26 | 35 | command: "chat.newLocal", |
| 27 | 36 | }); |
| ... | ... | @@ -30,7 +39,7 @@ describe("keybindings contracts", () => { |
| 30 | 39 | |
| 31 | 40 | it("rejects invalid command values", () => { |
| 32 | 41 | assert.throws(() => |
| 33 | keybindingRuleSchema.parse({ | |
| 42 | decode(KeybindingRule, { | |
| 34 | 43 | key: "mod+j", |
| 35 | 44 | command: "script.Test.run", |
| 36 | 45 | }), |
| ... | ... | @@ -38,7 +47,7 @@ describe("keybindings contracts", () => { |
| 38 | 47 | }); |
| 39 | 48 | |
| 40 | 49 | it("accepts dynamic script run commands", () => { |
| 41 | const parsed = keybindingRuleSchema.parse({ | |
| 50 | const parsed = decode(KeybindingRule, { | |
| 42 | 51 | key: "mod+r", |
| 43 | 52 | command: "script.setup.run", |
| 44 | 53 | }); |
| ... | ... | @@ -46,7 +55,7 @@ describe("keybindings contracts", () => { |
| 46 | 55 | }); |
| 47 | 56 | |
| 48 | 57 | it("parses keybindings array payload", () => { |
| 49 | const parsed = keybindingsConfigSchema.parse([ | |
| 58 | const parsed = decode(KeybindingsConfig, [ | |
| 50 | 59 | { key: "mod+j", command: "terminal.toggle" }, |
| 51 | 60 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 52 | 61 | ]); |
| ... | ... | @@ -54,7 +63,7 @@ describe("keybindings contracts", () => { |
| 54 | 63 | }); |
| 55 | 64 | |
| 56 | 65 | it("parses resolved keybinding rules", () => { |
| 57 | const parsed = resolvedKeybindingRuleSchema.parse({ | |
| 66 | const parsed = decode(ResolvedKeybindingRule, { | |
| 58 | 67 | command: "terminal.split", |
| 59 | 68 | shortcut: { |
| 60 | 69 | key: "d", |
| ... | ... | @@ -77,7 +86,7 @@ describe("keybindings contracts", () => { |
| 77 | 86 | }); |
| 78 | 87 | |
| 79 | 88 | it("parses resolved keybindings arrays", () => { |
| 80 | const parsed = resolvedKeybindingsConfigSchema.parse([ | |
| 89 | const parsed = decode(ResolvedKeybindingsConfig, [ | |
| 81 | 90 | { |
| 82 | 91 | command: "terminal.toggle", |
| 83 | 92 | shortcut: { |
| ... | ... | @@ -95,7 +104,7 @@ describe("keybindings contracts", () => { |
| 95 | 104 | |
| 96 | 105 | it("rejects unknown fields in resolved keybinding rules", () => { |
| 97 | 106 | assert.throws(() => |
| 98 | resolvedKeybindingRuleSchema.parse({ | |
| 107 | decodeResolvedRuleStrict({ | |
| 99 | 108 | command: "terminal.toggle", |
| 100 | 109 | shortcut: { |
| 101 | 110 | key: "j", |
packages/contracts/src/keybindings.ts+58-65| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | export const STATIC_KEYBINDING_COMMANDS = [ |
| 4 | 4 | "terminal.toggle", |
| ... | ... | @@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 10 | 10 | "editor.openFavorite", |
| 11 | 11 | ] as const; |
| 12 | 12 | |
| 13 | export const keybindingCommandSchema = z.union([ | |
| 14 | z.enum(STATIC_KEYBINDING_COMMANDS), | |
| 15 | z | |
| 16 | .string() | |
| 17 | .trim() | |
| 18 | .min(1) | |
| 19 | .max(96) | |
| 20 | .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | |
| 13 | export const KeybindingCommand = Schema.Union([ | |
| 14 | Schema.Literals(STATIC_KEYBINDING_COMMANDS), | |
| 15 | Schema.NonEmptyString.check( | |
| 16 | Schema.isMaxLength(96), | |
| 17 | Schema.isPattern(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | |
| 18 | ), | |
| 21 | 19 | ]); |
| 20 | export type KeybindingCommand = typeof KeybindingCommand.Type; | |
| 22 | 21 | |
| 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); | |
| 24 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | |
| 22 | export const KeybindingValue = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(64)); | |
| 23 | export type KeybindingValue = typeof KeybindingValue.Type; | |
| 25 | 24 | |
| 26 | export const keybindingRuleSchema = z.object({ | |
| 27 | key: keybindingValueSchema, | |
| 28 | command: keybindingCommandSchema, | |
| 29 | when: keybindingWhenSchema.optional(), | |
| 30 | }); | |
| 25 | export const KeybindingWhen = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(256)); | |
| 26 | export type KeybindingWhen = typeof KeybindingWhen.Type; | |
| 27 | export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({ | |
| 28 | key: KeybindingValue, | |
| 29 | command: KeybindingCommand, | |
| 30 | when: Schema.optional(KeybindingWhen), | |
| 31 | }) {} | |
| 31 | 32 | |
| 32 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); | |
| 33 | export const KeybindingsConfig = Schema.Array(KeybindingRule).check(Schema.isMaxLength(256)); | |
| 34 | export type KeybindingsConfig = typeof KeybindingsConfig.Type; | |
| 33 | 35 | |
| 34 | export const keybindingShortcutSchema = z | |
| 35 | .object({ | |
| 36 | key: z.string().trim().min(1).max(32), | |
| 37 | metaKey: z.boolean(), | |
| 38 | ctrlKey: z.boolean(), | |
| 39 | shiftKey: z.boolean(), | |
| 40 | altKey: z.boolean(), | |
| 41 | modKey: z.boolean(), | |
| 42 | }) | |
| 43 | .strict(); | |
| 36 | export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({ | |
| 37 | key: KeybindingValue, | |
| 38 | metaKey: Schema.Boolean, | |
| 39 | ctrlKey: Schema.Boolean, | |
| 40 | shiftKey: Schema.Boolean, | |
| 41 | altKey: Schema.Boolean, | |
| 42 | modKey: Schema.Boolean, | |
| 43 | }) {} | |
| 44 | 44 | |
| 45 | export 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 | ]); | |
| 45 | 65 | export type KeybindingWhenNode = |
| 46 | 66 | | { type: "identifier"; name: string } |
| 47 | 67 | | { type: "not"; node: KeybindingWhenNode } |
| 48 | 68 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } |
| 49 | 69 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; |
| 50 | 70 | |
| 51 | export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() => | |
| 52 | z.union([ | |
| 53 | z.object({ | |
| 54 | type: z.literal("identifier"), | |
| 55 | name: z.string().min(1), | |
| 56 | }), | |
| 57 | z.object({ | |
| 58 | type: z.literal("not"), | |
| 59 | node: keybindingWhenNodeSchema, | |
| 60 | }), | |
| 61 | z.object({ | |
| 62 | type: z.literal("and"), | |
| 63 | left: keybindingWhenNodeSchema, | |
| 64 | right: keybindingWhenNodeSchema, | |
| 65 | }), | |
| 66 | z.object({ | |
| 67 | type: z.literal("or"), | |
| 68 | left: keybindingWhenNodeSchema, | |
| 69 | right: keybindingWhenNodeSchema, | |
| 70 | }), | |
| 71 | ]), | |
| 72 | ); | |
| 73 | ||
| 74 | export const resolvedKeybindingRuleSchema = z | |
| 75 | .object({ | |
| 76 | command: keybindingCommandSchema, | |
| 77 | shortcut: keybindingShortcutSchema, | |
| 78 | whenAst: keybindingWhenNodeSchema.optional(), | |
| 79 | }) | |
| 80 | .strict(); | |
| 71 | export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>( | |
| 72 | "ResolvedKeybindingRule", | |
| 73 | )({ | |
| 74 | command: KeybindingCommand, | |
| 75 | shortcut: KeybindingShortcut, | |
| 76 | whenAst: Schema.optional(KeybindingWhenNode), | |
| 77 | }) {} | |
| 81 | 78 | |
| 82 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); | |
| 83 | ||
| 84 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | |
| 85 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | |
| 86 | export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>; | |
| 87 | export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>; | |
| 88 | export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>; | |
| 89 | export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>; | |
| 79 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( | |
| 80 | Schema.isMaxLength(256), | |
| 81 | ); | |
| 82 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
packages/contracts/src/orchestration.ts+798-177| ... | ... | @@ -3,6 +3,7 @@ import { Schema } from "effect"; |
| 3 | 3 | export const ORCHESTRATION_WS_METHODS = { |
| 4 | 4 | getSnapshot: "orchestration.getSnapshot", |
| 5 | 5 | dispatchCommand: "orchestration.dispatchCommand", |
| 6 | getTurnDiff: "orchestration.getTurnDiff", | |
| 6 | 7 | replayEvents: "orchestration.replayEvents", |
| 7 | 8 | } as const; |
| 8 | 9 | |
| ... | ... | @@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = { |
| 10 | 11 | domainEvent: "orchestration.domainEvent", |
| 11 | 12 | } as const; |
| 12 | 13 | |
| 13 | const IsoDateTimeSchema = Schema.String; | |
| 14 | const IdSchema = Schema.String; | |
| 14 | export const IsoDateTime = Schema.String; | |
| 15 | export type IsoDateTime = typeof IsoDateTime.Type; | |
| 16 | ||
| 17 | export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); | |
| 18 | export type ThreadId = typeof ThreadId.Type; | |
| 19 | export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); | |
| 20 | export type ProjectId = typeof ProjectId.Type; | |
| 21 | export const CommandId = Schema.String.pipe(Schema.brand("CommandId")); | |
| 22 | export type CommandId = typeof CommandId.Type; | |
| 23 | export const EventId = Schema.String.pipe(Schema.brand("EventId")); | |
| 24 | export type EventId = typeof EventId.Type; | |
| 25 | export const MessageId = Schema.String.pipe(Schema.brand("MessageId")); | |
| 26 | export type MessageId = typeof MessageId.Type; | |
| 27 | export const TurnId = Schema.String.pipe(Schema.brand("TurnId")); | |
| 28 | export type TurnId = typeof TurnId.Type; | |
| 29 | ||
| 30 | export const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); | |
| 31 | export type ProviderSessionId = typeof ProviderSessionId.Type; | |
| 32 | export const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); | |
| 33 | export type ProviderThreadId = typeof ProviderThreadId.Type; | |
| 34 | export const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); | |
| 35 | export type ProviderTurnId = typeof ProviderTurnId.Type; | |
| 36 | export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); | |
| 37 | export type ProviderItemId = typeof ProviderItemId.Type; | |
| 38 | export const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); | |
| 39 | export type ApprovalRequestId = typeof ApprovalRequestId.Type; | |
| 40 | export const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); | |
| 41 | export type CheckpointRef = typeof CheckpointRef.Type; | |
| 42 | ||
| 43 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | |
| 44 | export type ProviderKind = typeof ProviderKind.Type; | |
| 45 | export const ProviderApprovalPolicy = Schema.Literals([ | |
| 46 | "untrusted", | |
| 47 | "on-failure", | |
| 48 | "on-request", | |
| 49 | "never", | |
| 50 | ]); | |
| 51 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; | |
| 52 | export const ProviderSandboxMode = Schema.Literals([ | |
| 53 | "read-only", | |
| 54 | "workspace-write", | |
| 55 | "danger-full-access", | |
| 56 | ]); | |
| 57 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | |
| 58 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); | |
| 59 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; | |
| 60 | export const ProviderApprovalDecision = Schema.Literals([ | |
| 61 | "accept", | |
| 62 | "acceptForSession", | |
| 63 | "decline", | |
| 64 | "cancel", | |
| 65 | ]); | |
| 66 | export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; | |
| 15 | 67 | |
| 16 | export const OrchestrationMessageSchema = Schema.Struct({ | |
| 17 | id: IdSchema, | |
| 18 | role: Schema.Literals(["user", "assistant"]), | |
| 19 | text: Schema.String, | |
| 20 | createdAt: IsoDateTimeSchema, | |
| 21 | completedAt: Schema.optional(IsoDateTimeSchema), | |
| 22 | streaming: Schema.Boolean, | |
| 23 | }); | |
| 68 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | |
| 69 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 70 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | |
| 71 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | |
| 24 | 72 | |
| 25 | export type OrchestrationMessage = Schema.Schema.Type<typeof OrchestrationMessageSchema>; | |
| 73 | // Correlation id is command id by design in this model. | |
| 74 | export const CorrelationId = CommandId; | |
| 75 | export type CorrelationId = typeof CorrelationId.Type; | |
| 26 | 76 | |
| 27 | export const OrchestrationSessionSchema = Schema.Struct({ | |
| 28 | sessionId: IdSchema, | |
| 29 | status: Schema.Literals(["connecting", "ready", "running", "error", "closed"]), | |
| 30 | provider: Schema.Literals(["codex", "claudeCode"]), | |
| 31 | threadId: IdSchema, | |
| 32 | activeTurnId: Schema.NullOr(Schema.String), | |
| 33 | createdAt: IsoDateTimeSchema, | |
| 34 | updatedAt: IsoDateTimeSchema, | |
| 35 | lastError: Schema.NullOr(Schema.String), | |
| 77 | export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check( | |
| 78 | Schema.isGreaterThanOrEqualTo(0), | |
| 79 | ); | |
| 80 | ||
| 81 | const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); | |
| 82 | ||
| 83 | export const ChatImageAttachment = Schema.Struct({ | |
| 84 | type: Schema.Literal("image"), | |
| 85 | name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)), | |
| 86 | mimeType: TrimmedNonEmptyStringSchema.check( | |
| 87 | Schema.isMaxLength(100), | |
| 88 | Schema.isPattern(/^image\//i), | |
| 89 | ), | |
| 90 | sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), | |
| 91 | dataUrl: TrimmedNonEmptyStringSchema.check( | |
| 92 | Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | |
| 93 | ), | |
| 36 | 94 | }); |
| 95 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | |
| 96 | ||
| 97 | export const ChatAttachment = Schema.Union([ChatImageAttachment]); | |
| 98 | export type ChatAttachment = typeof ChatAttachment.Type; | |
| 99 | ||
| 100 | export const ProjectScriptIcon = Schema.Literals([ | |
| 101 | "play", | |
| 102 | "test", | |
| 103 | "lint", | |
| 104 | "configure", | |
| 105 | "build", | |
| 106 | "debug", | |
| 107 | ]); | |
| 37 | 108 | |
| 38 | export type OrchestrationSession = Schema.Schema.Type<typeof OrchestrationSessionSchema>; | |
| 109 | export const ProjectScript = Schema.Struct({ | |
| 110 | id: Schema.String, | |
| 111 | name: Schema.String, | |
| 112 | command: Schema.String, | |
| 113 | icon: ProjectScriptIcon, | |
| 114 | runOnWorktreeCreate: Schema.Boolean, | |
| 115 | }); | |
| 116 | export type ProjectScript = typeof ProjectScript.Type; | |
| 39 | 117 | |
| 40 | export const OrchestrationTurnDiffFileSchema = Schema.Struct({ | |
| 41 | path: Schema.String, | |
| 42 | kind: Schema.optional(Schema.String), | |
| 43 | additions: Schema.optional(Schema.Number), | |
| 44 | deletions: Schema.optional(Schema.Number), | |
| 118 | export const OrchestrationProject = Schema.Struct({ | |
| 119 | id: ProjectId, | |
| 120 | title: Schema.String, | |
| 121 | workspaceRoot: Schema.String, | |
| 122 | defaultModel: Schema.NullOr(Schema.String), | |
| 123 | scripts: Schema.Array(ProjectScript), | |
| 124 | createdAt: IsoDateTime, | |
| 125 | updatedAt: IsoDateTime, | |
| 126 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 45 | 127 | }); |
| 128 | export type OrchestrationProject = typeof OrchestrationProject.Type; | |
| 46 | 129 | |
| 47 | export type OrchestrationTurnDiffFile = Schema.Schema.Type<typeof OrchestrationTurnDiffFileSchema>; | |
| 130 | export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]); | |
| 131 | export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type; | |
| 48 | 132 | |
| 49 | export const OrchestrationTurnDiffSummarySchema = Schema.Struct({ | |
| 50 | turnId: IdSchema, | |
| 51 | completedAt: IsoDateTimeSchema, | |
| 52 | status: Schema.optional(Schema.String), | |
| 53 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | |
| 54 | assistantMessageId: Schema.optional(Schema.String), | |
| 55 | checkpointTurnCount: Schema.optional(Schema.Number), | |
| 133 | export const OrchestrationMessage = Schema.Struct({ | |
| 134 | id: MessageId, | |
| 135 | role: OrchestrationMessageRole, | |
| 136 | text: Schema.String, | |
| 137 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | |
| 138 | turnId: Schema.NullOr(TurnId), | |
| 139 | streaming: Schema.Boolean, | |
| 140 | createdAt: IsoDateTime, | |
| 141 | updatedAt: IsoDateTime, | |
| 142 | }); | |
| 143 | export type OrchestrationMessage = typeof OrchestrationMessage.Type; | |
| 144 | ||
| 145 | export const OrchestrationSessionStatus = Schema.Literals([ | |
| 146 | "idle", | |
| 147 | "starting", | |
| 148 | "running", | |
| 149 | "ready", | |
| 150 | "interrupted", | |
| 151 | "stopped", | |
| 152 | "error", | |
| 153 | ]); | |
| 154 | export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; | |
| 155 | ||
| 156 | export 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 | }); |
| 166 | export type OrchestrationSession = typeof OrchestrationSession.Type; | |
| 57 | 167 | |
| 58 | export type OrchestrationTurnDiffSummary = Schema.Schema.Type< | |
| 59 | typeof OrchestrationTurnDiffSummarySchema | |
| 60 | >; | |
| 168 | export const OrchestrationCheckpointFile = Schema.Struct({ | |
| 169 | path: Schema.String, | |
| 170 | kind: Schema.String, | |
| 171 | additions: NonNegativeInt, | |
| 172 | deletions: NonNegativeInt, | |
| 173 | }); | |
| 174 | export type OrchestrationCheckpointFile = typeof OrchestrationCheckpointFile.Type; | |
| 175 | ||
| 176 | export const OrchestrationCheckpointStatus = Schema.Literals(["ready", "missing", "error"]); | |
| 177 | export type OrchestrationCheckpointStatus = typeof OrchestrationCheckpointStatus.Type; | |
| 178 | ||
| 179 | export 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 | }); | |
| 188 | export type OrchestrationCheckpointSummary = typeof OrchestrationCheckpointSummary.Type; | |
| 61 | 189 | |
| 62 | export const OrchestrationThreadActivityToneSchema = Schema.Literals([ | |
| 63 | "thinking", | |
| 64 | "tool", | |
| 190 | export const OrchestrationThreadActivityTone = Schema.Literals([ | |
| 65 | 191 | "info", |
| 192 | "tool", | |
| 193 | "approval", | |
| 66 | 194 | "error", |
| 67 | 195 | ]); |
| 196 | export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; | |
| 68 | 197 | |
| 69 | export type OrchestrationThreadActivityTone = Schema.Schema.Type< | |
| 70 | typeof OrchestrationThreadActivityToneSchema | |
| 71 | >; | |
| 72 | ||
| 73 | export const OrchestrationThreadActivitySchema = Schema.Struct({ | |
| 74 | id: IdSchema, | |
| 75 | createdAt: IsoDateTimeSchema, | |
| 76 | label: Schema.String, | |
| 77 | detail: Schema.optional(Schema.String), | |
| 78 | tone: OrchestrationThreadActivityToneSchema, | |
| 79 | turnId: Schema.optional(Schema.String), | |
| 80 | requestId: Schema.optional(Schema.String), | |
| 81 | requestKind: Schema.optional(Schema.Literals(["command", "file-change"])), | |
| 198 | export const OrchestrationThreadActivity = Schema.Struct({ | |
| 199 | id: EventId, | |
| 200 | tone: OrchestrationThreadActivityTone, | |
| 201 | kind: Schema.String, | |
| 202 | summary: Schema.String, | |
| 203 | payload: Schema.Unknown, | |
| 204 | turnId: Schema.NullOr(TurnId), | |
| 205 | createdAt: IsoDateTime, | |
| 82 | 206 | }); |
| 207 | export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; | |
| 83 | 208 | |
| 84 | export type OrchestrationThreadActivity = Schema.Schema.Type<typeof OrchestrationThreadActivitySchema>; | |
| 85 | ||
| 86 | export const OrchestrationThreadSchema = Schema.Struct({ | |
| 87 | id: IdSchema, | |
| 88 | projectId: IdSchema, | |
| 209 | export const OrchestrationThread = Schema.Struct({ | |
| 210 | id: ThreadId, | |
| 211 | projectId: ProjectId, | |
| 89 | 212 | title: Schema.String, |
| 90 | 213 | model: Schema.String, |
| 91 | 214 | branch: Schema.NullOr(Schema.String), |
| 92 | 215 | worktreePath: Schema.NullOr(Schema.String), |
| 93 | createdAt: IsoDateTimeSchema, | |
| 94 | updatedAt: IsoDateTimeSchema, | |
| 95 | latestTurnId: Schema.NullOr(Schema.String), | |
| 96 | latestTurnStartedAt: Schema.NullOr(Schema.String), | |
| 97 | latestTurnCompletedAt: Schema.NullOr(Schema.String), | |
| 98 | latestTurnDurationMs: Schema.NullOr(Schema.Number), | |
| 99 | messages: Schema.Array(OrchestrationMessageSchema), | |
| 100 | session: Schema.NullOr(OrchestrationSessionSchema), | |
| 101 | turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), | |
| 102 | activities: Schema.Array(OrchestrationThreadActivitySchema), | |
| 103 | error: Schema.NullOr(Schema.String), | |
| 104 | }); | |
| 105 | ||
| 106 | export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>; | |
| 107 | ||
| 108 | export const OrchestrationGitReadModelSchema = Schema.Struct({ | |
| 109 | projectId: IdSchema, | |
| 110 | branch: Schema.NullOr(Schema.String), | |
| 111 | hasWorkingTreeChanges: Schema.Boolean, | |
| 112 | aheadCount: Schema.Number, | |
| 113 | behindCount: Schema.Number, | |
| 114 | updatedAt: IsoDateTimeSchema, | |
| 216 | latestTurnId: Schema.NullOr(TurnId), | |
| 217 | createdAt: IsoDateTime, | |
| 218 | updatedAt: IsoDateTime, | |
| 219 | deletedAt: Schema.NullOr(IsoDateTime), | |
| 220 | messages: Schema.Array(OrchestrationMessage), | |
| 221 | activities: Schema.Array(OrchestrationThreadActivity), | |
| 222 | checkpoints: Schema.Array(OrchestrationCheckpointSummary), | |
| 223 | session: Schema.NullOr(OrchestrationSession), | |
| 224 | }); | |
| 225 | export type OrchestrationThread = typeof OrchestrationThread.Type; | |
| 226 | ||
| 227 | export const OrchestrationReadModel = Schema.Struct({ | |
| 228 | snapshotSequence: NonNegativeInt, | |
| 229 | projects: Schema.Array(OrchestrationProject), | |
| 230 | threads: Schema.Array(OrchestrationThread), | |
| 231 | updatedAt: IsoDateTime, | |
| 115 | 232 | }); |
| 233 | export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; | |
| 116 | 234 | |
| 117 | export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationGitReadModelSchema>; | |
| 235 | export 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 | }); | |
| 118 | 244 | |
| 119 | export const OrchestrationReadModelSchema = Schema.Struct({ | |
| 120 | sequence: Schema.Number, | |
| 121 | threads: Schema.Array(OrchestrationThreadSchema), | |
| 122 | gitByProjectId: Schema.Record( Schema.String, OrchestrationGitReadModelSchema), | |
| 123 | updatedAt: IsoDateTimeSchema, | |
| 245 | export const ProjectMetaUpdateCommand = Schema.Struct({ | |
| 246 | type: Schema.Literal("project.meta.update"), | |
| 247 | commandId: CommandId, | |
| 248 | projectId: ProjectId, | |
| 249 | title: Schema.optional(Schema.String), | |
| 250 | workspaceRoot: Schema.optional(Schema.String), | |
| 251 | defaultModel: Schema.optional(Schema.String), | |
| 252 | scripts: Schema.optional(Schema.Array(ProjectScript)), | |
| 124 | 253 | }); |
| 125 | 254 | |
| 126 | export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>; | |
| 255 | export const ProjectDeleteCommand = Schema.Struct({ | |
| 256 | type: Schema.Literal("project.delete"), | |
| 257 | commandId: CommandId, | |
| 258 | projectId: ProjectId, | |
| 259 | }); | |
| 127 | 260 | |
| 128 | export const CreateThreadCommandSchema = Schema.Struct({ | |
| 261 | export const ThreadCreateCommand = Schema.Struct({ | |
| 129 | 262 | type: Schema.Literal("thread.create"), |
| 130 | commandId: IdSchema, | |
| 131 | threadId: IdSchema, | |
| 132 | projectId: IdSchema, | |
| 263 | commandId: CommandId, | |
| 264 | threadId: ThreadId, | |
| 265 | projectId: ProjectId, | |
| 133 | 266 | title: Schema.String, |
| 134 | 267 | model: Schema.String, |
| 135 | 268 | branch: Schema.NullOr(Schema.String), |
| 136 | 269 | worktreePath: Schema.NullOr(Schema.String), |
| 137 | createdAt: IsoDateTimeSchema, | |
| 270 | createdAt: IsoDateTime, | |
| 138 | 271 | }); |
| 139 | 272 | |
| 140 | export const DeleteThreadCommandSchema = Schema.Struct({ | |
| 273 | export const ThreadDeleteCommand = Schema.Struct({ | |
| 141 | 274 | type: Schema.Literal("thread.delete"), |
| 142 | commandId: IdSchema, | |
| 143 | threadId: IdSchema, | |
| 144 | createdAt: IsoDateTimeSchema, | |
| 275 | commandId: CommandId, | |
| 276 | threadId: ThreadId, | |
| 145 | 277 | }); |
| 146 | 278 | |
| 147 | export const UpdateThreadMetaCommandSchema = Schema.Struct({ | |
| 279 | export const ThreadMetaUpdateCommand = Schema.Struct({ | |
| 148 | 280 | type: Schema.Literal("thread.meta.update"), |
| 149 | commandId: IdSchema, | |
| 150 | threadId: IdSchema, | |
| 281 | commandId: CommandId, | |
| 282 | threadId: ThreadId, | |
| 151 | 283 | title: Schema.optional(Schema.String), |
| 152 | 284 | model: Schema.optional(Schema.String), |
| 153 | 285 | branch: Schema.optional(Schema.NullOr(Schema.String)), |
| 154 | 286 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), |
| 155 | createdAt: IsoDateTimeSchema, | |
| 156 | 287 | }); |
| 157 | 288 | |
| 158 | export const SendMessageCommandSchema = Schema.Struct({ | |
| 159 | type: Schema.Literal("message.send"), | |
| 160 | commandId: IdSchema, | |
| 161 | threadId: IdSchema, | |
| 162 | messageId: IdSchema, | |
| 163 | role: Schema.Literals(["user", "assistant"]), | |
| 164 | text: Schema.String, | |
| 165 | streaming: Schema.optional(Schema.Boolean), | |
| 166 | createdAt: IsoDateTimeSchema, | |
| 289 | export const ThreadTurnStartCommand = Schema.Struct({ | |
| 290 | type: Schema.Literal("thread.turn.start"), | |
| 291 | commandId: CommandId, | |
| 292 | threadId: ThreadId, | |
| 293 | message: Schema.Struct({ | |
| 294 | messageId: MessageId, | |
| 295 | role: Schema.Literal("user"), | |
| 296 | text: Schema.String, | |
| 297 | attachments: Schema.Array(ChatAttachment), | |
| 298 | }), | |
| 299 | model: Schema.optional(Schema.String), | |
| 300 | effort: Schema.optional(Schema.String), | |
| 301 | createdAt: IsoDateTime, | |
| 167 | 302 | }); |
| 168 | 303 | |
| 169 | export const SetThreadSessionSchema = Schema.Struct({ | |
| 170 | type: Schema.Literal("thread.session"), | |
| 171 | commandId: IdSchema, | |
| 172 | threadId: IdSchema, | |
| 173 | session: OrchestrationSessionSchema, | |
| 174 | createdAt: IsoDateTimeSchema, | |
| 304 | export const ThreadTurnInterruptCommand = Schema.Struct({ | |
| 305 | type: Schema.Literal("thread.turn.interrupt"), | |
| 306 | commandId: CommandId, | |
| 307 | threadId: ThreadId, | |
| 308 | turnId: Schema.optional(TurnId), | |
| 309 | createdAt: IsoDateTime, | |
| 175 | 310 | }); |
| 176 | 311 | |
| 177 | export const UpsertGitReadModelCommandSchema = Schema.Struct({ | |
| 178 | type: Schema.Literal("git.readModel.upsert"), | |
| 179 | commandId: IdSchema, | |
| 180 | projectId: IdSchema, | |
| 181 | branch: Schema.NullOr(Schema.String), | |
| 182 | hasWorkingTreeChanges: Schema.Boolean, | |
| 183 | aheadCount: Schema.Number, | |
| 184 | behindCount: Schema.Number, | |
| 185 | createdAt: IsoDateTimeSchema, | |
| 186 | }); | |
| 187 | ||
| 188 | export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({ | |
| 189 | type: Schema.Literal("thread.turnDiff.complete"), | |
| 190 | commandId: IdSchema, | |
| 191 | threadId: IdSchema, | |
| 192 | turnId: IdSchema, | |
| 193 | completedAt: IsoDateTimeSchema, | |
| 194 | status: Schema.optional(Schema.String), | |
| 195 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | |
| 196 | assistantMessageId: Schema.optional(Schema.String), | |
| 197 | checkpointTurnCount: Schema.optional(Schema.Number), | |
| 198 | createdAt: IsoDateTimeSchema, | |
| 199 | }); | |
| 200 | ||
| 201 | export const RevertThreadCommandSchema = Schema.Struct({ | |
| 202 | type: Schema.Literal("thread.revert"), | |
| 203 | commandId: IdSchema, | |
| 204 | threadId: IdSchema, | |
| 205 | turnCount: Schema.Number, | |
| 206 | messageCount: Schema.Number, | |
| 207 | createdAt: IsoDateTimeSchema, | |
| 208 | }); | |
| 209 | ||
| 210 | export const AppendThreadActivityCommandSchema = Schema.Struct({ | |
| 312 | export const ThreadApprovalRespondCommand = Schema.Struct({ | |
| 313 | type: Schema.Literal("thread.approval.respond"), | |
| 314 | commandId: CommandId, | |
| 315 | threadId: ThreadId, | |
| 316 | requestId: ApprovalRequestId, | |
| 317 | decision: ProviderApprovalDecision, | |
| 318 | createdAt: IsoDateTime, | |
| 319 | }); | |
| 320 | ||
| 321 | export const ThreadCheckpointRevertCommand = Schema.Struct({ | |
| 322 | type: Schema.Literal("thread.checkpoint.revert"), | |
| 323 | commandId: CommandId, | |
| 324 | threadId: ThreadId, | |
| 325 | turnCount: NonNegativeInt, | |
| 326 | createdAt: IsoDateTime, | |
| 327 | }); | |
| 328 | ||
| 329 | export const ThreadSessionStopCommand = Schema.Struct({ | |
| 330 | type: Schema.Literal("thread.session.stop"), | |
| 331 | commandId: CommandId, | |
| 332 | threadId: ThreadId, | |
| 333 | createdAt: IsoDateTime, | |
| 334 | }); | |
| 335 | ||
| 336 | export const ClientOrchestrationCommand = Schema.Union([ | |
| 337 | ProjectCreateCommand, | |
| 338 | ProjectMetaUpdateCommand, | |
| 339 | ProjectDeleteCommand, | |
| 340 | ThreadCreateCommand, | |
| 341 | ThreadDeleteCommand, | |
| 342 | ThreadMetaUpdateCommand, | |
| 343 | ThreadTurnStartCommand, | |
| 344 | ThreadTurnInterruptCommand, | |
| 345 | ThreadApprovalRespondCommand, | |
| 346 | ThreadCheckpointRevertCommand, | |
| 347 | ThreadSessionStopCommand, | |
| 348 | ]); | |
| 349 | export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; | |
| 350 | ||
| 351 | export 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 | ||
| 359 | export 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 | ||
| 369 | export 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 | ||
| 378 | export 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 | ||
| 392 | export const ThreadActivityAppendCommand = Schema.Struct({ | |
| 211 | 393 | type: Schema.Literal("thread.activity.append"), |
| 212 | commandId: IdSchema, | |
| 213 | threadId: IdSchema, | |
| 214 | activity: OrchestrationThreadActivitySchema, | |
| 215 | createdAt: IsoDateTimeSchema, | |
| 216 | }); | |
| 217 | ||
| 218 | export const OrchestrationCommandSchema = Schema.Union([ | |
| 219 | CreateThreadCommandSchema, | |
| 220 | DeleteThreadCommandSchema, | |
| 221 | UpdateThreadMetaCommandSchema, | |
| 222 | SendMessageCommandSchema, | |
| 223 | SetThreadSessionSchema, | |
| 224 | UpsertGitReadModelCommandSchema, | |
| 225 | CompleteThreadTurnDiffCommandSchema, | |
| 226 | AppendThreadActivityCommandSchema, | |
| 227 | RevertThreadCommandSchema,] | |
| 228 | ); | |
| 394 | commandId: CommandId, | |
| 395 | threadId: ThreadId, | |
| 396 | activity: OrchestrationThreadActivity, | |
| 397 | createdAt: IsoDateTime, | |
| 398 | }); | |
| 229 | 399 | |
| 230 | export type OrchestrationCommand = Schema.Schema.Type<typeof OrchestrationCommandSchema>; | |
| 400 | export const ThreadRevertCompleteCommand = Schema.Struct({ | |
| 401 | type: Schema.Literal("thread.revert.complete"), | |
| 402 | commandId: CommandId, | |
| 403 | threadId: ThreadId, | |
| 404 | turnCount: NonNegativeInt, | |
| 405 | createdAt: IsoDateTime, | |
| 406 | }); | |
| 231 | 407 | |
| 232 | export const OrchestrationEventSchema = Schema.Struct({ | |
| 233 | sequence: Schema.Number, | |
| 234 | eventId: IdSchema, | |
| 235 | type: Schema.String, | |
| 236 | aggregateType: Schema.String, | |
| 237 | aggregateId: Schema.String, | |
| 238 | occurredAt: IsoDateTimeSchema, | |
| 239 | commandId: Schema.NullOr(Schema.String), | |
| 240 | payload: Schema.Unknown, | |
| 408 | export const InternalOrchestrationCommand = Schema.Union([ | |
| 409 | ThreadSessionSetCommand, | |
| 410 | ThreadMessageAssistantDeltaCommand, | |
| 411 | ThreadMessageAssistantCompleteCommand, | |
| 412 | ThreadTurnDiffCompleteCommand, | |
| 413 | ThreadActivityAppendCommand, | |
| 414 | ThreadRevertCompleteCommand, | |
| 415 | ]); | |
| 416 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; | |
| 417 | ||
| 418 | export const OrchestrationCommand = Schema.Union([ | |
| 419 | ClientOrchestrationCommand, | |
| 420 | InternalOrchestrationCommand, | |
| 421 | ]); | |
| 422 | export type OrchestrationCommand = typeof OrchestrationCommand.Type; | |
| 423 | ||
| 424 | export 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 | ]); | |
| 442 | export type OrchestrationEventType = typeof OrchestrationEventType.Type; | |
| 443 | ||
| 444 | export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); | |
| 445 | export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; | |
| 446 | export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); | |
| 447 | ||
| 448 | export 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 | ||
| 458 | export 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 | ||
| 467 | export const ProjectDeletedPayload = Schema.Struct({ | |
| 468 | projectId: ProjectId, | |
| 469 | deletedAt: IsoDateTime, | |
| 241 | 470 | }); |
| 242 | 471 | |
| 243 | export type OrchestrationEvent = Schema.Schema.Type<typeof OrchestrationEventSchema>; | |
| 472 | export 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 | ||
| 483 | export const ThreadDeletedPayload = Schema.Struct({ | |
| 484 | threadId: ThreadId, | |
| 485 | deletedAt: IsoDateTime, | |
| 486 | }); | |
| 487 | ||
| 488 | export 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 | ||
| 497 | export 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 | ||
| 509 | export 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 | ||
| 517 | export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ | |
| 518 | threadId: ThreadId, | |
| 519 | turnId: Schema.optional(TurnId), | |
| 520 | createdAt: IsoDateTime, | |
| 521 | }); | |
| 522 | ||
| 523 | export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ | |
| 524 | threadId: ThreadId, | |
| 525 | requestId: ApprovalRequestId, | |
| 526 | decision: ProviderApprovalDecision, | |
| 527 | createdAt: IsoDateTime, | |
| 528 | }); | |
| 529 | ||
| 530 | export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ | |
| 531 | threadId: ThreadId, | |
| 532 | turnCount: NonNegativeInt, | |
| 533 | createdAt: IsoDateTime, | |
| 534 | }); | |
| 535 | ||
| 536 | export const ThreadRevertedPayload = Schema.Struct({ | |
| 537 | threadId: ThreadId, | |
| 538 | turnCount: NonNegativeInt, | |
| 539 | }); | |
| 540 | ||
| 541 | export const ThreadSessionStopRequestedPayload = Schema.Struct({ | |
| 542 | threadId: ThreadId, | |
| 543 | createdAt: IsoDateTime, | |
| 544 | }); | |
| 545 | ||
| 546 | export const ThreadSessionSetPayload = Schema.Struct({ | |
| 547 | threadId: ThreadId, | |
| 548 | session: OrchestrationSession, | |
| 549 | }); | |
| 550 | ||
| 551 | export 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 | ||
| 562 | export const ThreadActivityAppendedPayload = Schema.Struct({ | |
| 563 | threadId: ThreadId, | |
| 564 | activity: OrchestrationThreadActivity, | |
| 565 | }); | |
| 566 | ||
| 567 | export 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 | }); | |
| 576 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; | |
| 577 | ||
| 578 | const 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 | ||
| 590 | const 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 | ||
| 604 | export 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 | ]); | |
| 686 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; | |
| 687 | ||
| 688 | export 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 | ]); | |
| 770 | export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type; | |
| 771 | ||
| 772 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); | |
| 773 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; | |
| 774 | ||
| 775 | export const TurnCountRange = Schema.Struct({ | |
| 776 | fromTurnCount: NonNegativeInt, | |
| 777 | toTurnCount: NonNegativeInt, | |
| 778 | }); | |
| 779 | ||
| 780 | export const ThreadTurnDiff = Schema.Struct({ | |
| 781 | threadId: ThreadId, | |
| 782 | ...TurnCountRange.fields, | |
| 783 | diff: Schema.String, | |
| 784 | }); | |
| 785 | ||
| 786 | export const ProviderSessionRuntimeStatus = Schema.Literals([ | |
| 787 | "starting", | |
| 788 | "running", | |
| 789 | "stopped", | |
| 790 | "error", | |
| 791 | ]); | |
| 792 | export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type; | |
| 793 | ||
| 794 | export const ProjectionThreadTurnStatus = Schema.Literals([ | |
| 795 | "running", | |
| 796 | "completed", | |
| 797 | "interrupted", | |
| 798 | "error", | |
| 799 | ]); | |
| 800 | export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type; | |
| 801 | ||
| 802 | export 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 | }); | |
| 812 | export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type; | |
| 813 | ||
| 814 | export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]); | |
| 815 | export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type; | |
| 816 | ||
| 817 | export const ProjectionPendingApprovalDecision = Schema.NullOr(ProviderApprovalDecision); | |
| 818 | export type ProjectionPendingApprovalDecision = typeof ProjectionPendingApprovalDecision.Type; | |
| 819 | ||
| 820 | export const DispatchResult = Schema.Struct({ | |
| 821 | sequence: NonNegativeInt, | |
| 822 | }); | |
| 823 | export type DispatchResult = typeof DispatchResult.Type; | |
| 824 | ||
| 825 | export const OrchestrationGetSnapshotInput = Schema.Struct({}); | |
| 826 | export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; | |
| 827 | export const OrchestrationGetSnapshotResult = OrchestrationReadModel; | |
| 828 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; | |
| 829 | ||
| 830 | export const OrchestrationGetTurnDiffInput = Schema.Struct({ | |
| 831 | threadId: ThreadId, | |
| 832 | ...TurnCountRange.fields, | |
| 833 | }); | |
| 834 | export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; | |
| 835 | ||
| 836 | export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; | |
| 837 | export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; | |
| 838 | ||
| 839 | export const OrchestrationReplayEventsInput = Schema.Struct({ | |
| 840 | fromSequenceExclusive: NonNegativeInt, | |
| 841 | }); | |
| 842 | export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type; | |
| 843 | ||
| 844 | export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); | |
| 845 | export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; | |
| 846 | ||
| 847 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | projectAddInputSchema, | |
| 5 | projectAddResultSchema, | |
| 6 | projectSearchEntriesInputSchema, | |
| 7 | projectSearchEntriesResultSchema, | |
| 8 | projectListResultSchema, | |
| 9 | projectRemoveInputSchema, | |
| 10 | projectUpdateScriptsInputSchema, | |
| 11 | projectUpdateScriptsResultSchema, | |
| 12 | } from "./project"; | |
| 13 | ||
| 14 | describe("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 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const PROJECT_SCRIPT_ICON_VALUES = [ | |
| 4 | "play", | |
| 5 | "test", | |
| 6 | "lint", | |
| 7 | "configure", | |
| 8 | "build", | |
| 9 | "debug", | |
| 10 | ] as const; | |
| 11 | ||
| 12 | export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); | |
| 13 | ||
| 14 | export 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 | ||
| 29 | export const projectScriptsSchema = z.array(projectScriptSchema).max(64); | |
| 30 | ||
| 31 | export 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 | ||
| 40 | export const projectListResultSchema = z.array(projectRecordSchema); | |
| 41 | ||
| 42 | export const projectAddInputSchema = z.object({ | |
| 43 | cwd: z.string().trim().min(1), | |
| 44 | }); | |
| 45 | ||
| 46 | export const projectAddResultSchema = z.object({ | |
| 47 | project: projectRecordSchema, | |
| 48 | created: z.boolean(), | |
| 49 | }); | |
| 50 | ||
| 51 | export const projectRemoveInputSchema = z.object({ | |
| 52 | id: z.string().min(1), | |
| 53 | }); | |
| 1 | import { Schema } from "effect"; | |
| 54 | 2 | |
| 55 | 3 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 56 | ||
| 57 | export const projectSearchEntriesInputSchema = z.object({ | |
| 58 | cwd: z.string().trim().min(1), | |
| 59 | query: z.string().trim().max(256).default(""), | |
| 60 | limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), | |
| 61 | }); | |
| 62 | ||
| 63 | export const projectEntryKindSchema = z.enum(["file", "directory"]); | |
| 64 | ||
| 65 | export const projectEntrySchema = z.object({ | |
| 66 | path: z.string().min(1), | |
| 67 | kind: projectEntryKindSchema, | |
| 68 | parentPath: z.string().optional(), | |
| 69 | }); | |
| 70 | ||
| 71 | export const projectSearchEntriesResultSchema = z.object({ | |
| 72 | entries: z.array(projectEntrySchema), | |
| 73 | truncated: z.boolean(), | |
| 74 | }); | |
| 75 | ||
| 76 | export const projectUpdateScriptsInputSchema = z.object({ | |
| 77 | id: z.string().min(1), | |
| 78 | scripts: projectScriptsSchema, | |
| 79 | }); | |
| 80 | ||
| 81 | export const projectUpdateScriptsResultSchema = z.object({ | |
| 82 | project: projectRecordSchema, | |
| 83 | }); | |
| 84 | ||
| 85 | export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>; | |
| 86 | export 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 | */ | |
| 93 | export 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 | } | |
| 124 | export type ProjectRecord = z.infer<typeof projectRecordSchema>; | |
| 125 | export type ProjectListResult = z.infer<typeof projectListResultSchema>; | |
| 126 | export type ProjectAddInput = z.input<typeof projectAddInputSchema>; | |
| 127 | export type ProjectAddResult = z.infer<typeof projectAddResultSchema>; | |
| 128 | export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>; | |
| 129 | export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>; | |
| 130 | export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>; | |
| 131 | export type ProjectEntry = z.infer<typeof projectEntrySchema>; | |
| 132 | export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>; | |
| 133 | export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>; | |
| 134 | export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>; | |
| 4 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); | |
| 5 | ||
| 6 | export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>( | |
| 7 | "ProjectSearchEntriesInput", | |
| 8 | )({ | |
| 9 | cwd: TrimmedNonEmptyString, | |
| 10 | query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), | |
| 11 | limit: Schema.Int.check( | |
| 12 | Schema.isGreaterThanOrEqualTo(1), | |
| 13 | Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), | |
| 14 | ), | |
| 15 | }) {} | |
| 16 | ||
| 17 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); | |
| 18 | ||
| 19 | export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({ | |
| 20 | path: TrimmedNonEmptyString, | |
| 21 | kind: ProjectEntryKind, | |
| 22 | parentPath: Schema.optional(TrimmedNonEmptyString), | |
| 23 | }) {} | |
| 24 | ||
| 25 | export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>( | |
| 26 | "ProjectSearchEntriesResult", | |
| 27 | )({ | |
| 28 | entries: Schema.Array(ProjectEntry), | |
| 29 | truncated: Schema.Boolean, | |
| 30 | }) {} |
packages/contracts/src/provider.test.ts deleted-283| ... | ... | @@ -1,283 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 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 | ||
| 18 | describe("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 | ||
| 64 | describe("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 | ||
| 136 | describe("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 | ||
| 169 | describe("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 | ||
| 190 | describe("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 | import { z } from "zod"; | |
| 2 | ||
| 3 | export const providerKindSchema = z.enum(["codex", "claudeCode"]); | |
| 4 | ||
| 5 | export const providerApprovalPolicySchema = z.enum([ | |
| 6 | "untrusted", | |
| 7 | "on-failure", | |
| 8 | "on-request", | |
| 9 | "never", | |
| 10 | ]); | |
| 11 | ||
| 12 | export const providerSandboxModeSchema = z.enum([ | |
| 13 | "read-only", | |
| 14 | "workspace-write", | |
| 15 | "danger-full-access", | |
| 16 | ]); | |
| 17 | ||
| 18 | export const providerRequestKindSchema = z.enum(["command", "file-change"]); | |
| 19 | ||
| 20 | export const providerApprovalDecisionSchema = z.enum([ | |
| 21 | "accept", | |
| 22 | "acceptForSession", | |
| 23 | "decline", | |
| 24 | "cancel", | |
| 25 | ]); | |
| 26 | ||
| 27 | export const providerSessionStatusSchema = z.enum([ | |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | ApprovalRequestId, | |
| 5 | ChatAttachment, | |
| 6 | EventId, | |
| 7 | IsoDateTime, | |
| 8 | NonNegativeInt, | |
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 10 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 11 | ProviderApprovalDecision, | |
| 12 | ProviderApprovalPolicy, | |
| 13 | ProviderKind, | |
| 14 | ProviderItemId, | |
| 15 | ProviderRequestKind, | |
| 16 | ProviderSandboxMode, | |
| 17 | ProviderSessionId, | |
| 18 | ProviderThreadId, | |
| 19 | ProviderTurnId, | |
| 20 | ThreadId, | |
| 21 | TurnCountRange, | |
| 22 | } from "./orchestration"; | |
| 23 | ||
| 24 | const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); | |
| 25 | ||
| 26 | export const ProviderSessionStatus = Schema.Literals([ | |
| 28 | 27 | "connecting", |
| 29 | 28 | "ready", |
| 30 | 29 | "running", |
| 31 | 30 | "error", |
| 32 | 31 | "closed", |
| 33 | 32 | ]); |
| 34 | ||
| 35 | export const providerSessionSchema = z.object({ | |
| 36 | sessionId: z.string().min(1), | |
| 37 | provider: providerKindSchema, | |
| 38 | status: providerSessionStatusSchema, | |
| 39 | cwd: z.string().min(1).optional(), | |
| 40 | model: z.string().min(1).optional(), | |
| 41 | threadId: z.string().min(1).optional(), | |
| 42 | activeTurnId: z.string().min(1).optional(), | |
| 43 | createdAt: z.string().datetime(), | |
| 44 | updatedAt: z.string().datetime(), | |
| 45 | lastError: z.string().min(1).optional(), | |
| 33 | export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; | |
| 34 | ||
| 35 | export const ProviderSession = Schema.Struct({ | |
| 36 | sessionId: ProviderSessionId, | |
| 37 | provider: ProviderKind, | |
| 38 | status: ProviderSessionStatus, | |
| 39 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 40 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 41 | threadId: Schema.optional(ProviderThreadId), | |
| 42 | activeTurnId: Schema.optional(ProviderTurnId), | |
| 43 | createdAt: IsoDateTime, | |
| 44 | updatedAt: IsoDateTime, | |
| 45 | lastError: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 46 | 46 | }); |
| 47 | ||
| 48 | export const providerSessionStartInputSchema = z.object({ | |
| 49 | provider: providerKindSchema.default("codex"), | |
| 50 | cwd: z.string().min(1).optional(), | |
| 51 | model: z.string().trim().min(1).optional(), | |
| 52 | resumeThreadId: z.string().trim().min(1).optional(), | |
| 53 | codexBinaryPath: z.string().trim().min(1).optional(), | |
| 54 | codexHomePath: z.string().trim().min(1).optional(), | |
| 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), | |
| 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), | |
| 47 | export type ProviderSession = typeof ProviderSession.Type; | |
| 48 | ||
| 49 | export const ProviderSessionStartInput = Schema.Struct({ | |
| 50 | provider: Schema.optional(ProviderKind), | |
| 51 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 52 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 53 | resumeThreadId: Schema.optional(ProviderThreadId), | |
| 54 | codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 55 | codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 56 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | |
| 57 | sandboxMode: Schema.optional(ProviderSandboxMode), | |
| 57 | 58 | }); |
| 58 | ||
| 59 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | |
| 60 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 61 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | |
| 62 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | |
| 63 | ||
| 64 | export const providerSendTurnImageAttachmentSchema = z.object({ | |
| 65 | type: z.literal("image"), | |
| 66 | name: z.string().trim().min(1).max(255), | |
| 67 | mimeType: z | |
| 68 | .string() | |
| 69 | .trim() | |
| 70 | .min(1) | |
| 71 | .max(100) | |
| 72 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), | |
| 73 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | |
| 74 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | |
| 59 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; | |
| 60 | ||
| 61 | export const ProviderSendTurnInput = Schema.Struct({ | |
| 62 | sessionId: ProviderSessionId, | |
| 63 | input: Schema.optional( | |
| 64 | TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), | |
| 65 | ), | |
| 66 | attachments: Schema.optional( | |
| 67 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | |
| 68 | ), | |
| 69 | model: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 70 | effort: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 75 | 71 | }); |
| 72 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; | |
| 76 | 73 | |
| 77 | export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ | |
| 78 | providerSendTurnImageAttachmentSchema, | |
| 79 | ]); | |
| 80 | ||
| 81 | export 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 | ||
| 102 | export const providerTurnStartResultSchema = z.object({ | |
| 103 | threadId: z.string().min(1), | |
| 104 | turnId: z.string().min(1), | |
| 74 | export const ProviderTurnStartResult = Schema.Struct({ | |
| 75 | threadId: ProviderThreadId, | |
| 76 | turnId: ProviderTurnId, | |
| 105 | 77 | }); |
| 78 | export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; | |
| 106 | 79 | |
| 107 | export const providerInterruptTurnInputSchema = z.object({ | |
| 108 | sessionId: z.string().min(1), | |
| 109 | turnId: z.string().min(1).optional(), | |
| 80 | export const ProviderInterruptTurnInput = Schema.Struct({ | |
| 81 | sessionId: ProviderSessionId, | |
| 82 | turnId: Schema.optional(ProviderTurnId), | |
| 110 | 83 | }); |
| 84 | export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; | |
| 111 | 85 | |
| 112 | export const providerStopSessionInputSchema = z.object({ | |
| 113 | sessionId: z.string().min(1), | |
| 86 | export const ProviderStopSessionInput = Schema.Struct({ | |
| 87 | sessionId: ProviderSessionId, | |
| 114 | 88 | }); |
| 89 | export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; | |
| 115 | 90 | |
| 116 | export const providerListCheckpointsInputSchema = z.object({ | |
| 117 | sessionId: z.string().min(1), | |
| 91 | export const ProviderListCheckpointsInput = Schema.Struct({ | |
| 92 | sessionId: ProviderSessionId, | |
| 118 | 93 | }); |
| 119 | ||
| 120 | export const providerCheckpointSchema = z.object({ | |
| 121 | id: z.string().min(1), | |
| 122 | turnCount: z.number().int().min(0), | |
| 123 | messageCount: z.number().int().min(0), | |
| 124 | label: z.string().min(1), | |
| 125 | preview: z.string().min(1).optional(), | |
| 126 | isCurrent: z.boolean(), | |
| 94 | export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; | |
| 95 | ||
| 96 | export const ProviderCheckpoint = Schema.Struct({ | |
| 97 | id: TrimmedNonEmptyStringSchema, | |
| 98 | turnCount: NonNegativeInt, | |
| 99 | messageCount: NonNegativeInt, | |
| 100 | label: TrimmedNonEmptyStringSchema, | |
| 101 | preview: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 102 | isCurrent: Schema.Boolean, | |
| 127 | 103 | }); |
| 104 | export type ProviderCheckpoint = typeof ProviderCheckpoint.Type; | |
| 128 | 105 | |
| 129 | export const providerListCheckpointsResultSchema = z.object({ | |
| 130 | threadId: z.string().min(1), | |
| 131 | checkpoints: z.array(providerCheckpointSchema), | |
| 106 | export const ProviderListCheckpointsResult = Schema.Struct({ | |
| 107 | threadId: ThreadId, | |
| 108 | checkpoints: Schema.Array(ProviderCheckpoint), | |
| 132 | 109 | }); |
| 110 | export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; | |
| 133 | 111 | |
| 134 | export const providerRevertToCheckpointInputSchema = z.object({ | |
| 135 | sessionId: z.string().min(1), | |
| 136 | turnCount: z.number().int().min(0), | |
| 112 | export const ProviderRevertToCheckpointInput = Schema.Struct({ | |
| 113 | sessionId: ProviderSessionId, | |
| 114 | turnCount: NonNegativeInt, | |
| 137 | 115 | }); |
| 138 | ||
| 139 | export const providerRevertToCheckpointResultSchema = z.object({ | |
| 140 | threadId: z.string().min(1), | |
| 141 | turnCount: z.number().int().min(0), | |
| 142 | messageCount: z.number().int().min(0), | |
| 143 | rolledBackTurns: z.number().int().min(0), | |
| 144 | checkpoints: z.array(providerCheckpointSchema), | |
| 116 | export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; | |
| 117 | ||
| 118 | export const ProviderRevertToCheckpointResult = Schema.Struct({ | |
| 119 | threadId: ThreadId, | |
| 120 | turnCount: NonNegativeInt, | |
| 121 | messageCount: NonNegativeInt, | |
| 122 | rolledBackTurns: NonNegativeInt, | |
| 123 | checkpoints: Schema.Array(ProviderCheckpoint), | |
| 145 | 124 | }); |
| 125 | export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; | |
| 146 | 126 | |
| 147 | export const providerGetCheckpointDiffInputSchema = z | |
| 148 | .object({ | |
| 149 | sessionId: z.string().min(1), | |
| 150 | fromTurnCount: z.number().int().min(0), | |
| 151 | toTurnCount: z.number().int().min(0), | |
| 152 | }) | |
| 153 | .superRefine((value, ctx) => { | |
| 154 | if (value.fromTurnCount > value.toTurnCount) { | |
| 155 | ctx.addIssue({ | |
| 156 | code: z.ZodIssueCode.custom, | |
| 157 | message: "fromTurnCount must be less than or equal to toTurnCount", | |
| 158 | path: ["fromTurnCount"], | |
| 159 | }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 163 | export 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(), | |
| 127 | export const ProviderGetCheckpointDiffInput = Schema.Struct({ | |
| 128 | sessionId: ProviderSessionId, | |
| 129 | ...TurnCountRange.fields, | |
| 168 | 130 | }); |
| 131 | export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; | |
| 169 | 132 | |
| 170 | export const providerRespondToRequestInputSchema = z.object({ | |
| 171 | sessionId: z.string().min(1), | |
| 172 | requestId: z.string().min(1), | |
| 173 | decision: providerApprovalDecisionSchema, | |
| 133 | export const ProviderGetCheckpointDiffResult = Schema.Struct({ | |
| 134 | threadId: ThreadId, | |
| 135 | ...TurnCountRange.fields, | |
| 136 | diff: Schema.String, | |
| 174 | 137 | }); |
| 138 | export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; | |
| 175 | 139 | |
| 176 | export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); | |
| 177 | ||
| 178 | export const providerEventSchema = z.object({ | |
| 179 | id: z.string().min(1), | |
| 180 | kind: providerEventKindSchema, | |
| 181 | provider: providerKindSchema, | |
| 182 | sessionId: z.string().min(1), | |
| 183 | createdAt: z.string().datetime(), | |
| 184 | method: z.string().min(1), | |
| 185 | message: z.string().min(1).optional(), | |
| 186 | threadId: z.string().min(1).optional(), | |
| 187 | turnId: z.string().min(1).optional(), | |
| 188 | itemId: z.string().min(1).optional(), | |
| 189 | requestId: z.string().min(1).optional(), | |
| 190 | requestKind: providerRequestKindSchema.optional(), | |
| 191 | textDelta: z.string().optional(), | |
| 192 | payload: z.unknown().optional(), | |
| 140 | export const ProviderRespondToRequestInput = Schema.Struct({ | |
| 141 | sessionId: ProviderSessionId, | |
| 142 | requestId: ApprovalRequestId, | |
| 143 | decision: ProviderApprovalDecision, | |
| 144 | }); | |
| 145 | export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; | |
| 146 | ||
| 147 | export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); | |
| 148 | export type ProviderEventKind = typeof ProviderEventKind.Type; | |
| 149 | ||
| 150 | export const ProviderEvent = Schema.Struct({ | |
| 151 | id: EventId, | |
| 152 | kind: ProviderEventKind, | |
| 153 | provider: ProviderKind, | |
| 154 | sessionId: ProviderSessionId, | |
| 155 | createdAt: IsoDateTime, | |
| 156 | method: TrimmedNonEmptyStringSchema, | |
| 157 | message: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 158 | threadId: Schema.optional(ProviderThreadId), | |
| 159 | turnId: Schema.optional(ProviderTurnId), | |
| 160 | itemId: Schema.optional(ProviderItemId), | |
| 161 | requestId: Schema.optional(ApprovalRequestId), | |
| 162 | requestKind: Schema.optional(ProviderRequestKind), | |
| 163 | textDelta: Schema.optional(Schema.String), | |
| 164 | payload: Schema.optional(Schema.Unknown), | |
| 193 | 165 | }); |
| 166 | export type ProviderEvent = typeof ProviderEvent.Type; | |
| 194 | 167 | |
| 195 | export type ProviderKind = z.infer<typeof providerKindSchema>; | |
| 196 | export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>; | |
| 197 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; | |
| 198 | export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>; | |
| 199 | export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>; | |
| 200 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | |
| 201 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | |
| 202 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; | |
| 203 | export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentSchema>; | |
| 204 | export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>; | |
| 205 | export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>; | |
| 206 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | |
| 207 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; | |
| 208 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; | |
| 209 | export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>; | |
| 210 | export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>; | |
| 211 | export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>; | |
| 212 | export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>; | |
| 213 | export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>; | |
| 214 | export type ProviderRevertToCheckpointResult = z.infer< | |
| 215 | typeof providerRevertToCheckpointResultSchema | |
| 216 | >; | |
| 217 | export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>; | |
| 218 | export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>; | |
| 219 | export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>; | |
| 220 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | |
| 221 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | |
| 168 | export type ProviderSendTurnAttachment = typeof ChatAttachment.Type; | |
| 169 | export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type; |
packages/contracts/src/providerRuntime.ts+160-173| ... | ... | @@ -1,232 +1,219 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | |
| 3 | const IsoDateTimeSchema = Schema.String; | |
| 4 | const IdSchema = Schema.String; | |
| 5 | ||
| 6 | export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]); | |
| 7 | export type ProviderRuntimeProvider = Schema.Schema.Type<typeof ProviderRuntimeProviderSchema>; | |
| 8 | ||
| 9 | export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]); | |
| 10 | export type ProviderRuntimeApprovalKind = Schema.Schema.Type< | |
| 11 | typeof ProviderRuntimeApprovalKindSchema | |
| 12 | >; | |
| 13 | ||
| 14 | export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]); | |
| 15 | export type ProviderRuntimeToolKind = Schema.Schema.Type<typeof ProviderRuntimeToolKindSchema>; | |
| 3 | import { | |
| 4 | ApprovalRequestId, | |
| 5 | EventId, | |
| 6 | IsoDateTime, | |
| 7 | NonNegativeInt, | |
| 8 | ProviderApprovalDecision, | |
| 9 | ProviderItemId, | |
| 10 | ProviderKind, | |
| 11 | ProviderRequestKind, | |
| 12 | ProviderSessionId, | |
| 13 | ProviderThreadId, | |
| 14 | ProviderTurnId, | |
| 15 | ThreadId, | |
| 16 | TurnId, | |
| 17 | } from "./orchestration"; | |
| 18 | ||
| 19 | const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]); | |
| 20 | const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]); | |
| 21 | ||
| 22 | export const ProviderRuntimeToolKind = Schema.Union([ | |
| 23 | ProviderRequestKind, | |
| 24 | Schema.Literal("other"), | |
| 25 | ]); | |
| 26 | export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; | |
| 16 | 27 | |
| 17 | export const ProviderRuntimeTurnStatusSchema = Schema.Literals([ | |
| 28 | export const ProviderRuntimeTurnStatus = Schema.Literals([ | |
| 18 | 29 | "completed", |
| 19 | 30 | "failed", |
| 20 | 31 | "cancelled", |
| 21 | 32 | "interrupted", |
| 22 | 33 | ]); |
| 23 | export type ProviderRuntimeTurnStatus = Schema.Schema.Type<typeof ProviderRuntimeTurnStatusSchema>; | |
| 34 | export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type; | |
| 24 | 35 | |
| 25 | export const ProviderRuntimeSessionStartedEventSchema = Schema.Struct({ | |
| 36 | export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ | |
| 26 | 37 | type: Schema.Literal("session.started"), |
| 27 | eventId: IdSchema, | |
| 28 | provider: ProviderRuntimeProviderSchema, | |
| 29 | sessionId: IdSchema, | |
| 30 | createdAt: IsoDateTimeSchema, | |
| 31 | threadId: Schema.optional(Schema.String), | |
| 38 | eventId: EventId, | |
| 39 | provider: ProviderKind, | |
| 40 | sessionId: ProviderSessionId, | |
| 41 | createdAt: IsoDateTime, | |
| 42 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 32 | 43 | message: Schema.optional(Schema.String), |
| 33 | 44 | }); |
| 45 | export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; | |
| 34 | 46 | |
| 35 | export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({ | |
| 47 | export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ | |
| 36 | 48 | type: Schema.Literal("session.exited"), |
| 37 | eventId: IdSchema, | |
| 38 | provider: ProviderRuntimeProviderSchema, | |
| 39 | sessionId: IdSchema, | |
| 40 | createdAt: IsoDateTimeSchema, | |
| 41 | threadId: Schema.optional(Schema.String), | |
| 49 | eventId: EventId, | |
| 50 | provider: ProviderKind, | |
| 51 | sessionId: ProviderSessionId, | |
| 52 | createdAt: IsoDateTime, | |
| 53 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 42 | 54 | message: Schema.optional(Schema.String), |
| 43 | 55 | }); |
| 56 | export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; | |
| 44 | 57 | |
| 45 | export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({ | |
| 58 | export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ | |
| 46 | 59 | type: Schema.Literal("thread.started"), |
| 47 | eventId: IdSchema, | |
| 48 | provider: ProviderRuntimeProviderSchema, | |
| 49 | sessionId: IdSchema, | |
| 50 | createdAt: IsoDateTimeSchema, | |
| 51 | threadId: IdSchema, | |
| 60 | eventId: EventId, | |
| 61 | provider: ProviderKind, | |
| 62 | sessionId: ProviderSessionId, | |
| 63 | createdAt: IsoDateTime, | |
| 64 | threadId: RuntimeThreadIdSchema, | |
| 52 | 65 | }); |
| 66 | export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; | |
| 53 | 67 | |
| 54 | export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({ | |
| 68 | export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ | |
| 55 | 69 | type: Schema.Literal("turn.started"), |
| 56 | eventId: IdSchema, | |
| 57 | provider: ProviderRuntimeProviderSchema, | |
| 58 | sessionId: IdSchema, | |
| 59 | createdAt: IsoDateTimeSchema, | |
| 60 | threadId: Schema.optional(Schema.String), | |
| 61 | turnId: IdSchema, | |
| 70 | eventId: EventId, | |
| 71 | provider: ProviderKind, | |
| 72 | sessionId: ProviderSessionId, | |
| 73 | createdAt: IsoDateTime, | |
| 74 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 75 | turnId: RuntimeTurnIdSchema, | |
| 62 | 76 | }); |
| 77 | export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; | |
| 63 | 78 | |
| 64 | export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({ | |
| 79 | export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ | |
| 65 | 80 | type: Schema.Literal("turn.completed"), |
| 66 | eventId: IdSchema, | |
| 67 | provider: ProviderRuntimeProviderSchema, | |
| 68 | sessionId: IdSchema, | |
| 69 | createdAt: IsoDateTimeSchema, | |
| 70 | threadId: Schema.optional(Schema.String), | |
| 71 | turnId: Schema.optional(Schema.String), | |
| 72 | status: Schema.optional(ProviderRuntimeTurnStatusSchema), | |
| 81 | eventId: EventId, | |
| 82 | provider: ProviderKind, | |
| 83 | sessionId: ProviderSessionId, | |
| 84 | createdAt: IsoDateTime, | |
| 85 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 86 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 87 | status: Schema.optional(ProviderRuntimeTurnStatus), | |
| 73 | 88 | errorMessage: Schema.optional(Schema.String), |
| 74 | 89 | }); |
| 90 | export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; | |
| 75 | 91 | |
| 76 | export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({ | |
| 92 | export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({ | |
| 77 | 93 | type: Schema.Literal("message.delta"), |
| 78 | eventId: IdSchema, | |
| 79 | provider: ProviderRuntimeProviderSchema, | |
| 80 | sessionId: IdSchema, | |
| 81 | createdAt: IsoDateTimeSchema, | |
| 82 | threadId: Schema.optional(Schema.String), | |
| 83 | turnId: Schema.optional(Schema.String), | |
| 84 | itemId: Schema.optional(Schema.String), | |
| 94 | eventId: EventId, | |
| 95 | provider: ProviderKind, | |
| 96 | sessionId: ProviderSessionId, | |
| 97 | createdAt: IsoDateTime, | |
| 98 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 99 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 100 | itemId: Schema.optional(ProviderItemId), | |
| 85 | 101 | delta: Schema.String, |
| 86 | 102 | }); |
| 103 | export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type; | |
| 87 | 104 | |
| 88 | export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({ | |
| 105 | export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({ | |
| 89 | 106 | type: Schema.Literal("message.completed"), |
| 90 | eventId: IdSchema, | |
| 91 | provider: ProviderRuntimeProviderSchema, | |
| 92 | sessionId: IdSchema, | |
| 93 | createdAt: IsoDateTimeSchema, | |
| 94 | itemId: IdSchema, | |
| 95 | threadId: Schema.optional(Schema.String), | |
| 96 | turnId: Schema.optional(Schema.String), | |
| 107 | eventId: EventId, | |
| 108 | provider: ProviderKind, | |
| 109 | sessionId: ProviderSessionId, | |
| 110 | createdAt: IsoDateTime, | |
| 111 | itemId: ProviderItemId, | |
| 112 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 113 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 97 | 114 | }); |
| 115 | export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type; | |
| 98 | 116 | |
| 99 | export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ | |
| 117 | export const ProviderRuntimeToolStartedEvent = Schema.Struct({ | |
| 100 | 118 | type: Schema.Literal("tool.started"), |
| 101 | eventId: IdSchema, | |
| 102 | provider: ProviderRuntimeProviderSchema, | |
| 103 | sessionId: IdSchema, | |
| 104 | createdAt: IsoDateTimeSchema, | |
| 105 | threadId: Schema.optional(Schema.String), | |
| 106 | turnId: Schema.optional(Schema.String), | |
| 107 | itemId: Schema.optional(Schema.String), | |
| 108 | toolKind: ProviderRuntimeToolKindSchema, | |
| 119 | eventId: EventId, | |
| 120 | provider: ProviderKind, | |
| 121 | sessionId: ProviderSessionId, | |
| 122 | createdAt: IsoDateTime, | |
| 123 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 124 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 125 | itemId: Schema.optional(ProviderItemId), | |
| 126 | toolKind: ProviderRuntimeToolKind, | |
| 109 | 127 | title: Schema.String, |
| 110 | 128 | detail: Schema.optional(Schema.String), |
| 111 | 129 | }); |
| 130 | export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; | |
| 112 | 131 | |
| 113 | export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({ | |
| 132 | export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ | |
| 114 | 133 | type: Schema.Literal("tool.completed"), |
| 115 | eventId: IdSchema, | |
| 116 | provider: ProviderRuntimeProviderSchema, | |
| 117 | sessionId: IdSchema, | |
| 118 | createdAt: IsoDateTimeSchema, | |
| 119 | threadId: Schema.optional(Schema.String), | |
| 120 | turnId: Schema.optional(Schema.String), | |
| 121 | itemId: Schema.optional(Schema.String), | |
| 122 | toolKind: ProviderRuntimeToolKindSchema, | |
| 134 | eventId: EventId, | |
| 135 | provider: ProviderKind, | |
| 136 | sessionId: ProviderSessionId, | |
| 137 | createdAt: IsoDateTime, | |
| 138 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 139 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 140 | itemId: Schema.optional(ProviderItemId), | |
| 141 | toolKind: ProviderRuntimeToolKind, | |
| 123 | 142 | title: Schema.String, |
| 124 | 143 | detail: Schema.optional(Schema.String), |
| 125 | 144 | }); |
| 145 | export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; | |
| 126 | 146 | |
| 127 | export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({ | |
| 147 | export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ | |
| 128 | 148 | type: Schema.Literal("approval.requested"), |
| 129 | eventId: IdSchema, | |
| 130 | provider: ProviderRuntimeProviderSchema, | |
| 131 | sessionId: IdSchema, | |
| 132 | createdAt: IsoDateTimeSchema, | |
| 133 | threadId: Schema.optional(Schema.String), | |
| 134 | turnId: Schema.optional(Schema.String), | |
| 135 | itemId: Schema.optional(Schema.String), | |
| 136 | requestId: IdSchema, | |
| 137 | requestKind: ProviderRuntimeApprovalKindSchema, | |
| 149 | eventId: EventId, | |
| 150 | provider: ProviderKind, | |
| 151 | sessionId: ProviderSessionId, | |
| 152 | createdAt: IsoDateTime, | |
| 153 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 154 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 155 | itemId: Schema.optional(ProviderItemId), | |
| 156 | requestId: ApprovalRequestId, | |
| 157 | requestKind: ProviderRequestKind, | |
| 138 | 158 | detail: Schema.optional(Schema.String), |
| 139 | 159 | }); |
| 160 | export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type; | |
| 140 | 161 | |
| 141 | export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({ | |
| 162 | export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({ | |
| 142 | 163 | type: Schema.Literal("approval.resolved"), |
| 143 | eventId: IdSchema, | |
| 144 | provider: ProviderRuntimeProviderSchema, | |
| 145 | sessionId: IdSchema, | |
| 146 | createdAt: IsoDateTimeSchema, | |
| 147 | threadId: Schema.optional(Schema.String), | |
| 148 | turnId: Schema.optional(Schema.String), | |
| 149 | itemId: Schema.optional(Schema.String), | |
| 150 | requestId: IdSchema, | |
| 151 | requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema), | |
| 152 | decision: Schema.optional(Schema.String), | |
| 164 | eventId: EventId, | |
| 165 | provider: ProviderKind, | |
| 166 | sessionId: ProviderSessionId, | |
| 167 | createdAt: IsoDateTime, | |
| 168 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 169 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 170 | itemId: Schema.optional(ProviderItemId), | |
| 171 | requestId: ApprovalRequestId, | |
| 172 | requestKind: Schema.optional(ProviderRequestKind), | |
| 173 | decision: Schema.optional(ProviderApprovalDecision), | |
| 153 | 174 | }); |
| 175 | export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type; | |
| 154 | 176 | |
| 155 | export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({ | |
| 177 | export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({ | |
| 156 | 178 | type: Schema.Literal("checkpoint.captured"), |
| 157 | eventId: IdSchema, | |
| 158 | provider: ProviderRuntimeProviderSchema, | |
| 159 | sessionId: IdSchema, | |
| 160 | createdAt: IsoDateTimeSchema, | |
| 161 | threadId: IdSchema, | |
| 162 | turnId: Schema.optional(Schema.String), | |
| 163 | turnCount: Schema.Number, | |
| 164 | status: Schema.optional(ProviderRuntimeTurnStatusSchema), | |
| 179 | eventId: EventId, | |
| 180 | provider: ProviderKind, | |
| 181 | sessionId: ProviderSessionId, | |
| 182 | createdAt: IsoDateTime, | |
| 183 | threadId: RuntimeThreadIdSchema, | |
| 184 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 185 | turnCount: NonNegativeInt, | |
| 186 | status: Schema.optional(ProviderRuntimeTurnStatus), | |
| 165 | 187 | }); |
| 188 | export type ProviderRuntimeCheckpointCapturedEvent = typeof ProviderRuntimeCheckpointCapturedEvent.Type; | |
| 166 | 189 | |
| 167 | export const ProviderRuntimeErrorEventSchema = Schema.Struct({ | |
| 190 | export const ProviderRuntimeErrorEvent = Schema.Struct({ | |
| 168 | 191 | type: Schema.Literal("runtime.error"), |
| 169 | eventId: IdSchema, | |
| 170 | provider: ProviderRuntimeProviderSchema, | |
| 171 | sessionId: IdSchema, | |
| 172 | createdAt: IsoDateTimeSchema, | |
| 173 | threadId: Schema.optional(Schema.String), | |
| 174 | turnId: Schema.optional(Schema.String), | |
| 175 | itemId: Schema.optional(Schema.String), | |
| 192 | eventId: EventId, | |
| 193 | provider: ProviderKind, | |
| 194 | sessionId: ProviderSessionId, | |
| 195 | createdAt: IsoDateTime, | |
| 196 | threadId: Schema.optional(RuntimeThreadIdSchema), | |
| 197 | turnId: Schema.optional(RuntimeTurnIdSchema), | |
| 198 | itemId: Schema.optional(ProviderItemId), | |
| 176 | 199 | message: Schema.String, |
| 177 | 200 | }); |
| 178 | ||
| 179 | export const ProviderRuntimeEventSchema = Schema.Union([ | |
| 180 | ProviderRuntimeSessionStartedEventSchema, | |
| 181 | ProviderRuntimeSessionExitedEventSchema, | |
| 182 | ProviderRuntimeThreadStartedEventSchema, | |
| 183 | ProviderRuntimeTurnStartedEventSchema, | |
| 184 | ProviderRuntimeTurnCompletedEventSchema, | |
| 185 | ProviderRuntimeMessageDeltaEventSchema, | |
| 186 | ProviderRuntimeMessageCompletedEventSchema, | |
| 187 | ProviderRuntimeToolStartedEventSchema, | |
| 188 | ProviderRuntimeToolCompletedEventSchema, | |
| 189 | ProviderRuntimeApprovalRequestedEventSchema, | |
| 190 | ProviderRuntimeApprovalResolvedEventSchema, | |
| 191 | ProviderRuntimeCheckpointCapturedEventSchema, | |
| 192 | ProviderRuntimeErrorEventSchema, | |
| 201 | export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; | |
| 202 | ||
| 203 | export const ProviderRuntimeEvent = Schema.Union([ | |
| 204 | ProviderRuntimeSessionStartedEvent, | |
| 205 | ProviderRuntimeSessionExitedEvent, | |
| 206 | ProviderRuntimeThreadStartedEvent, | |
| 207 | ProviderRuntimeTurnStartedEvent, | |
| 208 | ProviderRuntimeTurnCompletedEvent, | |
| 209 | ProviderRuntimeMessageDeltaEvent, | |
| 210 | ProviderRuntimeMessageCompletedEvent, | |
| 211 | ProviderRuntimeToolStartedEvent, | |
| 212 | ProviderRuntimeToolCompletedEvent, | |
| 213 | ProviderRuntimeApprovalRequestedEvent, | |
| 214 | ProviderRuntimeApprovalResolvedEvent, | |
| 215 | ProviderRuntimeCheckpointCapturedEvent, | |
| 216 | ProviderRuntimeErrorEvent, | |
| 193 | 217 | ]); |
| 218 | export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type; | |
| 194 | 219 | |
| 195 | export type ProviderRuntimeSessionStartedEvent = Schema.Schema.Type< | |
| 196 | typeof ProviderRuntimeSessionStartedEventSchema | |
| 197 | >; | |
| 198 | export type ProviderRuntimeSessionExitedEvent = Schema.Schema.Type< | |
| 199 | typeof ProviderRuntimeSessionExitedEventSchema | |
| 200 | >; | |
| 201 | export type ProviderRuntimeThreadStartedEvent = Schema.Schema.Type< | |
| 202 | typeof ProviderRuntimeThreadStartedEventSchema | |
| 203 | >; | |
| 204 | export type ProviderRuntimeTurnStartedEvent = Schema.Schema.Type< | |
| 205 | typeof ProviderRuntimeTurnStartedEventSchema | |
| 206 | >; | |
| 207 | export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type< | |
| 208 | typeof ProviderRuntimeTurnCompletedEventSchema | |
| 209 | >; | |
| 210 | export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< | |
| 211 | typeof ProviderRuntimeMessageDeltaEventSchema | |
| 212 | >; | |
| 213 | export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type< | |
| 214 | typeof ProviderRuntimeMessageCompletedEventSchema | |
| 215 | >; | |
| 216 | export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< | |
| 217 | typeof ProviderRuntimeToolStartedEventSchema | |
| 218 | >; | |
| 219 | export type ProviderRuntimeToolCompletedEvent = Schema.Schema.Type< | |
| 220 | typeof ProviderRuntimeToolCompletedEventSchema | |
| 221 | >; | |
| 222 | export type ProviderRuntimeApprovalRequestedEvent = Schema.Schema.Type< | |
| 223 | typeof ProviderRuntimeApprovalRequestedEventSchema | |
| 224 | >; | |
| 225 | export type ProviderRuntimeApprovalResolvedEvent = Schema.Schema.Type< | |
| 226 | typeof ProviderRuntimeApprovalResolvedEventSchema | |
| 227 | >; | |
| 228 | export type ProviderRuntimeCheckpointCapturedEvent = Schema.Schema.Type< | |
| 229 | typeof ProviderRuntimeCheckpointCapturedEventSchema | |
| 230 | >; | |
| 231 | export type ProviderRuntimeErrorEvent = Schema.Schema.Type<typeof ProviderRuntimeErrorEventSchema>; | |
| 232 | export type ProviderRuntimeEvent = Schema.Schema.Type<typeof ProviderRuntimeEventSchema>; |
packages/contracts/src/server.ts+15-11| ... | ... | @@ -1,17 +1,21 @@ |
| 1 | import { z } from "zod"; | |
| 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; | |
| 3 | 3 | |
| 4 | export const serverConfigSchema = z.object({ | |
| 5 | cwd: z.string().min(1), | |
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 4 | export const ServerConfig = Schema.Struct({ | |
| 5 | cwd: Schema.NonEmptyString, | |
| 6 | keybindings: ResolvedKeybindingsConfig, | |
| 7 | 7 | }); |
| 8 | export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>; | |
| 8 | 9 | |
| 9 | export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; | |
| 10 | export const ServerUpsertKeybindingInput = KeybindingRule; | |
| 11 | export type ServerUpsertKeybindingInput = Schema.Codec.Encoded< | |
| 12 | typeof ServerUpsertKeybindingInput | |
| 13 | >; | |
| 10 | 14 | |
| 11 | export const serverUpsertKeybindingResultSchema = z.object({ | |
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | |
| 15 | export const ServerUpsertKeybindingResult = Schema.Struct({ | |
| 16 | keybindings: ResolvedKeybindingsConfig, | |
| 13 | 17 | }); |
| 18 | export type ServerUpsertKeybindingResult = Schema.Codec.Encoded< | |
| 19 | typeof ServerUpsertKeybindingResult | |
| 20 | >; | |
| 14 | 21 | |
| 15 | export type ServerConfig = z.infer<typeof serverConfigSchema>; | |
| 16 | export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>; | |
| 17 | export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>; |
packages/contracts/src/terminal.test.ts+127-102| ... | ... | @@ -1,40 +1,56 @@ |
| 1 | import { Schema } from "effect"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | DEFAULT_TERMINAL_ID, |
| 5 | terminalClearInputSchema, | |
| 6 | terminalCloseInputSchema, | |
| 7 | terminalEventSchema, | |
| 8 | terminalOpenInputSchema, | |
| 9 | terminalResizeInputSchema, | |
| 10 | terminalSessionSnapshotSchema, | |
| 11 | terminalThreadInputSchema, | |
| 12 | terminalWriteInputSchema, | |
| 6 | TerminalClearInput, | |
| 7 | TerminalCloseInput, | |
| 8 | TerminalEvent, | |
| 9 | TerminalOpenInput, | |
| 10 | TerminalResizeInput, | |
| 11 | TerminalSessionSnapshot, | |
| 12 | TerminalThreadInput, | |
| 13 | TerminalWriteInput, | |
| 13 | 14 | } from "./terminal"; |
| 14 | 15 | |
| 15 | describe("terminalOpenInputSchema", () => { | |
| 16 | function 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 | ||
| 20 | function 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 | ||
| 29 | describe("TerminalOpenInput", () => { | |
| 16 | 30 | it("accepts valid open input", () => { |
| 17 | const result = terminalOpenInputSchema.safeParse({ | |
| 18 | threadId: "thread-1", | |
| 19 | cwd: "/tmp/project", | |
| 20 | cols: 120, | |
| 21 | rows: 40, | |
| 22 | }); | |
| 23 | expect(result.success).toBe(true); | |
| 31 | expect( | |
| 32 | decodes(TerminalOpenInput, { | |
| 33 | threadId: "thread-1", | |
| 34 | cwd: "/tmp/project", | |
| 35 | cols: 120, | |
| 36 | rows: 40, | |
| 37 | }), | |
| 38 | ).toBe(true); | |
| 24 | 39 | }); |
| 25 | 40 | |
| 26 | 41 | it("rejects invalid bounds", () => { |
| 27 | const result = terminalOpenInputSchema.safeParse({ | |
| 28 | threadId: "thread-1", | |
| 29 | cwd: "/tmp/project", | |
| 30 | cols: 10, | |
| 31 | rows: 2, | |
| 32 | }); | |
| 33 | expect(result.success).toBe(false); | |
| 42 | expect( | |
| 43 | decodes(TerminalOpenInput, { | |
| 44 | threadId: "thread-1", | |
| 45 | cwd: "/tmp/project", | |
| 46 | cols: 10, | |
| 47 | rows: 2, | |
| 48 | }), | |
| 49 | ).toBe(false); | |
| 34 | 50 | }); |
| 35 | 51 | |
| 36 | 52 | it("defaults terminalId when missing", () => { |
| 37 | const parsed = terminalOpenInputSchema.parse({ | |
| 53 | const parsed = decodeSync(TerminalOpenInput, { | |
| 38 | 54 | threadId: "thread-1", |
| 39 | 55 | cwd: "/tmp/project", |
| 40 | 56 | cols: 100, |
| ... | ... | @@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => { |
| 44 | 60 | }); |
| 45 | 61 | |
| 46 | 62 | it("accepts optional env overrides", () => { |
| 47 | const parsed = terminalOpenInputSchema.parse({ | |
| 63 | const parsed = decodeSync(TerminalOpenInput, { | |
| 48 | 64 | threadId: "thread-1", |
| 49 | 65 | cwd: "/tmp/project", |
| 50 | 66 | cols: 100, |
| ... | ... | @@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => { |
| 61 | 77 | }); |
| 62 | 78 | |
| 63 | 79 | it("rejects invalid env keys", () => { |
| 64 | const result = terminalOpenInputSchema.safeParse({ | |
| 65 | threadId: "thread-1", | |
| 66 | cwd: "/tmp/project", | |
| 67 | cols: 100, | |
| 68 | rows: 24, | |
| 69 | env: { | |
| 70 | "bad-key": "1", | |
| 71 | }, | |
| 72 | }); | |
| 73 | expect(result.success).toBe(false); | |
| 80 | expect( | |
| 81 | decodes(TerminalOpenInput, { | |
| 82 | threadId: "thread-1", | |
| 83 | cwd: "/tmp/project", | |
| 84 | cols: 100, | |
| 85 | rows: 24, | |
| 86 | env: { | |
| 87 | "bad-key": "1", | |
| 88 | }, | |
| 89 | }), | |
| 90 | ).toBe(false); | |
| 74 | 91 | }); |
| 75 | 92 | }); |
| 76 | 93 | |
| 77 | describe("terminalWriteInputSchema", () => { | |
| 94 | describe("TerminalWriteInput", () => { | |
| 78 | 95 | it("accepts non-empty data", () => { |
| 79 | const result = terminalWriteInputSchema.safeParse({ | |
| 80 | threadId: "thread-1", | |
| 81 | data: "echo hello\n", | |
| 82 | }); | |
| 83 | expect(result.success).toBe(true); | |
| 96 | expect( | |
| 97 | decodes(TerminalWriteInput, { | |
| 98 | threadId: "thread-1", | |
| 99 | data: "echo hello\n", | |
| 100 | }), | |
| 101 | ).toBe(true); | |
| 84 | 102 | }); |
| 85 | 103 | |
| 86 | 104 | it("rejects empty data", () => { |
| 87 | const result = terminalWriteInputSchema.safeParse({ | |
| 88 | threadId: "thread-1", | |
| 89 | data: "", | |
| 90 | }); | |
| 91 | expect(result.success).toBe(false); | |
| 105 | expect( | |
| 106 | decodes(TerminalWriteInput, { | |
| 107 | threadId: "thread-1", | |
| 108 | data: "", | |
| 109 | }), | |
| 110 | ).toBe(false); | |
| 92 | 111 | }); |
| 93 | 112 | }); |
| 94 | 113 | |
| 95 | describe("terminalThreadInputSchema", () => { | |
| 114 | describe("TerminalThreadInput", () => { | |
| 96 | 115 | it("trims thread ids", () => { |
| 97 | const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " }); | |
| 116 | const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " }); | |
| 98 | 117 | expect(parsed.threadId).toBe("thread-1"); |
| 99 | 118 | }); |
| 100 | 119 | }); |
| 101 | 120 | |
| 102 | describe("terminalResizeInputSchema", () => { | |
| 121 | describe("TerminalResizeInput", () => { | |
| 103 | 122 | it("accepts valid size", () => { |
| 104 | const result = terminalResizeInputSchema.safeParse({ | |
| 105 | threadId: "thread-1", | |
| 106 | cols: 80, | |
| 107 | rows: 24, | |
| 108 | }); | |
| 109 | expect(result.success).toBe(true); | |
| 123 | expect( | |
| 124 | decodes(TerminalResizeInput, { | |
| 125 | threadId: "thread-1", | |
| 126 | cols: 80, | |
| 127 | rows: 24, | |
| 128 | }), | |
| 129 | ).toBe(true); | |
| 110 | 130 | }); |
| 111 | 131 | }); |
| 112 | 132 | |
| 113 | describe("terminalClearInputSchema", () => { | |
| 133 | describe("TerminalClearInput", () => { | |
| 114 | 134 | it("defaults terminal id", () => { |
| 115 | const parsed = terminalClearInputSchema.parse({ | |
| 135 | const parsed = decodeSync(TerminalClearInput, { | |
| 116 | 136 | threadId: "thread-1", |
| 117 | 137 | }); |
| 118 | 138 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 119 | 139 | }); |
| 120 | 140 | }); |
| 121 | 141 | |
| 122 | describe("terminalCloseInputSchema", () => { | |
| 142 | describe("TerminalCloseInput", () => { | |
| 123 | 143 | it("accepts optional deleteHistory", () => { |
| 124 | const result = terminalCloseInputSchema.safeParse({ | |
| 125 | threadId: "thread-1", | |
| 126 | deleteHistory: true, | |
| 127 | }); | |
| 128 | expect(result.success).toBe(true); | |
| 144 | expect( | |
| 145 | decodes(TerminalCloseInput, { | |
| 146 | threadId: "thread-1", | |
| 147 | deleteHistory: true, | |
| 148 | }), | |
| 149 | ).toBe(true); | |
| 129 | 150 | }); |
| 130 | 151 | }); |
| 131 | 152 | |
| 132 | describe("terminalSessionSnapshotSchema", () => { | |
| 153 | describe("TerminalSessionSnapshot", () => { | |
| 133 | 154 | it("accepts running snapshots", () => { |
| 134 | const result = terminalSessionSnapshotSchema.safeParse({ | |
| 135 | threadId: "thread-1", | |
| 136 | terminalId: DEFAULT_TERMINAL_ID, | |
| 137 | cwd: "/tmp/project", | |
| 138 | status: "running", | |
| 139 | pid: 1234, | |
| 140 | history: "hello\n", | |
| 141 | exitCode: null, | |
| 142 | exitSignal: null, | |
| 143 | updatedAt: new Date().toISOString(), | |
| 144 | }); | |
| 145 | expect(result.success).toBe(true); | |
| 155 | expect( | |
| 156 | decodes(TerminalSessionSnapshot, { | |
| 157 | threadId: "thread-1", | |
| 158 | terminalId: DEFAULT_TERMINAL_ID, | |
| 159 | cwd: "/tmp/project", | |
| 160 | status: "running", | |
| 161 | pid: 1234, | |
| 162 | history: "hello\n", | |
| 163 | exitCode: null, | |
| 164 | exitSignal: null, | |
| 165 | updatedAt: new Date().toISOString(), | |
| 166 | }), | |
| 167 | ).toBe(true); | |
| 146 | 168 | }); |
| 147 | 169 | }); |
| 148 | 170 | |
| 149 | describe("terminalEventSchema", () => { | |
| 171 | describe("TerminalEvent", () => { | |
| 150 | 172 | it("accepts output events", () => { |
| 151 | const result = terminalEventSchema.safeParse({ | |
| 152 | type: "output", | |
| 153 | threadId: "thread-1", | |
| 154 | terminalId: DEFAULT_TERMINAL_ID, | |
| 155 | createdAt: new Date().toISOString(), | |
| 156 | data: "line\n", | |
| 157 | }); | |
| 158 | expect(result.success).toBe(true); | |
| 173 | expect( | |
| 174 | decodes(TerminalEvent, { | |
| 175 | type: "output", | |
| 176 | threadId: "thread-1", | |
| 177 | terminalId: DEFAULT_TERMINAL_ID, | |
| 178 | createdAt: new Date().toISOString(), | |
| 179 | data: "line\n", | |
| 180 | }), | |
| 181 | ).toBe(true); | |
| 159 | 182 | }); |
| 160 | 183 | |
| 161 | 184 | it("accepts exited events", () => { |
| 162 | const result = terminalEventSchema.safeParse({ | |
| 163 | type: "exited", | |
| 164 | threadId: "thread-1", | |
| 165 | terminalId: DEFAULT_TERMINAL_ID, | |
| 166 | createdAt: new Date().toISOString(), | |
| 167 | exitCode: 0, | |
| 168 | exitSignal: null, | |
| 169 | }); | |
| 170 | expect(result.success).toBe(true); | |
| 185 | expect( | |
| 186 | decodes(TerminalEvent, { | |
| 187 | type: "exited", | |
| 188 | threadId: "thread-1", | |
| 189 | terminalId: DEFAULT_TERMINAL_ID, | |
| 190 | createdAt: new Date().toISOString(), | |
| 191 | exitCode: 0, | |
| 192 | exitSignal: null, | |
| 193 | }), | |
| 194 | ).toBe(true); | |
| 171 | 195 | }); |
| 172 | 196 | |
| 173 | 197 | it("accepts activity events", () => { |
| 174 | const result = terminalEventSchema.safeParse({ | |
| 175 | type: "activity", | |
| 176 | threadId: "thread-1", | |
| 177 | terminalId: DEFAULT_TERMINAL_ID, | |
| 178 | createdAt: new Date().toISOString(), | |
| 179 | hasRunningSubprocess: true, | |
| 180 | }); | |
| 181 | expect(result.success).toBe(true); | |
| 198 | expect( | |
| 199 | decodes(TerminalEvent, { | |
| 200 | type: "activity", | |
| 201 | threadId: "thread-1", | |
| 202 | terminalId: DEFAULT_TERMINAL_ID, | |
| 203 | createdAt: new Date().toISOString(), | |
| 204 | hasRunningSubprocess: true, | |
| 205 | }), | |
| 206 | ).toBe(true); | |
| 182 | 207 | }); |
| 183 | 208 | }); |
packages/contracts/src/terminal.ts+120-84| ... | ... | @@ -1,119 +1,155 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | export const DEFAULT_TERMINAL_ID = "default"; |
| 4 | 4 | |
| 5 | const terminalColsSchema = z.number().int().min(20).max(400); | |
| 6 | const terminalRowsSchema = z.number().int().min(5).max(200); | |
| 7 | const terminalIdSchema = z.string().trim().min(1).max(128); | |
| 8 | const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); | |
| 9 | const terminalEnvValueSchema = z.string().max(8_192); | |
| 10 | const terminalEnvSchema = z | |
| 11 | .record(terminalEnvKeySchema, terminalEnvValueSchema) | |
| 12 | .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); | |
| 13 | ||
| 14 | export const terminalThreadInputSchema = z.object({ | |
| 15 | threadId: z.string().trim().min(1), | |
| 5 | const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty()); | |
| 6 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( | |
| 7 | Schema.isLessThanOrEqualTo(400), | |
| 8 | ); | |
| 9 | const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( | |
| 10 | Schema.isLessThanOrEqualTo(200), | |
| 11 | ); | |
| 12 | const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128)); | |
| 13 | const TerminalEnvKeySchema = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check( | |
| 14 | Schema.isMaxLength(128), | |
| 15 | ); | |
| 16 | const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192)); | |
| 17 | const 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 | ||
| 26 | const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( | |
| 27 | Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID), | |
| 28 | ); | |
| 29 | ||
| 30 | export const TerminalThreadInput = Schema.Struct({ | |
| 31 | threadId: TrimmedNonEmptyStringSchema, | |
| 16 | 32 | }); |
| 33 | export type TerminalThreadInput = Schema.Codec.Encoded<typeof TerminalThreadInput>; | |
| 17 | 34 | |
| 18 | export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ | |
| 19 | terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), | |
| 35 | export const TerminalSessionInput = Schema.Struct({ | |
| 36 | ...TerminalThreadInput.fields, | |
| 37 | terminalId: TerminalIdWithDefaultSchema, | |
| 20 | 38 | }); |
| 21 | ||
| 22 | export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ | |
| 23 | cwd: z.string().trim().min(1), | |
| 24 | cols: terminalColsSchema.optional(), | |
| 25 | rows: terminalRowsSchema.optional(), | |
| 26 | env: terminalEnvSchema.optional(), | |
| 39 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; | |
| 40 | ||
| 41 | export const TerminalOpenInput = Schema.Struct({ | |
| 42 | ...TerminalSessionInput.fields, | |
| 43 | cwd: TrimmedNonEmptyStringSchema, | |
| 44 | cols: Schema.optional(TerminalColsSchema), | |
| 45 | rows: Schema.optional(TerminalRowsSchema), | |
| 46 | env: Schema.optional(TerminalEnvSchema), | |
| 27 | 47 | }); |
| 48 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; | |
| 28 | 49 | |
| 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ | |
| 30 | data: z.string().min(1).max(65_536), | |
| 50 | export const TerminalWriteInput = Schema.Struct({ | |
| 51 | ...TerminalSessionInput.fields, | |
| 52 | data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), | |
| 31 | 53 | }); |
| 54 | export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>; | |
| 32 | 55 | |
| 33 | export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ | |
| 34 | cols: terminalColsSchema, | |
| 35 | rows: terminalRowsSchema, | |
| 56 | export const TerminalResizeInput = Schema.Struct({ | |
| 57 | ...TerminalSessionInput.fields, | |
| 58 | cols: TerminalColsSchema, | |
| 59 | rows: TerminalRowsSchema, | |
| 36 | 60 | }); |
| 61 | export type TerminalResizeInput = Schema.Codec.Encoded<typeof TerminalResizeInput>; | |
| 37 | 62 | |
| 38 | export const terminalClearInputSchema = terminalSessionInputSchema; | |
| 63 | export const TerminalClearInput = TerminalSessionInput; | |
| 64 | export type TerminalClearInput = Schema.Codec.Encoded<typeof TerminalClearInput>; | |
| 39 | 65 | |
| 40 | export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ | |
| 41 | terminalId: terminalIdSchema.optional(), | |
| 42 | deleteHistory: z.boolean().optional(), | |
| 66 | export const TerminalRestartInput = Schema.Struct({ | |
| 67 | ...TerminalThreadInput.fields, | |
| 68 | cwd: TrimmedNonEmptyStringSchema, | |
| 69 | cols: TerminalColsSchema, | |
| 70 | rows: TerminalRowsSchema, | |
| 71 | env: Schema.optional(TerminalEnvSchema), | |
| 43 | 72 | }); |
| 44 | 73 | |
| 45 | export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]); | |
| 46 | ||
| 47 | export const terminalSessionSnapshotSchema = z.object({ | |
| 48 | threadId: z.string().min(1), | |
| 49 | terminalId: z.string().min(1), | |
| 50 | cwd: z.string().min(1), | |
| 51 | status: terminalSessionStatusSchema, | |
| 52 | pid: z.number().int().positive().nullable(), | |
| 53 | history: z.string(), | |
| 54 | exitCode: z.number().int().nullable(), | |
| 55 | exitSignal: z.number().int().nullable(), | |
| 56 | updatedAt: z.string().datetime(), | |
| 74 | export const TerminalCloseInput = Schema.Struct({ | |
| 75 | ...TerminalThreadInput.fields, | |
| 76 | terminalId: Schema.optional(TerminalIdSchema), | |
| 77 | deleteHistory: Schema.optional(Schema.Boolean), | |
| 78 | }); | |
| 79 | export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>; | |
| 80 | ||
| 81 | export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); | |
| 82 | export type TerminalSessionStatus = typeof TerminalSessionStatus.Type; | |
| 83 | ||
| 84 | export const TerminalSessionSnapshot = Schema.Struct({ | |
| 85 | threadId: Schema.String.check(Schema.isNonEmpty()), | |
| 86 | terminalId: Schema.String.check(Schema.isNonEmpty()), | |
| 87 | cwd: Schema.String.check(Schema.isNonEmpty()), | |
| 88 | status: TerminalSessionStatus, | |
| 89 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), | |
| 90 | history: Schema.String, | |
| 91 | exitCode: Schema.NullOr(Schema.Int), | |
| 92 | exitSignal: Schema.NullOr(Schema.Int), | |
| 93 | updatedAt: Schema.String, | |
| 57 | 94 | }); |
| 95 | export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; | |
| 58 | 96 | |
| 59 | const terminalEventBaseSchema = z.object({ | |
| 60 | threadId: z.string().min(1), | |
| 61 | terminalId: z.string().min(1), | |
| 62 | createdAt: z.string().datetime(), | |
| 97 | const TerminalEventBaseSchema = Schema.Struct({ | |
| 98 | threadId: Schema.String.check(Schema.isNonEmpty()), | |
| 99 | terminalId: Schema.String.check(Schema.isNonEmpty()), | |
| 100 | createdAt: Schema.String, | |
| 63 | 101 | }); |
| 64 | 102 | |
| 65 | export const terminalStartedEventSchema = terminalEventBaseSchema.extend({ | |
| 66 | type: z.literal("started"), | |
| 67 | snapshot: terminalSessionSnapshotSchema, | |
| 103 | export const TerminalStartedEvent = Schema.Struct({ | |
| 104 | ...TerminalEventBaseSchema.fields, | |
| 105 | type: Schema.Literal("started"), | |
| 106 | snapshot: TerminalSessionSnapshot, | |
| 68 | 107 | }); |
| 69 | 108 | |
| 70 | export const terminalOutputEventSchema = terminalEventBaseSchema.extend({ | |
| 71 | type: z.literal("output"), | |
| 72 | data: z.string(), | |
| 109 | export const TerminalOutputEvent = Schema.Struct({ | |
| 110 | ...TerminalEventBaseSchema.fields, | |
| 111 | type: Schema.Literal("output"), | |
| 112 | data: Schema.String, | |
| 73 | 113 | }); |
| 74 | 114 | |
| 75 | export const terminalExitedEventSchema = terminalEventBaseSchema.extend({ | |
| 76 | type: z.literal("exited"), | |
| 77 | exitCode: z.number().int().nullable(), | |
| 78 | exitSignal: z.number().int().nullable(), | |
| 115 | export const TerminalExitedEvent = Schema.Struct({ | |
| 116 | ...TerminalEventBaseSchema.fields, | |
| 117 | type: Schema.Literal("exited"), | |
| 118 | exitCode: Schema.NullOr(Schema.Int), | |
| 119 | exitSignal: Schema.NullOr(Schema.Int), | |
| 79 | 120 | }); |
| 80 | 121 | |
| 81 | export const terminalErrorEventSchema = terminalEventBaseSchema.extend({ | |
| 82 | type: z.literal("error"), | |
| 83 | message: z.string().min(1), | |
| 122 | export const TerminalErrorEvent = Schema.Struct({ | |
| 123 | ...TerminalEventBaseSchema.fields, | |
| 124 | type: Schema.Literal("error"), | |
| 125 | message: Schema.String.check(Schema.isNonEmpty()), | |
| 84 | 126 | }); |
| 85 | 127 | |
| 86 | export const terminalClearedEventSchema = terminalEventBaseSchema.extend({ | |
| 87 | type: z.literal("cleared"), | |
| 128 | export const TerminalClearedEvent = Schema.Struct({ | |
| 129 | ...TerminalEventBaseSchema.fields, | |
| 130 | type: Schema.Literal("cleared"), | |
| 88 | 131 | }); |
| 89 | 132 | |
| 90 | export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({ | |
| 91 | type: z.literal("restarted"), | |
| 92 | snapshot: terminalSessionSnapshotSchema, | |
| 133 | export const TerminalRestartedEvent = Schema.Struct({ | |
| 134 | ...TerminalEventBaseSchema.fields, | |
| 135 | type: Schema.Literal("restarted"), | |
| 136 | snapshot: TerminalSessionSnapshot, | |
| 93 | 137 | }); |
| 94 | 138 | |
| 95 | export const terminalActivityEventSchema = terminalEventBaseSchema.extend({ | |
| 96 | type: z.literal("activity"), | |
| 97 | hasRunningSubprocess: z.boolean(), | |
| 139 | export const TerminalActivityEvent = Schema.Struct({ | |
| 140 | ...TerminalEventBaseSchema.fields, | |
| 141 | type: Schema.Literal("activity"), | |
| 142 | hasRunningSubprocess: Schema.Boolean, | |
| 98 | 143 | }); |
| 99 | 144 | |
| 100 | export const terminalEventSchema = z.discriminatedUnion("type", [ | |
| 101 | terminalStartedEventSchema, | |
| 102 | terminalOutputEventSchema, | |
| 103 | terminalExitedEventSchema, | |
| 104 | terminalErrorEventSchema, | |
| 105 | terminalClearedEventSchema, | |
| 106 | terminalRestartedEventSchema, | |
| 107 | terminalActivityEventSchema, | |
| 145 | export const TerminalEvent = Schema.Union([ | |
| 146 | TerminalStartedEvent, | |
| 147 | TerminalOutputEvent, | |
| 148 | TerminalExitedEvent, | |
| 149 | TerminalErrorEvent, | |
| 150 | TerminalClearedEvent, | |
| 151 | TerminalRestartedEvent, | |
| 152 | TerminalActivityEvent, | |
| 108 | 153 | ]); |
| 154 | export type TerminalEvent = typeof TerminalEvent.Type; | |
| 109 | 155 | |
| 110 | export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>; | |
| 111 | export type TerminalSessionInput = z.input<typeof terminalSessionInputSchema>; | |
| 112 | export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>; | |
| 113 | export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>; | |
| 114 | export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>; | |
| 115 | export type TerminalClearInput = z.input<typeof terminalClearInputSchema>; | |
| 116 | export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>; | |
| 117 | export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>; | |
| 118 | export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>; | |
| 119 | export type TerminalEvent = z.infer<typeof terminalEventSchema>; |
packages/contracts/src/todo.test.ts deleted-52| ... | ... | @@ -1,52 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { newTodoInputSchema, todoSchema } from "./todo"; | |
| 4 | ||
| 5 | describe("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 | ||
| 37 | describe("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 @@ |
| 1 | import { z } from "zod"; | |
| 2 | ||
| 3 | export 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 | ||
| 10 | export const todoListSchema = z.array(todoSchema); | |
| 11 | ||
| 12 | export const newTodoInputSchema = z.object({ | |
| 13 | title: z.string().trim().min(1).max(280), | |
| 14 | }); | |
| 15 | ||
| 16 | export const todoIdSchema = z.string().min(1); | |
| 17 | ||
| 18 | export type Todo = z.infer<typeof todoSchema>; | |
| 19 | export type NewTodoInput = z.infer<typeof newTodoInputSchema>; |
packages/contracts/src/ws.ts+89-43| ... | ... | @@ -1,24 +1,43 @@ |
| 1 | import { z } from "zod"; | |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import { | |
| 4 | ClientOrchestrationCommand, | |
| 5 | ORCHESTRATION_WS_METHODS, | |
| 6 | OrchestrationGetSnapshotInput, | |
| 7 | OrchestrationGetTurnDiffInput, | |
| 8 | OrchestrationReplayEventsInput, | |
| 9 | } from "./orchestration"; | |
| 10 | import { | |
| 11 | GitCheckoutInput, | |
| 12 | GitCreateBranchInput, | |
| 13 | GitCreateWorktreeInput, | |
| 14 | GitInitInput, | |
| 15 | GitListBranchesInput, | |
| 16 | GitPullInput, | |
| 17 | GitRemoveWorktreeInput, | |
| 18 | GitRunStackedActionInput, | |
| 19 | GitStatusInput, | |
| 20 | } from "./git"; | |
| 21 | import { | |
| 22 | TerminalClearInput, | |
| 23 | TerminalCloseInput, | |
| 24 | TerminalOpenInput, | |
| 25 | TerminalResizeInput, | |
| 26 | TerminalRestartInput, | |
| 27 | TerminalWriteInput, | |
| 28 | } from "./terminal"; | |
| 29 | import { KeybindingRule } from "./keybindings"; | |
| 30 | import { ProjectSearchEntriesInput } from "./project"; | |
| 31 | import { OpenInEditorInput } from "./editor"; | |
| 2 | 32 | |
| 3 | 33 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 4 | 34 | |
| 5 | 35 | export 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 | 36 | // Project registry methods |
| 17 | 37 | projectsList: "projects.list", |
| 18 | 38 | projectsAdd: "projects.add", |
| 19 | 39 | projectsRemove: "projects.remove", |
| 20 | 40 | projectsSearchEntries: "projects.searchEntries", |
| 21 | projectsUpdateScripts: "projects.updateScripts", | |
| 22 | 41 | |
| 23 | 42 | // Shell methods |
| 24 | 43 | shellOpenInEditor: "shell.openInEditor", |
| ... | ... | @@ -54,49 +73,76 @@ export const WS_CHANNELS = { |
| 54 | 73 | serverWelcome: "server.welcome", |
| 55 | 74 | } as const; |
| 56 | 75 | |
| 57 | // ── Client → Server (request) ──────────────────────────────────────── | |
| 76 | // -- Tagged Union of all request body schemas ───────────────────────── | |
| 58 | 77 | |
| 59 | export const wsRequestSchema = z.object({ | |
| 60 | id: z.string().min(1), | |
| 61 | method: z.string().min(1), | |
| 62 | params: z.unknown().optional(), | |
| 63 | }); | |
| 78 | export const WebSocketRequestBody = Schema.TaggedUnion({ | |
| 79 | // Orchestration methods | |
| 80 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, | |
| 81 | [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, | |
| 82 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, | |
| 83 | [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, | |
| 64 | 84 | |
| 65 | export type WsRequest = z.infer<typeof wsRequestSchema>; | |
| 85 | // Project Search | |
| 86 | [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields, | |
| 66 | 87 | |
| 67 | // ── Server → Client (response to request) ──────────────────────────── | |
| 88 | // Shell methods | |
| 89 | [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields, | |
| 68 | 90 | |
| 69 | export const wsResponseSchema = z.object({ | |
| 70 | id: z.string().min(1), | |
| 71 | result: z.unknown().optional(), | |
| 72 | error: z | |
| 73 | .object({ | |
| 74 | message: z.string(), | |
| 75 | }) | |
| 76 | .optional(), | |
| 77 | }); | |
| 91 | // Git methods | |
| 92 | [WS_METHODS.gitPull]: GitPullInput.fields, | |
| 93 | [WS_METHODS.gitStatus]: GitStatusInput.fields, | |
| 94 | [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields, | |
| 95 | [WS_METHODS.gitListBranches]: GitListBranchesInput.fields, | |
| 96 | [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields, | |
| 97 | [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields, | |
| 98 | [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields, | |
| 99 | [WS_METHODS.gitCheckout]: GitCheckoutInput.fields, | |
| 100 | [WS_METHODS.gitInit]: GitInitInput.fields, | |
| 78 | 101 | |
| 79 | export 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, | |
| 80 | 109 | |
| 81 | // ── Server → Client (push event) ───────────────────────────────────── | |
| 110 | // Server meta | |
| 111 | [WS_METHODS.serverGetConfig]: {}, | |
| 112 | [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields, | |
| 113 | }); | |
| 82 | 114 | |
| 83 | export const wsPushSchema = z.object({ | |
| 84 | type: z.literal("push"), | |
| 85 | channel: z.string().min(1), | |
| 86 | data: z.unknown(), | |
| 115 | export const WebSocketRequest = Schema.Struct({ | |
| 116 | id: Schema.String, | |
| 117 | body: WebSocketRequestBody, | |
| 87 | 118 | }); |
| 119 | export type WebSocketRequest = typeof WebSocketRequest.Type; | |
| 120 | ||
| 88 | 121 | |
| 89 | export type WsPush = z.infer<typeof wsPushSchema>; | |
| 122 | export 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 | ||
| 132 | export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({ | |
| 133 | type: Schema.Literal("push"), | |
| 134 | channel: Schema.String, | |
| 135 | data: Schema.Unknown, | |
| 136 | }) {} | |
| 90 | 137 | |
| 91 | 138 | // ── Union of all server → client messages ───────────────────────────── |
| 92 | 139 | |
| 93 | export type WsServerMessage = WsResponse | WsPush; | |
| 140 | export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); | |
| 141 | export type WsResponse = typeof WsResponse.Type; | |
| 94 | 142 | |
| 95 | 143 | // ── Server welcome payload ─────────────────────────────────────────── |
| 96 | 144 | |
| 97 | export const wsWelcomePayloadSchema = z.object({ | |
| 98 | cwd: z.string().min(1), | |
| 99 | projectName: z.string().min(1), | |
| 100 | }); | |
| 101 | ||
| 102 | export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>; | |
| 145 | export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({ | |
| 146 | cwd: Schema.String, | |
| 147 | projectName: Schema.String, | |
| 148 | }) {} |