| 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,7 +55,7 @@ Output for every command: `DispatchResultSchema = { sequence: number }`. |
| 55 | - Input: | 55 | - Input: |
| 56 | - `commandId: CommandId` | 56 | - `commandId: CommandId` |
| 57 | - `projectId: ProjectId` | 57 | - `projectId: ProjectId` |
| 58 | - optional `{ title, workspaceRoot, defaultModel }` | 58 | - optional `{ title, workspaceRoot, defaultModel, scripts }` |
| 59 | - Output: `DispatchResult` | 59 | - Output: `DispatchResult` |
| 60 | 60 | ||
| 61 | 3. `project.delete` | 61 | 3. `project.delete` |
| ... | @@ -427,6 +427,7 @@ Rules: | ... | @@ -427,6 +427,7 @@ Rules: |
| 427 | - `title: string` | 427 | - `title: string` |
| 428 | - `workspaceRoot: string` | 428 | - `workspaceRoot: string` |
| 429 | - `defaultModel: string | null` | 429 | - `defaultModel: string | null` |
| 430 | - `scripts: ProjectScript[]` | ||
| 430 | - `createdAt: IsoDateTime` | 431 | - `createdAt: IsoDateTime` |
| 431 | - `updatedAt: IsoDateTime` | 432 | - `updatedAt: IsoDateTime` |
| 432 | - `deletedAt: IsoDateTime | null` | 433 | - `deletedAt: IsoDateTime | null` |
apps/server/integration/providerService.integration.test.ts+24-116| ... | @@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node"; | ... | @@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node"; |
| 3 | import { it, assert } from "@effect/vitest"; | 3 | import { it, assert } from "@effect/vitest"; |
| 4 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; | 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 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | 6 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; |
| 10 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 7 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 11 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 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 | import { | 10 | import { |
| 14 | ProviderService, | 11 | ProviderService, |
| 15 | type ProviderServiceShape, | 12 | type ProviderServiceShape, |
| 16 | } from "../src/provider/Services/ProviderService.ts"; | 13 | } from "../src/provider/Services/ProviderService.ts"; |
| 17 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; | 14 | import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; |
| 18 | import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts"; | 15 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; |
| 19 | import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts"; | ||
| 20 | 16 | ||
| 21 | import { | 17 | import { |
| 22 | makeTestProviderAdapterHarness, | 18 | makeTestProviderAdapterHarness, |
| ... | @@ -29,34 +25,11 @@ import { | ... | @@ -29,34 +25,11 @@ import { |
| 29 | codexTurnTextFixture, | 25 | codexTurnTextFixture, |
| 30 | } from "./fixtures/providerRuntime.ts"; | 26 | } from "./fixtures/providerRuntime.ts"; |
| 31 | 27 | ||
| 32 | const runGit = (cwd: string, args: ReadonlyArray<string>, allowNonZeroExit = false) => | 28 | const makeWorkspaceDirectory = Effect.gen(function* () { |
| 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* () { | ||
| 49 | const fs = yield* FileSystem.FileSystem; | 29 | const fs = yield* FileSystem.FileSystem; |
| 50 | const pathService = yield* Path.Path; | 30 | const pathService = yield* Path.Path; |
| 51 | const cwd = yield* fs.makeTempDirectory(); | 31 | const cwd = yield* fs.makeTempDirectory(); |
| 52 | |||
| 53 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | ||
| 54 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | ||
| 55 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | ||
| 56 | yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); | 32 | yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); |
| 57 | yield* runGit(cwd, ["add", "."]); | ||
| 58 | yield* runGit(cwd, ["commit", "-m", "Initial"]); | ||
| 59 | |||
| 60 | return cwd; | 33 | return cwd; |
| 61 | }).pipe(Effect.provide(NodeServices.layer)); | 34 | }).pipe(Effect.provide(NodeServices.layer)); |
| 62 | 35 | ||
| ... | @@ -67,7 +40,7 @@ interface IntegrationFixture { | ... | @@ -67,7 +40,7 @@ interface IntegrationFixture { |
| 67 | } | 40 | } |
| 68 | 41 | ||
| 69 | const makeIntegrationFixture = Effect.gen(function* () { | 42 | const makeIntegrationFixture = Effect.gen(function* () { |
| 70 | const cwd = yield* makeGitRepository; | 43 | const cwd = yield* makeWorkspaceDirectory; |
| 71 | const harness = yield* makeTestProviderAdapterHarness; | 44 | const harness = yield* makeTestProviderAdapterHarness; |
| 72 | 45 | ||
| 73 | const registry: typeof ProviderAdapterRegistry.Service = { | 46 | const registry: typeof ProviderAdapterRegistry.Service = { |
| ... | @@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () { | ... | @@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () { |
| 79 | }; | 52 | }; |
| 80 | 53 | ||
| 81 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | 54 | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 82 | Layer.provide(ProviderSessionRepositoryLive), | 55 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 83 | ); | 56 | ); |
| 84 | 57 | ||
| 85 | const shared = Layer.mergeAll( | 58 | const shared = Layer.mergeAll( |
| 86 | directoryLayer, | 59 | directoryLayer, |
| 87 | Layer.succeed(ProviderAdapterRegistry, registry), | 60 | Layer.succeed(ProviderAdapterRegistry, registry), |
| 88 | Layer.provide(CheckpointStoreLive, NodeServices.layer), | ||
| 89 | CheckpointRepositoryLive, | ||
| 90 | ).pipe(Layer.provide(SqlitePersistenceMemory)); | 61 | ).pipe(Layer.provide(SqlitePersistenceMemory)); |
| 91 | 62 | ||
| 92 | const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared)); | 63 | const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); |
| 93 | |||
| 94 | const layer = ProviderServiceLive.pipe( | ||
| 95 | Layer.provide(Layer.mergeAll(shared, checkpointLayer)), | ||
| 96 | Layer.merge(NodeServices.layer), | ||
| 97 | ); | ||
| 98 | 64 | ||
| 99 | return { | 65 | return { |
| 100 | cwd, | 66 | cwd, |
| ... | @@ -135,7 +101,7 @@ const runTurn = (input: { | ... | @@ -135,7 +101,7 @@ const runTurn = (input: { |
| 135 | 101 | ||
| 136 | return yield* collectEventsDuring( | 102 | return yield* collectEventsDuring( |
| 137 | input.provider.streamEvents, | 103 | input.provider.streamEvents, |
| 138 | input.response.events.length + 1, | 104 | input.response.events.length, |
| 139 | input.provider.sendTurn({ | 105 | input.provider.sendTurn({ |
| 140 | sessionId: input.sessionId, | 106 | sessionId: input.sessionId, |
| 141 | input: input.userText, | 107 | input: input.userText, |
| ... | @@ -144,7 +110,7 @@ const runTurn = (input: { | ... | @@ -144,7 +110,7 @@ const runTurn = (input: { |
| 144 | ); | 110 | ); |
| 145 | }); | 111 | }); |
| 146 | 112 | ||
| 147 | it.effect("replays typed runtime fixture and emits checkpoint.captured", () => | 113 | it.effect("replays typed runtime fixture events", () => |
| 148 | Effect.gen(function* () { | 114 | Effect.gen(function* () { |
| 149 | const fixture = yield* makeIntegrationFixture; | 115 | const fixture = yield* makeIntegrationFixture; |
| 150 | 116 | ||
| ... | @@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () => | ... | @@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () => |
| 154 | provider: "codex", | 120 | provider: "codex", |
| 155 | cwd: fixture.cwd, | 121 | cwd: fixture.cwd, |
| 156 | }); | 122 | }); |
| 157 | const threadId = session.threadId ?? ""; | 123 | assert.equal((session.threadId ?? "").length > 0, true); |
| 158 | assert.equal(threadId.length > 0, true); | ||
| 159 | 124 | ||
| 160 | const observedEvents = yield* runTurn({ | 125 | const observedEvents = yield* runTurn({ |
| 161 | provider, | 126 | provider, |
| 162 | harness: fixture.harness, | 127 | harness: fixture.harness, |
| 163 | sessionId: session.sessionId, | 128 | sessionId: session.sessionId, |
| 164 | userText: "hello", | 129 | userText: "hello", |
| 165 | response: { events: codexTurnTextFixture }, | 130 | response: { events: codexTurnTextFixture }, |
| 166 | }); | 131 | }); |
| 167 | 132 | ||
| 168 | assert.deepEqual( | 133 | assert.deepEqual( |
| 169 | observedEvents.map((event) => event.type), | 134 | observedEvents.map((event) => event.type), |
| 170 | [...codexTurnTextFixture.map((event) => event.type), "checkpoint.captured"], | 135 | codexTurnTextFixture.map((event) => event.type), |
| 171 | ); | 136 | ); |
| 172 | |||
| 173 | const checkpoints = yield* provider.listCheckpoints({ sessionId: session.sessionId }); | ||
| 174 | assert.deepEqual( | ||
| 175 | checkpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), | ||
| 176 | [0, 1], | ||
| 177 | ); | ||
| 178 | |||
| 179 | const diff = yield* provider.getCheckpointDiff({ | ||
| 180 | sessionId: session.sessionId, | ||
| 181 | fromTurnCount: 0, | ||
| 182 | toTurnCount: 1, | ||
| 183 | }); | ||
| 184 | assert.equal(diff.diff.trim(), ""); | ||
| 185 | |||
| 186 | const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); | ||
| 187 | assert.equal(turnOneRefExists, true); | ||
| 188 | }).pipe(Effect.provide(fixture.layer)); | 137 | }).pipe(Effect.provide(fixture.layer)); |
| 189 | }), | 138 | }).pipe(Effect.provide(NodeServices.layer)), |
| 190 | ); | 139 | ); |
| 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 | Effect.gen(function* () { | 142 | Effect.gen(function* () { |
| 194 | const fixture = yield* makeIntegrationFixture; | 143 | const fixture = yield* makeIntegrationFixture; |
| 195 | const { join } = yield* Path.Path; | 144 | const { join } = yield* Path.Path; |
| ... | @@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( | ... | @@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( |
| 201 | provider: "codex", | 150 | provider: "codex", |
| 202 | cwd: fixture.cwd, | 151 | cwd: fixture.cwd, |
| 203 | }); | 152 | }); |
| 204 | const threadId = session.threadId ?? ""; | 153 | assert.equal((session.threadId ?? "").length > 0, true); |
| 205 | assert.equal(threadId.length > 0, true); | ||
| 206 | 154 | ||
| 207 | const observedEvents = yield* runTurn({ | 155 | const observedEvents = yield* runTurn({ |
| 208 | provider, | 156 | provider, |
| ... | @@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( | ... | @@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( |
| 218 | 166 | ||
| 219 | assert.deepEqual( | 167 | assert.deepEqual( |
| 220 | observedEvents.map((event) => event.type), | 168 | observedEvents.map((event) => event.type), |
| 221 | [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], | 169 | codexTurnToolFixture.map((event) => event.type), |
| 222 | ); | 170 | ); |
| 223 | |||
| 224 | const diff = yield* provider.getCheckpointDiff({ | ||
| 225 | sessionId: session.sessionId, | ||
| 226 | fromTurnCount: 0, | ||
| 227 | toTurnCount: 1, | ||
| 228 | }); | ||
| 229 | assert.equal(diff.diff.includes("README.md"), true); | ||
| 230 | |||
| 231 | const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); | ||
| 232 | assert.equal(turnOneRefExists, true); | ||
| 233 | }).pipe(Effect.provide(fixture.layer)); | 171 | }).pipe(Effect.provide(fixture.layer)); |
| 234 | }).pipe(Effect.provide(NodeServices.layer)), | 172 | }).pipe(Effect.provide(NodeServices.layer)), |
| 235 | ); | 173 | ); |
| 236 | 174 | ||
| 237 | it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () => | 175 | it.effect("runs multi-turn tool/approval flow", () => |
| 238 | Effect.gen(function* () { | 176 | Effect.gen(function* () { |
| 239 | const fixture = yield* makeIntegrationFixture; | 177 | const fixture = yield* makeIntegrationFixture; |
| 240 | const { join } = yield* Path.Path; | 178 | const { join } = yield* Path.Path; |
| ... | @@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = | ... | @@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 246 | provider: "codex", | 184 | provider: "codex", |
| 247 | cwd: fixture.cwd, | 185 | cwd: fixture.cwd, |
| 248 | }); | 186 | }); |
| 249 | const threadId = session.threadId ?? ""; | 187 | assert.equal((session.threadId ?? "").length > 0, true); |
| 250 | assert.equal(threadId.length > 0, true); | ||
| 251 | 188 | ||
| 252 | const firstTurnEvents = yield* runTurn({ | 189 | const firstTurnEvents = yield* runTurn({ |
| 253 | provider, | 190 | provider, |
| ... | @@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = | ... | @@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 262 | }); | 199 | }); |
| 263 | assert.deepEqual( | 200 | assert.deepEqual( |
| 264 | firstTurnEvents.map((event) => event.type), | 201 | firstTurnEvents.map((event) => event.type), |
| 265 | [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], | 202 | codexTurnToolFixture.map((event) => event.type), |
| 266 | ); | 203 | ); |
| 267 | 204 | ||
| 268 | const secondTurnEvents = yield* runTurn({ | 205 | const secondTurnEvents = yield* runTurn({ |
| ... | @@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = | ... | @@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = |
| 278 | }); | 215 | }); |
| 279 | assert.deepEqual( | 216 | assert.deepEqual( |
| 280 | secondTurnEvents.map((event) => event.type), | 217 | secondTurnEvents.map((event) => event.type), |
| 281 | [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"], | 218 | codexTurnApprovalFixture.map((event) => event.type), |
| 282 | ); | ||
| 283 | |||
| 284 | const turnStateBeforeRevert = yield* provider.listCheckpoints({ | ||
| 285 | sessionId: session.sessionId, | ||
| 286 | }); | ||
| 287 | assert.deepEqual( | ||
| 288 | turnStateBeforeRevert.checkpoints.map((checkpoint) => checkpoint.turnCount), | ||
| 289 | [0, 1, 2], | ||
| 290 | ); | 219 | ); |
| 291 | const current = | ||
| 292 | turnStateBeforeRevert.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? | ||
| 293 | turnStateBeforeRevert.checkpoints[turnStateBeforeRevert.checkpoints.length - 1]; | ||
| 294 | assert.equal(current?.turnCount, 2); | ||
| 295 | }).pipe(Effect.provide(fixture.layer)); | 220 | }).pipe(Effect.provide(fixture.layer)); |
| 296 | }).pipe(Effect.provide(NodeServices.layer)), | 221 | }).pipe(Effect.provide(NodeServices.layer)), |
| 297 | ); | 222 | ); |
| 298 | 223 | ||
| 299 | it.effect("reverts to turn 1 and prunes later checkpoint refs", () => | 224 | it.effect("rolls back provider conversation state only", () => |
| 300 | Effect.gen(function* () { | 225 | Effect.gen(function* () { |
| 301 | const fixture = yield* makeIntegrationFixture; | 226 | const fixture = yield* makeIntegrationFixture; |
| 302 | const { join } = yield* Path.Path; | 227 | const { join } = yield* Path.Path; |
| ... | @@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => | ... | @@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => |
| 308 | provider: "codex", | 233 | provider: "codex", |
| 309 | cwd: fixture.cwd, | 234 | cwd: fixture.cwd, |
| 310 | }); | 235 | }); |
| 311 | const threadId = session.threadId ?? ""; | 236 | assert.equal((session.threadId ?? "").length > 0, true); |
| 312 | assert.equal(threadId.length > 0, true); | ||
| 313 | 237 | ||
| 314 | yield* runTurn({ | 238 | yield* runTurn({ |
| 315 | provider, | 239 | provider, |
| ... | @@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => | ... | @@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => |
| 335 | }, | 259 | }, |
| 336 | }); | 260 | }); |
| 337 | 261 | ||
| 338 | const turnTwoRef = checkpointRefForTurn(threadId, 2); | 262 | yield* provider.rollbackConversation({ |
| 339 | const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef); | ||
| 340 | assert.equal(turnTwoRefBefore, true); | ||
| 341 | |||
| 342 | const revert = yield* provider.revertToCheckpoint({ | ||
| 343 | sessionId: session.sessionId, | 263 | sessionId: session.sessionId, |
| 344 | turnCount: 1, | 264 | numTurns: 1, |
| 345 | }); | 265 | }); |
| 346 | assert.equal(revert.rolledBackTurns, 1); | ||
| 347 | 266 | ||
| 348 | const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); | 267 | const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); |
| 349 | assert.deepEqual(rollbackCalls, [1]); | 268 | assert.deepEqual(rollbackCalls, [1]); |
| 350 | 269 | ||
| 351 | const readme = yield* readFileString(join(fixture.cwd, "README.md")); | 270 | const readme = yield* readFileString(join(fixture.cwd, "README.md")); |
| 352 | assert.equal(readme, "v2\n"); | 271 | assert.equal(readme, "v3\n"); |
| 353 | |||
| 354 | const afterRevertCheckpoints = yield* provider.listCheckpoints({ | ||
| 355 | sessionId: session.sessionId, | ||
| 356 | }); | ||
| 357 | assert.deepEqual( | ||
| 358 | afterRevertCheckpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), | ||
| 359 | [0, 1], | ||
| 360 | ); | ||
| 361 | |||
| 362 | const turnTwoRefAfter = yield* gitRefExists(fixture.cwd, turnTwoRef); | ||
| 363 | assert.equal(turnTwoRefAfter, false); | ||
| 364 | }).pipe(Effect.provide(fixture.layer)); | 272 | }).pipe(Effect.provide(fixture.layer)); |
| 365 | }).pipe(Effect.provide(NodeServices.layer)), | 273 | }).pipe(Effect.provide(NodeServices.layer)), |
| 366 | ); | 274 | ); |
apps/server/src/checkpointing/Errors.ts+5-54| ... | @@ -1,39 +1,6 @@ | ... | @@ -1,39 +1,6 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts"; | 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; |
| 3 | 3 | import { GitCommandError } from "../git/Errors.ts"; | |
| 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 | } | ||
| 37 | 4 | ||
| 38 | /** | 5 | /** |
| 39 | * CheckpointUnavailableError - Expected checkpoint does not exist. | 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. |
| ... | @@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin | ... | @@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass<Checkpoin |
| 68 | } | 35 | } |
| 69 | } | 36 | } |
| 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 | * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. | 39 | * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. |
| 89 | */ | 40 | */ |
| ... | @@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint | ... | @@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint |
| 116 | } | 67 | } |
| 117 | 68 | ||
| 118 | export type CheckpointStoreError = | 69 | export type CheckpointStoreError = |
| 119 | | CheckpointGitCommandError | 70 | | GitCommandError |
| 71 | | CheckpointInvariantError | ||
| 120 | | CheckpointUnavailableError | 72 | | CheckpointUnavailableError |
| 121 | | CheckpointRepositoryError; | 73 | | CheckpointRepositoryError; |
| 122 | 74 | ||
| 123 | export type CheckpointServiceError = | 75 | export type CheckpointServiceError = |
| 124 | | CheckpointStoreError | 76 | | CheckpointStoreError |
| 125 | | CheckpointMetadataRepositoryError | 77 | | ProjectionRepositoryError |
| 126 | | CheckpointServiceValidationError | ||
| 127 | | CheckpointSessionNotFoundError | 78 | | CheckpointSessionNotFoundError |
| 128 | | CheckpointInvariantError; | 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,162 +9,83 @@ |
| 9 | * | 9 | * |
| 10 | * @module CheckpointStoreLive | 10 | * @module CheckpointStoreLive |
| 11 | */ | 11 | */ |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | ||
| 13 | import { randomUUID } from "node:crypto"; | 12 | import { randomUUID } from "node:crypto"; |
| 14 | import os from "node:os"; | 13 | |
| 15 | import path from "node:path"; | 14 | import { Effect, Layer, FileSystem, Path } from "effect"; |
| 16 | 15 | ||
| 17 | import { Effect, Layer } from "effect"; | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 18 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 19 | |||
| 20 | import { | ||
| 21 | CheckpointGitCommandError, | ||
| 22 | CheckpointRepositoryError, | ||
| 23 | CheckpointUnavailableError, | ||
| 24 | type CheckpointStoreError, | ||
| 25 | } from "../Errors.ts"; | ||
| 26 | import { GitCommandError } from "../../git/Errors.ts"; | 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 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | 20 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 29 | 21 | import { CheckpointRef } from "@t3tools/contracts"; | |
| 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 | } | ||
| 136 | 22 | ||
| 137 | const makeCheckpointStore = Effect.gen(function* () { | 23 | const makeCheckpointStore = Effect.gen(function* () { |
| 138 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 24 | const fs = yield* FileSystem.FileSystem; |
| 139 | const runGitWithSpawner = (input: RunGitInput) => | 25 | const path = yield* Path.Path; |
| 140 | runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); | 26 | const git = yield* GitService; |
| 141 | const resolveHeadCommitWithSpawner = (cwd: string) => | 27 | |
| 142 | resolveHeadCommit(cwd).pipe( | 28 | const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> => |
| 143 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 29 | git |
| 144 | ); | 30 | .execute({ |
| 145 | const hasHeadCommitWithSpawner = (cwd: string) => | 31 | operation: "CheckpointStore.resolveHeadCommit", |
| 146 | hasHeadCommit(cwd).pipe( | 32 | cwd, |
| 147 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 33 | args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], |
| 148 | ); | 34 | allowNonZeroExit: true, |
| 149 | const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) => | 35 | }) |
| 150 | resolveCheckpointCommit(cwd, checkpointRef).pipe( | 36 | .pipe( |
| 151 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 37 | Effect.map((result) => { |
| 152 | ); | 38 | if (result.code !== 0) { |
| 39 | return null; | ||
| 40 | } | ||
| 41 | const commit = result.stdout.trim(); | ||
| 42 | return commit.length > 0 ? commit : null; | ||
| 43 | }), | ||
| 44 | ); | ||
| 153 | 45 | ||
| 154 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | 46 | const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> => |
| 155 | Effect.gen(function* () { | 47 | git |
| 156 | const normalizedCwd = cwd.trim(); | 48 | .execute({ |
| 49 | operation: "CheckpointStore.hasHeadCommit", | ||
| 50 | cwd, | ||
| 51 | args: ["rev-parse", "--verify", "HEAD"], | ||
| 52 | allowNonZeroExit: true, | ||
| 53 | }) | ||
| 54 | .pipe(Effect.map((result) => result.code === 0)); | ||
| 55 | |||
| 56 | const resolveCheckpointCommit = ( | ||
| 57 | cwd: string, | ||
| 58 | checkpointRef: CheckpointRef, | ||
| 59 | ): Effect.Effect<string | null, GitCommandError> => | ||
| 60 | git | ||
| 61 | .execute({ | ||
| 62 | operation: "CheckpointStore.resolveCheckpointCommit", | ||
| 63 | cwd, | ||
| 64 | args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], | ||
| 65 | allowNonZeroExit: true, | ||
| 66 | }) | ||
| 67 | .pipe( | ||
| 68 | Effect.map((result) => { | ||
| 69 | if (result.code !== 0) { | ||
| 70 | return null; | ||
| 71 | } | ||
| 72 | const commit = result.stdout.trim(); | ||
| 73 | return commit.length > 0 ? commit : null; | ||
| 74 | }), | ||
| 75 | ); | ||
| 157 | 76 | ||
| 158 | return yield* runGitWithSpawner({ | 77 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => |
| 78 | git | ||
| 79 | .execute({ | ||
| 159 | operation: "CheckpointStore.isGitRepository", | 80 | operation: "CheckpointStore.isGitRepository", |
| 160 | cwd: normalizedCwd, | 81 | cwd: cwd.trim(), |
| 161 | args: ["rev-parse", "--is-inside-work-tree"], | 82 | args: ["rev-parse", "--is-inside-work-tree"], |
| 162 | allowNonZeroExit: true, | 83 | allowNonZeroExit: true, |
| 163 | }).pipe( | 84 | }) |
| 85 | .pipe( | ||
| 164 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), | 86 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), |
| 165 | Effect.catch(() => Effect.succeed(false)), | 87 | Effect.catch(() => Effect.succeed(false)), |
| 166 | ); | 88 | ); |
| 167 | }); | ||
| 168 | 89 | ||
| 169 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => | 90 | const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => |
| 170 | Effect.gen(function* () { | 91 | Effect.gen(function* () { |
| ... | @@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 173 | const checkpointRef = input.checkpointRef.trim(); | 94 | const checkpointRef = input.checkpointRef.trim(); |
| 174 | 95 | ||
| 175 | yield* Effect.acquireUseRelease( | 96 | yield* Effect.acquireUseRelease( |
| 176 | Effect.tryPromise({ | 97 | fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), |
| 177 | try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")), | ||
| 178 | catch: (cause) => cause, | ||
| 179 | }), | ||
| 180 | (tempDir) => | 98 | (tempDir) => |
| 181 | Effect.gen(function* () { | 99 | Effect.gen(function* () { |
| 182 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); | 100 | const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); |
| ... | @@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 184 | ...process.env, | 102 | ...process.env, |
| 185 | GIT_INDEX_FILE: tempIndexPath, | 103 | GIT_INDEX_FILE: tempIndexPath, |
| 186 | GIT_AUTHOR_NAME: "T3 Code", | 104 | GIT_AUTHOR_NAME: "T3 Code", |
| 187 | GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", | 105 | GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", |
| 188 | GIT_COMMITTER_NAME: "T3 Code", | 106 | GIT_COMMITTER_NAME: "T3 Code", |
| 189 | GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", | 107 | GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", |
| 190 | }; | 108 | }; |
| 191 | 109 | ||
| 192 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd); | 110 | const headExists = yield* hasHeadCommit(normalizedCwd); |
| 193 | if (headExists) { | 111 | if (headExists) { |
| 194 | yield* runGitWithSpawner({ | 112 | yield* git.execute({ |
| 195 | operation, | 113 | operation, |
| 196 | cwd: normalizedCwd, | 114 | cwd: normalizedCwd, |
| 197 | args: ["read-tree", "HEAD"], | 115 | args: ["read-tree", "HEAD"], |
| ... | @@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 199 | }); | 117 | }); |
| 200 | } | 118 | } |
| 201 | 119 | ||
| 202 | yield* runGitWithSpawner({ | 120 | yield* git.execute({ |
| 203 | operation, | 121 | operation, |
| 204 | cwd: normalizedCwd, | 122 | cwd: normalizedCwd, |
| 205 | args: ["add", "-A", "--", "."], | 123 | args: ["add", "-A", "--", "."], |
| 206 | env: commitEnv, | 124 | env: commitEnv, |
| 207 | }); | 125 | }); |
| 208 | 126 | ||
| 209 | const writeTreeResult = yield* runGitWithSpawner({ | 127 | const writeTreeResult = yield* git.execute({ |
| 210 | operation, | 128 | operation, |
| 211 | cwd: normalizedCwd, | 129 | cwd: normalizedCwd, |
| 212 | args: ["write-tree"], | 130 | args: ["write-tree"], |
| ... | @@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 215 | const treeOid = writeTreeResult.stdout.trim(); | 133 | const treeOid = writeTreeResult.stdout.trim(); |
| 216 | if (treeOid.length === 0) { | 134 | if (treeOid.length === 0) { |
| 217 | return yield* Effect.fail( | 135 | return yield* Effect.fail( |
| 218 | new CheckpointGitCommandError({ | 136 | new GitCommandError({ |
| 219 | operation, | 137 | operation, |
| 220 | command: "git write-tree", | 138 | command: "git write-tree", |
| 221 | cwd: normalizedCwd, | 139 | cwd: normalizedCwd, |
| ... | @@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 225 | } | 143 | } |
| 226 | 144 | ||
| 227 | const message = `t3 checkpoint ref=${checkpointRef}`; | 145 | const message = `t3 checkpoint ref=${checkpointRef}`; |
| 228 | const commitTreeResult = yield* runGitWithSpawner({ | 146 | const commitTreeResult = yield* git.execute({ |
| 229 | operation, | 147 | operation, |
| 230 | cwd: normalizedCwd, | 148 | cwd: normalizedCwd, |
| 231 | args: ["commit-tree", treeOid, "-m", message], | 149 | args: ["commit-tree", treeOid, "-m", message], |
| ... | @@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 234 | const commitOid = commitTreeResult.stdout.trim(); | 152 | const commitOid = commitTreeResult.stdout.trim(); |
| 235 | if (commitOid.length === 0) { | 153 | if (commitOid.length === 0) { |
| 236 | return yield* Effect.fail( | 154 | return yield* Effect.fail( |
| 237 | new CheckpointGitCommandError({ | 155 | new GitCommandError({ |
| 238 | operation, | 156 | operation, |
| 239 | command: "git commit-tree", | 157 | command: "git commit-tree", |
| 240 | cwd: normalizedCwd, | 158 | cwd: normalizedCwd, |
| ... | @@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 243 | ); | 161 | ); |
| 244 | } | 162 | } |
| 245 | 163 | ||
| 246 | yield* runGitWithSpawner({ | 164 | yield* git.execute({ |
| 247 | operation, | 165 | operation, |
| 248 | cwd: normalizedCwd, | 166 | cwd: normalizedCwd, |
| 249 | args: ["update-ref", checkpointRef, commitOid], | 167 | args: ["update-ref", checkpointRef, commitOid], |
| 250 | }); | 168 | }); |
| 251 | }), | 169 | }), |
| 252 | removeDirectory, | 170 | (tempDir) => fs.remove(tempDir, { recursive: true }), |
| 253 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | 171 | ).pipe( |
| 172 | Effect.catchTags({ | ||
| 173 | PlatformError: (error) => | ||
| 174 | Effect.fail( | ||
| 175 | new CheckpointInvariantError({ | ||
| 176 | operation: "CheckpointStore.captureCheckpoint", | ||
| 177 | detail: "Failed to capture checkpoint.", | ||
| 178 | cause: error, | ||
| 179 | }), | ||
| 180 | ), | ||
| 181 | }), | ||
| 182 | ); | ||
| 254 | }); | 183 | }); |
| 255 | 184 | ||
| 256 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => | 185 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => |
| 257 | Effect.gen(function* () { | 186 | Effect.gen(function* () { |
| 258 | const operation = "CheckpointStore.hasCheckpointRef"; | 187 | return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( |
| 259 | const normalizedCwd = input.cwd.trim(); | ||
| 260 | const checkpointRef = input.checkpointRef.trim(); | ||
| 261 | |||
| 262 | return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | ||
| 263 | Effect.map((commit) => commit !== null), | 188 | Effect.map((commit) => commit !== null), |
| 264 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 265 | ); | ||
| 266 | }); | ||
| 267 | |||
| 268 | const ensureRootCheckpoint: CheckpointStoreShape["ensureRootCheckpoint"] = (input) => | ||
| 269 | Effect.gen(function* () { | ||
| 270 | const operation = "CheckpointStore.ensureRootCheckpoint"; | ||
| 271 | const normalizedCwd = input.cwd.trim(); | ||
| 272 | const checkpointRef = input.checkpointRef.trim(); | ||
| 273 | |||
| 274 | const existing = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | ||
| 275 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 276 | ); | 189 | ); |
| 277 | if (existing) { | ||
| 278 | return true; | ||
| 279 | } | ||
| 280 | |||
| 281 | const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | ||
| 282 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 283 | ); | ||
| 284 | if (!headCommit) { | ||
| 285 | return false; | ||
| 286 | } | ||
| 287 | |||
| 288 | yield* runGitWithSpawner({ | ||
| 289 | operation, | ||
| 290 | cwd: normalizedCwd, | ||
| 291 | args: ["update-ref", checkpointRef, headCommit], | ||
| 292 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 293 | return true; | ||
| 294 | }); | 190 | }); |
| 295 | 191 | ||
| 296 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => | 192 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => |
| 297 | Effect.gen(function* () { | 193 | Effect.gen(function* () { |
| 298 | const operation = "CheckpointStore.restoreCheckpoint"; | 194 | const operation = "CheckpointStore.restoreCheckpoint"; |
| 299 | const normalizedCwd = input.cwd.trim(); | ||
| 300 | const checkpointRef = input.checkpointRef.trim(); | ||
| 301 | 195 | ||
| 302 | let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( | 196 | let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); |
| 303 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 304 | ); | ||
| 305 | 197 | ||
| 306 | if (!commitOid && input.fallbackToHead === true) { | 198 | if (!commitOid && input.fallbackToHead === true) { |
| 307 | commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | 199 | commitOid = yield* resolveHeadCommit(input.cwd); |
| 308 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 309 | ); | ||
| 310 | } | 200 | } |
| 311 | 201 | ||
| 312 | if (!commitOid) { | 202 | if (!commitOid) { |
| 313 | return false; | 203 | return false; |
| 314 | } | 204 | } |
| 315 | 205 | ||
| 316 | yield* runGitWithSpawner({ | 206 | yield* git.execute({ |
| 317 | operation, | 207 | operation, |
| 318 | cwd: normalizedCwd, | 208 | cwd: input.cwd, |
| 319 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | 209 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], |
| 320 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | 210 | }); |
| 321 | yield* runGitWithSpawner({ | 211 | yield* git.execute({ |
| 322 | operation, | 212 | operation, |
| 323 | cwd: normalizedCwd, | 213 | cwd: input.cwd, |
| 324 | args: ["clean", "-fd", "--", "."], | 214 | args: ["clean", "-fd", "--", "."], |
| 325 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | 215 | }); |
| 326 | 216 | ||
| 327 | const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe( | 217 | const headExists = yield* hasHeadCommit(input.cwd); |
| 328 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 329 | ); | ||
| 330 | if (headExists) { | 218 | if (headExists) { |
| 331 | yield* runGitWithSpawner({ | 219 | yield* git.execute({ |
| 332 | operation, | 220 | operation, |
| 333 | cwd: normalizedCwd, | 221 | cwd: input.cwd, |
| 334 | args: ["reset", "--quiet", "--", "."], | 222 | args: ["reset", "--quiet", "--", "."], |
| 335 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | 223 | }); |
| 336 | } | 224 | } |
| 337 | 225 | ||
| 338 | return true; | 226 | return true; |
| ... | @@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 341 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => | 229 | const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => |
| 342 | Effect.gen(function* () { | 230 | Effect.gen(function* () { |
| 343 | const operation = "CheckpointStore.diffCheckpoints"; | 231 | const operation = "CheckpointStore.diffCheckpoints"; |
| 344 | const normalizedCwd = input.cwd.trim(); | 232 | |
| 345 | const fromCheckpointRef = input.fromCheckpointRef.trim(); | 233 | let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); |
| 346 | const toCheckpointRef = input.toCheckpointRef.trim(); | 234 | const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); |
| 347 | |||
| 348 | let fromCommitOid = yield* resolveCheckpointCommitWithSpawner( | ||
| 349 | normalizedCwd, | ||
| 350 | fromCheckpointRef, | ||
| 351 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 352 | const toCommitOid = yield* resolveCheckpointCommitWithSpawner( | ||
| 353 | normalizedCwd, | ||
| 354 | toCheckpointRef, | ||
| 355 | ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | ||
| 356 | 235 | ||
| 357 | if (!fromCommitOid && input.fallbackFromToHead === true) { | 236 | if (!fromCommitOid && input.fallbackFromToHead === true) { |
| 358 | const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( | 237 | const headCommit = yield* resolveHeadCommit(input.cwd); |
| 359 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 360 | ); | ||
| 361 | if (headCommit) { | 238 | if (headCommit) { |
| 362 | fromCommitOid = headCommit; | 239 | fromCommitOid = headCommit; |
| 363 | } | 240 | } |
| ... | @@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 365 | 242 | ||
| 366 | if (!fromCommitOid || !toCommitOid) { | 243 | if (!fromCommitOid || !toCommitOid) { |
| 367 | return yield* Effect.fail( | 244 | return yield* Effect.fail( |
| 368 | new CheckpointGitCommandError({ | 245 | new GitCommandError({ |
| 369 | operation, | 246 | operation, |
| 370 | command: "git diff", | 247 | command: "git diff", |
| 371 | cwd: normalizedCwd, | 248 | cwd: input.cwd, |
| 372 | detail: "Checkpoint ref is unavailable for diff operation.", | 249 | detail: "Checkpoint ref is unavailable for diff operation.", |
| 373 | }), | 250 | }), |
| 374 | ); | 251 | ); |
| 375 | } | 252 | } |
| 376 | 253 | ||
| 377 | const result = yield* runGitWithSpawner({ | 254 | const result = yield* git.execute({ |
| 378 | operation, | 255 | operation, |
| 379 | cwd: normalizedCwd, | 256 | cwd: input.cwd, |
| 380 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], | 257 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], |
| 381 | }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); | 258 | }); |
| 382 | 259 | ||
| 383 | return result.stdout; | 260 | return result.stdout; |
| 384 | }); | 261 | }); |
| ... | @@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 386 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => | 263 | const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => |
| 387 | Effect.gen(function* () { | 264 | Effect.gen(function* () { |
| 388 | const operation = "CheckpointStore.deleteCheckpointRefs"; | 265 | const operation = "CheckpointStore.deleteCheckpointRefs"; |
| 389 | const normalizedCwd = input.cwd.trim(); | ||
| 390 | const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim()); | ||
| 391 | 266 | ||
| 392 | yield* Effect.forEach( | 267 | yield* Effect.forEach( |
| 393 | checkpointRefs, | 268 | input.checkpointRefs, |
| 394 | (checkpointRef) => | 269 | (checkpointRef) => |
| 395 | runGitWithSpawner({ | 270 | git.execute({ |
| 396 | operation, | 271 | operation, |
| 397 | cwd: normalizedCwd, | 272 | cwd: input.cwd, |
| 398 | args: ["update-ref", "-d", checkpointRef], | 273 | args: ["update-ref", "-d", checkpointRef], |
| 399 | allowNonZeroExit: true, | 274 | allowNonZeroExit: true, |
| 400 | }).pipe( | 275 | }), |
| 401 | Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), | ||
| 402 | ), | ||
| 403 | { discard: true }, | 276 | { discard: true }, |
| 404 | ); | 277 | ); |
| 405 | }); | 278 | }); |
| ... | @@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 408 | isGitRepository, | 281 | isGitRepository, |
| 409 | captureCheckpoint, | 282 | captureCheckpoint, |
| 410 | hasCheckpointRef, | 283 | hasCheckpointRef, |
| 411 | ensureRootCheckpoint, | ||
| 412 | restoreCheckpoint, | 284 | restoreCheckpoint, |
| 413 | diffCheckpoints, | 285 | diffCheckpoints, |
| 414 | deleteCheckpointRefs, | 286 | deleteCheckpointRefs, |
| 415 | } satisfies CheckpointStoreShape; | 287 | } satisfies CheckpointStoreShape; |
| 416 | }); | 288 | }); |
| 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,33 +14,29 @@ import { ServiceMap } from "effect"; |
| 14 | import type { Effect } from "effect"; | 14 | import type { Effect } from "effect"; |
| 15 | 15 | ||
| 16 | import type { CheckpointStoreError } from "../Errors.ts"; | 16 | import type { CheckpointStoreError } from "../Errors.ts"; |
| 17 | import { CheckpointRef } from "@t3tools/contracts"; | ||
| 17 | 18 | ||
| 18 | export interface CaptureCheckpointInput { | 19 | export interface CaptureCheckpointInput { |
| 19 | readonly cwd: string; | 20 | readonly cwd: string; |
| 20 | readonly checkpointRef: string; | 21 | readonly checkpointRef: CheckpointRef; |
| 21 | } | ||
| 22 | |||
| 23 | export interface EnsureRootCheckpointInput { | ||
| 24 | readonly cwd: string; | ||
| 25 | readonly checkpointRef: string; | ||
| 26 | } | 22 | } |
| 27 | 23 | ||
| 28 | export interface RestoreCheckpointInput { | 24 | export interface RestoreCheckpointInput { |
| 29 | readonly cwd: string; | 25 | readonly cwd: string; |
| 30 | readonly checkpointRef: string; | 26 | readonly checkpointRef: CheckpointRef; |
| 31 | readonly fallbackToHead?: boolean; | 27 | readonly fallbackToHead?: boolean; |
| 32 | } | 28 | } |
| 33 | 29 | ||
| 34 | export interface DiffCheckpointsInput { | 30 | export interface DiffCheckpointsInput { |
| 35 | readonly cwd: string; | 31 | readonly cwd: string; |
| 36 | readonly fromCheckpointRef: string; | 32 | readonly fromCheckpointRef: CheckpointRef; |
| 37 | readonly toCheckpointRef: string; | 33 | readonly toCheckpointRef: CheckpointRef; |
| 38 | readonly fallbackFromToHead?: boolean; | 34 | readonly fallbackFromToHead?: boolean; |
| 39 | } | 35 | } |
| 40 | 36 | ||
| 41 | export interface DeleteCheckpointRefsInput { | 37 | export interface DeleteCheckpointRefsInput { |
| 42 | readonly cwd: string; | 38 | readonly cwd: string; |
| 43 | readonly checkpointRefs: ReadonlyArray<string>; | 39 | readonly checkpointRefs: ReadonlyArray<CheckpointRef>; |
| 44 | } | 40 | } |
| 45 | 41 | ||
| 46 | export interface CheckpointStoreShape { | 42 | export interface CheckpointStoreShape { |
| ... | @@ -63,13 +59,6 @@ export interface CheckpointStoreShape { | ... | @@ -63,13 +59,6 @@ export interface CheckpointStoreShape { |
| 63 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, | 59 | input: Omit<RestoreCheckpointInput, "fallbackToHead">, |
| 64 | ) => Effect.Effect<boolean, CheckpointStoreError>; | 60 | ) => Effect.Effect<boolean, CheckpointStoreError>; |
| 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 | * Restore workspace/staging state to a checkpoint. | 63 | * Restore workspace/staging state to a checkpoint. |
| 75 | */ | 64 | */ |
apps/server/src/codexAppServerManager.ts+96-55| ... | @@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto"; | ... | @@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto"; |
| 3 | import { EventEmitter } from "node:events"; | 3 | import { EventEmitter } from "node:events"; |
| 4 | import readline from "node:readline"; | 4 | import readline from "node:readline"; |
| 5 | 5 | ||
| 6 | import type { | 6 | import { |
| 7 | ProviderApprovalDecision, | 7 | ApprovalRequestId, |
| 8 | ProviderEvent, | 8 | EventId, |
| 9 | ProviderItemId, | ||
| 9 | ProviderRequestKind, | 10 | ProviderRequestKind, |
| 10 | ProviderSendTurnInput, | 11 | ProviderSessionId, |
| 11 | ProviderSession, | 12 | ProviderThreadId, |
| 12 | ProviderSessionStartInput, | 13 | ProviderTurnId, |
| 13 | ProviderTurnStartResult, | 14 | normalizeModelSlug, |
| 15 | type ProviderApprovalDecision, | ||
| 16 | type ProviderEvent, | ||
| 17 | type ProviderSendTurnInput, | ||
| 18 | type ProviderSession, | ||
| 19 | type ProviderSessionStartInput, | ||
| 20 | type ProviderTurnStartResult, | ||
| 14 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 15 | import { normalizeModelSlug } from "@t3tools/contracts"; | ||
| 16 | 22 | ||
| 17 | type PendingRequestKey = string; | 23 | type PendingRequestKey = string; |
| 18 | 24 | ||
| ... | @@ -24,13 +30,13 @@ interface PendingRequest { | ... | @@ -24,13 +30,13 @@ interface PendingRequest { |
| 24 | } | 30 | } |
| 25 | 31 | ||
| 26 | interface PendingApprovalRequest { | 32 | interface PendingApprovalRequest { |
| 27 | requestId: string; | 33 | requestId: ApprovalRequestId; |
| 28 | jsonRpcId: string | number; | 34 | jsonRpcId: string | number; |
| 29 | method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; | 35 | method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; |
| 30 | requestKind: ProviderRequestKind; | 36 | requestKind: ProviderRequestKind; |
| 31 | threadId?: string; | 37 | threadId?: ProviderThreadId; |
| 32 | turnId?: string; | 38 | turnId?: ProviderTurnId; |
| 33 | itemId?: string; | 39 | itemId?: ProviderItemId; |
| 34 | } | 40 | } |
| 35 | 41 | ||
| 36 | interface CodexSessionContext { | 42 | interface CodexSessionContext { |
| ... | @@ -38,7 +44,7 @@ interface CodexSessionContext { | ... | @@ -38,7 +44,7 @@ interface CodexSessionContext { |
| 38 | child: ChildProcessWithoutNullStreams; | 44 | child: ChildProcessWithoutNullStreams; |
| 39 | output: readline.Interface; | 45 | output: readline.Interface; |
| 40 | pending: Map<PendingRequestKey, PendingRequest>; | 46 | pending: Map<PendingRequestKey, PendingRequest>; |
| 41 | pendingApprovals: Map<string, PendingApprovalRequest>; | 47 | pendingApprovals: Map<ApprovalRequestId, PendingApprovalRequest>; |
| 42 | nextRequestId: number; | 48 | nextRequestId: number; |
| 43 | stopping: boolean; | 49 | stopping: boolean; |
| 44 | } | 50 | } |
| ... | @@ -66,12 +72,12 @@ interface JsonRpcNotification { | ... | @@ -66,12 +72,12 @@ interface JsonRpcNotification { |
| 66 | } | 72 | } |
| 67 | 73 | ||
| 68 | export interface CodexThreadTurnSnapshot { | 74 | export interface CodexThreadTurnSnapshot { |
| 69 | id: string; | 75 | id: ProviderTurnId; |
| 70 | items: unknown[]; | 76 | items: unknown[]; |
| 71 | } | 77 | } |
| 72 | 78 | ||
| 73 | export interface CodexThreadSnapshot { | 79 | export interface CodexThreadSnapshot { |
| 74 | threadId: string; | 80 | threadId: ProviderThreadId; |
| 75 | turns: CodexThreadTurnSnapshot[]; | 81 | turns: CodexThreadTurnSnapshot[]; |
| 76 | } | 82 | } |
| 77 | 83 | ||
| ... | @@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents { | ... | @@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents { |
| 143 | } | 149 | } |
| 144 | 150 | ||
| 145 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { | 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 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { | 154 | async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> { |
| 149 | const sessionId = randomUUID(); | 155 | const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); |
| 150 | const now = new Date().toISOString(); | 156 | const now = new Date().toISOString(); |
| 151 | let context: CodexSessionContext | undefined; | 157 | let context: CodexSessionContext | undefined; |
| 152 | 158 | ||
| ... | @@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 244 | } | 250 | } |
| 245 | 251 | ||
| 246 | const threadOpenRecord = this.readObject(threadOpenResponse); | 252 | const threadOpenRecord = this.readObject(threadOpenResponse); |
| 247 | const threadId = | 253 | const threadIdRaw = |
| 248 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? | 254 | this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? |
| 249 | this.readString(threadOpenRecord, "threadId"); | 255 | this.readString(threadOpenRecord, "threadId"); |
| 250 | if (!threadId) { | 256 | if (!threadIdRaw) { |
| 251 | throw new Error(`${threadOpenMethod} response did not include a thread id.`); | 257 | throw new Error(`${threadOpenMethod} response did not include a thread id.`); |
| 252 | } | 258 | } |
| 259 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | ||
| 253 | 260 | ||
| 254 | this.updateSession(context, { | 261 | this.updateSession(context, { |
| 255 | status: "ready", | 262 | status: "ready", |
| ... | @@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -268,7 +275,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 268 | this.stopSession(sessionId); | 275 | this.stopSession(sessionId); |
| 269 | } else { | 276 | } else { |
| 270 | this.emitEvent({ | 277 | this.emitEvent({ |
| 271 | id: randomUUID(), | 278 | id: EventId.makeUnsafe(randomUUID()), |
| 272 | kind: "error", | 279 | kind: "error", |
| 273 | provider: "codex", | 280 | provider: "codex", |
| 274 | sessionId, | 281 | sessionId, |
| ... | @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 310 | } | 317 | } |
| 311 | 318 | ||
| 312 | const turnStartParams: { | 319 | const turnStartParams: { |
| 313 | threadId: string; | 320 | threadId: ProviderThreadId; |
| 314 | input: Array< | 321 | input: Array< |
| 315 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } | 322 | { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } |
| 316 | >; | 323 | >; |
| ... | @@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 331 | const response = await this.sendRequest(context, "turn/start", turnStartParams); | 338 | const response = await this.sendRequest(context, "turn/start", turnStartParams); |
| 332 | 339 | ||
| 333 | const turn = this.readObject(this.readObject(response), "turn"); | 340 | const turn = this.readObject(this.readObject(response), "turn"); |
| 334 | const turnId = this.readString(turn, "id"); | 341 | const turnIdRaw = this.readString(turn, "id"); |
| 335 | if (!turnId) { | 342 | if (!turnIdRaw) { |
| 336 | throw new Error("turn/start response did not include a turn id."); | 343 | throw new Error("turn/start response did not include a turn id."); |
| 337 | } | 344 | } |
| 345 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | ||
| 338 | 346 | ||
| 339 | this.updateSession(context, { | 347 | this.updateSession(context, { |
| 340 | status: "running", | 348 | status: "running", |
| ... | @@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -347,7 +355,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 347 | }; | 355 | }; |
| 348 | } | 356 | } |
| 349 | 357 | ||
| 350 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | 358 | async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> { |
| 351 | const context = this.requireSession(sessionId); | 359 | const context = this.requireSession(sessionId); |
| 352 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | 360 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| 353 | 361 | ||
| ... | @@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -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 | const context = this.requireSession(sessionId); | 373 | const context = this.requireSession(sessionId); |
| 366 | const threadId = context.session.threadId; | 374 | const threadId = context.session.threadId; |
| 367 | if (!threadId) { | 375 | if (!threadId) { |
| ... | @@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 375 | return this.parseThreadSnapshot("thread/read", response); | 383 | return this.parseThreadSnapshot("thread/read", response); |
| 376 | } | 384 | } |
| 377 | 385 | ||
| 378 | async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> { | 386 | async rollbackThread(sessionId: ProviderSessionId, numTurns: number): Promise<CodexThreadSnapshot> { |
| 379 | const context = this.requireSession(sessionId); | 387 | const context = this.requireSession(sessionId); |
| 380 | const threadId = context.session.threadId; | 388 | const threadId = context.session.threadId; |
| 381 | if (!threadId) { | 389 | if (!threadId) { |
| ... | @@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 397 | } | 405 | } |
| 398 | 406 | ||
| 399 | async respondToRequest( | 407 | async respondToRequest( |
| 400 | sessionId: string, | 408 | sessionId: ProviderSessionId, |
| 401 | requestId: string, | 409 | requestId: ApprovalRequestId, |
| 402 | decision: ProviderApprovalDecision, | 410 | decision: ProviderApprovalDecision, |
| 403 | ): Promise<void> { | 411 | ): Promise<void> { |
| 404 | const context = this.requireSession(sessionId); | 412 | const context = this.requireSession(sessionId); |
| ... | @@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 416 | }); | 424 | }); |
| 417 | 425 | ||
| 418 | this.emitEvent({ | 426 | this.emitEvent({ |
| 419 | id: randomUUID(), | 427 | id: EventId.makeUnsafe(randomUUID()), |
| 420 | kind: "notification", | 428 | kind: "notification", |
| 421 | provider: "codex", | 429 | provider: "codex", |
| 422 | sessionId: context.session.sessionId, | 430 | sessionId: context.session.sessionId, |
| ... | @@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -435,7 +443,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 435 | }); | 443 | }); |
| 436 | } | 444 | } |
| 437 | 445 | ||
| 438 | stopSession(sessionId: string): void { | 446 | stopSession(sessionId: ProviderSessionId): void { |
| 439 | const context = this.sessions.get(sessionId); | 447 | const context = this.sessions.get(sessionId); |
| 440 | if (!context) { | 448 | if (!context) { |
| 441 | return; | 449 | return; |
| ... | @@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -470,7 +478,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 470 | })); | 478 | })); |
| 471 | } | 479 | } |
| 472 | 480 | ||
| 473 | hasSession(sessionId: string): boolean { | 481 | hasSession(sessionId: ProviderSessionId): boolean { |
| 474 | return this.sessions.has(sessionId); | 482 | return this.sessions.has(sessionId); |
| 475 | } | 483 | } |
| 476 | 484 | ||
| ... | @@ -480,7 +488,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -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 | const context = this.sessions.get(sessionId); | 492 | const context = this.sessions.get(sessionId); |
| 485 | if (!context) { | 493 | if (!context) { |
| 486 | throw new Error(`Unknown session: ${sessionId}`); | 494 | throw new Error(`Unknown session: ${sessionId}`); |
| ... | @@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -591,7 +599,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 591 | : undefined; | 599 | : undefined; |
| 592 | 600 | ||
| 593 | this.emitEvent({ | 601 | this.emitEvent({ |
| 594 | id: randomUUID(), | 602 | id: EventId.makeUnsafe(randomUUID()), |
| 595 | kind: "notification", | 603 | kind: "notification", |
| 596 | provider: "codex", | 604 | provider: "codex", |
| 597 | sessionId: context.session.sessionId, | 605 | sessionId: context.session.sessionId, |
| ... | @@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -605,7 +613,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 605 | }); | 613 | }); |
| 606 | 614 | ||
| 607 | if (notification.method === "thread/started") { | 615 | if (notification.method === "thread/started") { |
| 608 | const threadId = this.readString(this.readObject(notification.params)?.thread, "id"); | 616 | const threadId = toProviderThreadId( |
| 617 | this.readString(this.readObject(notification.params)?.thread, "id"), | ||
| 618 | ); | ||
| 609 | if (threadId) { | 619 | if (threadId) { |
| 610 | this.updateSession(context, { threadId }); | 620 | this.updateSession(context, { threadId }); |
| 611 | } | 621 | } |
| ... | @@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -613,7 +623,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 613 | } | 623 | } |
| 614 | 624 | ||
| 615 | if (notification.method === "turn/started") { | 625 | if (notification.method === "turn/started") { |
| 616 | const turnId = this.readString(this.readObject(notification.params)?.turn, "id"); | 626 | const turnId = toProviderTurnId( |
| 627 | this.readString(this.readObject(notification.params)?.turn, "id"), | ||
| 628 | ); | ||
| 617 | this.updateSession(context, { | 629 | this.updateSession(context, { |
| 618 | status: "running", | 630 | status: "running", |
| 619 | activeTurnId: turnId, | 631 | activeTurnId: turnId, |
| ... | @@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -647,9 +659,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 647 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { | 659 | private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { |
| 648 | const route = this.readRouteFields(request.params); | 660 | const route = this.readRouteFields(request.params); |
| 649 | const requestKind = this.requestKindForMethod(request.method); | 661 | const requestKind = this.requestKindForMethod(request.method); |
| 650 | let requestId: string | undefined; | 662 | let requestId: ApprovalRequestId | undefined; |
| 651 | if (requestKind) { | 663 | if (requestKind) { |
| 652 | requestId = randomUUID(); | 664 | requestId = ApprovalRequestId.makeUnsafe(randomUUID()); |
| 653 | const pendingRequest: PendingApprovalRequest = { | 665 | const pendingRequest: PendingApprovalRequest = { |
| 654 | requestId, | 666 | requestId, |
| 655 | jsonRpcId: request.id, | 667 | jsonRpcId: request.id, |
| ... | @@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -666,7 +678,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 666 | } | 678 | } |
| 667 | 679 | ||
| 668 | this.emitEvent({ | 680 | this.emitEvent({ |
| 669 | id: randomUUID(), | 681 | id: EventId.makeUnsafe(randomUUID()), |
| 670 | kind: "request", | 682 | kind: "request", |
| 671 | provider: "codex", | 683 | provider: "codex", |
| 672 | sessionId: context.session.sessionId, | 684 | sessionId: context.session.sessionId, |
| ... | @@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -761,7 +773,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 761 | 773 | ||
| 762 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { | 774 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { |
| 763 | this.emitEvent({ | 775 | this.emitEvent({ |
| 764 | id: randomUUID(), | 776 | id: EventId.makeUnsafe(randomUUID()), |
| 765 | kind: "session", | 777 | kind: "session", |
| 766 | provider: "codex", | 778 | provider: "codex", |
| 767 | sessionId: context.session.sessionId, | 779 | sessionId: context.session.sessionId, |
| ... | @@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -773,7 +785,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 773 | 785 | ||
| 774 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { | 786 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { |
| 775 | this.emitEvent({ | 787 | this.emitEvent({ |
| 776 | id: randomUUID(), | 788 | id: EventId.makeUnsafe(randomUUID()), |
| 777 | kind: "error", | 789 | kind: "error", |
| 778 | provider: "codex", | 790 | provider: "codex", |
| 779 | sessionId: context.session.sessionId, | 791 | sessionId: context.session.sessionId, |
| ... | @@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -810,15 +822,20 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 810 | private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { | 822 | private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { |
| 811 | const responseRecord = this.readObject(response); | 823 | const responseRecord = this.readObject(response); |
| 812 | const thread = this.readObject(responseRecord, "thread"); | 824 | const thread = this.readObject(responseRecord, "thread"); |
| 813 | const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); | 825 | const threadIdRaw = this.readString(thread, "id") ?? |
| 814 | if (!threadId) { | 826 | this.readString(responseRecord, "threadId"); |
| 827 | if (!threadIdRaw) { | ||
| 815 | throw new Error(`${method} response did not include a thread id.`); | 828 | throw new Error(`${method} response did not include a thread id.`); |
| 816 | } | 829 | } |
| 830 | const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); | ||
| 817 | 831 | ||
| 818 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; | 832 | const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; |
| 819 | const turns = turnsRaw.map((turnValue, index) => { | 833 | const turns = turnsRaw.map((turnValue, index) => { |
| 820 | const turn = this.readObject(turnValue); | 834 | const turn = this.readObject(turnValue); |
| 821 | const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; | 835 | const turnIdRaw = |
| 836 | this.readString(turn, "id") ?? | ||
| 837 | `${threadIdRaw}:turn:${index + 1}`; | ||
| 838 | const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); | ||
| 822 | const items = this.readArray(turn, "items") ?? []; | 839 | const items = this.readArray(turn, "items") ?? []; |
| 823 | return { | 840 | return { |
| 824 | id: turnId, | 841 | id: turnId, |
| ... | @@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 865 | } | 882 | } |
| 866 | 883 | ||
| 867 | private readRouteFields(params: unknown): { | 884 | private readRouteFields(params: unknown): { |
| 868 | threadId?: string; | 885 | threadId?: ProviderThreadId; |
| 869 | turnId?: string; | 886 | turnId?: ProviderTurnId; |
| 870 | itemId?: string; | 887 | itemId?: ProviderItemId; |
| 871 | } { | 888 | } { |
| 872 | const route: { | 889 | const route: { |
| 873 | threadId?: string; | 890 | threadId?: ProviderThreadId; |
| 874 | turnId?: string; | 891 | turnId?: ProviderTurnId; |
| 875 | itemId?: string; | 892 | itemId?: ProviderItemId; |
| 876 | } = {}; | 893 | } = {}; |
| 877 | 894 | ||
| 878 | const threadId = | 895 | const threadId = toProviderThreadId( |
| 879 | this.readString(params, "threadId") ?? | 896 | this.readString(params, "threadId") ?? |
| 880 | this.readString(this.readObject(params, "thread"), "id"); | 897 | this.readString(this.readObject(params, "thread"), "id"), |
| 881 | const turnId = | 898 | ); |
| 882 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"); | 899 | const turnId = toProviderTurnId( |
| 883 | const itemId = | 900 | this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), |
| 884 | this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"); | 901 | ); |
| 902 | const itemId = toProviderItemId( | ||
| 903 | this.readString(params, "itemId") ?? | ||
| 904 | this.readString(this.readObject(params, "item"), "id"), | ||
| 905 | ); | ||
| 885 | 906 | ||
| 886 | if (threadId) { | 907 | if (threadId) { |
| 887 | route.threadId = threadId; | 908 | route.threadId = threadId; |
| ... | @@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -941,3 +962,23 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 941 | return typeof candidate === "boolean" ? candidate : undefined; | 962 | return typeof candidate === "boolean" ? candidate : undefined; |
| 942 | } | 963 | } |
| 943 | } | 964 | } |
| 965 | |||
| 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,10 +3,10 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | gitPullInputSchema, | 6 | GitPullInput, |
| 7 | gitPullResultSchema, | 7 | GitPullResult, |
| 8 | gitStatusInputSchema, | 8 | GitStatusInput, |
| 9 | gitStatusResultSchema, | 9 | GitStatusResult, |
| 10 | type GitCheckoutInput, | 10 | type GitCheckoutInput, |
| 11 | type GitCreateBranchInput, | 11 | type GitCreateBranchInput, |
| 12 | type GitCreateWorktreeInput, | 12 | type GitCreateWorktreeInput, |
| ... | @@ -14,11 +14,7 @@ import { | ... | @@ -14,11 +14,7 @@ import { |
| 14 | type GitInitInput, | 14 | type GitInitInput, |
| 15 | type GitListBranchesInput, | 15 | type GitListBranchesInput, |
| 16 | type GitListBranchesResult, | 16 | type GitListBranchesResult, |
| 17 | type GitPullInput, | ||
| 18 | type GitPullResult, | ||
| 19 | type GitRemoveWorktreeInput, | 17 | type GitRemoveWorktreeInput, |
| 20 | type GitStatusInput, | ||
| 21 | type GitStatusResult, | ||
| 22 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 23 | import { type ProcessRunResult, runProcess } from "./processRunner"; | 19 | import { type ProcessRunResult, runProcess } from "./processRunner"; |
| 24 | 20 | ||
| ... | @@ -29,8 +25,6 @@ export interface TerminalCommandInput { | ... | @@ -29,8 +25,6 @@ export interface TerminalCommandInput { |
| 29 | maxOutputBytes?: number; | 25 | maxOutputBytes?: number; |
| 30 | } | 26 | } |
| 31 | 27 | ||
| 32 | |||
| 33 | |||
| 34 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | 28 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { |
| 35 | upstreamRef: string | null; | 29 | upstreamRef: string | null; |
| 36 | } | 30 | } |
| ... | @@ -220,10 +214,9 @@ async function executeGit( | ... | @@ -220,10 +214,9 @@ async function executeGit( |
| 220 | export class GitCoreService { | 214 | export class GitCoreService { |
| 221 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); | 215 | private readonly lastUpstreamRefreshAt = new Map<string, number>(); |
| 222 | 216 | ||
| 223 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { |
| 224 | const input = gitStatusInputSchema.parse(raw); | ||
| 225 | const details = await this.statusDetails(input.cwd); | 218 | const details = await this.statusDetails(input.cwd); |
| 226 | return gitStatusResultSchema.parse({ | 219 | return GitStatusResult.makeUnsafe({ |
| 227 | branch: details.branch, | 220 | branch: details.branch, |
| 228 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 229 | workingTree: details.workingTree, | 222 | workingTree: details.workingTree, |
| ... | @@ -392,10 +385,10 @@ export class GitCoreService { | ... | @@ -392,10 +385,10 @@ export class GitCoreService { |
| 392 | }); | 385 | }); |
| 393 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); |
| 394 | const refreshed = await this.statusDetails(cwd); | 387 | const refreshed = await this.statusDetails(cwd); |
| 395 | return gitPullResultSchema.parse({ | 388 | return GitPullResult.makeUnsafe({ |
| 396 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 397 | branch, | 390 | branch, |
| 398 | ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), | 391 | upstreamBranch: refreshed.upstreamRef, |
| 399 | }); | 392 | }); |
| 400 | } | 393 | } |
| 401 | 394 | ||
| ... | @@ -678,9 +671,7 @@ export class GitCoreService { | ... | @@ -678,9 +671,7 @@ export class GitCoreService { |
| 678 | 671 | ||
| 679 | const defaultGitCoreService = new GitCoreService(); | 672 | const defaultGitCoreService = new GitCoreService(); |
| 680 | 673 | ||
| 681 | export async function runTerminalCommand( | 674 | export async function runTerminalCommand(input: TerminalCommandInput): Promise<ProcessRunResult> { |
| 682 | input: TerminalCommandInput, | ||
| 683 | ): Promise<ProcessRunResult> { | ||
| 684 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | 675 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; |
| 685 | const shellPath = | 676 | const shellPath = |
| 686 | process.platform === "win32" | 677 | process.platform === "win32" |
| ... | @@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL | ... | @@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL |
| 712 | return defaultGitCoreService.listBranches(input); | 703 | return defaultGitCoreService.listBranches(input); |
| 713 | } | 704 | } |
| 714 | 705 | ||
| 715 | export async function pullGitBranch(raw: GitPullInput): Promise<GitPullResult> { | 706 | export async function pullGitBranch(input: GitPullInput): Promise<GitPullResult> { |
| 716 | const input = gitPullInputSchema.parse(raw); | ||
| 717 | return defaultGitCoreService.pullCurrentBranch(input.cwd); | 707 | return defaultGitCoreService.pullCurrentBranch(input.cwd); |
| 718 | } | 708 | } |
| 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,13 +4,10 @@ import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | gitRunStackedActionInputSchema, | 7 | GitRunStackedActionInput, |
| 8 | gitRunStackedActionResultSchema, | 8 | GitRunStackedActionResult, |
| 9 | gitStatusInputSchema, | 9 | GitStatusInput, |
| 10 | type GitRunStackedActionInput, | 10 | GitStatusResult, |
| 11 | type GitRunStackedActionResult, | ||
| 12 | type GitStatusInput, | ||
| 13 | type GitStatusResult, | ||
| 14 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 15 | 12 | ||
| 16 | import type { | 13 | import type { |
| ... | @@ -187,8 +184,7 @@ export class GitManager { | ... | @@ -187,8 +184,7 @@ export class GitManager { |
| 187 | this.gitCore = deps.gitCore ?? new GitCoreService(); | 184 | this.gitCore = deps.gitCore ?? new GitCoreService(); |
| 188 | } | 185 | } |
| 189 | 186 | ||
| 190 | async status(raw: GitStatusInput): Promise<GitStatusResult> { | 187 | async status(input: GitStatusInput): Promise<GitStatusResult> { |
| 191 | const input = gitStatusInputSchema.parse(raw); | ||
| 192 | const details = await this.gitCore.statusDetails(input.cwd); | 188 | const details = await this.gitCore.statusDetails(input.cwd); |
| 193 | 189 | ||
| 194 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; | 190 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; |
| ... | @@ -214,8 +210,7 @@ export class GitManager { | ... | @@ -214,8 +210,7 @@ export class GitManager { |
| 214 | }; | 210 | }; |
| 215 | } | 211 | } |
| 216 | 212 | ||
| 217 | async runStackedAction(raw: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { | 213 | async runStackedAction(input: GitRunStackedActionInput): Promise<GitRunStackedActionResult> { |
| 218 | const input = gitRunStackedActionInputSchema.parse(raw); | ||
| 219 | const wantsPush = input.action !== "commit"; | 214 | const wantsPush = input.action !== "commit"; |
| 220 | const wantsPr = input.action === "commit_push_pr"; | 215 | const wantsPr = input.action === "commit_push_pr"; |
| 221 | 216 | ||
| ... | @@ -237,12 +232,12 @@ export class GitManager { | ... | @@ -237,12 +232,12 @@ export class GitManager { |
| 237 | ? await this.runPrStep(input.cwd, initialStatus.branch) | 232 | ? await this.runPrStep(input.cwd, initialStatus.branch) |
| 238 | : { status: "skipped_not_requested" as const }; | 233 | : { status: "skipped_not_requested" as const }; |
| 239 | 234 | ||
| 240 | return gitRunStackedActionResultSchema.parse({ | 235 | return { |
| 241 | action: input.action, | 236 | action: input.action, |
| 242 | commit, | 237 | commit, |
| 243 | push, | 238 | push, |
| 244 | pr, | 239 | pr, |
| 245 | }); | 240 | }; |
| 246 | } | 241 | } |
| 247 | 242 | ||
| 248 | private async runCommitStep( | 243 | private async runCommitStep( |
apps/server/src/keybindings.ts+11-15| ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; | ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import type { | 5 | import { |
| 6 | KeybindingRule, | 6 | KeybindingRule, |
| 7 | KeybindingsConfig, | 7 | KeybindingsConfig, |
| 8 | KeybindingShortcut, | 8 | KeybindingShortcut, |
| ... | @@ -10,7 +10,8 @@ import type { | ... | @@ -10,7 +10,8 @@ import type { |
| 10 | ResolvedKeybindingRule, | 10 | ResolvedKeybindingRule, |
| 11 | ResolvedKeybindingsConfig, | 11 | ResolvedKeybindingsConfig, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | 13 | import { Mutable } from "effect/Types"; |
| 14 | import { Schema } from "effect"; | ||
| 14 | 15 | ||
| 15 | interface KeybindingsLogger { | 16 | interface KeybindingsLogger { |
| 16 | warn(message: string, context?: Record<string, unknown>): void; | 17 | warn(message: string, context?: Record<string, unknown>): void; |
| ... | @@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey | ... | @@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey |
| 267 | export function compileResolvedKeybindingsConfig( | 268 | export function compileResolvedKeybindingsConfig( |
| 268 | config: KeybindingsConfig, | 269 | config: KeybindingsConfig, |
| 269 | ): ResolvedKeybindingsConfig { | 270 | ): ResolvedKeybindingsConfig { |
| 270 | const compiled: ResolvedKeybindingsConfig = []; | 271 | const compiled: Mutable<ResolvedKeybindingsConfig> = []; |
| 271 | for (const rule of config) { | 272 | for (const rule of config) { |
| 272 | const resolved = compileResolvedKeybindingRule(rule); | 273 | const resolved = compileResolvedKeybindingRule(rule); |
| 273 | if (!resolved) continue; | 274 | if (!resolved) continue; |
| ... | @@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig( | ... | @@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig( |
| 277 | } | 278 | } |
| 278 | 279 | ||
| 279 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | 280 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { |
| 280 | const resolved: ResolvedKeybindingsConfig = []; | 281 | const resolved: Mutable<ResolvedKeybindingsConfig> = []; |
| 281 | for (const rule of DEFAULT_KEYBINDINGS) { | 282 | for (const rule of DEFAULT_KEYBINDINGS) { |
| 282 | const compiled = compileResolvedKeybindingRule(rule); | 283 | const compiled = compileResolvedKeybindingRule(rule); |
| 283 | if (!compiled) { | 284 | if (!compiled) { |
| ... | @@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig( | ... | @@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig( |
| 322 | return []; | 323 | return []; |
| 323 | } | 324 | } |
| 324 | 325 | ||
| 325 | const sanitized: KeybindingsConfig = []; | 326 | const sanitized: Mutable<KeybindingsConfig> = []; |
| 326 | let invalidEntries = 0; | 327 | let invalidEntries = 0; |
| 327 | for (const entry of parsed) { | 328 | for (const entry of parsed) { |
| 328 | const result = keybindingRuleSchema.safeParse(entry); | 329 | const result = Schema.decodeUnknownExit(KeybindingRule)(entry); |
| 329 | if (result.success) { | 330 | if (result._tag === "Success") { |
| 330 | if (!compileResolvedKeybindingRule(result.data)) { | 331 | if (!compileResolvedKeybindingRule(result.value)) { |
| 331 | invalidEntries += 1; | 332 | invalidEntries += 1; |
| 332 | continue; | 333 | continue; |
| 333 | } | 334 | } |
| 334 | sanitized.push(result.data); | 335 | sanitized.push(result.value); |
| 335 | continue; | 336 | continue; |
| 336 | } | 337 | } |
| 337 | invalidEntries += 1; | 338 | invalidEntries += 1; |
| ... | @@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig( | ... | @@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig( |
| 417 | 418 | ||
| 418 | export function upsertKeybindingRule( | 419 | export function upsertKeybindingRule( |
| 419 | logger: KeybindingsLogger, | 420 | logger: KeybindingsLogger, |
| 420 | rawRule: unknown, | 421 | rule: KeybindingRule, |
| 421 | ): ResolvedKeybindingsConfig { | 422 | ): ResolvedKeybindingsConfig { |
| 422 | const rule = keybindingRuleSchema.parse(rawRule); | ||
| 423 | if (!compileResolvedKeybindingRule(rule)) { | ||
| 424 | throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); | ||
| 425 | } | ||
| 426 | |||
| 427 | const configPath = resolveKeybindingsConfigPath(); | 423 | const configPath = resolveKeybindingsConfigPath(); |
| 428 | const customConfig = loadCustomKeybindingsConfig(logger, { | 424 | const customConfig = loadCustomKeybindingsConfig(logger, { |
| 429 | throwOnUnreadableConfig: true, | 425 | throwOnUnreadableConfig: true, |
apps/server/src/orchestration/Errors.ts+21-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { SchemaIssue, Schema } from "effect"; | 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 | export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()( | 9 | export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()( |
| 6 | "OrchestrationCommandJsonParseError", | 10 | "OrchestrationCommandJsonParseError", |
| ... | @@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< | ... | @@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< |
| 39 | } | 43 | } |
| 40 | } | 44 | } |
| 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 | export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()( | 59 | export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()( |
| 43 | "OrchestrationProjectorDecodeError", | 60 | "OrchestrationProjectorDecodeError", |
| 44 | { | 61 | { |
| ... | @@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< | ... | @@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< |
| 67 | 84 | ||
| 68 | export type OrchestrationDispatchError = | 85 | export type OrchestrationDispatchError = |
| 69 | | OrchestrationEventStoreError | 86 | | OrchestrationEventStoreError |
| 87 | | OrchestrationCommandReceiptRepositoryError | ||
| 88 | | ProjectionRepositoryError | ||
| 70 | | OrchestrationCommandInvariantError | 89 | | OrchestrationCommandInvariantError |
| 90 | | OrchestrationCommandPreviouslyRejectedError | ||
| 71 | | OrchestrationProjectorDecodeError | 91 | | OrchestrationProjectorDecodeError |
| 72 | | OrchestrationListenerCallbackError; | 92 | | OrchestrationListenerCallbackError; |
| 73 | 93 |
apps/server/src/orchestration/Layer.test.ts+155-203| ... | @@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; | ... | @@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; | 2 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; |
| 3 | import { describe, expect, it } from "vitest"; | 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 | import { | 9 | import { |
| 6 | OrchestrationEventStore, | 10 | OrchestrationEventStore, |
| 7 | type OrchestrationEventStoreShape, | 11 | type OrchestrationEventStoreShape, |
| 8 | } from "../persistence/Services/OrchestrationEventStore.ts"; | 12 | } from "../persistence/Services/OrchestrationEventStore.ts"; |
| 9 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | ||
| 10 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | ||
| 11 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; | 13 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 14 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; | ||
| 12 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | 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 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 18 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 19 | Layer.provide(OrchestrationProjectionPipelineLive), | ||
| 17 | Layer.provide(OrchestrationEventStoreLive), | 20 | Layer.provide(OrchestrationEventStoreLive), |
| 21 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | ||
| 18 | Layer.provide(SqlitePersistenceMemory), | 22 | Layer.provide(SqlitePersistenceMemory), |
| 19 | ); | 23 | ); |
| 20 | const runtime = ManagedRuntime.make(orchestrationLayer); | 24 | const runtime = ManagedRuntime.make(orchestrationLayer); |
| ... | @@ -26,20 +30,33 @@ export async function createOrchestrationSystem() { | ... | @@ -26,20 +30,33 @@ export async function createOrchestrationSystem() { |
| 26 | }; | 30 | }; |
| 27 | } | 31 | } |
| 28 | 32 | ||
| 33 | function now() { | ||
| 34 | return new Date().toISOString(); | ||
| 35 | } | ||
| 36 | |||
| 29 | describe("OrchestrationEngine", () => { | 37 | describe("OrchestrationEngine", () => { |
| 30 | it("returns deterministic read models for repeated reads", async () => { | 38 | it("returns deterministic read models for repeated reads", async () => { |
| 31 | const createdAt = new Date().toISOString(); | 39 | const createdAt = now(); |
| 32 | const projectId = "project-1"; | ||
| 33 | const threadId = "thread-1"; | ||
| 34 | |||
| 35 | const system = await createOrchestrationSystem(); | 40 | const system = await createOrchestrationSystem(); |
| 36 | const engine = system.engine; | 41 | const { engine } = system; |
| 42 | |||
| 43 | await system.run( | ||
| 44 | engine.dispatch({ | ||
| 45 | type: "project.create", | ||
| 46 | commandId: "cmd-project-1-create", | ||
| 47 | projectId: "project-1", | ||
| 48 | title: "Project 1", | ||
| 49 | workspaceRoot: "/tmp/project-1", | ||
| 50 | defaultModel: "gpt-5-codex", | ||
| 51 | createdAt, | ||
| 52 | }), | ||
| 53 | ); | ||
| 37 | await system.run( | 54 | await system.run( |
| 38 | engine.dispatch({ | 55 | engine.dispatch({ |
| 39 | type: "thread.create", | 56 | type: "thread.create", |
| 40 | commandId: "cmd-1", | 57 | commandId: "cmd-thread-1-create", |
| 41 | threadId, | 58 | threadId: "thread-1", |
| 42 | projectId, | 59 | projectId: "project-1", |
| 43 | title: "Thread", | 60 | title: "Thread", |
| 44 | model: "gpt-5-codex", | 61 | model: "gpt-5-codex", |
| 45 | branch: null, | 62 | branch: null, |
| ... | @@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => { | ... | @@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => { |
| 49 | ); | 66 | ); |
| 50 | await system.run( | 67 | await system.run( |
| 51 | engine.dispatch({ | 68 | engine.dispatch({ |
| 52 | type: "message.send", | 69 | type: "thread.turn.start", |
| 53 | commandId: "cmd-3", | 70 | commandId: "cmd-turn-start-1", |
| 54 | threadId, | 71 | threadId: "thread-1", |
| 55 | messageId: "msg-1", | 72 | message: { |
| 56 | role: "user", | 73 | messageId: "msg-1", |
| 57 | text: "hello", | 74 | role: "user", |
| 58 | streaming: false, | 75 | text: "hello", |
| 76 | attachments: [], | ||
| 77 | }, | ||
| 59 | createdAt, | 78 | createdAt, |
| 60 | }), | 79 | }), |
| 61 | ); | 80 | ); |
| 81 | |||
| 62 | const readModelA = await system.run(engine.getReadModel()); | 82 | const readModelA = await system.run(engine.getReadModel()); |
| 63 | const readModelB = await system.run(engine.getReadModel()); | 83 | const readModelB = await system.run(engine.getReadModel()); |
| 64 | expect(readModelB).toEqual(readModelA); | 84 | expect(readModelB).toEqual(readModelA); |
| ... | @@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => { | ... | @@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => { |
| 67 | 87 | ||
| 68 | it("replays append-only events from sequence", async () => { | 88 | it("replays append-only events from sequence", async () => { |
| 69 | const system = await createOrchestrationSystem(); | 89 | const system = await createOrchestrationSystem(); |
| 70 | const engine = system.engine; | 90 | const { engine } = system; |
| 91 | const createdAt = now(); | ||
| 92 | |||
| 93 | await system.run( | ||
| 94 | engine.dispatch({ | ||
| 95 | type: "project.create", | ||
| 96 | commandId: "cmd-project-replay-create", | ||
| 97 | projectId: "project-replay", | ||
| 98 | title: "Replay Project", | ||
| 99 | workspaceRoot: "/tmp/project-replay", | ||
| 100 | defaultModel: "gpt-5-codex", | ||
| 101 | createdAt, | ||
| 102 | }), | ||
| 103 | ); | ||
| 71 | await system.run( | 104 | await system.run( |
| 72 | engine.dispatch({ | 105 | engine.dispatch({ |
| 73 | type: "thread.create", | 106 | type: "thread.create", |
| 74 | commandId: "cmd-a", | 107 | commandId: "cmd-thread-replay-create", |
| 75 | threadId: "thread-replay", | 108 | threadId: "thread-replay", |
| 76 | projectId: "project-replay", | 109 | projectId: "project-replay", |
| 77 | title: "replay", | 110 | title: "replay", |
| 78 | model: "gpt-5-codex", | 111 | model: "gpt-5-codex", |
| 79 | branch: null, | 112 | branch: null, |
| 80 | worktreePath: null, | 113 | worktreePath: null, |
| 81 | createdAt: new Date().toISOString(), | 114 | createdAt, |
| 82 | }), | 115 | }), |
| 83 | ); | 116 | ); |
| 84 | await system.run( | 117 | await system.run( |
| 85 | engine.dispatch({ | 118 | engine.dispatch({ |
| 86 | type: "thread.delete", | 119 | type: "thread.delete", |
| 87 | commandId: "cmd-b", | 120 | commandId: "cmd-thread-replay-delete", |
| 88 | threadId: "thread-replay", | 121 | threadId: "thread-replay", |
| 89 | createdAt: new Date().toISOString(), | ||
| 90 | }), | 122 | }), |
| 91 | ); | 123 | ); |
| 124 | |||
| 92 | const events = await system.run( | 125 | const events = await system.run( |
| 93 | Stream.runCollect(engine.readEvents(0)).pipe( | 126 | Stream.runCollect(engine.readEvents(0)).pipe( |
| 94 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), | 127 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), |
| 95 | ), | 128 | ), |
| 96 | ); | 129 | ); |
| 97 | expect(events.length).toBe(2); | 130 | expect(events.map((event) => event.type)).toEqual([ |
| 98 | expect(events[0]?.type).toBe("thread.created"); | 131 | "project.created", |
| 99 | expect(events[1]?.type).toBe("thread.deleted"); | 132 | "thread.created", |
| 133 | "thread.deleted", | ||
| 134 | ]); | ||
| 100 | await system.dispose(); | 135 | await system.dispose(); |
| 101 | }); | 136 | }); |
| 102 | 137 | ||
| 103 | it("streams persisted domain events in order", async () => { | 138 | it("streams persisted domain events in order", async () => { |
| 104 | const system = await createOrchestrationSystem(); | 139 | const system = await createOrchestrationSystem(); |
| 105 | const engine = system.engine; | 140 | const { engine } = system; |
| 141 | const createdAt = now(); | ||
| 142 | |||
| 143 | await system.run( | ||
| 144 | engine.dispatch({ | ||
| 145 | type: "project.create", | ||
| 146 | commandId: "cmd-project-stream-create", | ||
| 147 | projectId: "project-stream", | ||
| 148 | title: "Stream Project", | ||
| 149 | workspaceRoot: "/tmp/project-stream", | ||
| 150 | defaultModel: "gpt-5-codex", | ||
| 151 | createdAt, | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 106 | const eventTypes: string[] = []; | 155 | const eventTypes: string[] = []; |
| 107 | await system.run( | 156 | await system.run( |
| 108 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| ... | @@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => { | ... | @@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => { |
| 113 | ), | 162 | ), |
| 114 | ); | 163 | ); |
| 115 | yield* Effect.sleep("10 millis"); | 164 | yield* Effect.sleep("10 millis"); |
| 116 | const createdAt = new Date().toISOString(); | ||
| 117 | yield* engine.dispatch({ | 165 | yield* engine.dispatch({ |
| 118 | type: "thread.create", | 166 | type: "thread.create", |
| 119 | commandId: "cmd-stream-domain-a", | 167 | commandId: "cmd-stream-thread-create", |
| 120 | threadId: "thread-stream-domain", | 168 | threadId: "thread-stream", |
| 121 | projectId: "project-stream-domain", | 169 | projectId: "project-stream", |
| 122 | title: "domain-stream", | 170 | title: "domain-stream", |
| 123 | model: "gpt-5-codex", | 171 | model: "gpt-5-codex", |
| 124 | branch: null, | 172 | branch: null, |
| ... | @@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => { | ... | @@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => { |
| 127 | }); | 175 | }); |
| 128 | yield* engine.dispatch({ | 176 | yield* engine.dispatch({ |
| 129 | type: "thread.meta.update", | 177 | type: "thread.meta.update", |
| 130 | commandId: "cmd-stream-domain-b", | 178 | commandId: "cmd-stream-thread-update", |
| 131 | threadId: "thread-stream-domain", | 179 | threadId: "thread-stream", |
| 132 | title: "domain-stream-updated", | 180 | title: "domain-stream-updated", |
| 133 | createdAt, | ||
| 134 | }); | 181 | }); |
| 135 | eventTypes.push((yield* Queue.take(eventQueue)).type); | 182 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| 136 | eventTypes.push((yield* Queue.take(eventQueue)).type); | 183 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| 137 | }).pipe(Effect.scoped), | 184 | }).pipe(Effect.scoped), |
| 138 | ); | 185 | ); |
| 186 | |||
| 139 | expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); | 187 | expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); |
| 140 | await system.dispose(); | 188 | await system.dispose(); |
| 141 | }); | 189 | }); |
| 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 | const system = await createOrchestrationSystem(); | 192 | const system = await createOrchestrationSystem(); |
| 145 | const engine = system.engine; | 193 | const { engine } = system; |
| 146 | const completedAt = new Date().toISOString(); | 194 | const createdAt = now(); |
| 195 | |||
| 196 | await system.run( | ||
| 197 | engine.dispatch({ | ||
| 198 | type: "project.create", | ||
| 199 | commandId: "cmd-project-turn-diff-create", | ||
| 200 | projectId: "project-turn-diff", | ||
| 201 | title: "Turn Diff Project", | ||
| 202 | workspaceRoot: "/tmp/project-turn-diff", | ||
| 203 | defaultModel: "gpt-5-codex", | ||
| 204 | createdAt, | ||
| 205 | }), | ||
| 206 | ); | ||
| 147 | await system.run( | 207 | await system.run( |
| 148 | engine.dispatch({ | 208 | engine.dispatch({ |
| 149 | type: "thread.create", | 209 | type: "thread.create", |
| 150 | commandId: "cmd-thread-turn-diff", | 210 | commandId: "cmd-thread-turn-diff-create", |
| 151 | threadId: "thread-turn-diff", | 211 | threadId: "thread-turn-diff", |
| 152 | projectId: "project-turn-diff", | 212 | projectId: "project-turn-diff", |
| 153 | title: "Turn diff thread", | 213 | title: "Turn diff thread", |
| 154 | model: "gpt-5-codex", | 214 | model: "gpt-5-codex", |
| 155 | branch: null, | 215 | branch: null, |
| 156 | worktreePath: null, | 216 | worktreePath: null, |
| 157 | createdAt: completedAt, | 217 | createdAt, |
| 158 | }), | 218 | }), |
| 159 | ); | 219 | ); |
| 160 | await system.run( | 220 | await system.run( |
| 161 | engine.dispatch({ | 221 | engine.dispatch({ |
| 162 | type: "thread.turnDiff.complete", | 222 | type: "thread.turn.diff.complete", |
| 163 | commandId: "cmd-turn-diff-complete", | 223 | commandId: "cmd-turn-diff-complete", |
| 164 | threadId: "thread-turn-diff", | 224 | threadId: "thread-turn-diff", |
| 165 | turnId: "turn-1", | 225 | turnId: "turn-1", |
| 166 | completedAt, | 226 | completedAt: createdAt, |
| 167 | status: "completed", | 227 | checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", |
| 228 | status: "ready", | ||
| 168 | files: [], | 229 | files: [], |
| 169 | checkpointTurnCount: 1, | 230 | checkpointTurnCount: 1, |
| 170 | createdAt: completedAt, | 231 | createdAt, |
| 171 | }), | 232 | }), |
| 172 | ); | 233 | ); |
| 173 | 234 | ||
| 174 | const firstThread = (await system.run(engine.getReadModel())).threads.find( | 235 | const thread = (await system.run(engine.getReadModel())).threads.find( |
| 175 | (thread) => thread.id === "thread-turn-diff", | 236 | (entry) => entry.id === "thread-turn-diff", |
| 176 | ); | 237 | ); |
| 177 | expect(firstThread?.turnDiffSummaries).toEqual([ | 238 | expect(thread?.checkpoints).toEqual([ |
| 178 | { | 239 | { |
| 179 | turnId: "turn-1", | 240 | turnId: "turn-1", |
| 180 | completedAt, | ||
| 181 | status: "completed", | ||
| 182 | files: [], | ||
| 183 | checkpointTurnCount: 1, | 241 | checkpointTurnCount: 1, |
| 242 | checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", | ||
| 243 | status: "ready", | ||
| 244 | files: [], | ||
| 245 | assistantMessageId: null, | ||
| 246 | completedAt: createdAt, | ||
| 184 | }, | 247 | }, |
| 185 | ]); | 248 | ]); |
| 186 | await system.dispose(); | 249 | await system.dispose(); |
| 187 | }); | 250 | }); |
| 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 | it("keeps processing queued commands after a storage failure", async () => { | 252 | it("keeps processing queued commands after a storage failure", async () => { |
| 328 | const events: OrchestrationEvent[] = []; | 253 | const events: OrchestrationEvent[] = []; |
| 329 | let nextSequence = 1; | 254 | let nextSequence = 1; |
| ... | @@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => { |
| 331 | 256 | ||
| 332 | const flakyStore: OrchestrationEventStoreShape = { | 257 | const flakyStore: OrchestrationEventStoreShape = { |
| 333 | append(event) { | 258 | append(event) { |
| 334 | if (shouldFailFirstAppend) { | 259 | if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") { |
| 335 | shouldFailFirstAppend = false; | 260 | shouldFailFirstAppend = false; |
| 336 | return Effect.fail( | 261 | return Effect.fail( |
| 337 | new PersistenceSqlError({ | 262 | new PersistenceSqlError({ |
| ... | @@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => { | ... | @@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => { |
| 358 | 283 | ||
| 359 | const runtime = ManagedRuntime.make( | 284 | const runtime = ManagedRuntime.make( |
| 360 | OrchestrationEngineLive.pipe( | 285 | OrchestrationEngineLive.pipe( |
| 286 | Layer.provide(OrchestrationProjectionPipelineLive), | ||
| 361 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | 287 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 288 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | ||
| 289 | Layer.provide(SqlitePersistenceMemory), | ||
| 362 | ), | 290 | ), |
| 363 | ); | 291 | ); |
| 364 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | 292 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 365 | const createdAt = new Date().toISOString(); | 293 | const createdAt = now(); |
| 294 | |||
| 295 | await runtime.runPromise( | ||
| 296 | engine.dispatch({ | ||
| 297 | type: "project.create", | ||
| 298 | commandId: "cmd-project-flaky-create", | ||
| 299 | projectId: "project-flaky", | ||
| 300 | title: "Flaky Project", | ||
| 301 | workspaceRoot: "/tmp/project-flaky", | ||
| 302 | defaultModel: "gpt-5-codex", | ||
| 303 | createdAt, | ||
| 304 | }), | ||
| 305 | ); | ||
| 366 | 306 | ||
| 367 | await expect( | 307 | await expect( |
| 368 | runtime.runPromise( | 308 | runtime.runPromise( |
| ... | @@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => { | ... | @@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => { |
| 394 | }), | 334 | }), |
| 395 | ); | 335 | ); |
| 396 | 336 | ||
| 397 | expect(result.sequence).toBe(1); | 337 | expect(result.sequence).toBe(2); |
| 398 | expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1); | 338 | expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); |
| 399 | |||
| 400 | await runtime.dispose(); | 339 | await runtime.dispose(); |
| 401 | }); | 340 | }); |
| 402 | 341 | ||
| 403 | it("fails command dispatch when command invariants are violated", async () => { | 342 | it("fails command dispatch when command invariants are violated", async () => { |
| 404 | const system = await createOrchestrationSystem(); | 343 | const system = await createOrchestrationSystem(); |
| 405 | const engine = system.engine; | 344 | const { engine } = system; |
| 406 | const createdAt = new Date().toISOString(); | ||
| 407 | 345 | ||
| 408 | await expect( | 346 | await expect( |
| 409 | system.run( | 347 | system.run( |
| 410 | engine.dispatch({ | 348 | engine.dispatch({ |
| 411 | type: "message.send", | 349 | type: "thread.turn.start", |
| 412 | commandId: "cmd-invariant-missing-thread", | 350 | commandId: "cmd-invariant-missing-thread", |
| 413 | threadId: "thread-missing", | 351 | threadId: "thread-missing", |
| 414 | messageId: "msg-missing", | 352 | message: { |
| 415 | role: "user", | 353 | messageId: "msg-missing", |
| 416 | text: "hello", | 354 | role: "user", |
| 417 | streaming: false, | 355 | text: "hello", |
| 418 | createdAt, | 356 | attachments: [], |
| 357 | }, | ||
| 358 | createdAt: now(), | ||
| 419 | }), | 359 | }), |
| 420 | ), | 360 | ), |
| 421 | ).rejects.toThrow("Thread 'thread-missing' does not exist"); | 361 | ).rejects.toThrow("Thread 'thread-missing' does not exist"); |
| ... | @@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => { | ... | @@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => { |
| 425 | 365 | ||
| 426 | it("rejects duplicate thread creation", async () => { | 366 | it("rejects duplicate thread creation", async () => { |
| 427 | const system = await createOrchestrationSystem(); | 367 | const system = await createOrchestrationSystem(); |
| 428 | const engine = system.engine; | 368 | const { engine } = system; |
| 429 | const createdAt = new Date().toISOString(); | 369 | const createdAt = now(); |
| 370 | |||
| 371 | await system.run( | ||
| 372 | engine.dispatch({ | ||
| 373 | type: "project.create", | ||
| 374 | commandId: "cmd-project-duplicate-create", | ||
| 375 | projectId: "project-duplicate", | ||
| 376 | title: "Duplicate Project", | ||
| 377 | workspaceRoot: "/tmp/project-duplicate", | ||
| 378 | defaultModel: "gpt-5-codex", | ||
| 379 | createdAt, | ||
| 380 | }), | ||
| 381 | ); | ||
| 430 | 382 | ||
| 431 | await system.run( | 383 | await system.run( |
| 432 | engine.dispatch({ | 384 | engine.dispatch({ |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts created+610| ... | @@ -0,0 +1,610 @@ | ||
| 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,20 +1,24 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationCommand, | ||
| 3 | OrchestrationEvent, | 2 | OrchestrationEvent, |
| 4 | OrchestrationReadModel, | 3 | OrchestrationReadModel, |
| 4 | ProjectId, | ||
| 5 | ThreadId, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | 7 | import { OrchestrationCommand } from "@t3tools/contracts"; |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; | 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; |
| 8 | 9 | ||
| 9 | import { createLogger } from "../../logger.ts"; | ||
| 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 11 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | ||
| 11 | import { | 12 | import { |
| 12 | toOrchestrationCommandDecodeError, | 13 | toOrchestrationCommandDecodeError, |
| 13 | toOrchestrationJsonParseError, | 14 | toOrchestrationJsonParseError, |
| 15 | OrchestrationCommandInvariantError, | ||
| 16 | OrchestrationCommandPreviouslyRejectedError, | ||
| 14 | type OrchestrationDispatchError, | 17 | type OrchestrationDispatchError, |
| 15 | } from "../Errors.ts"; | 18 | } from "../Errors.ts"; |
| 16 | import { decideOrchestrationCommand } from "../decider.ts"; | 19 | import { decideOrchestrationCommand } from "../decider.ts"; |
| 17 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; | 20 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; |
| 21 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | ||
| 18 | import { | 22 | import { |
| 19 | OrchestrationEngineService, | 23 | OrchestrationEngineService, |
| 20 | type OrchestrationEngineShape, | 24 | type OrchestrationEngineShape, |
| ... | @@ -25,11 +29,39 @@ interface CommandEnvelope { | ... | @@ -25,11 +29,39 @@ interface CommandEnvelope { |
| 25 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | 29 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 26 | } | 30 | } |
| 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 | const makeOrchestrationEngine = Effect.gen(function* () { | 61 | const makeOrchestrationEngine = Effect.gen(function* () { |
| 31 | const logger = createLogger("orchestration"); | ||
| 32 | const eventStore = yield* OrchestrationEventStore; | 62 | const eventStore = yield* OrchestrationEventStore; |
| 63 | const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; | ||
| 64 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 33 | 65 | ||
| 34 | let readModel = createEmptyReadModel(new Date().toISOString()); | 66 | let readModel = createEmptyReadModel(new Date().toISOString()); |
| 35 | 67 | ||
| ... | @@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 38 | 70 | ||
| 39 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | 71 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => |
| 40 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| 73 | const existingReceipt = yield* commandReceiptRepository.getByCommandId({ | ||
| 74 | commandId: envelope.command.commandId, | ||
| 75 | }); | ||
| 76 | if (Option.isSome(existingReceipt)) { | ||
| 77 | if (existingReceipt.value.status === "accepted") { | ||
| 78 | yield* Deferred.succeed(envelope.result, { | ||
| 79 | sequence: existingReceipt.value.resultSequence, | ||
| 80 | }); | ||
| 81 | return; | ||
| 82 | } | ||
| 83 | yield* Deferred.fail( | ||
| 84 | envelope.result, | ||
| 85 | new OrchestrationCommandPreviouslyRejectedError({ | ||
| 86 | commandId: envelope.command.commandId, | ||
| 87 | detail: existingReceipt.value.error ?? "Previously rejected.", | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | return; | ||
| 91 | } | ||
| 92 | |||
| 41 | const eventBase = yield* decideOrchestrationCommand({ | 93 | const eventBase = yield* decideOrchestrationCommand({ |
| 42 | command: envelope.command, | 94 | command: envelope.command, |
| 43 | readModel, | 95 | readModel, |
| 44 | }); | 96 | }); |
| 45 | const savedEvent = yield* eventStore.append(eventBase); | 97 | const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; |
| 46 | readModel = yield* projectEvent(readModel, savedEvent); | 98 | let lastSavedEvent = null as OrchestrationEvent | null; |
| 47 | yield* PubSub.publish(eventPubSub, savedEvent); | 99 | |
| 48 | 100 | for (const nextEvent of eventBases) { | |
| 49 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | 101 | const savedEvent = yield* eventStore.append(nextEvent); |
| 50 | }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); | 102 | yield* projectionPipeline.projectEvent(savedEvent); |
| 51 | 103 | readModel = yield* projectEvent(readModel, savedEvent); | |
| 52 | // bootstrap read model from event store | 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 | yield* Stream.runForEach(eventStore.readAll(), (event) => | 153 | yield* Stream.runForEach(eventStore.readAll(), (event) => |
| 54 | Effect.gen(function* () { | 154 | Effect.gen(function* () { |
| 55 | readModel = yield* projectEvent(readModel, event); | 155 | readModel = yield* projectEvent(readModel, event); |
| ... | @@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 58 | 158 | ||
| 59 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | 159 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); |
| 60 | yield* Effect.forkScoped(worker); | 160 | yield* Effect.forkScoped(worker); |
| 61 | logger.info("orchestration engine started", { | 161 | yield* Effect.log("orchestration engine started").pipe( |
| 62 | sequence: readModel.sequence, | 162 | Effect.annotateLogs({ sequence: readModel.snapshotSequence }), |
| 63 | }); | 163 | ); |
| 64 | 164 | ||
| 65 | const getReadModel: OrchestrationEngineShape["getReadModel"] = () => | 165 | const getReadModel: OrchestrationEngineShape["getReadModel"] = () => |
| 66 | Effect.sync((): OrchestrationReadModel => readModel); | 166 | Effect.sync((): OrchestrationReadModel => readModel); |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 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,14 +1,26 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | 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 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | 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 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 15 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 16 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | ||
| 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 17 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | import { | 18 | import { |
| 8 | ProviderService, | 19 | ProviderService, |
| 9 | type ProviderServiceShape, | 20 | type ProviderServiceShape, |
| 10 | } from "../../provider/Services/ProviderService.ts"; | 21 | } from "../../provider/Services/ProviderService.ts"; |
| 11 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 22 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | ||
| 12 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; | 24 | import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; |
| 13 | import { | 25 | import { |
| 14 | OrchestrationEngineService, | 26 | OrchestrationEngineService, |
| ... | @@ -16,17 +28,16 @@ import { | ... | @@ -16,17 +28,16 @@ import { |
| 16 | } from "../Services/OrchestrationEngine.ts"; | 28 | } from "../Services/OrchestrationEngine.ts"; |
| 17 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 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 | function createProviderServiceHarness() { | 39 | function createProviderServiceHarness() { |
| 20 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 40 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 21 | const getCheckpointDiff = vi.fn( | ||
| 22 | (input: { sessionId: string; fromTurnCount: number; toTurnCount: number }) => | ||
| 23 | Effect.succeed({ | ||
| 24 | threadId: "thread-1", | ||
| 25 | fromTurnCount: input.fromTurnCount, | ||
| 26 | toTurnCount: input.toTurnCount, | ||
| 27 | diff: "", | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | 41 | ||
| 31 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | 42 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 32 | const service: ProviderServiceShape = { | 43 | const service: ProviderServiceShape = { |
| ... | @@ -36,9 +47,7 @@ function createProviderServiceHarness() { | ... | @@ -36,9 +47,7 @@ function createProviderServiceHarness() { |
| 36 | respondToRequest: () => unsupported(), | 47 | respondToRequest: () => unsupported(), |
| 37 | stopSession: () => unsupported(), | 48 | stopSession: () => unsupported(), |
| 38 | listSessions: () => Effect.succeed([]), | 49 | listSessions: () => Effect.succeed([]), |
| 39 | listCheckpoints: () => unsupported(), | 50 | rollbackConversation: () => unsupported(), |
| 40 | getCheckpointDiff, | ||
| 41 | revertToCheckpoint: () => unsupported(), | ||
| 42 | stopAll: () => Effect.void, | 51 | stopAll: () => Effect.void, |
| 43 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 52 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 44 | }; | 53 | }; |
| ... | @@ -50,7 +59,6 @@ function createProviderServiceHarness() { | ... | @@ -50,7 +59,6 @@ function createProviderServiceHarness() { |
| 50 | return { | 59 | return { |
| 51 | service, | 60 | service, |
| 52 | emit, | 61 | emit, |
| 53 | getCheckpointDiff, | ||
| 54 | }; | 62 | }; |
| 55 | } | 63 | } |
| 56 | 64 | ||
| ... | @@ -59,6 +67,7 @@ async function waitForThread( | ... | @@ -59,6 +67,7 @@ async function waitForThread( |
| 59 | predicate: (thread: { | 67 | predicate: (thread: { |
| 60 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; | 68 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; |
| 61 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; | 69 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; |
| 70 | activities: ReadonlyArray<{ kind: string }>; | ||
| 62 | }) => boolean, | 71 | }) => boolean, |
| 63 | timeoutMs = 2000, | 72 | timeoutMs = 2000, |
| 64 | ) { | 73 | ) { |
| ... | @@ -66,9 +75,10 @@ async function waitForThread( | ... | @@ -66,9 +75,10 @@ async function waitForThread( |
| 66 | const poll = async (): Promise<{ | 75 | const poll = async (): Promise<{ |
| 67 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; | 76 | session: { status: string; activeTurnId: string | null; lastError: string | null } | null; |
| 68 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; | 77 | messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; |
| 78 | activities: ReadonlyArray<{ kind: string }>; | ||
| 69 | }> => { | 79 | }> => { |
| 70 | const readModel = await Effect.runPromise(engine.getReadModel()); | 80 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 71 | const thread = readModel.threads.find((entry) => entry.id === "thread-1"); | 81 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); |
| 72 | if (thread && predicate(thread)) { | 82 | if (thread && predicate(thread)) { |
| 73 | return thread; | 83 | return thread; |
| 74 | } | 84 | } |
| ... | @@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => { |
| 102 | async function createHarness() { | 112 | async function createHarness() { |
| 103 | const provider = createProviderServiceHarness(); | 113 | const provider = createProviderServiceHarness(); |
| 104 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 114 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 115 | Layer.provide(OrchestrationProjectionPipelineLive), | ||
| 105 | Layer.provide(OrchestrationEventStoreLive), | 116 | Layer.provide(OrchestrationEventStoreLive), |
| 117 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | ||
| 106 | Layer.provide(SqlitePersistenceMemory), | 118 | Layer.provide(SqlitePersistenceMemory), |
| 107 | ); | 119 | ); |
| 108 | const layer = ProviderRuntimeIngestionLive.pipe( | 120 | const layer = ProviderRuntimeIngestionLive.pipe( |
| ... | @@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => { |
| 117 | await Effect.runPromise(Effect.sleep("10 millis")); | 129 | await Effect.runPromise(Effect.sleep("10 millis")); |
| 118 | 130 | ||
| 119 | const createdAt = new Date().toISOString(); | 131 | const createdAt = new Date().toISOString(); |
| 132 | await Effect.runPromise( | ||
| 133 | engine.dispatch({ | ||
| 134 | type: "project.create", | ||
| 135 | commandId: asCommandId("cmd-provider-project-create"), | ||
| 136 | projectId: asProjectId("project-1"), | ||
| 137 | title: "Provider Project", | ||
| 138 | workspaceRoot: "/tmp/provider-project", | ||
| 139 | defaultModel: "gpt-5-codex", | ||
| 140 | createdAt, | ||
| 141 | }), | ||
| 142 | ); | ||
| 120 | await Effect.runPromise( | 143 | await Effect.runPromise( |
| 121 | engine.dispatch({ | 144 | engine.dispatch({ |
| 122 | type: "thread.create", | 145 | type: "thread.create", |
| 123 | commandId: "cmd-thread-create", | 146 | commandId: asCommandId("cmd-thread-create"), |
| 124 | threadId: "thread-1", | 147 | threadId: asThreadId("thread-1"), |
| 125 | projectId: "project-1", | 148 | projectId: asProjectId("project-1"), |
| 126 | title: "Thread", | 149 | title: "Thread", |
| 127 | model: "gpt-5-codex", | 150 | model: "gpt-5-codex", |
| 128 | branch: null, | 151 | branch: null, |
| ... | @@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => { |
| 132 | ); | 155 | ); |
| 133 | await Effect.runPromise( | 156 | await Effect.runPromise( |
| 134 | engine.dispatch({ | 157 | engine.dispatch({ |
| 135 | type: "thread.session", | 158 | type: "thread.session.set", |
| 136 | commandId: "cmd-session-seed", | 159 | commandId: asCommandId("cmd-session-seed"), |
| 137 | threadId: "thread-1", | 160 | threadId: asThreadId("thread-1"), |
| 138 | session: { | 161 | session: { |
| 139 | sessionId: "sess-1", | 162 | threadId: asThreadId("thread-1"), |
| 140 | provider: "codex", | ||
| 141 | status: "ready", | 163 | status: "ready", |
| 142 | threadId: "thread-1", | 164 | providerName: "codex", |
| 165 | providerSessionId: asSessionId("sess-1"), | ||
| 166 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | ||
| 143 | activeTurnId: null, | 167 | activeTurnId: null, |
| 144 | createdAt, | ||
| 145 | updatedAt: createdAt, | 168 | updatedAt: createdAt, |
| 146 | lastError: null, | 169 | lastError: null, |
| 147 | }, | 170 | }, |
| ... | @@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 161 | 184 | ||
| 162 | harness.emit({ | 185 | harness.emit({ |
| 163 | type: "turn.started", | 186 | type: "turn.started", |
| 164 | eventId: "evt-turn-started", | 187 | eventId: asEventId("evt-turn-started"), |
| 165 | provider: "codex", | 188 | provider: "codex", |
| 166 | sessionId: "sess-1", | 189 | sessionId: asSessionId("sess-1"), |
| 167 | createdAt: now, | 190 | createdAt: now, |
| 168 | turnId: "turn-1", | 191 | turnId: asProviderTurnId("turn-1"), |
| 169 | }); | 192 | }); |
| 170 | 193 | ||
| 171 | await waitForThread( | 194 | await waitForThread( |
| ... | @@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 175 | 198 | ||
| 176 | harness.emit({ | 199 | harness.emit({ |
| 177 | type: "turn.completed", | 200 | type: "turn.completed", |
| 178 | eventId: "evt-turn-completed", | 201 | eventId: asEventId("evt-turn-completed"), |
| 179 | provider: "codex", | 202 | provider: "codex", |
| 180 | sessionId: "sess-1", | 203 | sessionId: asSessionId("sess-1"), |
| 181 | createdAt: new Date().toISOString(), | 204 | createdAt: new Date().toISOString(), |
| 182 | turnId: "turn-1", | 205 | turnId: asProviderTurnId("turn-1"), |
| 183 | status: "failed", | 206 | status: "failed", |
| 184 | errorMessage: "turn failed", | 207 | errorMessage: "turn failed", |
| 185 | }); | 208 | }); |
| ... | @@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 187 | const thread = await waitForThread( | 210 | const thread = await waitForThread( |
| 188 | harness.engine, | 211 | harness.engine, |
| 189 | (entry) => | 212 | (entry) => |
| 190 | entry.session?.status === "ready" && | 213 | entry.session?.status === "error" && |
| 191 | entry.session?.activeTurnId === null && | 214 | entry.session?.activeTurnId === null && |
| 192 | entry.session?.lastError === "turn failed", | 215 | entry.session?.lastError === "turn failed", |
| 193 | ); | 216 | ); |
| 194 | expect(thread.session?.status).toBe("ready"); | 217 | expect(thread.session?.status).toBe("error"); |
| 195 | expect(thread.session?.lastError).toBe("turn failed"); | 218 | expect(thread.session?.lastError).toBe("turn failed"); |
| 196 | }); | 219 | }); |
| 197 | 220 | ||
| ... | @@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => { |
| 201 | 224 | ||
| 202 | harness.emit({ | 225 | harness.emit({ |
| 203 | type: "message.delta", | 226 | type: "message.delta", |
| 204 | eventId: "evt-message-delta-1", | 227 | eventId: asEventId("evt-message-delta-1"), |
| 205 | provider: "codex", | 228 | provider: "codex", |
| 206 | sessionId: "sess-1", | 229 | sessionId: asSessionId("sess-1"), |
| 207 | createdAt: now, | 230 | createdAt: now, |
| 208 | turnId: "turn-2", | 231 | turnId: asProviderTurnId("turn-2"), |
| 209 | itemId: "item-1", | 232 | itemId: asItemId("item-1"), |
| 210 | delta: "hello", | 233 | delta: "hello", |
| 211 | }); | 234 | }); |
| 212 | harness.emit({ | 235 | harness.emit({ |
| 213 | type: "message.delta", | 236 | type: "message.delta", |
| 214 | eventId: "evt-message-delta-2", | 237 | eventId: asEventId("evt-message-delta-2"), |
| 215 | provider: "codex", | 238 | provider: "codex", |
| 216 | sessionId: "sess-1", | 239 | sessionId: asSessionId("sess-1"), |
| 217 | createdAt: now, | 240 | createdAt: now, |
| 218 | turnId: "turn-2", | 241 | turnId: asProviderTurnId("turn-2"), |
| 219 | itemId: "item-1", | 242 | itemId: asItemId("item-1"), |
| 220 | delta: " world", | 243 | delta: " world", |
| 221 | }); | 244 | }); |
| 222 | harness.emit({ | 245 | harness.emit({ |
| 223 | type: "message.completed", | 246 | type: "message.completed", |
| 224 | eventId: "evt-message-completed", | 247 | eventId: asEventId("evt-message-completed"), |
| 225 | provider: "codex", | 248 | provider: "codex", |
| 226 | sessionId: "sess-1", | 249 | sessionId: asSessionId("sess-1"), |
| 227 | createdAt: now, | 250 | createdAt: now, |
| 228 | turnId: "turn-2", | 251 | turnId: asProviderTurnId("turn-2"), |
| 229 | itemId: "item-1", | 252 | itemId: asItemId("item-1"), |
| 230 | }); | 253 | }); |
| 231 | 254 | ||
| 232 | const thread = await waitForThread(harness.engine, (entry) => | 255 | const thread = await waitForThread(harness.engine, (entry) => |
| ... | @@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => { |
| 243 | 266 | ||
| 244 | harness.emit({ | 267 | harness.emit({ |
| 245 | type: "runtime.error", | 268 | type: "runtime.error", |
| 246 | eventId: "evt-runtime-error", | 269 | eventId: asEventId("evt-runtime-error"), |
| 247 | provider: "codex", | 270 | provider: "codex", |
| 248 | sessionId: "sess-1", | 271 | sessionId: asSessionId("sess-1"), |
| 249 | createdAt: now, | 272 | createdAt: now, |
| 250 | turnId: "turn-3", | 273 | turnId: asProviderTurnId("turn-3"), |
| 251 | message: "runtime exploded", | 274 | message: "runtime exploded", |
| 252 | }); | 275 | }); |
| 253 | 276 | ||
| ... | @@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => { |
| 261 | expect(thread.session?.status).toBe("error"); | 284 | expect(thread.session?.status).toBe("error"); |
| 262 | expect(thread.session?.lastError).toBe("runtime exploded"); | 285 | expect(thread.session?.lastError).toBe("runtime exploded"); |
| 263 | }); | 286 | }); |
| 287 | |||
| 288 | it("maps session/thread lifecycle and tool.started into session/activity projections", async () => { | ||
| 289 | const harness = await createHarness(); | ||
| 290 | const now = new Date().toISOString(); | ||
| 291 | |||
| 292 | harness.emit({ | ||
| 293 | type: "session.started", | ||
| 294 | eventId: asEventId("evt-session-started"), | ||
| 295 | provider: "codex", | ||
| 296 | sessionId: asSessionId("sess-1"), | ||
| 297 | createdAt: now, | ||
| 298 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | ||
| 299 | message: "session started", | ||
| 300 | }); | ||
| 301 | harness.emit({ | ||
| 302 | type: "thread.started", | ||
| 303 | eventId: asEventId("evt-thread-started"), | ||
| 304 | provider: "codex", | ||
| 305 | sessionId: asSessionId("sess-1"), | ||
| 306 | createdAt: now, | ||
| 307 | threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), | ||
| 308 | }); | ||
| 309 | harness.emit({ | ||
| 310 | type: "tool.started", | ||
| 311 | eventId: asEventId("evt-tool-started"), | ||
| 312 | provider: "codex", | ||
| 313 | sessionId: asSessionId("sess-1"), | ||
| 314 | createdAt: now, | ||
| 315 | turnId: asProviderTurnId("turn-9"), | ||
| 316 | toolKind: "read", | ||
| 317 | title: "Read file", | ||
| 318 | detail: "/tmp/file.ts", | ||
| 319 | }); | ||
| 320 | |||
| 321 | const thread = await waitForThread( | ||
| 322 | harness.engine, | ||
| 323 | (entry) => | ||
| 324 | entry.session?.status === "ready" && | ||
| 325 | entry.session?.activeTurnId === null && | ||
| 326 | entry.activities.some((activity) => activity.kind === "tool.started"), | ||
| 327 | ); | ||
| 328 | |||
| 329 | expect(thread.session?.status).toBe("ready"); | ||
| 330 | expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true); | ||
| 331 | }); | ||
| 264 | }); | 332 | }); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+168-151| ... | @@ -1,11 +1,14 @@ | ... | @@ -1,11 +1,14 @@ |
| 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 | import { Effect, Layer, Queue, Stream } from "effect"; | 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 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 12 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 10 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 13 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 11 | import { | 14 | import { |
| ... | @@ -13,87 +16,108 @@ import { | ... | @@ -13,87 +16,108 @@ import { |
| 13 | type ProviderRuntimeIngestionShape, | 16 | type ProviderRuntimeIngestionShape, |
| 14 | } from "../Services/ProviderRuntimeIngestion.ts"; | 17 | } from "../Services/ProviderRuntimeIngestion.ts"; |
| 15 | 18 | ||
| 16 | const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`; | 19 | const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; |
| 17 | 20 | const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => | |
| 18 | type ActivityTone = "thinking" | "tool" | "info" | "error"; | 21 | CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); |
| 19 | 22 | ||
| 20 | interface ThreadActivityInput { | 23 | function toTurnId(value: string | undefined): TurnId | undefined { |
| 21 | readonly id: string; | 24 | const normalized = value?.trim(); |
| 22 | readonly createdAt: string; | 25 | return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined; |
| 23 | readonly label: string; | ||
| 24 | readonly detail?: string; | ||
| 25 | readonly tone: ActivityTone; | ||
| 26 | readonly turnId?: string; | ||
| 27 | readonly requestId?: string; | ||
| 28 | readonly requestKind?: "command" | "file-change"; | ||
| 29 | } | 26 | } |
| 30 | 27 | ||
| 31 | function truncateDetail(value: string, limit = 180): string { | 28 | function truncateDetail(value: string, limit = 180): string { |
| 32 | return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; | 29 | return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; |
| 33 | } | 30 | } |
| 34 | 31 | ||
| 35 | function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<ThreadActivityInput> { | 32 | function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<OrchestrationThreadActivity> { |
| 36 | switch (event.type) { | 33 | switch (event.type) { |
| 37 | case "approval.requested": | 34 | case "approval.requested": { |
| 38 | return [ | 35 | return [ |
| 39 | { | 36 | { |
| 40 | id: event.eventId, | 37 | id: event.eventId, |
| 41 | createdAt: event.createdAt, | 38 | createdAt: event.createdAt, |
| 42 | label: | 39 | tone: "approval", |
| 40 | kind: "approval.requested", | ||
| 41 | summary: | ||
| 43 | event.requestKind === "command" | 42 | event.requestKind === "command" |
| 44 | ? "Command approval requested" | 43 | ? "Command approval requested" |
| 45 | : "File-change approval requested", | 44 | : "File-change approval requested", |
| 46 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | 45 | payload: { |
| 47 | tone: "tool", | 46 | requestId: event.requestId, |
| 48 | ...(event.turnId ? { turnId: event.turnId } : {}), | 47 | requestKind: event.requestKind, |
| 49 | requestId: event.requestId, | 48 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), |
| 50 | requestKind: event.requestKind, | 49 | }, |
| 50 | turnId: toTurnId(event.turnId) ?? null, | ||
| 51 | }, | 51 | }, |
| 52 | ]; | 52 | ]; |
| 53 | case "approval.resolved": | 53 | } |
| 54 | |||
| 55 | case "approval.resolved": { | ||
| 54 | return [ | 56 | return [ |
| 55 | { | 57 | { |
| 56 | id: event.eventId, | 58 | id: event.eventId, |
| 57 | createdAt: event.createdAt, | 59 | createdAt: event.createdAt, |
| 58 | label: "Approval resolved", | 60 | tone: "approval", |
| 59 | tone: "info", | 61 | kind: "approval.resolved", |
| 60 | ...(event.turnId ? { turnId: event.turnId } : {}), | 62 | summary: "Approval resolved", |
| 61 | requestId: event.requestId, | 63 | payload: { |
| 62 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | 64 | requestId: event.requestId, |
| 65 | ...(event.requestKind ? { requestKind: event.requestKind } : {}), | ||
| 66 | ...(event.decision ? { decision: event.decision } : {}), | ||
| 67 | }, | ||
| 68 | turnId: toTurnId(event.turnId) ?? null, | ||
| 63 | }, | 69 | }, |
| 64 | ]; | 70 | ]; |
| 65 | case "runtime.error": | 71 | } |
| 72 | |||
| 73 | case "runtime.error": { | ||
| 66 | return [ | 74 | return [ |
| 67 | { | 75 | { |
| 68 | id: event.eventId, | 76 | id: event.eventId, |
| 69 | createdAt: event.createdAt, | 77 | createdAt: event.createdAt, |
| 70 | label: "Runtime error", | ||
| 71 | detail: truncateDetail(event.message), | ||
| 72 | tone: "error", | 78 | tone: "error", |
| 73 | ...(event.turnId ? { turnId: event.turnId } : {}), | 79 | kind: "runtime.error", |
| 80 | summary: "Runtime error", | ||
| 81 | payload: { | ||
| 82 | message: truncateDetail(event.message), | ||
| 83 | }, | ||
| 84 | turnId: toTurnId(event.turnId) ?? null, | ||
| 74 | }, | 85 | }, |
| 75 | ]; | 86 | ]; |
| 76 | case "checkpoint.captured": | 87 | } |
| 88 | case "tool.completed": { | ||
| 77 | return [ | 89 | return [ |
| 78 | { | 90 | { |
| 79 | id: event.eventId, | 91 | id: event.eventId, |
| 80 | createdAt: event.createdAt, | 92 | createdAt: event.createdAt, |
| 81 | label: "Checkpoint captured", | 93 | tone: "tool", |
| 82 | tone: "info", | 94 | kind: "tool.completed", |
| 83 | ...(event.turnId ? { turnId: event.turnId } : {}), | 95 | summary: `${event.title} complete`, |
| 96 | payload: { | ||
| 97 | toolKind: event.toolKind, | ||
| 98 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | ||
| 99 | }, | ||
| 100 | turnId: toTurnId(event.turnId) ?? null, | ||
| 84 | }, | 101 | }, |
| 85 | ]; | 102 | ]; |
| 86 | case "tool.completed": | 103 | } |
| 104 | case "tool.started": { | ||
| 87 | return [ | 105 | return [ |
| 88 | { | 106 | { |
| 89 | id: event.eventId, | 107 | id: event.eventId, |
| 90 | createdAt: event.createdAt, | 108 | createdAt: event.createdAt, |
| 91 | label: `${event.title} complete`, | ||
| 92 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | ||
| 93 | tone: "tool", | 109 | tone: "tool", |
| 94 | ...(event.turnId ? { turnId: event.turnId } : {}), | 110 | kind: "tool.started", |
| 111 | summary: `${event.title} started`, | ||
| 112 | payload: { | ||
| 113 | toolKind: event.toolKind, | ||
| 114 | ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), | ||
| 115 | }, | ||
| 116 | turnId: toTurnId(event.turnId) ?? null, | ||
| 95 | }, | 117 | }, |
| 96 | ]; | 118 | ]; |
| 119 | } | ||
| 120 | |||
| 97 | default: | 121 | default: |
| 98 | return []; | 122 | return []; |
| 99 | } | 123 | } |
| ... | @@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th | ... | @@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray<Th |
| 102 | const make = Effect.gen(function* () { | 126 | const make = Effect.gen(function* () { |
| 103 | const orchestrationEngine = yield* OrchestrationEngineService; | 127 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 104 | const providerService = yield* ProviderService; | 128 | const providerService = yield* ProviderService; |
| 105 | const logger = createLogger("provider-runtime-ingestion"); | ||
| 106 | 129 | ||
| 107 | const turnMessageIdsByTurnKey = new Map<string, Set<string>>(); | 130 | const turnMessageIdsByTurnKey = new Map<string, Set<MessageId>>(); |
| 108 | const latestMessageIdByTurnKey = new Map<string, string>(); | 131 | const latestMessageIdByTurnKey = new Map<string, MessageId>(); |
| 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 | const key = providerTurnKey(sessionId, turnId); | 138 | const key = providerTurnKey(sessionId, turnId); |
| 112 | const existingIds = turnMessageIdsByTurnKey.get(key); | 139 | const existingIds = turnMessageIdsByTurnKey.get(key); |
| 113 | if (existingIds) { | 140 | if (existingIds) { |
| ... | @@ -118,19 +145,15 @@ const make = Effect.gen(function* () { | ... | @@ -118,19 +145,15 @@ const make = Effect.gen(function* () { |
| 118 | latestMessageIdByTurnKey.set(key, messageId); | 145 | latestMessageIdByTurnKey.set(key, messageId); |
| 119 | }; | 146 | }; |
| 120 | 147 | ||
| 121 | const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { | 148 | const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { |
| 122 | return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<string>(); | 149 | return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set<MessageId>(); |
| 123 | }; | 150 | }; |
| 124 | 151 | ||
| 125 | const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { | 152 | const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { |
| 126 | turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId)); | 153 | turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId)); |
| 127 | }; | 154 | }; |
| 128 | 155 | ||
| 129 | const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => { | 156 | const clearTurnStateForSession = (sessionId: ProviderSessionId) => { |
| 130 | return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId)); | ||
| 131 | }; | ||
| 132 | |||
| 133 | const clearTurnStateForSession = (sessionId: string) => { | ||
| 134 | const prefix = `${sessionId}:`; | 157 | const prefix = `${sessionId}:`; |
| 135 | for (const key of turnMessageIdsByTurnKey.keys()) { | 158 | for (const key of turnMessageIdsByTurnKey.keys()) { |
| 136 | if (key.startsWith(prefix)) { | 159 | if (key.startsWith(prefix)) { |
| ... | @@ -148,144 +171,138 @@ const make = Effect.gen(function* () { | ... | @@ -148,144 +171,138 @@ const make = Effect.gen(function* () { |
| 148 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 149 | const readModel = yield* orchestrationEngine.getReadModel(); | 172 | const readModel = yield* orchestrationEngine.getReadModel(); |
| 150 | const thread = readModel.threads.find( | 173 | const thread = readModel.threads.find( |
| 151 | (entry) => entry.session?.sessionId === event.sessionId, | 174 | (entry) => entry.session?.providerSessionId === event.sessionId, |
| 152 | ); | 175 | ); |
| 153 | if (!thread) return; | 176 | if (!thread) return; |
| 154 | 177 | ||
| 155 | const now = event.createdAt; | 178 | const now = event.createdAt; |
| 156 | if (event.type === "turn.started" || event.type === "turn.completed") { | 179 | |
| 180 | if ( | ||
| 181 | event.type === "session.started" || | ||
| 182 | event.type === "session.exited" || | ||
| 183 | event.type === "thread.started" || | ||
| 184 | event.type === "turn.started" || | ||
| 185 | event.type === "turn.completed" | ||
| 186 | ) { | ||
| 187 | const activeTurnId = event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; | ||
| 188 | const providerThreadIdFromEvent = | ||
| 189 | event.type === "thread.started" | ||
| 190 | ? ProviderThreadId.makeUnsafe(event.threadId) | ||
| 191 | : event.threadId !== undefined | ||
| 192 | ? ProviderThreadId.makeUnsafe(event.threadId) | ||
| 193 | : null; | ||
| 194 | const providerThreadId = providerThreadIdFromEvent ?? (thread.session?.providerThreadId ?? null); | ||
| 195 | const status = | ||
| 196 | event.type === "turn.started" | ||
| 197 | ? "running" | ||
| 198 | : event.type === "session.exited" | ||
| 199 | ? "stopped" | ||
| 200 | : event.type === "turn.completed" && event.status === "failed" | ||
| 201 | ? "error" | ||
| 202 | : "ready"; | ||
| 203 | const lastError = | ||
| 204 | event.type === "turn.completed" && event.status === "failed" | ||
| 205 | ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") | ||
| 206 | : status === "ready" | ||
| 207 | ? null | ||
| 208 | : (thread.session?.lastError ?? null); | ||
| 209 | |||
| 157 | yield* orchestrationEngine.dispatch({ | 210 | yield* orchestrationEngine.dispatch({ |
| 158 | type: "thread.session", | 211 | type: "thread.session.set", |
| 159 | commandId: crypto.randomUUID(), | 212 | commandId: providerCommandId(event, "thread-session-set"), |
| 160 | threadId: thread.id, | 213 | threadId: thread.id, |
| 161 | session: { | 214 | session: { |
| 162 | sessionId: event.sessionId, | ||
| 163 | provider: event.provider, | ||
| 164 | status: event.type === "turn.started" ? "running" : "ready", | ||
| 165 | threadId: thread.id, | 215 | threadId: thread.id, |
| 166 | activeTurnId: event.type === "turn.started" ? event.turnId : null, | 216 | status, |
| 167 | createdAt: thread.session?.createdAt ?? now, | 217 | providerName: event.provider, |
| 218 | providerSessionId: event.sessionId, | ||
| 219 | providerThreadId, | ||
| 220 | activeTurnId, | ||
| 221 | lastError, | ||
| 168 | updatedAt: now, | 222 | updatedAt: now, |
| 169 | lastError: | ||
| 170 | event.type === "turn.completed" && event.status === "failed" | ||
| 171 | ? (event.errorMessage ?? null) | ||
| 172 | : null, | ||
| 173 | }, | 223 | }, |
| 174 | createdAt: now, | 224 | createdAt: now, |
| 175 | }); | 225 | }); |
| 176 | } | 226 | } |
| 177 | 227 | ||
| 178 | if (event.type === "message.delta" && event.delta.length > 0) { | 228 | if (event.type === "message.delta" && event.delta.length > 0) { |
| 179 | const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`; | 229 | const assistantMessageId = MessageId.makeUnsafe( |
| 180 | if (event.turnId) { | 230 | `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, |
| 181 | rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); | 231 | ); |
| 232 | const turnId = toTurnId(event.turnId); | ||
| 233 | if (turnId) { | ||
| 234 | rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | ||
| 182 | } | 235 | } |
| 236 | |||
| 183 | yield* orchestrationEngine.dispatch({ | 237 | yield* orchestrationEngine.dispatch({ |
| 184 | type: "message.send", | 238 | type: "thread.message.assistant.delta", |
| 185 | commandId: crypto.randomUUID(), | 239 | commandId: providerCommandId(event, "assistant-delta"), |
| 186 | threadId: thread.id, | 240 | threadId: thread.id, |
| 187 | messageId: assistantMessageId, | 241 | messageId: assistantMessageId, |
| 188 | role: "assistant", | 242 | delta: event.delta, |
| 189 | text: event.delta, | 243 | ...(turnId ? { turnId } : {}), |
| 190 | streaming: true, | ||
| 191 | createdAt: now, | 244 | createdAt: now, |
| 192 | }); | 245 | }); |
| 193 | } | 246 | } |
| 194 | 247 | ||
| 195 | if (event.type === "message.completed") { | 248 | if (event.type === "message.completed") { |
| 196 | const assistantMessageId = `assistant:${event.itemId}`; | 249 | const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); |
| 197 | if (event.turnId) { | 250 | const turnId = toTurnId(event.turnId); |
| 198 | rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); | 251 | if (turnId) { |
| 252 | rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); | ||
| 199 | } | 253 | } |
| 254 | |||
| 200 | yield* orchestrationEngine.dispatch({ | 255 | yield* orchestrationEngine.dispatch({ |
| 201 | type: "message.send", | 256 | type: "thread.message.assistant.complete", |
| 202 | commandId: crypto.randomUUID(), | 257 | commandId: providerCommandId(event, "assistant-complete"), |
| 203 | threadId: thread.id, | 258 | threadId: thread.id, |
| 204 | messageId: assistantMessageId, | 259 | messageId: assistantMessageId, |
| 205 | role: "assistant", | 260 | ...(turnId ? { turnId } : {}), |
| 206 | text: "", | ||
| 207 | streaming: false, | ||
| 208 | createdAt: now, | 261 | createdAt: now, |
| 209 | }); | 262 | }); |
| 210 | } | 263 | } |
| 211 | 264 | ||
| 212 | if (event.type === "turn.completed" && event.turnId) { | 265 | if (event.type === "turn.completed") { |
| 213 | const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); | 266 | const turnId = toTurnId(event.turnId); |
| 214 | yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => | 267 | if (turnId) { |
| 215 | orchestrationEngine.dispatch({ | 268 | const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, turnId); |
| 216 | type: "message.send", | 269 | yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => |
| 217 | commandId: crypto.randomUUID(), | 270 | orchestrationEngine.dispatch({ |
| 218 | threadId: thread.id, | 271 | type: "thread.message.assistant.complete", |
| 219 | messageId: assistantMessageId, | 272 | commandId: providerCommandId(event, "assistant-complete-finalize"), |
| 220 | role: "assistant", | 273 | threadId: thread.id, |
| 221 | text: "", | 274 | messageId: assistantMessageId, |
| 222 | streaming: false, | 275 | turnId, |
| 223 | createdAt: now, | 276 | createdAt: now, |
| 224 | }), | 277 | }), |
| 225 | ).pipe(Effect.asVoid); | 278 | ).pipe(Effect.asVoid); |
| 226 | clearAssistantMessageIdsForTurn(event.sessionId, event.turnId); | 279 | clearAssistantMessageIdsForTurn(event.sessionId, turnId); |
| 280 | } | ||
| 227 | } | 281 | } |
| 228 | 282 | ||
| 229 | if (event.type === "session.exited") { | 283 | if (event.type === "session.exited") { |
| 230 | clearTurnStateForSession(event.sessionId); | 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 | if (event.type === "runtime.error") { | 287 | if (event.type === "runtime.error") { |
| 288 | const providerThreadId = | ||
| 289 | event.threadId !== undefined | ||
| 290 | ? ProviderThreadId.makeUnsafe(event.threadId) | ||
| 291 | : (thread.session?.providerThreadId ?? null); | ||
| 292 | |||
| 276 | yield* orchestrationEngine.dispatch({ | 293 | yield* orchestrationEngine.dispatch({ |
| 277 | type: "thread.session", | 294 | type: "thread.session.set", |
| 278 | commandId: crypto.randomUUID(), | 295 | commandId: providerCommandId(event, "runtime-error-session-set"), |
| 279 | threadId: thread.id, | 296 | threadId: thread.id, |
| 280 | session: { | 297 | session: { |
| 281 | sessionId: event.sessionId, | ||
| 282 | provider: event.provider, | ||
| 283 | status: "error", | ||
| 284 | threadId: thread.id, | 298 | threadId: thread.id, |
| 285 | activeTurnId: event.turnId ?? null, | 299 | status: "error", |
| 286 | createdAt: thread.session?.createdAt ?? now, | 300 | providerName: event.provider, |
| 287 | updatedAt: now, | 301 | providerSessionId: event.sessionId, |
| 302 | providerThreadId, | ||
| 303 | activeTurnId: toTurnId(event.turnId) ?? null, | ||
| 288 | lastError: event.message, | 304 | lastError: event.message, |
| 305 | updatedAt: now, | ||
| 289 | }, | 306 | }, |
| 290 | createdAt: now, | 307 | createdAt: now, |
| 291 | }); | 308 | }); |
| ... | @@ -295,7 +312,7 @@ const make = Effect.gen(function* () { | ... | @@ -295,7 +312,7 @@ const make = Effect.gen(function* () { |
| 295 | yield* Effect.forEach(activities, (activity) => | 312 | yield* Effect.forEach(activities, (activity) => |
| 296 | orchestrationEngine.dispatch({ | 313 | orchestrationEngine.dispatch({ |
| 297 | type: "thread.activity.append", | 314 | type: "thread.activity.append", |
| 298 | commandId: crypto.randomUUID(), | 315 | commandId: providerCommandId(event, "thread-activity-append"), |
| 299 | threadId: thread.id, | 316 | threadId: thread.id, |
| 300 | activity, | 317 | activity, |
| 301 | createdAt: activity.createdAt, | 318 | createdAt: activity.createdAt, |
apps/server/src/orchestration/Schemas.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 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,9 +13,30 @@ import { |
| 13 | const now = new Date().toISOString(); | 13 | const now = new Date().toISOString(); |
| 14 | 14 | ||
| 15 | const readModel: OrchestrationReadModel = { | 15 | const readModel: OrchestrationReadModel = { |
| 16 | sequence: 2, | 16 | snapshotSequence: 2, |
| 17 | updatedAt: now, | 17 | updatedAt: now, |
| 18 | gitByProjectId: {}, | 18 | projects: [ |
| 19 | { | ||
| 20 | id: "project-a", | ||
| 21 | title: "Project A", | ||
| 22 | workspaceRoot: "/tmp/project-a", | ||
| 23 | defaultModel: "gpt-5-codex", | ||
| 24 | scripts: [], | ||
| 25 | createdAt: now, | ||
| 26 | updatedAt: now, | ||
| 27 | deletedAt: null, | ||
| 28 | }, | ||
| 29 | { | ||
| 30 | id: "project-b", | ||
| 31 | title: "Project B", | ||
| 32 | workspaceRoot: "/tmp/project-b", | ||
| 33 | defaultModel: "gpt-5-codex", | ||
| 34 | scripts: [], | ||
| 35 | createdAt: now, | ||
| 36 | updatedAt: now, | ||
| 37 | deletedAt: null, | ||
| 38 | }, | ||
| 39 | ], | ||
| 19 | threads: [ | 40 | threads: [ |
| 20 | { | 41 | { |
| 21 | id: "thread-1", | 42 | id: "thread-1", |
| ... | @@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = { | ... | @@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = { |
| 27 | createdAt: now, | 48 | createdAt: now, |
| 28 | updatedAt: now, | 49 | updatedAt: now, |
| 29 | latestTurnId: null, | 50 | latestTurnId: null, |
| 30 | latestTurnStartedAt: null, | ||
| 31 | latestTurnCompletedAt: null, | ||
| 32 | latestTurnDurationMs: null, | ||
| 33 | messages: [], | 51 | messages: [], |
| 34 | session: null, | 52 | session: null, |
| 35 | turnDiffSummaries: [], | ||
| 36 | activities: [], | 53 | activities: [], |
| 37 | error: null, | 54 | checkpoints: [], |
| 38 | }, | 55 | }, |
| 39 | { | 56 | { |
| 40 | id: "thread-2", | 57 | id: "thread-2", |
| ... | @@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = { | ... | @@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = { |
| 46 | createdAt: now, | 63 | createdAt: now, |
| 47 | updatedAt: now, | 64 | updatedAt: now, |
| 48 | latestTurnId: null, | 65 | latestTurnId: null, |
| 49 | latestTurnStartedAt: null, | ||
| 50 | latestTurnCompletedAt: null, | ||
| 51 | latestTurnDurationMs: null, | ||
| 52 | messages: [], | 66 | messages: [], |
| 53 | session: null, | 67 | session: null, |
| 54 | turnDiffSummaries: [], | ||
| 55 | activities: [], | 68 | activities: [], |
| 56 | error: null, | 69 | checkpoints: [], |
| 57 | }, | 70 | }, |
| 58 | ], | 71 | ], |
| 59 | }; | 72 | }; |
| 60 | 73 | ||
| 61 | const messageSendCommand: OrchestrationCommand = { | 74 | const messageSendCommand: OrchestrationCommand = { |
| 62 | type: "message.send", | 75 | type: "thread.turn.start", |
| 63 | commandId: "cmd-1", | 76 | commandId: "cmd-1", |
| 64 | threadId: "thread-1", | 77 | threadId: "thread-1", |
| 65 | messageId: "msg-1", | 78 | message: { |
| 66 | role: "user", | 79 | messageId: "msg-1", |
| 67 | text: "hello", | 80 | role: "user", |
| 68 | streaming: false, | 81 | text: "hello", |
| 82 | attachments: [], | ||
| 83 | }, | ||
| 69 | createdAt: now, | 84 | createdAt: now, |
| 70 | }; | 85 | }; |
| 71 | 86 |
apps/server/src/orchestration/commandInvariants.ts+56-5| ... | @@ -1,4 +1,11 @@ | ... | @@ -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 | import { Effect } from "effect"; | 9 | import { Effect } from "effect"; |
| 3 | 10 | ||
| 4 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 11 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| ... | @@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma | ... | @@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma |
| 12 | 19 | ||
| 13 | export function findThreadById( | 20 | export function findThreadById( |
| 14 | readModel: OrchestrationReadModel, | 21 | readModel: OrchestrationReadModel, |
| 15 | threadId: string, | 22 | threadId: ThreadId, |
| 16 | ): OrchestrationThread | undefined { | 23 | ): OrchestrationThread | undefined { |
| 17 | return readModel.threads.find((thread) => thread.id === threadId); | 24 | return readModel.threads.find((thread) => thread.id === threadId); |
| 18 | } | 25 | } |
| 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 | export function listThreadsByProjectId( | 34 | export function listThreadsByProjectId( |
| 21 | readModel: OrchestrationReadModel, | 35 | readModel: OrchestrationReadModel, |
| 22 | projectId: string, | 36 | projectId: ProjectId, |
| 23 | ): ReadonlyArray<OrchestrationThread> { | 37 | ): ReadonlyArray<OrchestrationThread> { |
| 24 | return readModel.threads.filter((thread) => thread.projectId === projectId); | 38 | return readModel.threads.filter((thread) => thread.projectId === projectId); |
| 25 | } | 39 | } |
| 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 | export function requireThread( | 78 | export function requireThread( |
| 28 | input: { | 79 | input: { |
| 29 | readonly readModel: OrchestrationReadModel; | 80 | readonly readModel: OrchestrationReadModel; |
| 30 | readonly command: OrchestrationCommand; | 81 | readonly command: OrchestrationCommand; |
| 31 | readonly threadId: string; | 82 | readonly threadId: ThreadId; |
| 32 | }, | 83 | }, |
| 33 | ): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> { | 84 | ): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> { |
| 34 | const thread = findThreadById(input.readModel, input.threadId); | 85 | const thread = findThreadById(input.readModel, input.threadId); |
| ... | @@ -47,7 +98,7 @@ export function requireThreadAbsent( | ... | @@ -47,7 +98,7 @@ export function requireThreadAbsent( |
| 47 | input: { | 98 | input: { |
| 48 | readonly readModel: OrchestrationReadModel; | 99 | readonly readModel: OrchestrationReadModel; |
| 49 | readonly command: OrchestrationCommand; | 100 | readonly command: OrchestrationCommand; |
| 50 | readonly threadId: string; | 101 | readonly threadId: ThreadId; |
| 51 | }, | 102 | }, |
| 52 | ): Effect.Effect<void, OrchestrationCommandInvariantError> { | 103 | ): Effect.Effect<void, OrchestrationCommandInvariantError> { |
| 53 | if (!findThreadById(input.readModel, input.threadId)) { | 104 | if (!findThreadById(input.readModel, input.threadId)) { |
apps/server/src/orchestration/decider.projectScripts.test.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 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,36 +7,152 @@ import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 9 | import { | 9 | import { |
| 10 | requireNonNegativeInteger, | 10 | requireProject, |
| 11 | requireProjectAbsent, | ||
| 11 | requireThread, | 12 | requireThread, |
| 12 | requireThreadAbsent, | 13 | requireThreadAbsent, |
| 13 | } from "./commandInvariants.ts"; | 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 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ | 49 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ |
| 16 | command, | 50 | command, |
| 17 | readModel, | 51 | readModel, |
| 18 | }: { | 52 | }: { |
| 19 | readonly command: OrchestrationCommand; | 53 | readonly command: OrchestrationCommand; |
| 20 | readonly readModel: OrchestrationReadModel; | 54 | readonly readModel: OrchestrationReadModel; |
| 21 | }): Effect.fn.Return<Omit<OrchestrationEvent, "sequence">, OrchestrationCommandInvariantError> { | 55 | }): Effect.fn.Return< |
| 22 | const eventId = crypto.randomUUID(); | 56 | Omit<OrchestrationEvent, "sequence"> | ReadonlyArray<Omit<OrchestrationEvent, "sequence">>, |
| 23 | 57 | OrchestrationCommandInvariantError | |
| 58 | > { | ||
| 24 | switch (command.type) { | 59 | switch (command.type) { |
| 25 | case "thread.create": | 60 | case "project.create": { |
| 61 | yield* requireProjectAbsent({ | ||
| 62 | readModel, | ||
| 63 | command, | ||
| 64 | projectId: command.projectId, | ||
| 65 | }); | ||
| 66 | |||
| 67 | return { | ||
| 68 | ...withEventBase({ | ||
| 69 | aggregateKind: "project", | ||
| 70 | aggregateId: command.projectId, | ||
| 71 | occurredAt: command.createdAt, | ||
| 72 | commandId: command.commandId, | ||
| 73 | }), | ||
| 74 | type: "project.created", | ||
| 75 | payload: { | ||
| 76 | projectId: command.projectId, | ||
| 77 | title: command.title, | ||
| 78 | workspaceRoot: command.workspaceRoot, | ||
| 79 | defaultModel: command.defaultModel ?? null, | ||
| 80 | scripts: [], | ||
| 81 | createdAt: command.createdAt, | ||
| 82 | updatedAt: command.createdAt, | ||
| 83 | }, | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | case "project.meta.update": { | ||
| 88 | yield* requireProject({ | ||
| 89 | readModel, | ||
| 90 | command, | ||
| 91 | projectId: command.projectId, | ||
| 92 | }); | ||
| 93 | const occurredAt = nowIso(); | ||
| 94 | return { | ||
| 95 | ...withEventBase({ | ||
| 96 | aggregateKind: "project", | ||
| 97 | aggregateId: command.projectId, | ||
| 98 | occurredAt, | ||
| 99 | commandId: command.commandId, | ||
| 100 | }), | ||
| 101 | type: "project.meta-updated", | ||
| 102 | payload: { | ||
| 103 | projectId: command.projectId, | ||
| 104 | ...(command.title !== undefined ? { title: command.title } : {}), | ||
| 105 | ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), | ||
| 106 | ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), | ||
| 107 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), | ||
| 108 | updatedAt: occurredAt, | ||
| 109 | }, | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | case "project.delete": { | ||
| 114 | yield* requireProject({ | ||
| 115 | readModel, | ||
| 116 | command, | ||
| 117 | projectId: command.projectId, | ||
| 118 | }); | ||
| 119 | const occurredAt = nowIso(); | ||
| 120 | return { | ||
| 121 | ...withEventBase({ | ||
| 122 | aggregateKind: "project", | ||
| 123 | aggregateId: command.projectId, | ||
| 124 | occurredAt, | ||
| 125 | commandId: command.commandId, | ||
| 126 | }), | ||
| 127 | type: "project.deleted", | ||
| 128 | payload: { | ||
| 129 | projectId: command.projectId, | ||
| 130 | deletedAt: occurredAt, | ||
| 131 | }, | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | |||
| 135 | case "thread.create": { | ||
| 136 | yield* requireProject({ | ||
| 137 | readModel, | ||
| 138 | command, | ||
| 139 | projectId: command.projectId, | ||
| 140 | }); | ||
| 26 | yield* requireThreadAbsent({ | 141 | yield* requireThreadAbsent({ |
| 27 | readModel, | 142 | readModel, |
| 28 | command, | 143 | command, |
| 29 | threadId: command.threadId, | 144 | threadId: command.threadId, |
| 30 | }); | 145 | }); |
| 31 | return { | 146 | return { |
| 32 | eventId, | 147 | ...withEventBase({ |
| 148 | aggregateKind: "thread", | ||
| 149 | aggregateId: command.threadId, | ||
| 150 | occurredAt: command.createdAt, | ||
| 151 | commandId: command.commandId, | ||
| 152 | }), | ||
| 33 | type: "thread.created", | 153 | type: "thread.created", |
| 34 | aggregateType: "thread", | ||
| 35 | aggregateId: command.threadId, | ||
| 36 | occurredAt: command.createdAt, | ||
| 37 | commandId: command.commandId, | ||
| 38 | payload: { | 154 | payload: { |
| 39 | id: command.threadId, | 155 | threadId: command.threadId, |
| 40 | projectId: command.projectId, | 156 | projectId: command.projectId, |
| 41 | title: command.title, | 157 | title: command.title, |
| 42 | model: command.model, | 158 | model: command.model, |
| ... | @@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 46 | updatedAt: command.createdAt, | 162 | updatedAt: command.createdAt, |
| 47 | }, | 163 | }, |
| 48 | }; | 164 | }; |
| 49 | case "thread.delete": | 165 | } |
| 166 | |||
| 167 | case "thread.delete": { | ||
| 50 | yield* requireThread({ | 168 | yield* requireThread({ |
| 51 | readModel, | 169 | readModel, |
| 52 | command, | 170 | command, |
| 53 | threadId: command.threadId, | 171 | threadId: command.threadId, |
| 54 | }); | 172 | }); |
| 173 | const occurredAt = nowIso(); | ||
| 55 | return { | 174 | return { |
| 56 | eventId, | 175 | ...withEventBase({ |
| 176 | aggregateKind: "thread", | ||
| 177 | aggregateId: command.threadId, | ||
| 178 | occurredAt, | ||
| 179 | commandId: command.commandId, | ||
| 180 | }), | ||
| 57 | type: "thread.deleted", | 181 | type: "thread.deleted", |
| 58 | aggregateType: "thread", | ||
| 59 | aggregateId: command.threadId, | ||
| 60 | occurredAt: command.createdAt, | ||
| 61 | commandId: command.commandId, | ||
| 62 | payload: { | 182 | payload: { |
| 63 | id: command.threadId, | 183 | threadId: command.threadId, |
| 64 | deletedAt: command.createdAt, | 184 | deletedAt: occurredAt, |
| 65 | }, | 185 | }, |
| 66 | }; | 186 | }; |
| 67 | case "thread.meta.update": | 187 | } |
| 188 | |||
| 189 | case "thread.meta.update": { | ||
| 68 | yield* requireThread({ | 190 | yield* requireThread({ |
| 69 | readModel, | 191 | readModel, |
| 70 | command, | 192 | command, |
| 71 | threadId: command.threadId, | 193 | threadId: command.threadId, |
| 72 | }); | 194 | }); |
| 195 | const occurredAt = nowIso(); | ||
| 73 | return { | 196 | return { |
| 74 | eventId, | 197 | ...withEventBase({ |
| 198 | aggregateKind: "thread", | ||
| 199 | aggregateId: command.threadId, | ||
| 200 | occurredAt, | ||
| 201 | commandId: command.commandId, | ||
| 202 | }), | ||
| 75 | type: "thread.meta-updated", | 203 | type: "thread.meta-updated", |
| 76 | aggregateType: "thread", | ||
| 77 | aggregateId: command.threadId, | ||
| 78 | occurredAt: command.createdAt, | ||
| 79 | commandId: command.commandId, | ||
| 80 | payload: { | 204 | payload: { |
| 81 | threadId: command.threadId, | 205 | threadId: command.threadId, |
| 82 | ...(command.title !== undefined ? { title: command.title } : {}), | 206 | ...(command.title !== undefined ? { title: command.title } : {}), |
| 83 | ...(command.model !== undefined ? { model: command.model } : {}), | 207 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 84 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | 208 | ...(command.branch !== undefined ? { branch: command.branch } : {}), |
| 85 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | 209 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), |
| 210 | updatedAt: occurredAt, | ||
| 211 | }, | ||
| 212 | }; | ||
| 213 | } | ||
| 214 | |||
| 215 | case "thread.turn.start": { | ||
| 216 | yield* requireThread({ | ||
| 217 | readModel, | ||
| 218 | command, | ||
| 219 | threadId: command.threadId, | ||
| 220 | }); | ||
| 221 | const userMessageEvent: Omit<OrchestrationEvent, "sequence"> = { | ||
| 222 | ...withEventBase({ | ||
| 223 | aggregateKind: "thread", | ||
| 224 | aggregateId: command.threadId, | ||
| 225 | occurredAt: command.createdAt, | ||
| 226 | commandId: command.commandId, | ||
| 227 | }), | ||
| 228 | type: "thread.message-sent", | ||
| 229 | payload: { | ||
| 230 | threadId: command.threadId, | ||
| 231 | messageId: command.message.messageId, | ||
| 232 | role: "user", | ||
| 233 | text: command.message.text, | ||
| 234 | attachments: command.message.attachments, | ||
| 235 | turnId: null, | ||
| 236 | streaming: false, | ||
| 237 | createdAt: command.createdAt, | ||
| 86 | updatedAt: command.createdAt, | 238 | updatedAt: command.createdAt, |
| 87 | }, | 239 | }, |
| 88 | }; | 240 | }; |
| 89 | case "message.send": | 241 | const turnStartRequestedEvent: Omit<OrchestrationEvent, "sequence"> = { |
| 242 | ...withEventBase({ | ||
| 243 | aggregateKind: "thread", | ||
| 244 | aggregateId: command.threadId, | ||
| 245 | occurredAt: command.createdAt, | ||
| 246 | commandId: command.commandId, | ||
| 247 | }), | ||
| 248 | causationEventId: userMessageEvent.eventId, | ||
| 249 | type: "thread.turn-start-requested", | ||
| 250 | payload: { | ||
| 251 | threadId: command.threadId, | ||
| 252 | messageId: command.message.messageId, | ||
| 253 | ...(command.model !== undefined ? { model: command.model } : {}), | ||
| 254 | ...(command.effort !== undefined ? { effort: command.effort } : {}), | ||
| 255 | createdAt: command.createdAt, | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | return [userMessageEvent, turnStartRequestedEvent]; | ||
| 259 | } | ||
| 260 | |||
| 261 | case "thread.turn.interrupt": { | ||
| 90 | yield* requireThread({ | 262 | yield* requireThread({ |
| 91 | readModel, | 263 | readModel, |
| 92 | command, | 264 | command, |
| 93 | threadId: command.threadId, | 265 | threadId: command.threadId, |
| 94 | }); | 266 | }); |
| 95 | return { | 267 | return { |
| 96 | eventId, | 268 | ...withEventBase({ |
| 97 | type: "message.sent", | 269 | aggregateKind: "thread", |
| 98 | aggregateType: "thread", | 270 | aggregateId: command.threadId, |
| 99 | aggregateId: command.threadId, | 271 | occurredAt: command.createdAt, |
| 100 | occurredAt: command.createdAt, | 272 | commandId: command.commandId, |
| 101 | commandId: command.commandId, | 273 | }), |
| 274 | type: "thread.turn-interrupt-requested", | ||
| 102 | payload: { | 275 | payload: { |
| 103 | id: command.messageId, | ||
| 104 | role: command.role, | ||
| 105 | text: command.text, | ||
| 106 | threadId: command.threadId, | 276 | threadId: command.threadId, |
| 277 | ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), | ||
| 107 | createdAt: command.createdAt, | 278 | createdAt: command.createdAt, |
| 108 | streaming: command.streaming === true, | ||
| 109 | }, | 279 | }, |
| 110 | }; | 280 | }; |
| 111 | case "thread.session": | 281 | } |
| 282 | |||
| 283 | case "thread.approval.respond": { | ||
| 112 | yield* requireThread({ | 284 | yield* requireThread({ |
| 113 | readModel, | 285 | readModel, |
| 114 | command, | 286 | command, |
| 115 | threadId: command.threadId, | 287 | threadId: command.threadId, |
| 116 | }); | 288 | }); |
| 117 | return { | 289 | return { |
| 118 | eventId, | 290 | ...withEventBase({ |
| 291 | aggregateKind: "thread", | ||
| 292 | aggregateId: command.threadId, | ||
| 293 | occurredAt: command.createdAt, | ||
| 294 | commandId: command.commandId, | ||
| 295 | metadata: { | ||
| 296 | requestId: command.requestId, | ||
| 297 | }, | ||
| 298 | }), | ||
| 299 | type: "thread.approval-response-requested", | ||
| 300 | payload: { | ||
| 301 | threadId: command.threadId, | ||
| 302 | requestId: command.requestId, | ||
| 303 | decision: command.decision, | ||
| 304 | createdAt: command.createdAt, | ||
| 305 | }, | ||
| 306 | }; | ||
| 307 | } | ||
| 308 | |||
| 309 | case "thread.checkpoint.revert": { | ||
| 310 | yield* requireThread({ | ||
| 311 | readModel, | ||
| 312 | command, | ||
| 313 | threadId: command.threadId, | ||
| 314 | }); | ||
| 315 | return { | ||
| 316 | ...withEventBase({ | ||
| 317 | aggregateKind: "thread", | ||
| 318 | aggregateId: command.threadId, | ||
| 319 | occurredAt: command.createdAt, | ||
| 320 | commandId: command.commandId, | ||
| 321 | }), | ||
| 322 | type: "thread.checkpoint-revert-requested", | ||
| 323 | payload: { | ||
| 324 | threadId: command.threadId, | ||
| 325 | turnCount: command.turnCount, | ||
| 326 | createdAt: command.createdAt, | ||
| 327 | }, | ||
| 328 | }; | ||
| 329 | } | ||
| 330 | |||
| 331 | case "thread.session.stop": { | ||
| 332 | yield* requireThread({ | ||
| 333 | readModel, | ||
| 334 | command, | ||
| 335 | threadId: command.threadId, | ||
| 336 | }); | ||
| 337 | return { | ||
| 338 | ...withEventBase({ | ||
| 339 | aggregateKind: "thread", | ||
| 340 | aggregateId: command.threadId, | ||
| 341 | occurredAt: command.createdAt, | ||
| 342 | commandId: command.commandId, | ||
| 343 | }), | ||
| 344 | type: "thread.session-stop-requested", | ||
| 345 | payload: { | ||
| 346 | threadId: command.threadId, | ||
| 347 | createdAt: command.createdAt, | ||
| 348 | }, | ||
| 349 | }; | ||
| 350 | } | ||
| 351 | |||
| 352 | case "thread.session.set": { | ||
| 353 | yield* requireThread({ | ||
| 354 | readModel, | ||
| 355 | command, | ||
| 356 | threadId: command.threadId, | ||
| 357 | }); | ||
| 358 | return { | ||
| 359 | ...withEventBase({ | ||
| 360 | aggregateKind: "thread", | ||
| 361 | aggregateId: command.threadId, | ||
| 362 | occurredAt: command.createdAt, | ||
| 363 | commandId: command.commandId, | ||
| 364 | metadata: { | ||
| 365 | ...(command.session.providerSessionId !== null | ||
| 366 | ? { providerSessionId: command.session.providerSessionId } | ||
| 367 | : {}), | ||
| 368 | ...(command.session.providerThreadId !== null | ||
| 369 | ? { providerThreadId: command.session.providerThreadId } | ||
| 370 | : {}), | ||
| 371 | }, | ||
| 372 | }), | ||
| 119 | type: "thread.session-set", | 373 | type: "thread.session-set", |
| 120 | aggregateType: "thread", | ||
| 121 | aggregateId: command.threadId, | ||
| 122 | occurredAt: command.createdAt, | ||
| 123 | commandId: command.commandId, | ||
| 124 | payload: { | 374 | payload: { |
| 125 | threadId: command.threadId, | 375 | threadId: command.threadId, |
| 126 | session: command.session, | 376 | session: command.session, |
| 127 | }, | 377 | }, |
| 128 | }; | 378 | }; |
| 129 | case "git.readModel.upsert": | 379 | } |
| 380 | |||
| 381 | case "thread.message.assistant.delta": { | ||
| 382 | yield* requireThread({ | ||
| 383 | readModel, | ||
| 384 | command, | ||
| 385 | threadId: command.threadId, | ||
| 386 | }); | ||
| 130 | return { | 387 | return { |
| 131 | eventId, | 388 | ...withEventBase({ |
| 132 | type: "git.read-model-upsert", | 389 | aggregateKind: "thread", |
| 133 | aggregateType: "project", | 390 | aggregateId: command.threadId, |
| 134 | aggregateId: command.projectId, | 391 | occurredAt: command.createdAt, |
| 135 | occurredAt: command.createdAt, | 392 | commandId: command.commandId, |
| 136 | commandId: command.commandId, | 393 | }), |
| 394 | type: "thread.message-sent", | ||
| 137 | payload: { | 395 | payload: { |
| 138 | projectId: command.projectId, | 396 | threadId: command.threadId, |
| 139 | branch: command.branch, | 397 | messageId: command.messageId, |
| 140 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | 398 | role: "assistant", |
| 141 | aheadCount: command.aheadCount, | 399 | text: command.delta, |
| 142 | behindCount: command.behindCount, | 400 | turnId: command.turnId ?? null, |
| 401 | streaming: true, | ||
| 402 | createdAt: command.createdAt, | ||
| 143 | updatedAt: command.createdAt, | 403 | updatedAt: command.createdAt, |
| 144 | }, | 404 | }, |
| 145 | }; | 405 | }; |
| 146 | case "thread.turnDiff.complete": | 406 | } |
| 407 | |||
| 408 | case "thread.message.assistant.complete": { | ||
| 409 | yield* requireThread({ | ||
| 410 | readModel, | ||
| 411 | command, | ||
| 412 | threadId: command.threadId, | ||
| 413 | }); | ||
| 414 | return { | ||
| 415 | ...withEventBase({ | ||
| 416 | aggregateKind: "thread", | ||
| 417 | aggregateId: command.threadId, | ||
| 418 | occurredAt: command.createdAt, | ||
| 419 | commandId: command.commandId, | ||
| 420 | }), | ||
| 421 | type: "thread.message-sent", | ||
| 422 | payload: { | ||
| 423 | threadId: command.threadId, | ||
| 424 | messageId: command.messageId, | ||
| 425 | role: "assistant", | ||
| 426 | text: "", | ||
| 427 | turnId: command.turnId ?? null, | ||
| 428 | streaming: false, | ||
| 429 | createdAt: command.createdAt, | ||
| 430 | updatedAt: command.createdAt, | ||
| 431 | }, | ||
| 432 | }; | ||
| 433 | } | ||
| 434 | |||
| 435 | case "thread.turn.diff.complete": { | ||
| 147 | yield* requireThread({ | 436 | yield* requireThread({ |
| 148 | readModel, | 437 | readModel, |
| 149 | command, | 438 | command, |
| 150 | threadId: command.threadId, | 439 | threadId: command.threadId, |
| 151 | }); | 440 | }); |
| 152 | return { | 441 | return { |
| 153 | eventId, | 442 | ...withEventBase({ |
| 443 | aggregateKind: "thread", | ||
| 444 | aggregateId: command.threadId, | ||
| 445 | occurredAt: command.createdAt, | ||
| 446 | commandId: command.commandId, | ||
| 447 | }), | ||
| 154 | type: "thread.turn-diff-completed", | 448 | type: "thread.turn-diff-completed", |
| 155 | aggregateType: "thread", | ||
| 156 | aggregateId: command.threadId, | ||
| 157 | occurredAt: command.createdAt, | ||
| 158 | commandId: command.commandId, | ||
| 159 | payload: { | 449 | payload: { |
| 160 | threadId: command.threadId, | 450 | threadId: command.threadId, |
| 161 | turnId: command.turnId, | 451 | turnId: command.turnId, |
| 162 | completedAt: command.completedAt, | 452 | checkpointTurnCount: command.checkpointTurnCount, |
| 163 | ...(command.status !== undefined ? { status: command.status } : {}), | 453 | checkpointRef: command.checkpointRef, |
| 454 | status: command.status, | ||
| 164 | files: command.files, | 455 | files: command.files, |
| 165 | ...(command.assistantMessageId !== undefined | 456 | assistantMessageId: command.assistantMessageId ?? null, |
| 166 | ? { assistantMessageId: command.assistantMessageId } | 457 | completedAt: command.completedAt, |
| 167 | : {}), | ||
| 168 | ...(command.checkpointTurnCount !== undefined | ||
| 169 | ? { checkpointTurnCount: command.checkpointTurnCount } | ||
| 170 | : {}), | ||
| 171 | }, | 458 | }, |
| 172 | }; | 459 | }; |
| 173 | case "thread.activity.append": | 460 | } |
| 461 | |||
| 462 | case "thread.revert.complete": { | ||
| 174 | yield* requireThread({ | 463 | yield* requireThread({ |
| 175 | readModel, | 464 | readModel, |
| 176 | command, | 465 | command, |
| 177 | threadId: command.threadId, | 466 | threadId: command.threadId, |
| 178 | }); | 467 | }); |
| 179 | return { | 468 | return { |
| 180 | eventId, | 469 | ...withEventBase({ |
| 181 | type: "thread.activity-appended", | 470 | aggregateKind: "thread", |
| 182 | aggregateType: "thread", | 471 | aggregateId: command.threadId, |
| 183 | aggregateId: command.threadId, | 472 | occurredAt: command.createdAt, |
| 184 | occurredAt: command.createdAt, | 473 | commandId: command.commandId, |
| 185 | commandId: command.commandId, | 474 | }), |
| 475 | type: "thread.reverted", | ||
| 186 | payload: { | 476 | payload: { |
| 187 | threadId: command.threadId, | 477 | threadId: command.threadId, |
| 188 | activity: command.activity, | 478 | turnCount: command.turnCount, |
| 189 | }, | 479 | }, |
| 190 | }; | 480 | }; |
| 191 | case "thread.revert": | 481 | } |
| 482 | |||
| 483 | case "thread.activity.append": { | ||
| 192 | yield* requireThread({ | 484 | yield* requireThread({ |
| 193 | readModel, | 485 | readModel, |
| 194 | command, | 486 | command, |
| 195 | threadId: command.threadId, | 487 | threadId: command.threadId, |
| 196 | }); | 488 | }); |
| 197 | yield* requireNonNegativeInteger({ | 489 | const requestId = |
| 198 | commandType: command.type, | 490 | typeof command.activity.payload === "object" && |
| 199 | field: "turnCount", | 491 | command.activity.payload !== null && |
| 200 | value: command.turnCount, | 492 | "requestId" in command.activity.payload && |
| 201 | }); | 493 | typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" |
| 202 | yield* requireNonNegativeInteger({ | 494 | ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"]) |
| 203 | commandType: command.type, | 495 | : undefined; |
| 204 | field: "messageCount", | ||
| 205 | value: command.messageCount, | ||
| 206 | }); | ||
| 207 | return { | 496 | return { |
| 208 | eventId, | 497 | ...withEventBase({ |
| 209 | type: "thread.reverted", | 498 | aggregateKind: "thread", |
| 210 | aggregateType: "thread", | 499 | aggregateId: command.threadId, |
| 211 | aggregateId: command.threadId, | 500 | occurredAt: command.createdAt, |
| 212 | occurredAt: command.createdAt, | 501 | commandId: command.commandId, |
| 213 | commandId: command.commandId, | 502 | ...(requestId !== undefined ? { metadata: { requestId } } : {}), |
| 503 | }), | ||
| 504 | type: "thread.activity-appended", | ||
| 214 | payload: { | 505 | payload: { |
| 215 | threadId: command.threadId, | 506 | threadId: command.threadId, |
| 216 | turnCount: command.turnCount, | 507 | activity: command.activity, |
| 217 | messageCount: command.messageCount, | ||
| 218 | }, | 508 | }, |
| 219 | }; | 509 | }; |
| 510 | } | ||
| 511 | |||
| 220 | default: { | 512 | default: { |
| 221 | command satisfies never; | 513 | command satisfies never; |
| 222 | const fallback = command as any; | 514 | const fallback = command as never as { type: string }; |
| 223 | return yield* new OrchestrationCommandInvariantError({ | 515 | return yield* new OrchestrationCommandInvariantError({ |
| 224 | commandType: fallback.type, | 516 | commandType: fallback.type, |
| 225 | detail: `Unknown command type: ${fallback.type}`, | 517 | detail: `Unknown command type: ${fallback.type}`, |
apps/server/src/orchestration/projector.test.ts+226-199| ... | @@ -4,32 +4,60 @@ import { describe, expect, it } from "vitest"; | ... | @@ -4,32 +4,60 @@ import { describe, expect, it } from "vitest"; |
| 4 | 4 | ||
| 5 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | 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 | describe("orchestration projector", () => { | 31 | describe("orchestration projector", () => { |
| 8 | it("applies thread.created events using runtime schema decoding", async () => { | 32 | it("applies thread.created events", async () => { |
| 9 | const now = new Date().toISOString(); | 33 | const now = new Date().toISOString(); |
| 10 | const model = createEmptyReadModel(now); | 34 | const model = createEmptyReadModel(now); |
| 11 | const event: OrchestrationEvent = { | ||
| 12 | sequence: 1, | ||
| 13 | eventId: "event-thread-created", | ||
| 14 | type: "thread.created", | ||
| 15 | aggregateType: "thread", | ||
| 16 | aggregateId: "thread-1", | ||
| 17 | occurredAt: now, | ||
| 18 | commandId: "cmd-1", | ||
| 19 | payload: { | ||
| 20 | id: "thread-1", | ||
| 21 | projectId: "project-1", | ||
| 22 | title: "demo", | ||
| 23 | model: "gpt-5-codex", | ||
| 24 | branch: null, | ||
| 25 | worktreePath: null, | ||
| 26 | createdAt: now, | ||
| 27 | updatedAt: now, | ||
| 28 | }, | ||
| 29 | }; | ||
| 30 | 35 | ||
| 31 | const next = await Effect.runPromise(projectEvent(model, event)); | 36 | const next = await Effect.runPromise( |
| 32 | expect(next.sequence).toBe(1); | 37 | projectEvent( |
| 38 | model, | ||
| 39 | makeEvent({ | ||
| 40 | sequence: 1, | ||
| 41 | type: "thread.created", | ||
| 42 | aggregateType: "thread", | ||
| 43 | aggregateId: "thread-1", | ||
| 44 | occurredAt: now, | ||
| 45 | commandId: "cmd-thread-create", | ||
| 46 | payload: { | ||
| 47 | threadId: "thread-1", | ||
| 48 | projectId: "project-1", | ||
| 49 | title: "demo", | ||
| 50 | model: "gpt-5-codex", | ||
| 51 | branch: null, | ||
| 52 | worktreePath: null, | ||
| 53 | createdAt: now, | ||
| 54 | updatedAt: now, | ||
| 55 | }, | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | |||
| 60 | expect(next.snapshotSequence).toBe(1); | ||
| 33 | expect(next.threads).toEqual([ | 61 | expect(next.threads).toEqual([ |
| 34 | { | 62 | { |
| 35 | id: "thread-1", | 63 | id: "thread-1", |
| ... | @@ -38,17 +66,14 @@ describe("orchestration projector", () => { | ... | @@ -38,17 +66,14 @@ describe("orchestration projector", () => { |
| 38 | model: "gpt-5-codex", | 66 | model: "gpt-5-codex", |
| 39 | branch: null, | 67 | branch: null, |
| 40 | worktreePath: null, | 68 | worktreePath: null, |
| 69 | latestTurnId: null, | ||
| 41 | createdAt: now, | 70 | createdAt: now, |
| 42 | updatedAt: now, | 71 | updatedAt: now, |
| 43 | latestTurnId: null, | 72 | deletedAt: null, |
| 44 | latestTurnStartedAt: null, | ||
| 45 | latestTurnCompletedAt: null, | ||
| 46 | latestTurnDurationMs: null, | ||
| 47 | messages: [], | 73 | messages: [], |
| 48 | session: null, | ||
| 49 | activities: [], | 74 | activities: [], |
| 50 | turnDiffSummaries: [], | 75 | checkpoints: [], |
| 51 | error: null, | 76 | session: null, |
| 52 | }, | 77 | }, |
| 53 | ]); | 78 | ]); |
| 54 | }); | 79 | }); |
| ... | @@ -56,203 +81,205 @@ describe("orchestration projector", () => { | ... | @@ -56,203 +81,205 @@ describe("orchestration projector", () => { |
| 56 | it("fails when event payload cannot be decoded by runtime schema", async () => { | 81 | it("fails when event payload cannot be decoded by runtime schema", async () => { |
| 57 | const now = new Date().toISOString(); | 82 | const now = new Date().toISOString(); |
| 58 | const model = createEmptyReadModel(now); | 83 | const model = createEmptyReadModel(now); |
| 59 | const event: OrchestrationEvent = { | ||
| 60 | sequence: 1, | ||
| 61 | eventId: "event-message-invalid", | ||
| 62 | type: "message.sent", | ||
| 63 | aggregateType: "thread", | ||
| 64 | aggregateId: "thread-1", | ||
| 65 | occurredAt: now, | ||
| 66 | commandId: "cmd-1", | ||
| 67 | payload: { | ||
| 68 | id: "message-1", | ||
| 69 | threadId: "thread-1", | ||
| 70 | text: "hello", | ||
| 71 | createdAt: now, | ||
| 72 | streaming: false, | ||
| 73 | }, | ||
| 74 | }; | ||
| 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 | const now = new Date().toISOString(); | 113 | const now = new Date().toISOString(); |
| 81 | const model = createEmptyReadModel(now); | 114 | const model = createEmptyReadModel(now); |
| 82 | const event: OrchestrationEvent = { | ||
| 83 | sequence: 7, | ||
| 84 | eventId: "event-unknown", | ||
| 85 | type: "custom.event.ignored", | ||
| 86 | aggregateType: "unknown", | ||
| 87 | aggregateId: "aggregate-1", | ||
| 88 | occurredAt: "2026-01-01T00:00:00.000Z", | ||
| 89 | commandId: null, | ||
| 90 | payload: { test: true }, | ||
| 91 | }; | ||
| 92 | 115 | ||
| 93 | const next = await Effect.runPromise(projectEvent(model, event)); | 116 | const next = await Effect.runPromise( |
| 94 | expect(next.sequence).toBe(7); | 117 | projectEvent( |
| 118 | model, | ||
| 119 | makeEvent({ | ||
| 120 | sequence: 7, | ||
| 121 | type: "thread.turn-start-requested", | ||
| 122 | aggregateType: "thread", | ||
| 123 | aggregateId: "thread-1", | ||
| 124 | occurredAt: "2026-01-01T00:00:00.000Z", | ||
| 125 | commandId: "cmd-unhandled", | ||
| 126 | payload: { | ||
| 127 | threadId: "thread-1", | ||
| 128 | messageId: "message-1", | ||
| 129 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 130 | }, | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | |||
| 135 | expect(next.snapshotSequence).toBe(7); | ||
| 95 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); | 136 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); |
| 96 | expect(next.threads).toEqual([]); | 137 | expect(next.threads).toEqual([]); |
| 97 | }); | 138 | }); |
| 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 | const createdAt = "2026-02-23T08:00:00.000Z"; | 141 | const createdAt = "2026-02-23T08:00:00.000Z"; |
| 101 | const startedAt = "2026-02-23T08:00:05.000Z"; | 142 | const startedAt = "2026-02-23T08:00:05.000Z"; |
| 102 | const completedAt = "2026-02-23T08:00:11.250Z"; | ||
| 103 | const model = createEmptyReadModel(createdAt); | 143 | const model = createEmptyReadModel(createdAt); |
| 104 | 144 | ||
| 105 | const createdEvent: OrchestrationEvent = { | 145 | const afterCreate = await Effect.runPromise( |
| 106 | sequence: 1, | 146 | projectEvent( |
| 107 | eventId: "thread-created", | 147 | model, |
| 108 | type: "thread.created", | 148 | makeEvent({ |
| 109 | aggregateType: "thread", | 149 | sequence: 1, |
| 110 | aggregateId: "thread-1", | 150 | type: "thread.created", |
| 111 | occurredAt: createdAt, | 151 | aggregateType: "thread", |
| 112 | commandId: "cmd-create", | 152 | aggregateId: "thread-1", |
| 113 | payload: { | 153 | occurredAt: createdAt, |
| 114 | id: "thread-1", | 154 | commandId: "cmd-create", |
| 115 | projectId: "project-1", | 155 | payload: { |
| 116 | title: "demo", | 156 | threadId: "thread-1", |
| 117 | model: "gpt-5.3-codex", | 157 | projectId: "project-1", |
| 118 | branch: null, | 158 | title: "demo", |
| 119 | worktreePath: null, | 159 | model: "gpt-5.3-codex", |
| 120 | createdAt, | 160 | branch: null, |
| 121 | updatedAt: createdAt, | 161 | worktreePath: null, |
| 122 | }, | 162 | createdAt, |
| 123 | }; | 163 | updatedAt: createdAt, |
| 124 | 164 | }, | |
| 125 | const runningSessionEvent: OrchestrationEvent = { | 165 | }), |
| 126 | sequence: 2, | 166 | ), |
| 127 | eventId: "session-running", | 167 | ); |
| 128 | type: "thread.session-set", | ||
| 129 | aggregateType: "thread", | ||
| 130 | aggregateId: "thread-1", | ||
| 131 | occurredAt: startedAt, | ||
| 132 | commandId: "cmd-running", | ||
| 133 | payload: { | ||
| 134 | threadId: "thread-1", | ||
| 135 | session: { | ||
| 136 | sessionId: "session-1", | ||
| 137 | status: "running", | ||
| 138 | provider: "codex", | ||
| 139 | threadId: "thread-1", | ||
| 140 | activeTurnId: "turn-1", | ||
| 141 | createdAt, | ||
| 142 | updatedAt: startedAt, | ||
| 143 | lastError: null, | ||
| 144 | }, | ||
| 145 | }, | ||
| 146 | }; | ||
| 147 | |||
| 148 | const readySessionEvent: OrchestrationEvent = { | ||
| 149 | sequence: 3, | ||
| 150 | eventId: "session-ready", | ||
| 151 | type: "thread.session-set", | ||
| 152 | aggregateType: "thread", | ||
| 153 | aggregateId: "thread-1", | ||
| 154 | occurredAt: completedAt, | ||
| 155 | commandId: "cmd-ready", | ||
| 156 | payload: { | ||
| 157 | threadId: "thread-1", | ||
| 158 | session: { | ||
| 159 | sessionId: "session-1", | ||
| 160 | status: "ready", | ||
| 161 | provider: "codex", | ||
| 162 | threadId: "thread-1", | ||
| 163 | activeTurnId: null, | ||
| 164 | createdAt, | ||
| 165 | updatedAt: completedAt, | ||
| 166 | lastError: null, | ||
| 167 | }, | ||
| 168 | }, | ||
| 169 | }; | ||
| 170 | 168 | ||
| 171 | const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); | 169 | const afterRunning = await Effect.runPromise( |
| 172 | const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent)); | 170 | projectEvent( |
| 173 | const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent)); | 171 | afterCreate, |
| 174 | const thread = afterReady.threads[0]; | 172 | makeEvent({ |
| 173 | sequence: 2, | ||
| 174 | type: "thread.session-set", | ||
| 175 | aggregateType: "thread", | ||
| 176 | aggregateId: "thread-1", | ||
| 177 | occurredAt: startedAt, | ||
| 178 | commandId: "cmd-running", | ||
| 179 | payload: { | ||
| 180 | threadId: "thread-1", | ||
| 181 | session: { | ||
| 182 | threadId: "thread-1", | ||
| 183 | status: "running", | ||
| 184 | providerName: "codex", | ||
| 185 | providerSessionId: "session-1", | ||
| 186 | providerThreadId: "provider-thread-1", | ||
| 187 | activeTurnId: "turn-1", | ||
| 188 | lastError: null, | ||
| 189 | updatedAt: startedAt, | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | ); | ||
| 175 | 195 | ||
| 196 | const thread = afterRunning.threads[0]; | ||
| 176 | expect(thread?.latestTurnId).toBe("turn-1"); | 197 | expect(thread?.latestTurnId).toBe("turn-1"); |
| 177 | expect(thread?.latestTurnStartedAt).toBe(startedAt); | 198 | expect(thread?.session?.status).toBe("running"); |
| 178 | expect(thread?.latestTurnCompletedAt).toBe(completedAt); | ||
| 179 | expect(thread?.latestTurnDurationMs).toBe(6250); | ||
| 180 | }); | 199 | }); |
| 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 | const createdAt = "2026-02-23T09:00:00.000Z"; | 202 | const createdAt = "2026-02-23T09:00:00.000Z"; |
| 184 | const deltaAt = "2026-02-23T09:00:01.000Z"; | 203 | const deltaAt = "2026-02-23T09:00:01.000Z"; |
| 185 | const completeAt = "2026-02-23T09:00:03.500Z"; | 204 | const completeAt = "2026-02-23T09:00:03.500Z"; |
| 186 | const model = createEmptyReadModel(createdAt); | 205 | const model = createEmptyReadModel(createdAt); |
| 187 | 206 | ||
| 188 | const createdEvent: OrchestrationEvent = { | 207 | const afterCreate = await Effect.runPromise( |
| 189 | sequence: 1, | 208 | projectEvent( |
| 190 | eventId: "thread-created", | 209 | model, |
| 191 | type: "thread.created", | 210 | makeEvent({ |
| 192 | aggregateType: "thread", | 211 | sequence: 1, |
| 193 | aggregateId: "thread-1", | 212 | type: "thread.created", |
| 194 | occurredAt: createdAt, | 213 | aggregateType: "thread", |
| 195 | commandId: "cmd-create", | 214 | aggregateId: "thread-1", |
| 196 | payload: { | 215 | occurredAt: createdAt, |
| 197 | id: "thread-1", | 216 | commandId: "cmd-create", |
| 198 | projectId: "project-1", | 217 | payload: { |
| 199 | title: "demo", | 218 | threadId: "thread-1", |
| 200 | model: "gpt-5.3-codex", | 219 | projectId: "project-1", |
| 201 | branch: null, | 220 | title: "demo", |
| 202 | worktreePath: null, | 221 | model: "gpt-5.3-codex", |
| 203 | createdAt, | 222 | branch: null, |
| 204 | updatedAt: createdAt, | 223 | worktreePath: null, |
| 205 | }, | 224 | createdAt, |
| 206 | }; | 225 | updatedAt: createdAt, |
| 226 | }, | ||
| 227 | }), | ||
| 228 | ), | ||
| 229 | ); | ||
| 207 | 230 | ||
| 208 | const deltaEvent: OrchestrationEvent = { | 231 | const afterDelta = await Effect.runPromise( |
| 209 | sequence: 2, | 232 | projectEvent( |
| 210 | eventId: "assistant-delta", | 233 | afterCreate, |
| 211 | type: "message.sent", | 234 | makeEvent({ |
| 212 | aggregateType: "thread", | 235 | sequence: 2, |
| 213 | aggregateId: "thread-1", | 236 | type: "thread.message-sent", |
| 214 | occurredAt: deltaAt, | 237 | aggregateType: "thread", |
| 215 | commandId: "cmd-delta", | 238 | aggregateId: "thread-1", |
| 216 | payload: { | 239 | occurredAt: deltaAt, |
| 217 | id: "assistant:msg-1", | 240 | commandId: "cmd-delta", |
| 218 | role: "assistant", | 241 | payload: { |
| 219 | text: "hello", | 242 | threadId: "thread-1", |
| 220 | threadId: "thread-1", | 243 | messageId: "assistant:msg-1", |
| 221 | createdAt: deltaAt, | 244 | role: "assistant", |
| 222 | streaming: true, | 245 | text: "hello", |
| 223 | }, | 246 | turnId: "turn-1", |
| 224 | }; | 247 | streaming: true, |
| 248 | createdAt: deltaAt, | ||
| 249 | updatedAt: deltaAt, | ||
| 250 | }, | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | ); | ||
| 225 | 254 | ||
| 226 | const completionEvent: OrchestrationEvent = { | 255 | const afterComplete = await Effect.runPromise( |
| 227 | sequence: 3, | 256 | projectEvent( |
| 228 | eventId: "assistant-complete", | 257 | afterDelta, |
| 229 | type: "message.sent", | 258 | makeEvent({ |
| 230 | aggregateType: "thread", | 259 | sequence: 3, |
| 231 | aggregateId: "thread-1", | 260 | type: "thread.message-sent", |
| 232 | occurredAt: completeAt, | 261 | aggregateType: "thread", |
| 233 | commandId: "cmd-complete", | 262 | aggregateId: "thread-1", |
| 234 | payload: { | 263 | occurredAt: completeAt, |
| 235 | id: "assistant:msg-1", | 264 | commandId: "cmd-complete", |
| 236 | role: "assistant", | 265 | payload: { |
| 237 | text: "", | 266 | threadId: "thread-1", |
| 238 | threadId: "thread-1", | 267 | messageId: "assistant:msg-1", |
| 239 | createdAt: completeAt, | 268 | role: "assistant", |
| 240 | streaming: false, | 269 | text: "", |
| 241 | }, | 270 | turnId: "turn-1", |
| 242 | }; | 271 | streaming: false, |
| 272 | createdAt: completeAt, | ||
| 273 | updatedAt: completeAt, | ||
| 274 | }, | ||
| 275 | }), | ||
| 276 | ), | ||
| 277 | ); | ||
| 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 | const message = afterComplete.threads[0]?.messages[0]; | 279 | const message = afterComplete.threads[0]?.messages[0]; |
| 248 | 280 | expect(message?.id).toBe("assistant:msg-1"); | |
| 249 | expect(message).toEqual({ | 281 | expect(message?.text).toBe("hello"); |
| 250 | id: "assistant:msg-1", | 282 | expect(message?.streaming).toBe(false); |
| 251 | role: "assistant", | 283 | expect(message?.updatedAt).toBe(completeAt); |
| 252 | text: "hello", | ||
| 253 | createdAt: deltaAt, | ||
| 254 | completedAt: completeAt, | ||
| 255 | streaming: false, | ||
| 256 | }); | ||
| 257 | }); | 284 | }); |
| 258 | }); | 285 | }); |
apps/server/src/orchestration/projector.ts+176-253| ... | @@ -1,29 +1,36 @@ | ... | @@ -1,29 +1,36 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationEvent, | 2 | OrchestrationEvent, |
| 3 | OrchestrationGitReadModel, | ||
| 4 | OrchestrationMessage, | ||
| 5 | OrchestrationReadModel, | 3 | OrchestrationReadModel, |
| 6 | OrchestrationSession, | 4 | ThreadId, |
| 7 | OrchestrationThread, | ||
| 8 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 9 | import { | 6 | import { |
| 10 | OrchestrationThreadActivitySchema, | 7 | OrchestrationCheckpointSummary, |
| 11 | OrchestrationGitReadModelSchema, | 8 | OrchestrationMessage, |
| 12 | OrchestrationMessageSchema, | 9 | OrchestrationSession, |
| 13 | OrchestrationSessionSchema, | 10 | OrchestrationThread, |
| 14 | OrchestrationThreadSchema, | ||
| 15 | OrchestrationTurnDiffFileSchema, | ||
| 16 | OrchestrationTurnDiffSummarySchema, | ||
| 17 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 18 | import { Effect, Schema } from "effect"; | 12 | import { Effect, Schema } from "effect"; |
| 19 | 13 | ||
| 20 | import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; | 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 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | 29 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 23 | 30 | ||
| 24 | function updateThread( | 31 | function updateThread( |
| 25 | threads: ReadonlyArray<OrchestrationThread>, | 32 | threads: ReadonlyArray<OrchestrationThread>, |
| 26 | threadId: string, | 33 | threadId: ThreadId, |
| 27 | patch: ThreadPatch, | 34 | patch: ThreadPatch, |
| 28 | ): OrchestrationThread[] { | 35 | ): OrchestrationThread[] { |
| 29 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); | 36 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); |
| ... | @@ -32,7 +39,7 @@ function updateThread( | ... | @@ -32,7 +39,7 @@ function updateThread( |
| 32 | function decodeForEvent<A>( | 39 | function decodeForEvent<A>( |
| 33 | schema: Schema.Schema<A>, | 40 | schema: Schema.Schema<A>, |
| 34 | value: unknown, | 41 | value: unknown, |
| 35 | eventType: string, | 42 | eventType: OrchestrationEvent["type"], |
| 36 | field: string, | 43 | field: string, |
| 37 | ): Effect.Effect<A, OrchestrationProjectorDecodeError> { | 44 | ): Effect.Effect<A, OrchestrationProjectorDecodeError> { |
| 38 | return Schema.decodeUnknownEffect(schema)(value).pipe( | 45 | return Schema.decodeUnknownEffect(schema)(value).pipe( |
| ... | @@ -40,66 +47,11 @@ function decodeForEvent<A>( | ... | @@ -40,66 +47,11 @@ function decodeForEvent<A>( |
| 40 | ) as Effect.Effect<A, OrchestrationProjectorDecodeError>; | 47 | ) as Effect.Effect<A, OrchestrationProjectorDecodeError>; |
| 41 | } | 48 | } |
| 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 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | 50 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { |
| 99 | return { | 51 | return { |
| 100 | sequence: 0, | 52 | snapshotSequence: 0, |
| 53 | projects: [], | ||
| 101 | threads: [], | 54 | threads: [], |
| 102 | gitByProjectId: {}, | ||
| 103 | updatedAt: nowIso, | 55 | updatedAt: nowIso, |
| 104 | }; | 56 | }; |
| 105 | } | 57 | } |
| ... | @@ -110,39 +62,104 @@ export function projectEvent( | ... | @@ -110,39 +62,104 @@ export function projectEvent( |
| 110 | ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> { | 62 | ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> { |
| 111 | const nextBase: OrchestrationReadModel = { | 63 | const nextBase: OrchestrationReadModel = { |
| 112 | ...model, | 64 | ...model, |
| 113 | sequence: event.sequence, | 65 | snapshotSequence: event.sequence, |
| 114 | updatedAt: event.occurredAt, | 66 | updatedAt: event.occurredAt, |
| 115 | }; | 67 | }; |
| 116 | 68 | ||
| 117 | switch (event.type) { | 69 | switch (event.type) { |
| 70 | case "project.created": | ||
| 71 | return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( | ||
| 72 | Effect.map((payload) => { | ||
| 73 | const existing = nextBase.projects.find((entry) => entry.id === payload.projectId); | ||
| 74 | const nextProject = { | ||
| 75 | id: payload.projectId, | ||
| 76 | title: payload.title, | ||
| 77 | workspaceRoot: payload.workspaceRoot, | ||
| 78 | defaultModel: payload.defaultModel, | ||
| 79 | scripts: payload.scripts, | ||
| 80 | createdAt: payload.createdAt, | ||
| 81 | updatedAt: payload.updatedAt, | ||
| 82 | deletedAt: null, | ||
| 83 | }; | ||
| 84 | |||
| 85 | return { | ||
| 86 | ...nextBase, | ||
| 87 | projects: existing | ||
| 88 | ? nextBase.projects.map((entry) => (entry.id === payload.projectId ? nextProject : entry)) | ||
| 89 | : [...nextBase.projects, nextProject], | ||
| 90 | }; | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | case "project.meta-updated": | ||
| 95 | return decodeForEvent( | ||
| 96 | ProjectMetaUpdatedPayload, | ||
| 97 | event.payload, | ||
| 98 | event.type, | ||
| 99 | "payload", | ||
| 100 | ).pipe( | ||
| 101 | Effect.map((payload) => ({ | ||
| 102 | ...nextBase, | ||
| 103 | projects: nextBase.projects.map((project) => | ||
| 104 | project.id === payload.projectId | ||
| 105 | ? { | ||
| 106 | ...project, | ||
| 107 | ...(payload.title !== undefined ? { title: payload.title } : {}), | ||
| 108 | ...(payload.workspaceRoot !== undefined | ||
| 109 | ? { workspaceRoot: payload.workspaceRoot } | ||
| 110 | : {}), | ||
| 111 | ...(payload.defaultModel !== undefined | ||
| 112 | ? { defaultModel: payload.defaultModel } | ||
| 113 | : {}), | ||
| 114 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), | ||
| 115 | updatedAt: payload.updatedAt, | ||
| 116 | } | ||
| 117 | : project, | ||
| 118 | ), | ||
| 119 | })), | ||
| 120 | ); | ||
| 121 | |||
| 122 | case "project.deleted": | ||
| 123 | return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe( | ||
| 124 | Effect.map((payload) => ({ | ||
| 125 | ...nextBase, | ||
| 126 | projects: nextBase.projects.map((project) => | ||
| 127 | project.id === payload.projectId | ||
| 128 | ? { | ||
| 129 | ...project, | ||
| 130 | deletedAt: payload.deletedAt, | ||
| 131 | updatedAt: payload.deletedAt, | ||
| 132 | } | ||
| 133 | : project, | ||
| 134 | ), | ||
| 135 | })), | ||
| 136 | ); | ||
| 137 | |||
| 118 | case "thread.created": | 138 | case "thread.created": |
| 119 | return Effect.gen(function* () { | 139 | return Effect.gen(function* () { |
| 120 | const payload = yield* decodeForEvent( | 140 | const payload = yield* decodeForEvent( |
| 121 | ThreadCreatedPayloadSchema, | 141 | ThreadCreatedPayload, |
| 122 | event.payload, | 142 | event.payload, |
| 123 | event.type, | 143 | event.type, |
| 124 | "payload", | 144 | "payload", |
| 125 | ); | 145 | ); |
| 126 | const thread: OrchestrationThread = yield* decodeForEvent( | 146 | const thread: OrchestrationThread = yield* decodeForEvent( |
| 127 | OrchestrationThreadSchema, | 147 | OrchestrationThread, |
| 128 | { | 148 | { |
| 129 | id: payload.id, | 149 | id: payload.threadId, |
| 130 | projectId: payload.projectId, | 150 | projectId: payload.projectId, |
| 131 | title: payload.title, | 151 | title: payload.title, |
| 132 | model: payload.model, | 152 | model: payload.model, |
| 133 | branch: payload.branch, | 153 | branch: payload.branch, |
| 134 | worktreePath: payload.worktreePath, | 154 | worktreePath: payload.worktreePath, |
| 155 | latestTurnId: null, | ||
| 135 | createdAt: payload.createdAt, | 156 | createdAt: payload.createdAt, |
| 136 | updatedAt: payload.updatedAt, | 157 | updatedAt: payload.updatedAt, |
| 137 | latestTurnId: null, | 158 | deletedAt: null, |
| 138 | latestTurnStartedAt: null, | ||
| 139 | latestTurnCompletedAt: null, | ||
| 140 | latestTurnDurationMs: null, | ||
| 141 | messages: [], | 159 | messages: [], |
| 142 | session: null, | ||
| 143 | turnDiffSummaries: [], | ||
| 144 | activities: [], | 160 | activities: [], |
| 145 | error: null, | 161 | checkpoints: [], |
| 162 | session: null, | ||
| 146 | }, | 163 | }, |
| 147 | event.type, | 164 | event.type, |
| 148 | "thread", | 165 | "thread", |
| ... | @@ -155,36 +172,38 @@ export function projectEvent( | ... | @@ -155,36 +172,38 @@ export function projectEvent( |
| 155 | : [...nextBase.threads, thread], | 172 | : [...nextBase.threads, thread], |
| 156 | }; | 173 | }; |
| 157 | }); | 174 | }); |
| 175 | |||
| 158 | case "thread.deleted": | 176 | case "thread.deleted": |
| 159 | return Effect.succeed({ | 177 | return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( |
| 160 | ...nextBase, | 178 | Effect.map((payload) => ({ |
| 161 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), | 179 | ...nextBase, |
| 162 | }); | 180 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 181 | deletedAt: payload.deletedAt, | ||
| 182 | updatedAt: payload.deletedAt, | ||
| 183 | }), | ||
| 184 | })), | ||
| 185 | ); | ||
| 186 | |||
| 163 | case "thread.meta-updated": | 187 | case "thread.meta-updated": |
| 164 | return decodeForEvent( | 188 | return decodeForEvent( |
| 165 | ThreadMetaUpdatedPayloadSchema, | 189 | ThreadMetaUpdatedPayload, |
| 166 | event.payload, | 190 | event.payload, |
| 167 | event.type, | 191 | event.type, |
| 168 | "payload", | 192 | "payload", |
| 169 | ).pipe( | 193 | ).pipe( |
| 170 | Effect.map((payload) => { | 194 | Effect.map((payload) => ({ |
| 171 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | 195 | ...nextBase, |
| 172 | if (!thread) { | 196 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 173 | return nextBase; | 197 | ...(payload.title !== undefined ? { title: payload.title } : {}), |
| 174 | } | 198 | ...(payload.model !== undefined ? { model: payload.model } : {}), |
| 175 | return { | 199 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), |
| 176 | ...nextBase, | 200 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), |
| 177 | threads: updateThread(nextBase.threads, payload.threadId, { | 201 | updatedAt: payload.updatedAt, |
| 178 | ...(payload.title !== undefined ? { title: payload.title } : {}), | 202 | }), |
| 179 | ...(payload.model !== undefined ? { model: payload.model } : {}), | 203 | })), |
| 180 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | ||
| 181 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | ||
| 182 | updatedAt: payload.updatedAt, | ||
| 183 | }), | ||
| 184 | }; | ||
| 185 | }), | ||
| 186 | ); | 204 | ); |
| 187 | case "message.sent": | 205 | |
| 206 | case "thread.message-sent": | ||
| 188 | return Effect.gen(function* () { | 207 | return Effect.gen(function* () { |
| 189 | const payload = yield* decodeForEvent( | 208 | const payload = yield* decodeForEvent( |
| 190 | MessageSentPayloadSchema, | 209 | MessageSentPayloadSchema, |
| ... | @@ -192,27 +211,30 @@ export function projectEvent( | ... | @@ -192,27 +211,30 @@ export function projectEvent( |
| 192 | event.type, | 211 | event.type, |
| 193 | "payload", | 212 | "payload", |
| 194 | ); | 213 | ); |
| 195 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); | 214 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 196 | if (!targetThread) { | 215 | if (!thread) { |
| 197 | return nextBase; | 216 | return nextBase; |
| 198 | } | 217 | } |
| 199 | 218 | ||
| 200 | const message: OrchestrationMessage = yield* decodeForEvent( | 219 | const message: OrchestrationMessage = yield* decodeForEvent( |
| 201 | OrchestrationMessageSchema, | 220 | OrchestrationMessage, |
| 202 | { | 221 | { |
| 203 | id: payload.id, | 222 | id: payload.messageId, |
| 204 | role: payload.role, | 223 | role: payload.role, |
| 205 | text: payload.text, | 224 | text: payload.text, |
| 206 | createdAt: payload.createdAt, | 225 | ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), |
| 207 | ...(payload.streaming ? {} : { completedAt: event.occurredAt }), | 226 | turnId: payload.turnId, |
| 208 | streaming: payload.streaming, | 227 | streaming: payload.streaming, |
| 228 | createdAt: payload.createdAt, | ||
| 229 | updatedAt: payload.updatedAt, | ||
| 209 | }, | 230 | }, |
| 210 | event.type, | 231 | event.type, |
| 211 | "message", | 232 | "message", |
| 212 | ); | 233 | ); |
| 213 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); | 234 | |
| 214 | const nextMessages = existingMessage | 235 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); |
| 215 | ? targetThread.messages.map((entry) => | 236 | const messages = existingMessage |
| 237 | ? thread.messages.map((entry) => | ||
| 216 | entry.id === message.id | 238 | entry.id === message.id |
| 217 | ? { | 239 | ? { |
| 218 | ...entry, | 240 | ...entry, |
| ... | @@ -222,29 +244,29 @@ export function projectEvent( | ... | @@ -222,29 +244,29 @@ export function projectEvent( |
| 222 | ? message.text | 244 | ? message.text |
| 223 | : entry.text, | 245 | : entry.text, |
| 224 | streaming: message.streaming, | 246 | streaming: message.streaming, |
| 225 | createdAt: entry.createdAt, | 247 | updatedAt: message.updatedAt, |
| 226 | ...(message.streaming | 248 | turnId: message.turnId, |
| 227 | ? { completedAt: undefined } | 249 | ...(message.attachments !== undefined |
| 228 | : { completedAt: message.completedAt ?? event.occurredAt }), | 250 | ? { attachments: message.attachments } |
| 251 | : {}), | ||
| 229 | } | 252 | } |
| 230 | : entry, | 253 | : entry, |
| 231 | ) | 254 | ) |
| 232 | : !message.streaming && message.text.length === 0 | 255 | : [...thread.messages, message]; |
| 233 | ? targetThread.messages | ||
| 234 | : [...targetThread.messages, message]; | ||
| 235 | 256 | ||
| 236 | return { | 257 | return { |
| 237 | ...nextBase, | 258 | ...nextBase, |
| 238 | threads: updateThread(nextBase.threads, payload.threadId, { | 259 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 239 | messages: nextMessages, | 260 | messages, |
| 240 | updatedAt: event.occurredAt, | 261 | updatedAt: event.occurredAt, |
| 241 | }), | 262 | }), |
| 242 | }; | 263 | }; |
| 243 | }); | 264 | }); |
| 265 | |||
| 244 | case "thread.session-set": | 266 | case "thread.session-set": |
| 245 | return Effect.gen(function* () { | 267 | return Effect.gen(function* () { |
| 246 | const payload = yield* decodeForEvent( | 268 | const payload = yield* decodeForEvent( |
| 247 | ThreadSessionSetPayloadSchema, | 269 | ThreadSessionSetPayload, |
| 248 | event.payload, | 270 | event.payload, |
| 249 | event.type, | 271 | event.type, |
| 250 | "payload", | 272 | "payload", |
| ... | @@ -255,67 +277,26 @@ export function projectEvent( | ... | @@ -255,67 +277,26 @@ export function projectEvent( |
| 255 | } | 277 | } |
| 256 | 278 | ||
| 257 | const session: OrchestrationSession = yield* decodeForEvent( | 279 | const session: OrchestrationSession = yield* decodeForEvent( |
| 258 | OrchestrationSessionSchema, | 280 | OrchestrationSession, |
| 259 | { | 281 | payload.session, |
| 260 | ...payload.session, | ||
| 261 | threadId: payload.threadId, | ||
| 262 | }, | ||
| 263 | event.type, | 282 | event.type, |
| 264 | "session", | 283 | "session", |
| 265 | ); | 284 | ); |
| 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 | return { | 286 | return { |
| 306 | ...nextBase, | 287 | ...nextBase, |
| 307 | threads: updateThread(nextBase.threads, payload.threadId, { | 288 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 308 | session, | 289 | session, |
| 290 | latestTurnId: session.activeTurnId, | ||
| 309 | updatedAt: event.occurredAt, | 291 | updatedAt: event.occurredAt, |
| 310 | error: session.lastError, | ||
| 311 | ...turnTimingPatch, | ||
| 312 | }), | 292 | }), |
| 313 | }; | 293 | }; |
| 314 | }); | 294 | }); |
| 295 | |||
| 315 | case "thread.turn-diff-completed": | 296 | case "thread.turn-diff-completed": |
| 316 | return Effect.gen(function* () { | 297 | return Effect.gen(function* () { |
| 317 | const payload = yield* decodeForEvent( | 298 | const payload = yield* decodeForEvent( |
| 318 | ThreadTurnDiffCompletedPayloadSchema, | 299 | ThreadTurnDiffCompletedPayload, |
| 319 | event.payload, | 300 | event.payload, |
| 320 | event.type, | 301 | event.type, |
| 321 | "payload", | 302 | "payload", |
| ... | @@ -325,110 +306,65 @@ export function projectEvent( | ... | @@ -325,110 +306,65 @@ export function projectEvent( |
| 325 | return nextBase; | 306 | return nextBase; |
| 326 | } | 307 | } |
| 327 | 308 | ||
| 328 | const nextSummary = yield* decodeForEvent( | 309 | const checkpoint = yield* decodeForEvent( |
| 329 | OrchestrationTurnDiffSummarySchema, | 310 | OrchestrationCheckpointSummary, |
| 330 | { | 311 | { |
| 331 | turnId: payload.turnId, | 312 | turnId: payload.turnId, |
| 332 | completedAt: payload.completedAt, | 313 | checkpointTurnCount: payload.checkpointTurnCount, |
| 333 | ...(payload.status !== undefined ? { status: payload.status } : {}), | 314 | checkpointRef: payload.checkpointRef, |
| 315 | status: payload.status, | ||
| 334 | files: payload.files, | 316 | files: payload.files, |
| 335 | ...(payload.assistantMessageId !== undefined | 317 | assistantMessageId: payload.assistantMessageId, |
| 336 | ? { assistantMessageId: payload.assistantMessageId } | 318 | completedAt: payload.completedAt, |
| 337 | : {}), | ||
| 338 | ...(payload.checkpointTurnCount !== undefined | ||
| 339 | ? { checkpointTurnCount: payload.checkpointTurnCount } | ||
| 340 | : {}), | ||
| 341 | }, | 319 | }, |
| 342 | event.type, | 320 | event.type, |
| 343 | "turnDiffSummary", | 321 | "checkpoint", |
| 344 | ); | 322 | ); |
| 345 | const turnDiffSummaries = [ | 323 | |
| 346 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), | 324 | const checkpoints = [ |
| 347 | nextSummary, | 325 | ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId), |
| 348 | ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | 326 | checkpoint, |
| 349 | const startedAtMs = thread.latestTurnStartedAt | 327 | ].toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); |
| 350 | ? Date.parse(thread.latestTurnStartedAt) | ||
| 351 | : Number.NaN; | ||
| 352 | const completedAtMs = Date.parse(nextSummary.completedAt); | ||
| 353 | const latestTurnDurationMs = | ||
| 354 | !Number.isNaN(startedAtMs) && | ||
| 355 | !Number.isNaN(completedAtMs) && | ||
| 356 | completedAtMs >= startedAtMs | ||
| 357 | ? completedAtMs - startedAtMs | ||
| 358 | : thread.latestTurnDurationMs; | ||
| 359 | 328 | ||
| 360 | return { | 329 | return { |
| 361 | ...nextBase, | 330 | ...nextBase, |
| 362 | threads: updateThread(nextBase.threads, payload.threadId, { | 331 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 332 | checkpoints, | ||
| 363 | latestTurnId: payload.turnId, | 333 | latestTurnId: payload.turnId, |
| 364 | latestTurnCompletedAt: nextSummary.completedAt, | ||
| 365 | latestTurnDurationMs, | ||
| 366 | turnDiffSummaries, | ||
| 367 | updatedAt: event.occurredAt, | 334 | updatedAt: event.occurredAt, |
| 368 | }), | 335 | }), |
| 369 | }; | 336 | }; |
| 370 | }); | 337 | }); |
| 338 | |||
| 371 | case "thread.reverted": | 339 | case "thread.reverted": |
| 372 | return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( | 340 | return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe( |
| 373 | Effect.map((payload) => { | 341 | Effect.map((payload) => { |
| 374 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | 342 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); |
| 375 | if (!thread) { | 343 | if (!thread) { |
| 376 | return nextBase; | 344 | return nextBase; |
| 377 | } | 345 | } |
| 378 | 346 | ||
| 379 | const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); | 347 | const checkpoints = thread.checkpoints |
| 380 | const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); | 348 | .filter((entry) => entry.checkpointTurnCount <= payload.turnCount) |
| 381 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => | 349 | .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); |
| 382 | left.completedAt.localeCompare(right.completedAt), | ||
| 383 | ); | ||
| 384 | const inferredTurnCountByTurnId = new Map<string, number>(); | ||
| 385 | for (let index = 0; index < sortedSummaries.length; index += 1) { | ||
| 386 | const summary = sortedSummaries[index]; | ||
| 387 | if (!summary) continue; | ||
| 388 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); | ||
| 389 | } | ||
| 390 | 350 | ||
| 391 | const turnDiffSummaries = thread.turnDiffSummaries | 351 | const latestTurnId = |
| 392 | .filter((summary) => { | 352 | checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null; |
| 393 | const checkpointTurnCount = | ||
| 394 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; | ||
| 395 | return checkpointTurnCount <= targetTurnCount; | ||
| 396 | }) | ||
| 397 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | ||
| 398 | const latestSummary = | ||
| 399 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; | ||
| 400 | const activities = thread.activities.filter((activity) => { | ||
| 401 | if (!activity.turnId) { | ||
| 402 | return true; | ||
| 403 | } | ||
| 404 | const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); | ||
| 405 | if (checkpointTurnCount === undefined) { | ||
| 406 | return true; | ||
| 407 | } | ||
| 408 | return checkpointTurnCount <= targetTurnCount; | ||
| 409 | }); | ||
| 410 | 353 | ||
| 411 | return { | 354 | return { |
| 412 | ...nextBase, | 355 | ...nextBase, |
| 413 | threads: updateThread(nextBase.threads, payload.threadId, { | 356 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 414 | messages: thread.messages.slice( | 357 | checkpoints, |
| 415 | 0, | 358 | latestTurnId, |
| 416 | Math.min(targetMessageCount, thread.messages.length), | ||
| 417 | ), | ||
| 418 | turnDiffSummaries, | ||
| 419 | latestTurnId: latestSummary?.turnId ?? null, | ||
| 420 | latestTurnStartedAt: null, | ||
| 421 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | ||
| 422 | latestTurnDurationMs: null, | ||
| 423 | activities, | ||
| 424 | updatedAt: event.occurredAt, | 359 | updatedAt: event.occurredAt, |
| 425 | }), | 360 | }), |
| 426 | }; | 361 | }; |
| 427 | }), | 362 | }), |
| 428 | ); | 363 | ); |
| 364 | |||
| 429 | case "thread.activity-appended": | 365 | case "thread.activity-appended": |
| 430 | return decodeForEvent( | 366 | return decodeForEvent( |
| 431 | ThreadActivityAppendedPayloadSchema, | 367 | ThreadActivityAppendedPayload, |
| 432 | event.payload, | 368 | event.payload, |
| 433 | event.type, | 369 | event.type, |
| 434 | "payload", | 370 | "payload", |
| ... | @@ -438,8 +374,9 @@ export function projectEvent( | ... | @@ -438,8 +374,9 @@ export function projectEvent( |
| 438 | if (!thread) { | 374 | if (!thread) { |
| 439 | return nextBase; | 375 | return nextBase; |
| 440 | } | 376 | } |
| 377 | |||
| 441 | const activities = [ | 378 | const activities = [ |
| 442 | ...thread.activities.filter((activity) => activity.id !== payload.activity.id), | 379 | ...thread.activities.filter((entry) => entry.id !== payload.activity.id), |
| 443 | payload.activity, | 380 | payload.activity, |
| 444 | ] | 381 | ] |
| 445 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) | 382 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) |
| ... | @@ -454,21 +391,7 @@ export function projectEvent( | ... | @@ -454,21 +391,7 @@ export function projectEvent( |
| 454 | }; | 391 | }; |
| 455 | }), | 392 | }), |
| 456 | ); | 393 | ); |
| 457 | case "git.read-model-upsert": | 394 | |
| 458 | return decodeForEvent( | ||
| 459 | OrchestrationGitReadModelSchema, | ||
| 460 | event.payload, | ||
| 461 | event.type, | ||
| 462 | "payload", | ||
| 463 | ).pipe( | ||
| 464 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ | ||
| 465 | ...nextBase, | ||
| 466 | gitByProjectId: { | ||
| 467 | ...nextBase.gitByProjectId, | ||
| 468 | [gitReadModel.projectId]: gitReadModel, | ||
| 469 | }, | ||
| 470 | })), | ||
| 471 | ); | ||
| 472 | default: | 395 | default: |
| 473 | return Effect.succeed(nextBase); | 396 | return Effect.succeed(nextBase); |
| 474 | } | 397 | } |
apps/server/src/persistence/Errors.ts+16| ... | @@ -200,3 +200,19 @@ export type CheckpointRepositoryError = | ... | @@ -200,3 +200,19 @@ export type CheckpointRepositoryError = |
| 200 | export type ProviderSessionRepositoryError = | 200 | export type ProviderSessionRepositoryError = |
| 201 | | ProviderSessionRepositoryValidationError | 201 | | ProviderSessionRepositoryValidationError |
| 202 | | ProviderSessionRepositoryPersistenceError; | 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,39 +1,119 @@ |
| 1 | import { Effect, Layer, Stream } from "effect"; | 1 | import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; | 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 | import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; | 7 | import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; |
| 5 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | ||
| 6 | import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; | 8 | import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; |
| 7 | import { NodeServices } from "@effect/platform-node"; | 9 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; |
| 8 | 10 | ||
| 9 | const layer = it.layer( | 11 | const layer = it.layer( |
| 10 | Layer.mergeAll( | 12 | OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| 11 | Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory), | ||
| 12 | NodeServices.layer, | ||
| 13 | ), | ||
| 14 | ); | 13 | ); |
| 15 | 14 | ||
| 16 | layer("OrchestrationEventStore", (it) => { | 15 | layer("OrchestrationEventStore", (it) => { |
| 17 | it.effect("stores and replays events", () => | 16 | it.effect("stores json columns as strings and replays decoded events", () => |
| 18 | Effect.gen(function* () { | 17 | Effect.gen(function* () { |
| 19 | const createdAt = new Date().toISOString(); | ||
| 20 | |||
| 21 | const eventStore = yield* OrchestrationEventStore; | 18 | const eventStore = yield* OrchestrationEventStore; |
| 22 | const saved = yield* eventStore.append({ | 19 | const sql = yield* SqlClient.SqlClient; |
| 23 | eventId: "event-1", | 20 | const now = new Date().toISOString(); |
| 24 | type: "thread.created", | 21 | |
| 25 | aggregateType: "thread", | 22 | const appended = yield* eventStore.append({ |
| 26 | aggregateId: "thread-1", | 23 | type: "project.created", |
| 27 | occurredAt: createdAt, | 24 | eventId: EventId.makeUnsafe("evt-store-roundtrip"), |
| 28 | commandId: "cmd-1", | 25 | aggregateKind: "project", |
| 29 | payload: { id: "thread-1", projectId: "project-1", title: "demo" }, | 26 | aggregateId: ProjectId.makeUnsafe("project-roundtrip"), |
| 27 | occurredAt: now, | ||
| 28 | commandId: CommandId.makeUnsafe("cmd-store-roundtrip"), | ||
| 29 | causationEventId: null, | ||
| 30 | correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"), | ||
| 31 | metadata: { | ||
| 32 | adapterKey: "codex", | ||
| 33 | }, | ||
| 34 | payload: { | ||
| 35 | projectId: ProjectId.makeUnsafe("project-roundtrip"), | ||
| 36 | title: "Roundtrip Project", | ||
| 37 | workspaceRoot: "/tmp/project-roundtrip", | ||
| 38 | defaultModel: null, | ||
| 39 | scripts: [], | ||
| 40 | createdAt: now, | ||
| 41 | updatedAt: now, | ||
| 42 | }, | ||
| 30 | }); | 43 | }); |
| 31 | assert.equal(saved.sequence, 1); | ||
| 32 | 44 | ||
| 33 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe( | 45 | const storedRows = yield* sql<{ |
| 34 | Effect.map(Array.from), | 46 | readonly payloadJson: string; |
| 47 | readonly metadataJson: string; | ||
| 48 | }>` | ||
| 49 | SELECT | ||
| 50 | payload_json AS "payloadJson", | ||
| 51 | metadata_json AS "metadataJson" | ||
| 52 | FROM orchestration_events | ||
| 53 | WHERE event_id = ${appended.eventId} | ||
| 54 | `; | ||
| 55 | assert.equal(storedRows.length, 1); | ||
| 56 | assert.equal(typeof storedRows[0]?.payloadJson, "string"); | ||
| 57 | assert.equal(typeof storedRows[0]?.metadataJson, "string"); | ||
| 58 | |||
| 59 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe( | ||
| 60 | Effect.map((chunk) => Array.from(chunk)), | ||
| 61 | ); | ||
| 62 | assert.equal(replayed.length, 1); | ||
| 63 | assert.equal(replayed[0]?.type, "project.created"); | ||
| 64 | assert.equal(replayed[0]?.metadata.adapterKey, "codex"); | ||
| 65 | }), | ||
| 66 | ); | ||
| 67 | |||
| 68 | it.effect("fails with PersistenceDecodeError when stored json is invalid", () => | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const eventStore = yield* OrchestrationEventStore; | ||
| 71 | const sql = yield* SqlClient.SqlClient; | ||
| 72 | const now = new Date().toISOString(); | ||
| 73 | |||
| 74 | yield* sql` | ||
| 75 | INSERT INTO orchestration_events ( | ||
| 76 | event_id, | ||
| 77 | aggregate_kind, | ||
| 78 | stream_id, | ||
| 79 | stream_version, | ||
| 80 | event_type, | ||
| 81 | occurred_at, | ||
| 82 | command_id, | ||
| 83 | causation_event_id, | ||
| 84 | correlation_id, | ||
| 85 | actor_kind, | ||
| 86 | payload_json, | ||
| 87 | metadata_json | ||
| 88 | ) | ||
| 89 | VALUES ( | ||
| 90 | ${EventId.makeUnsafe("evt-store-invalid-json")}, | ||
| 91 | ${"project"}, | ||
| 92 | ${ProjectId.makeUnsafe("project-invalid-json")}, | ||
| 93 | ${0}, | ||
| 94 | ${"project.created"}, | ||
| 95 | ${now}, | ||
| 96 | ${CommandId.makeUnsafe("cmd-store-invalid-json")}, | ||
| 97 | ${null}, | ||
| 98 | ${null}, | ||
| 99 | ${"server"}, | ||
| 100 | ${"{"}, | ||
| 101 | ${"{}"} | ||
| 102 | ) | ||
| 103 | `; | ||
| 104 | |||
| 105 | const replayResult = yield* Effect.result( | ||
| 106 | Stream.runCollect(eventStore.readFromSequence(0, 10)), | ||
| 35 | ); | 107 | ); |
| 36 | assert.deepEqual(replayed, [saved]); | 108 | assert.equal(replayResult._tag, "Failure"); |
| 109 | if (replayResult._tag === "Failure") { | ||
| 110 | assert.ok(replayResult.failure instanceof PersistenceDecodeError); | ||
| 111 | assert.ok( | ||
| 112 | replayResult.failure.operation.includes( | ||
| 113 | "OrchestrationEventStore.readFromSequence:decodeRows", | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | } | ||
| 37 | }), | 117 | }), |
| 38 | ); | 118 | ); |
| 39 | }); | 119 | }); |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+149-87| ... | @@ -1,13 +1,22 @@ | ... | @@ -1,13 +1,22 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | 2 | CommandId, |
| 3 | EventId, | ||
| 4 | IsoDateTime, | ||
| 5 | NonNegativeInt, | ||
| 6 | OrchestrationActorKind, | ||
| 7 | OrchestrationAggregateKind, | ||
| 8 | OrchestrationEvent, | ||
| 9 | OrchestrationEventMetadata, | ||
| 10 | OrchestrationEventType, | ||
| 11 | ProjectId, | ||
| 12 | ThreadId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 4 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 15 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 5 | import { Effect, Layer, Schema, Stream } from "effect"; | 16 | import { Effect, Layer, Schema, Stream } from "effect"; |
| 6 | 17 | ||
| 7 | import { | 18 | import { |
| 8 | toPersistenceDecodeCauseError, | ||
| 9 | toPersistenceDecodeError, | 19 | toPersistenceDecodeError, |
| 10 | toPersistenceSerializationError, | ||
| 11 | toPersistenceSqlError, | 20 | toPersistenceSqlError, |
| 12 | type OrchestrationEventStoreError, | 21 | type OrchestrationEventStoreError, |
| 13 | } from "../Errors.ts"; | 22 | } from "../Errors.ts"; |
| ... | @@ -16,57 +25,77 @@ import { | ... | @@ -16,57 +25,77 @@ import { |
| 16 | type OrchestrationEventStoreShape, | 25 | type OrchestrationEventStoreShape, |
| 17 | } from "../Services/OrchestrationEventStore.ts"; | 26 | } from "../Services/OrchestrationEventStore.ts"; |
| 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({ | 32 | const AppendEventRequestSchema = Schema.Struct({ |
| 22 | sequence: Schema.Number, | 33 | eventId: EventId, |
| 23 | eventId: Schema.String, | 34 | aggregateKind: OrchestrationAggregateKind, |
| 24 | type: Schema.String, | 35 | streamId: Schema.Union([ProjectId, ThreadId]), |
| 25 | aggregateType: Schema.String, | 36 | type: OrchestrationEventType, |
| 26 | aggregateId: Schema.String, | 37 | causationEventId: Schema.NullOr(EventId), |
| 27 | occurredAt: Schema.String, | 38 | correlationId: Schema.NullOr(CommandId), |
| 28 | commandId: Schema.NullOr(Schema.String), | 39 | actorKind: OrchestrationActorKind, |
| 29 | payloadJson: Schema.String, | 40 | occurredAt: IsoDateTime, |
| 41 | commandId: Schema.NullOr(CommandId), | ||
| 42 | payloadJson: UnknownFromJsonString, | ||
| 43 | metadataJson: EventMetadataFromJsonString, | ||
| 30 | }); | 44 | }); |
| 31 | 45 | ||
| 32 | const AppendEventRequestSchema = Schema.Struct({ | 46 | const OrchestrationEventPersistedRowSchema = Schema.Struct({ |
| 33 | eventId: Schema.String, | 47 | sequence: NonNegativeInt, |
| 34 | type: Schema.String, | 48 | eventId: EventId, |
| 35 | aggregateType: Schema.String, | 49 | type: OrchestrationEventType, |
| 36 | aggregateId: Schema.String, | 50 | aggregateKind: OrchestrationAggregateKind, |
| 37 | occurredAt: Schema.String, | 51 | aggregateId: Schema.Union([ProjectId, ThreadId]), |
| 38 | commandId: Schema.NullOr(Schema.String), | 52 | occurredAt: IsoDateTime, |
| 39 | payloadJson: Schema.String, | 53 | commandId: Schema.NullOr(CommandId), |
| 54 | causationEventId: Schema.NullOr(EventId), | ||
| 55 | correlationId: Schema.NullOr(CommandId), | ||
| 56 | payload: UnknownFromJsonString, | ||
| 57 | metadata: EventMetadataFromJsonString, | ||
| 40 | }); | 58 | }); |
| 41 | 59 | ||
| 42 | const ReadFromSequenceRequestSchema = Schema.Struct({ | 60 | const ReadFromSequenceRequestSchema = Schema.Struct({ |
| 43 | sequenceExclusive: Schema.Number, | 61 | sequenceExclusive: NonNegativeInt, |
| 44 | limit: Schema.Number, | 62 | limit: Schema.Number, |
| 45 | }); | 63 | }); |
| 46 | const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; | 64 | const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; |
| 47 | const READ_PAGE_SIZE = 500; | 65 | const READ_PAGE_SIZE = 500; |
| 48 | 66 | ||
| 49 | function eventRowToOrchestrationEvent( | 67 | function inferActorKind( |
| 50 | row: Schema.Schema.Type<typeof EventRowSchema>, | 68 | event: Omit<OrchestrationEvent, "sequence">, |
| 51 | operation: string, | 69 | ): Schema.Schema.Type<typeof OrchestrationActorKind> { |
| 52 | ): Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError> { | 70 | if (event.commandId !== null && event.commandId.startsWith("provider:")) { |
| 53 | return Effect.try({ | 71 | return "provider"; |
| 54 | try: () => JSON.parse(row.payloadJson) as unknown, | 72 | } |
| 55 | catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), | 73 | if (event.commandId !== null && event.commandId.startsWith("server:")) { |
| 56 | }).pipe( | 74 | return "server"; |
| 57 | Effect.flatMap((payload) => | 75 | } |
| 58 | decodeEvent({ | 76 | if ( |
| 59 | sequence: row.sequence, | 77 | event.metadata.providerSessionId !== undefined || |
| 60 | eventId: row.eventId, | 78 | event.metadata.providerThreadId !== undefined || |
| 61 | type: row.type, | 79 | event.metadata.providerTurnId !== undefined || |
| 62 | aggregateType: row.aggregateType, | 80 | event.metadata.providerItemId !== undefined || |
| 63 | aggregateId: row.aggregateId, | 81 | event.metadata.adapterKey !== undefined |
| 64 | occurredAt: row.occurredAt, | 82 | ) { |
| 65 | commandId: row.commandId, | 83 | return "provider"; |
| 66 | payload, | 84 | } |
| 67 | }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), | 85 | if (event.commandId === null) { |
| 68 | ), | 86 | return "server"; |
| 69 | ); | 87 | } |
| 88 | return "client"; | ||
| 89 | } | ||
| 90 | |||
| 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 | const makeEventStore = Effect.gen(function* () { | 101 | const makeEventStore = Effect.gen(function* () { |
| ... | @@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () { |
| 74 | 103 | ||
| 75 | const appendEventRow = SqlSchema.findOne({ | 104 | const appendEventRow = SqlSchema.findOne({ |
| 76 | Request: AppendEventRequestSchema, | 105 | Request: AppendEventRequestSchema, |
| 77 | Result: EventRowSchema, | 106 | Result: OrchestrationEventPersistedRowSchema, |
| 78 | execute: (request) => | 107 | execute: (request) => |
| 79 | sql` | 108 | sql` |
| 80 | INSERT INTO orchestration_events ( | 109 | INSERT INTO orchestration_events ( |
| 81 | event_id, | 110 | event_id, |
| 111 | aggregate_kind, | ||
| 112 | stream_id, | ||
| 113 | stream_version, | ||
| 82 | event_type, | 114 | event_type, |
| 83 | aggregate_type, | ||
| 84 | aggregate_id, | ||
| 85 | occurred_at, | 115 | occurred_at, |
| 86 | command_id, | 116 | command_id, |
| 87 | payload_json | 117 | causation_event_id, |
| 118 | correlation_id, | ||
| 119 | actor_kind, | ||
| 120 | payload_json, | ||
| 121 | metadata_json | ||
| 88 | ) | 122 | ) |
| 89 | VALUES ( | 123 | VALUES ( |
| 90 | ${request.eventId}, | 124 | ${request.eventId}, |
| 125 | ${request.aggregateKind}, | ||
| 126 | ${request.streamId}, | ||
| 127 | COALESCE( | ||
| 128 | ( | ||
| 129 | SELECT stream_version + 1 | ||
| 130 | FROM orchestration_events | ||
| 131 | WHERE aggregate_kind = ${request.aggregateKind} | ||
| 132 | AND stream_id = ${request.streamId} | ||
| 133 | ORDER BY stream_version DESC | ||
| 134 | LIMIT 1 | ||
| 135 | ), | ||
| 136 | 0 | ||
| 137 | ), | ||
| 91 | ${request.type}, | 138 | ${request.type}, |
| 92 | ${request.aggregateType}, | ||
| 93 | ${request.aggregateId}, | ||
| 94 | ${request.occurredAt}, | 139 | ${request.occurredAt}, |
| 95 | ${request.commandId}, | 140 | ${request.commandId}, |
| 96 | ${request.payloadJson} | 141 | ${request.causationEventId}, |
| 142 | ${request.correlationId}, | ||
| 143 | ${request.actorKind}, | ||
| 144 | ${request.payloadJson}, | ||
| 145 | ${request.metadataJson} | ||
| 97 | ) | 146 | ) |
| 98 | RETURNING | 147 | RETURNING |
| 99 | sequence, | 148 | sequence, |
| 100 | event_id AS "eventId", | 149 | event_id AS "eventId", |
| 101 | event_type AS "type", | 150 | event_type AS "type", |
| 102 | aggregate_type AS "aggregateType", | 151 | aggregate_kind AS "aggregateKind", |
| 103 | aggregate_id AS "aggregateId", | 152 | stream_id AS "aggregateId", |
| 104 | occurred_at AS "occurredAt", | 153 | occurred_at AS "occurredAt", |
| 105 | command_id AS "commandId", | 154 | command_id AS "commandId", |
| 106 | payload_json AS "payloadJson" | 155 | causation_event_id AS "causationEventId", |
| 156 | correlation_id AS "correlationId", | ||
| 157 | payload_json AS "payload", | ||
| 158 | metadata_json AS "metadata" | ||
| 107 | `, | 159 | `, |
| 108 | }); | 160 | }); |
| 109 | 161 | ||
| 110 | const readEventRowsFromSequence = SqlSchema.findAll({ | 162 | const readEventRowsFromSequence = SqlSchema.findAll({ |
| 111 | Request: ReadFromSequenceRequestSchema, | 163 | Request: ReadFromSequenceRequestSchema, |
| 112 | Result: EventRowSchema, | 164 | Result: OrchestrationEventPersistedRowSchema, |
| 113 | execute: (request) => | 165 | execute: (request) => |
| 114 | sql` | 166 | sql` |
| 115 | SELECT | 167 | SELECT |
| 116 | sequence, | 168 | sequence, |
| 117 | event_id AS "eventId", | 169 | event_id AS "eventId", |
| 118 | event_type AS "type", | 170 | event_type AS "type", |
| 119 | aggregate_type AS "aggregateType", | 171 | aggregate_kind AS "aggregateKind", |
| 120 | aggregate_id AS "aggregateId", | 172 | stream_id AS "aggregateId", |
| 121 | occurred_at AS "occurredAt", | 173 | occurred_at AS "occurredAt", |
| 122 | command_id AS "commandId", | 174 | command_id AS "commandId", |
| 123 | payload_json AS "payloadJson" | 175 | causation_event_id AS "causationEventId", |
| 176 | correlation_id AS "correlationId", | ||
| 177 | payload_json AS "payload", | ||
| 178 | metadata_json AS "metadata" | ||
| 124 | FROM orchestration_events | 179 | FROM orchestration_events |
| 125 | WHERE sequence > ${request.sequenceExclusive} | 180 | WHERE sequence > ${request.sequenceExclusive} |
| 126 | ORDER BY sequence ASC | 181 | ORDER BY sequence ASC |
| ... | @@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () { |
| 129 | }); | 184 | }); |
| 130 | 185 | ||
| 131 | const append: OrchestrationEventStoreShape["append"] = (event) => | 186 | const append: OrchestrationEventStoreShape["append"] = (event) => |
| 132 | Effect.try({ | 187 | appendEventRow({ |
| 133 | try: () => JSON.stringify(event.payload), | 188 | eventId: event.eventId, |
| 134 | catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"), | 189 | aggregateKind: event.aggregateKind, |
| 190 | streamId: event.aggregateId, | ||
| 191 | type: event.type, | ||
| 192 | causationEventId: event.causationEventId, | ||
| 193 | correlationId: event.correlationId, | ||
| 194 | actorKind: inferActorKind(event), | ||
| 195 | occurredAt: event.occurredAt, | ||
| 196 | commandId: event.commandId, | ||
| 197 | payloadJson: event.payload, | ||
| 198 | metadataJson: event.metadata, | ||
| 135 | }).pipe( | 199 | }).pipe( |
| 136 | Effect.flatMap((payloadJson) => | 200 | Effect.mapError( |
| 137 | appendEventRow({ | 201 | toPersistenceSqlOrDecodeError( |
| 138 | eventId: event.eventId, | 202 | "OrchestrationEventStore.append:insert", |
| 139 | type: event.type, | 203 | "OrchestrationEventStore.append:decodeRow", |
| 140 | aggregateType: event.aggregateType, | 204 | ), |
| 141 | aggregateId: event.aggregateId, | 205 | ), |
| 142 | occurredAt: event.occurredAt, | 206 | Effect.flatMap((row) => |
| 143 | commandId: event.commandId, | 207 | decodeEvent(row).pipe( |
| 144 | payloadJson, | 208 | Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), |
| 145 | }).pipe( | ||
| 146 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")), | ||
| 147 | Effect.flatMap((row) => | ||
| 148 | eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"), | ||
| 149 | ), | ||
| 150 | ), | 209 | ), |
| 151 | ), | 210 | ), |
| 152 | ); | 211 | ); |
| ... | @@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () { |
| 159 | if (normalizedLimit === 0) { | 218 | if (normalizedLimit === 0) { |
| 160 | return Stream.empty; | 219 | return Stream.empty; |
| 161 | } | 220 | } |
| 162 | const readPage = (cursor: number, remaining: number): Stream.Stream< | 221 | const readPage = ( |
| 163 | OrchestrationEvent, | 222 | cursor: number, |
| 164 | OrchestrationEventStoreError | 223 | remaining: number, |
| 165 | > => | 224 | ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> => |
| 166 | Stream.fromEffect( | 225 | Stream.fromEffect( |
| 167 | readEventRowsFromSequence({ | 226 | readEventRowsFromSequence({ |
| 168 | sequenceExclusive: cursor, | 227 | sequenceExclusive: cursor, |
| 169 | limit: Math.min(remaining, READ_PAGE_SIZE), | 228 | limit: Math.min(remaining, READ_PAGE_SIZE), |
| 170 | }).pipe( | 229 | }).pipe( |
| 171 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readFromSequence:query")), | 230 | Effect.mapError( |
| 231 | toPersistenceSqlOrDecodeError( | ||
| 232 | "OrchestrationEventStore.readFromSequence:query", | ||
| 233 | "OrchestrationEventStore.readFromSequence:decodeRows", | ||
| 234 | ), | ||
| 235 | ), | ||
| 172 | Effect.flatMap((rows) => | 236 | Effect.flatMap((rows) => |
| 173 | Effect.forEach(rows, (row) => | 237 | Effect.forEach(rows, (row) => |
| 174 | eventRowToOrchestrationEvent( | 238 | decodeEvent(row).pipe( |
| 175 | row, | 239 | Effect.mapError( |
| 176 | "OrchestrationEventStore.readFromSequence:rowToEvent", | 240 | toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), |
| 241 | ), | ||
| 177 | ), | 242 | ), |
| 178 | ), | 243 | ), |
| 179 | ), | 244 | ), |
| ... | @@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () { | ... | @@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () { |
| 204 | } satisfies OrchestrationEventStoreShape; | 269 | } satisfies OrchestrationEventStoreShape; |
| 205 | }); | 270 | }); |
| 206 | 271 | ||
| 207 | export const OrchestrationEventStoreLive = Layer.effect( | 272 | export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); |
| 208 | OrchestrationEventStore, | ||
| 209 | makeEventStore, | ||
| 210 | ); |
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,3 +1,4 @@ |
| 1 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import { Effect, Layer, Option, Schema } from "effect"; | 4 | import { Effect, Layer, Option, Schema } from "effect"; |
| ... | @@ -12,26 +13,24 @@ import { | ... | @@ -12,26 +13,24 @@ import { |
| 12 | type ProviderSessionRepositoryShape, | 13 | type ProviderSessionRepositoryShape, |
| 13 | } from "../Services/ProviderSessions.ts"; | 14 | } from "../Services/ProviderSessions.ts"; |
| 14 | 15 | ||
| 15 | const NonEmptyString = Schema.NonEmptyString; | 16 | const ProviderKind = Schema.Literals(["codex", "claudeCode"]); |
| 16 | |||
| 17 | const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]); | ||
| 18 | 17 | ||
| 19 | const ProviderSessionRowSchema = Schema.Struct({ | 18 | const ProviderSessionRowSchema = Schema.Struct({ |
| 20 | sessionId: Schema.String, | 19 | sessionId: ProviderSessionId, |
| 21 | provider: ProviderKindSchema, | 20 | provider: ProviderKind, |
| 22 | threadId: Schema.NullOr(Schema.String), | 21 | threadId: Schema.NullOr(ThreadId), |
| 23 | createdAt: Schema.String, | 22 | createdAt: Schema.String, |
| 24 | updatedAt: Schema.String, | 23 | updatedAt: Schema.String, |
| 25 | }); | 24 | }); |
| 26 | 25 | ||
| 27 | const SessionIdRequestSchema = Schema.Struct({ | 26 | const SessionIdRequestSchema = Schema.Struct({ |
| 28 | sessionId: NonEmptyString, | 27 | sessionId: ProviderSessionId, |
| 29 | }); | 28 | }); |
| 30 | 29 | ||
| 31 | const UpsertSessionRequestSchema = Schema.Struct({ | 30 | const UpsertSessionRequestSchema = Schema.Struct({ |
| 32 | sessionId: NonEmptyString, | 31 | sessionId: ProviderSessionId, |
| 33 | provider: ProviderKindSchema, | 32 | provider: ProviderKind, |
| 34 | threadId: Schema.NullOr(NonEmptyString), | 33 | threadId: Schema.NullOr(ThreadId), |
| 35 | }); | 34 | }); |
| 36 | 35 | ||
| 37 | function errorMessage(cause: unknown, fallback: string): string { | 36 | function errorMessage(cause: unknown, fallback: string): string { |
| ... | @@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () { | ... | @@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 163 | const parsed = yield* decodeInput( | 162 | const parsed = yield* decodeInput( |
| 164 | UpsertSessionRequestSchema, | 163 | UpsertSessionRequestSchema, |
| 165 | { | 164 | { |
| 166 | sessionId: input.sessionId.trim(), | 165 | sessionId: input.sessionId, |
| 167 | provider: input.provider, | 166 | provider: input.provider, |
| 168 | threadId: input.threadId?.trim() || null, | 167 | threadId: input.threadId ?? null, |
| 169 | }, | 168 | }, |
| 170 | "ProviderSessionRepository.upsertSession", | 169 | "ProviderSessionRepository.upsertSession", |
| 171 | ); | 170 | ); |
| ... | @@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { | ... | @@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 182 | Effect.gen(function* () { | 181 | Effect.gen(function* () { |
| 183 | const parsed = yield* decodeInput( | 182 | const parsed = yield* decodeInput( |
| 184 | SessionIdRequestSchema, | 183 | SessionIdRequestSchema, |
| 185 | { sessionId: input.sessionId.trim() }, | 184 | { sessionId: input.sessionId }, |
| 186 | "ProviderSessionRepository.getSession", | 185 | "ProviderSessionRepository.getSession", |
| 187 | ); | 186 | ); |
| 188 | 187 | ||
| ... | @@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { | ... | @@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { |
| 207 | Effect.gen(function* () { | 206 | Effect.gen(function* () { |
| 208 | const parsed = yield* decodeInput( | 207 | const parsed = yield* decodeInput( |
| 209 | SessionIdRequestSchema, | 208 | SessionIdRequestSchema, |
| 210 | { sessionId: input.sessionId.trim() }, | 209 | { sessionId: input.sessionId }, |
| 211 | "ProviderSessionRepository.deleteSession", | 210 | "ProviderSessionRepository.deleteSession", |
| 212 | ); | 211 | ); |
| 213 | 212 |
apps/server/src/persistence/Migrations.ts+8-6| ... | @@ -13,9 +13,10 @@ import * as Layer from "effect/Layer"; | ... | @@ -13,9 +13,10 @@ import * as Layer from "effect/Layer"; |
| 13 | 13 | ||
| 14 | // Import all migrations statically | 14 | // Import all migrations statically |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| 16 | import Migration0002 from "./Migrations/002_Projects.ts"; | 16 | import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; |
| 17 | import Migration0003 from "./Migrations/003_Checkpoints.ts"; | 17 | import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessions.ts"; | 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; |
| 19 | import Migration0005 from "./Migrations/005_Projections.ts"; | ||
| 19 | 20 | ||
| 20 | /** | 21 | /** |
| 21 | * Migration loader with all migrations defined inline. | 22 | * Migration loader with all migrations defined inline. |
| ... | @@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts"; | ... | @@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts"; |
| 29 | */ | 30 | */ |
| 30 | const loader = Migrator.fromRecord({ | 31 | const loader = Migrator.fromRecord({ |
| 31 | "1_OrchestrationEvents": Migration0001, | 32 | "1_OrchestrationEvents": Migration0001, |
| 32 | "2_Projects": Migration0002, | 33 | "2_OrchestrationCommandReceipts": Migration0002, |
| 33 | "3_CheckpointCatalog": Migration0003, | 34 | "3_CheckpointDiffBlobs": Migration0003, |
| 34 | "4_ProviderSessions": Migration0004, | 35 | "4_ProviderSessionRuntime": Migration0004, |
| 36 | "5_Projections": Migration0005, | ||
| 35 | }); | 37 | }); |
| 36 | 38 | ||
| 37 | /** | 39 | /** |
apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts+25-5| ... | @@ -8,17 +8,37 @@ export default Effect.gen(function* () { | ... | @@ -8,17 +8,37 @@ export default Effect.gen(function* () { |
| 8 | CREATE TABLE IF NOT EXISTS orchestration_events ( | 8 | CREATE TABLE IF NOT EXISTS orchestration_events ( |
| 9 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, | 9 | sequence INTEGER PRIMARY KEY AUTOINCREMENT, |
| 10 | event_id TEXT NOT NULL UNIQUE, | 10 | event_id TEXT NOT NULL UNIQUE, |
| 11 | aggregate_kind TEXT NOT NULL, | ||
| 12 | stream_id TEXT NOT NULL, | ||
| 13 | stream_version INTEGER NOT NULL, | ||
| 11 | event_type TEXT NOT NULL, | 14 | event_type TEXT NOT NULL, |
| 12 | aggregate_type TEXT NOT NULL, | ||
| 13 | aggregate_id TEXT NOT NULL, | ||
| 14 | occurred_at TEXT NOT NULL, | 15 | occurred_at TEXT NOT NULL, |
| 15 | command_id TEXT, | 16 | command_id TEXT, |
| 16 | payload_json TEXT NOT 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 | yield* sql` | 25 | yield* sql` |
| 21 | CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate | 26 | CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version |
| 22 | ON orchestration_events(aggregate_type, aggregate_id, sequence) | 27 | ON orchestration_events(aggregate_kind, stream_id, stream_version) |
| 28 | `; | ||
| 29 | |||
| 30 | yield* sql` | ||
| 31 | CREATE INDEX IF NOT EXISTS idx_orch_events_stream_sequence | ||
| 32 | ON orchestration_events(aggregate_kind, stream_id, sequence) | ||
| 33 | `; | ||
| 34 | |||
| 35 | yield* sql` | ||
| 36 | CREATE INDEX IF NOT EXISTS idx_orch_events_command_id | ||
| 37 | ON orchestration_events(command_id) | ||
| 38 | `; | ||
| 39 | |||
| 40 | yield* sql` | ||
| 41 | CREATE INDEX IF NOT EXISTS idx_orch_events_correlation_id | ||
| 42 | ON orchestration_events(correlation_id) | ||
| 23 | `; | 43 | `; |
| 24 | }); | 44 | }); |
apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 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,7 +9,7 @@ |
| 9 | * | 9 | * |
| 10 | * @module OrchestrationEventStore | 10 | * @module OrchestrationEventStore |
| 11 | */ | 11 | */ |
| 12 | import type { OrchestrationEvent } from "@t3tools/contracts"; | 12 | import { OrchestrationEvent } from "@t3tools/contracts"; |
| 13 | import { ServiceMap } from "effect"; | 13 | import { ServiceMap } from "effect"; |
| 14 | import type { Effect, Stream } from "effect"; | 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,29 +1,29 @@ |
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | 1 | import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { Option, ServiceMap } from "effect"; | 2 | import { Option, ServiceMap } from "effect"; |
| 3 | import type { Effect } from "effect"; | 3 | import type { Effect } from "effect"; |
| 4 | 4 | ||
| 5 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; | 5 | import type { ProviderSessionRepositoryError } from "../Errors.ts"; |
| 6 | 6 | ||
| 7 | export interface ProviderSessionEntry { | 7 | export interface ProviderSessionEntry { |
| 8 | readonly sessionId: string; | 8 | readonly sessionId: ProviderSessionId; |
| 9 | readonly provider: ProviderKind; | 9 | readonly provider: ProviderKind; |
| 10 | readonly threadId?: string; | 10 | readonly threadId?: ThreadId; |
| 11 | readonly createdAt: string; | 11 | readonly createdAt: string; |
| 12 | readonly updatedAt: string; | 12 | readonly updatedAt: string; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | export interface UpsertProviderSessionInput { | 15 | export interface UpsertProviderSessionInput { |
| 16 | readonly sessionId: string; | 16 | readonly sessionId: ProviderSessionId; |
| 17 | readonly provider: ProviderKind; | 17 | readonly provider: ProviderKind; |
| 18 | readonly threadId?: string; | 18 | readonly threadId?: ThreadId; |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | export interface DeleteProviderSessionInput { | 21 | export interface DeleteProviderSessionInput { |
| 22 | readonly sessionId: string; | 22 | readonly sessionId: ProviderSessionId; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | export interface GetProviderSessionInput { | 25 | export interface GetProviderSessionInput { |
| 26 | readonly sessionId: string; | 26 | readonly sessionId: ProviderSessionId; |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | export interface ProviderSessionRepositoryShape { | 29 | export interface ProviderSessionRepositoryShape { |
apps/server/src/provider/Layers/CodexAdapter.ts+26-12| ... | @@ -6,13 +6,18 @@ | ... | @@ -6,13 +6,18 @@ |
| 6 | * | 6 | * |
| 7 | * @module CodexAdapterLive | 7 | * @module CodexAdapterLive |
| 8 | */ | 8 | */ |
| 9 | import type { | 9 | import { |
| 10 | ProviderEvent, | 10 | type ProviderEvent, |
| 11 | ProviderRuntimeEvent, | 11 | type ProviderRuntimeEvent, |
| 12 | ProviderRuntimeTurnStatus, | 12 | type ProviderRuntimeTurnStatus, |
| 13 | ProviderRuntimeToolKind, | 13 | type ProviderRuntimeToolKind, |
| 14 | ProviderItemId, | ||
| 15 | ProviderApprovalDecision, | ||
| 16 | ProviderSessionId, | ||
| 17 | ProviderThreadId, | ||
| 18 | ProviderTurnId, | ||
| 14 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 15 | import { Effect, Layer, Queue, Stream } from "effect"; | 20 | import { Effect, Layer, Queue, Schema, Stream } from "effect"; |
| 16 | 21 | ||
| 17 | import { | 22 | import { |
| 18 | ProviderAdapterProcessError, | 23 | ProviderAdapterProcessError, |
| ... | @@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string { | ... | @@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string { |
| 42 | } | 47 | } |
| 43 | 48 | ||
| 44 | function toSessionError( | 49 | function toSessionError( |
| 45 | sessionId: string, | 50 | sessionId: ProviderSessionId, |
| 46 | cause: unknown, | 51 | cause: unknown, |
| 47 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { | 52 | ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { |
| 48 | const normalized = toMessage(cause, "").toLowerCase(); | 53 | const normalized = toMessage(cause, "").toLowerCase(); |
| ... | @@ -63,7 +68,11 @@ function toSessionError( | ... | @@ -63,7 +68,11 @@ function toSessionError( |
| 63 | return undefined; | 68 | return undefined; |
| 64 | } | 69 | } |
| 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 | const sessionError = toSessionError(sessionId, cause); | 76 | const sessionError = toSessionError(sessionId, cause); |
| 68 | if (sessionError) { | 77 | if (sessionError) { |
| 69 | return sessionError; | 78 | return sessionError; |
| ... | @@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | | ... | @@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | |
| 188 | provider: event.provider, | 197 | provider: event.provider, |
| 189 | sessionId: event.sessionId, | 198 | sessionId: event.sessionId, |
| 190 | createdAt: event.createdAt, | 199 | createdAt: event.createdAt, |
| 191 | itemId, | 200 | itemId: ProviderItemId.makeUnsafe(itemId), |
| 192 | ...(event.threadId ? { threadId: event.threadId } : {}), | 201 | ...(event.threadId ? { threadId: event.threadId } : {}), |
| 193 | ...(event.turnId ? { turnId: event.turnId } : {}), | 202 | ...(event.turnId ? { turnId: event.turnId } : {}), |
| 194 | }; | 203 | }; |
| ... | @@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime | ... | @@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 282 | } | 291 | } |
| 283 | 292 | ||
| 284 | if (event.method === "item/requestApproval/decision" && event.requestId) { | 293 | if (event.method === "item/requestApproval/decision" && event.requestId) { |
| 285 | const decision = asString(payload?.decision); | 294 | const decision = Schema.decodeUnknownSync(ProviderApprovalDecision)(payload?.decision); |
| 286 | return [ | 295 | return [ |
| 287 | { | 296 | { |
| 288 | type: "approval.resolved", | 297 | type: "approval.resolved", |
| ... | @@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime | ... | @@ -339,7 +348,10 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 339 | } | 348 | } |
| 340 | 349 | ||
| 341 | if (event.method === "thread/started") { | 350 | if (event.method === "thread/started") { |
| 342 | const threadId = event.threadId ?? asString(asObject(payload?.thread)?.id); | 351 | const payloadThreadId = asString(asObject(payload?.thread)?.id); |
| 352 | const threadId = | ||
| 353 | event.threadId ?? | ||
| 354 | (payloadThreadId ? ProviderThreadId.makeUnsafe(payloadThreadId) : undefined); | ||
| 343 | if (!threadId) { | 355 | if (!threadId) { |
| 344 | return []; | 356 | return []; |
| 345 | } | 357 | } |
| ... | @@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime | ... | @@ -356,7 +368,9 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 356 | } | 368 | } |
| 357 | 369 | ||
| 358 | if (event.method === "turn/started") { | 370 | if (event.method === "turn/started") { |
| 359 | const turnId = event.turnId ?? asString(turn?.id); | 371 | const payloadTurnId = asString(turn?.id); |
| 372 | const turnId = | ||
| 373 | event.turnId ?? (payloadTurnId ? ProviderTurnId.makeUnsafe(payloadTurnId) : undefined); | ||
| 360 | if (!turnId) { | 374 | if (!turnId) { |
| 361 | return []; | 375 | return []; |
| 362 | } | 376 | } |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+1-5| ... | @@ -7,7 +7,6 @@ | ... | @@ -7,7 +7,6 @@ |
| 7 | * | 7 | * |
| 8 | * @module ProviderAdapterRegistryLive | 8 | * @module ProviderAdapterRegistryLive |
| 9 | */ | 9 | */ |
| 10 | import type { ProviderKind } from "@t3tools/contracts"; | ||
| 11 | import { Effect, Layer } from "effect"; | 10 | import { Effect, Layer } from "effect"; |
| 12 | 11 | ||
| 13 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; | 12 | import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; |
| ... | @@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions { | ... | @@ -25,10 +24,7 @@ export interface ProviderAdapterRegistryLiveOptions { |
| 25 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => | 24 | const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => |
| 26 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| 27 | const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; | 26 | const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; |
| 28 | const byProvider = new Map<ProviderKind, ProviderAdapterShape<ProviderAdapterError>>(); | 27 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 29 | for (const adapter of adapters) { | ||
| 30 | byProvider.set(adapter.provider, adapter); | ||
| 31 | } | ||
| 32 | 28 | ||
| 33 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { | 29 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
| 34 | const adapter = byProvider.get(provider); | 30 | const adapter = byProvider.get(provider); |
apps/server/src/provider/Layers/ProviderService.test.ts+216-234| ... | @@ -4,25 +4,26 @@ import path from "node:path"; | ... | @@ -4,25 +4,26 @@ import path from "node:path"; |
| 4 | 4 | ||
| 5 | import type { | 5 | import type { |
| 6 | ProviderApprovalDecision, | 6 | ProviderApprovalDecision, |
| 7 | ProviderGetCheckpointDiffInput, | ||
| 8 | ProviderGetCheckpointDiffResult, | ||
| 9 | ProviderListCheckpointsInput, | ||
| 10 | ProviderListCheckpointsResult, | ||
| 11 | ProviderRevertToCheckpointInput, | ||
| 12 | ProviderRuntimeEvent, | 7 | ProviderRuntimeEvent, |
| 13 | ProviderRevertToCheckpointResult, | ||
| 14 | ProviderSendTurnInput, | 8 | ProviderSendTurnInput, |
| 15 | ProviderSession, | 9 | ProviderSession, |
| 16 | ProviderSessionStartInput, | ||
| 17 | ProviderTurnStartResult, | 10 | ProviderTurnStartResult, |
| 18 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 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 | import { it, assert, vi } from "@effect/vitest"; | 21 | import { it, assert, vi } from "@effect/vitest"; |
| 21 | import { assertFailure } from "@effect/vitest/utils"; | 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 | import { | 27 | import { |
| 27 | ProviderAdapterSessionNotFoundError, | 28 | ProviderAdapterSessionNotFoundError, |
| 28 | ProviderSessionNotFoundError, | 29 | ProviderSessionNotFoundError, |
| ... | @@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | ... | @@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; |
| 34 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 35 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 35 | import { ProviderService } from "../Services/ProviderService.ts"; | 36 | import { ProviderService } from "../Services/ProviderService.ts"; |
| 36 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 37 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 37 | import { ProviderServiceLive } from "./ProviderService.ts"; | 38 | import { makeProviderServiceLive } from "./ProviderService.ts"; |
| 38 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | 39 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; |
| 39 | import { NodeServices } from "@effect/platform-node"; | 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 | import { | 43 | import { |
| 42 | makeSqlitePersistenceLive, | 44 | makeSqlitePersistenceLive, |
| 43 | SqlitePersistenceMemory, | 45 | SqlitePersistenceMemory, |
| 44 | } from "../../persistence/Layers/Sqlite.ts"; | 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 | function makeFakeCodexAdapter() { | 65 | function makeFakeCodexAdapter() { |
| 47 | const sessions = new Map<string, ProviderSession>(); | 66 | const sessions = new Map<ProviderSessionId, ProviderSession>(); |
| 48 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 67 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 49 | let nextSession = 1; | 68 | let nextSession = 1; |
| 50 | 69 | ||
| 51 | const startSession = vi.fn((input: ProviderSessionStartInput) => | 70 | const startSession = vi.fn((input: ProviderSessionStartInput) => |
| 52 | Effect.sync(() => { | 71 | Effect.sync(() => { |
| 53 | const now = new Date().toISOString(); | 72 | const now = new Date().toISOString(); |
| 73 | const next = nextSession; | ||
| 54 | const session: ProviderSession = { | 74 | const session: ProviderSession = { |
| 55 | sessionId: `sess-${nextSession}`, | 75 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), |
| 56 | provider: "codex", | 76 | provider: "codex", |
| 57 | status: "ready", | 77 | status: "ready", |
| 58 | threadId: input.resumeThreadId ?? `thread-${nextSession}`, | 78 | threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), |
| 59 | cwd: input.cwd ?? process.cwd(), | 79 | cwd: input.cwd ?? process.cwd(), |
| 60 | createdAt: now, | 80 | createdAt: now, |
| 61 | updatedAt: now, | 81 | updatedAt: now, |
| ... | @@ -80,8 +100,8 @@ function makeFakeCodexAdapter() { | ... | @@ -80,8 +100,8 @@ function makeFakeCodexAdapter() { |
| 80 | } | 100 | } |
| 81 | 101 | ||
| 82 | return Effect.succeed({ | 102 | return Effect.succeed({ |
| 83 | threadId: "thread-1", | 103 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 84 | turnId: "turn-1", | 104 | turnId: ProviderTurnId.makeUnsafe("turn-1"), |
| 85 | }); | 105 | }); |
| 86 | }, | 106 | }, |
| 87 | ); | 107 | ); |
| ... | @@ -102,7 +122,7 @@ function makeFakeCodexAdapter() { | ... | @@ -102,7 +122,7 @@ function makeFakeCodexAdapter() { |
| 102 | const stopSession = vi.fn( | 122 | const stopSession = vi.fn( |
| 103 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => | 123 | (sessionId: string): Effect.Effect<void, ProviderAdapterError> => |
| 104 | Effect.sync(() => { | 124 | Effect.sync(() => { |
| 105 | sessions.delete(sessionId); | 125 | sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); |
| 106 | }), | 126 | }), |
| 107 | ); | 127 | ); |
| 108 | 128 | ||
| ... | @@ -112,22 +132,29 @@ function makeFakeCodexAdapter() { | ... | @@ -112,22 +132,29 @@ function makeFakeCodexAdapter() { |
| 112 | ); | 132 | ); |
| 113 | 133 | ||
| 114 | const hasSession = vi.fn( | 134 | const hasSession = vi.fn( |
| 115 | (sessionId: string): Effect.Effect<boolean> => Effect.succeed(sessions.has(sessionId)), | 135 | (sessionId: string): Effect.Effect<boolean> => |
| 136 | Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), | ||
| 116 | ); | 137 | ); |
| 117 | 138 | ||
| 118 | const readThread = vi.fn( | 139 | const readThread = vi.fn( |
| 119 | ( | 140 | ( |
| 120 | _sessionId: string, | 141 | _sessionId: ProviderSessionId, |
| 121 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | 142 | ): Effect.Effect< |
| 122 | Effect.succeed({ threadId: "thread-1", turns: [] }), | 143 | { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> }, |
| 144 | ProviderAdapterError | ||
| 145 | > => | ||
| 146 | Effect.succeed({ | ||
| 147 | threadId: ProviderThreadId.makeUnsafe("thread-1"), | ||
| 148 | turns: [{ id: asTurnId("turn-1"), items: [] }], | ||
| 149 | }), | ||
| 123 | ); | 150 | ); |
| 124 | 151 | ||
| 125 | const rollbackThread = vi.fn( | 152 | const rollbackThread = vi.fn( |
| 126 | ( | 153 | ( |
| 127 | _sessionId: string, | 154 | _sessionId: ProviderSessionId, |
| 128 | _numTurns: number, | 155 | _numTurns: number, |
| 129 | ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => | 156 | ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => |
| 130 | Effect.succeed({ threadId: "thread-1", turns: [] }), | 157 | Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), |
| 131 | ); | 158 | ); |
| 132 | 159 | ||
| 133 | const stopAll = vi.fn( | 160 | const stopAll = vi.fn( |
| ... | @@ -172,80 +199,11 @@ function makeFakeCodexAdapter() { | ... | @@ -172,80 +199,11 @@ function makeFakeCodexAdapter() { |
| 172 | }; | 199 | }; |
| 173 | } | 200 | } |
| 174 | 201 | ||
| 175 | function makeCheckpointServiceDouble() { | 202 | const sleep = (ms: number) => |
| 176 | const initializeForSession = vi.fn( | 203 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); |
| 177 | (_: { providerSessionId: string; cwd: string }) => Effect.void, | ||
| 178 | ); | ||
| 179 | const captureCurrentTurn = vi.fn( | ||
| 180 | (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void, | ||
| 181 | ); | ||
| 182 | const listCheckpoints = vi.fn( | ||
| 183 | (_input: ProviderListCheckpointsInput): Effect.Effect<ProviderListCheckpointsResult> => | ||
| 184 | Effect.succeed({ | ||
| 185 | threadId: "thread-1", | ||
| 186 | checkpoints: [ | ||
| 187 | { | ||
| 188 | id: "root", | ||
| 189 | turnCount: 0, | ||
| 190 | messageCount: 0, | ||
| 191 | label: "Start of conversation", | ||
| 192 | isCurrent: true, | ||
| 193 | }, | ||
| 194 | ], | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | const getCheckpointDiff = vi.fn( | ||
| 198 | (input: ProviderGetCheckpointDiffInput): Effect.Effect<ProviderGetCheckpointDiffResult> => | ||
| 199 | Effect.succeed({ | ||
| 200 | threadId: "thread-1", | ||
| 201 | fromTurnCount: input.fromTurnCount, | ||
| 202 | toTurnCount: input.toTurnCount, | ||
| 203 | diff: "diff --git a/a.ts b/a.ts", | ||
| 204 | }), | ||
| 205 | ); | ||
| 206 | const revertToCheckpoint = vi.fn( | ||
| 207 | (input: ProviderRevertToCheckpointInput): Effect.Effect<ProviderRevertToCheckpointResult> => | ||
| 208 | Effect.succeed({ | ||
| 209 | threadId: "thread-1", | ||
| 210 | turnCount: input.turnCount, | ||
| 211 | messageCount: 0, | ||
| 212 | rolledBackTurns: 0, | ||
| 213 | checkpoints: [ | ||
| 214 | { | ||
| 215 | id: "root", | ||
| 216 | turnCount: 0, | ||
| 217 | messageCount: 0, | ||
| 218 | label: "Start of conversation", | ||
| 219 | isCurrent: input.turnCount === 0, | ||
| 220 | }, | ||
| 221 | ], | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | const releaseSession = vi.fn((_input: { providerSessionId: string }) => Effect.void); | ||
| 225 | |||
| 226 | const service: typeof CheckpointService.Service = { | ||
| 227 | initializeForSession, | ||
| 228 | captureCurrentTurn, | ||
| 229 | listCheckpoints, | ||
| 230 | getCheckpointDiff, | ||
| 231 | revertToCheckpoint, | ||
| 232 | releaseSession, | ||
| 233 | }; | ||
| 234 | |||
| 235 | return { | ||
| 236 | service, | ||
| 237 | initializeForSession, | ||
| 238 | captureCurrentTurn, | ||
| 239 | listCheckpoints, | ||
| 240 | getCheckpointDiff, | ||
| 241 | revertToCheckpoint, | ||
| 242 | releaseSession, | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | 204 | ||
| 246 | function makeProviderServiceLayer() { | 205 | function makeProviderServiceLayer() { |
| 247 | const codex = makeFakeCodexAdapter(); | 206 | const codex = makeFakeCodexAdapter(); |
| 248 | const checkpoint = makeCheckpointServiceDouble(); | ||
| 249 | const registry: typeof ProviderAdapterRegistry.Service = { | 207 | const registry: typeof ProviderAdapterRegistry.Service = { |
| 250 | getByProvider: (provider) => | 208 | getByProvider: (provider) => |
| 251 | provider === "codex" | 209 | provider === "codex" |
| ... | @@ -254,28 +212,26 @@ function makeProviderServiceLayer() { | ... | @@ -254,28 +212,26 @@ function makeProviderServiceLayer() { |
| 254 | listProviders: () => Effect.succeed(["codex"]), | 212 | listProviders: () => Effect.succeed(["codex"]), |
| 255 | }; | 213 | }; |
| 256 | 214 | ||
| 257 | const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); | ||
| 258 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); | 215 | const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); |
| 259 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | 216 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| 260 | Layer.provide(ProviderSessionRepositoryLive), | ||
| 261 | Layer.provide(SqlitePersistenceMemory), | 217 | Layer.provide(SqlitePersistenceMemory), |
| 262 | ); | 218 | ); |
| 219 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | ||
| 263 | 220 | ||
| 264 | const layer = it.layer( | 221 | const layer = it.layer( |
| 265 | Layer.mergeAll( | 222 | Layer.mergeAll( |
| 266 | ProviderServiceLive.pipe( | 223 | makeProviderServiceLive().pipe( |
| 267 | Layer.provide(providerAdapterLayer), | 224 | Layer.provide(providerAdapterLayer), |
| 268 | Layer.provide(directoryLayer), | 225 | Layer.provide(directoryLayer), |
| 269 | Layer.provide(checkpointLayer), | ||
| 270 | ), | 226 | ), |
| 271 | directoryLayer, | 227 | directoryLayer, |
| 228 | runtimeRepositoryLayer, | ||
| 272 | NodeServices.layer, | 229 | NodeServices.layer, |
| 273 | ), | 230 | ), |
| 274 | ); | 231 | ); |
| 275 | 232 | ||
| 276 | return { | 233 | return { |
| 277 | codex, | 234 | codex, |
| 278 | checkpoint, | ||
| 279 | layer, | 235 | layer, |
| 280 | }; | 236 | }; |
| 281 | } | 237 | } |
| ... | @@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 287 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | 243 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 288 | 244 | ||
| 289 | const codex = makeFakeCodexAdapter(); | 245 | const codex = makeFakeCodexAdapter(); |
| 290 | const checkpoint = makeCheckpointServiceDouble(); | ||
| 291 | const registry: typeof ProviderAdapterRegistry.Service = { | 246 | const registry: typeof ProviderAdapterRegistry.Service = { |
| 292 | getByProvider: (provider) => | 247 | getByProvider: (provider) => |
| 293 | provider === "codex" | 248 | provider === "codex" |
| ... | @@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( | ... | @@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( |
| 297 | }; | 252 | }; |
| 298 | 253 | ||
| 299 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | 254 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 300 | const directoryLayer = ProviderSessionDirectoryLive.pipe( | 255 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| 301 | Layer.provide(ProviderSessionRepositoryLive), | ||
| 302 | Layer.provide(persistenceLayer), | 256 | Layer.provide(persistenceLayer), |
| 303 | ); | 257 | ); |
| 258 | const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | ||
| 304 | 259 | ||
| 305 | yield* Effect.gen(function* () { | 260 | yield* Effect.gen(function* () { |
| 306 | const directory = yield* ProviderSessionDirectory; | 261 | const directory = yield* ProviderSessionDirectory; |
| 307 | yield* directory.upsert({ | 262 | yield* directory.upsert({ |
| 308 | sessionId: "sess-stale", | 263 | sessionId: asSessionId("sess-stale"), |
| 309 | provider: "codex", | 264 | provider: "codex", |
| 310 | threadId: "thread-stale", | 265 | threadId: ThreadId.makeUnsafe("thread-stale"), |
| 311 | }); | 266 | }); |
| 312 | }).pipe(Effect.provide(directoryLayer)); | 267 | }).pipe(Effect.provide(directoryLayer)); |
| 313 | 268 | ||
| 314 | const providerLayer = ProviderServiceLive.pipe( | 269 | const providerLayer = makeProviderServiceLive().pipe( |
| 315 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | 270 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 316 | Layer.provide(directoryLayer), | 271 | Layer.provide(directoryLayer), |
| 317 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | ||
| 318 | ); | 272 | ); |
| 319 | 273 | ||
| 320 | checkpoint.releaseSession.mockClear(); | ||
| 321 | yield* Effect.gen(function* () { | 274 | yield* Effect.gen(function* () { |
| 322 | yield* ProviderService; | 275 | yield* ProviderService; |
| 323 | }).pipe(Effect.provide(providerLayer)); | 276 | }).pipe(Effect.provide(providerLayer)); |
| 324 | 277 | ||
| 325 | assert.deepEqual(checkpoint.releaseSession.mock.calls, []); | ||
| 326 | const persistedProvider = yield* Effect.gen(function* () { | 278 | const persistedProvider = yield* Effect.gen(function* () { |
| 327 | const directory = yield* ProviderSessionDirectory; | 279 | const directory = yield* ProviderSessionDirectory; |
| 328 | return yield* directory.getProvider("sess-stale"); | 280 | return yield* directory.getProvider(asSessionId("sess-stale")); |
| 329 | }).pipe(Effect.provide(directoryLayer)); | 281 | }).pipe(Effect.provide(directoryLayer)); |
| 330 | assert.equal(persistedProvider, "codex"); | 282 | assert.equal(persistedProvider, "codex"); |
| 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 | fs.rmSync(tempDir, { recursive: true, force: true }); | 300 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 333 | }).pipe(Effect.provide(NodeServices.layer)), | 301 | }).pipe(Effect.provide(NodeServices.layer)), |
| 334 | ); | 302 | ); |
| 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 | Effect.gen(function* () { | 305 | Effect.gen(function* () { |
| 338 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); | 306 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); |
| 339 | const dbPath = path.join(tempDir, "orchestration.sqlite"); | 307 | const dbPath = path.join(tempDir, "orchestration.sqlite"); |
| 340 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | 308 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 309 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | ||
| 310 | Layer.provide(persistenceLayer), | ||
| 311 | ); | ||
| 341 | 312 | ||
| 342 | const checkpoint = makeCheckpointServiceDouble(); | ||
| 343 | const firstCodex = makeFakeCodexAdapter(); | 313 | const firstCodex = makeFakeCodexAdapter(); |
| 344 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { | 314 | const firstRegistry: typeof ProviderAdapterRegistry.Service = { |
| 345 | getByProvider: (provider) => | 315 | getByProvider: (provider) => |
| ... | @@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi | ... | @@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 350 | }; | 320 | }; |
| 351 | 321 | ||
| 352 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 322 | const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 353 | Layer.provide(ProviderSessionRepositoryLive), | 323 | Layer.provide(runtimeRepositoryLayer), |
| 354 | Layer.provide(persistenceLayer), | ||
| 355 | ); | 324 | ); |
| 356 | const firstProviderLayer = ProviderServiceLive.pipe( | 325 | const firstProviderLayer = makeProviderServiceLive().pipe( |
| 357 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), | 326 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), |
| 358 | Layer.provide(firstDirectoryLayer), | 327 | Layer.provide(firstDirectoryLayer), |
| 359 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | ||
| 360 | ); | 328 | ); |
| 361 | 329 | ||
| 362 | const startedSession = yield* Effect.gen(function* () { | 330 | const startedSession = yield* Effect.gen(function* () { |
| ... | @@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi | ... | @@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 372 | yield* provider.stopAll(); | 340 | yield* provider.stopAll(); |
| 373 | }).pipe(Effect.provide(firstProviderLayer)); | 341 | }).pipe(Effect.provide(firstProviderLayer)); |
| 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 | const secondCodex = makeFakeCodexAdapter(); | 357 | const secondCodex = makeFakeCodexAdapter(); |
| 376 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { | 358 | const secondRegistry: typeof ProviderAdapterRegistry.Service = { |
| 377 | getByProvider: (provider) => | 359 | getByProvider: (provider) => |
| ... | @@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi | ... | @@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 381 | listProviders: () => Effect.succeed(["codex"]), | 363 | listProviders: () => Effect.succeed(["codex"]), |
| 382 | }; | 364 | }; |
| 383 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 365 | const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 384 | Layer.provide(ProviderSessionRepositoryLive), | 366 | Layer.provide(runtimeRepositoryLayer), |
| 385 | Layer.provide(persistenceLayer), | ||
| 386 | ); | 367 | ); |
| 387 | const secondProviderLayer = ProviderServiceLive.pipe( | 368 | const secondProviderLayer = makeProviderServiceLive().pipe( |
| 388 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), | 369 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), |
| 389 | Layer.provide(secondDirectoryLayer), | 370 | Layer.provide(secondDirectoryLayer), |
| 390 | Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), | ||
| 391 | ); | 371 | ); |
| 392 | 372 | ||
| 393 | checkpoint.getCheckpointDiff.mockClear(); | ||
| 394 | secondCodex.startSession.mockClear(); | 373 | secondCodex.startSession.mockClear(); |
| 374 | secondCodex.rollbackThread.mockClear(); | ||
| 395 | 375 | ||
| 396 | yield* Effect.gen(function* () { | 376 | yield* Effect.gen(function* () { |
| 397 | const provider = yield* ProviderService; | 377 | const provider = yield* ProviderService; |
| 398 | yield* provider.getCheckpointDiff({ | 378 | yield* provider.rollbackConversation({ |
| 399 | sessionId: startedSession.sessionId, | 379 | sessionId: startedSession.sessionId, |
| 400 | fromTurnCount: 0, | 380 | numTurns: 1, |
| 401 | toTurnCount: 0, | ||
| 402 | }); | 381 | }); |
| 403 | }).pipe(Effect.provide(secondProviderLayer)); | 382 | }).pipe(Effect.provide(secondProviderLayer)); |
| 404 | 383 | ||
| ... | @@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi | ... | @@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi |
| 410 | }, | 389 | }, |
| 411 | ], | 390 | ], |
| 412 | ]); | 391 | ]); |
| 413 | assert.equal(checkpoint.getCheckpointDiff.mock.calls.length, 1); | 392 | assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); |
| 414 | const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | 393 | const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; |
| 415 | assert.equal(typeof recoveredDiffInput?.sessionId, "string"); | 394 | assert.equal(typeof rollbackCall?.[0], "string"); |
| 416 | assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true); | 395 | assert.equal(rollbackCall?.[1], 1); |
| 417 | assert.equal(recoveredDiffInput?.fromTurnCount, 0); | ||
| 418 | assert.equal(recoveredDiffInput?.toTurnCount, 0); | ||
| 419 | 396 | ||
| 420 | fs.rmSync(tempDir, { recursive: true, force: true }); | 397 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 421 | }).pipe(Effect.provide(NodeServices.layer)), | 398 | }).pipe(Effect.provide(NodeServices.layer)), |
| 422 | ); | 399 | ); |
| 423 | 400 | ||
| 424 | routing.layer("ProviderServiceLive routing", (it) => { | 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 | Effect.gen(function* () { | 403 | Effect.gen(function* () { |
| 427 | const provider = yield* ProviderService; | 404 | const provider = yield* ProviderService; |
| 428 | 405 | ||
| ... | @@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 431 | cwd: "/tmp/project", | 408 | cwd: "/tmp/project", |
| 432 | }); | 409 | }); |
| 433 | assert.equal(session.provider, "codex"); | 410 | assert.equal(session.provider, "codex"); |
| 434 | assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [ | ||
| 435 | [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }], | ||
| 436 | ]); | ||
| 437 | 411 | ||
| 438 | const sessions = yield* provider.listSessions(); | 412 | const sessions = yield* provider.listSessions(); |
| 439 | assert.equal(sessions.length, 1); | 413 | assert.equal(sessions.length, 1); |
| ... | @@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 450 | 424 | ||
| 451 | yield* provider.respondToRequest({ | 425 | yield* provider.respondToRequest({ |
| 452 | sessionId: session.sessionId, | 426 | sessionId: session.sessionId, |
| 453 | requestId: "req-1", | 427 | requestId: asRequestId("req-1"), |
| 454 | decision: "accept", | 428 | decision: "accept", |
| 455 | }); | 429 | }); |
| 456 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ | 430 | assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ |
| 457 | [session.sessionId, "req-1", "accept"], | 431 | [session.sessionId, asRequestId("req-1"), "accept"], |
| 458 | ]); | 432 | ]); |
| 459 | 433 | ||
| 460 | yield* provider.listCheckpoints({ sessionId: session.sessionId }); | 434 | yield* provider.rollbackConversation({ |
| 461 | yield* provider.getCheckpointDiff({ | ||
| 462 | sessionId: session.sessionId, | 435 | sessionId: session.sessionId, |
| 463 | fromTurnCount: 0, | 436 | numTurns: 0, |
| 464 | toTurnCount: 0, | ||
| 465 | }); | 437 | }); |
| 466 | yield* provider.revertToCheckpoint({ | ||
| 467 | sessionId: session.sessionId, | ||
| 468 | turnCount: 0, | ||
| 469 | }); | ||
| 470 | |||
| 471 | assert.deepEqual(routing.checkpoint.listCheckpoints.mock.calls, [ | ||
| 472 | [{ sessionId: session.sessionId }], | ||
| 473 | ]); | ||
| 474 | assert.deepEqual(routing.checkpoint.getCheckpointDiff.mock.calls, [ | ||
| 475 | [{ sessionId: session.sessionId, fromTurnCount: 0, toTurnCount: 0 }], | ||
| 476 | ]); | ||
| 477 | assert.deepEqual(routing.checkpoint.revertToCheckpoint.mock.calls, [ | ||
| 478 | [{ sessionId: session.sessionId, turnCount: 0 }], | ||
| 479 | ]); | ||
| 480 | 438 | ||
| 481 | yield* provider.stopSession({ sessionId: session.sessionId }); | 439 | yield* provider.stopSession({ sessionId: session.sessionId }); |
| 482 | assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [ | ||
| 483 | [{ providerSessionId: session.sessionId }], | ||
| 484 | ]); | ||
| 485 | const sendAfterStop = yield* Effect.result( | 440 | const sendAfterStop = yield* Effect.result( |
| 486 | provider.sendTurn({ | 441 | provider.sendTurn({ |
| 487 | sessionId: session.sessionId, | 442 | sessionId: session.sessionId, |
| ... | @@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 496 | }), | 451 | }), |
| 497 | ); | 452 | ); |
| 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 | Effect.gen(function* () { | 455 | Effect.gen(function* () { |
| 501 | const provider = yield* ProviderService; | 456 | const provider = yield* ProviderService; |
| 502 | 457 | ||
| ... | @@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 505 | cwd: "/tmp/project", | 460 | cwd: "/tmp/project", |
| 506 | }); | 461 | }); |
| 507 | yield* routing.codex.stopSession(initial.sessionId); | 462 | yield* routing.codex.stopSession(initial.sessionId); |
| 508 | |||
| 509 | routing.checkpoint.getCheckpointDiff.mockClear(); | ||
| 510 | routing.checkpoint.revertToCheckpoint.mockClear(); | ||
| 511 | routing.codex.startSession.mockClear(); | 463 | routing.codex.startSession.mockClear(); |
| 464 | routing.codex.rollbackThread.mockClear(); | ||
| 512 | 465 | ||
| 513 | yield* provider.getCheckpointDiff({ | 466 | yield* provider.rollbackConversation({ |
| 514 | sessionId: initial.sessionId, | 467 | sessionId: initial.sessionId, |
| 515 | fromTurnCount: 0, | 468 | numTurns: 1, |
| 516 | toTurnCount: 0, | ||
| 517 | }); | ||
| 518 | yield* provider.revertToCheckpoint({ | ||
| 519 | sessionId: initial.sessionId, | ||
| 520 | turnCount: 0, | ||
| 521 | }); | 469 | }); |
| 522 | 470 | ||
| 523 | assert.deepEqual(routing.codex.startSession.mock.calls, [ | 471 | assert.deepEqual(routing.codex.startSession.mock.calls, [ |
| ... | @@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 528 | }, | 476 | }, |
| 529 | ], | 477 | ], |
| 530 | ]); | 478 | ]); |
| 531 | assert.equal(routing.checkpoint.getCheckpointDiff.mock.calls.length, 1); | 479 | assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); |
| 532 | assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1); | 480 | const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; |
| 533 | 481 | assert.equal(rollbackCall?.[1], 1); | |
| 534 | const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; | ||
| 535 | const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0]; | ||
| 536 | assert.equal(diffInput?.fromTurnCount, 0); | ||
| 537 | assert.equal(diffInput?.toTurnCount, 0); | ||
| 538 | assert.equal(diffInput?.sessionId === initial.sessionId, false); | ||
| 539 | assert.equal(revertInput?.sessionId, diffInput?.sessionId); | ||
| 540 | assert.equal(revertInput?.turnCount, 0); | ||
| 541 | }), | 482 | }), |
| 542 | ); | 483 | ); |
| 543 | 484 | ||
| 544 | it.effect("releases checkpoint session state when stopping all sessions", () => | 485 | it.effect("stops all sessions and clears adapter runtime", () => |
| 545 | Effect.gen(function* () { | 486 | Effect.gen(function* () { |
| 546 | const provider = yield* ProviderService; | 487 | const provider = yield* ProviderService; |
| 547 | 488 | ||
| 548 | const first = yield* provider.startSession({ | 489 | yield* provider.startSession({ |
| 549 | provider: "codex", | 490 | provider: "codex", |
| 550 | }); | 491 | }); |
| 551 | const second = yield* provider.startSession({ | 492 | yield* provider.startSession({ |
| 552 | provider: "codex", | 493 | provider: "codex", |
| 553 | }); | 494 | }); |
| 554 | 495 | ||
| 555 | yield* provider.stopAll(); | 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 | const remaining = yield* provider.listSessions(); | 498 | const remaining = yield* provider.listSessions(); |
| 563 | assert.equal(remaining.length, 0); | 499 | assert.equal(remaining.length, 0); |
| 564 | }), | 500 | }), |
| 565 | ); | 501 | ); |
| 502 | |||
| 503 | it.effect("persists runtime status transitions in provider_session_runtime", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | const provider = yield* ProviderService; | ||
| 506 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | ||
| 507 | |||
| 508 | const session = yield* provider.startSession({ | ||
| 509 | provider: "codex", | ||
| 510 | }); | ||
| 511 | yield* provider.sendTurn({ | ||
| 512 | sessionId: session.sessionId, | ||
| 513 | input: "hello", | ||
| 514 | attachments: [], | ||
| 515 | }); | ||
| 516 | |||
| 517 | const runningRuntime = yield* runtimeRepository.getBySessionId({ | ||
| 518 | providerSessionId: session.sessionId, | ||
| 519 | }); | ||
| 520 | assert.equal(Option.isSome(runningRuntime), true); | ||
| 521 | if (Option.isSome(runningRuntime)) { | ||
| 522 | assert.equal(runningRuntime.value.status, "running"); | ||
| 523 | assert.deepEqual(runningRuntime.value.resumeCursor, { | ||
| 524 | resumeThreadId: "thread-1", | ||
| 525 | }); | ||
| 526 | const payload = runningRuntime.value.runtimePayload; | ||
| 527 | assert.equal(payload !== null && typeof payload === "object", true); | ||
| 528 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | ||
| 529 | assert.equal(payload.cwd, process.cwd()); | ||
| 530 | assert.equal(payload.model, null); | ||
| 531 | assert.equal(payload.activeTurnId, "turn-1"); | ||
| 532 | assert.equal(payload.lastError, null); | ||
| 533 | assert.equal(payload.lastRuntimeEvent, "provider.sendTurn"); | ||
| 534 | } | ||
| 535 | } | ||
| 536 | |||
| 537 | yield* provider.stopAll(); | ||
| 538 | const stoppedRuntime = yield* runtimeRepository.getBySessionId({ | ||
| 539 | providerSessionId: session.sessionId, | ||
| 540 | }); | ||
| 541 | assert.equal(Option.isSome(stoppedRuntime), true); | ||
| 542 | if (Option.isSome(stoppedRuntime)) { | ||
| 543 | assert.equal(stoppedRuntime.value.status, "stopped"); | ||
| 544 | } | ||
| 545 | }), | ||
| 546 | ); | ||
| 566 | }); | 547 | }); |
| 567 | 548 | ||
| 568 | const fanout = makeProviderServiceLayer(); | 549 | const fanout = makeProviderServiceLayer(); |
| 569 | fanout.layer("ProviderServiceLive fanout", (it) => { | 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 | Effect.gen(function* () { | 552 | Effect.gen(function* () { |
| 572 | const provider = yield* ProviderService; | 553 | const provider = yield* ProviderService; |
| 573 | const session = yield* provider.startSession({ | 554 | const session = yield* provider.startSession({ |
| ... | @@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 577 | const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]); | 558 | const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]); |
| 578 | const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => | 559 | const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => |
| 579 | Ref.update(eventsRef, (current) => [...current, event]), | 560 | Ref.update(eventsRef, (current) => [...current, event]), |
| 580 | ).pipe( | 561 | ).pipe(Effect.forkChild); |
| 581 | Effect.forkChild, | 562 | yield* sleep(20); |
| 582 | ); | ||
| 583 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | ||
| 584 | 563 | ||
| 585 | const completedEvent: ProviderRuntimeEvent = { | 564 | const completedEvent: ProviderRuntimeEvent = { |
| 586 | type: "turn.completed", | 565 | type: "turn.completed", |
| 587 | eventId: "evt-1", | 566 | eventId: asEventId("evt-1"), |
| 588 | provider: "codex", | 567 | provider: "codex", |
| 589 | sessionId: session.sessionId, | 568 | sessionId: session.sessionId, |
| 590 | createdAt: new Date().toISOString(), | 569 | createdAt: new Date().toISOString(), |
| 591 | threadId: "thread-1", | 570 | threadId: asThreadId("thread-1"), |
| 592 | turnId: "turn-1", | 571 | turnId: asTurnId("turn-1"), |
| 593 | status: "completed", | 572 | status: "completed", |
| 594 | }; | 573 | }; |
| 595 | 574 | ||
| 596 | fanout.codex.emit(completedEvent); | 575 | fanout.codex.emit(completedEvent); |
| 597 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | 576 | yield* sleep(20); |
| 598 | 577 | ||
| 599 | const events = yield* Ref.get(eventsRef); | 578 | const events = yield* Ref.get(eventsRef); |
| 600 | yield* Fiber.interrupt(consumer); | 579 | yield* Fiber.interrupt(consumer); |
| 601 | 580 | ||
| 602 | assert.equal(events.length >= 2, true); | 581 | assert.equal(events.some((entry) => entry.type === "turn.completed"), true); |
| 603 | assert.equal(events[0]?.type, "turn.completed"); | ||
| 604 | assert.equal(events[1]?.type, "checkpoint.captured"); | ||
| 605 | assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ | ||
| 606 | [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], | ||
| 607 | ]); | ||
| 608 | }), | 582 | }), |
| 609 | ); | 583 | ); |
| 610 | 584 | ||
| ... | @@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 614 | const session = yield* provider.startSession({ | 588 | const session = yield* provider.startSession({ |
| 615 | provider: "codex", | 589 | provider: "codex", |
| 616 | }); | 590 | }); |
| 617 | fanout.checkpoint.captureCurrentTurn.mockClear(); | ||
| 618 | 591 | ||
| 619 | const receivedByHealthy: string[] = []; | 592 | const receivedByHealthy: string[] = []; |
| 593 | const expectedEventIds = new Set<string>([ | ||
| 594 | "evt-ordered-1", | ||
| 595 | "evt-ordered-2", | ||
| 596 | "evt-ordered-3", | ||
| 597 | ]); | ||
| 620 | const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( | 598 | const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( |
| 621 | Stream.runForEach((event) => | 599 | Stream.runForEach((event) => |
| 622 | Effect.sync(() => { | 600 | Effect.sync(() => { |
| ... | @@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 629 | Stream.runForEach(() => Effect.fail("listener crash")), | 607 | Stream.runForEach(() => Effect.fail("listener crash")), |
| 630 | Effect.forkChild, | 608 | Effect.forkChild, |
| 631 | ); | 609 | ); |
| 632 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 0))); | 610 | yield* sleep(20); |
| 633 | 611 | ||
| 634 | const events: ReadonlyArray<ProviderRuntimeEvent> = [ | 612 | const events: ReadonlyArray<ProviderRuntimeEvent> = [ |
| 635 | { | 613 | { |
| 636 | type: "tool.completed", | 614 | type: "tool.completed", |
| 637 | eventId: "evt-ordered-1", | 615 | eventId: asEventId("evt-ordered-1"), |
| 638 | provider: "codex", | 616 | provider: "codex", |
| 639 | sessionId: session.sessionId, | 617 | sessionId: session.sessionId, |
| 640 | createdAt: new Date().toISOString(), | 618 | createdAt: new Date().toISOString(), |
| 641 | threadId: "thread-1", | 619 | threadId: asThreadId("thread-1"), |
| 642 | turnId: "turn-1", | 620 | turnId: asTurnId("turn-1"), |
| 643 | toolKind: "command", | 621 | toolKind: "command", |
| 644 | title: "Command run", | 622 | title: "Command run", |
| 645 | detail: "echo one", | 623 | detail: "echo one", |
| 646 | }, | 624 | }, |
| 647 | { | 625 | { |
| 648 | type: "message.delta", | 626 | type: "message.delta", |
| 649 | eventId: "evt-ordered-2", | 627 | eventId: asEventId("evt-ordered-2"), |
| 650 | provider: "codex", | 628 | provider: "codex", |
| 651 | sessionId: session.sessionId, | 629 | sessionId: session.sessionId, |
| 652 | createdAt: new Date().toISOString(), | 630 | createdAt: new Date().toISOString(), |
| 653 | threadId: "thread-1", | 631 | threadId: asThreadId("thread-1"), |
| 654 | turnId: "turn-1", | 632 | turnId: asTurnId("turn-1"), |
| 655 | delta: "hello", | 633 | delta: "hello", |
| 656 | }, | 634 | }, |
| 657 | { | 635 | { |
| 658 | type: "turn.completed", | 636 | type: "turn.completed", |
| 659 | eventId: "evt-ordered-3", | 637 | eventId: asEventId("evt-ordered-3"), |
| 660 | provider: "codex", | 638 | provider: "codex", |
| 661 | sessionId: session.sessionId, | 639 | sessionId: session.sessionId, |
| 662 | createdAt: new Date().toISOString(), | 640 | createdAt: new Date().toISOString(), |
| 663 | threadId: "thread-1", | 641 | threadId: asThreadId("thread-1"), |
| 664 | turnId: "turn-1", | 642 | turnId: asTurnId("turn-1"), |
| 665 | status: "completed", | 643 | status: "completed", |
| 666 | }, | 644 | }, |
| 667 | ]; | 645 | ]; |
| ... | @@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 673 | assert.equal(failingResult._tag, "Failure"); | 651 | assert.equal(failingResult._tag, "Failure"); |
| 674 | yield* Fiber.join(healthyFiber); | 652 | yield* Fiber.join(healthyFiber); |
| 675 | 653 | ||
| 676 | assert.deepEqual(receivedByHealthy.slice(0, 3), [ | 654 | assert.deepEqual( |
| 677 | "evt-ordered-1", | 655 | receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), |
| 678 | "evt-ordered-2", | 656 | [ |
| 679 | "evt-ordered-3", | 657 | "evt-ordered-1", |
| 680 | ]); | 658 | "evt-ordered-2", |
| 681 | assert.equal(receivedByHealthy.length, 3); | 659 | "evt-ordered-3", |
| 660 | ], | ||
| 661 | ); | ||
| 682 | }), | 662 | }), |
| 683 | ); | 663 | ); |
| 684 | }); | 664 | }); |
| ... | @@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 689 | Effect.gen(function* () { | 669 | Effect.gen(function* () { |
| 690 | const provider = yield* ProviderService; | 670 | const provider = yield* ProviderService; |
| 691 | 671 | ||
| 692 | const parse = providerSessionStartInputSchema.safeParse({ | 672 | let parseCause: unknown; |
| 693 | provider: "invalid-provider", | 673 | let decodeFailed = false; |
| 694 | }); | 674 | try { |
| 695 | assert.equal(parse.success, false); | 675 | Schema.decodeUnknownSync(ProviderSessionStartInput)({ |
| 696 | if (parse.success) { | 676 | provider: "invalid-provider", |
| 697 | return; | 677 | }); |
| 678 | } catch (cause) { | ||
| 679 | parseCause = cause; | ||
| 680 | decodeFailed = true; | ||
| 698 | } | 681 | } |
| 682 | assert.equal(decodeFailed, true); | ||
| 699 | 683 | ||
| 700 | const failure = yield* Effect.result( | 684 | const failure = yield* Effect.result( |
| 701 | provider.startSession({ | 685 | provider.startSession({ |
| ... | @@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 703 | } as never), | 687 | } as never), |
| 704 | ); | 688 | ); |
| 705 | 689 | ||
| 706 | const cause = (parse.error as { cause?: unknown }).cause; | ||
| 707 | assertFailure( | 690 | assertFailure( |
| 708 | failure, | 691 | failure, |
| 709 | new ProviderValidationError({ | 692 | new ProviderValidationError({ |
| 710 | operation: "ProviderService.startSession", | 693 | operation: "ProviderService.startSession", |
| 711 | issue: parse.error.message, | 694 | issue: decodeIssueMessage(parseCause), |
| 712 | ...(cause !== undefined ? { cause } : {}), | 695 | cause: parseCause, |
| 713 | }), | 696 | }), |
| 714 | ); | 697 | ); |
| 715 | }), | 698 | }), |
| ... | @@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 723 | Effect.sync(() => { | 706 | Effect.sync(() => { |
| 724 | const now = new Date().toISOString(); | 707 | const now = new Date().toISOString(); |
| 725 | return { | 708 | return { |
| 726 | sessionId: "sess-missing-thread", | 709 | sessionId: asSessionId("sess-missing-thread"), |
| 727 | provider: "codex", | 710 | provider: "codex", |
| 728 | status: "ready", | 711 | status: "ready", |
| 729 | cwd: input.cwd ?? process.cwd(), | 712 | cwd: input.cwd ?? process.cwd(), |
| ... | @@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => { | ... | @@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 743 | assertFailure( | 726 | assertFailure( |
| 744 | failure, | 727 | failure, |
| 745 | new ProviderValidationError({ | 728 | new ProviderValidationError({ |
| 746 | operation: "ProviderService.startSession", | 729 | operation: "ProviderService.startSession", |
| 747 | issue: | 730 | issue: "Provider 'codex' returned a session without threadId.", |
| 748 | "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.", | 731 | }), |
| 749 | }), | ||
| 750 | ); | 732 | ); |
| 751 | }), | 733 | }), |
| 752 | ); | 734 | ); |
apps/server/src/provider/Layers/ProviderService.ts+190-237| ... | @@ -5,29 +5,25 @@ | ... | @@ -5,29 +5,25 @@ |
| 5 | * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a | 5 | * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a |
| 6 | * unified provider event stream for subscribers. | 6 | * unified provider event stream for subscribers. |
| 7 | * | 7 | * |
| 8 | * It does not implement provider protocol details (adapter concern) and does | 8 | * It does not implement provider protocol details (adapter concern). |
| 9 | * not implement checkpoint persistence mechanics (checkpointing concern). | ||
| 10 | * | 9 | * |
| 11 | * @module ProviderServiceLive | 10 | * @module ProviderServiceLive |
| 12 | */ | 11 | */ |
| 13 | import { randomUUID } from "node:crypto"; | ||
| 14 | |||
| 15 | import { | 12 | import { |
| 16 | providerGetCheckpointDiffInputSchema, | 13 | NonNegativeInt, |
| 17 | providerInterruptTurnInputSchema, | 14 | ProviderSessionId, |
| 18 | providerListCheckpointsInputSchema, | 15 | ProviderThreadId, |
| 19 | providerRespondToRequestInputSchema, | 16 | ThreadId, |
| 20 | providerRevertToCheckpointInputSchema, | 17 | ProviderInterruptTurnInput, |
| 21 | providerSendTurnInputSchema, | 18 | ProviderRespondToRequestInput, |
| 22 | providerSessionStartInputSchema, | 19 | ProviderSendTurnInput, |
| 23 | providerStopSessionInputSchema, | 20 | ProviderSessionStartInput, |
| 21 | ProviderStopSessionInput, | ||
| 24 | type ProviderRuntimeEvent, | 22 | type ProviderRuntimeEvent, |
| 25 | type ProviderRuntimeTurnCompletedEvent, | ||
| 26 | type ProviderSession, | 23 | type ProviderSession, |
| 27 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 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 | import { ProviderValidationError } from "../Errors.ts"; | 27 | import { ProviderValidationError } from "../Errors.ts"; |
| 32 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 28 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 33 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; | 29 | import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; |
| ... | @@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions { | ... | @@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions { |
| 38 | readonly canonicalEventLogPath?: string; | 34 | readonly canonicalEventLogPath?: string; |
| 39 | } | 35 | } |
| 40 | 36 | ||
| 41 | function toCheckpointCaptureErrorEvent( | 37 | const ProviderRollbackConversationInput = Schema.Struct({ |
| 42 | event: ProviderRuntimeTurnCompletedEvent, | 38 | sessionId: ProviderSessionId, |
| 43 | error: { readonly message: string }, | 39 | numTurns: NonNegativeInt, |
| 44 | ): ProviderRuntimeEvent { | 40 | }); |
| 45 | return { | ||
| 46 | type: "runtime.error", | ||
| 47 | eventId: randomUUID(), | ||
| 48 | provider: event.provider, | ||
| 49 | sessionId: event.sessionId, | ||
| 50 | createdAt: new Date().toISOString(), | ||
| 51 | ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), | ||
| 52 | ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), | ||
| 53 | message: error.message, | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 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 | } | ||
| 75 | 41 | ||
| 76 | function toValidationError( | 42 | function toValidationError( |
| 77 | operation: string, | 43 | operation: string, |
| ... | @@ -85,6 +51,63 @@ function toValidationError( | ... | @@ -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 | const makeProviderService = (options?: ProviderServiceLiveOptions) => | 111 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 89 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 90 | const canonicalEventLogger = | 113 | const canonicalEventLogger = |
| ... | @@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 94 | 117 | ||
| 95 | const registry = yield* ProviderAdapterRegistry; | 118 | const registry = yield* ProviderAdapterRegistry; |
| 96 | const directory = yield* ProviderSessionDirectory; | 119 | const directory = yield* ProviderSessionDirectory; |
| 97 | const checkpointService = yield* CheckpointService; | ||
| 98 | |||
| 99 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 120 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 100 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 121 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 101 | const routedSessionAliasesRef = yield* Ref.make<Map<string, string>>(new Map()); | 122 | const routedSessionAliasesRef = yield* Ref.make<Map<ProviderSessionId, ProviderSessionId>>( |
| 123 | new Map(), | ||
| 124 | ); | ||
| 102 | 125 | ||
| 103 | const canonicalizeRuntimeEventSession = ( | 126 | const canonicalizeRuntimeEventSession = ( |
| 104 | event: ProviderRuntimeEvent, | 127 | event: ProviderRuntimeEvent, |
| ... | @@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 109 | if (liveSessionId === event.sessionId) { | 132 | if (liveSessionId === event.sessionId) { |
| 110 | return { | 133 | return { |
| 111 | ...event, | 134 | ...event, |
| 112 | sessionId: staleSessionId, | 135 | sessionId: ProviderSessionId.makeUnsafe(staleSessionId), |
| 113 | } satisfies ProviderRuntimeEvent; | 136 | } satisfies ProviderRuntimeEvent; |
| 114 | } | 137 | } |
| 115 | } | 138 | } |
| ... | @@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 131 | Effect.asVoid, | 154 | Effect.asVoid, |
| 132 | ); | 155 | ); |
| 133 | 156 | ||
| 134 | const upsertSessionBinding = ( | 157 | const upsertSessionBinding = (session: ProviderSession, operation: string) => |
| 135 | session: ProviderSession, | ||
| 136 | operation: string, | ||
| 137 | ) => | ||
| 138 | Effect.gen(function* () { | 158 | Effect.gen(function* () { |
| 139 | const threadId = session.threadId?.trim(); | 159 | const providerThreadId = session.threadId; |
| 140 | if (!threadId) { | 160 | if (!providerThreadId) { |
| 141 | return yield* toValidationError( | 161 | return yield* toValidationError( |
| 142 | operation, | 162 | operation, |
| 143 | `Provider '${session.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, | 163 | `Provider '${session.provider}' returned a session without threadId.`, |
| 144 | ); | 164 | ); |
| 145 | } | 165 | } |
| 146 | 166 | ||
| 167 | const providerSessionId = asProviderSessionId(session.sessionId); | ||
| 168 | const brandedThreadId = ThreadId.makeUnsafe(providerThreadId); | ||
| 147 | yield* directory.upsert({ | 169 | yield* directory.upsert({ |
| 148 | sessionId: session.sessionId, | 170 | sessionId: providerSessionId, |
| 149 | provider: session.provider, | 171 | provider: session.provider, |
| 150 | threadId, | 172 | threadId: brandedThreadId, |
| 173 | providerThreadId, | ||
| 174 | status: toRuntimeStatus(session), | ||
| 175 | resumeCursor: { resumeThreadId: providerThreadId }, | ||
| 176 | runtimePayload: toRuntimePayloadFromSession(session), | ||
| 151 | }); | 177 | }); |
| 152 | 178 | ||
| 153 | return threadId; | 179 | return brandedThreadId; |
| 154 | }); | 180 | }); |
| 155 | 181 | ||
| 156 | const clearAliasKey = (staleSessionId: string) => | 182 | const clearAliasKey = (staleSessionId: ProviderSessionId) => |
| 157 | Ref.update(routedSessionAliasesRef, (current) => { | 183 | Ref.update(routedSessionAliasesRef, (current) => { |
| 158 | if (!current.has(staleSessionId)) { | 184 | if (!current.has(staleSessionId)) { |
| 159 | return current; | 185 | return current; |
| ... | @@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 163 | return next; | 189 | return next; |
| 164 | }); | 190 | }); |
| 165 | 191 | ||
| 166 | const clearAliasesReferencing = (sessionId: string) => | 192 | const clearAliasesReferencing = (sessionId: ProviderSessionId) => |
| 167 | Ref.update(routedSessionAliasesRef, (current) => { | 193 | Ref.update(routedSessionAliasesRef, (current) => { |
| 168 | let changed = false; | 194 | let changed = false; |
| 169 | const next = new Map<string, string>(); | 195 | const next = new Map<ProviderSessionId, ProviderSessionId>(); |
| 170 | for (const [key, value] of current) { | 196 | for (const [key, value] of current) { |
| 171 | if (key === sessionId || value === sessionId) { | 197 | if (key === sessionId || value === sessionId) { |
| 172 | changed = true; | 198 | changed = true; |
| ... | @@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 177 | return changed ? next : current; | 203 | return changed ? next : current; |
| 178 | }); | 204 | }); |
| 179 | 205 | ||
| 180 | const setAlias = (staleSessionId: string, liveSessionId: string) => | 206 | const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => |
| 181 | Ref.update(routedSessionAliasesRef, (current) => { | 207 | Ref.update(routedSessionAliasesRef, (current) => { |
| 182 | const existing = current.get(staleSessionId); | 208 | const existing = current.get(staleSessionId); |
| 183 | if (existing === liveSessionId) { | 209 | if (existing === liveSessionId) { |
| ... | @@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 193 | registry.getByProvider(provider), | 219 | registry.getByProvider(provider), |
| 194 | ); | 220 | ); |
| 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 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 222 | const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 225 | publishRuntimeEvent(event).pipe( | 223 | publishRuntimeEvent(event); |
| 226 | Effect.flatMap(() => { | ||
| 227 | if (event.type !== "turn.completed") { | ||
| 228 | return Effect.void; | ||
| 229 | } | ||
| 230 | return onTurnCompleted(event); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | 224 | ||
| 234 | const worker = Effect.forever( | 225 | const worker = Effect.forever( |
| 235 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), | 226 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), |
| ... | @@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 243 | ).pipe(Effect.asVoid); | 234 | ).pipe(Effect.asVoid); |
| 244 | 235 | ||
| 245 | const recoverSessionForThread = (input: { | 236 | const recoverSessionForThread = (input: { |
| 246 | readonly staleSessionId: string; | 237 | readonly staleSessionId: ProviderSessionId; |
| 247 | readonly provider: ProviderSession["provider"]; | 238 | readonly provider: ProviderSession["provider"]; |
| 248 | readonly threadId: string; | 239 | readonly threadId: ThreadId; |
| 249 | readonly operation: string; | 240 | readonly operation: string; |
| 250 | }) => | 241 | }) => |
| 251 | Effect.gen(function* () { | 242 | Effect.gen(function* () { |
| 252 | const adapter = yield* registry.getByProvider(input.provider); | 243 | const adapter = yield* registry.getByProvider(input.provider); |
| 253 | const activeSessions = yield* adapter.listSessions(); | 244 | const activeSessions = yield* adapter.listSessions(); |
| 254 | const existing = activeSessions.find( | 245 | const existing = activeSessions.find( |
| 255 | (session) => session.threadId?.trim() === input.threadId, | 246 | (session) => session.threadId?.trim() === input.threadId.trim(), |
| 256 | ); | 247 | ); |
| 257 | if (existing) { | 248 | if (existing) { |
| 258 | const existingThreadId = yield* upsertSessionBinding( | 249 | const existingThreadId = yield* upsertSessionBinding( |
| ... | @@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 277 | 268 | ||
| 278 | const resumed = yield* adapter.startSession({ | 269 | const resumed = yield* adapter.startSession({ |
| 279 | provider: input.provider, | 270 | provider: input.provider, |
| 280 | resumeThreadId: input.threadId, | 271 | resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId), |
| 281 | }); | 272 | }); |
| 282 | if (resumed.provider !== adapter.provider) { | 273 | if (resumed.provider !== adapter.provider) { |
| 283 | return yield* toValidationError( | 274 | return yield* toValidationError( |
| ... | @@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 303 | threadId: resumedThreadId, | 294 | threadId: resumedThreadId, |
| 304 | }); | 295 | }); |
| 305 | 296 | ||
| 306 | const checkpointCwd = resumed.cwd ?? process.cwd(); | ||
| 307 | yield* checkpointService.initializeForSession({ | ||
| 308 | providerSessionId: resumed.sessionId, | ||
| 309 | cwd: checkpointCwd, | ||
| 310 | }); | ||
| 311 | |||
| 312 | if (resumed.sessionId !== input.staleSessionId) { | 297 | if (resumed.sessionId !== input.staleSessionId) { |
| 313 | yield* setAlias(input.staleSessionId, resumed.sessionId); | 298 | yield* setAlias(input.staleSessionId, resumed.sessionId); |
| 314 | } else { | 299 | } else { |
| ... | @@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 322 | }); | 307 | }); |
| 323 | 308 | ||
| 324 | const resolveRoutableSession = (input: { | 309 | const resolveRoutableSession = (input: { |
| 325 | readonly sessionId: string; | 310 | readonly sessionId: ProviderSessionId; |
| 326 | readonly operation: string; | 311 | readonly operation: string; |
| 327 | readonly allowRecovery: boolean; | 312 | readonly allowRecovery: boolean; |
| 328 | }) => | 313 | }) => |
| ... | @@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 364 | } | 349 | } |
| 365 | 350 | ||
| 366 | const threadIdOption = yield* directory.getThreadId(input.sessionId); | 351 | const threadIdOption = yield* directory.getThreadId(input.sessionId); |
| 367 | const threadId = Option.getOrUndefined(threadIdOption)?.trim(); | 352 | const threadId = Option.getOrUndefined(threadIdOption); |
| 368 | if (!threadId) { | 353 | if (!threadId) { |
| 369 | return yield* toValidationError( | 354 | return yield* toValidationError( |
| 370 | input.operation, | 355 | input.operation, |
| ... | @@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 388 | 373 | ||
| 389 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => | 374 | const startSession: ProviderServiceShape["startSession"] = (rawInput) => |
| 390 | Effect.gen(function* () { | 375 | Effect.gen(function* () { |
| 391 | const parsed = providerSessionStartInputSchema.safeParse(rawInput); | 376 | const parsed = yield* decodeInputOrValidationError({ |
| 392 | if (!parsed.success) { | 377 | operation: "ProviderService.startSession", |
| 393 | return yield* toValidationError( | 378 | schema: ProviderSessionStartInput, |
| 394 | "ProviderService.startSession", | 379 | payload: rawInput, |
| 395 | parsed.error.message, | 380 | }); |
| 396 | parsed.error.cause, | ||
| 397 | ); | ||
| 398 | } | ||
| 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 | const adapter = yield* registry.getByProvider(input.provider); | 388 | const adapter = yield* registry.getByProvider(input.provider); |
| 402 | const session = yield* adapter.startSession(input); | 389 | const session = yield* adapter.startSession(input); |
| 403 | 390 | ||
| ... | @@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 410 | 397 | ||
| 411 | yield* upsertSessionBinding(session, "ProviderService.startSession"); | 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 | return session; | 400 | return session; |
| 420 | }); | 401 | }); |
| 421 | 402 | ||
| 422 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => | 403 | const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => |
| 423 | Effect.gen(function* () { | 404 | Effect.gen(function* () { |
| 424 | const parsed = providerSendTurnInputSchema.safeParse(rawInput); | 405 | const parsed = yield* decodeInputOrValidationError({ |
| 425 | if (!parsed.success) { | 406 | operation: "ProviderService.sendTurn", |
| 407 | schema: ProviderSendTurnInput, | ||
| 408 | payload: rawInput, | ||
| 409 | }); | ||
| 410 | |||
| 411 | const input = { | ||
| 412 | ...parsed, | ||
| 413 | attachments: parsed.attachments ?? [], | ||
| 414 | }; | ||
| 415 | if (!input.input && input.attachments.length === 0) { | ||
| 426 | return yield* toValidationError( | 416 | return yield* toValidationError( |
| 427 | "ProviderService.sendTurn", | 417 | "ProviderService.sendTurn", |
| 428 | parsed.error.message, | 418 | "Either input text or at least one attachment is required", |
| 429 | parsed.error.cause, | ||
| 430 | ); | 419 | ); |
| 431 | } | 420 | } |
| 432 | |||
| 433 | const input = parsed.data; | ||
| 434 | const routed = yield* resolveRoutableSession({ | 421 | const routed = yield* resolveRoutableSession({ |
| 435 | sessionId: input.sessionId, | 422 | sessionId: input.sessionId, |
| 436 | operation: "ProviderService.sendTurn", | 423 | operation: "ProviderService.sendTurn", |
| 437 | allowRecovery: true, | 424 | allowRecovery: true, |
| 438 | }); | 425 | }); |
| 439 | return yield* routed.adapter.sendTurn({ | 426 | const turn = yield* routed.adapter.sendTurn({ |
| 440 | ...input, | 427 | ...input, |
| 441 | sessionId: routed.sessionId, | 428 | sessionId: routed.sessionId, |
| 442 | }); | 429 | }); |
| 443 | }); | 430 | const threadId = yield* directory |
| 444 | 431 | .getThreadId(asProviderSessionId(input.sessionId)) | |
| 445 | const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => | 432 | .pipe(Effect.map(Option.getOrUndefined)); |
| 446 | Effect.gen(function* () { | 433 | if (!threadId) { |
| 447 | const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); | ||
| 448 | if (!parsed.success) { | ||
| 449 | return yield* toValidationError( | 434 | return yield* toValidationError( |
| 450 | "ProviderService.interruptTurn", | 435 | "ProviderService.sendTurn", |
| 451 | parsed.error.message, | 436 | `No thread id is tracked for provider session '${input.sessionId}'.`, |
| 452 | parsed.error.cause, | ||
| 453 | ); | 437 | ); |
| 454 | } | 438 | } |
| 439 | yield* directory.upsert({ | ||
| 440 | sessionId: asProviderSessionId(input.sessionId), | ||
| 441 | provider: routed.adapter.provider, | ||
| 442 | threadId, | ||
| 443 | providerThreadId: asProviderThreadId(turn.threadId), | ||
| 444 | status: "running", | ||
| 445 | resumeCursor: { resumeThreadId: turn.threadId }, | ||
| 446 | runtimePayload: { | ||
| 447 | activeTurnId: turn.turnId, | ||
| 448 | lastRuntimeEvent: "provider.sendTurn", | ||
| 449 | lastRuntimeEventAt: new Date().toISOString(), | ||
| 450 | }, | ||
| 451 | }); | ||
| 452 | return turn; | ||
| 453 | }); | ||
| 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 | const routed = yield* resolveRoutableSession({ | 462 | const routed = yield* resolveRoutableSession({ |
| 458 | sessionId: input.sessionId, | 463 | sessionId: input.sessionId, |
| 459 | operation: "ProviderService.interruptTurn", | 464 | operation: "ProviderService.interruptTurn", |
| ... | @@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 464 | 469 | ||
| 465 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => | 470 | const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => |
| 466 | Effect.gen(function* () { | 471 | Effect.gen(function* () { |
| 467 | const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); | 472 | const input = yield* decodeInputOrValidationError({ |
| 468 | if (!parsed.success) { | 473 | operation: "ProviderService.respondToRequest", |
| 469 | return yield* toValidationError( | 474 | schema: ProviderRespondToRequestInput, |
| 470 | "ProviderService.respondToRequest", | 475 | payload: rawInput, |
| 471 | parsed.error.message, | 476 | }); |
| 472 | parsed.error.cause, | ||
| 473 | ); | ||
| 474 | } | ||
| 475 | |||
| 476 | const input = parsed.data; | ||
| 477 | const routed = yield* resolveRoutableSession({ | 477 | const routed = yield* resolveRoutableSession({ |
| 478 | sessionId: input.sessionId, | 478 | sessionId: input.sessionId, |
| 479 | operation: "ProviderService.respondToRequest", | 479 | operation: "ProviderService.respondToRequest", |
| ... | @@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 484 | 484 | ||
| 485 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => | 485 | const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => |
| 486 | Effect.gen(function* () { | 486 | Effect.gen(function* () { |
| 487 | const parsed = providerStopSessionInputSchema.safeParse(rawInput); | 487 | const input = yield* decodeInputOrValidationError({ |
| 488 | if (!parsed.success) { | 488 | operation: "ProviderService.stopSession", |
| 489 | return yield* toValidationError( | 489 | schema: ProviderStopSessionInput, |
| 490 | "ProviderService.stopSession", | 490 | payload: rawInput, |
| 491 | parsed.error.message, | 491 | }); |
| 492 | parsed.error.cause, | ||
| 493 | ); | ||
| 494 | } | ||
| 495 | |||
| 496 | const input = parsed.data; | ||
| 497 | const routed = yield* resolveRoutableSession({ | 492 | const routed = yield* resolveRoutableSession({ |
| 498 | sessionId: input.sessionId, | 493 | sessionId: input.sessionId, |
| 499 | operation: "ProviderService.stopSession", | 494 | operation: "ProviderService.stopSession", |
| ... | @@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 503 | yield* routed.adapter.stopSession(routed.sessionId); | 498 | yield* routed.adapter.stopSession(routed.sessionId); |
| 504 | } | 499 | } |
| 505 | if (routed.sessionId !== input.sessionId) { | 500 | if (routed.sessionId !== input.sessionId) { |
| 506 | yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId }); | 501 | yield* directory.remove(asProviderSessionId(routed.sessionId)); |
| 507 | yield* directory.remove(routed.sessionId); | ||
| 508 | yield* clearAliasesReferencing(routed.sessionId); | 502 | yield* clearAliasesReferencing(routed.sessionId); |
| 509 | } | 503 | } |
| 510 | yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); | 504 | yield* directory.remove(asProviderSessionId(input.sessionId)); |
| 511 | yield* directory.remove(input.sessionId); | ||
| 512 | yield* clearAliasesReferencing(input.sessionId); | 505 | yield* clearAliasesReferencing(input.sessionId); |
| 513 | }); | 506 | }); |
| 514 | 507 | ||
| ... | @@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 517 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), | 510 | Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), |
| 518 | ); | 511 | ); |
| 519 | 512 | ||
| 520 | const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => | 513 | const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => |
| 521 | Effect.gen(function* () { | ||
| 522 | const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); | ||
| 523 | if (!parsed.success) { | ||
| 524 | return yield* toValidationError( | ||
| 525 | "ProviderService.listCheckpoints", | ||
| 526 | parsed.error.message, | ||
| 527 | parsed.error.cause, | ||
| 528 | ); | ||
| 529 | } | ||
| 530 | |||
| 531 | const input = parsed.data; | ||
| 532 | const routed = yield* resolveRoutableSession({ | ||
| 533 | sessionId: input.sessionId, | ||
| 534 | operation: "ProviderService.listCheckpoints", | ||
| 535 | allowRecovery: true, | ||
| 536 | }); | ||
| 537 | return yield* checkpointService.listCheckpoints({ | ||
| 538 | ...input, | ||
| 539 | sessionId: routed.sessionId, | ||
| 540 | }); | ||
| 541 | }); | ||
| 542 | |||
| 543 | const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => | ||
| 544 | Effect.gen(function* () { | 514 | Effect.gen(function* () { |
| 545 | const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); | 515 | const input = yield* decodeInputOrValidationError({ |
| 546 | if (!parsed.success) { | 516 | operation: "ProviderService.rollbackConversation", |
| 547 | return yield* toValidationError( | 517 | schema: ProviderRollbackConversationInput, |
| 548 | "ProviderService.getCheckpointDiff", | 518 | payload: rawInput, |
| 549 | parsed.error.message, | ||
| 550 | parsed.error.cause, | ||
| 551 | ); | ||
| 552 | } | ||
| 553 | |||
| 554 | const input = parsed.data; | ||
| 555 | const routed = yield* resolveRoutableSession({ | ||
| 556 | sessionId: input.sessionId, | ||
| 557 | operation: "ProviderService.getCheckpointDiff", | ||
| 558 | allowRecovery: true, | ||
| 559 | }); | ||
| 560 | return yield* checkpointService.getCheckpointDiff({ | ||
| 561 | ...input, | ||
| 562 | sessionId: routed.sessionId, | ||
| 563 | }); | 519 | }); |
| 564 | }); | 520 | if (input.numTurns === 0) { |
| 565 | 521 | return; | |
| 566 | const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => | ||
| 567 | Effect.gen(function* () { | ||
| 568 | const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); | ||
| 569 | if (!parsed.success) { | ||
| 570 | return yield* toValidationError( | ||
| 571 | "ProviderService.revertToCheckpoint", | ||
| 572 | parsed.error.message, | ||
| 573 | parsed.error.cause, | ||
| 574 | ); | ||
| 575 | } | 522 | } |
| 576 | |||
| 577 | const input = parsed.data; | ||
| 578 | const routed = yield* resolveRoutableSession({ | 523 | const routed = yield* resolveRoutableSession({ |
| 579 | sessionId: input.sessionId, | 524 | sessionId: input.sessionId, |
| 580 | operation: "ProviderService.revertToCheckpoint", | 525 | operation: "ProviderService.rollbackConversation", |
| 581 | allowRecovery: true, | 526 | allowRecovery: true, |
| 582 | }); | 527 | }); |
| 583 | return yield* checkpointService.revertToCheckpoint({ | 528 | yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); |
| 584 | ...input, | ||
| 585 | sessionId: routed.sessionId, | ||
| 586 | }); | ||
| 587 | }); | 529 | }); |
| 588 | 530 | ||
| 589 | const stopAll: ProviderServiceShape["stopAll"] = () => | 531 | const stopAll: ProviderServiceShape["stopAll"] = () => |
| 590 | Effect.gen(function* () { | 532 | Effect.gen(function* () { |
| 591 | const sessionIds = yield* directory.listSessionIds(); | 533 | const sessionIds = yield* directory.listSessionIds(); |
| 592 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); | 534 | yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); |
| 593 | // Keep persisted session bindings so stale sessions can be resumed after | ||
| 594 | // process restart via resumeThreadId. | ||
| 595 | yield* Effect.forEach(sessionIds, (sessionId) => | 535 | yield* Effect.forEach(sessionIds, (sessionId) => |
| 596 | checkpointService.releaseSession({ providerSessionId: sessionId }), | 536 | directory.getProvider(sessionId).pipe( |
| 537 | Effect.flatMap((provider) => | ||
| 538 | directory.upsert({ | ||
| 539 | sessionId, | ||
| 540 | provider, | ||
| 541 | status: "stopped", | ||
| 542 | runtimePayload: { | ||
| 543 | activeTurnId: null, | ||
| 544 | lastRuntimeEvent: "provider.stopAll", | ||
| 545 | lastRuntimeEventAt: new Date().toISOString(), | ||
| 546 | }, | ||
| 547 | }), | ||
| 548 | ), | ||
| 549 | ), | ||
| 597 | ).pipe(Effect.asVoid); | 550 | ).pipe(Effect.asVoid); |
| 551 | // Keep persisted session bindings so stale sessions can be resumed after | ||
| 552 | // process restart via resumeThreadId. | ||
| 598 | yield* Ref.set(routedSessionAliasesRef, new Map()); | 553 | yield* Ref.set(routedSessionAliasesRef, new Map()); |
| 599 | }); | 554 | }); |
| 600 | 555 | ||
| ... | @@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 605 | respondToRequest, | 560 | respondToRequest, |
| 606 | stopSession, | 561 | stopSession, |
| 607 | listSessions, | 562 | listSessions, |
| 608 | listCheckpoints, | 563 | rollbackConversation, |
| 609 | getCheckpointDiff, | ||
| 610 | revertToCheckpoint, | ||
| 611 | stopAll, | 564 | stopAll, |
| 612 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 565 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 613 | } satisfies ProviderServiceShape; | 566 | } satisfies ProviderServiceShape; |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+103-22| ... | @@ -3,22 +3,35 @@ import os from "node:os"; | ... | @@ -3,22 +3,35 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { NodeServices } from "@effect/platform-node"; | 5 | import { NodeServices } from "@effect/platform-node"; |
| 6 | import { | ||
| 7 | ProviderSessionId, | ||
| 8 | ProviderThreadId, | ||
| 9 | ThreadId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 6 | import { it, assert } from "@effect/vitest"; | 11 | import { it, assert } from "@effect/vitest"; |
| 7 | import { assertFailure, assertSome } from "@effect/vitest/utils"; | 12 | import { assertFailure, assertSome } from "@effect/vitest/utils"; |
| 8 | import { Effect, Layer } from "effect"; | 13 | import { Effect, Layer, Option } from "effect"; |
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 14 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 10 | 15 | ||
| 11 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 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 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; | 19 | import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; |
| 14 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 20 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 15 | import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; | 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 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { | 27 | function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlClient, E, R>) { |
| 18 | return ProviderSessionDirectoryLive.pipe( | 28 | const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( |
| 19 | Layer.provide(ProviderSessionRepositoryLive), | ||
| 20 | Layer.provide(persistenceLayer), | 29 | Layer.provide(persistenceLayer), |
| 21 | ); | 30 | ); |
| 31 | return Layer.mergeAll( | ||
| 32 | runtimeRepositoryLayer, | ||
| 33 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | ||
| 34 | ); | ||
| 22 | } | 35 | } |
| 23 | 36 | ||
| 24 | const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); | 37 | const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); |
| ... | @@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => { | ... | @@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 27 | it("upserts, reads, and removes session bindings", () => | 40 | it("upserts, reads, and removes session bindings", () => |
| 28 | Effect.gen(function* () { | 41 | Effect.gen(function* () { |
| 29 | const directory = yield* ProviderSessionDirectory; | 42 | const directory = yield* ProviderSessionDirectory; |
| 43 | const runtimeRepository = yield* ProviderSessionRuntimeRepository; | ||
| 30 | 44 | ||
| 31 | yield* directory.upsert({ | 45 | yield* directory.upsert({ |
| 32 | sessionId: "sess-1", | 46 | sessionId: sessionId("sess-1"), |
| 33 | provider: "codex", | 47 | provider: "codex", |
| 34 | threadId: "thread-1", | 48 | threadId: threadId("thread-1"), |
| 35 | }); | 49 | }); |
| 36 | 50 | ||
| 37 | const provider = yield* directory.getProvider("sess-1"); | 51 | const provider = yield* directory.getProvider(sessionId("sess-1")); |
| 38 | assert.equal(provider, "codex"); | 52 | assert.equal(provider, "codex"); |
| 39 | const threadId = yield* directory.getThreadId("sess-1"); | 53 | const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1")); |
| 40 | assertSome(threadId, "thread-1"); | 54 | assertSome(resolvedThreadId, threadId("thread-1")); |
| 41 | 55 | ||
| 42 | yield* directory.upsert({ | 56 | yield* directory.upsert({ |
| 43 | sessionId: "sess-1", | 57 | sessionId: sessionId("sess-1"), |
| 44 | provider: "codex", | 58 | provider: "codex", |
| 45 | threadId: "thread-2", | 59 | threadId: threadId("thread-2"), |
| 60 | }); | ||
| 61 | const updatedThreadId = yield* directory.getThreadId(sessionId("sess-1")); | ||
| 62 | assertSome(updatedThreadId, threadId("thread-2")); | ||
| 63 | |||
| 64 | const runtime = yield* runtimeRepository.getBySessionId({ | ||
| 65 | providerSessionId: sessionId("sess-1"), | ||
| 46 | }); | 66 | }); |
| 47 | const updatedThreadId = yield* directory.getThreadId("sess-1"); | 67 | assert.equal(Option.isSome(runtime), true); |
| 48 | assertSome(updatedThreadId, "thread-2"); | 68 | if (Option.isSome(runtime)) { |
| 69 | assert.equal(runtime.value.threadId, "thread-2"); | ||
| 70 | assert.equal(runtime.value.providerThreadId, "thread-2"); | ||
| 71 | assert.equal(runtime.value.status, "running"); | ||
| 72 | assert.equal(runtime.value.adapterKey, "codex"); | ||
| 73 | } | ||
| 49 | 74 | ||
| 50 | const sessionIds = yield* directory.listSessionIds(); | 75 | const sessionIds = yield* directory.listSessionIds(); |
| 51 | assert.deepEqual(sessionIds, ["sess-1"]); | 76 | assert.deepEqual(sessionIds, [sessionId("sess-1")]); |
| 52 | 77 | ||
| 53 | yield* directory.remove("sess-1"); | 78 | yield* directory.remove(sessionId("sess-1")); |
| 54 | const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result); | 79 | const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result); |
| 55 | assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); | 80 | assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); |
| 56 | })); | 81 | })); |
| 57 | 82 | ||
| ... | @@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => { | ... | @@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 60 | const directory = yield* ProviderSessionDirectory; | 85 | const directory = yield* ProviderSessionDirectory; |
| 61 | const result = yield* Effect.result( | 86 | const result = yield* Effect.result( |
| 62 | directory.upsert({ | 87 | directory.upsert({ |
| 63 | sessionId: " ", | 88 | sessionId: sessionId(" "), |
| 64 | provider: "codex", | 89 | provider: "codex", |
| 65 | }), | 90 | }), |
| 66 | ); | 91 | ); |
| ... | @@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => { | ... | @@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 73 | ); | 98 | ); |
| 74 | })); | 99 | })); |
| 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 | it("rehydrates persisted mappings across layer restart", () => | 149 | it("rehydrates persisted mappings across layer restart", () => |
| 77 | Effect.gen(function* () { | 150 | Effect.gen(function* () { |
| 78 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); | 151 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); |
| ... | @@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => { | ... | @@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => { |
| 82 | yield* Effect.gen(function* () { | 155 | yield* Effect.gen(function* () { |
| 83 | const directory = yield* ProviderSessionDirectory; | 156 | const directory = yield* ProviderSessionDirectory; |
| 84 | yield* directory.upsert({ | 157 | yield* directory.upsert({ |
| 85 | sessionId: "sess-restart", | 158 | sessionId: sessionId("sess-restart"), |
| 86 | provider: "codex", | 159 | provider: "codex", |
| 87 | threadId: "thread-restart", | 160 | threadId: threadId("thread-restart"), |
| 88 | }); | 161 | }); |
| 89 | }).pipe(Effect.provide(directoryLayer)); | 162 | }).pipe(Effect.provide(directoryLayer)); |
| 90 | 163 | ||
| 91 | yield* Effect.gen(function* () { | 164 | yield* Effect.gen(function* () { |
| 92 | const directory = yield* ProviderSessionDirectory; | 165 | const directory = yield* ProviderSessionDirectory; |
| 93 | const provider = yield* directory.getProvider("sess-restart"); | 166 | const sql = yield* SqlClient.SqlClient; |
| 167 | const provider = yield* directory.getProvider(sessionId("sess-restart")); | ||
| 94 | assert.equal(provider, "codex"); | 168 | assert.equal(provider, "codex"); |
| 95 | 169 | ||
| 96 | const threadId = yield* directory.getThreadId("sess-restart"); | 170 | const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart")); |
| 97 | assertSome(threadId, "thread-restart"); | 171 | assertSome(resolvedThreadId, threadId("thread-restart")); |
| 172 | |||
| 173 | const legacyTableRows = yield* sql<{ readonly name: string }>` | ||
| 174 | SELECT name | ||
| 175 | FROM sqlite_master | ||
| 176 | WHERE type = 'table' AND name = 'provider_sessions' | ||
| 177 | `; | ||
| 178 | assert.equal(legacyTableRows.length, 0); | ||
| 98 | }).pipe(Effect.provide(directoryLayer)); | 179 | }).pipe(Effect.provide(directoryLayer)); |
| 99 | 180 | ||
| 100 | fs.rmSync(tempDir, { recursive: true, force: true }); | 181 | fs.rmSync(tempDir, { recursive: true, force: true }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+142-33| ... | @@ -1,6 +1,12 @@ | ... | @@ -1,6 +1,12 @@ |
| 1 | import { | ||
| 2 | ProviderSessionId, | ||
| 3 | ProviderThreadId, | ||
| 4 | ThreadId, | ||
| 5 | type ProviderKind, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 1 | import { Effect, Layer, Option } from "effect"; | 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 | import { | 10 | import { |
| 5 | ProviderSessionDirectoryPersistenceError, | 11 | ProviderSessionDirectoryPersistenceError, |
| 6 | ProviderSessionNotFoundError, | 12 | ProviderSessionNotFoundError, |
| ... | @@ -22,51 +28,154 @@ function toPersistenceError(operation: string) { | ... | @@ -22,51 +28,154 @@ function toPersistenceError(operation: string) { |
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding { | 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 | return { | 37 | return { |
| 26 | sessionId: binding.sessionId.trim(), | 38 | sessionId: ProviderSessionId.makeUnsafe(sessionId), |
| 27 | provider: binding.provider, | 39 | provider: binding.provider, |
| 28 | ...(binding.threadId?.trim() ? { threadId: binding.threadId.trim() } : {}), | 40 | ...(adapterKey !== undefined && adapterKey.length > 0 ? { adapterKey } : {}), |
| 41 | ...(threadId !== undefined && threadId.length > 0 | ||
| 42 | ? { threadId: ThreadId.makeUnsafe(threadId) } | ||
| 43 | : {}), | ||
| 44 | ...(providerThreadId === null | ||
| 45 | ? { providerThreadId: null } | ||
| 46 | : providerThreadId !== undefined && providerThreadId.length > 0 | ||
| 47 | ? { providerThreadId: ProviderThreadId.makeUnsafe(providerThreadId) } | ||
| 48 | : {}), | ||
| 49 | ...(binding.status !== undefined ? { status: binding.status } : {}), | ||
| 50 | ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}), | ||
| 51 | ...(binding.runtimePayload !== undefined ? { runtimePayload: binding.runtimePayload } : {}), | ||
| 29 | }; | 52 | }; |
| 30 | } | 53 | } |
| 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 | const makeProviderSessionDirectory = Effect.gen(function* () { | 90 | const makeProviderSessionDirectory = Effect.gen(function* () { |
| 33 | const repository = yield* ProviderSessionRepository; | 91 | const repository = yield* ProviderSessionRuntimeRepository; |
| 34 | 92 | ||
| 35 | const persistUpsert = (binding: ProviderSessionBinding) => | 93 | const persistUpsert = (binding: ProviderSessionBinding) => |
| 94 | Effect.gen(function* () { | ||
| 95 | const existing = yield* repository | ||
| 96 | .getBySessionId({ | ||
| 97 | providerSessionId: binding.sessionId, | ||
| 98 | }) | ||
| 99 | .pipe( | ||
| 100 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")), | ||
| 101 | ); | ||
| 102 | |||
| 103 | const existingRuntime = Option.getOrUndefined(existing); | ||
| 104 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; | ||
| 105 | if (!resolvedThreadId) { | ||
| 106 | return yield* Effect.fail( | ||
| 107 | new ProviderValidationError({ | ||
| 108 | operation: "ProviderSessionDirectory.upsert", | ||
| 109 | issue: "threadId must be a non-empty string.", | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | const now = new Date().toISOString(); | ||
| 115 | yield* repository | ||
| 116 | .upsert({ | ||
| 117 | providerSessionId: binding.sessionId, | ||
| 118 | threadId: resolvedThreadId, | ||
| 119 | providerName: binding.provider, | ||
| 120 | adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, | ||
| 121 | providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null, | ||
| 122 | status: binding.status ?? existingRuntime?.status ?? "running", | ||
| 123 | lastSeenAt: now, | ||
| 124 | resumeCursor: | ||
| 125 | binding.resumeCursor !== undefined | ||
| 126 | ? binding.resumeCursor | ||
| 127 | : (existingRuntime?.resumeCursor ?? null), | ||
| 128 | runtimePayload: mergeRuntimePayload( | ||
| 129 | existingRuntime?.runtimePayload ?? null, | ||
| 130 | binding.runtimePayload, | ||
| 131 | ), | ||
| 132 | }) | ||
| 133 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); | ||
| 134 | }); | ||
| 135 | |||
| 136 | const persistDelete = (sessionId: ProviderSessionId) => | ||
| 36 | repository | 137 | repository |
| 37 | .upsertSession({ | 138 | .deleteBySessionId({ providerSessionId: sessionId }) |
| 38 | sessionId: binding.sessionId, | 139 | .pipe( |
| 39 | provider: binding.provider, | 140 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), |
| 40 | ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), | 141 | ); |
| 41 | }) | 142 | |
| 42 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession"))); | 143 | const getBinding = (sessionId: ProviderSessionId) => |
| 43 | 144 | repository.getBySessionId({ providerSessionId: sessionId }).pipe( | |
| 44 | const persistDelete = (sessionId: string) => | 145 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), |
| 45 | repository | 146 | Effect.flatMap((runtime) => |
| 46 | .deleteSession({ sessionId }) | 147 | Option.match(runtime, { |
| 47 | .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession"))); | 148 | onNone: () => Effect.succeed(Option.none<ProviderSessionBinding>()), |
| 48 | 149 | onSome: (value) => | |
| 49 | const getBinding = (sessionId: string) => | 150 | decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( |
| 50 | repository.getSession({ sessionId }).pipe( | 151 | Effect.map((provider) => |
| 51 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")), | 152 | Option.some({ |
| 52 | Effect.map((entry) => | 153 | sessionId: value.providerSessionId, |
| 53 | Option.map(entry, (value) => ({ | 154 | provider, |
| 54 | sessionId: value.sessionId, | 155 | threadId: value.threadId, |
| 55 | provider: value.provider, | 156 | }), |
| 56 | ...(value.threadId !== undefined ? { threadId: value.threadId } : {}), | 157 | ), |
| 57 | })), | 158 | ), |
| 159 | }), | ||
| 58 | ), | 160 | ), |
| 59 | ); | 161 | ); |
| 60 | 162 | ||
| 61 | const listBindings = () => | 163 | const listBindings = () => |
| 62 | repository.listSessions().pipe( | 164 | repository.list().pipe( |
| 63 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")), | 165 | Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), |
| 64 | Effect.map((rows) => | 166 | Effect.flatMap((rows) => |
| 65 | rows.map((row) => ({ | 167 | Effect.forEach( |
| 66 | sessionId: row.sessionId, | 168 | rows, |
| 67 | provider: row.provider, | 169 | (row) => |
| 68 | ...(row.threadId !== undefined ? { threadId: row.threadId } : {}), | 170 | decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( |
| 69 | })), | 171 | Effect.map((provider) => ({ |
| 172 | sessionId: row.providerSessionId, | ||
| 173 | provider, | ||
| 174 | threadId: row.threadId, | ||
| 175 | })), | ||
| 176 | ), | ||
| 177 | { concurrency: "unbounded" }, | ||
| 178 | ), | ||
| 70 | ), | 179 | ), |
| 71 | ); | 180 | ); |
| 72 | 181 |
apps/server/src/provider/Services/ProviderAdapter.ts+18-9| ... | @@ -8,24 +8,28 @@ | ... | @@ -8,24 +8,28 @@ |
| 8 | * @module ProviderAdapter | 8 | * @module ProviderAdapter |
| 9 | */ | 9 | */ |
| 10 | import type { | 10 | import type { |
| 11 | ApprovalRequestId, | ||
| 11 | ProviderApprovalDecision, | 12 | ProviderApprovalDecision, |
| 12 | ProviderKind, | 13 | ProviderKind, |
| 13 | ProviderRuntimeEvent, | 14 | ProviderRuntimeEvent, |
| 14 | ProviderSendTurnInput, | 15 | ProviderSendTurnInput, |
| 15 | ProviderSession, | 16 | ProviderSession, |
| 17 | ProviderSessionId, | ||
| 16 | ProviderSessionStartInput, | 18 | ProviderSessionStartInput, |
| 19 | ProviderThreadId, | ||
| 20 | ProviderTurnId, | ||
| 17 | ProviderTurnStartResult, | 21 | ProviderTurnStartResult, |
| 18 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 19 | import type { Effect } from "effect"; | 23 | import type { Effect } from "effect"; |
| 20 | import type { Stream } from "effect"; | 24 | import type { Stream } from "effect"; |
| 21 | 25 | ||
| 22 | export interface ProviderThreadTurnSnapshot { | 26 | export interface ProviderThreadTurnSnapshot { |
| 23 | readonly id: string; | 27 | readonly id: ProviderTurnId; |
| 24 | readonly items: ReadonlyArray<unknown>; | 28 | readonly items: ReadonlyArray<unknown>; |
| 25 | } | 29 | } |
| 26 | 30 | ||
| 27 | export interface ProviderThreadSnapshot { | 31 | export interface ProviderThreadSnapshot { |
| 28 | readonly threadId: string; | 32 | readonly threadId: ProviderThreadId; |
| 29 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; | 33 | readonly turns: ReadonlyArray<ProviderThreadTurnSnapshot>; |
| 30 | } | 34 | } |
| 31 | 35 | ||
| ... | @@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> { | ... | @@ -52,21 +56,24 @@ export interface ProviderAdapterShape<TError> { |
| 52 | /** | 56 | /** |
| 53 | * Interrupt an active turn. | 57 | * Interrupt an active turn. |
| 54 | */ | 58 | */ |
| 55 | readonly interruptTurn: (sessionId: string, turnId?: string) => Effect.Effect<void, TError>; | 59 | readonly interruptTurn: ( |
| 60 | sessionId: ProviderSessionId, | ||
| 61 | turnId?: ProviderTurnId, | ||
| 62 | ) => Effect.Effect<void, TError>; | ||
| 56 | 63 | ||
| 57 | /** | 64 | /** |
| 58 | * Respond to an interactive approval request. | 65 | * Respond to an interactive approval request. |
| 59 | */ | 66 | */ |
| 60 | readonly respondToRequest: ( | 67 | readonly respondToRequest: ( |
| 61 | sessionId: string, | 68 | sessionId: ProviderSessionId, |
| 62 | requestId: string, | 69 | requestId: ApprovalRequestId, |
| 63 | decision: ProviderApprovalDecision, | 70 | decision: ProviderApprovalDecision, |
| 64 | ) => Effect.Effect<void, TError>; | 71 | ) => Effect.Effect<void, TError>; |
| 65 | 72 | ||
| 66 | /** | 73 | /** |
| 67 | * Stop one provider session. | 74 | * Stop one provider session. |
| 68 | */ | 75 | */ |
| 69 | readonly stopSession: (sessionId: string) => Effect.Effect<void, TError>; | 76 | readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect<void, TError>; |
| 70 | 77 | ||
| 71 | /** | 78 | /** |
| 72 | * List currently active provider sessions for this adapter. | 79 | * List currently active provider sessions for this adapter. |
| ... | @@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> { | ... | @@ -76,18 +83,20 @@ export interface ProviderAdapterShape<TError> { |
| 76 | /** | 83 | /** |
| 77 | * Check whether this adapter owns an active session id. | 84 | * Check whether this adapter owns an active session id. |
| 78 | */ | 85 | */ |
| 79 | readonly hasSession: (sessionId: string) => Effect.Effect<boolean>; | 86 | readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect<boolean>; |
| 80 | 87 | ||
| 81 | /** | 88 | /** |
| 82 | * Read a provider thread snapshot. | 89 | * Read a provider thread snapshot. |
| 83 | */ | 90 | */ |
| 84 | readonly readThread: (sessionId: string) => Effect.Effect<ProviderThreadSnapshot, TError>; | 91 | readonly readThread: ( |
| 92 | sessionId: ProviderSessionId, | ||
| 93 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; | ||
| 85 | 94 | ||
| 86 | /** | 95 | /** |
| 87 | * Roll back a provider thread by N turns. | 96 | * Roll back a provider thread by N turns. |
| 88 | */ | 97 | */ |
| 89 | readonly rollbackThread: ( | 98 | readonly rollbackThread: ( |
| 90 | sessionId: string, | 99 | sessionId: ProviderSessionId, |
| 91 | numTurns: number, | 100 | numTurns: number, |
| 92 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; | 101 | ) => Effect.Effect<ProviderThreadSnapshot, TError>; |
| 93 | 102 |
apps/server/src/provider/Services/ProviderService.ts+7-25| ... | @@ -12,20 +12,15 @@ | ... | @@ -12,20 +12,15 @@ |
| 12 | * @module ProviderService | 12 | * @module ProviderService |
| 13 | */ | 13 | */ |
| 14 | import type { | 14 | import type { |
| 15 | ProviderGetCheckpointDiffInput, | ||
| 16 | ProviderGetCheckpointDiffResult, | ||
| 17 | ProviderInterruptTurnInput, | 15 | ProviderInterruptTurnInput, |
| 18 | ProviderListCheckpointsInput, | ||
| 19 | ProviderListCheckpointsResult, | ||
| 20 | ProviderRespondToRequestInput, | 16 | ProviderRespondToRequestInput, |
| 21 | ProviderRevertToCheckpointInput, | 17 | ProviderRuntimeEvent, |
| 22 | ProviderRevertToCheckpointResult, | ||
| 23 | ProviderSendTurnInput, | 18 | ProviderSendTurnInput, |
| 24 | ProviderSession, | 19 | ProviderSession, |
| 20 | ProviderSessionId, | ||
| 25 | ProviderSessionStartInput, | 21 | ProviderSessionStartInput, |
| 26 | ProviderStopSessionInput, | 22 | ProviderStopSessionInput, |
| 27 | ProviderTurnStartResult, | 23 | ProviderTurnStartResult, |
| 28 | ProviderRuntimeEvent, | ||
| 29 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 30 | import { ServiceMap } from "effect"; | 25 | import { ServiceMap } from "effect"; |
| 31 | import type { Effect, Stream } from "effect"; | 26 | import type { Effect, Stream } from "effect"; |
| ... | @@ -74,25 +69,12 @@ export interface ProviderServiceShape { | ... | @@ -74,25 +69,12 @@ export interface ProviderServiceShape { |
| 74 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; | 69 | readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>; |
| 75 | 70 | ||
| 76 | /** | 71 | /** |
| 77 | * List checkpoints for a provider session. | 72 | * Roll back provider conversation state by a number of turns. |
| 78 | */ | ||
| 79 | readonly listCheckpoints: ( | ||
| 80 | input: ProviderListCheckpointsInput, | ||
| 81 | ) => Effect.Effect<ProviderListCheckpointsResult, ProviderServiceError>; | ||
| 82 | |||
| 83 | /** | ||
| 84 | * Diff two checkpoints for a provider session. | ||
| 85 | */ | ||
| 86 | readonly getCheckpointDiff: ( | ||
| 87 | input: ProviderGetCheckpointDiffInput, | ||
| 88 | ) => Effect.Effect<ProviderGetCheckpointDiffResult, ProviderServiceError>; | ||
| 89 | |||
| 90 | /** | ||
| 91 | * Revert a provider session to a checkpoint. | ||
| 92 | */ | 73 | */ |
| 93 | readonly revertToCheckpoint: ( | 74 | readonly rollbackConversation: (input: { |
| 94 | input: ProviderRevertToCheckpointInput, | 75 | readonly sessionId: ProviderSessionId; |
| 95 | ) => Effect.Effect<ProviderRevertToCheckpointResult, ProviderServiceError>; | 76 | readonly numTurns: number; |
| 77 | }) => Effect.Effect<void, ProviderServiceError>; | ||
| 96 | 78 | ||
| 97 | /** | 79 | /** |
| 98 | * Stop all active provider sessions. | 80 | * Stop all active provider sessions. |
apps/server/src/provider/Services/ProviderSessionDirectory.ts+19-8| ... | @@ -7,7 +7,13 @@ | ... | @@ -7,7 +7,13 @@ |
| 7 | * | 7 | * |
| 8 | * @module ProviderSessionDirectory | 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 | import { Option, ServiceMap } from "effect"; | 17 | import { Option, ServiceMap } from "effect"; |
| 12 | import type { Effect } from "effect"; | 18 | import type { Effect } from "effect"; |
| 13 | 19 | ||
| ... | @@ -18,9 +24,14 @@ import type { | ... | @@ -18,9 +24,14 @@ import type { |
| 18 | } from "../Errors.ts"; | 24 | } from "../Errors.ts"; |
| 19 | 25 | ||
| 20 | export interface ProviderSessionBinding { | 26 | export interface ProviderSessionBinding { |
| 21 | readonly sessionId: string; | 27 | readonly sessionId: ProviderSessionId; |
| 22 | readonly provider: ProviderKind; | 28 | readonly provider: ProviderKind; |
| 23 | readonly threadId?: string; | 29 | readonly threadId?: ThreadId; |
| 30 | readonly adapterKey?: string; | ||
| 31 | readonly providerThreadId?: ProviderThreadId | null; | ||
| 32 | readonly status?: ProviderSessionRuntimeStatus; | ||
| 33 | readonly resumeCursor?: unknown | null; | ||
| 34 | readonly runtimePayload?: unknown | null; | ||
| 24 | } | 35 | } |
| 25 | 36 | ||
| 26 | export type ProviderSessionDirectoryReadError = | 37 | export type ProviderSessionDirectoryReadError = |
| ... | @@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape { | ... | @@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape { |
| 43 | * Resolve the provider owner for a session id. | 54 | * Resolve the provider owner for a session id. |
| 44 | */ | 55 | */ |
| 45 | readonly getProvider: ( | 56 | readonly getProvider: ( |
| 46 | sessionId: string, | 57 | sessionId: ProviderSessionId, |
| 47 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; | 58 | ) => Effect.Effect<ProviderKind, ProviderSessionDirectoryReadError>; |
| 48 | 59 | ||
| 49 | /** | 60 | /** |
| 50 | * Resolve the tracked thread id for a session, if known. | 61 | * Resolve the tracked thread id for a session, if known. |
| 51 | */ | 62 | */ |
| 52 | readonly getThreadId: ( | 63 | readonly getThreadId: ( |
| 53 | sessionId: string, | 64 | sessionId: ProviderSessionId, |
| 54 | ) => Effect.Effect<Option.Option<string>, ProviderSessionDirectoryReadError>; | 65 | ) => Effect.Effect<Option.Option<ThreadId>, ProviderSessionDirectoryReadError>; |
| 55 | 66 | ||
| 56 | /** | 67 | /** |
| 57 | * Remove a session binding. | 68 | * Remove a session binding. |
| 58 | */ | 69 | */ |
| 59 | readonly remove: ( | 70 | readonly remove: ( |
| 60 | sessionId: string, | 71 | sessionId: ProviderSessionId, |
| 61 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; | 72 | ) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>; |
| 62 | 73 | ||
| 63 | /** | 74 | /** |
| 64 | * List tracked session ids. | 75 | * List tracked session ids. |
| 65 | */ | 76 | */ |
| 66 | readonly listSessionIds: () => Effect.Effect< | 77 | readonly listSessionIds: () => Effect.Effect< |
| 67 | ReadonlyArray<string>, | 78 | ReadonlyArray<ProviderSessionId>, |
| 68 | ProviderSessionDirectoryPersistenceError | 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,20 +4,16 @@ import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | DEFAULT_TERMINAL_ID, | 6 | DEFAULT_TERMINAL_ID, |
| 7 | type TerminalClearInput, | 7 | TerminalClearInput, |
| 8 | type TerminalCloseInput, | 8 | TerminalCloseInput, |
| 9 | TerminalOpenInput, | ||
| 10 | TerminalResizeInput, | ||
| 11 | TerminalWriteInput, | ||
| 9 | type TerminalEvent, | 12 | type TerminalEvent, |
| 10 | type TerminalOpenInput, | ||
| 11 | type TerminalResizeInput, | ||
| 12 | type TerminalSessionSnapshot, | 13 | type TerminalSessionSnapshot, |
| 13 | type TerminalSessionStatus, | 14 | type TerminalSessionStatus, |
| 14 | type TerminalWriteInput, | ||
| 15 | terminalClearInputSchema, | ||
| 16 | terminalCloseInputSchema, | ||
| 17 | terminalOpenInputSchema, | ||
| 18 | terminalResizeInputSchema, | ||
| 19 | terminalWriteInputSchema, | ||
| 20 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { Schema } from "effect"; | ||
| 21 | 17 | ||
| 22 | import { createLogger } from "./logger"; | 18 | import { createLogger } from "./logger"; |
| 23 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | 19 | import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| ... | @@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120; | ... | @@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120; |
| 30 | const DEFAULT_OPEN_ROWS = 30; | 26 | const DEFAULT_OPEN_ROWS = 30; |
| 31 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 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 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; | 35 | type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; |
| 34 | 36 | ||
| 35 | export interface TerminalManagerEvents { | 37 | export interface TerminalManagerEvents { |
| ... | @@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 332 | } | 334 | } |
| 333 | 335 | ||
| 334 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | 336 | async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 335 | const input = terminalOpenInputSchema.parse(raw); | 337 | const input = decodeTerminalOpenInput(raw); |
| 336 | return this.runWithThreadLock(input.threadId, async () => { | 338 | return this.runWithThreadLock(input.threadId, async () => { |
| 337 | await this.assertValidCwd(input.cwd); | 339 | await this.assertValidCwd(input.cwd); |
| 338 | 340 | ||
| ... | @@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 404 | } | 406 | } |
| 405 | 407 | ||
| 406 | async write(raw: TerminalWriteInput): Promise<void> { | 408 | async write(raw: TerminalWriteInput): Promise<void> { |
| 407 | const input = terminalWriteInputSchema.parse(raw); | 409 | const input = decodeTerminalWriteInput(raw); |
| 408 | const session = this.requireSession(input.threadId, input.terminalId); | 410 | const session = this.requireSession(input.threadId, input.terminalId); |
| 409 | if (!session.process || session.status !== "running") { | 411 | if (!session.process || session.status !== "running") { |
| 410 | throw new Error( | 412 | throw new Error( |
| ... | @@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 415 | } | 417 | } |
| 416 | 418 | ||
| 417 | async resize(raw: TerminalResizeInput): Promise<void> { | 419 | async resize(raw: TerminalResizeInput): Promise<void> { |
| 418 | const input = terminalResizeInputSchema.parse(raw); | 420 | const input = decodeTerminalResizeInput(raw); |
| 419 | const session = this.requireSession(input.threadId, input.terminalId); | 421 | const session = this.requireSession(input.threadId, input.terminalId); |
| 420 | if (!session.process || session.status !== "running") { | 422 | if (!session.process || session.status !== "running") { |
| 421 | throw new Error( | 423 | throw new Error( |
| ... | @@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 429 | } | 431 | } |
| 430 | 432 | ||
| 431 | async clear(raw: TerminalClearInput): Promise<void> { | 433 | async clear(raw: TerminalClearInput): Promise<void> { |
| 432 | const input = terminalClearInputSchema.parse(raw); | 434 | const input = decodeTerminalClearInput(raw); |
| 433 | await this.runWithThreadLock(input.threadId, async () => { | 435 | await this.runWithThreadLock(input.threadId, async () => { |
| 434 | const session = this.requireSession(input.threadId, input.terminalId); | 436 | const session = this.requireSession(input.threadId, input.terminalId); |
| 435 | session.history = ""; | 437 | session.history = ""; |
| ... | @@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 445 | } | 447 | } |
| 446 | 448 | ||
| 447 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { | 449 | async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> { |
| 448 | const input = terminalOpenInputSchema.parse(raw); | 450 | const input = decodeTerminalOpenInput(raw); |
| 449 | return this.runWithThreadLock(input.threadId, async () => { | 451 | return this.runWithThreadLock(input.threadId, async () => { |
| 450 | await this.assertValidCwd(input.cwd); | 452 | await this.assertValidCwd(input.cwd); |
| 451 | 453 | ||
| ... | @@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { | ... | @@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> { |
| 490 | } | 492 | } |
| 491 | 493 | ||
| 492 | async close(raw: TerminalCloseInput): Promise<void> { | 494 | async close(raw: TerminalCloseInput): Promise<void> { |
| 493 | const input = terminalCloseInputSchema.parse(raw); | 495 | const input = decodeTerminalCloseInput(raw); |
| 494 | await this.runWithThreadLock(input.threadId, async () => { | 496 | await this.runWithThreadLock(input.threadId, async () => { |
| 495 | if (input.terminalId) { | 497 | if (input.terminalId) { |
| 496 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); | 498 | await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); |
apps/server/src/workspaceEntries.ts+7-9| ... | @@ -4,10 +4,9 @@ import path from "node:path"; | ... | @@ -4,10 +4,9 @@ import path from "node:path"; |
| 4 | import { runProcess } from "./processRunner"; | 4 | import { runProcess } from "./processRunner"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | projectSearchEntriesInputSchema, | 7 | ProjectEntry, |
| 8 | type ProjectEntry, | 8 | ProjectSearchEntriesInput, |
| 9 | type ProjectSearchEntriesInput, | 9 | ProjectSearchEntriesResult, |
| 10 | type ProjectSearchEntriesResult, | ||
| 11 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | ||
| 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; | 12 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| ... | @@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { | ... | @@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 413 | export async function searchWorkspaceEntries( | 412 | export async function searchWorkspaceEntries( |
| 414 | input: ProjectSearchEntriesInput, | 413 | input: ProjectSearchEntriesInput, |
| 415 | ): Promise<ProjectSearchEntriesResult> { | 414 | ): Promise<ProjectSearchEntriesResult> { |
| 416 | const parsed = projectSearchEntriesInputSchema.parse(input); | 415 | const index = await getWorkspaceIndex(input.cwd); |
| 417 | const index = await getWorkspaceIndex(parsed.cwd); | 416 | const normalizedQuery = normalizeQuery(input.query); |
| 418 | const normalizedQuery = normalizeQuery(parsed.query); | ||
| 419 | const candidates = normalizedQuery | 417 | const candidates = normalizedQuery |
| 420 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) | 418 | ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) |
| 421 | : index.entries; | 419 | : index.entries; |
| ... | @@ -427,7 +425,7 @@ export async function searchWorkspaceEntries( | ... | @@ -427,7 +425,7 @@ export async function searchWorkspaceEntries( |
| 427 | }); | 425 | }); |
| 428 | 426 | ||
| 429 | return { | 427 | return { |
| 430 | entries: ranked.slice(0, parsed.limit), | 428 | entries: ranked.slice(0, input.limit), |
| 431 | truncated: index.truncated || ranked.length > parsed.limit, | 429 | truncated: index.truncated || ranked.length > input.limit, |
| 432 | }; | 430 | }; |
| 433 | } | 431 | } |
apps/server/src/wsServer.test.ts+30-168| ... | @@ -3,7 +3,7 @@ import os from "node:os"; | ... | @@ -3,7 +3,7 @@ import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | import { EventEmitter } from "node:events"; | 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 | import { describe, expect, it, afterEach, vi } from "vitest"; | 7 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 8 | import { createServer } from "./wsServer"; | 8 | import { createServer } from "./wsServer"; |
| 9 | import WebSocket from "ws"; | 9 | import WebSocket from "ws"; |
| ... | @@ -21,7 +21,6 @@ import { | ... | @@ -21,7 +21,6 @@ import { |
| 21 | type WsResponse, | 21 | type WsResponse, |
| 22 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 23 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | 23 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| 24 | import { ProjectRepository } from "./persistence/Services/Projects"; | ||
| 25 | import type { | 24 | import type { |
| 26 | TerminalClearInput, | 25 | TerminalClearInput, |
| 27 | TerminalCloseInput, | 26 | TerminalCloseInput, |
| ... | @@ -32,9 +31,7 @@ import type { | ... | @@ -32,9 +31,7 @@ import type { |
| 32 | TerminalWriteInput, | 31 | TerminalWriteInput, |
| 33 | } from "@t3tools/contracts"; | 32 | } from "@t3tools/contracts"; |
| 34 | import type { TerminalManager } from "./terminalManager"; | 33 | import type { TerminalManager } from "./terminalManager"; |
| 35 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | 34 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 36 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | ||
| 37 | import { NodeServices } from "@effect/platform-node"; | ||
| 38 | import { SqlClient } from "effect/unstable/sql"; | 35 | import { SqlClient } from "effect/unstable/sql"; |
| 39 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 36 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 40 | 37 | ||
| ... | @@ -664,38 +661,6 @@ describe("WebSocket Server", () => { | ... | @@ -664,38 +661,6 @@ describe("WebSocket Server", () => { |
| 664 | expect(response.error!.message).toContain("Unknown method"); | 661 | expect(response.error!.message).toContain("Unknown method"); |
| 665 | }); | 662 | }); |
| 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 | it("keeps orchestration domain push behavior for provider runtime events", async () => { | 664 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 700 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 665 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 701 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | 666 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| ... | @@ -709,15 +674,7 @@ describe("WebSocket Server", () => { | ... | @@ -709,15 +674,7 @@ describe("WebSocket Server", () => { |
| 709 | respondToRequest: () => unsupported(), | 674 | respondToRequest: () => unsupported(), |
| 710 | stopSession: () => unsupported(), | 675 | stopSession: () => unsupported(), |
| 711 | listSessions: () => Effect.succeed([]), | 676 | listSessions: () => Effect.succeed([]), |
| 712 | listCheckpoints: () => unsupported(), | 677 | rollbackConversation: () => unsupported(), |
| 713 | getCheckpointDiff: (input) => | ||
| 714 | Effect.succeed({ | ||
| 715 | threadId: "thread-1", | ||
| 716 | fromTurnCount: input.fromTurnCount, | ||
| 717 | toTurnCount: input.toTurnCount, | ||
| 718 | diff: "", | ||
| 719 | }), | ||
| 720 | revertToCheckpoint: () => unsupported(), | ||
| 721 | stopAll: () => Effect.void, | 678 | stopAll: () => Effect.void, |
| 722 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 679 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| 723 | }; | 680 | }; |
| ... | @@ -736,6 +693,16 @@ describe("WebSocket Server", () => { | ... | @@ -736,6 +693,16 @@ describe("WebSocket Server", () => { |
| 736 | await waitForMessage(ws); | 693 | await waitForMessage(ws); |
| 737 | 694 | ||
| 738 | const createdAt = new Date().toISOString(); | 695 | const createdAt = new Date().toISOString(); |
| 696 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | ||
| 697 | type: "project.create", | ||
| 698 | commandId: "cmd-ws-project-create", | ||
| 699 | projectId: "project-1", | ||
| 700 | title: "WS Project", | ||
| 701 | workspaceRoot: "/tmp/ws-project", | ||
| 702 | defaultModel: "gpt-5-codex", | ||
| 703 | createdAt, | ||
| 704 | }); | ||
| 705 | expect(createProjectResponse.error).toBeUndefined(); | ||
| 739 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | 706 | const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { |
| 740 | type: "thread.create", | 707 | type: "thread.create", |
| 741 | commandId: "cmd-ws-runtime-thread-create", | 708 | commandId: "cmd-ws-runtime-thread-create", |
| ... | @@ -928,11 +895,8 @@ describe("WebSocket Server", () => { | ... | @@ -928,11 +895,8 @@ describe("WebSocket Server", () => { |
| 928 | expect(response!.error!.message).toContain("Invalid request format"); | 895 | expect(response!.error!.message).toContain("Invalid request format"); |
| 929 | }); | 896 | }); |
| 930 | 897 | ||
| 931 | it("supports projects list/add/dedupe/remove", async () => { | 898 | it("returns unknown method for removed projects CRUD methods", async () => { |
| 932 | const stateDir = makeTempDir("t3code-ws-projects-state-"); | 899 | server = createTestServer({ cwd: "/test" }); |
| 933 | const firstProjectCwd = makeTempDir("t3code-ws-project-a-"); | ||
| 934 | |||
| 935 | server = createTestServer({ stateDir, cwd: "/test" }); | ||
| 936 | await server.start(); | 900 | await server.start(); |
| 937 | const addr = server.httpServer.address(); | 901 | const addr = server.httpServer.address(); |
| 938 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 902 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| ... | @@ -941,75 +905,21 @@ describe("WebSocket Server", () => { | ... | @@ -941,75 +905,21 @@ describe("WebSocket Server", () => { |
| 941 | connections.push(ws); | 905 | connections.push(ws); |
| 942 | await waitForMessage(ws); | 906 | await waitForMessage(ws); |
| 943 | 907 | ||
| 944 | const emptyList = await sendRequest(ws, WS_METHODS.projectsList); | 908 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); |
| 945 | expect(emptyList.error).toBeUndefined(); | 909 | expect(listResponse.result).toBeUndefined(); |
| 946 | expect(emptyList.result).toEqual([]); | 910 | expect(listResponse.error?.message).toContain("Unknown method"); |
| 947 | |||
| 948 | const created = await sendRequest(ws, WS_METHODS.projectsAdd, { | ||
| 949 | cwd: firstProjectCwd, | ||
| 950 | }); | ||
| 951 | expect(created.error).toBeUndefined(); | ||
| 952 | expect((created.result as { created: boolean }).created).toBe(true); | ||
| 953 | 911 | ||
| 954 | const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, { | 912 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { |
| 955 | cwd: firstProjectCwd, | 913 | cwd: "/tmp/project-a", |
| 956 | }); | ||
| 957 | expect(duplicate.error).toBeUndefined(); | ||
| 958 | expect((duplicate.result as { created: boolean }).created).toBe(false); | ||
| 959 | |||
| 960 | const listed = await sendRequest(ws, WS_METHODS.projectsList); | ||
| 961 | expect(listed.error).toBeUndefined(); | ||
| 962 | const listedProjects = listed.result as Array<{ id: string; cwd: string }>; | ||
| 963 | expect(listedProjects).toHaveLength(1); | ||
| 964 | |||
| 965 | const projectId = listedProjects[0]?.id; | ||
| 966 | expect(projectId).toBeTruthy(); | ||
| 967 | if (!projectId) return; | ||
| 968 | |||
| 969 | const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { | ||
| 970 | id: projectId, | ||
| 971 | scripts: [ | ||
| 972 | { | ||
| 973 | id: "setup", | ||
| 974 | name: "Setup", | ||
| 975 | command: "bun install", | ||
| 976 | icon: "configure", | ||
| 977 | runOnWorktreeCreate: true, | ||
| 978 | }, | ||
| 979 | ], | ||
| 980 | }); | 914 | }); |
| 981 | expect(updatedScripts.error).toBeUndefined(); | 915 | expect(addResponse.result).toBeUndefined(); |
| 982 | const scriptPayload = ( | 916 | expect(addResponse.error?.message).toContain("Unknown method"); |
| 983 | updatedScripts.result as { | ||
| 984 | project: { | ||
| 985 | scripts: Array<{ | ||
| 986 | id: string; | ||
| 987 | name: string; | ||
| 988 | command: string; | ||
| 989 | icon: string; | ||
| 990 | runOnWorktreeCreate: boolean; | ||
| 991 | }>; | ||
| 992 | }; | ||
| 993 | } | ||
| 994 | ).project.scripts; | ||
| 995 | expect(scriptPayload).toEqual([ | ||
| 996 | { | ||
| 997 | id: "setup", | ||
| 998 | name: "Setup", | ||
| 999 | command: "bun install", | ||
| 1000 | icon: "configure", | ||
| 1001 | runOnWorktreeCreate: true, | ||
| 1002 | }, | ||
| 1003 | ]); | ||
| 1004 | 917 | ||
| 1005 | const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { | 918 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 1006 | id: projectId, | 919 | id: "project-a", |
| 1007 | }); | 920 | }); |
| 1008 | expect(removed.error).toBeUndefined(); | 921 | expect(removeResponse.result).toBeUndefined(); |
| 1009 | 922 | expect(removeResponse.error?.message).toContain("Unknown method"); | |
| 1010 | const afterRemove = await sendRequest(ws, WS_METHODS.projectsList); | ||
| 1011 | expect(afterRemove.error).toBeUndefined(); | ||
| 1012 | expect(afterRemove.result).toEqual([]); | ||
| 1013 | }); | 923 | }); |
| 1014 | 924 | ||
| 1015 | it("supports projects.searchEntries", async () => { | 925 | it("supports projects.searchEntries", async () => { |
| ... | @@ -1079,7 +989,7 @@ describe("WebSocket Server", () => { | ... | @@ -1079,7 +989,7 @@ describe("WebSocket Server", () => { |
| 1079 | expect(pullResponse.error?.message).not.toContain("Unknown method"); | 989 | expect(pullResponse.error?.message).not.toContain("Unknown method"); |
| 1080 | }); | 990 | }); |
| 1081 | 991 | ||
| 1082 | it("responds to git.status via the git manager", async () => { | 992 | it("returns unknown method for git.status", async () => { |
| 1083 | const gitManager = { | 993 | const gitManager = { |
| 1084 | status: vi.fn().mockResolvedValue({ | 994 | status: vi.fn().mockResolvedValue({ |
| 1085 | branch: "feature/test", | 995 | branch: "feature/test", |
| ... | @@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => { | ... | @@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => { |
| 1109 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | 1019 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { |
| 1110 | cwd: "/test", | 1020 | cwd: "/test", |
| 1111 | }); | 1021 | }); |
| 1112 | expect(response.error).toBeUndefined(); | 1022 | expect(response.result).toBeUndefined(); |
| 1113 | expect(response.result).toEqual({ | 1023 | expect(response.error?.message).toContain("Unknown method"); |
| 1114 | branch: "feature/test", | 1024 | expect(gitManager.status).not.toHaveBeenCalled(); |
| 1115 | hasWorkingTreeChanges: true, | ||
| 1116 | workingTree: { | ||
| 1117 | files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], | ||
| 1118 | insertions: 7, | ||
| 1119 | deletions: 2, | ||
| 1120 | }, | ||
| 1121 | hasUpstream: false, | ||
| 1122 | aheadCount: 0, | ||
| 1123 | behindCount: 0, | ||
| 1124 | openPr: null, | ||
| 1125 | }); | ||
| 1126 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | ||
| 1127 | }); | 1025 | }); |
| 1128 | 1026 | ||
| 1129 | it("returns errors from git.runStackedAction", async () => { | 1027 | it("returns errors from git.runStackedAction", async () => { |
| ... | @@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => { | ... | @@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => { |
| 1153 | }); | 1051 | }); |
| 1154 | }); | 1052 | }); |
| 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 | it("rejects websocket connections without a valid auth token", async () => { | 1054 | it("rejects websocket connections without a valid auth token", async () => { |
| 1193 | server = createTestServer({ cwd: "/test", authToken: "secret-token" }); | 1055 | server = createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1194 | await server.start(); | 1056 | await server.start(); |
apps/server/src/wsServer.ts+161-197| ... | @@ -9,16 +9,15 @@ import { | ... | @@ -9,16 +9,15 @@ import { |
| 9 | EDITORS, | 9 | EDITORS, |
| 10 | ORCHESTRATION_WS_CHANNELS, | 10 | ORCHESTRATION_WS_CHANNELS, |
| 11 | ORCHESTRATION_WS_METHODS, | 11 | ORCHESTRATION_WS_METHODS, |
| 12 | TerminalEvent, | ||
| 12 | WS_CHANNELS, | 13 | WS_CHANNELS, |
| 13 | WS_METHODS, | 14 | WS_METHODS, |
| 14 | type TerminalEvent, | 15 | WebSocketRequest, |
| 15 | type WsPush, | 16 | WsPush, |
| 16 | type WsRequest, | 17 | WsResponse, |
| 17 | type WsResponse, | ||
| 18 | wsRequestSchema, | ||
| 19 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 20 | import { NodeServices } from "@effect/platform-node"; | 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 | import { WebSocketServer, type WebSocket } from "ws"; | 21 | import { WebSocketServer, type WebSocket } from "ws"; |
| 23 | 22 | ||
| 24 | import { createLogger } from "./logger"; | 23 | import { createLogger } from "./logger"; |
| ... | @@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager"; | ... | @@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager"; |
| 36 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 35 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 37 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 36 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 38 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | 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 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | 43 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; |
| 40 | import { | 44 | import { |
| 41 | OrchestrationEngineService, | 45 | OrchestrationEngineService, |
| 42 | type OrchestrationEngineShape, | 46 | type OrchestrationEngineShape, |
| 43 | } from "./orchestration/Services/OrchestrationEngine"; | 47 | } from "./orchestration/Services/OrchestrationEngine"; |
| 44 | import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion"; | 48 | import { |
| 45 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | 49 | ProjectionSnapshotQuery, |
| 50 | type ProjectionSnapshotQueryShape, | ||
| 51 | } from "./orchestration/Services/ProjectionSnapshotQuery"; | ||
| 52 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | ||
| 46 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; | 53 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; |
| 47 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; | ||
| 48 | import assert from "node:assert"; | 54 | import assert from "node:assert"; |
| 55 | import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs"; | ||
| 49 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | 56 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| 57 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | ||
| 50 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | 58 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 51 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | 59 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 52 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; | 60 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| 53 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; | 61 | import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; |
| 54 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 62 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 55 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 63 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 56 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; | 64 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; |
| 57 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; | 65 | import { |
| 58 | import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions"; | 66 | CheckpointDiffBlobRepository, |
| 67 | type CheckpointDiffBlobRepositoryShape, | ||
| 68 | } from "./persistence/Services/CheckpointDiffBlobs"; | ||
| 59 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | 69 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 60 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 70 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 61 | import * as SqlError from "effect/unstable/sql/SqlError"; | 71 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| 62 | import * as Migrator from "effect/unstable/sql/Migrator"; | 72 | import * as Migrator from "effect/unstable/sql/Migrator"; |
| 73 | import { clamp } from "effect/Number"; | ||
| 63 | 74 | ||
| 64 | const MIME_TYPES: Record<string, string> = { | 75 | const MIME_TYPES: Record<string, string> = { |
| 65 | ".html": "text/html; charset=utf-8", | 76 | ".html": "text/html; charset=utf-8", |
| ... | @@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { | ... | @@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 122 | } | 133 | } |
| 123 | 134 | ||
| 124 | type EffectRuntime = ManagedRuntime.ManagedRuntime< | 135 | type EffectRuntime = ManagedRuntime.ManagedRuntime< |
| 125 | | ProjectRepository | ||
| 126 | | OrchestrationEngineService | 136 | | OrchestrationEngineService |
| 137 | | ProjectionSnapshotQuery | ||
| 127 | | ProviderService | 138 | | ProviderService |
| 128 | | ProviderRuntimeIngestionService, | 139 | | CheckpointDiffBlobRepository |
| 140 | | OrchestrationReactor, | ||
| 129 | unknown | 141 | unknown |
| 130 | >; | 142 | >; |
| 131 | 143 | ||
| ... | @@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) { | ... | @@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) { |
| 147 | 159 | ||
| 148 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); | 160 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); |
| 149 | let effectRuntime: EffectRuntime | null = null; | 161 | let effectRuntime: EffectRuntime | null = null; |
| 150 | let projectRepository: ProjectRepositoryShape | null = null; | ||
| 151 | let orchestrationEngine: OrchestrationEngineShape | null = null; | 162 | let orchestrationEngine: OrchestrationEngineShape | null = null; |
| 163 | let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null; | ||
| 152 | let providerService: ProviderServiceShape | null = null; | 164 | let providerService: ProviderServiceShape | null = null; |
| 165 | let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null; | ||
| 153 | const clients = new Set<WebSocket>(); | 166 | const clients = new Set<WebSocket>(); |
| 154 | const logger = createLogger("ws"); | 167 | const logger = createLogger("ws"); |
| 155 | const logWebSocketEvents = | 168 | const logWebSocketEvents = |
| ... | @@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) { | ... | @@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) { |
| 173 | return orchestrationEngine; | 186 | return orchestrationEngine; |
| 174 | }; | 187 | }; |
| 175 | 188 | ||
| 176 | const getProjectRepository = () => { | ||
| 177 | assert(projectRepository, "Project repository is not started"); | ||
| 178 | return projectRepository; | ||
| 179 | }; | ||
| 180 | |||
| 181 | const getEffectRuntime = () => { | 189 | const getEffectRuntime = () => { |
| 182 | assert(effectRuntime, "Effect runtime is not started"); | 190 | assert(effectRuntime, "Effect runtime is not started"); |
| 183 | return effectRuntime; | 191 | return effectRuntime; |
| 184 | }; | 192 | }; |
| 185 | 193 | ||
| 186 | const getProviderService = () => { | 194 | const getProjectionSnapshotQuery = () => { |
| 187 | assert(providerService, "Provider service is not started"); | 195 | assert(projectionSnapshotQuery, "Projection snapshot query is not started"); |
| 188 | return providerService; | 196 | return projectionSnapshotQuery; |
| 197 | }; | ||
| 198 | |||
| 199 | const getCheckpointDiffBlobRepository = () => { | ||
| 200 | assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started"); | ||
| 201 | return checkpointDiffBlobRepository; | ||
| 189 | }; | 202 | }; |
| 190 | 203 | ||
| 191 | const attachSubscriptionss = Effect.gen(function* () { | 204 | const attachSubscriptionss = Effect.gen(function* () { |
| 192 | const orchestrationEngine = yield* OrchestrationEngineService; | 205 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 193 | const runtimeIngestion = yield* ProviderRuntimeIngestionService; | 206 | const orchestrationReactor = yield* OrchestrationReactor; |
| 194 | subscriptionsScope = yield* Scope.make("sequential"); | 207 | subscriptionsScope = yield* Scope.make("sequential"); |
| 195 | 208 | ||
| 196 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => | 209 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => |
| ... | @@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) { |
| 216 | }), | 229 | }), |
| 217 | ).pipe(Effect.forkIn(subscriptionsScope)); | 230 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 218 | 231 | ||
| 219 | yield* runtimeIngestion.start.pipe(Scope.provide(subscriptionsScope)); | 232 | yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope)); |
| 220 | }); | 233 | }); |
| 221 | 234 | ||
| 222 | const onTerminalEvent = (event: TerminalEvent) => { | 235 | const onTerminalEvent = (event: TerminalEvent) => { |
| ... | @@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) { | ... | @@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) { |
| 347 | }); | 360 | }); |
| 348 | 361 | ||
| 349 | async function handleMessage(ws: WebSocket, raw: unknown) { | 362 | async function handleMessage(ws: WebSocket, raw: unknown) { |
| 350 | let request: WsRequest; | 363 | let request: WebSocketRequest; |
| 351 | try { | 364 | try { |
| 352 | const parsed = JSON.parse(String(raw)); | 365 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw); |
| 353 | request = wsRequestSchema.parse(parsed); | ||
| 354 | } catch { | 366 | } catch { |
| 355 | const errorResponse: WsResponse = { | 367 | const errorResponse: WsResponse = { |
| 356 | id: "unknown", | 368 | id: "unknown", |
| ... | @@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) { | ... | @@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) { |
| 374 | } | 386 | } |
| 375 | } | 387 | } |
| 376 | 388 | ||
| 377 | async function routeRequest(request: WsRequest): Promise<unknown> { | 389 | async function routeRequest(request: WebSocketRequest): Promise<unknown> { |
| 378 | const orchestrationEngine = getOrchestrationEngine(); | 390 | const orchestrationEngine = getOrchestrationEngine(); |
| 379 | const projectRepository = getProjectRepository(); | 391 | const projectionReadModelQuery = getProjectionSnapshotQuery(); |
| 380 | const effectRuntime = getEffectRuntime(); | 392 | const effectRuntime = getEffectRuntime(); |
| 381 | const providerService = getProviderService(); | 393 | const diffBlobRepository = getCheckpointDiffBlobRepository(); |
| 382 | |||
| 383 | switch (request.method) { | ||
| 384 | case WS_METHODS.providersStartSession: | ||
| 385 | return effectRuntime.runPromise(providerService.startSession(request.params as never)); | ||
| 386 | |||
| 387 | case WS_METHODS.providersSendTurn: | ||
| 388 | return effectRuntime.runPromise(providerService.sendTurn(request.params as never)); | ||
| 389 | 394 | ||
| 390 | case WS_METHODS.providersInterruptTurn: | 395 | switch (request.body._tag) { |
| 391 | return effectRuntime.runPromise(providerService.interruptTurn(request.params as never)); | 396 | case ORCHESTRATION_WS_METHODS.getSnapshot: { |
| 392 | 397 | return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot()); | |
| 393 | case WS_METHODS.providersRespondToRequest: | ||
| 394 | return effectRuntime.runPromise(providerService.respondToRequest(request.params as never)); | ||
| 395 | |||
| 396 | case WS_METHODS.providersStopSession: { | ||
| 397 | await effectRuntime.runPromise(providerService.stopSession(request.params as never)); | ||
| 398 | return undefined; | ||
| 399 | } | 398 | } |
| 400 | 399 | ||
| 401 | case WS_METHODS.providersListCheckpoints: | 400 | case ORCHESTRATION_WS_METHODS.dispatchCommand: { |
| 402 | return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never)); | 401 | orchestrationCommandLogger?.write({ |
| 403 | 402 | observedAt: new Date().toISOString(), | |
| 404 | case WS_METHODS.providersGetCheckpointDiff: | 403 | command: request.body.command, |
| 405 | return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never)); | 404 | }); |
| 406 | 405 | return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command)); | |
| 407 | case WS_METHODS.providersRevertToCheckpoint: { | ||
| 408 | const result = await effectRuntime.runPromise( | ||
| 409 | providerService.revertToCheckpoint(request.params as never), | ||
| 410 | ); | ||
| 411 | const params = request.params as { sessionId?: string }; | ||
| 412 | const sessionId = params.sessionId; | ||
| 413 | if (typeof sessionId === "string") { | ||
| 414 | const readModel = await effectRuntime.runPromise(orchestrationEngine.getReadModel()); | ||
| 415 | const thread = readModel.threads.find((entry) => entry.session?.sessionId === sessionId); | ||
| 416 | if (thread) { | ||
| 417 | const now = new Date().toISOString(); | ||
| 418 | await effectRuntime.runPromise( | ||
| 419 | orchestrationEngine.dispatch({ | ||
| 420 | type: "thread.revert", | ||
| 421 | commandId: crypto.randomUUID(), | ||
| 422 | threadId: thread.id, | ||
| 423 | turnCount: result.turnCount, | ||
| 424 | messageCount: result.messageCount, | ||
| 425 | createdAt: now, | ||
| 426 | }), | ||
| 427 | ); | ||
| 428 | if (thread.session) { | ||
| 429 | await effectRuntime.runPromise( | ||
| 430 | orchestrationEngine.dispatch({ | ||
| 431 | type: "thread.session", | ||
| 432 | commandId: crypto.randomUUID(), | ||
| 433 | threadId: thread.id, | ||
| 434 | session: { | ||
| 435 | ...thread.session, | ||
| 436 | status: "ready", | ||
| 437 | activeTurnId: null, | ||
| 438 | updatedAt: now, | ||
| 439 | lastError: null, | ||
| 440 | }, | ||
| 441 | createdAt: now, | ||
| 442 | }), | ||
| 443 | ); | ||
| 444 | } | ||
| 445 | } | ||
| 446 | } | ||
| 447 | return result; | ||
| 448 | } | 406 | } |
| 449 | 407 | ||
| 450 | case WS_METHODS.projectsList: | 408 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { |
| 451 | return effectRuntime.runPromise(projectRepository.list()); | 409 | const blob = await effectRuntime.runPromise(diffBlobRepository.get(request.body)); |
| 410 | if (Option.isNone(blob)) { | ||
| 411 | throw new Error( | ||
| 412 | `Turn diff not found for thread '${request.body.threadId}' from ${request.body.fromTurnCount} to ${request.body.toTurnCount}.`, | ||
| 413 | ); | ||
| 414 | } | ||
| 452 | 415 | ||
| 453 | case WS_METHODS.projectsAdd: | 416 | return { |
| 454 | return effectRuntime.runPromise(projectRepository.add(request.params as never)); | 417 | threadId: request.body.threadId, |
| 418 | fromTurnCount: request.body.fromTurnCount, | ||
| 419 | toTurnCount: request.body.toTurnCount, | ||
| 420 | diff: blob.value?.diff, | ||
| 421 | }; | ||
| 422 | } | ||
| 455 | 423 | ||
| 456 | case WS_METHODS.projectsRemove: | 424 | case ORCHESTRATION_WS_METHODS.replayEvents: |
| 457 | return effectRuntime.runPromise(projectRepository.remove(request.params as never)); | 425 | return effectRuntime.runPromise( |
| 426 | Stream.runCollect( | ||
| 427 | orchestrationEngine.readEvents( | ||
| 428 | clamp(request.body.fromSequenceExclusive, { | ||
| 429 | maximum: Number.MAX_SAFE_INTEGER, | ||
| 430 | minimum: 0, | ||
| 431 | }), | ||
| 432 | ), | ||
| 433 | ).pipe(Effect.map((events) => Array.from(events))), | ||
| 434 | ); | ||
| 458 | 435 | ||
| 459 | case WS_METHODS.projectsSearchEntries: | 436 | case WS_METHODS.projectsSearchEntries: |
| 460 | return searchWorkspaceEntries(request.params as never); | 437 | return searchWorkspaceEntries(request.body); |
| 461 | case WS_METHODS.projectsUpdateScripts: | ||
| 462 | return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never)); | ||
| 463 | 438 | ||
| 464 | case WS_METHODS.shellOpenInEditor: { | 439 | case WS_METHODS.shellOpenInEditor: { |
| 465 | const params = request.params as { | 440 | const body = request.body; |
| 466 | cwd: string; | 441 | const editorDef = EDITORS.find((e) => e.id === body.editor); |
| 467 | editor: string; | 442 | if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`); |
| 468 | }; | ||
| 469 | if (!params?.cwd) throw new Error("cwd is required"); | ||
| 470 | const editorDef = EDITORS.find((e) => e.id === params.editor); | ||
| 471 | if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`); | ||
| 472 | 443 | ||
| 473 | let command: string; | 444 | let command: string; |
| 474 | let args: string[]; | 445 | let args: string[]; |
| 475 | 446 | ||
| 476 | if (editorDef.command) { | 447 | if (editorDef.command) { |
| 477 | command = editorDef.command; | 448 | command = editorDef.command; |
| 478 | args = [params.cwd]; | 449 | args = [body.cwd]; |
| 479 | } else if (editorDef.id === "file-manager") { | 450 | } else if (editorDef.id === "file-manager") { |
| 480 | // Use platform-specific file manager command | 451 | // Use platform-specific file manager command |
| 481 | switch (process.platform) { | 452 | switch (process.platform) { |
| ... | @@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) { |
| 489 | command = "xdg-open"; | 460 | command = "xdg-open"; |
| 490 | break; | 461 | break; |
| 491 | } | 462 | } |
| 492 | args = [params.cwd]; | 463 | args = [body.cwd]; |
| 493 | } else { | 464 | } else { |
| 494 | return undefined; | 465 | return undefined; |
| 495 | } | 466 | } |
| ... | @@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) { | ... | @@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) { |
| 505 | return undefined; | 476 | return undefined; |
| 506 | } | 477 | } |
| 507 | 478 | ||
| 508 | case WS_METHODS.gitStatus: { | 479 | // TODO: Bring back |
| 509 | const params = request.params as { cwd: string }; | 480 | // case WS_METHODS.gitStatus: { |
| 510 | const status = await gitManager.status(request.params as never); | 481 | // const params = request.params as { cwd: string }; |
| 511 | const projects = await effectRuntime.runPromise(projectRepository.list()); | 482 | // const status = await gitManager.status(request.params as never); |
| 512 | const project = projects.find((entry) => entry.cwd === params.cwd); | 483 | // const projects = await effectRuntime.runPromise(projectRepository.list()); |
| 513 | if (project) { | 484 | // const project = projects.find((entry) => entry.cwd === params.cwd); |
| 514 | await effectRuntime.runPromise( | 485 | // if (project) { |
| 515 | orchestrationEngine.dispatch({ | 486 | // await effectRuntime.runPromise( |
| 516 | type: "git.readModel.upsert", | 487 | // orchestrationEngine.dispatch({ |
| 517 | commandId: crypto.randomUUID(), | 488 | // type: "git.readModel.upsert", |
| 518 | projectId: project.id, | 489 | // commandId: crypto.randomUUID(), |
| 519 | branch: status.branch, | 490 | // projectId: project.id, |
| 520 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, | 491 | // branch: status.branch, |
| 521 | aheadCount: status.aheadCount, | 492 | // hasWorkingTreeChanges: status.hasWorkingTreeChanges, |
| 522 | behindCount: status.behindCount, | 493 | // aheadCount: status.aheadCount, |
| 523 | createdAt: new Date().toISOString(), | 494 | // behindCount: status.behindCount, |
| 524 | }), | 495 | // createdAt: new Date().toISOString(), |
| 525 | ); | 496 | // }), |
| 526 | } | 497 | // ); |
| 527 | return status; | 498 | // } |
| 528 | } | 499 | // return status; |
| 500 | // } | ||
| 529 | 501 | ||
| 530 | case WS_METHODS.gitPull: | 502 | case WS_METHODS.gitPull: |
| 531 | return pullGitBranch(request.params as never); | 503 | return pullGitBranch(request.body); |
| 532 | 504 | ||
| 533 | case WS_METHODS.gitRunStackedAction: | 505 | case WS_METHODS.gitRunStackedAction: |
| 534 | return gitManager.runStackedAction(request.params as never); | 506 | return gitManager.runStackedAction(request.body); |
| 535 | case WS_METHODS.gitListBranches: | 507 | case WS_METHODS.gitListBranches: |
| 536 | return listGitBranches(request.params as never); | 508 | return listGitBranches(request.body); |
| 537 | 509 | ||
| 538 | case WS_METHODS.gitCreateWorktree: | 510 | case WS_METHODS.gitCreateWorktree: |
| 539 | return createGitWorktree(request.params as never); | 511 | return createGitWorktree(request.body); |
| 540 | 512 | ||
| 541 | case WS_METHODS.gitRemoveWorktree: | 513 | case WS_METHODS.gitRemoveWorktree: |
| 542 | return removeGitWorktree(request.params as never); | 514 | return removeGitWorktree(request.body); |
| 543 | 515 | ||
| 544 | case WS_METHODS.gitCreateBranch: | 516 | case WS_METHODS.gitCreateBranch: |
| 545 | return createGitBranch(request.params as never); | 517 | return createGitBranch(request.body); |
| 546 | 518 | ||
| 547 | case WS_METHODS.gitCheckout: | 519 | case WS_METHODS.gitCheckout: |
| 548 | return checkoutGitBranch(request.params as never); | 520 | return checkoutGitBranch(request.body); |
| 549 | 521 | ||
| 550 | case WS_METHODS.gitInit: | 522 | case WS_METHODS.gitInit: |
| 551 | return initGitRepo(request.params as never); | 523 | return initGitRepo(request.body); |
| 552 | 524 | ||
| 553 | case WS_METHODS.terminalOpen: | 525 | case WS_METHODS.terminalOpen: |
| 554 | return terminalManager.open(request.params as never); | 526 | return terminalManager.open(request.body); |
| 555 | 527 | ||
| 556 | case WS_METHODS.terminalWrite: | 528 | case WS_METHODS.terminalWrite: |
| 557 | await terminalManager.write(request.params as never); | 529 | await terminalManager.write(request.body); |
| 558 | return undefined; | 530 | return undefined; |
| 559 | 531 | ||
| 560 | case WS_METHODS.terminalResize: | 532 | case WS_METHODS.terminalResize: |
| 561 | await terminalManager.resize(request.params as never); | 533 | await terminalManager.resize(request.body); |
| 562 | return undefined; | 534 | return undefined; |
| 563 | 535 | ||
| 564 | case WS_METHODS.terminalClear: | 536 | case WS_METHODS.terminalClear: |
| 565 | await terminalManager.clear(request.params as never); | 537 | await terminalManager.clear(request.body); |
| 566 | return undefined; | 538 | return undefined; |
| 567 | 539 | ||
| 568 | case WS_METHODS.terminalRestart: | 540 | case WS_METHODS.terminalRestart: |
| 569 | return terminalManager.restart(request.params as never); | 541 | return terminalManager.restart(request.body); |
| 570 | 542 | ||
| 571 | case WS_METHODS.terminalClose: | 543 | case WS_METHODS.terminalClose: |
| 572 | await terminalManager.close(request.params as never); | 544 | await terminalManager.close(request.body); |
| 573 | return undefined; | 545 | return undefined; |
| 574 | 546 | ||
| 575 | case WS_METHODS.serverGetConfig: | 547 | case WS_METHODS.serverGetConfig: |
| ... | @@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) { | ... | @@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) { |
| 579 | }; | 551 | }; |
| 580 | 552 | ||
| 581 | case WS_METHODS.serverUpsertKeybinding: | 553 | case WS_METHODS.serverUpsertKeybinding: |
| 582 | keybindingsConfig = upsertKeybindingRule(logger, request.params); | 554 | keybindingsConfig = upsertKeybindingRule(logger, request.body); |
| 583 | return { | 555 | return { |
| 584 | keybindings: keybindingsConfig, | 556 | keybindings: keybindingsConfig, |
| 585 | }; | 557 | }; |
| 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 | default: | 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,7 +572,12 @@ export function createServer(options: ServerOptions) { |
| 628 | path.join(orchestrationLogsDir, "orchestration-command.ndjson"), | 572 | path.join(orchestrationLogsDir, "orchestration-command.ndjson"), |
| 629 | ); | 573 | ); |
| 630 | const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); | 574 | const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); |
| 631 | const orchestrationLayer = Layer.provide(OrchestrationEngineLive, OrchestrationEventStoreLive); | 575 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 576 | Layer.provide(OrchestrationProjectionPipelineLive), | ||
| 577 | Layer.provide(OrchestrationEventStoreLive), | ||
| 578 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | ||
| 579 | ); | ||
| 580 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | ||
| 632 | const providerLayer = | 581 | const providerLayer = |
| 633 | customProviderLayer ?? | 582 | customProviderLayer ?? |
| 634 | (() => { | 583 | (() => { |
| ... | @@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) { | ... | @@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) { |
| 639 | Layer.provide(codexAdapterLayer), | 588 | Layer.provide(codexAdapterLayer), |
| 640 | ); | 589 | ); |
| 641 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 590 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 642 | Layer.provide(ProviderSessionRepositoryLive), | 591 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 643 | ); | ||
| 644 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | ||
| 645 | const checkpointDependenciesLayer = Layer.mergeAll( | ||
| 646 | checkpointStoreLayer, | ||
| 647 | CheckpointRepositoryLive, | ||
| 648 | adapterRegistryLayer, | ||
| 649 | providerSessionDirectoryLayer, | ||
| 650 | ); | ||
| 651 | const checkpointServiceLayer = CheckpointServiceLive.pipe( | ||
| 652 | Layer.provideMerge(checkpointDependenciesLayer), | ||
| 653 | ); | 592 | ); |
| 654 | return makeProviderServiceLive({ | 593 | return makeProviderServiceLive({ |
| 655 | canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), | 594 | canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), |
| 656 | }).pipe(Layer.provideMerge(checkpointServiceLayer)); | 595 | }).pipe( |
| 596 | Layer.provide(adapterRegistryLayer), | ||
| 597 | Layer.provide(providerSessionDirectoryLayer), | ||
| 598 | ); | ||
| 657 | })(); | 599 | })(); |
| 658 | 600 | ||
| 659 | const runtimeServicesLayer = Layer.mergeAll( | 601 | const runtimeServicesLayer = Layer.mergeAll( |
| 660 | orchestrationLayer, | 602 | orchestrationLayer, |
| 661 | ProjectRepositoryLive, | 603 | OrchestrationProjectionSnapshotQueryLive, |
| 604 | CheckpointDiffBlobRepositoryLive, | ||
| 605 | checkpointStoreLayer, | ||
| 662 | providerLayer, | 606 | providerLayer, |
| 663 | ); | 607 | ); |
| 664 | const layer = ProviderRuntimeIngestionLive.pipe( | 608 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 609 | Layer.provideMerge(runtimeServicesLayer), | ||
| 610 | ); | ||
| 611 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | ||
| 665 | Layer.provideMerge(runtimeServicesLayer), | 612 | Layer.provideMerge(runtimeServicesLayer), |
| 613 | ); | ||
| 614 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | ||
| 615 | Layer.provideMerge(runtimeServicesLayer), | ||
| 616 | ); | ||
| 617 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | ||
| 618 | Layer.provideMerge(runtimeIngestionLayer), | ||
| 619 | Layer.provideMerge(providerCommandReactorLayer), | ||
| 620 | Layer.provideMerge(checkpointReactorLayer), | ||
| 621 | ); | ||
| 622 | const layer = orchestrationReactorLayer.pipe( | ||
| 666 | Layer.provide(persistenceLayer), | 623 | Layer.provide(persistenceLayer), |
| 667 | Layer.provideMerge(NodeServices.layer), | 624 | Layer.provideMerge(NodeServices.layer), |
| 668 | ); | 625 | ); |
| 669 | const runtime = ManagedRuntime.make(layer); | 626 | const runtime = ManagedRuntime.make(layer); |
| 670 | 627 | ||
| 671 | try { | 628 | try { |
| 672 | const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([ | 629 | const [ |
| 630 | nextOrchestrationEngine, | ||
| 631 | nextProjectionSnapshotQuery, | ||
| 632 | nextCheckpointDiffBlobRepository, | ||
| 633 | nextProviderService, | ||
| 634 | ] = await Promise.all([ | ||
| 673 | runtime.runPromise(Effect.service(OrchestrationEngineService)), | 635 | runtime.runPromise(Effect.service(OrchestrationEngineService)), |
| 674 | runtime.runPromise(Effect.service(ProjectRepository)), | 636 | runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), |
| 637 | runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)), | ||
| 675 | runtime.runPromise(Effect.service(ProviderService)), | 638 | runtime.runPromise(Effect.service(ProviderService)), |
| 676 | ]); | 639 | ]); |
| 677 | orchestrationEngine = nextOrchestrationEngine; | 640 | orchestrationEngine = nextOrchestrationEngine; |
| 678 | projectRepository = repository; | 641 | projectionSnapshotQuery = nextProjectionSnapshotQuery; |
| 642 | checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository; | ||
| 679 | providerService = nextProviderService; | 643 | providerService = nextProviderService; |
| 680 | await runtime.runPromise(repository.pruneMissing()); | ||
| 681 | await runtime.runPromise(attachSubscriptionss); | 644 | await runtime.runPromise(attachSubscriptionss); |
| 682 | } catch (error) { | 645 | } catch (error) { |
| 683 | await runtime.dispose().catch(() => undefined); | 646 | await runtime.dispose().catch(() => undefined); |
| ... | @@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) { | ... | @@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) { |
| 701 | const liveProviderService = providerService; | 664 | const liveProviderService = providerService; |
| 702 | effectRuntime = null; | 665 | effectRuntime = null; |
| 703 | orchestrationEngine = null; | 666 | orchestrationEngine = null; |
| 704 | projectRepository = null; | 667 | projectionSnapshotQuery = null; |
| 668 | checkpointDiffBlobRepository = null; | ||
| 705 | providerService = null; | 669 | providerService = null; |
| 706 | 670 | ||
| 707 | if (!runtime) { | 671 | if (!runtime) { |
apps/server/tsconfig.json+2-1| ... | @@ -12,5 +12,6 @@ | ... | @@ -12,5 +12,6 @@ |
| 12 | } | 12 | } |
| 13 | ] | 13 | ] |
| 14 | }, | 14 | }, |
| 15 | "include": ["src", "integration", "tsdown.config.ts", "scripts"] | 15 | "include": ["src", "tsdown.config.ts", "scripts"], |
| 16 | "exclude": ["src/**/*.test.ts", "integration"] | ||
| 16 | } | 17 | } |
packages/contracts/package.json+1-2| ... | @@ -24,8 +24,7 @@ | ... | @@ -24,8 +24,7 @@ |
| 24 | "test": "vitest run" | 24 | "test": "vitest run" |
| 25 | }, | 25 | }, |
| 26 | "dependencies": { | 26 | "dependencies": { |
| 27 | "effect": "4.0.0-beta.10", | 27 | "effect": "4.0.0-beta.10" |
| 28 | "zod": "^3.24.1" | ||
| 29 | }, | 28 | }, |
| 30 | "devDependencies": { | 29 | "devDependencies": { |
| 31 | "@effect/language-service": "0.75.1", | 30 | "@effect/language-service": "0.75.1", |
packages/contracts/src/agent.ts deleted-27| ... | @@ -1,27 +0,0 @@ | ||
| 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,152 +1,159 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | // ── Existing branch/worktree Git API ───────────────────────────────── | 3 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 4 | 4 | ||
| 5 | export const gitListBranchesInputSchema = z.object({ | 5 | // Domain Types |
| 6 | cwd: z.string().min(1), | 6 | |
| 7 | }); | 7 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); |
| 8 | 8 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); | |
| 9 | export const gitCreateWorktreeInputSchema = z.object({ | 9 | export const GitPushStepStatus = Schema.Literals([ |
| 10 | cwd: z.string().min(1), | 10 | "pushed", |
| 11 | branch: z.string().min(1), | 11 | "skipped_not_requested", |
| 12 | newBranch: z.string().min(1), | 12 | "skipped_up_to_date", |
| 13 | path: z.string().min(1).optional(), | 13 | ]); |
| 14 | }); | 14 | export const GitPrStepStatus = Schema.Literals([ |
| 15 | 15 | "created", | |
| 16 | export const gitRemoveWorktreeInputSchema = z.object({ | 16 | "opened_existing", |
| 17 | cwd: z.string().min(1), | 17 | "skipped_not_requested", |
| 18 | path: z.string().min(1), | 18 | ]); |
| 19 | force: z.boolean().optional(), | 19 | |
| 20 | }); | 20 | export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({ |
| 21 | 21 | name: TrimmedNonEmptyString, | |
| 22 | export const gitCreateBranchInputSchema = z.object({ | 22 | current: Schema.Boolean, |
| 23 | cwd: z.string().min(1), | 23 | isDefault: Schema.Boolean, |
| 24 | branch: z.string().min(1), | 24 | worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 25 | }); | 25 | }) {} |
| 26 | 26 | ||
| 27 | export const gitCheckoutInputSchema = z.object({ | 27 | export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({ |
| 28 | cwd: z.string().min(1), | 28 | path: TrimmedNonEmptyString, |
| 29 | branch: z.string().min(1), | 29 | branch: TrimmedNonEmptyString, |
| 30 | }); | 30 | }) {} |
| 31 | 31 | ||
| 32 | export const gitInitInputSchema = z.object({ | 32 | // RPC Inputs |
| 33 | cwd: z.string().min(1), | 33 | |
| 34 | }); | 34 | export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({ |
| 35 | 35 | cwd: TrimmedNonEmptyString, | |
| 36 | export const gitBranchSchema = z.object({ | 36 | }) {} |
| 37 | name: z.string().min(1), | 37 | |
| 38 | current: z.boolean(), | 38 | export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({ |
| 39 | isDefault: z.boolean(), | 39 | cwd: TrimmedNonEmptyString, |
| 40 | worktreePath: z.string().min(1).nullable(), | 40 | }) {} |
| 41 | }); | 41 | |
| 42 | 42 | export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>( | |
| 43 | export const gitWorktreeSchema = z.object({ | 43 | "GitRunStackedActionInput", |
| 44 | path: z.string().min(1), | 44 | )({ |
| 45 | branch: z.string().min(1), | 45 | cwd: TrimmedNonEmptyString, |
| 46 | }); | 46 | action: GitStackedAction, |
| 47 | 47 | commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), | |
| 48 | export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>; | 48 | }) {} |
| 49 | export type GitBranch = z.infer<typeof gitBranchSchema>; | 49 | |
| 50 | export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>; | 50 | export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>( |
| 51 | export type GitWorktree = z.infer<typeof gitWorktreeSchema>; | 51 | "GitListBranchesInput", |
| 52 | export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>; | 52 | )({ |
| 53 | export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>; | 53 | cwd: TrimmedNonEmptyString, |
| 54 | export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>; | 54 | }) {} |
| 55 | export type GitInitInput = z.infer<typeof gitInitInputSchema>; | 55 | |
| 56 | 56 | export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>( | |
| 57 | export interface GitListBranchesResult { | 57 | "GitCreateWorktreeInput", |
| 58 | branches: GitBranch[]; | 58 | )({ |
| 59 | isRepo: boolean; | 59 | cwd: TrimmedNonEmptyString, |
| 60 | } | 60 | branch: TrimmedNonEmptyString, |
| 61 | 61 | newBranch: TrimmedNonEmptyString, | |
| 62 | export interface GitCreateWorktreeResult { | 62 | path: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 63 | worktree: GitWorktree; | 63 | }) {} |
| 64 | } | 64 | |
| 65 | 65 | export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>( | |
| 66 | // ── Stacked action Git API ─────────────────────────────────────────── | 66 | "GitRemoveWorktreeInput", |
| 67 | 67 | )({ | |
| 68 | export const gitStatusInputSchema = z.object({ | 68 | cwd: TrimmedNonEmptyString, |
| 69 | cwd: z.string().trim().min(1), | 69 | path: TrimmedNonEmptyString, |
| 70 | }); | 70 | force: Schema.Boolean.pipe(Schema.NullOr), |
| 71 | 71 | }) {} | |
| 72 | export const gitPullInputSchema = z.object({ | 72 | |
| 73 | cwd: z.string().trim().min(1), | 73 | export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>( |
| 74 | }); | 74 | "GitCreateBranchInput", |
| 75 | 75 | )({ | |
| 76 | export const gitStatusResultSchema = z.object({ | 76 | cwd: TrimmedNonEmptyString, |
| 77 | branch: z.string().min(1).nullable(), | 77 | branch: TrimmedNonEmptyString, |
| 78 | hasWorkingTreeChanges: z.boolean(), | 78 | }) {} |
| 79 | workingTree: z.object({ | 79 | |
| 80 | files: z.array( | 80 | export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({ |
| 81 | z.object({ | 81 | cwd: TrimmedNonEmptyString, |
| 82 | path: z.string().min(1), | 82 | branch: TrimmedNonEmptyString, |
| 83 | insertions: z.number().int().nonnegative(), | 83 | }) {} |
| 84 | deletions: z.number().int().nonnegative(), | 84 | |
| 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(), | 102 | insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), |
| 88 | deletions: z.number().int().nonnegative(), | 103 | deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), |
| 89 | }), | 104 | }), |
| 90 | hasUpstream: z.boolean(), | 105 | hasUpstream: Schema.Boolean, |
| 91 | aheadCount: z.number().int().nonnegative(), | 106 | aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), |
| 92 | behindCount: z.number().int().nonnegative(), | 107 | behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), |
| 93 | openPr: z | 108 | openPr: Schema.Struct({ |
| 94 | .object({ | 109 | number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), |
| 95 | number: z.number().int().positive(), | 110 | title: TrimmedNonEmptyString, |
| 96 | title: z.string().min(1), | 111 | url: Schema.String, |
| 97 | url: z.string().url(), | 112 | baseBranch: TrimmedNonEmptyString, |
| 98 | baseBranch: z.string().min(1), | 113 | headBranch: TrimmedNonEmptyString, |
| 99 | headBranch: z.string().min(1), | 114 | }).pipe(Schema.NullOr), |
| 100 | }) | 115 | }) {} |
| 101 | .nullable(), | 116 | |
| 102 | }); | 117 | export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>( |
| 103 | 118 | "GitListBranchesResult", | |
| 104 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); | 119 | )({ |
| 105 | 120 | branches: Schema.Array(GitBranch), | |
| 106 | const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); | 121 | isRepo: Schema.Boolean, |
| 107 | const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]); | 122 | }) {} |
| 108 | const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]); | 123 | |
| 109 | 124 | export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>( | |
| 110 | export const gitRunStackedActionInputSchema = z.object({ | 125 | "GitCreateWorktreeResult", |
| 111 | cwd: z.string().trim().min(1), | 126 | )({ |
| 112 | action: gitStackedActionSchema, | 127 | worktree: GitWorktree, |
| 113 | commitMessage: z.string().trim().min(1).max(10_000).optional(), | 128 | }) {} |
| 114 | }); | 129 | |
| 115 | 130 | export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>( | |
| 116 | export const gitRunStackedActionResultSchema = z.object({ | 131 | "GitRunStackedActionResult", |
| 117 | action: gitStackedActionSchema, | 132 | )({ |
| 118 | commit: z.object({ | 133 | action: GitStackedAction, |
| 119 | status: gitCommitStepStatusSchema, | 134 | commit: Schema.Struct({ |
| 120 | commitSha: z.string().min(1).optional(), | 135 | status: GitCommitStepStatus, |
| 121 | subject: z.string().min(1).optional(), | 136 | commitSha: Schema.optional(TrimmedNonEmptyString), |
| 137 | subject: Schema.optional(TrimmedNonEmptyString), | ||
| 122 | }), | 138 | }), |
| 123 | push: z.object({ | 139 | push: Schema.Struct({ |
| 124 | status: gitPushStepStatusSchema, | 140 | status: GitPushStepStatus, |
| 125 | branch: z.string().min(1).optional(), | 141 | branch: Schema.optional(TrimmedNonEmptyString), |
| 126 | upstreamBranch: z.string().min(1).optional(), | 142 | upstreamBranch: Schema.optional(TrimmedNonEmptyString), |
| 127 | setUpstream: z.boolean().optional(), | 143 | setUpstream: Schema.optional(Schema.Boolean), |
| 128 | }), | 144 | }), |
| 129 | pr: z.object({ | 145 | pr: Schema.Struct({ |
| 130 | status: gitPrStepStatusSchema, | 146 | status: GitPrStepStatus, |
| 131 | url: z.string().url().optional(), | 147 | url: Schema.optional(Schema.String), |
| 132 | number: z.number().int().positive().optional(), | 148 | number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), |
| 133 | baseBranch: z.string().min(1).optional(), | 149 | baseBranch: Schema.optional(TrimmedNonEmptyString), |
| 134 | headBranch: z.string().min(1).optional(), | 150 | headBranch: Schema.optional(TrimmedNonEmptyString), |
| 135 | title: z.string().min(1).optional(), | 151 | title: Schema.optional(TrimmedNonEmptyString), |
| 136 | }), | 152 | }), |
| 137 | }); | 153 | }) {} |
| 138 | 154 | ||
| 139 | export type GitStatusInput = z.input<typeof gitStatusInputSchema>; | 155 | export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({ |
| 140 | export type GitPullInput = z.input<typeof gitPullInputSchema>; | 156 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 141 | export type GitStatusResult = z.infer<typeof gitStatusResultSchema>; | 157 | branch: TrimmedNonEmptyString, |
| 142 | export type GitStackedAction = z.infer<typeof gitStackedActionSchema>; | 158 | upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 143 | export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>; | 159 | }) {} |
| 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>; |
packages/contracts/src/index.ts+2-3| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | export * from "./todo"; | ||
| 2 | export * from "./ipc"; | 1 | export * from "./ipc"; |
| 3 | export * from "./agent"; | ||
| 4 | export * from "./terminal"; | 2 | export * from "./terminal"; |
| 5 | export * from "./provider"; | 3 | export * from "./provider"; |
| 6 | export * from "./providerRuntime"; | 4 | export * from "./providerRuntime"; |
| ... | @@ -8,6 +6,7 @@ export * from "./model"; | ... | @@ -8,6 +6,7 @@ export * from "./model"; |
| 8 | export * from "./ws"; | 6 | export * from "./ws"; |
| 9 | export * from "./keybindings"; | 7 | export * from "./keybindings"; |
| 10 | export * from "./server"; | 8 | export * from "./server"; |
| 11 | export * from "./project"; | ||
| 12 | export * from "./git"; | 9 | export * from "./git"; |
| 13 | export * from "./orchestration"; | 10 | export * from "./orchestration"; |
| 11 | export * from "./editor"; | ||
| 12 | export * from "./project"; |
packages/contracts/src/ipc.ts+7-69| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | ||
| 2 | import type { | 1 | import type { |
| 3 | GitCheckoutInput, | 2 | GitCheckoutInput, |
| 4 | GitCreateBranchInput, | 3 | GitCreateBranchInput, |
| ... | @@ -15,31 +14,7 @@ import type { | ... | @@ -15,31 +14,7 @@ import type { |
| 15 | GitStatusInput, | 14 | GitStatusInput, |
| 16 | GitStatusResult, | 15 | GitStatusResult, |
| 17 | } from "./git"; | 16 | } from "./git"; |
| 18 | import type { | 17 | import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; |
| 19 | ProviderGetCheckpointDiffInput, | ||
| 20 | ProviderGetCheckpointDiffResult, | ||
| 21 | ProviderInterruptTurnInput, | ||
| 22 | ProviderListCheckpointsInput, | ||
| 23 | ProviderListCheckpointsResult, | ||
| 24 | ProviderRevertToCheckpointInput, | ||
| 25 | ProviderRevertToCheckpointResult, | ||
| 26 | ProviderRespondToRequestInput, | ||
| 27 | ProviderSendTurnInput, | ||
| 28 | ProviderSession, | ||
| 29 | ProviderSessionStartInput, | ||
| 30 | ProviderStopSessionInput, | ||
| 31 | ProviderTurnStartResult, | ||
| 32 | } from "./provider"; | ||
| 33 | import type { | ||
| 34 | ProjectAddInput, | ||
| 35 | ProjectAddResult, | ||
| 36 | ProjectListResult, | ||
| 37 | ProjectSearchEntriesInput, | ||
| 38 | ProjectSearchEntriesResult, | ||
| 39 | ProjectRemoveInput, | ||
| 40 | ProjectUpdateScriptsInput, | ||
| 41 | ProjectUpdateScriptsResult, | ||
| 42 | } from "./project"; | ||
| 43 | import type { ServerConfig } from "./server"; | 18 | import type { ServerConfig } from "./server"; |
| 44 | import type { | 19 | import type { |
| 45 | TerminalClearInput, | 20 | TerminalClearInput, |
| ... | @@ -50,28 +25,17 @@ import type { | ... | @@ -50,28 +25,17 @@ import type { |
| 50 | TerminalSessionSnapshot, | 25 | TerminalSessionSnapshot, |
| 51 | TerminalWriteInput, | 26 | TerminalWriteInput, |
| 52 | } from "./terminal"; | 27 | } from "./terminal"; |
| 53 | import type { NewTodoInput, Todo } from "./todo"; | ||
| 54 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | 28 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; |
| 55 | import type { | 29 | import type { |
| 56 | OrchestrationCommand, | 30 | ClientOrchestrationCommand, |
| 31 | OrchestrationGetTurnDiffInput, | ||
| 32 | OrchestrationGetTurnDiffResult, | ||
| 57 | OrchestrationEvent, | 33 | OrchestrationEvent, |
| 58 | OrchestrationReadModel, | 34 | OrchestrationReadModel, |
| 59 | } from "./orchestration"; | 35 | } from "./orchestration"; |
| 60 | 36 | import { EditorId } from "./editor"; | |
| 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"]; | ||
| 67 | 37 | ||
| 68 | export interface NativeApi { | 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 | dialogs: { | 39 | dialogs: { |
| 76 | pickFolder: () => Promise<string | null>; | 40 | pickFolder: () => Promise<string | null>; |
| 77 | confirm: (message: string) => Promise<boolean>; | 41 | confirm: (message: string) => Promise<boolean>; |
| ... | @@ -85,35 +49,8 @@ export interface NativeApi { | ... | @@ -85,35 +49,8 @@ export interface NativeApi { |
| 85 | close: (input: TerminalCloseInput) => Promise<void>; | 49 | close: (input: TerminalCloseInput) => Promise<void>; |
| 86 | onEvent: (callback: (event: TerminalEvent) => void) => () => void; | 50 | onEvent: (callback: (event: TerminalEvent) => void) => () => void; |
| 87 | }; | 51 | }; |
| 88 | agent: { | ||
| 89 | spawn: (config: AgentConfig) => Promise<string>; | ||
| 90 | kill: (sessionId: string) => Promise<void>; | ||
| 91 | write: (sessionId: string, data: string) => Promise<void>; | ||
| 92 | onOutput: (callback: (chunk: OutputChunk) => void) => () => void; | ||
| 93 | onExit: (callback: (exit: AgentExit) => void) => () => void; | ||
| 94 | }; | ||
| 95 | providers: { | ||
| 96 | startSession: (input: ProviderSessionStartInput) => Promise<ProviderSession>; | ||
| 97 | sendTurn: (input: ProviderSendTurnInput) => Promise<ProviderTurnStartResult>; | ||
| 98 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | ||
| 99 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | ||
| 100 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | ||
| 101 | listCheckpoints: ( | ||
| 102 | input: ProviderListCheckpointsInput, | ||
| 103 | ) => Promise<ProviderListCheckpointsResult>; | ||
| 104 | getCheckpointDiff: ( | ||
| 105 | input: ProviderGetCheckpointDiffInput, | ||
| 106 | ) => Promise<ProviderGetCheckpointDiffResult>; | ||
| 107 | revertToCheckpoint: ( | ||
| 108 | input: ProviderRevertToCheckpointInput, | ||
| 109 | ) => Promise<ProviderRevertToCheckpointResult>; | ||
| 110 | }; | ||
| 111 | projects: { | 52 | projects: { |
| 112 | list: () => Promise<ProjectListResult>; | ||
| 113 | add: (input: ProjectAddInput) => Promise<ProjectAddResult>; | ||
| 114 | remove: (input: ProjectRemoveInput) => Promise<void>; | ||
| 115 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | 53 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| 116 | updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>; | ||
| 117 | }; | 54 | }; |
| 118 | shell: { | 55 | shell: { |
| 119 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; | 56 | openInEditor: (cwd: string, editor: EditorId) => Promise<void>; |
| ... | @@ -144,7 +81,8 @@ export interface NativeApi { | ... | @@ -144,7 +81,8 @@ export interface NativeApi { |
| 144 | }; | 81 | }; |
| 145 | orchestration: { | 82 | orchestration: { |
| 146 | getSnapshot: () => Promise<OrchestrationReadModel>; | 83 | getSnapshot: () => Promise<OrchestrationReadModel>; |
| 147 | dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>; | 84 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
| 85 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; | ||
| 148 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; | 86 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; |
| 149 | onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; | 87 | onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; |
| 150 | }; | 88 | }; |
packages/contracts/src/keybindings.test.ts+22-13| ... | @@ -1,27 +1,36 @@ | ... | @@ -1,27 +1,36 @@ |
| 1 | import { Schema } from "effect"; | ||
| 1 | import { assert, describe, it } from "vitest"; | 2 | import { assert, describe, it } from "vitest"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | keybindingRuleSchema, | 5 | KeybindingsConfig, |
| 5 | keybindingsConfigSchema, | 6 | KeybindingRule, |
| 6 | resolvedKeybindingRuleSchema, | 7 | ResolvedKeybindingRule, |
| 7 | resolvedKeybindingsConfigSchema, | 8 | ResolvedKeybindingsConfig, |
| 8 | } from "./keybindings"; | 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 | describe("keybindings contracts", () => { | 19 | describe("keybindings contracts", () => { |
| 11 | it("parses keybinding rules", () => { | 20 | it("parses keybinding rules", () => { |
| 12 | const parsed = keybindingRuleSchema.parse({ | 21 | const parsed = decode(KeybindingRule, { |
| 13 | key: "mod+j", | 22 | key: "mod+j", |
| 14 | command: "terminal.toggle", | 23 | command: "terminal.toggle", |
| 15 | }); | 24 | }); |
| 16 | assert.strictEqual(parsed.command, "terminal.toggle"); | 25 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 17 | 26 | ||
| 18 | const parsedClose = keybindingRuleSchema.parse({ | 27 | const parsedClose = decode(KeybindingRule, { |
| 19 | key: "mod+w", | 28 | key: "mod+w", |
| 20 | command: "terminal.close", | 29 | command: "terminal.close", |
| 21 | }); | 30 | }); |
| 22 | assert.strictEqual(parsedClose.command, "terminal.close"); | 31 | assert.strictEqual(parsedClose.command, "terminal.close"); |
| 23 | 32 | ||
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | 33 | const parsedLocal = decode(KeybindingRule, { |
| 25 | key: "mod+shift+n", | 34 | key: "mod+shift+n", |
| 26 | command: "chat.newLocal", | 35 | command: "chat.newLocal", |
| 27 | }); | 36 | }); |
| ... | @@ -30,7 +39,7 @@ describe("keybindings contracts", () => { | ... | @@ -30,7 +39,7 @@ describe("keybindings contracts", () => { |
| 30 | 39 | ||
| 31 | it("rejects invalid command values", () => { | 40 | it("rejects invalid command values", () => { |
| 32 | assert.throws(() => | 41 | assert.throws(() => |
| 33 | keybindingRuleSchema.parse({ | 42 | decode(KeybindingRule, { |
| 34 | key: "mod+j", | 43 | key: "mod+j", |
| 35 | command: "script.Test.run", | 44 | command: "script.Test.run", |
| 36 | }), | 45 | }), |
| ... | @@ -38,7 +47,7 @@ describe("keybindings contracts", () => { | ... | @@ -38,7 +47,7 @@ describe("keybindings contracts", () => { |
| 38 | }); | 47 | }); |
| 39 | 48 | ||
| 40 | it("accepts dynamic script run commands", () => { | 49 | it("accepts dynamic script run commands", () => { |
| 41 | const parsed = keybindingRuleSchema.parse({ | 50 | const parsed = decode(KeybindingRule, { |
| 42 | key: "mod+r", | 51 | key: "mod+r", |
| 43 | command: "script.setup.run", | 52 | command: "script.setup.run", |
| 44 | }); | 53 | }); |
| ... | @@ -46,7 +55,7 @@ describe("keybindings contracts", () => { | ... | @@ -46,7 +55,7 @@ describe("keybindings contracts", () => { |
| 46 | }); | 55 | }); |
| 47 | 56 | ||
| 48 | it("parses keybindings array payload", () => { | 57 | it("parses keybindings array payload", () => { |
| 49 | const parsed = keybindingsConfigSchema.parse([ | 58 | const parsed = decode(KeybindingsConfig, [ |
| 50 | { key: "mod+j", command: "terminal.toggle" }, | 59 | { key: "mod+j", command: "terminal.toggle" }, |
| 51 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 60 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 52 | ]); | 61 | ]); |
| ... | @@ -54,7 +63,7 @@ describe("keybindings contracts", () => { | ... | @@ -54,7 +63,7 @@ describe("keybindings contracts", () => { |
| 54 | }); | 63 | }); |
| 55 | 64 | ||
| 56 | it("parses resolved keybinding rules", () => { | 65 | it("parses resolved keybinding rules", () => { |
| 57 | const parsed = resolvedKeybindingRuleSchema.parse({ | 66 | const parsed = decode(ResolvedKeybindingRule, { |
| 58 | command: "terminal.split", | 67 | command: "terminal.split", |
| 59 | shortcut: { | 68 | shortcut: { |
| 60 | key: "d", | 69 | key: "d", |
| ... | @@ -77,7 +86,7 @@ describe("keybindings contracts", () => { | ... | @@ -77,7 +86,7 @@ describe("keybindings contracts", () => { |
| 77 | }); | 86 | }); |
| 78 | 87 | ||
| 79 | it("parses resolved keybindings arrays", () => { | 88 | it("parses resolved keybindings arrays", () => { |
| 80 | const parsed = resolvedKeybindingsConfigSchema.parse([ | 89 | const parsed = decode(ResolvedKeybindingsConfig, [ |
| 81 | { | 90 | { |
| 82 | command: "terminal.toggle", | 91 | command: "terminal.toggle", |
| 83 | shortcut: { | 92 | shortcut: { |
| ... | @@ -95,7 +104,7 @@ describe("keybindings contracts", () => { | ... | @@ -95,7 +104,7 @@ describe("keybindings contracts", () => { |
| 95 | 104 | ||
| 96 | it("rejects unknown fields in resolved keybinding rules", () => { | 105 | it("rejects unknown fields in resolved keybinding rules", () => { |
| 97 | assert.throws(() => | 106 | assert.throws(() => |
| 98 | resolvedKeybindingRuleSchema.parse({ | 107 | decodeResolvedRuleStrict({ |
| 99 | command: "terminal.toggle", | 108 | command: "terminal.toggle", |
| 100 | shortcut: { | 109 | shortcut: { |
| 101 | key: "j", | 110 | key: "j", |
packages/contracts/src/keybindings.ts+58-65| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | export const STATIC_KEYBINDING_COMMANDS = [ | 3 | export const STATIC_KEYBINDING_COMMANDS = [ |
| 4 | "terminal.toggle", | 4 | "terminal.toggle", |
| ... | @@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 10 | "editor.openFavorite", | 10 | "editor.openFavorite", |
| 11 | ] as const; | 11 | ] as const; |
| 12 | 12 | ||
| 13 | export const keybindingCommandSchema = z.union([ | 13 | export const KeybindingCommand = Schema.Union([ |
| 14 | z.enum(STATIC_KEYBINDING_COMMANDS), | 14 | Schema.Literals(STATIC_KEYBINDING_COMMANDS), |
| 15 | z | 15 | Schema.NonEmptyString.check( |
| 16 | .string() | 16 | Schema.isMaxLength(96), |
| 17 | .trim() | 17 | Schema.isPattern(/^script\.[a-z0-9][a-z0-9-]*\.run$/), |
| 18 | .min(1) | 18 | ), |
| 19 | .max(96) | ||
| 20 | .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), | ||
| 21 | ]); | 19 | ]); |
| 20 | export type KeybindingCommand = typeof KeybindingCommand.Type; | ||
| 22 | 21 | ||
| 23 | const keybindingValueSchema = z.string().trim().min(1).max(64); | 22 | export const KeybindingValue = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(64)); |
| 24 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | 23 | export type KeybindingValue = typeof KeybindingValue.Type; |
| 25 | 24 | ||
| 26 | export const keybindingRuleSchema = z.object({ | 25 | export const KeybindingWhen = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(256)); |
| 27 | key: keybindingValueSchema, | 26 | export type KeybindingWhen = typeof KeybindingWhen.Type; |
| 28 | command: keybindingCommandSchema, | 27 | export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({ |
| 29 | when: keybindingWhenSchema.optional(), | 28 | key: KeybindingValue, |
| 30 | }); | 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 | 36 | export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({ |
| 35 | .object({ | 37 | key: KeybindingValue, |
| 36 | key: z.string().trim().min(1).max(32), | 38 | metaKey: Schema.Boolean, |
| 37 | metaKey: z.boolean(), | 39 | ctrlKey: Schema.Boolean, |
| 38 | ctrlKey: z.boolean(), | 40 | shiftKey: Schema.Boolean, |
| 39 | shiftKey: z.boolean(), | 41 | altKey: Schema.Boolean, |
| 40 | altKey: z.boolean(), | 42 | modKey: Schema.Boolean, |
| 41 | modKey: z.boolean(), | 43 | }) {} |
| 42 | }) | ||
| 43 | .strict(); | ||
| 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 | export type KeybindingWhenNode = | 65 | export type KeybindingWhenNode = |
| 46 | | { type: "identifier"; name: string } | 66 | | { type: "identifier"; name: string } |
| 47 | | { type: "not"; node: KeybindingWhenNode } | 67 | | { type: "not"; node: KeybindingWhenNode } |
| 48 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } | 68 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } |
| 49 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; | 69 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; |
| 50 | 70 | ||
| 51 | export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() => | 71 | export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>( |
| 52 | z.union([ | 72 | "ResolvedKeybindingRule", |
| 53 | z.object({ | 73 | )({ |
| 54 | type: z.literal("identifier"), | 74 | command: KeybindingCommand, |
| 55 | name: z.string().min(1), | 75 | shortcut: KeybindingShortcut, |
| 56 | }), | 76 | whenAst: Schema.optional(KeybindingWhenNode), |
| 57 | z.object({ | 77 | }) {} |
| 58 | type: z.literal("not"), | ||
| 59 | node: keybindingWhenNodeSchema, | ||
| 60 | }), | ||
| 61 | z.object({ | ||
| 62 | type: z.literal("and"), | ||
| 63 | left: keybindingWhenNodeSchema, | ||
| 64 | right: keybindingWhenNodeSchema, | ||
| 65 | }), | ||
| 66 | z.object({ | ||
| 67 | type: z.literal("or"), | ||
| 68 | left: keybindingWhenNodeSchema, | ||
| 69 | right: keybindingWhenNodeSchema, | ||
| 70 | }), | ||
| 71 | ]), | ||
| 72 | ); | ||
| 73 | |||
| 74 | export const resolvedKeybindingRuleSchema = z | ||
| 75 | .object({ | ||
| 76 | command: keybindingCommandSchema, | ||
| 77 | shortcut: keybindingShortcutSchema, | ||
| 78 | whenAst: keybindingWhenNodeSchema.optional(), | ||
| 79 | }) | ||
| 80 | .strict(); | ||
| 81 | 78 | ||
| 82 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); | 79 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( |
| 83 | 80 | Schema.isMaxLength(256), | |
| 84 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | 81 | ); |
| 85 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | 82 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
| 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>; |
packages/contracts/src/orchestration.ts+798-177| ... | @@ -3,6 +3,7 @@ import { Schema } from "effect"; | ... | @@ -3,6 +3,7 @@ import { Schema } from "effect"; |
| 3 | export const ORCHESTRATION_WS_METHODS = { | 3 | export const ORCHESTRATION_WS_METHODS = { |
| 4 | getSnapshot: "orchestration.getSnapshot", | 4 | getSnapshot: "orchestration.getSnapshot", |
| 5 | dispatchCommand: "orchestration.dispatchCommand", | 5 | dispatchCommand: "orchestration.dispatchCommand", |
| 6 | getTurnDiff: "orchestration.getTurnDiff", | ||
| 6 | replayEvents: "orchestration.replayEvents", | 7 | replayEvents: "orchestration.replayEvents", |
| 7 | } as const; | 8 | } as const; |
| 8 | 9 | ||
| ... | @@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = { | ... | @@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = { |
| 10 | domainEvent: "orchestration.domainEvent", | 11 | domainEvent: "orchestration.domainEvent", |
| 11 | } as const; | 12 | } as const; |
| 12 | 13 | ||
| 13 | const IsoDateTimeSchema = Schema.String; | 14 | export const IsoDateTime = Schema.String; |
| 14 | const IdSchema = 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({ | 68 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 17 | id: IdSchema, | 69 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; |
| 18 | role: Schema.Literals(["user", "assistant"]), | 70 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 19 | text: Schema.String, | 71 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; |
| 20 | createdAt: IsoDateTimeSchema, | ||
| 21 | completedAt: Schema.optional(IsoDateTimeSchema), | ||
| 22 | streaming: Schema.Boolean, | ||
| 23 | }); | ||
| 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({ | 77 | export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check( |
| 28 | sessionId: IdSchema, | 78 | Schema.isGreaterThanOrEqualTo(0), |
| 29 | status: Schema.Literals(["connecting", "ready", "running", "error", "closed"]), | 79 | ); |
| 30 | provider: Schema.Literals(["codex", "claudeCode"]), | 80 | |
| 31 | threadId: IdSchema, | 81 | const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 32 | activeTurnId: Schema.NullOr(Schema.String), | 82 | |
| 33 | createdAt: IsoDateTimeSchema, | 83 | export const ChatImageAttachment = Schema.Struct({ |
| 34 | updatedAt: IsoDateTimeSchema, | 84 | type: Schema.Literal("image"), |
| 35 | lastError: Schema.NullOr(Schema.String), | 85 | name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)), |
| 86 | mimeType: TrimmedNonEmptyStringSchema.check( | ||
| 87 | Schema.isMaxLength(100), | ||
| 88 | Schema.isPattern(/^image\//i), | ||
| 89 | ), | ||
| 90 | sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), | ||
| 91 | dataUrl: TrimmedNonEmptyStringSchema.check( | ||
| 92 | Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | ||
| 93 | ), | ||
| 36 | }); | 94 | }); |
| 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({ | 118 | export const OrchestrationProject = Schema.Struct({ |
| 41 | path: Schema.String, | 119 | id: ProjectId, |
| 42 | kind: Schema.optional(Schema.String), | 120 | title: Schema.String, |
| 43 | additions: Schema.optional(Schema.Number), | 121 | workspaceRoot: Schema.String, |
| 44 | deletions: Schema.optional(Schema.Number), | 122 | defaultModel: Schema.NullOr(Schema.String), |
| 123 | scripts: Schema.Array(ProjectScript), | ||
| 124 | createdAt: IsoDateTime, | ||
| 125 | updatedAt: IsoDateTime, | ||
| 126 | deletedAt: Schema.NullOr(IsoDateTime), | ||
| 45 | }); | 127 | }); |
| 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({ | 133 | export const OrchestrationMessage = Schema.Struct({ |
| 50 | turnId: IdSchema, | 134 | id: MessageId, |
| 51 | completedAt: IsoDateTimeSchema, | 135 | role: OrchestrationMessageRole, |
| 52 | status: Schema.optional(Schema.String), | 136 | text: Schema.String, |
| 53 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | 137 | attachments: Schema.optional(Schema.Array(ChatAttachment)), |
| 54 | assistantMessageId: Schema.optional(Schema.String), | 138 | turnId: Schema.NullOr(TurnId), |
| 55 | checkpointTurnCount: Schema.optional(Schema.Number), | 139 | streaming: Schema.Boolean, |
| 140 | createdAt: IsoDateTime, | ||
| 141 | updatedAt: IsoDateTime, | ||
| 142 | }); | ||
| 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< | 168 | export const OrchestrationCheckpointFile = Schema.Struct({ |
| 59 | typeof OrchestrationTurnDiffSummarySchema | 169 | path: Schema.String, |
| 60 | >; | 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([ | 190 | export const OrchestrationThreadActivityTone = Schema.Literals([ |
| 63 | "thinking", | ||
| 64 | "tool", | ||
| 65 | "info", | 191 | "info", |
| 192 | "tool", | ||
| 193 | "approval", | ||
| 66 | "error", | 194 | "error", |
| 67 | ]); | 195 | ]); |
| 196 | export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; | ||
| 68 | 197 | ||
| 69 | export type OrchestrationThreadActivityTone = Schema.Schema.Type< | 198 | export const OrchestrationThreadActivity = Schema.Struct({ |
| 70 | typeof OrchestrationThreadActivityToneSchema | 199 | id: EventId, |
| 71 | >; | 200 | tone: OrchestrationThreadActivityTone, |
| 72 | 201 | kind: Schema.String, | |
| 73 | export const OrchestrationThreadActivitySchema = Schema.Struct({ | 202 | summary: Schema.String, |
| 74 | id: IdSchema, | 203 | payload: Schema.Unknown, |
| 75 | createdAt: IsoDateTimeSchema, | 204 | turnId: Schema.NullOr(TurnId), |
| 76 | label: Schema.String, | 205 | createdAt: IsoDateTime, |
| 77 | detail: Schema.optional(Schema.String), | ||
| 78 | tone: OrchestrationThreadActivityToneSchema, | ||
| 79 | turnId: Schema.optional(Schema.String), | ||
| 80 | requestId: Schema.optional(Schema.String), | ||
| 81 | requestKind: Schema.optional(Schema.Literals(["command", "file-change"])), | ||
| 82 | }); | 206 | }); |
| 207 | export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; | ||
| 83 | 208 | ||
| 84 | export type OrchestrationThreadActivity = Schema.Schema.Type<typeof OrchestrationThreadActivitySchema>; | 209 | export const OrchestrationThread = Schema.Struct({ |
| 85 | 210 | id: ThreadId, | |
| 86 | export const OrchestrationThreadSchema = Schema.Struct({ | 211 | projectId: ProjectId, |
| 87 | id: IdSchema, | ||
| 88 | projectId: IdSchema, | ||
| 89 | title: Schema.String, | 212 | title: Schema.String, |
| 90 | model: Schema.String, | 213 | model: Schema.String, |
| 91 | branch: Schema.NullOr(Schema.String), | 214 | branch: Schema.NullOr(Schema.String), |
| 92 | worktreePath: Schema.NullOr(Schema.String), | 215 | worktreePath: Schema.NullOr(Schema.String), |
| 93 | createdAt: IsoDateTimeSchema, | 216 | latestTurnId: Schema.NullOr(TurnId), |
| 94 | updatedAt: IsoDateTimeSchema, | 217 | createdAt: IsoDateTime, |
| 95 | latestTurnId: Schema.NullOr(Schema.String), | 218 | updatedAt: IsoDateTime, |
| 96 | latestTurnStartedAt: Schema.NullOr(Schema.String), | 219 | deletedAt: Schema.NullOr(IsoDateTime), |
| 97 | latestTurnCompletedAt: Schema.NullOr(Schema.String), | 220 | messages: Schema.Array(OrchestrationMessage), |
| 98 | latestTurnDurationMs: Schema.NullOr(Schema.Number), | 221 | activities: Schema.Array(OrchestrationThreadActivity), |
| 99 | messages: Schema.Array(OrchestrationMessageSchema), | 222 | checkpoints: Schema.Array(OrchestrationCheckpointSummary), |
| 100 | session: Schema.NullOr(OrchestrationSessionSchema), | 223 | session: Schema.NullOr(OrchestrationSession), |
| 101 | turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), | 224 | }); |
| 102 | activities: Schema.Array(OrchestrationThreadActivitySchema), | 225 | export type OrchestrationThread = typeof OrchestrationThread.Type; |
| 103 | error: Schema.NullOr(Schema.String), | 226 | |
| 104 | }); | 227 | export const OrchestrationReadModel = Schema.Struct({ |
| 105 | 228 | snapshotSequence: NonNegativeInt, | |
| 106 | export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>; | 229 | projects: Schema.Array(OrchestrationProject), |
| 107 | 230 | threads: Schema.Array(OrchestrationThread), | |
| 108 | export const OrchestrationGitReadModelSchema = Schema.Struct({ | 231 | updatedAt: IsoDateTime, |
| 109 | projectId: IdSchema, | ||
| 110 | branch: Schema.NullOr(Schema.String), | ||
| 111 | hasWorkingTreeChanges: Schema.Boolean, | ||
| 112 | aheadCount: Schema.Number, | ||
| 113 | behindCount: Schema.Number, | ||
| 114 | updatedAt: IsoDateTimeSchema, | ||
| 115 | }); | 232 | }); |
| 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({ | 245 | export const ProjectMetaUpdateCommand = Schema.Struct({ |
| 120 | sequence: Schema.Number, | 246 | type: Schema.Literal("project.meta.update"), |
| 121 | threads: Schema.Array(OrchestrationThreadSchema), | 247 | commandId: CommandId, |
| 122 | gitByProjectId: Schema.Record( Schema.String, OrchestrationGitReadModelSchema), | 248 | projectId: ProjectId, |
| 123 | updatedAt: IsoDateTimeSchema, | 249 | title: Schema.optional(Schema.String), |
| 250 | workspaceRoot: Schema.optional(Schema.String), | ||
| 251 | defaultModel: Schema.optional(Schema.String), | ||
| 252 | scripts: Schema.optional(Schema.Array(ProjectScript)), | ||
| 124 | }); | 253 | }); |
| 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 | type: Schema.Literal("thread.create"), | 262 | type: Schema.Literal("thread.create"), |
| 130 | commandId: IdSchema, | 263 | commandId: CommandId, |
| 131 | threadId: IdSchema, | 264 | threadId: ThreadId, |
| 132 | projectId: IdSchema, | 265 | projectId: ProjectId, |
| 133 | title: Schema.String, | 266 | title: Schema.String, |
| 134 | model: Schema.String, | 267 | model: Schema.String, |
| 135 | branch: Schema.NullOr(Schema.String), | 268 | branch: Schema.NullOr(Schema.String), |
| 136 | worktreePath: Schema.NullOr(Schema.String), | 269 | worktreePath: Schema.NullOr(Schema.String), |
| 137 | createdAt: IsoDateTimeSchema, | 270 | createdAt: IsoDateTime, |
| 138 | }); | 271 | }); |
| 139 | 272 | ||
| 140 | export const DeleteThreadCommandSchema = Schema.Struct({ | 273 | export const ThreadDeleteCommand = Schema.Struct({ |
| 141 | type: Schema.Literal("thread.delete"), | 274 | type: Schema.Literal("thread.delete"), |
| 142 | commandId: IdSchema, | 275 | commandId: CommandId, |
| 143 | threadId: IdSchema, | 276 | threadId: ThreadId, |
| 144 | createdAt: IsoDateTimeSchema, | ||
| 145 | }); | 277 | }); |
| 146 | 278 | ||
| 147 | export const UpdateThreadMetaCommandSchema = Schema.Struct({ | 279 | export const ThreadMetaUpdateCommand = Schema.Struct({ |
| 148 | type: Schema.Literal("thread.meta.update"), | 280 | type: Schema.Literal("thread.meta.update"), |
| 149 | commandId: IdSchema, | 281 | commandId: CommandId, |
| 150 | threadId: IdSchema, | 282 | threadId: ThreadId, |
| 151 | title: Schema.optional(Schema.String), | 283 | title: Schema.optional(Schema.String), |
| 152 | model: Schema.optional(Schema.String), | 284 | model: Schema.optional(Schema.String), |
| 153 | branch: Schema.optional(Schema.NullOr(Schema.String)), | 285 | branch: Schema.optional(Schema.NullOr(Schema.String)), |
| 154 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), | 286 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), |
| 155 | createdAt: IsoDateTimeSchema, | ||
| 156 | }); | 287 | }); |
| 157 | 288 | ||
| 158 | export const SendMessageCommandSchema = Schema.Struct({ | 289 | export const ThreadTurnStartCommand = Schema.Struct({ |
| 159 | type: Schema.Literal("message.send"), | 290 | type: Schema.Literal("thread.turn.start"), |
| 160 | commandId: IdSchema, | 291 | commandId: CommandId, |
| 161 | threadId: IdSchema, | 292 | threadId: ThreadId, |
| 162 | messageId: IdSchema, | 293 | message: Schema.Struct({ |
| 163 | role: Schema.Literals(["user", "assistant"]), | 294 | messageId: MessageId, |
| 164 | text: Schema.String, | 295 | role: Schema.Literal("user"), |
| 165 | streaming: Schema.optional(Schema.Boolean), | 296 | text: Schema.String, |
| 166 | createdAt: IsoDateTimeSchema, | 297 | attachments: Schema.Array(ChatAttachment), |
| 298 | }), | ||
| 299 | model: Schema.optional(Schema.String), | ||
| 300 | effort: Schema.optional(Schema.String), | ||
| 301 | createdAt: IsoDateTime, | ||
| 167 | }); | 302 | }); |
| 168 | 303 | ||
| 169 | export const SetThreadSessionSchema = Schema.Struct({ | 304 | export const ThreadTurnInterruptCommand = Schema.Struct({ |
| 170 | type: Schema.Literal("thread.session"), | 305 | type: Schema.Literal("thread.turn.interrupt"), |
| 171 | commandId: IdSchema, | 306 | commandId: CommandId, |
| 172 | threadId: IdSchema, | 307 | threadId: ThreadId, |
| 173 | session: OrchestrationSessionSchema, | 308 | turnId: Schema.optional(TurnId), |
| 174 | createdAt: IsoDateTimeSchema, | 309 | createdAt: IsoDateTime, |
| 175 | }); | 310 | }); |
| 176 | 311 | ||
| 177 | export const UpsertGitReadModelCommandSchema = Schema.Struct({ | 312 | export const ThreadApprovalRespondCommand = Schema.Struct({ |
| 178 | type: Schema.Literal("git.readModel.upsert"), | 313 | type: Schema.Literal("thread.approval.respond"), |
| 179 | commandId: IdSchema, | 314 | commandId: CommandId, |
| 180 | projectId: IdSchema, | 315 | threadId: ThreadId, |
| 181 | branch: Schema.NullOr(Schema.String), | 316 | requestId: ApprovalRequestId, |
| 182 | hasWorkingTreeChanges: Schema.Boolean, | 317 | decision: ProviderApprovalDecision, |
| 183 | aheadCount: Schema.Number, | 318 | createdAt: IsoDateTime, |
| 184 | behindCount: Schema.Number, | 319 | }); |
| 185 | createdAt: IsoDateTimeSchema, | 320 | |
| 186 | }); | 321 | export const ThreadCheckpointRevertCommand = Schema.Struct({ |
| 187 | 322 | type: Schema.Literal("thread.checkpoint.revert"), | |
| 188 | export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({ | 323 | commandId: CommandId, |
| 189 | type: Schema.Literal("thread.turnDiff.complete"), | 324 | threadId: ThreadId, |
| 190 | commandId: IdSchema, | 325 | turnCount: NonNegativeInt, |
| 191 | threadId: IdSchema, | 326 | createdAt: IsoDateTime, |
| 192 | turnId: IdSchema, | 327 | }); |
| 193 | completedAt: IsoDateTimeSchema, | 328 | |
| 194 | status: Schema.optional(Schema.String), | 329 | export const ThreadSessionStopCommand = Schema.Struct({ |
| 195 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | 330 | type: Schema.Literal("thread.session.stop"), |
| 196 | assistantMessageId: Schema.optional(Schema.String), | 331 | commandId: CommandId, |
| 197 | checkpointTurnCount: Schema.optional(Schema.Number), | 332 | threadId: ThreadId, |
| 198 | createdAt: IsoDateTimeSchema, | 333 | createdAt: IsoDateTime, |
| 199 | }); | 334 | }); |
| 200 | 335 | ||
| 201 | export const RevertThreadCommandSchema = Schema.Struct({ | 336 | export const ClientOrchestrationCommand = Schema.Union([ |
| 202 | type: Schema.Literal("thread.revert"), | 337 | ProjectCreateCommand, |
| 203 | commandId: IdSchema, | 338 | ProjectMetaUpdateCommand, |
| 204 | threadId: IdSchema, | 339 | ProjectDeleteCommand, |
| 205 | turnCount: Schema.Number, | 340 | ThreadCreateCommand, |
| 206 | messageCount: Schema.Number, | 341 | ThreadDeleteCommand, |
| 207 | createdAt: IsoDateTimeSchema, | 342 | ThreadMetaUpdateCommand, |
| 208 | }); | 343 | ThreadTurnStartCommand, |
| 209 | 344 | ThreadTurnInterruptCommand, | |
| 210 | export const AppendThreadActivityCommandSchema = Schema.Struct({ | 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 | type: Schema.Literal("thread.activity.append"), | 393 | type: Schema.Literal("thread.activity.append"), |
| 212 | commandId: IdSchema, | 394 | commandId: CommandId, |
| 213 | threadId: IdSchema, | 395 | threadId: ThreadId, |
| 214 | activity: OrchestrationThreadActivitySchema, | 396 | activity: OrchestrationThreadActivity, |
| 215 | createdAt: IsoDateTimeSchema, | 397 | createdAt: IsoDateTime, |
| 216 | }); | 398 | }); |
| 217 | |||
| 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 | ); | ||
| 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({ | 408 | export const InternalOrchestrationCommand = Schema.Union([ |
| 233 | sequence: Schema.Number, | 409 | ThreadSessionSetCommand, |
| 234 | eventId: IdSchema, | 410 | ThreadMessageAssistantDeltaCommand, |
| 235 | type: Schema.String, | 411 | ThreadMessageAssistantCompleteCommand, |
| 236 | aggregateType: Schema.String, | 412 | ThreadTurnDiffCompleteCommand, |
| 237 | aggregateId: Schema.String, | 413 | ThreadActivityAppendCommand, |
| 238 | occurredAt: IsoDateTimeSchema, | 414 | ThreadRevertCompleteCommand, |
| 239 | commandId: Schema.NullOr(Schema.String), | 415 | ]); |
| 240 | payload: Schema.Unknown, | 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,134 +1,30 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 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 | }); | ||
| 54 | 2 | ||
| 55 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | 3 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 56 | 4 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); | |
| 57 | export const projectSearchEntriesInputSchema = z.object({ | 5 | |
| 58 | cwd: z.string().trim().min(1), | 6 | export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>( |
| 59 | query: z.string().trim().max(256).default(""), | 7 | "ProjectSearchEntriesInput", |
| 60 | limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), | 8 | )({ |
| 61 | }); | 9 | cwd: TrimmedNonEmptyString, |
| 62 | 10 | query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), | |
| 63 | export const projectEntryKindSchema = z.enum(["file", "directory"]); | 11 | limit: Schema.Int.check( |
| 64 | 12 | Schema.isGreaterThanOrEqualTo(1), | |
| 65 | export const projectEntrySchema = z.object({ | 13 | Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), |
| 66 | path: z.string().min(1), | 14 | ), |
| 67 | kind: projectEntryKindSchema, | 15 | }) {} |
| 68 | parentPath: z.string().optional(), | 16 | |
| 69 | }); | 17 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); |
| 70 | 18 | ||
| 71 | export const projectSearchEntriesResultSchema = z.object({ | 19 | export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({ |
| 72 | entries: z.array(projectEntrySchema), | 20 | path: TrimmedNonEmptyString, |
| 73 | truncated: z.boolean(), | 21 | kind: ProjectEntryKind, |
| 74 | }); | 22 | parentPath: Schema.optional(TrimmedNonEmptyString), |
| 75 | 23 | }) {} | |
| 76 | export const projectUpdateScriptsInputSchema = z.object({ | 24 | |
| 77 | id: z.string().min(1), | 25 | export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>( |
| 78 | scripts: projectScriptsSchema, | 26 | "ProjectSearchEntriesResult", |
| 79 | }); | 27 | )({ |
| 80 | 28 | entries: Schema.Array(ProjectEntry), | |
| 81 | export const projectUpdateScriptsResultSchema = z.object({ | 29 | truncated: Schema.Boolean, |
| 82 | project: projectRecordSchema, | 30 | }) {} |
| 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>; |
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,221 +1,169 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | export const providerKindSchema = z.enum(["codex", "claudeCode"]); | 3 | import { |
| 4 | 4 | ApprovalRequestId, | |
| 5 | export const providerApprovalPolicySchema = z.enum([ | 5 | ChatAttachment, |
| 6 | "untrusted", | 6 | EventId, |
| 7 | "on-failure", | 7 | IsoDateTime, |
| 8 | "on-request", | 8 | NonNegativeInt, |
| 9 | "never", | 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | ]); | 10 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 11 | 11 | ProviderApprovalDecision, | |
| 12 | export const providerSandboxModeSchema = z.enum([ | 12 | ProviderApprovalPolicy, |
| 13 | "read-only", | 13 | ProviderKind, |
| 14 | "workspace-write", | 14 | ProviderItemId, |
| 15 | "danger-full-access", | 15 | ProviderRequestKind, |
| 16 | ]); | 16 | ProviderSandboxMode, |
| 17 | 17 | ProviderSessionId, | |
| 18 | export const providerRequestKindSchema = z.enum(["command", "file-change"]); | 18 | ProviderThreadId, |
| 19 | 19 | ProviderTurnId, | |
| 20 | export const providerApprovalDecisionSchema = z.enum([ | 20 | ThreadId, |
| 21 | "accept", | 21 | TurnCountRange, |
| 22 | "acceptForSession", | 22 | } from "./orchestration"; |
| 23 | "decline", | 23 | |
| 24 | "cancel", | 24 | const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 25 | ]); | 25 | |
| 26 | 26 | export const ProviderSessionStatus = Schema.Literals([ | |
| 27 | export const providerSessionStatusSchema = z.enum([ | ||
| 28 | "connecting", | 27 | "connecting", |
| 29 | "ready", | 28 | "ready", |
| 30 | "running", | 29 | "running", |
| 31 | "error", | 30 | "error", |
| 32 | "closed", | 31 | "closed", |
| 33 | ]); | 32 | ]); |
| 34 | 33 | export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; | |
| 35 | export const providerSessionSchema = z.object({ | 34 | |
| 36 | sessionId: z.string().min(1), | 35 | export const ProviderSession = Schema.Struct({ |
| 37 | provider: providerKindSchema, | 36 | sessionId: ProviderSessionId, |
| 38 | status: providerSessionStatusSchema, | 37 | provider: ProviderKind, |
| 39 | cwd: z.string().min(1).optional(), | 38 | status: ProviderSessionStatus, |
| 40 | model: z.string().min(1).optional(), | 39 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 41 | threadId: z.string().min(1).optional(), | 40 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 42 | activeTurnId: z.string().min(1).optional(), | 41 | threadId: Schema.optional(ProviderThreadId), |
| 43 | createdAt: z.string().datetime(), | 42 | activeTurnId: Schema.optional(ProviderTurnId), |
| 44 | updatedAt: z.string().datetime(), | 43 | createdAt: IsoDateTime, |
| 45 | lastError: z.string().min(1).optional(), | 44 | updatedAt: IsoDateTime, |
| 45 | lastError: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 46 | }); | 46 | }); |
| 47 | 47 | export type ProviderSession = typeof ProviderSession.Type; | |
| 48 | export const providerSessionStartInputSchema = z.object({ | 48 | |
| 49 | provider: providerKindSchema.default("codex"), | 49 | export const ProviderSessionStartInput = Schema.Struct({ |
| 50 | cwd: z.string().min(1).optional(), | 50 | provider: Schema.optional(ProviderKind), |
| 51 | model: z.string().trim().min(1).optional(), | 51 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 52 | resumeThreadId: z.string().trim().min(1).optional(), | 52 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 53 | codexBinaryPath: z.string().trim().min(1).optional(), | 53 | resumeThreadId: Schema.optional(ProviderThreadId), |
| 54 | codexHomePath: z.string().trim().min(1).optional(), | 54 | codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), |
| 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), | 55 | codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), |
| 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), | 56 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 57 | sandboxMode: Schema.optional(ProviderSandboxMode), | ||
| 57 | }); | 58 | }); |
| 58 | 59 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; | |
| 59 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | 60 | |
| 60 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | 61 | export const ProviderSendTurnInput = Schema.Struct({ |
| 61 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | 62 | sessionId: ProviderSessionId, |
| 62 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | 63 | input: Schema.optional( |
| 63 | 64 | TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), | |
| 64 | export const providerSendTurnImageAttachmentSchema = z.object({ | 65 | ), |
| 65 | type: z.literal("image"), | 66 | attachments: Schema.optional( |
| 66 | name: z.string().trim().min(1).max(255), | 67 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), |
| 67 | mimeType: z | 68 | ), |
| 68 | .string() | 69 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 69 | .trim() | 70 | effort: Schema.optional(TrimmedNonEmptyStringSchema), |
| 70 | .min(1) | ||
| 71 | .max(100) | ||
| 72 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), | ||
| 73 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | ||
| 74 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | ||
| 75 | }); | 71 | }); |
| 72 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; | ||
| 76 | 73 | ||
| 77 | export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ | 74 | export const ProviderTurnStartResult = Schema.Struct({ |
| 78 | providerSendTurnImageAttachmentSchema, | 75 | threadId: ProviderThreadId, |
| 79 | ]); | 76 | turnId: ProviderTurnId, |
| 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), | ||
| 105 | }); | 77 | }); |
| 78 | export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; | ||
| 106 | 79 | ||
| 107 | export const providerInterruptTurnInputSchema = z.object({ | 80 | export const ProviderInterruptTurnInput = Schema.Struct({ |
| 108 | sessionId: z.string().min(1), | 81 | sessionId: ProviderSessionId, |
| 109 | turnId: z.string().min(1).optional(), | 82 | turnId: Schema.optional(ProviderTurnId), |
| 110 | }); | 83 | }); |
| 84 | export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; | ||
| 111 | 85 | ||
| 112 | export const providerStopSessionInputSchema = z.object({ | 86 | export const ProviderStopSessionInput = Schema.Struct({ |
| 113 | sessionId: z.string().min(1), | 87 | sessionId: ProviderSessionId, |
| 114 | }); | 88 | }); |
| 89 | export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; | ||
| 115 | 90 | ||
| 116 | export const providerListCheckpointsInputSchema = z.object({ | 91 | export const ProviderListCheckpointsInput = Schema.Struct({ |
| 117 | sessionId: z.string().min(1), | 92 | sessionId: ProviderSessionId, |
| 118 | }); | 93 | }); |
| 119 | 94 | export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; | |
| 120 | export const providerCheckpointSchema = z.object({ | 95 | |
| 121 | id: z.string().min(1), | 96 | export const ProviderCheckpoint = Schema.Struct({ |
| 122 | turnCount: z.number().int().min(0), | 97 | id: TrimmedNonEmptyStringSchema, |
| 123 | messageCount: z.number().int().min(0), | 98 | turnCount: NonNegativeInt, |
| 124 | label: z.string().min(1), | 99 | messageCount: NonNegativeInt, |
| 125 | preview: z.string().min(1).optional(), | 100 | label: TrimmedNonEmptyStringSchema, |
| 126 | isCurrent: z.boolean(), | 101 | preview: Schema.optional(TrimmedNonEmptyStringSchema), |
| 102 | isCurrent: Schema.Boolean, | ||
| 127 | }); | 103 | }); |
| 104 | export type ProviderCheckpoint = typeof ProviderCheckpoint.Type; | ||
| 128 | 105 | ||
| 129 | export const providerListCheckpointsResultSchema = z.object({ | 106 | export const ProviderListCheckpointsResult = Schema.Struct({ |
| 130 | threadId: z.string().min(1), | 107 | threadId: ThreadId, |
| 131 | checkpoints: z.array(providerCheckpointSchema), | 108 | checkpoints: Schema.Array(ProviderCheckpoint), |
| 132 | }); | 109 | }); |
| 110 | export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; | ||
| 133 | 111 | ||
| 134 | export const providerRevertToCheckpointInputSchema = z.object({ | 112 | export const ProviderRevertToCheckpointInput = Schema.Struct({ |
| 135 | sessionId: z.string().min(1), | 113 | sessionId: ProviderSessionId, |
| 136 | turnCount: z.number().int().min(0), | 114 | turnCount: NonNegativeInt, |
| 137 | }); | 115 | }); |
| 138 | 116 | export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; | |
| 139 | export const providerRevertToCheckpointResultSchema = z.object({ | 117 | |
| 140 | threadId: z.string().min(1), | 118 | export const ProviderRevertToCheckpointResult = Schema.Struct({ |
| 141 | turnCount: z.number().int().min(0), | 119 | threadId: ThreadId, |
| 142 | messageCount: z.number().int().min(0), | 120 | turnCount: NonNegativeInt, |
| 143 | rolledBackTurns: z.number().int().min(0), | 121 | messageCount: NonNegativeInt, |
| 144 | checkpoints: z.array(providerCheckpointSchema), | 122 | rolledBackTurns: NonNegativeInt, |
| 123 | checkpoints: Schema.Array(ProviderCheckpoint), | ||
| 145 | }); | 124 | }); |
| 125 | export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; | ||
| 146 | 126 | ||
| 147 | export const providerGetCheckpointDiffInputSchema = z | 127 | export const ProviderGetCheckpointDiffInput = Schema.Struct({ |
| 148 | .object({ | 128 | sessionId: ProviderSessionId, |
| 149 | sessionId: z.string().min(1), | 129 | ...TurnCountRange.fields, |
| 150 | fromTurnCount: z.number().int().min(0), | ||
| 151 | toTurnCount: z.number().int().min(0), | ||
| 152 | }) | ||
| 153 | .superRefine((value, ctx) => { | ||
| 154 | if (value.fromTurnCount > value.toTurnCount) { | ||
| 155 | ctx.addIssue({ | ||
| 156 | code: z.ZodIssueCode.custom, | ||
| 157 | message: "fromTurnCount must be less than or equal to toTurnCount", | ||
| 158 | path: ["fromTurnCount"], | ||
| 159 | }); | ||
| 160 | } | ||
| 161 | }); | ||
| 162 | |||
| 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(), | ||
| 168 | }); | 130 | }); |
| 131 | export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; | ||
| 169 | 132 | ||
| 170 | export const providerRespondToRequestInputSchema = z.object({ | 133 | export const ProviderGetCheckpointDiffResult = Schema.Struct({ |
| 171 | sessionId: z.string().min(1), | 134 | threadId: ThreadId, |
| 172 | requestId: z.string().min(1), | 135 | ...TurnCountRange.fields, |
| 173 | decision: providerApprovalDecisionSchema, | 136 | diff: Schema.String, |
| 174 | }); | 137 | }); |
| 138 | export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; | ||
| 175 | 139 | ||
| 176 | export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); | 140 | export const ProviderRespondToRequestInput = Schema.Struct({ |
| 177 | 141 | sessionId: ProviderSessionId, | |
| 178 | export const providerEventSchema = z.object({ | 142 | requestId: ApprovalRequestId, |
| 179 | id: z.string().min(1), | 143 | decision: ProviderApprovalDecision, |
| 180 | kind: providerEventKindSchema, | 144 | }); |
| 181 | provider: providerKindSchema, | 145 | export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; |
| 182 | sessionId: z.string().min(1), | 146 | |
| 183 | createdAt: z.string().datetime(), | 147 | export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); |
| 184 | method: z.string().min(1), | 148 | export type ProviderEventKind = typeof ProviderEventKind.Type; |
| 185 | message: z.string().min(1).optional(), | 149 | |
| 186 | threadId: z.string().min(1).optional(), | 150 | export const ProviderEvent = Schema.Struct({ |
| 187 | turnId: z.string().min(1).optional(), | 151 | id: EventId, |
| 188 | itemId: z.string().min(1).optional(), | 152 | kind: ProviderEventKind, |
| 189 | requestId: z.string().min(1).optional(), | 153 | provider: ProviderKind, |
| 190 | requestKind: providerRequestKindSchema.optional(), | 154 | sessionId: ProviderSessionId, |
| 191 | textDelta: z.string().optional(), | 155 | createdAt: IsoDateTime, |
| 192 | payload: z.unknown().optional(), | 156 | method: TrimmedNonEmptyStringSchema, |
| 157 | message: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 158 | threadId: Schema.optional(ProviderThreadId), | ||
| 159 | turnId: Schema.optional(ProviderTurnId), | ||
| 160 | itemId: Schema.optional(ProviderItemId), | ||
| 161 | requestId: Schema.optional(ApprovalRequestId), | ||
| 162 | requestKind: Schema.optional(ProviderRequestKind), | ||
| 163 | textDelta: Schema.optional(Schema.String), | ||
| 164 | payload: Schema.optional(Schema.Unknown), | ||
| 193 | }); | 165 | }); |
| 166 | export type ProviderEvent = typeof ProviderEvent.Type; | ||
| 194 | 167 | ||
| 195 | export type ProviderKind = z.infer<typeof providerKindSchema>; | 168 | export type ProviderSendTurnAttachment = typeof ChatAttachment.Type; |
| 196 | export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>; | 169 | export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type; |
| 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>; |
packages/contracts/src/providerRuntime.ts+160-173| ... | @@ -1,232 +1,219 @@ | ... | @@ -1,232 +1,219 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | const IsoDateTimeSchema = Schema.String; | 3 | import { |
| 4 | const IdSchema = Schema.String; | 4 | ApprovalRequestId, |
| 5 | 5 | EventId, | |
| 6 | export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]); | 6 | IsoDateTime, |
| 7 | export type ProviderRuntimeProvider = Schema.Schema.Type<typeof ProviderRuntimeProviderSchema>; | 7 | NonNegativeInt, |
| 8 | 8 | ProviderApprovalDecision, | |
| 9 | export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]); | 9 | ProviderItemId, |
| 10 | export type ProviderRuntimeApprovalKind = Schema.Schema.Type< | 10 | ProviderKind, |
| 11 | typeof ProviderRuntimeApprovalKindSchema | 11 | ProviderRequestKind, |
| 12 | >; | 12 | ProviderSessionId, |
| 13 | 13 | ProviderThreadId, | |
| 14 | export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]); | 14 | ProviderTurnId, |
| 15 | export type ProviderRuntimeToolKind = Schema.Schema.Type<typeof ProviderRuntimeToolKindSchema>; | 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 | "completed", | 29 | "completed", |
| 19 | "failed", | 30 | "failed", |
| 20 | "cancelled", | 31 | "cancelled", |
| 21 | "interrupted", | 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 | type: Schema.Literal("session.started"), | 37 | type: Schema.Literal("session.started"), |
| 27 | eventId: IdSchema, | 38 | eventId: EventId, |
| 28 | provider: ProviderRuntimeProviderSchema, | 39 | provider: ProviderKind, |
| 29 | sessionId: IdSchema, | 40 | sessionId: ProviderSessionId, |
| 30 | createdAt: IsoDateTimeSchema, | 41 | createdAt: IsoDateTime, |
| 31 | threadId: Schema.optional(Schema.String), | 42 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 32 | message: Schema.optional(Schema.String), | 43 | message: Schema.optional(Schema.String), |
| 33 | }); | 44 | }); |
| 45 | export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; | ||
| 34 | 46 | ||
| 35 | export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({ | 47 | export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ |
| 36 | type: Schema.Literal("session.exited"), | 48 | type: Schema.Literal("session.exited"), |
| 37 | eventId: IdSchema, | 49 | eventId: EventId, |
| 38 | provider: ProviderRuntimeProviderSchema, | 50 | provider: ProviderKind, |
| 39 | sessionId: IdSchema, | 51 | sessionId: ProviderSessionId, |
| 40 | createdAt: IsoDateTimeSchema, | 52 | createdAt: IsoDateTime, |
| 41 | threadId: Schema.optional(Schema.String), | 53 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 42 | message: Schema.optional(Schema.String), | 54 | message: Schema.optional(Schema.String), |
| 43 | }); | 55 | }); |
| 56 | export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; | ||
| 44 | 57 | ||
| 45 | export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({ | 58 | export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ |
| 46 | type: Schema.Literal("thread.started"), | 59 | type: Schema.Literal("thread.started"), |
| 47 | eventId: IdSchema, | 60 | eventId: EventId, |
| 48 | provider: ProviderRuntimeProviderSchema, | 61 | provider: ProviderKind, |
| 49 | sessionId: IdSchema, | 62 | sessionId: ProviderSessionId, |
| 50 | createdAt: IsoDateTimeSchema, | 63 | createdAt: IsoDateTime, |
| 51 | threadId: IdSchema, | 64 | threadId: RuntimeThreadIdSchema, |
| 52 | }); | 65 | }); |
| 66 | export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; | ||
| 53 | 67 | ||
| 54 | export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({ | 68 | export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ |
| 55 | type: Schema.Literal("turn.started"), | 69 | type: Schema.Literal("turn.started"), |
| 56 | eventId: IdSchema, | 70 | eventId: EventId, |
| 57 | provider: ProviderRuntimeProviderSchema, | 71 | provider: ProviderKind, |
| 58 | sessionId: IdSchema, | 72 | sessionId: ProviderSessionId, |
| 59 | createdAt: IsoDateTimeSchema, | 73 | createdAt: IsoDateTime, |
| 60 | threadId: Schema.optional(Schema.String), | 74 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 61 | turnId: IdSchema, | 75 | turnId: RuntimeTurnIdSchema, |
| 62 | }); | 76 | }); |
| 77 | export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; | ||
| 63 | 78 | ||
| 64 | export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({ | 79 | export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ |
| 65 | type: Schema.Literal("turn.completed"), | 80 | type: Schema.Literal("turn.completed"), |
| 66 | eventId: IdSchema, | 81 | eventId: EventId, |
| 67 | provider: ProviderRuntimeProviderSchema, | 82 | provider: ProviderKind, |
| 68 | sessionId: IdSchema, | 83 | sessionId: ProviderSessionId, |
| 69 | createdAt: IsoDateTimeSchema, | 84 | createdAt: IsoDateTime, |
| 70 | threadId: Schema.optional(Schema.String), | 85 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 71 | turnId: Schema.optional(Schema.String), | 86 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 72 | status: Schema.optional(ProviderRuntimeTurnStatusSchema), | 87 | status: Schema.optional(ProviderRuntimeTurnStatus), |
| 73 | errorMessage: Schema.optional(Schema.String), | 88 | errorMessage: Schema.optional(Schema.String), |
| 74 | }); | 89 | }); |
| 90 | export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; | ||
| 75 | 91 | ||
| 76 | export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({ | 92 | export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({ |
| 77 | type: Schema.Literal("message.delta"), | 93 | type: Schema.Literal("message.delta"), |
| 78 | eventId: IdSchema, | 94 | eventId: EventId, |
| 79 | provider: ProviderRuntimeProviderSchema, | 95 | provider: ProviderKind, |
| 80 | sessionId: IdSchema, | 96 | sessionId: ProviderSessionId, |
| 81 | createdAt: IsoDateTimeSchema, | 97 | createdAt: IsoDateTime, |
| 82 | threadId: Schema.optional(Schema.String), | 98 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 83 | turnId: Schema.optional(Schema.String), | 99 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 84 | itemId: Schema.optional(Schema.String), | 100 | itemId: Schema.optional(ProviderItemId), |
| 85 | delta: Schema.String, | 101 | delta: Schema.String, |
| 86 | }); | 102 | }); |
| 103 | export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type; | ||
| 87 | 104 | ||
| 88 | export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({ | 105 | export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({ |
| 89 | type: Schema.Literal("message.completed"), | 106 | type: Schema.Literal("message.completed"), |
| 90 | eventId: IdSchema, | 107 | eventId: EventId, |
| 91 | provider: ProviderRuntimeProviderSchema, | 108 | provider: ProviderKind, |
| 92 | sessionId: IdSchema, | 109 | sessionId: ProviderSessionId, |
| 93 | createdAt: IsoDateTimeSchema, | 110 | createdAt: IsoDateTime, |
| 94 | itemId: IdSchema, | 111 | itemId: ProviderItemId, |
| 95 | threadId: Schema.optional(Schema.String), | 112 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 96 | turnId: Schema.optional(Schema.String), | 113 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 97 | }); | 114 | }); |
| 115 | export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type; | ||
| 98 | 116 | ||
| 99 | export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ | 117 | export const ProviderRuntimeToolStartedEvent = Schema.Struct({ |
| 100 | type: Schema.Literal("tool.started"), | 118 | type: Schema.Literal("tool.started"), |
| 101 | eventId: IdSchema, | 119 | eventId: EventId, |
| 102 | provider: ProviderRuntimeProviderSchema, | 120 | provider: ProviderKind, |
| 103 | sessionId: IdSchema, | 121 | sessionId: ProviderSessionId, |
| 104 | createdAt: IsoDateTimeSchema, | 122 | createdAt: IsoDateTime, |
| 105 | threadId: Schema.optional(Schema.String), | 123 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 106 | turnId: Schema.optional(Schema.String), | 124 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 107 | itemId: Schema.optional(Schema.String), | 125 | itemId: Schema.optional(ProviderItemId), |
| 108 | toolKind: ProviderRuntimeToolKindSchema, | 126 | toolKind: ProviderRuntimeToolKind, |
| 109 | title: Schema.String, | 127 | title: Schema.String, |
| 110 | detail: Schema.optional(Schema.String), | 128 | detail: Schema.optional(Schema.String), |
| 111 | }); | 129 | }); |
| 130 | export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; | ||
| 112 | 131 | ||
| 113 | export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({ | 132 | export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ |
| 114 | type: Schema.Literal("tool.completed"), | 133 | type: Schema.Literal("tool.completed"), |
| 115 | eventId: IdSchema, | 134 | eventId: EventId, |
| 116 | provider: ProviderRuntimeProviderSchema, | 135 | provider: ProviderKind, |
| 117 | sessionId: IdSchema, | 136 | sessionId: ProviderSessionId, |
| 118 | createdAt: IsoDateTimeSchema, | 137 | createdAt: IsoDateTime, |
| 119 | threadId: Schema.optional(Schema.String), | 138 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 120 | turnId: Schema.optional(Schema.String), | 139 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 121 | itemId: Schema.optional(Schema.String), | 140 | itemId: Schema.optional(ProviderItemId), |
| 122 | toolKind: ProviderRuntimeToolKindSchema, | 141 | toolKind: ProviderRuntimeToolKind, |
| 123 | title: Schema.String, | 142 | title: Schema.String, |
| 124 | detail: Schema.optional(Schema.String), | 143 | detail: Schema.optional(Schema.String), |
| 125 | }); | 144 | }); |
| 145 | export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; | ||
| 126 | 146 | ||
| 127 | export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({ | 147 | export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ |
| 128 | type: Schema.Literal("approval.requested"), | 148 | type: Schema.Literal("approval.requested"), |
| 129 | eventId: IdSchema, | 149 | eventId: EventId, |
| 130 | provider: ProviderRuntimeProviderSchema, | 150 | provider: ProviderKind, |
| 131 | sessionId: IdSchema, | 151 | sessionId: ProviderSessionId, |
| 132 | createdAt: IsoDateTimeSchema, | 152 | createdAt: IsoDateTime, |
| 133 | threadId: Schema.optional(Schema.String), | 153 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 134 | turnId: Schema.optional(Schema.String), | 154 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 135 | itemId: Schema.optional(Schema.String), | 155 | itemId: Schema.optional(ProviderItemId), |
| 136 | requestId: IdSchema, | 156 | requestId: ApprovalRequestId, |
| 137 | requestKind: ProviderRuntimeApprovalKindSchema, | 157 | requestKind: ProviderRequestKind, |
| 138 | detail: Schema.optional(Schema.String), | 158 | detail: Schema.optional(Schema.String), |
| 139 | }); | 159 | }); |
| 160 | export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type; | ||
| 140 | 161 | ||
| 141 | export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({ | 162 | export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({ |
| 142 | type: Schema.Literal("approval.resolved"), | 163 | type: Schema.Literal("approval.resolved"), |
| 143 | eventId: IdSchema, | 164 | eventId: EventId, |
| 144 | provider: ProviderRuntimeProviderSchema, | 165 | provider: ProviderKind, |
| 145 | sessionId: IdSchema, | 166 | sessionId: ProviderSessionId, |
| 146 | createdAt: IsoDateTimeSchema, | 167 | createdAt: IsoDateTime, |
| 147 | threadId: Schema.optional(Schema.String), | 168 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 148 | turnId: Schema.optional(Schema.String), | 169 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 149 | itemId: Schema.optional(Schema.String), | 170 | itemId: Schema.optional(ProviderItemId), |
| 150 | requestId: IdSchema, | 171 | requestId: ApprovalRequestId, |
| 151 | requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema), | 172 | requestKind: Schema.optional(ProviderRequestKind), |
| 152 | decision: Schema.optional(Schema.String), | 173 | decision: Schema.optional(ProviderApprovalDecision), |
| 153 | }); | 174 | }); |
| 175 | export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type; | ||
| 154 | 176 | ||
| 155 | export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({ | 177 | export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({ |
| 156 | type: Schema.Literal("checkpoint.captured"), | 178 | type: Schema.Literal("checkpoint.captured"), |
| 157 | eventId: IdSchema, | 179 | eventId: EventId, |
| 158 | provider: ProviderRuntimeProviderSchema, | 180 | provider: ProviderKind, |
| 159 | sessionId: IdSchema, | 181 | sessionId: ProviderSessionId, |
| 160 | createdAt: IsoDateTimeSchema, | 182 | createdAt: IsoDateTime, |
| 161 | threadId: IdSchema, | 183 | threadId: RuntimeThreadIdSchema, |
| 162 | turnId: Schema.optional(Schema.String), | 184 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 163 | turnCount: Schema.Number, | 185 | turnCount: NonNegativeInt, |
| 164 | status: Schema.optional(ProviderRuntimeTurnStatusSchema), | 186 | status: Schema.optional(ProviderRuntimeTurnStatus), |
| 165 | }); | 187 | }); |
| 188 | export type ProviderRuntimeCheckpointCapturedEvent = typeof ProviderRuntimeCheckpointCapturedEvent.Type; | ||
| 166 | 189 | ||
| 167 | export const ProviderRuntimeErrorEventSchema = Schema.Struct({ | 190 | export const ProviderRuntimeErrorEvent = Schema.Struct({ |
| 168 | type: Schema.Literal("runtime.error"), | 191 | type: Schema.Literal("runtime.error"), |
| 169 | eventId: IdSchema, | 192 | eventId: EventId, |
| 170 | provider: ProviderRuntimeProviderSchema, | 193 | provider: ProviderKind, |
| 171 | sessionId: IdSchema, | 194 | sessionId: ProviderSessionId, |
| 172 | createdAt: IsoDateTimeSchema, | 195 | createdAt: IsoDateTime, |
| 173 | threadId: Schema.optional(Schema.String), | 196 | threadId: Schema.optional(RuntimeThreadIdSchema), |
| 174 | turnId: Schema.optional(Schema.String), | 197 | turnId: Schema.optional(RuntimeTurnIdSchema), |
| 175 | itemId: Schema.optional(Schema.String), | 198 | itemId: Schema.optional(ProviderItemId), |
| 176 | message: Schema.String, | 199 | message: Schema.String, |
| 177 | }); | 200 | }); |
| 178 | 201 | export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; | |
| 179 | export const ProviderRuntimeEventSchema = Schema.Union([ | 202 | |
| 180 | ProviderRuntimeSessionStartedEventSchema, | 203 | export const ProviderRuntimeEvent = Schema.Union([ |
| 181 | ProviderRuntimeSessionExitedEventSchema, | 204 | ProviderRuntimeSessionStartedEvent, |
| 182 | ProviderRuntimeThreadStartedEventSchema, | 205 | ProviderRuntimeSessionExitedEvent, |
| 183 | ProviderRuntimeTurnStartedEventSchema, | 206 | ProviderRuntimeThreadStartedEvent, |
| 184 | ProviderRuntimeTurnCompletedEventSchema, | 207 | ProviderRuntimeTurnStartedEvent, |
| 185 | ProviderRuntimeMessageDeltaEventSchema, | 208 | ProviderRuntimeTurnCompletedEvent, |
| 186 | ProviderRuntimeMessageCompletedEventSchema, | 209 | ProviderRuntimeMessageDeltaEvent, |
| 187 | ProviderRuntimeToolStartedEventSchema, | 210 | ProviderRuntimeMessageCompletedEvent, |
| 188 | ProviderRuntimeToolCompletedEventSchema, | 211 | ProviderRuntimeToolStartedEvent, |
| 189 | ProviderRuntimeApprovalRequestedEventSchema, | 212 | ProviderRuntimeToolCompletedEvent, |
| 190 | ProviderRuntimeApprovalResolvedEventSchema, | 213 | ProviderRuntimeApprovalRequestedEvent, |
| 191 | ProviderRuntimeCheckpointCapturedEventSchema, | 214 | ProviderRuntimeApprovalResolvedEvent, |
| 192 | ProviderRuntimeErrorEventSchema, | 215 | ProviderRuntimeCheckpointCapturedEvent, |
| 216 | ProviderRuntimeErrorEvent, | ||
| 193 | ]); | 217 | ]); |
| 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,17 +1,21 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 2 | import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; | 2 | import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; |
| 3 | 3 | ||
| 4 | export const serverConfigSchema = z.object({ | 4 | export const ServerConfig = Schema.Struct({ |
| 5 | cwd: z.string().min(1), | 5 | cwd: Schema.NonEmptyString, |
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | 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({ | 15 | export const ServerUpsertKeybindingResult = Schema.Struct({ |
| 12 | keybindings: resolvedKeybindingsConfigSchema.default([]), | 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,40 +1,56 @@ |
| 1 | import { Schema } from "effect"; | ||
| 1 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | DEFAULT_TERMINAL_ID, | 5 | DEFAULT_TERMINAL_ID, |
| 5 | terminalClearInputSchema, | 6 | TerminalClearInput, |
| 6 | terminalCloseInputSchema, | 7 | TerminalCloseInput, |
| 7 | terminalEventSchema, | 8 | TerminalEvent, |
| 8 | terminalOpenInputSchema, | 9 | TerminalOpenInput, |
| 9 | terminalResizeInputSchema, | 10 | TerminalResizeInput, |
| 10 | terminalSessionSnapshotSchema, | 11 | TerminalSessionSnapshot, |
| 11 | terminalThreadInputSchema, | 12 | TerminalThreadInput, |
| 12 | terminalWriteInputSchema, | 13 | TerminalWriteInput, |
| 13 | } from "./terminal"; | 14 | } from "./terminal"; |
| 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 | it("accepts valid open input", () => { | 30 | it("accepts valid open input", () => { |
| 17 | const result = terminalOpenInputSchema.safeParse({ | 31 | expect( |
| 18 | threadId: "thread-1", | 32 | decodes(TerminalOpenInput, { |
| 19 | cwd: "/tmp/project", | 33 | threadId: "thread-1", |
| 20 | cols: 120, | 34 | cwd: "/tmp/project", |
| 21 | rows: 40, | 35 | cols: 120, |
| 22 | }); | 36 | rows: 40, |
| 23 | expect(result.success).toBe(true); | 37 | }), |
| 38 | ).toBe(true); | ||
| 24 | }); | 39 | }); |
| 25 | 40 | ||
| 26 | it("rejects invalid bounds", () => { | 41 | it("rejects invalid bounds", () => { |
| 27 | const result = terminalOpenInputSchema.safeParse({ | 42 | expect( |
| 28 | threadId: "thread-1", | 43 | decodes(TerminalOpenInput, { |
| 29 | cwd: "/tmp/project", | 44 | threadId: "thread-1", |
| 30 | cols: 10, | 45 | cwd: "/tmp/project", |
| 31 | rows: 2, | 46 | cols: 10, |
| 32 | }); | 47 | rows: 2, |
| 33 | expect(result.success).toBe(false); | 48 | }), |
| 49 | ).toBe(false); | ||
| 34 | }); | 50 | }); |
| 35 | 51 | ||
| 36 | it("defaults terminalId when missing", () => { | 52 | it("defaults terminalId when missing", () => { |
| 37 | const parsed = terminalOpenInputSchema.parse({ | 53 | const parsed = decodeSync(TerminalOpenInput, { |
| 38 | threadId: "thread-1", | 54 | threadId: "thread-1", |
| 39 | cwd: "/tmp/project", | 55 | cwd: "/tmp/project", |
| 40 | cols: 100, | 56 | cols: 100, |
| ... | @@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => { | ... | @@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => { |
| 44 | }); | 60 | }); |
| 45 | 61 | ||
| 46 | it("accepts optional env overrides", () => { | 62 | it("accepts optional env overrides", () => { |
| 47 | const parsed = terminalOpenInputSchema.parse({ | 63 | const parsed = decodeSync(TerminalOpenInput, { |
| 48 | threadId: "thread-1", | 64 | threadId: "thread-1", |
| 49 | cwd: "/tmp/project", | 65 | cwd: "/tmp/project", |
| 50 | cols: 100, | 66 | cols: 100, |
| ... | @@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => { | ... | @@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => { |
| 61 | }); | 77 | }); |
| 62 | 78 | ||
| 63 | it("rejects invalid env keys", () => { | 79 | it("rejects invalid env keys", () => { |
| 64 | const result = terminalOpenInputSchema.safeParse({ | 80 | expect( |
| 65 | threadId: "thread-1", | 81 | decodes(TerminalOpenInput, { |
| 66 | cwd: "/tmp/project", | 82 | threadId: "thread-1", |
| 67 | cols: 100, | 83 | cwd: "/tmp/project", |
| 68 | rows: 24, | 84 | cols: 100, |
| 69 | env: { | 85 | rows: 24, |
| 70 | "bad-key": "1", | 86 | env: { |
| 71 | }, | 87 | "bad-key": "1", |
| 72 | }); | 88 | }, |
| 73 | expect(result.success).toBe(false); | 89 | }), |
| 90 | ).toBe(false); | ||
| 74 | }); | 91 | }); |
| 75 | }); | 92 | }); |
| 76 | 93 | ||
| 77 | describe("terminalWriteInputSchema", () => { | 94 | describe("TerminalWriteInput", () => { |
| 78 | it("accepts non-empty data", () => { | 95 | it("accepts non-empty data", () => { |
| 79 | const result = terminalWriteInputSchema.safeParse({ | 96 | expect( |
| 80 | threadId: "thread-1", | 97 | decodes(TerminalWriteInput, { |
| 81 | data: "echo hello\n", | 98 | threadId: "thread-1", |
| 82 | }); | 99 | data: "echo hello\n", |
| 83 | expect(result.success).toBe(true); | 100 | }), |
| 101 | ).toBe(true); | ||
| 84 | }); | 102 | }); |
| 85 | 103 | ||
| 86 | it("rejects empty data", () => { | 104 | it("rejects empty data", () => { |
| 87 | const result = terminalWriteInputSchema.safeParse({ | 105 | expect( |
| 88 | threadId: "thread-1", | 106 | decodes(TerminalWriteInput, { |
| 89 | data: "", | 107 | threadId: "thread-1", |
| 90 | }); | 108 | data: "", |
| 91 | expect(result.success).toBe(false); | 109 | }), |
| 110 | ).toBe(false); | ||
| 92 | }); | 111 | }); |
| 93 | }); | 112 | }); |
| 94 | 113 | ||
| 95 | describe("terminalThreadInputSchema", () => { | 114 | describe("TerminalThreadInput", () => { |
| 96 | it("trims thread ids", () => { | 115 | it("trims thread ids", () => { |
| 97 | const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " }); | 116 | const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " }); |
| 98 | expect(parsed.threadId).toBe("thread-1"); | 117 | expect(parsed.threadId).toBe("thread-1"); |
| 99 | }); | 118 | }); |
| 100 | }); | 119 | }); |
| 101 | 120 | ||
| 102 | describe("terminalResizeInputSchema", () => { | 121 | describe("TerminalResizeInput", () => { |
| 103 | it("accepts valid size", () => { | 122 | it("accepts valid size", () => { |
| 104 | const result = terminalResizeInputSchema.safeParse({ | 123 | expect( |
| 105 | threadId: "thread-1", | 124 | decodes(TerminalResizeInput, { |
| 106 | cols: 80, | 125 | threadId: "thread-1", |
| 107 | rows: 24, | 126 | cols: 80, |
| 108 | }); | 127 | rows: 24, |
| 109 | expect(result.success).toBe(true); | 128 | }), |
| 129 | ).toBe(true); | ||
| 110 | }); | 130 | }); |
| 111 | }); | 131 | }); |
| 112 | 132 | ||
| 113 | describe("terminalClearInputSchema", () => { | 133 | describe("TerminalClearInput", () => { |
| 114 | it("defaults terminal id", () => { | 134 | it("defaults terminal id", () => { |
| 115 | const parsed = terminalClearInputSchema.parse({ | 135 | const parsed = decodeSync(TerminalClearInput, { |
| 116 | threadId: "thread-1", | 136 | threadId: "thread-1", |
| 117 | }); | 137 | }); |
| 118 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 138 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 119 | }); | 139 | }); |
| 120 | }); | 140 | }); |
| 121 | 141 | ||
| 122 | describe("terminalCloseInputSchema", () => { | 142 | describe("TerminalCloseInput", () => { |
| 123 | it("accepts optional deleteHistory", () => { | 143 | it("accepts optional deleteHistory", () => { |
| 124 | const result = terminalCloseInputSchema.safeParse({ | 144 | expect( |
| 125 | threadId: "thread-1", | 145 | decodes(TerminalCloseInput, { |
| 126 | deleteHistory: true, | 146 | threadId: "thread-1", |
| 127 | }); | 147 | deleteHistory: true, |
| 128 | expect(result.success).toBe(true); | 148 | }), |
| 149 | ).toBe(true); | ||
| 129 | }); | 150 | }); |
| 130 | }); | 151 | }); |
| 131 | 152 | ||
| 132 | describe("terminalSessionSnapshotSchema", () => { | 153 | describe("TerminalSessionSnapshot", () => { |
| 133 | it("accepts running snapshots", () => { | 154 | it("accepts running snapshots", () => { |
| 134 | const result = terminalSessionSnapshotSchema.safeParse({ | 155 | expect( |
| 135 | threadId: "thread-1", | 156 | decodes(TerminalSessionSnapshot, { |
| 136 | terminalId: DEFAULT_TERMINAL_ID, | 157 | threadId: "thread-1", |
| 137 | cwd: "/tmp/project", | 158 | terminalId: DEFAULT_TERMINAL_ID, |
| 138 | status: "running", | 159 | cwd: "/tmp/project", |
| 139 | pid: 1234, | 160 | status: "running", |
| 140 | history: "hello\n", | 161 | pid: 1234, |
| 141 | exitCode: null, | 162 | history: "hello\n", |
| 142 | exitSignal: null, | 163 | exitCode: null, |
| 143 | updatedAt: new Date().toISOString(), | 164 | exitSignal: null, |
| 144 | }); | 165 | updatedAt: new Date().toISOString(), |
| 145 | expect(result.success).toBe(true); | 166 | }), |
| 167 | ).toBe(true); | ||
| 146 | }); | 168 | }); |
| 147 | }); | 169 | }); |
| 148 | 170 | ||
| 149 | describe("terminalEventSchema", () => { | 171 | describe("TerminalEvent", () => { |
| 150 | it("accepts output events", () => { | 172 | it("accepts output events", () => { |
| 151 | const result = terminalEventSchema.safeParse({ | 173 | expect( |
| 152 | type: "output", | 174 | decodes(TerminalEvent, { |
| 153 | threadId: "thread-1", | 175 | type: "output", |
| 154 | terminalId: DEFAULT_TERMINAL_ID, | 176 | threadId: "thread-1", |
| 155 | createdAt: new Date().toISOString(), | 177 | terminalId: DEFAULT_TERMINAL_ID, |
| 156 | data: "line\n", | 178 | createdAt: new Date().toISOString(), |
| 157 | }); | 179 | data: "line\n", |
| 158 | expect(result.success).toBe(true); | 180 | }), |
| 181 | ).toBe(true); | ||
| 159 | }); | 182 | }); |
| 160 | 183 | ||
| 161 | it("accepts exited events", () => { | 184 | it("accepts exited events", () => { |
| 162 | const result = terminalEventSchema.safeParse({ | 185 | expect( |
| 163 | type: "exited", | 186 | decodes(TerminalEvent, { |
| 164 | threadId: "thread-1", | 187 | type: "exited", |
| 165 | terminalId: DEFAULT_TERMINAL_ID, | 188 | threadId: "thread-1", |
| 166 | createdAt: new Date().toISOString(), | 189 | terminalId: DEFAULT_TERMINAL_ID, |
| 167 | exitCode: 0, | 190 | createdAt: new Date().toISOString(), |
| 168 | exitSignal: null, | 191 | exitCode: 0, |
| 169 | }); | 192 | exitSignal: null, |
| 170 | expect(result.success).toBe(true); | 193 | }), |
| 194 | ).toBe(true); | ||
| 171 | }); | 195 | }); |
| 172 | 196 | ||
| 173 | it("accepts activity events", () => { | 197 | it("accepts activity events", () => { |
| 174 | const result = terminalEventSchema.safeParse({ | 198 | expect( |
| 175 | type: "activity", | 199 | decodes(TerminalEvent, { |
| 176 | threadId: "thread-1", | 200 | type: "activity", |
| 177 | terminalId: DEFAULT_TERMINAL_ID, | 201 | threadId: "thread-1", |
| 178 | createdAt: new Date().toISOString(), | 202 | terminalId: DEFAULT_TERMINAL_ID, |
| 179 | hasRunningSubprocess: true, | 203 | createdAt: new Date().toISOString(), |
| 180 | }); | 204 | hasRunningSubprocess: true, |
| 181 | expect(result.success).toBe(true); | 205 | }), |
| 206 | ).toBe(true); | ||
| 182 | }); | 207 | }); |
| 183 | }); | 208 | }); |
packages/contracts/src/terminal.ts+120-84| ... | @@ -1,119 +1,155 @@ | ... | @@ -1,119 +1,155 @@ |
| 1 | import { z } from "zod"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | export const DEFAULT_TERMINAL_ID = "default"; | 3 | export const DEFAULT_TERMINAL_ID = "default"; |
| 4 | 4 | ||
| 5 | const terminalColsSchema = z.number().int().min(20).max(400); | 5 | const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty()); |
| 6 | const terminalRowsSchema = z.number().int().min(5).max(200); | 6 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( |
| 7 | const terminalIdSchema = z.string().trim().min(1).max(128); | 7 | Schema.isLessThanOrEqualTo(400), |
| 8 | const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); | 8 | ); |
| 9 | const terminalEnvValueSchema = z.string().max(8_192); | 9 | const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( |
| 10 | const terminalEnvSchema = z | 10 | Schema.isLessThanOrEqualTo(200), |
| 11 | .record(terminalEnvKeySchema, terminalEnvValueSchema) | 11 | ); |
| 12 | .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); | 12 | const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128)); |
| 13 | 13 | const TerminalEnvKeySchema = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check( | |
| 14 | export const terminalThreadInputSchema = z.object({ | 14 | Schema.isMaxLength(128), |
| 15 | threadId: z.string().trim().min(1), | 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({ | 35 | export const TerminalSessionInput = Schema.Struct({ |
| 19 | terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), | 36 | ...TerminalThreadInput.fields, |
| 37 | terminalId: TerminalIdWithDefaultSchema, | ||
| 20 | }); | 38 | }); |
| 21 | 39 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; | |
| 22 | export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ | 40 | |
| 23 | cwd: z.string().trim().min(1), | 41 | export const TerminalOpenInput = Schema.Struct({ |
| 24 | cols: terminalColsSchema.optional(), | 42 | ...TerminalSessionInput.fields, |
| 25 | rows: terminalRowsSchema.optional(), | 43 | cwd: TrimmedNonEmptyStringSchema, |
| 26 | env: terminalEnvSchema.optional(), | 44 | cols: Schema.optional(TerminalColsSchema), |
| 45 | rows: Schema.optional(TerminalRowsSchema), | ||
| 46 | env: Schema.optional(TerminalEnvSchema), | ||
| 27 | }); | 47 | }); |
| 48 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; | ||
| 28 | 49 | ||
| 29 | export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ | 50 | export const TerminalWriteInput = Schema.Struct({ |
| 30 | data: z.string().min(1).max(65_536), | 51 | ...TerminalSessionInput.fields, |
| 52 | data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), | ||
| 31 | }); | 53 | }); |
| 54 | export type TerminalWriteInput = Schema.Codec.Encoded<typeof TerminalWriteInput>; | ||
| 32 | 55 | ||
| 33 | export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ | 56 | export const TerminalResizeInput = Schema.Struct({ |
| 34 | cols: terminalColsSchema, | 57 | ...TerminalSessionInput.fields, |
| 35 | rows: terminalRowsSchema, | 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({ | 66 | export const TerminalRestartInput = Schema.Struct({ |
| 41 | terminalId: terminalIdSchema.optional(), | 67 | ...TerminalThreadInput.fields, |
| 42 | deleteHistory: z.boolean().optional(), | 68 | cwd: TrimmedNonEmptyStringSchema, |
| 69 | cols: TerminalColsSchema, | ||
| 70 | rows: TerminalRowsSchema, | ||
| 71 | env: Schema.optional(TerminalEnvSchema), | ||
| 43 | }); | 72 | }); |
| 44 | 73 | ||
| 45 | export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]); | 74 | export const TerminalCloseInput = Schema.Struct({ |
| 46 | 75 | ...TerminalThreadInput.fields, | |
| 47 | export const terminalSessionSnapshotSchema = z.object({ | 76 | terminalId: Schema.optional(TerminalIdSchema), |
| 48 | threadId: z.string().min(1), | 77 | deleteHistory: Schema.optional(Schema.Boolean), |
| 49 | terminalId: z.string().min(1), | 78 | }); |
| 50 | cwd: z.string().min(1), | 79 | export type TerminalCloseInput = Schema.Codec.Encoded<typeof TerminalCloseInput>; |
| 51 | status: terminalSessionStatusSchema, | 80 | |
| 52 | pid: z.number().int().positive().nullable(), | 81 | export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); |
| 53 | history: z.string(), | 82 | export type TerminalSessionStatus = typeof TerminalSessionStatus.Type; |
| 54 | exitCode: z.number().int().nullable(), | 83 | |
| 55 | exitSignal: z.number().int().nullable(), | 84 | export const TerminalSessionSnapshot = Schema.Struct({ |
| 56 | updatedAt: z.string().datetime(), | 85 | threadId: Schema.String.check(Schema.isNonEmpty()), |
| 86 | terminalId: Schema.String.check(Schema.isNonEmpty()), | ||
| 87 | cwd: Schema.String.check(Schema.isNonEmpty()), | ||
| 88 | status: TerminalSessionStatus, | ||
| 89 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), | ||
| 90 | history: Schema.String, | ||
| 91 | exitCode: Schema.NullOr(Schema.Int), | ||
| 92 | exitSignal: Schema.NullOr(Schema.Int), | ||
| 93 | updatedAt: Schema.String, | ||
| 57 | }); | 94 | }); |
| 95 | export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; | ||
| 58 | 96 | ||
| 59 | const terminalEventBaseSchema = z.object({ | 97 | const TerminalEventBaseSchema = Schema.Struct({ |
| 60 | threadId: z.string().min(1), | 98 | threadId: Schema.String.check(Schema.isNonEmpty()), |
| 61 | terminalId: z.string().min(1), | 99 | terminalId: Schema.String.check(Schema.isNonEmpty()), |
| 62 | createdAt: z.string().datetime(), | 100 | createdAt: Schema.String, |
| 63 | }); | 101 | }); |
| 64 | 102 | ||
| 65 | export const terminalStartedEventSchema = terminalEventBaseSchema.extend({ | 103 | export const TerminalStartedEvent = Schema.Struct({ |
| 66 | type: z.literal("started"), | 104 | ...TerminalEventBaseSchema.fields, |
| 67 | snapshot: terminalSessionSnapshotSchema, | 105 | type: Schema.Literal("started"), |
| 106 | snapshot: TerminalSessionSnapshot, | ||
| 68 | }); | 107 | }); |
| 69 | 108 | ||
| 70 | export const terminalOutputEventSchema = terminalEventBaseSchema.extend({ | 109 | export const TerminalOutputEvent = Schema.Struct({ |
| 71 | type: z.literal("output"), | 110 | ...TerminalEventBaseSchema.fields, |
| 72 | data: z.string(), | 111 | type: Schema.Literal("output"), |
| 112 | data: Schema.String, | ||
| 73 | }); | 113 | }); |
| 74 | 114 | ||
| 75 | export const terminalExitedEventSchema = terminalEventBaseSchema.extend({ | 115 | export const TerminalExitedEvent = Schema.Struct({ |
| 76 | type: z.literal("exited"), | 116 | ...TerminalEventBaseSchema.fields, |
| 77 | exitCode: z.number().int().nullable(), | 117 | type: Schema.Literal("exited"), |
| 78 | exitSignal: z.number().int().nullable(), | 118 | exitCode: Schema.NullOr(Schema.Int), |
| 119 | exitSignal: Schema.NullOr(Schema.Int), | ||
| 79 | }); | 120 | }); |
| 80 | 121 | ||
| 81 | export const terminalErrorEventSchema = terminalEventBaseSchema.extend({ | 122 | export const TerminalErrorEvent = Schema.Struct({ |
| 82 | type: z.literal("error"), | 123 | ...TerminalEventBaseSchema.fields, |
| 83 | message: z.string().min(1), | 124 | type: Schema.Literal("error"), |
| 125 | message: Schema.String.check(Schema.isNonEmpty()), | ||
| 84 | }); | 126 | }); |
| 85 | 127 | ||
| 86 | export const terminalClearedEventSchema = terminalEventBaseSchema.extend({ | 128 | export const TerminalClearedEvent = Schema.Struct({ |
| 87 | type: z.literal("cleared"), | 129 | ...TerminalEventBaseSchema.fields, |
| 130 | type: Schema.Literal("cleared"), | ||
| 88 | }); | 131 | }); |
| 89 | 132 | ||
| 90 | export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({ | 133 | export const TerminalRestartedEvent = Schema.Struct({ |
| 91 | type: z.literal("restarted"), | 134 | ...TerminalEventBaseSchema.fields, |
| 92 | snapshot: terminalSessionSnapshotSchema, | 135 | type: Schema.Literal("restarted"), |
| 136 | snapshot: TerminalSessionSnapshot, | ||
| 93 | }); | 137 | }); |
| 94 | 138 | ||
| 95 | export const terminalActivityEventSchema = terminalEventBaseSchema.extend({ | 139 | export const TerminalActivityEvent = Schema.Struct({ |
| 96 | type: z.literal("activity"), | 140 | ...TerminalEventBaseSchema.fields, |
| 97 | hasRunningSubprocess: z.boolean(), | 141 | type: Schema.Literal("activity"), |
| 142 | hasRunningSubprocess: Schema.Boolean, | ||
| 98 | }); | 143 | }); |
| 99 | 144 | ||
| 100 | export const terminalEventSchema = z.discriminatedUnion("type", [ | 145 | export const TerminalEvent = Schema.Union([ |
| 101 | terminalStartedEventSchema, | 146 | TerminalStartedEvent, |
| 102 | terminalOutputEventSchema, | 147 | TerminalOutputEvent, |
| 103 | terminalExitedEventSchema, | 148 | TerminalExitedEvent, |
| 104 | terminalErrorEventSchema, | 149 | TerminalErrorEvent, |
| 105 | terminalClearedEventSchema, | 150 | TerminalClearedEvent, |
| 106 | terminalRestartedEventSchema, | 151 | TerminalRestartedEvent, |
| 107 | terminalActivityEventSchema, | 152 | TerminalActivityEvent, |
| 108 | ]); | 153 | ]); |
| 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,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 | // ── WebSocket RPC Method Names ─────────────────────────────────────── | 33 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 4 | 34 | ||
| 5 | export const WS_METHODS = { | 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 | // Project registry methods | 36 | // Project registry methods |
| 17 | projectsList: "projects.list", | 37 | projectsList: "projects.list", |
| 18 | projectsAdd: "projects.add", | 38 | projectsAdd: "projects.add", |
| 19 | projectsRemove: "projects.remove", | 39 | projectsRemove: "projects.remove", |
| 20 | projectsSearchEntries: "projects.searchEntries", | 40 | projectsSearchEntries: "projects.searchEntries", |
| 21 | projectsUpdateScripts: "projects.updateScripts", | ||
| 22 | 41 | ||
| 23 | // Shell methods | 42 | // Shell methods |
| 24 | shellOpenInEditor: "shell.openInEditor", | 43 | shellOpenInEditor: "shell.openInEditor", |
| ... | @@ -54,49 +73,76 @@ export const WS_CHANNELS = { | ... | @@ -54,49 +73,76 @@ export const WS_CHANNELS = { |
| 54 | serverWelcome: "server.welcome", | 73 | serverWelcome: "server.welcome", |
| 55 | } as const; | 74 | } as const; |
| 56 | 75 | ||
| 57 | // ── Client → Server (request) ──────────────────────────────────────── | 76 | // -- Tagged Union of all request body schemas ───────────────────────── |
| 58 | 77 | ||
| 59 | export const wsRequestSchema = z.object({ | 78 | export const WebSocketRequestBody = Schema.TaggedUnion({ |
| 60 | id: z.string().min(1), | 79 | // Orchestration methods |
| 61 | method: z.string().min(1), | 80 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, |
| 62 | params: z.unknown().optional(), | 81 | [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, |
| 63 | }); | 82 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, |
| 83 | [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, | ||
| 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({ | 91 | // Git methods |
| 70 | id: z.string().min(1), | 92 | [WS_METHODS.gitPull]: GitPullInput.fields, |
| 71 | result: z.unknown().optional(), | 93 | [WS_METHODS.gitStatus]: GitStatusInput.fields, |
| 72 | error: z | 94 | [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields, |
| 73 | .object({ | 95 | [WS_METHODS.gitListBranches]: GitListBranchesInput.fields, |
| 74 | message: z.string(), | 96 | [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields, |
| 75 | }) | 97 | [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields, |
| 76 | .optional(), | 98 | [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields, |
| 77 | }); | 99 | [WS_METHODS.gitCheckout]: GitCheckoutInput.fields, |
| 100 | [WS_METHODS.gitInit]: GitInitInput.fields, | ||
| 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({ | 115 | export const WebSocketRequest = Schema.Struct({ |
| 84 | type: z.literal("push"), | 116 | id: Schema.String, |
| 85 | channel: z.string().min(1), | 117 | body: WebSocketRequestBody, |
| 86 | data: z.unknown(), | ||
| 87 | }); | 118 | }); |
| 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 | // ── Union of all server → client messages ───────────────────────────── | 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 | // ── Server welcome payload ─────────────────────────────────────────── | 143 | // ── Server welcome payload ─────────────────────────────────────────── |
| 96 | 144 | ||
| 97 | export const wsWelcomePayloadSchema = z.object({ | 145 | export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({ |
| 98 | cwd: z.string().min(1), | 146 | cwd: Schema.String, |
| 99 | projectName: z.string().min(1), | 147 | projectName: Schema.String, |
| 100 | }); | 148 | }) {} |
| 101 | |||
| 102 | export type WsWelcomePayload = z.infer<typeof wsWelcomePayloadSchema>; |