diff --git a/.plans/spec-1-1-cutover-plan.md b/.plans/spec-1-1-cutover-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..d48937b40514ae7f71397d4c6dd3dcf86d12d463 --- /dev/null +++ b/.plans/spec-1-1-cutover-plan.md @@ -0,0 +1,201 @@ +# Spec 1:1 Cutover Plan + +Goal: Align the orchestration model to `SPEC.md` 1:1 and remove legacy persistence/application cruft. + +Execution mode for this plan: +- Hard cutover only. Existing DB and migration history are disposable. +- Intermediate steps are allowed to break runtime, tests, typecheck, and lint. +- We optimize for small, reviewable work units, not continuous app operability. +- Only the final gate requires everything to run cleanly. + +## 1. Freeze SPEC contract as source of truth +Work units: +- Create `.plans/spec-contract-matrix.md` with one row per requirement in `SPEC.md` sections `7.1`-`7.4`. +- Add exact SQL-level requirements per row: table, column, type, nullability, PK/unique, index, and invariants. +- Add app-level requirements per row: writer path, reader path, and owning module. +- Mark each row with status labels: `required`, `implemented`, `to-replace`, `delete`. +- Identify any ambiguous spec lines and record a concrete interpretation in the matrix. + +Deliverables: +- Complete matrix file with no unclassified rows. +- Single source checklist used by all later steps. + +Breakage allowed: +- No code changes required yet. + +Exit criteria: +- Every requirement in `7.1`-`7.4` has exactly one matrix row. + +## 2. Hard cutover migrations (replace current migration set) +Work units: +- Delete the current legacy migration files and rewrite migration loader ordering. +- Create `001_orchestration_events.ts` with full envelope columns and required event indexes. +- Create `002_orchestration_command_receipts.ts` with PK + lookup indexes. +- Create `003_checkpoint_diff_blobs.ts` with uniqueness on `(thread_id, from_turn_count, to_turn_count)`. +- Create `004_provider_session_runtime.ts` with PK and runtime lookup indexes. +- Create `005_projections.ts` with all projection tables: + - `projection_projects` + - `projection_threads` + - `projection_thread_messages` + - `projection_thread_activities` + - `projection_thread_sessions` + - `projection_thread_turns` + - `projection_checkpoints` + - `projection_pending_approvals` + - `projection_state` +- Add all required indexes/constraints in `005_projections.ts`. +- Ensure old tables (`projects`, `provider_checkpoints`, `provider_sessions`) are not recreated. + +Deliverables: +- New 5-file migration chain. +- Updated migration loader references only new migrations. + +Breakage allowed: +- Repositories/services can be temporarily broken due to removed old tables. + +Exit criteria: +- Fresh DB initializes with only canonical tables plus migration bookkeeping. + +## 3. Align persistence row/request schemas to DB 1:1 +Work units: +- Define row schemas for each canonical table (contracts or persistence layer module). +- Define request schemas for every insert/update/query operation touching canonical tables. +- Remove or deprecate row/request schemas tied to deleted legacy tables. +- Normalize enum and null semantics to match contracts exactly. +- Ensure SQL aliases map 1:1 to schema field names (no implicit shape transforms). + +Deliverables: +- Canonical row/request schemas committed. +- Zero references to legacy row schemas in active code paths. + +Breakage allowed: +- Runtime can still fail while query layers are being rewired. + +Exit criteria: +- Every canonical table used in code has a typed row schema and typed request schema. + +## 4. Rewrite event store for full persisted envelope +Work units: +- Refactor append path to write full envelope fields: + - `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` +- Implement stream version assignment/checking per aggregate stream. +- Refactor read/replay path to decode payload and metadata from JSON and return `OrchestrationEvent` consistently. +- Remove assumptions from old minimal schema (`aggregate_id`, missing metadata/actor). +- Add explicit SQL ordering guarantees for replay (`ORDER BY sequence ASC`). + +Deliverables: +- Event store append/replay fully aligned with canonical envelope. + +Breakage allowed: +- Command dispatch flow can be partially broken until receipts/projectors are updated. + +Exit criteria: +- Event store no longer depends on legacy event table shape. + +## 5. Add command receipt idempotency +Work units: +- Introduce persistence access layer for `orchestration_command_receipts`. +- In command dispatch flow, check existing receipt by `commandId` before append. +- On first execution, persist accepted receipt with `resultSequence`. +- On domain rejection, persist rejected receipt with error payload. +- On duplicate command, return prior result from receipt without re-appending event. +- Ensure receipt write and event append ordering is deterministic. + +Deliverables: +- Dispatch path with idempotency behavior wired through receipts. + +Breakage allowed: +- Snapshot/read model may still be inconsistent until projectors are fully wired. + +Exit criteria: +- Duplicate command IDs no longer create duplicate events. + +## 6. Build DB-backed projection pipeline +Work units: +- Create projector runner that consumes events and applies table-specific projections. +- Implement projector handlers for each projection table. +- For each handler, update target row(s) and `projection_state.last_applied_sequence` in the same transaction. +- Define projector names used in `projection_state` and make them stable constants. +- Add replay bootstrap from event store to bring projections up to latest sequence on startup. +- Add safe resume logic from projector `last_applied_sequence`. + +Deliverables: +- Persistent projector pipeline writing all `projection_*` tables. + +Breakage allowed: +- Web/API layer may still read old in-memory model until step 7. + +Exit criteria: +- Events drive projection rows in DB; projection state advances transactionally. + +## 7. Move RPC reads to projections and diff blobs +Work units: +- Implement snapshot query service reading only projection tables. +- Build thread hydration from projection rows: messages, activities, checkpoints, session. +- Compute `snapshotSequence` as the minimum required projector sequence from `projection_state`. +- Implement `getTurnDiff` query backed by `checkpoint_diff_blobs` only. +- Remove or bypass in-memory snapshot construction for RPC responses. +- Validate replay handoff contract: snapshot sequence -> replay from `fromSequenceExclusive`. + +Deliverables: +- `orchestration.getSnapshot` and `orchestration.getTurnDiff` served from DB projections/blob store. + +Breakage allowed: +- Provider runtime persistence may still be partially legacy until step 8. + +Exit criteria: +- No orchestration read RPC depends on legacy tables or in-memory-only state. + +## 8. Migrate provider runtime persistence to canonical table +Work units: +- Create repository/service for `provider_session_runtime`. +- Update adapter/session manager to persist runtime/resume cursor in new table. +- Ensure domain-visible session state still flows through orchestration events to `projection_thread_sessions`. +- Remove writes to legacy provider session tables. +- Verify restart/resume path reads runtime state from canonical table only. + +Deliverables: +- Provider runtime state entirely backed by `provider_session_runtime`. + +Breakage allowed: +- Some legacy interfaces may still exist but should be disconnected. + +Exit criteria: +- Runtime restore no longer reads/writes legacy provider session persistence. + +## 9. Remove old cruft aggressively +Work units: +- Delete legacy repositories/services that map to removed tables. +- Remove dead migration imports and obsolete persistence service interfaces. +- Remove compatibility code paths that translate legacy row shapes. +- Remove unused contracts/types linked to deprecated persistence model. +- Update internal docs/comments to reference canonical projection/event model only. + +Deliverables: +- Legacy persistence and translation layers removed from active codebase. + +Breakage allowed: +- Temporary compile failures acceptable while deletion/refactor is in progress. + +Exit criteria: +- No production code path references deleted legacy tables/services. + +## 10. Final verification gate (first point where green is required) +Work units: +- Add migration tests that assert canonical tables, columns, constraints, and indexes. +- Add event store tests for envelope persistence, metadata, actor kind, and replay. +- Add receipt idempotency tests for accept/reject/duplicate paths. +- Add projector tests for transactional row updates + `projection_state` updates. +- Add snapshot tests verifying projection-sourced output and `snapshotSequence` semantics. +- Add turn diff tests verifying `checkpoint_diff_blobs` source of truth. +- Add provider runtime tests for persist + restart + resume behavior. +- Run project lint/typecheck/tests and fix failures. + +Deliverables: +- Green checks with canonical schema + persistence model in place. + +Breakage allowed: +- None at end of step. + +Exit criteria: +- SPEC `7.1`-`7.4` requirements satisfied and validated by tests. diff --git a/.plans/spec-contract-matrix.md b/.plans/spec-contract-matrix.md new file mode 100644 index 0000000000000000000000000000000000000000..842f0d25d297bfb062ad8cb3a99bcbdb5297df19 --- /dev/null +++ b/.plans/spec-contract-matrix.md @@ -0,0 +1,401 @@ +# SPEC Contract Matrix (Sections 7.1-7.4) + +Status legend: +- `required`: requirement acknowledged, no current implementation claim yet. +- `implemented`: requirement currently satisfied in code + schema. +- `to-replace`: partial/misaligned implementation exists and must be replaced. +- `delete`: current path actively conflicts with SPEC and should be removed. + +## 7.1 Write-Side Persisted Tables + +### W1 +- Spec ref: `7.1.1 orchestration_events` +- Requirement: append-only event store with canonical envelope columns. +- SQL contract: + - `sequence INTEGER PRIMARY KEY` (global monotonic) + - `event_id TEXT UNIQUE NOT NULL` + - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` + - `stream_id TEXT NOT NULL` + - `stream_version INTEGER NOT NULL` + - `event_type TEXT NOT NULL` + - `occurred_at TEXT NOT NULL` + - `command_id TEXT NULL` + - `causation_event_id TEXT NULL` + - `correlation_id TEXT NULL` + - `actor_kind TEXT NOT NULL CHECK IN ('client','server','provider')` + - `payload_json TEXT NOT NULL` + - `metadata_json TEXT NOT NULL` +- Current writer path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` +- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` +- Owner module: `apps/server/src/persistence` (event store + migrations) +- Status: `to-replace` +- Notes: current migration/table lacks `stream_id`, `stream_version`, `causation_event_id`, `correlation_id`, `actor_kind`, `metadata_json`. + +### W2 +- Spec ref: `7.1.2 orchestration_command_receipts` +- Requirement: command idempotency + ack replay receipts table. +- SQL contract: + - `command_id TEXT PRIMARY KEY` + - `aggregate_kind TEXT NOT NULL CHECK IN ('project','thread')` + - `aggregate_id TEXT NOT NULL` + - `accepted_at TEXT NOT NULL` + - `result_sequence INTEGER NOT NULL` + - `status TEXT NOT NULL CHECK IN ('accepted','rejected')` + - `error TEXT NULL` +- Current writer path: none +- Current reader path: none +- Owner module: `apps/server/src/orchestration` dispatch boundary + `apps/server/src/persistence` +- Status: `to-replace` +- Notes: missing table and missing idempotency flow. + +### W3 +- Spec ref: `7.1.3 checkpoint_diff_blobs` +- Requirement: store large plaintext diffs separate from checkpoint summaries. +- SQL contract: + - `thread_id TEXT NOT NULL` + - `from_turn_count INTEGER NOT NULL` + - `to_turn_count INTEGER NOT NULL` + - `diff TEXT NOT NULL` + - `created_at TEXT NOT NULL` + - `UNIQUE(thread_id, from_turn_count, to_turn_count)` +- Current writer path: none +- Current reader path: none +- Owner module: `apps/server/src/persistence` + turn diff query service +- Status: `to-replace` +- Notes: no canonical diff blob table yet. + +### W4 +- Spec ref: `7.1.4 provider_session_runtime` +- Requirement: server-internal provider runtime/resume state. +- SQL contract: + - `provider_session_id TEXT PRIMARY KEY` + - `thread_id TEXT NOT NULL` + - `provider_name TEXT NOT NULL` + - `adapter_key TEXT NOT NULL` + - `provider_thread_id TEXT NULL` + - `status TEXT NOT NULL CHECK IN ('starting','running','stopped','error')` + - `last_seen_at TEXT NOT NULL` + - `resume_cursor_json TEXT NULL` + - `runtime_payload_json TEXT NULL` +- Current writer path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) +- Current reader path: legacy provider session persistence (`apps/server/src/persistence/Layers/ProviderSessions.ts`) +- Owner module: provider runtime manager + persistence runtime repository +- Status: `to-replace` +- Notes: existing `provider_sessions` schema is incompatible and too small. + +## 7.2 Canonical Persisted Event Schema + +### E1 +- Spec ref: `7.2 OrchestrationPersistedEventSchema` +- Requirement: full typed persisted event envelope in shared contracts. +- SQL contract: envelope fields in W1 must map 1:1 to contracts schema. +- Current writer path: contracts defined in `packages/contracts/src/orchestration.ts` +- Current reader path: used by persistence decode boundaries (partial) +- Owner module: `packages/contracts` +- Status: `implemented` +- Notes: contract schema exists; DB + store mapping still incomplete. + +### E2 +- Spec ref: `7.2 Rules/payload discriminated by eventType` +- Requirement: `payload` validation keyed by `eventType`. +- SQL contract: `event_type` drives payload decode schema; invalid combinations rejected. +- Current writer path: `packages/contracts/src/orchestration.ts` +- Current reader path: `apps/server/src/persistence/Layers/OrchestrationEventStore.ts` decode path +- Owner module: contracts + event store +- Status: `to-replace` +- Notes: decode is present but DB does not persist full envelope columns. + +### E3 +- Spec ref: `7.2 Rules/provider ids scope` +- Requirement: provider ids live in metadata/provider payload, not as thread identity replacement. +- SQL contract: provider fields persisted inside `metadata_json`; `stream_id` remains project/thread id. +- Current writer path: `apps/server/src/orchestration/decider.ts` (metadata mostly empty) +- Current reader path: projector/event consumers +- Owner module: decider + provider ingestion + event store +- Status: `to-replace` +- Notes: metadata plumbing is incomplete in persistence path. + +### E4 +- Spec ref: `7.2 Rules/streamVersion concurrency guard` +- Requirement: stream version monotonic per aggregate stream; enforced on write. +- SQL contract: `stream_version INTEGER NOT NULL` + uniqueness/invariant enforcement per stream. +- Current writer path: none +- Current reader path: none +- Owner module: event store append logic + DB constraints +- Status: `to-replace` +- Notes: no stream version assignment/checking today. + +## 7.3 Required Projected Tables (Read Models) + +### P1 +- Spec ref: `7.3.1 projection_projects` +- Requirement: persisted project projection table. +- SQL contract: + - `project_id TEXT PRIMARY KEY` + - `title TEXT NOT NULL` + - `workspace_root TEXT NOT NULL` + - `default_model TEXT NULL` + - `created_at TEXT NOT NULL` + - `updated_at TEXT NOT NULL` + - `deleted_at TEXT NULL` +- Current writer path: none (in-memory projector only) +- Current reader path: none (snapshot not DB-projected) +- Owner module: projector pipeline + snapshot query +- Status: `to-replace` +- Notes: legacy `projects` table is separate concept and should be removed from orchestration model. + +### P2 +- Spec ref: `7.3.2 projection_threads` +- Requirement: persisted thread projection table. +- SQL contract: + - `thread_id TEXT PRIMARY KEY` + - `project_id TEXT NOT NULL` + - `title TEXT NOT NULL` + - `model TEXT NOT NULL` + - `branch TEXT NULL` + - `worktree_path TEXT NULL` + - `latest_turn_id TEXT NULL` + - `created_at TEXT NOT NULL` + - `updated_at TEXT NOT NULL` + - `deleted_at TEXT NULL` +- Current writer path: none (in-memory projector only) +- Current reader path: none (snapshot not DB-projected) +- Owner module: projector pipeline + snapshot query +- Status: `to-replace` +- Notes: missing table and projector writes. + +### P3 +- Spec ref: `7.3.3 projection_thread_messages` +- Requirement: persisted thread message projection table. +- SQL contract: + - `message_id TEXT PRIMARY KEY` + - `thread_id TEXT NOT NULL` + - `turn_id TEXT NULL` + - `role TEXT NOT NULL CHECK IN ('user','assistant','system')` + - `text TEXT NOT NULL` + - `is_streaming INTEGER/BOOLEAN NOT NULL` + - `created_at TEXT NOT NULL` + - `updated_at TEXT NOT NULL` +- Current writer path: none (in-memory projector only) +- Current reader path: none (snapshot not DB-projected) +- Owner module: projector pipeline + snapshot query +- Status: `to-replace` +- Notes: missing table and message projection writes. + +### P4 +- Spec ref: `7.3.4 projection_thread_activities` +- Requirement: persisted thread activity projection table. +- SQL contract: + - `activity_id TEXT PRIMARY KEY` + - `thread_id TEXT NOT NULL` + - `turn_id TEXT NULL` + - `tone TEXT NOT NULL CHECK IN ('info','tool','approval','error')` + - `kind TEXT NOT NULL` + - `summary TEXT NOT NULL` + - `payload_json TEXT NOT NULL` + - `created_at TEXT NOT NULL` +- Current writer path: none (in-memory projector only) +- Current reader path: none (snapshot not DB-projected) +- Owner module: projector pipeline + snapshot query +- Status: `to-replace` +- Notes: no canonical activity projection persistence. + +### P5 +- Spec ref: `7.3.5 projection_thread_sessions` +- Requirement: persisted thread session projection table. +- SQL contract: + - `thread_id TEXT PRIMARY KEY` + - `status TEXT NOT NULL CHECK IN ('idle','starting','running','ready','interrupted','stopped','error')` + - `provider_name TEXT NULL` + - `provider_session_id TEXT NULL` + - `provider_thread_id TEXT NULL` + - `active_turn_id TEXT NULL` + - `last_error TEXT NULL` + - `updated_at TEXT NOT NULL` +- Current writer path: none (in-memory projector only) +- Current reader path: none (snapshot not DB-projected) +- Owner module: projector pipeline + snapshot query +- Status: `to-replace` +- Notes: current provider session table is not this domain projection. + +### P6 +- Spec ref: `7.3.6 projection_thread_turns` +- Requirement: persisted thread turn projection table. +- SQL contract: + - `turn_id TEXT PRIMARY KEY` + - `thread_id TEXT NOT NULL` + - `turn_count INTEGER NOT NULL` + - `status TEXT NOT NULL CHECK IN ('running','completed','interrupted','error')` + - `user_message_id TEXT NULL` + - `assistant_message_id TEXT NULL` + - `started_at TEXT NOT NULL` + - `completed_at TEXT NULL` +- Current writer path: none +- Current reader path: none +- Owner module: projector pipeline + session/turn query helpers +- Status: `to-replace` +- Notes: missing table and projection logic. + +### P7 +- Spec ref: `7.3.7 projection_checkpoints` +- Requirement: persisted checkpoint summary projection table. +- SQL contract: + - `thread_id TEXT NOT NULL` + - `turn_id TEXT NOT NULL` + - `checkpoint_turn_count INTEGER NOT NULL` + - `checkpoint_ref TEXT NOT NULL` + - `status TEXT NOT NULL CHECK IN ('ready','missing','error')` + - `files_json TEXT NOT NULL` + - `assistant_message_id TEXT NULL` + - `completed_at TEXT NOT NULL` + - `UNIQUE(thread_id, checkpoint_turn_count)` +- Current writer path: legacy `provider_checkpoints` writes in `apps/server/src/persistence/Layers/Checkpoints.ts` +- Current reader path: legacy checkpoint repository +- Owner module: projector pipeline + checkpoint query layer +- Status: `to-replace` +- Notes: current table semantics do not match canonical checkpoint projection schema. + +### P8 +- Spec ref: `7.3.8 projection_pending_approvals` +- Requirement: persisted pending-approval projection table. +- SQL contract: + - `request_id TEXT PRIMARY KEY` + - `thread_id TEXT NOT NULL` + - `turn_id TEXT NULL` + - `status TEXT NOT NULL CHECK IN ('pending','resolved')` + - `decision TEXT NULL CHECK IN ('accept','acceptForSession','decline','cancel')` + - `created_at TEXT NOT NULL` + - `resolved_at TEXT NULL` +- Current writer path: none +- Current reader path: none +- Owner module: projector pipeline + approval query layer +- Status: `to-replace` +- Notes: missing table and projection logic. + +### P9 +- Spec ref: `7.3.9 projection_state` +- Requirement: projector progress tracking table. +- SQL contract: + - `projector TEXT PRIMARY KEY` + - `last_applied_sequence INTEGER NOT NULL` + - `updated_at TEXT NOT NULL` +- Current writer path: none +- Current reader path: none +- Owner module: projector runner/checkpointing +- Status: `to-replace` +- Notes: missing table and projector bookkeeping. + +### P10 +- Spec ref: `7.3 Projection consistency rules` +- Requirement: projector row updates and `projection_state` update must be atomic per event. +- SQL contract: per-projector transaction boundary covering both projection write and state update. +- Current writer path: none (in-memory projector has no SQL transaction) +- Current reader path: none +- Owner module: projector runner +- Status: `to-replace` +- Notes: requires transactional projection executor. + +### P11 +- Spec ref: `7.3 Optional debug field` +- Requirement: `lastEventSequence` on projection rows is optional and not required for correctness. +- SQL contract: optional; not required in baseline schema. +- Current writer path: none +- Current reader path: none +- Owner module: projector runner +- Status: `required` +- Notes: interpretation: exclude from first cutover unless debugging requires it. + +## 7.4 Snapshot and RPC Requirements + +### R1 +- Spec ref: `7.4.1` +- Requirement: `orchestration.getSnapshot` fully served from projection tables and returns `snapshotSequence`. +- SQL contract: snapshot query joins/reads only `projection_*` + `projection_state`. +- Current writer path: in-memory model built in `apps/server/src/orchestration/projector.ts` +- Current reader path: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts#getReadModel` +- Owner module: snapshot query service + ws RPC handler +- Status: `delete` +- Notes: current in-memory read model path must be removed for SPEC compliance. + +### R2 +- Spec ref: `7.4.2` +- Requirement: snapshot `projects[]` source is `projection_projects`. +- SQL contract: `projects` collection assembled from `projection_projects` rows. +- Current writer path: none +- Current reader path: in-memory thread/project arrays +- Owner module: snapshot query service +- Status: `to-replace` +- Notes: no DB project projection reader exists yet. + +### R3 +- Spec ref: `7.4.3` +- Requirement: thread snapshot `checkpoints[]` source is `projection_checkpoints` with required fields. +- SQL contract: fields `turnId`, `completedAt`, `status`, `files[]`, `checkpointRef`, optional `assistantMessageId`, `checkpointTurnCount`. +- Current writer path: legacy checkpoint repo data model +- Current reader path: in-memory checkpoints from orchestration events +- Owner module: snapshot query service + checkpoint projector +- Status: `to-replace` +- Notes: canonical projection table and reader not implemented. + +### R4 +- Spec ref: `7.4.4` +- Requirement: no `listCheckpoints` orchestration RPC; list in snapshot + full diff via `getTurnDiff` from diff blobs. +- SQL contract: `getTurnDiff` reads `checkpoint_diff_blobs` only. +- Current writer path: none for diff blobs +- Current reader path: `orchestration.getTurnDiff` schema exists, data backing incomplete +- Owner module: ws RPC handler + diff query service +- Status: `to-replace` +- Notes: current checkpoint repository is not canonical source. + +### R5 +- Spec ref: `7.4.5` +- Requirement: client acts on `ThreadId`; server resolves provider session via `projection_thread_sessions`. +- SQL contract: session lookup by `thread_id` from projection table. +- Current writer path: mixed provider/session handling paths +- Current reader path: legacy provider session persistence lookups +- Owner module: provider dispatch/session resolution +- Status: `to-replace` +- Notes: remove provider-session-as-routing-key behavior. + +### R6 +- Spec ref: `7.4.6` +- Requirement: `snapshotSequence` derived from `projection_state` minimum over dependent projectors. +- SQL contract: `MIN(last_applied_sequence)` across required projector keys. +- Current writer path: none +- Current reader path: currently from in-memory event projection sequence +- Owner module: snapshot query service +- Status: `to-replace` +- Notes: must move from in-memory sequence to DB projection-state semantics. + +### R7 +- Spec ref: `7.4.7` +- Requirement: snapshot/replay handoff has no gap (`getSnapshot` -> subscribe from snapshot sequence). +- SQL contract: read consistency strategy guaranteeing no missing events between snapshot visibility and replay start. +- Current writer path: event stream via `OrchestrationEventStore.readFromSequence` +- Current reader path: ws replay flow in `apps/server/src/wsServer.ts` +- Owner module: ws RPC + event stream handoff layer +- Status: `to-replace` +- Notes: interpretation requires explicit consistency boundary (transaction, sequence fence, or equivalent). + +## Ambiguous/Interpretation Decisions (tracked upfront) + +### A1 +- Topic: `orchestration_events.stream_id` vs event runtime `aggregateId` naming. +- Decision: persist canonical DB column name `stream_id`; map to runtime `aggregateId` where needed in decider/projector code. + +### A2 +- Topic: JSON column typing in SQLite for `payload`, `metadata`, projection payload/files, runtime cursor/payload. +- Decision: store as `TEXT` JSON with strict encode/decode schemas at boundaries. + +### A3 +- Topic: `snapshotSequence` dependency set for min-sequence computation. +- Decision: include all projectors used to construct snapshot payload (`projects`, `threads`, `messages`, `activities`, `sessions`, `turns`, `checkpoints`, `pending_approvals`). + +### A4 +- Topic: no-gap handoff mechanism in `7.4.7`. +- Decision: implement explicit sequence fence semantics at snapshot time; replay starts from fence `fromSequenceExclusive`. + +## Checklist Completeness Statement +- Coverage scope: `SPEC.md` sections `7.1`, `7.2`, `7.3`, `7.4`. +- Requirement rows present: `W1-W4`, `E1-E4`, `P1-P11`, `R1-R7`. +- Unclassified rows: `0`. diff --git a/SPEC.md b/SPEC.md index 95266217808c9a5b1ca8dfa63f430f2488e922b1..42881e29800e49c45c6e39e078733e2efea3fccd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -55,7 +55,7 @@ Output for every command: `DispatchResultSchema = { sequence: number }`. - Input: - `commandId: CommandId` - `projectId: ProjectId` - - optional `{ title, workspaceRoot, defaultModel }` + - optional `{ title, workspaceRoot, defaultModel, scripts }` - Output: `DispatchResult` 3. `project.delete` @@ -427,6 +427,7 @@ Rules: - `title: string` - `workspaceRoot: string` - `defaultModel: string | null` +- `scripts: ProjectScript[]` - `createdAt: IsoDateTime` - `updatedAt: IsoDateTime` - `deletedAt: IsoDateTime | null` diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index b32853277ae72eed9320c4ca55abca03ba5ded11..f630e0619afea5bd0850be37ec30f20ceb682496 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -3,20 +3,16 @@ import { NodeServices } from "@effect/platform-node"; import { it, assert } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; -import { runGit as runGitProcess } from "../src/git/Process.ts"; -import { CheckpointServiceLive } from "../src/checkpointing/Layers/CheckpointService.ts"; -import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; -import { ProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; -import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts"; -import { ProviderSessionRepositoryLive } from "../src/persistence/Layers/ProviderSessions.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeTestProviderAdapterHarness, @@ -29,34 +25,11 @@ import { codexTurnTextFixture, } from "./fixtures/providerRuntime.ts"; -const runGit = (cwd: string, args: ReadonlyArray, allowNonZeroExit = false) => - runGitProcess({ - operation: "providerService.integration.git", - cwd, - args, - allowNonZeroExit, - }).pipe(Effect.provide(NodeServices.layer)); - -const gitRefExists = (cwd: string, ref: string) => - runGit(cwd, ["show-ref", "--verify", "--quiet", ref], true).pipe( - Effect.map((result) => result.code === 0), - ); - -const checkpointRefForTurn = (threadId: string, turnCount: number) => - `refs/t3/checkpoints/${Buffer.from(threadId, "utf8").toString("base64url")}/turn/${turnCount}`; - -const makeGitRepository = Effect.gen(function* () { +const makeWorkspaceDirectory = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const cwd = yield* fs.makeTempDirectory(); - - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@example.com"]); - yield* runGit(cwd, ["config", "user.name", "Test User"]); yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "Initial"]); - return cwd; }).pipe(Effect.provide(NodeServices.layer)); @@ -67,7 +40,7 @@ interface IntegrationFixture { } const makeIntegrationFixture = Effect.gen(function* () { - const cwd = yield* makeGitRepository; + const cwd = yield* makeWorkspaceDirectory; const harness = yield* makeTestProviderAdapterHarness; const registry: typeof ProviderAdapterRegistry.Service = { @@ -79,22 +52,15 @@ const makeIntegrationFixture = Effect.gen(function* () { }; const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), + Layer.provide(ProviderSessionRuntimeRepositoryLive), ); const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), - Layer.provide(CheckpointStoreLive, NodeServices.layer), - CheckpointRepositoryLive, ).pipe(Layer.provide(SqlitePersistenceMemory)); - const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared)); - - const layer = ProviderServiceLive.pipe( - Layer.provide(Layer.mergeAll(shared, checkpointLayer)), - Layer.merge(NodeServices.layer), - ); + const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); return { cwd, @@ -135,7 +101,7 @@ const runTurn = (input: { return yield* collectEventsDuring( input.provider.streamEvents, - input.response.events.length + 1, + input.response.events.length, input.provider.sendTurn({ sessionId: input.sessionId, input: input.userText, @@ -144,7 +110,7 @@ const runTurn = (input: { ); }); -it.effect("replays typed runtime fixture and emits checkpoint.captured", () => +it.effect("replays typed runtime fixture events", () => Effect.gen(function* () { const fixture = yield* makeIntegrationFixture; @@ -154,42 +120,25 @@ it.effect("replays typed runtime fixture and emits checkpoint.captured", () => provider: "codex", cwd: fixture.cwd, }); - const threadId = session.threadId ?? ""; - assert.equal(threadId.length > 0, true); + assert.equal((session.threadId ?? "").length > 0, true); const observedEvents = yield* runTurn({ provider, harness: fixture.harness, sessionId: session.sessionId, userText: "hello", - response: { events: codexTurnTextFixture }, + response: { events: codexTurnTextFixture }, }); assert.deepEqual( observedEvents.map((event) => event.type), - [...codexTurnTextFixture.map((event) => event.type), "checkpoint.captured"], + codexTurnTextFixture.map((event) => event.type), ); - - const checkpoints = yield* provider.listCheckpoints({ sessionId: session.sessionId }); - assert.deepEqual( - checkpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), - [0, 1], - ); - - const diff = yield* provider.getCheckpointDiff({ - sessionId: session.sessionId, - fromTurnCount: 0, - toTurnCount: 1, - }); - assert.equal(diff.diff.trim(), ""); - - const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); - assert.equal(turnOneRefExists, true); }).pipe(Effect.provide(fixture.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("captures file-changing fixture turn and produces a non-empty diff", () => +it.effect("replays file-changing fixture turn events", () => Effect.gen(function* () { const fixture = yield* makeIntegrationFixture; const { join } = yield* Path.Path; @@ -201,8 +150,7 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( provider: "codex", cwd: fixture.cwd, }); - const threadId = session.threadId ?? ""; - assert.equal(threadId.length > 0, true); + assert.equal((session.threadId ?? "").length > 0, true); const observedEvents = yield* runTurn({ provider, @@ -218,23 +166,13 @@ it.effect("captures file-changing fixture turn and produces a non-empty diff", ( assert.deepEqual( observedEvents.map((event) => event.type), - [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], + codexTurnToolFixture.map((event) => event.type), ); - - const diff = yield* provider.getCheckpointDiff({ - sessionId: session.sessionId, - fromTurnCount: 0, - toTurnCount: 1, - }); - assert.equal(diff.diff.includes("README.md"), true); - - const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); - assert.equal(turnOneRefExists, true); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () => +it.effect("runs multi-turn tool/approval flow", () => Effect.gen(function* () { const fixture = yield* makeIntegrationFixture; const { join } = yield* Path.Path; @@ -246,8 +184,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = provider: "codex", cwd: fixture.cwd, }); - const threadId = session.threadId ?? ""; - assert.equal(threadId.length > 0, true); + assert.equal((session.threadId ?? "").length > 0, true); const firstTurnEvents = yield* runTurn({ provider, @@ -262,7 +199,7 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = }); assert.deepEqual( firstTurnEvents.map((event) => event.type), - [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], + codexTurnToolFixture.map((event) => event.type), ); const secondTurnEvents = yield* runTurn({ @@ -278,25 +215,13 @@ it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () = }); assert.deepEqual( secondTurnEvents.map((event) => event.type), - [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"], + codexTurnApprovalFixture.map((event) => event.type), ); - - const turnStateBeforeRevert = yield* provider.listCheckpoints({ - sessionId: session.sessionId, - }); - assert.deepEqual( - turnStateBeforeRevert.checkpoints.map((checkpoint) => checkpoint.turnCount), - [0, 1, 2], - ); - const current = - turnStateBeforeRevert.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? - turnStateBeforeRevert.checkpoints[turnStateBeforeRevert.checkpoints.length - 1]; - assert.equal(current?.turnCount, 2); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("reverts to turn 1 and prunes later checkpoint refs", () => +it.effect("rolls back provider conversation state only", () => Effect.gen(function* () { const fixture = yield* makeIntegrationFixture; const { join } = yield* Path.Path; @@ -308,8 +233,7 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => provider: "codex", cwd: fixture.cwd, }); - const threadId = session.threadId ?? ""; - assert.equal(threadId.length > 0, true); + assert.equal((session.threadId ?? "").length > 0, true); yield* runTurn({ provider, @@ -335,32 +259,16 @@ it.effect("reverts to turn 1 and prunes later checkpoint refs", () => }, }); - const turnTwoRef = checkpointRefForTurn(threadId, 2); - const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef); - assert.equal(turnTwoRefBefore, true); - - const revert = yield* provider.revertToCheckpoint({ + yield* provider.rollbackConversation({ sessionId: session.sessionId, - turnCount: 1, + numTurns: 1, }); - assert.equal(revert.rolledBackTurns, 1); const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); assert.deepEqual(rollbackCalls, [1]); const readme = yield* readFileString(join(fixture.cwd, "README.md")); - assert.equal(readme, "v2\n"); - - const afterRevertCheckpoints = yield* provider.listCheckpoints({ - sessionId: session.sessionId, - }); - assert.deepEqual( - afterRevertCheckpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), - [0, 1], - ); - - const turnTwoRefAfter = yield* gitRefExists(fixture.cwd, turnTwoRef); - assert.equal(turnTwoRefAfter, false); + assert.equal(readme, "v3\n"); }).pipe(Effect.provide(fixture.layer)); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 2e9bbed0997a907a27d06ac29ca5314457467102..0e0ecf1d0757fb63a867719f3ec2d4db2d73c443 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,39 +1,6 @@ import { Schema } from "effect"; -import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts"; - -/** - * CheckpointValidationError - Invalid checkpoint API input. - */ -export class CheckpointValidationError extends Schema.TaggedErrorClass()( - "CheckpointValidationError", - { - operation: Schema.String, - issue: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; - } -} - -/** - * CheckpointGitCommandError - Git command execution failed in checkpoint store. - */ -export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( - "CheckpointGitCommandError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } -} +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; +import { GitCommandError } from "../git/Errors.ts"; /** * CheckpointUnavailableError - Expected checkpoint does not exist. @@ -68,22 +35,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( - "CheckpointServiceValidationError", - { - operation: Schema.String, - issue: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint service validation failed in ${this.operation}: ${this.issue}`; - } -} - /** * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. */ @@ -116,13 +67,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass - runGitProcess({ - operation: "CheckpointService.test.git", - cwd, - args, - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.provide(NodeServices.layer), - ); - -const makeGitRepository = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectory(); - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@example.com"]); - yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), "v1\n"); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "Initial"]); - return cwd; -}); - -class InMemoryProviderAdapter implements ProviderAdapterShape { - readonly provider = "codex" as const; - - private readThreadFailure: ProviderAdapterError | undefined; - private readonly sessions = new Map(); - private readonly snapshots = new Map(); - - constructor( - readonly sessionId: string, - readonly cwd: string, - turns: ReadonlyArray, - ) { - this.addSession({ - sessionId, - threadId: "thread-1", - turns, - }); - } - - addSession(input: { - readonly sessionId: string; - readonly threadId: string; - readonly turns: ReadonlyArray; - }): void { - const now = new Date().toISOString(); - this.sessions.set(input.sessionId, { - sessionId: input.sessionId, - provider: "codex", - status: "ready", - threadId: input.threadId, - cwd: this.cwd, - createdAt: now, - updatedAt: now, - }); - this.snapshots.set(input.sessionId, { - threadId: input.threadId, - turns: input.turns, - }); - } - - setTurns(turns: ReadonlyArray, sessionId = this.sessionId): void { - const snapshot = this.snapshots.get(sessionId); - if (!snapshot) { - return; - } - this.snapshots.set(sessionId, { - threadId: snapshot.threadId, - turns, - }); - } - - getTurns(sessionId = this.sessionId): ReadonlyArray { - return this.snapshots.get(sessionId)?.turns ?? []; - } - - setReadThreadFailure(error: ProviderAdapterError | undefined): void { - this.readThreadFailure = error; - } - - readonly startSession = ( - _input: ProviderSessionStartInput, - ): Effect.Effect => - Effect.fail( - new ProviderAdapterValidationError({ - provider: this.provider, - operation: "startSession", - issue: "not implemented in test adapter", - }), - ); - - readonly sendTurn = ( - _input: ProviderSendTurnInput, - ): Effect.Effect => - Effect.fail( - new ProviderAdapterValidationError({ - provider: this.provider, - operation: "sendTurn", - issue: "not implemented in test adapter", - }), - ); - - readonly interruptTurn = ( - _sessionId: string, - _turnId?: string, - ): Effect.Effect => Effect.void; - - readonly respondToRequest = ( - _sessionId: string, - _requestId: string, - _decision: ProviderApprovalDecision, - ): Effect.Effect => Effect.void; - - readonly stopSession = (sessionId: string): Effect.Effect => - Effect.sync(() => { - this.sessions.delete(sessionId); - this.snapshots.delete(sessionId); - }); - - readonly listSessions = (): Effect.Effect> => - Effect.sync(() => Array.from(this.sessions.values())); - - readonly hasSession = (sessionId: string): Effect.Effect => - Effect.succeed(this.sessions.has(sessionId)); - - readonly readThread = ( - sessionId: string, - ): Effect.Effect => - this.hasSession(sessionId).pipe( - Effect.flatMap((hasSession) => - hasSession - ? this.readThreadFailure - ? Effect.fail(this.readThreadFailure) - : (() => { - const snapshot = this.snapshots.get(sessionId); - if (!snapshot) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: this.provider, - sessionId, - }), - ); - } - return Effect.succeed(snapshot); - })() - : Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: this.provider, - sessionId, - }), - ), - ), - ); - - readonly rollbackThread = ( - sessionId: string, - numTurns: number, - ): Effect.Effect => - this.readThread(sessionId).pipe( - Effect.flatMap((snapshot) => { - if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > snapshot.turns.length) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: this.provider, - operation: "rollbackThread", - issue: "invalid rollback input", - }), - ); - } - const next = { - threadId: snapshot.threadId, - turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns), - } satisfies ProviderThreadSnapshot; - this.snapshots.set(sessionId, next); - return Effect.succeed(next); - }), - ); - - readonly stopAll = (): Effect.Effect => - Effect.sync(() => { - this.sessions.clear(); - this.snapshots.clear(); - }); - - readonly streamEvents = Stream.empty; -} - -function makeFixture(turns: ReadonlyArray) { - return Effect.gen(function* () { - const cwd = yield* makeGitRepository; - - const sessionId = "sess-1"; - const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns); - - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider: ProviderKind) => - provider === "codex" - ? Effect.succeed(adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; - - const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), - ); - - const dependencies = Layer.mergeAll( - Layer.provide(CheckpointStoreLive, NodeServices.layer), - CheckpointRepositoryLive, - Layer.succeed(ProviderAdapterRegistry, registry), - directoryLayer, - NodeServices.layer, - ).pipe(Layer.provide(SqlitePersistenceMemory)); - - const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); - - return { - layer, - adapter, - sessionId, - cwd, - }; - }); -} - -it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () => - Effect.gen(function* () { - const firstTurn: ProviderThreadTurnSnapshot = { - id: "turn-1", - items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], - }; - const secondTurn: ProviderThreadTurnSnapshot = { - id: "turn-2", - items: [{ type: "agentMessage", text: "second" }], - }; - - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const service = yield* CheckpointService; - const directory = yield* ProviderSessionDirectory; - - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); - - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); - - yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); - fixture.adapter.setTurns([firstTurn]); - - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); - assert.equal(listed.checkpoints.length, 2); - assert.equal(listed.checkpoints[1]?.id, "turn-1"); - assert.equal(listed.checkpoints[1]?.isCurrent, true); - - const diff = yield* service.getCheckpointDiff({ - sessionId: fixture.sessionId, - fromTurnCount: 0, - toTurnCount: 1, - }); - assert.equal(diff.threadId, "thread-1"); - assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); - assert.equal(diff.diff.includes("-v1"), true); - assert.equal(diff.diff.includes("+v2"), true); - - yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n"); - fixture.adapter.setTurns([firstTurn, secondTurn]); - - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const beforeRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); - assert.equal(beforeRevert, "v3\n"); - - const reverted = yield* service.revertToCheckpoint({ - sessionId: fixture.sessionId, - turnCount: 1, - }); - - const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); - assert.equal(afterRevert, "v2\n"); - assert.equal(reverted.turnCount, 1); - assert.equal(reverted.rolledBackTurns, 1); - assert.equal(reverted.checkpoints.length, 2); - assert.equal(reverted.checkpoints[1]?.id, "turn-1"); - assert.equal(reverted.checkpoints[1]?.isCurrent, true); - assert.equal(fixture.adapter.getTurns().length, 1); - }).pipe(Effect.provide(fixture.layer)); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect( - "CheckpointServiceLive reuses checkpoint history after session churn on the same thread", - () => - Effect.gen(function* () { - const firstTurn: ProviderThreadTurnSnapshot = { - id: "turn-1", - items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], - }; - const secondTurn: ProviderThreadTurnSnapshot = { - id: "turn-2", - items: [{ type: "agentMessage", text: "second" }], - }; - - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* CheckpointService; - const directory = yield* ProviderSessionDirectory; - - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); - - yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); - fixture.adapter.setTurns([firstTurn]); - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const resumedSessionId = "sess-2"; - fixture.adapter.addSession({ - sessionId: resumedSessionId, - threadId: "thread-1", - turns: [firstTurn, secondTurn], - }); - yield* directory.upsert({ - sessionId: resumedSessionId, - provider: "codex", - threadId: "thread-1", - }); - - yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n"); - yield* service.captureCurrentTurn({ - providerSessionId: resumedSessionId, - }); - - const listed = yield* service.listCheckpoints({ sessionId: resumedSessionId }); - assert.equal(listed.threadId, "thread-1"); - assert.equal(listed.checkpoints.length, 3); - assert.equal(listed.checkpoints[2]?.id, "turn-2"); - assert.equal(listed.checkpoints[2]?.isCurrent, true); - - const diff = yield* service.getCheckpointDiff({ - sessionId: resumedSessionId, - fromTurnCount: 1, - toTurnCount: 2, - }); - assert.equal(diff.threadId, "thread-1"); - assert.equal(diff.diff.includes("-v2"), true); - assert.equal(diff.diff.includes("+v3"), true); - - const reverted = yield* service.revertToCheckpoint({ - sessionId: resumedSessionId, - turnCount: 1, - }); - const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); - assert.equal(afterRevert, "v2\n"); - assert.equal(reverted.turnCount, 1); - assert.equal(reverted.rolledBackTurns, 1); - assert.equal(fixture.adapter.getTurns(resumedSessionId).length, 1); - }).pipe(Effect.provide(fixture.layer)); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect( - "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions", - () => - Effect.gen(function* () { - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const service = yield* CheckpointService; - const result = yield* service - .listCheckpoints({ - sessionId: "missing", - }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.equal(result.failure._tag, "CheckpointSessionNotFoundError"); - } - }).pipe(Effect.provide(fixture.layer)); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect( - "CheckpointServiceLive fails with CheckpointUnavailableError when a checkpoint ref is missing", - () => - Effect.gen(function* () { - const firstTurn: ProviderThreadTurnSnapshot = { - id: "turn-1", - items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], - }; - - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* CheckpointService; - const store = yield* CheckpointStore; - const checkpointRepository = yield* CheckpointRepository; - const directory = yield* ProviderSessionDirectory; - - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); - - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); - - yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); - fixture.adapter.setTurns([firstTurn]); - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const checkpoint = yield* checkpointRepository.getCheckpoint({ - threadId: "thread-1", - turnCount: 1, - }); - if (checkpoint._tag !== "Some") { - assert.fail("Expected turn-1 checkpoint row to exist."); - } - - yield* store.deleteCheckpointRefs({ - cwd: fixture.cwd, - checkpointRefs: [checkpoint.value.checkpointRef], - }); - - const result = yield* service - .revertToCheckpoint({ - sessionId: fixture.sessionId, - turnCount: 1, - }) - .pipe(Effect.result); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.equal(result.failure._tag, "CheckpointUnavailableError"); - } - }).pipe(Effect.provide(fixture.layer)); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect("CheckpointServiceLive initializes root checkpoints without requiring thread/read", () => - Effect.gen(function* () { - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const service = yield* CheckpointService; - const checkpointRepository = yield* CheckpointRepository; - const directory = yield* ProviderSessionDirectory; - - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); - fixture.adapter.setReadThreadFailure( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "readThread", - issue: "thread/read is temporarily unavailable", - }), - ); - - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); - - const root = yield* checkpointRepository.getCheckpoint({ - threadId: "thread-1", - turnCount: 0, - }); - assert.equal(root._tag, "Some"); - if (root._tag === "Some") { - assert.equal(root.value.threadId, "thread-1"); - } - }).pipe(Effect.provide(fixture.layer)); - }).pipe(Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.ts b/apps/server/src/checkpointing/Layers/CheckpointService.ts deleted file mode 100644 index 9ec5a37011e461ac538c6ab609ca051a52fd6912..0000000000000000000000000000000000000000 --- a/apps/server/src/checkpointing/Layers/CheckpointService.ts +++ /dev/null @@ -1,1108 +0,0 @@ -/** - * CheckpointServiceLive - Checkpoint workflow orchestration layer. - * - * Coordinates provider thread snapshots, filesystem checkpoint refs - * (`CheckpointStore`), and checkpoint metadata persistence - * (`CheckpointRepository`) for initialize/capture/list/diff/revert workflows. - * - * This layer owns checkpoint workflow rules and invariants. It does not execute - * provider protocol details directly (adapter concern), and it does not perform - * raw SQL or Git operations directly (catalog/store concerns). - * - * @module CheckpointServiceLive - */ -import type { - ProviderCheckpoint, - ProviderGetCheckpointDiffInput, - ProviderListCheckpointsInput, - ProviderRevertToCheckpointInput, -} from "@t3tools/contracts"; -import { - providerGetCheckpointDiffInputSchema, - providerListCheckpointsInputSchema, - providerRevertToCheckpointInputSchema, -} from "@t3tools/contracts"; -import { Effect, Layer, Option, Ref, Semaphore } from "effect"; - -import { - CheckpointInvariantError, - CheckpointRepositoryError, - CheckpointServiceValidationError, - CheckpointSessionNotFoundError, - CheckpointUnavailableError, - type CheckpointServiceError, -} from "../Errors.ts"; -import { - CheckpointService, - type CaptureCurrentTurnInput, - type CheckpointServiceShape, - type InitializeCheckpointSessionInput, -} from "../Services/CheckpointService.ts"; -import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; -import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; -import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; -import type { - ProviderAdapterShape, - ProviderThreadSnapshot, - ProviderThreadTurnSnapshot, -} from "../../provider/Services/ProviderAdapter.ts"; -import type { ProviderAdapterError } from "../../provider/Errors.ts"; - -interface CheckpointCwdResolution { - readonly candidateCwd: string; - readonly checkpointCwd: Option.Option; -} - -const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; - -function checkpointRefThreadSegment(threadId: string): string { - return Buffer.from(threadId, "utf8").toString("base64url"); -} - -function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { - return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function trimToPreview(value: string): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (normalized.length <= 120) { - return normalized; - } - return `${normalized.slice(0, 117)}...`; -} - -function summarizeUserMessageContent(content: unknown[]): string | undefined { - const segments: string[] = []; - - for (const part of content) { - const record = asObject(part); - const type = asString(record?.type); - if (!type) { - continue; - } - - if (type === "text") { - const text = asString(record?.text); - if (text && text.trim().length > 0) { - segments.push(text.trim()); - } - continue; - } - - if (type === "image") { - segments.push("[Image attachment]"); - continue; - } - - if (type === "localImage") { - segments.push("[Local image attachment]"); - } - } - - if (segments.length === 0) { - return undefined; - } - return trimToPreview(segments.join(" ")); -} - -function summarizeTurn(turn: ProviderThreadTurnSnapshot): { - readonly messageCountDelta: number; - readonly preview?: string; -} { - let messageCountDelta = 0; - let preview: string | undefined; - - for (const item of turn.items) { - const record = asObject(item); - const type = asString(record?.type); - if (!type) { - continue; - } - - if (type === "userMessage") { - messageCountDelta += 1; - if (!preview) { - const content = asArray(record?.content); - preview = summarizeUserMessageContent(content); - } - continue; - } - - if (type === "agentMessage") { - messageCountDelta += 1; - if (!preview) { - const text = asString(record?.text); - if (text && text.trim().length > 0) { - preview = trimToPreview(text); - } - } - } - } - - return { - messageCountDelta, - ...(preview ? { preview } : {}), - }; -} - -function buildCheckpoints(turns: ReadonlyArray): ProviderCheckpoint[] { - const checkpoints: ProviderCheckpoint[] = []; - let messageCount = 0; - const isEmpty = turns.length === 0; - - checkpoints.push({ - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: isEmpty, - }); - - for (let index = 0; index < turns.length; index += 1) { - const turn = turns[index]; - if (!turn) { - continue; - } - - const turnSummary = summarizeTurn(turn); - messageCount += turnSummary.messageCountDelta; - checkpoints.push({ - id: turn.id, - turnCount: index + 1, - messageCount, - label: `Turn ${index + 1}`, - ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), - isCurrent: index === turns.length - 1, - }); - } - - return checkpoints; -} - -function rootCheckpoint(): ProviderCheckpoint { - return { - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: true, - }; -} - -function validationError( - operation: string, - issue: string, - cause?: unknown, -): CheckpointServiceValidationError { - return new CheckpointServiceValidationError({ - operation, - issue, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function invariantError( - operation: string, - detail: string, - cause?: unknown, -): CheckpointInvariantError { - return new CheckpointInvariantError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function normalizeNonEmpty( - value: string, - operation: string, - field: string, -): Effect.Effect { - const normalized = value.trim(); - if (normalized.length === 0) { - return Effect.fail(validationError(operation, `${field} must be a non-empty string.`)); - } - return Effect.succeed(normalized); -} - -function decodeInitializeInput( - input: InitializeCheckpointSessionInput, -): Effect.Effect { - return Effect.gen(function* () { - const operation = "CheckpointService.initializeForSession"; - const providerSessionId = yield* normalizeNonEmpty( - input.providerSessionId, - operation, - "providerSessionId", - ); - const cwd = yield* normalizeNonEmpty(input.cwd, operation, "cwd"); - return { - providerSessionId, - cwd, - } satisfies InitializeCheckpointSessionInput; - }); -} - -function decodeCaptureInput( - input: CaptureCurrentTurnInput, -): Effect.Effect { - return Effect.gen(function* () { - const operation = "CheckpointService.captureCurrentTurn"; - const providerSessionId = yield* normalizeNonEmpty( - input.providerSessionId, - operation, - "providerSessionId", - ); - const turnId = input.turnId?.trim(); - const status = input.status?.trim(); - - return { - providerSessionId, - ...(turnId ? { turnId } : {}), - ...(status ? { status } : {}), - } satisfies CaptureCurrentTurnInput; - }); -} - -function decodeListInput( - input: ProviderListCheckpointsInput, -): Effect.Effect { - const parsed = providerListCheckpointsInputSchema.safeParse(input); - if (!parsed.success) { - return Effect.fail( - validationError( - "CheckpointService.listCheckpoints", - parsed.error.message, - parsed.error.cause, - ), - ); - } - return Effect.succeed(parsed.data); -} - -function decodeDiffInput( - input: ProviderGetCheckpointDiffInput, -): Effect.Effect { - const parsed = providerGetCheckpointDiffInputSchema.safeParse(input); - if (!parsed.success) { - return Effect.fail( - validationError( - "CheckpointService.getCheckpointDiff", - parsed.error.message, - parsed.error.cause, - ), - ); - } - return Effect.succeed(parsed.data); -} - -function decodeRevertInput( - input: ProviderRevertToCheckpointInput, -): Effect.Effect { - const parsed = providerRevertToCheckpointInputSchema.safeParse(input); - if (!parsed.success) { - return Effect.fail( - validationError( - "CheckpointService.revertToCheckpoint", - parsed.error.message, - parsed.error.cause, - ), - ); - } - return Effect.succeed(parsed.data); -} - -const makeCheckpointService = Effect.gen(function* () { - const store = yield* CheckpointStore; - const checkpointRepository = yield* CheckpointRepository; - const registry = yield* ProviderAdapterRegistry; - const directory = yield* ProviderSessionDirectory; - - const sessionCheckpointCwdsRef = yield* Ref.make>(new Map()); - const sessionSemaphoresRef = yield* Ref.make>( - new Map(), - ); - - const getSessionSemaphore = (sessionId: string) => - Ref.modify(sessionSemaphoresRef, (current) => { - const existing = current.get(sessionId); - if (existing) { - return [existing, current] as const; - } - - const next = new Map(current); - const semaphore = Semaphore.makeUnsafe(1); - next.set(sessionId, semaphore); - return [semaphore, next] as const; - }); - - const withSessionLock = ( - sessionId: string, - effect: Effect.Effect, - ) => - getSessionSemaphore(sessionId).pipe( - Effect.flatMap((semaphore) => semaphore.withPermits(1)(effect)), - ); - - const setTrackedCheckpointCwd = (sessionId: string, cwd: string) => - Ref.update(sessionCheckpointCwdsRef, (current) => { - const next = new Map(current); - next.set(sessionId, cwd); - return next; - }); - - const clearTrackedCheckpointCwd = (sessionId: string) => - Ref.update(sessionCheckpointCwdsRef, (current) => { - if (!current.has(sessionId)) { - return current; - } - const next = new Map(current); - next.delete(sessionId); - return next; - }); - - const clearSessionSemaphore = (sessionId: string) => - Ref.update(sessionSemaphoresRef, (current) => { - if (!current.has(sessionId)) { - return current; - } - const next = new Map(current); - next.delete(sessionId); - return next; - }); - - const getTrackedCheckpointCwd = (sessionId: string) => - Ref.get(sessionCheckpointCwdsRef).pipe( - Effect.map((current) => Option.fromNullishOr(current.get(sessionId))), - ); - - const mapDirectoryLookupError = ( - sessionId: string, - operation: string, - cause: unknown, - ): CheckpointSessionNotFoundError | CheckpointInvariantError => { - if ( - typeof cause === "object" && - cause !== null && - "_tag" in cause && - (cause as { _tag?: string })._tag === "ProviderSessionNotFoundError" - ) { - return new CheckpointSessionNotFoundError({ - sessionId, - cause, - }); - } - - return invariantError( - operation, - `Failed to resolve provider session metadata for session ${sessionId}.`, - cause, - ); - }; - - const resolveAdapterForSession = ( - sessionId: string, - ): Effect.Effect, CheckpointServiceError> => - directory.getProvider(sessionId).pipe( - Effect.mapError((cause) => - mapDirectoryLookupError( - sessionId, - "CheckpointService.resolveAdapterForSession:getProvider", - cause, - ), - ), - Effect.flatMap((provider) => - registry - .getByProvider(provider) - .pipe( - Effect.mapError((cause) => - invariantError( - "CheckpointService.resolveAdapterForSession", - `Provider '${provider}' is not registered for session ${sessionId}.`, - cause, - ), - ), - ), - ), - ); - - const ensureSessionIsActive = ( - sessionId: string, - adapter: ProviderAdapterShape, - ): Effect.Effect => - adapter.hasSession(sessionId).pipe( - Effect.flatMap((hasSession) => - hasSession - ? Effect.void - : Effect.fail( - new CheckpointSessionNotFoundError({ - sessionId, - }), - ), - ), - ); - - const readThreadSnapshot = ( - sessionId: string, - adapter: ProviderAdapterShape, - operation: string, - ): Effect.Effect => - adapter - .readThread(sessionId) - .pipe( - Effect.mapError((cause) => - invariantError( - operation, - `Failed to read thread snapshot for session ${sessionId}.`, - cause, - ), - ), - ); - - const resolveThreadIdForSession = ( - sessionId: string, - operation: string, - ): Effect.Effect => - directory.getThreadId(sessionId).pipe( - Effect.mapError((cause) => - mapDirectoryLookupError(sessionId, `${operation}:getThreadId`, cause), - ), - Effect.flatMap((threadId) => - Option.match(threadId, { - onSome: (value) => { - const normalizedThreadId = value.trim(); - if (normalizedThreadId.length > 0) { - return Effect.succeed(normalizedThreadId); - } - return Effect.fail( - invariantError(operation, `Resolved empty thread id for session ${sessionId}.`), - ); - }, - onNone: () => - Effect.fail( - invariantError( - operation, - `Missing thread id in session directory for session ${sessionId}.`, - ), - ), - }), - ), - ); - - const ensureThreadMatchesSnapshot = ( - resolvedThreadId: string, - snapshot: ProviderThreadSnapshot, - operation: string, - ): Effect.Effect => { - const snapshotThreadId = snapshot.threadId.trim(); - if (snapshotThreadId.length === 0) { - return Effect.fail( - invariantError(operation, "Provider thread snapshot is missing threadId."), - ); - } - - if (snapshotThreadId !== resolvedThreadId) { - return Effect.fail( - invariantError( - operation, - `Resolved thread ${resolvedThreadId} does not match provider snapshot thread ${snapshotThreadId}.`, - ), - ); - } - - return Effect.void; - }; - - const rollbackThreadSnapshot = ( - sessionId: string, - adapter: ProviderAdapterShape, - numTurns: number, - operation: string, - ): Effect.Effect => - adapter - .rollbackThread(sessionId, numTurns) - .pipe( - Effect.mapError((cause) => - invariantError(operation, `Failed to rollback thread for session ${sessionId}.`, cause), - ), - ); - - const persistCheckpointMetadata = (input: { - readonly providerSessionId: string; - readonly threadId: string; - readonly checkpoint: ProviderCheckpoint; - }): Effect.Effect => - checkpointRepository.upsertCheckpoint({ - providerSessionId: input.providerSessionId, - threadId: input.threadId, - checkpointId: input.checkpoint.id, - checkpointRef: checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount), - turnCount: input.checkpoint.turnCount, - messageCount: input.checkpoint.messageCount, - label: input.checkpoint.label, - ...(input.checkpoint.preview !== undefined ? { preview: input.checkpoint.preview } : {}), - createdAt: new Date().toISOString(), - }); - - const captureAndPersistCheckpoint = (input: { - readonly providerSessionId: string; - readonly cwd: string; - readonly threadId: string; - readonly checkpoint: ProviderCheckpoint; - }): Effect.Effect => { - const checkpointRef = checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount); - return Effect.gen(function* () { - yield* store.captureCheckpoint({ - cwd: input.cwd, - checkpointRef, - }); - - yield* persistCheckpointMetadata({ - providerSessionId: input.providerSessionId, - threadId: input.threadId, - checkpoint: input.checkpoint, - }).pipe( - Effect.catch((error) => - store - .deleteCheckpointRefs({ - cwd: input.cwd, - checkpointRefs: [checkpointRef], - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); - }); - }; - - const latestCheckpointFromSnapshot = ( - snapshot: ProviderThreadSnapshot, - operation: string, - ): Effect.Effect => { - if (snapshot.turns.length === 0) { - return Effect.fail( - invariantError(operation, "Cannot derive latest checkpoint from an empty thread snapshot."), - ); - } - - const checkpoints = buildCheckpoints(snapshot.turns); - const latest = checkpoints[checkpoints.length - 1]; - if (!latest || latest.turnCount !== snapshot.turns.length) { - return Effect.fail( - invariantError( - operation, - `Unable to derive checkpoint metadata for turn ${snapshot.turns.length}.`, - ), - ); - } - - return Effect.succeed(latest); - }; - - const ensureRootCheckpointMetadata = ( - threadId: string, - operation: string, - ): Effect.Effect => - checkpointRepository - .getCheckpoint({ threadId, turnCount: 0 }) - .pipe( - Effect.flatMap((rootEntry) => - Option.isSome(rootEntry) - ? Effect.void - : Effect.fail( - invariantError( - operation, - `Missing root checkpoint metadata for thread ${threadId}.`, - ), - ), - ), - ); - - const getCheckpointRefOrFail = ( - threadId: string, - turnCount: number, - operation: string, - ): Effect.Effect => - checkpointRepository.getCheckpoint({ threadId, turnCount }).pipe( - Effect.mapError((error): CheckpointServiceError => error), - Effect.flatMap((checkpoint): Effect.Effect => { - if (Option.isNone(checkpoint)) { - return Effect.fail( - new CheckpointUnavailableError({ - threadId, - turnCount, - detail: `Filesystem checkpoint is unavailable for turn ${turnCount} in thread ${threadId}.`, - }), - ); - } - - const checkpointRef = checkpoint.value.checkpointRef.trim(); - if (checkpointRef.length === 0) { - return Effect.fail( - invariantError( - operation, - `Checkpoint metadata for turn ${turnCount} is missing checkpointRef.`, - ), - ); - } - - return Effect.succeed(checkpointRef); - }), - ); - - const resolveCandidateCwd = ( - sessionId: string, - adapter: ProviderAdapterShape, - ): Effect.Effect => - adapter.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.cwd), - Effect.map((cwd) => { - const candidate = cwd?.trim() || process.cwd(); - if (candidate.length > 0) { - return candidate; - } - return process.cwd(); - }), - ); - - const resolveCheckpointCwd = ( - sessionId: string, - adapter: ProviderAdapterShape, - ): Effect.Effect => - Effect.gen(function* () { - const tracked = yield* getTrackedCheckpointCwd(sessionId); - if (Option.isSome(tracked)) { - return { - candidateCwd: tracked.value, - checkpointCwd: tracked, - } satisfies CheckpointCwdResolution; - } - - const candidateCwd = yield* resolveCandidateCwd(sessionId, adapter); - const supportsGit = yield* store.isGitRepository(candidateCwd); - if (!supportsGit) { - yield* clearTrackedCheckpointCwd(sessionId); - return { - candidateCwd, - checkpointCwd: Option.none(), - } satisfies CheckpointCwdResolution; - } - - yield* setTrackedCheckpointCwd(sessionId, candidateCwd); - - return { - candidateCwd, - checkpointCwd: Option.some(candidateCwd), - } satisfies CheckpointCwdResolution; - }); - - const initializeForSession: CheckpointServiceShape["initializeForSession"] = (rawInput) => - decodeInitializeInput(rawInput).pipe( - Effect.flatMap((input) => - withSessionLock( - input.providerSessionId, - Effect.gen(function* () { - const adapter = yield* resolveAdapterForSession(input.providerSessionId); - yield* ensureSessionIsActive(input.providerSessionId, adapter); - const threadId = yield* resolveThreadIdForSession( - input.providerSessionId, - "CheckpointService.initializeForSession", - ); - - const supportsGit = yield* store.isGitRepository(input.cwd); - if (!supportsGit) { - yield* clearTrackedCheckpointCwd(input.providerSessionId); - return; - } - - const existingRoot = yield* checkpointRepository.getCheckpoint({ - threadId, - turnCount: 0, - }); - if (Option.isSome(existingRoot)) { - const existingRef = existingRoot.value.checkpointRef.trim(); - if (existingRef.length === 0) { - return yield* invariantError( - "CheckpointService.initializeForSession", - "Existing root checkpoint metadata is missing checkpointRef.", - ); - } - const exists = yield* store.hasCheckpointRef({ - cwd: input.cwd, - checkpointRef: existingRef, - }); - if (!exists) { - return yield* invariantError( - "CheckpointService.initializeForSession", - "Root checkpoint metadata exists but filesystem ref is missing.", - ); - } - yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); - return; - } - - yield* captureAndPersistCheckpoint({ - providerSessionId: input.providerSessionId, - cwd: input.cwd, - threadId, - checkpoint: rootCheckpoint(), - }); - yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); - }), - ), - ), - ); - - const captureCurrentTurn: CheckpointServiceShape["captureCurrentTurn"] = (rawInput) => - decodeCaptureInput(rawInput).pipe( - Effect.flatMap((input) => - withSessionLock( - input.providerSessionId, - Effect.gen(function* () { - const adapter = yield* resolveAdapterForSession(input.providerSessionId); - yield* ensureSessionIsActive(input.providerSessionId, adapter); - const snapshot = yield* readThreadSnapshot( - input.providerSessionId, - adapter, - "CheckpointService.captureCurrentTurn:readThread", - ); - const threadId = yield* resolveThreadIdForSession( - input.providerSessionId, - "CheckpointService.captureCurrentTurn", - ); - yield* ensureThreadMatchesSnapshot( - threadId, - snapshot, - "CheckpointService.captureCurrentTurn", - ); - if (snapshot.turns.length === 0) { - return; - } - - const resolution = yield* resolveCheckpointCwd(input.providerSessionId, adapter); - if (Option.isNone(resolution.checkpointCwd)) { - return; - } - - const checkpoint = yield* latestCheckpointFromSnapshot( - snapshot, - "CheckpointService.captureCurrentTurn", - ); - - yield* ensureRootCheckpointMetadata(threadId, "CheckpointService.captureCurrentTurn"); - - const existing = yield* checkpointRepository.getCheckpoint({ - threadId, - turnCount: checkpoint.turnCount, - }); - if (Option.isSome(existing)) { - const checkpointRef = existing.value.checkpointRef.trim(); - if (checkpointRef.length === 0) { - return yield* invariantError( - "CheckpointService.captureCurrentTurn", - `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`, - ); - } - const exists = yield* store.hasCheckpointRef({ - cwd: resolution.checkpointCwd.value, - checkpointRef, - }); - if (!exists) { - return yield* invariantError( - "CheckpointService.captureCurrentTurn", - `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`, - ); - } - return; - } - - yield* captureAndPersistCheckpoint({ - providerSessionId: input.providerSessionId, - cwd: resolution.checkpointCwd.value, - threadId, - checkpoint, - }); - }), - ), - ), - ); - - const listCheckpoints: CheckpointServiceShape["listCheckpoints"] = (rawInput) => - decodeListInput(rawInput).pipe( - Effect.flatMap((input) => - withSessionLock( - input.sessionId, - Effect.gen(function* () { - const adapter = yield* resolveAdapterForSession(input.sessionId); - yield* ensureSessionIsActive(input.sessionId, adapter); - const snapshot = yield* readThreadSnapshot( - input.sessionId, - adapter, - "CheckpointService.listCheckpoints:readThread", - ); - const threadId = yield* resolveThreadIdForSession( - input.sessionId, - "CheckpointService.listCheckpoints", - ); - yield* ensureThreadMatchesSnapshot( - threadId, - snapshot, - "CheckpointService.listCheckpoints", - ); - - const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); - if (Option.isNone(resolution.checkpointCwd)) { - return yield* new CheckpointRepositoryError({ - cwd: resolution.candidateCwd, - detail: "Filesystem checkpoints are unavailable for this session.", - }); - } - - const checkpoints = yield* checkpointRepository.listCheckpoints({ - threadId, - }); - if (checkpoints.length === 0) { - return yield* invariantError( - "CheckpointService.listCheckpoints", - `Missing checkpoint metadata for active session ${input.sessionId}.`, - ); - } - - return { - threadId, - checkpoints: Array.from(checkpoints), - }; - }), - ), - ), - ); - - const getCheckpointDiff: CheckpointServiceShape["getCheckpointDiff"] = (rawInput) => - decodeDiffInput(rawInput).pipe( - Effect.flatMap((input) => - withSessionLock( - input.sessionId, - Effect.gen(function* () { - const adapter = yield* resolveAdapterForSession(input.sessionId); - yield* ensureSessionIsActive(input.sessionId, adapter); - const snapshot = yield* readThreadSnapshot( - input.sessionId, - adapter, - "CheckpointService.getCheckpointDiff:readThread", - ); - const threadId = yield* resolveThreadIdForSession( - input.sessionId, - "CheckpointService.getCheckpointDiff", - ); - yield* ensureThreadMatchesSnapshot( - threadId, - snapshot, - "CheckpointService.getCheckpointDiff", - ); - - if (input.toTurnCount > snapshot.turns.length) { - return yield* validationError( - "CheckpointService.getCheckpointDiff", - `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, - ); - } - - const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); - if (Option.isNone(resolution.checkpointCwd)) { - return yield* new CheckpointRepositoryError({ - cwd: resolution.candidateCwd, - detail: "Filesystem checkpoints are unavailable for this session.", - }); - } - - const fromCheckpointRef = yield* getCheckpointRefOrFail( - threadId, - input.fromTurnCount, - "CheckpointService.getCheckpointDiff:fromCheckpoint", - ); - const toCheckpointRef = yield* getCheckpointRefOrFail( - threadId, - input.toTurnCount, - "CheckpointService.getCheckpointDiff:toCheckpoint", - ); - - const diff = yield* store.diffCheckpoints({ - cwd: resolution.checkpointCwd.value, - fromCheckpointRef, - toCheckpointRef, - fallbackFromToHead: input.fromTurnCount === 0, - }); - - return { - threadId, - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff, - }; - }), - ), - ), - ); - - const revertToCheckpoint: CheckpointServiceShape["revertToCheckpoint"] = (rawInput) => - decodeRevertInput(rawInput).pipe( - Effect.flatMap((input) => - withSessionLock( - input.sessionId, - Effect.gen(function* () { - const adapter = yield* resolveAdapterForSession(input.sessionId); - yield* ensureSessionIsActive(input.sessionId, adapter); - const beforeSnapshot = yield* readThreadSnapshot( - input.sessionId, - adapter, - "CheckpointService.revertToCheckpoint:readThread", - ); - const threadId = yield* resolveThreadIdForSession( - input.sessionId, - "CheckpointService.revertToCheckpoint", - ); - yield* ensureThreadMatchesSnapshot( - threadId, - beforeSnapshot, - "CheckpointService.revertToCheckpoint", - ); - const currentTurnCount = beforeSnapshot.turns.length; - - if (input.turnCount > currentTurnCount) { - return yield* validationError( - "CheckpointService.revertToCheckpoint", - `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, - ); - } - - const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); - if (Option.isNone(resolution.checkpointCwd)) { - return yield* new CheckpointRepositoryError({ - cwd: resolution.candidateCwd, - detail: "Filesystem checkpoints are unavailable for this session.", - }); - } - - const checkpointCwd = resolution.checkpointCwd.value; - const checkpointRef = yield* getCheckpointRefOrFail( - threadId, - input.turnCount, - "CheckpointService.revertToCheckpoint:checkpointLookup", - ); - - const restored = yield* store.restoreCheckpoint({ - cwd: checkpointCwd, - checkpointRef, - fallbackToHead: input.turnCount === 0, - }); - if (!restored) { - return yield* new CheckpointUnavailableError({ - threadId, - turnCount: input.turnCount, - detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${threadId}.`, - }); - } - - const requestedRollbackTurns = currentTurnCount - input.turnCount; - const afterSnapshot = - requestedRollbackTurns > 0 - ? yield* rollbackThreadSnapshot( - input.sessionId, - adapter, - requestedRollbackTurns, - "CheckpointService.revertToCheckpoint:rollbackThread", - ) - : beforeSnapshot; - - const staleTurnCounts: number[] = []; - for (let turn = input.turnCount + 1; turn <= currentTurnCount; turn += 1) { - staleTurnCounts.push(turn); - } - - const staleCheckpointRefs = yield* Effect.forEach( - staleTurnCounts, - (turnCount) => - checkpointRepository.getCheckpoint({ - threadId, - turnCount, - }), - { discard: false }, - ).pipe( - Effect.map((entries) => - entries - .flatMap((entry) => - Option.isSome(entry) ? [entry.value.checkpointRef.trim()] : [], - ) - .filter((ref) => ref.length > 0), - ), - ); - - yield* store.deleteCheckpointRefs({ - cwd: checkpointCwd, - checkpointRefs: staleCheckpointRefs, - }); - yield* checkpointRepository.deleteAfterTurn({ - threadId, - maxTurnCount: input.turnCount, - }); - const checkpoints = yield* checkpointRepository.listCheckpoints({ - threadId, - }); - const currentCheckpoint = - checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? - checkpoints[checkpoints.length - 1] ?? - checkpoints[0]; - const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); - - return { - threadId, - turnCount: currentCheckpoint?.turnCount ?? 0, - messageCount: currentCheckpoint?.messageCount ?? 0, - rolledBackTurns, - checkpoints: Array.from(checkpoints), - }; - }), - ), - ), - ); - - const releaseSession: CheckpointServiceShape["releaseSession"] = (input) => - Effect.gen(function* () { - const providerSessionId = yield* normalizeNonEmpty( - input.providerSessionId, - "CheckpointService.releaseSession", - "providerSessionId", - ); - yield* clearTrackedCheckpointCwd(providerSessionId); - yield* clearSessionSemaphore(providerSessionId); - }); - - return { - initializeForSession, - captureCurrentTurn, - listCheckpoints, - getCheckpointDiff, - revertToCheckpoint, - releaseSession, - } satisfies CheckpointServiceShape; -}); - -export const CheckpointServiceLive = Layer.effect(CheckpointService, makeCheckpointService); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 31b15cc6f02057bf64b3581ec79632f90e469c16..7e4615d30950a0031e744c9239cc9e023955bf9d 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -9,162 +9,83 @@ * * @module CheckpointStoreLive */ -import { mkdtemp, rm } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; -import { Effect, Layer } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Layer, FileSystem, Path } from "effect"; -import { - CheckpointGitCommandError, - CheckpointRepositoryError, - CheckpointUnavailableError, - type CheckpointStoreError, -} from "../Errors.ts"; +import { CheckpointInvariantError } from "../Errors.ts"; import { GitCommandError } from "../../git/Errors.ts"; -import { runGit, RunGitInput } from "../../git/Process.ts"; +import { GitServiceLive } from "../../git/Layer.ts"; +import { GitService } from "../../git/Service.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; - -function causeDetail(cause: unknown, fallback: string): string { - if (cause instanceof GitCommandError) { - return cause.detail; - } - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function mapFilesystemFailure( - operation: string, - cwd: string, - cause: unknown, -): CheckpointStoreError { - const detail = causeDetail(cause, `${operation} failed.`); - - const unavailable = - /^Filesystem checkpoint is unavailable for turn (\d+) in thread (.+)\.?$/i.exec(detail); - if (unavailable) { - const [, rawTurnCount, threadId] = unavailable; - const turnCount = Number.parseInt(rawTurnCount ?? "", 10); - if (threadId && Number.isInteger(turnCount) && turnCount >= 0) { - return new CheckpointUnavailableError({ - threadId, - turnCount, - detail, - cause, - }); - } - } - - const lowered = detail.toLowerCase(); - if (lowered.includes("not a git repository")) { - return new CheckpointRepositoryError({ - cwd, - detail, - cause, - }); - } - - return new CheckpointGitCommandError({ - operation, - command: cause instanceof GitCommandError ? cause.command : "git", - cwd, - detail, - cause, - }); -} - -function resolveHeadCommit( - cwd: string, -): Effect.Effect { - return runGit({ - operation: "CheckpointStore.resolveHeadCommit", - cwd, - args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], - allowNonZeroExit: true, - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - }), - ); -} - -function hasHeadCommit( - cwd: string, -): Effect.Effect { - return runGit({ - operation: "CheckpointStore.hasHeadCommit", - cwd, - args: ["rev-parse", "--verify", "HEAD"], - allowNonZeroExit: true, - }).pipe(Effect.map((result) => result.code === 0)); -} - -function resolveCheckpointCommit( - cwd: string, - checkpointRef: string, -): Effect.Effect { - return runGit({ - operation: "CheckpointStore.resolveCheckpointCommit", - cwd, - args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], - allowNonZeroExit: true, - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - }), - ); -} - -function removeDirectory(pathname: string): Effect.Effect { - return Effect.tryPromise({ - try: () => rm(pathname, { recursive: true, force: true }), - catch: (cause) => cause, - }).pipe(Effect.orDie); -} +import { CheckpointRef } from "@t3tools/contracts"; const makeCheckpointStore = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runGitWithSpawner = (input: RunGitInput) => - runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - const resolveHeadCommitWithSpawner = (cwd: string) => - resolveHeadCommit(cwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const hasHeadCommitWithSpawner = (cwd: string) => - hasHeadCommit(cwd).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) => - resolveCheckpointCommit(cwd, checkpointRef).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitService; + + const resolveHeadCommit = (cwd: string): Effect.Effect => + git + .execute({ + operation: "CheckpointStore.resolveHeadCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + allowNonZeroExit: true, + }) + .pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); + + const hasHeadCommit = (cwd: string): Effect.Effect => + git + .execute({ + operation: "CheckpointStore.hasHeadCommit", + cwd, + args: ["rev-parse", "--verify", "HEAD"], + allowNonZeroExit: true, + }) + .pipe(Effect.map((result) => result.code === 0)); + + const resolveCheckpointCommit = ( + cwd: string, + checkpointRef: CheckpointRef, + ): Effect.Effect => + git + .execute({ + operation: "CheckpointStore.resolveCheckpointCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], + allowNonZeroExit: true, + }) + .pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - Effect.gen(function* () { - const normalizedCwd = cwd.trim(); - - return yield* runGitWithSpawner({ + git + .execute({ operation: "CheckpointStore.isGitRepository", - cwd: normalizedCwd, + cwd: cwd.trim(), args: ["rev-parse", "--is-inside-work-tree"], allowNonZeroExit: true, - }).pipe( + }) + .pipe( Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), Effect.catch(() => Effect.succeed(false)), ); - }); const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => Effect.gen(function* () { @@ -173,10 +94,7 @@ const makeCheckpointStore = Effect.gen(function* () { const checkpointRef = input.checkpointRef.trim(); yield* Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")), - catch: (cause) => cause, - }), + fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), (tempDir) => Effect.gen(function* () { const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); @@ -184,14 +102,14 @@ const makeCheckpointStore = Effect.gen(function* () { ...process.env, GIT_INDEX_FILE: tempIndexPath, GIT_AUTHOR_NAME: "T3 Code", - GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", + GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", GIT_COMMITTER_NAME: "T3 Code", - GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", + GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; - const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd); + const headExists = yield* hasHeadCommit(normalizedCwd); if (headExists) { - yield* runGitWithSpawner({ + yield* git.execute({ operation, cwd: normalizedCwd, args: ["read-tree", "HEAD"], @@ -199,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - yield* runGitWithSpawner({ + yield* git.execute({ operation, cwd: normalizedCwd, args: ["add", "-A", "--", "."], env: commitEnv, }); - const writeTreeResult = yield* runGitWithSpawner({ + const writeTreeResult = yield* git.execute({ operation, cwd: normalizedCwd, args: ["write-tree"], @@ -215,7 +133,7 @@ const makeCheckpointStore = Effect.gen(function* () { const treeOid = writeTreeResult.stdout.trim(); if (treeOid.length === 0) { return yield* Effect.fail( - new CheckpointGitCommandError({ + new GitCommandError({ operation, command: "git write-tree", cwd: normalizedCwd, @@ -225,7 +143,7 @@ const makeCheckpointStore = Effect.gen(function* () { } const message = `t3 checkpoint ref=${checkpointRef}`; - const commitTreeResult = yield* runGitWithSpawner({ + const commitTreeResult = yield* git.execute({ operation, cwd: normalizedCwd, args: ["commit-tree", treeOid, "-m", message], @@ -234,7 +152,7 @@ const makeCheckpointStore = Effect.gen(function* () { const commitOid = commitTreeResult.stdout.trim(); if (commitOid.length === 0) { return yield* Effect.fail( - new CheckpointGitCommandError({ + new GitCommandError({ operation, command: "git commit-tree", cwd: normalizedCwd, @@ -243,96 +161,66 @@ const makeCheckpointStore = Effect.gen(function* () { ); } - yield* runGitWithSpawner({ + yield* git.execute({ operation, cwd: normalizedCwd, args: ["update-ref", checkpointRef, commitOid], }); }), - removeDirectory, - ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + (tempDir) => fs.remove(tempDir, { recursive: true }), + ).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail( + new CheckpointInvariantError({ + operation: "CheckpointStore.captureCheckpoint", + detail: "Failed to capture checkpoint.", + cause: error, + }), + ), + }), + ); }); const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => Effect.gen(function* () { - const operation = "CheckpointStore.hasCheckpointRef"; - const normalizedCwd = input.cwd.trim(); - const checkpointRef = input.checkpointRef.trim(); - - return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( + return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( Effect.map((commit) => commit !== null), - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), ); }); - const ensureRootCheckpoint: CheckpointStoreShape["ensureRootCheckpoint"] = (input) => - Effect.gen(function* () { - const operation = "CheckpointStore.ensureRootCheckpoint"; - const normalizedCwd = input.cwd.trim(); - const checkpointRef = input.checkpointRef.trim(); - - const existing = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); - if (existing) { - return true; - } - - const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); - if (!headCommit) { - return false; - } - - yield* runGitWithSpawner({ - operation, - cwd: normalizedCwd, - args: ["update-ref", checkpointRef, headCommit], - }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); - return true; - }); - const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => Effect.gen(function* () { const operation = "CheckpointStore.restoreCheckpoint"; - const normalizedCwd = input.cwd.trim(); - const checkpointRef = input.checkpointRef.trim(); - let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); + let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); if (!commitOid && input.fallbackToHead === true) { - commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); + commitOid = yield* resolveHeadCommit(input.cwd); } if (!commitOid) { return false; } - yield* runGitWithSpawner({ + yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], - }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); - yield* runGitWithSpawner({ + }); + yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["clean", "-fd", "--", "."], - }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + }); - const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); + const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* runGitWithSpawner({ + yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["reset", "--quiet", "--", "."], - }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + }); } return true; @@ -341,23 +229,12 @@ const makeCheckpointStore = Effect.gen(function* () { const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => Effect.gen(function* () { const operation = "CheckpointStore.diffCheckpoints"; - const normalizedCwd = input.cwd.trim(); - const fromCheckpointRef = input.fromCheckpointRef.trim(); - const toCheckpointRef = input.toCheckpointRef.trim(); - let fromCommitOid = yield* resolveCheckpointCommitWithSpawner( - normalizedCwd, - fromCheckpointRef, - ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); - const toCommitOid = yield* resolveCheckpointCommitWithSpawner( - normalizedCwd, - toCheckpointRef, - ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); + const toCommitOid = yield* resolveCheckpointCommit(input.cwd, input.toCheckpointRef); if (!fromCommitOid && input.fallbackFromToHead === true) { - const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ); + const headCommit = yield* resolveHeadCommit(input.cwd); if (headCommit) { fromCommitOid = headCommit; } @@ -365,20 +242,20 @@ const makeCheckpointStore = Effect.gen(function* () { if (!fromCommitOid || !toCommitOid) { return yield* Effect.fail( - new CheckpointGitCommandError({ + new GitCommandError({ operation, command: "git diff", - cwd: normalizedCwd, + cwd: input.cwd, detail: "Checkpoint ref is unavailable for diff operation.", }), ); } - const result = yield* runGitWithSpawner({ + const result = yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], - }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + }); return result.stdout; }); @@ -386,20 +263,16 @@ const makeCheckpointStore = Effect.gen(function* () { const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => Effect.gen(function* () { const operation = "CheckpointStore.deleteCheckpointRefs"; - const normalizedCwd = input.cwd.trim(); - const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim()); yield* Effect.forEach( - checkpointRefs, + input.checkpointRefs, (checkpointRef) => - runGitWithSpawner({ + git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["update-ref", "-d", checkpointRef], allowNonZeroExit: true, - }).pipe( - Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), - ), + }), { discard: true }, ); }); @@ -408,11 +281,12 @@ const makeCheckpointStore = Effect.gen(function* () { isGitRepository, captureCheckpoint, hasCheckpointRef, - ensureRootCheckpoint, restoreCheckpoint, diffCheckpoints, deleteCheckpointRefs, } satisfies CheckpointStoreShape; }); -export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); +export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe( + Layer.provideMerge(GitServiceLive), +); diff --git a/apps/server/src/checkpointing/Services/CheckpointService.ts b/apps/server/src/checkpointing/Services/CheckpointService.ts deleted file mode 100644 index 9d782b52d85a6ed5ce3ac4b685b1538b7e535198..0000000000000000000000000000000000000000 --- a/apps/server/src/checkpointing/Services/CheckpointService.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * CheckpointService - Application orchestrator for checkpoint workflows. - * - * Coordinates `CheckpointStore` (Git refs), `CheckpointRepository` (metadata), and - * provider timeline operations resolved through `ProviderSessionDirectory` and - * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). - * - * This service owns checkpoint workflow rules and validation, while concrete - * storage/process details stay in lower-level services. - * - * @module CheckpointService - */ -import type { - ProviderGetCheckpointDiffInput, - ProviderGetCheckpointDiffResult, - ProviderListCheckpointsInput, - ProviderListCheckpointsResult, - ProviderRevertToCheckpointInput, - ProviderRevertToCheckpointResult, -} from "@t3tools/contracts"; -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { CheckpointServiceError } from "../Errors.ts"; - -export interface InitializeCheckpointSessionInput { - readonly providerSessionId: string; - readonly cwd: string; -} - -export interface CaptureCurrentTurnInput { - readonly providerSessionId: string; - readonly turnId?: string; - readonly status?: string; -} - -export interface CheckpointServiceShape { - /** - * Initialize root checkpoint and metadata for a provider session. - */ - readonly initializeForSession: ( - input: InitializeCheckpointSessionInput, - ) => Effect.Effect; - - /** - * Capture the current thread turn into checkpoint refs/metadata. - */ - readonly captureCurrentTurn: ( - input: CaptureCurrentTurnInput, - ) => Effect.Effect; - - /** - * List checkpoints for a provider session. - */ - readonly listCheckpoints: ( - input: ProviderListCheckpointsInput, - ) => Effect.Effect; - - /** - * Diff two checkpoint turn counts for a provider session. - */ - readonly getCheckpointDiff: ( - input: ProviderGetCheckpointDiffInput, - ) => Effect.Effect; - - /** - * Revert provider/filesystem state to a checkpoint. - */ - readonly revertToCheckpoint: ( - input: ProviderRevertToCheckpointInput, - ) => Effect.Effect; - - /** - * Release in-memory checkpoint session state after session shutdown. - * - * This only clears local coordination state (locks/cwd tracking). It does not - * delete durable checkpoint metadata or git refs. - */ - readonly releaseSession: (input: { - readonly providerSessionId: string; - }) => Effect.Effect; -} - -/** - * CheckpointService - Service tag for checkpoint orchestration. - */ -export class CheckpointService extends ServiceMap.Service< - CheckpointService, - CheckpointServiceShape ->()("checkpointing/CheckpointService") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index 7dece4884468d4842e06a3e81ea573458064931c..c6f7c7c6c758aacfe32ea67115e9bbbdbc083a39 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -14,33 +14,29 @@ import { ServiceMap } from "effect"; import type { Effect } from "effect"; import type { CheckpointStoreError } from "../Errors.ts"; +import { CheckpointRef } from "@t3tools/contracts"; export interface CaptureCheckpointInput { readonly cwd: string; - readonly checkpointRef: string; -} - -export interface EnsureRootCheckpointInput { - readonly cwd: string; - readonly checkpointRef: string; + readonly checkpointRef: CheckpointRef; } export interface RestoreCheckpointInput { readonly cwd: string; - readonly checkpointRef: string; + readonly checkpointRef: CheckpointRef; readonly fallbackToHead?: boolean; } export interface DiffCheckpointsInput { readonly cwd: string; - readonly fromCheckpointRef: string; - readonly toCheckpointRef: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; readonly fallbackFromToHead?: boolean; } export interface DeleteCheckpointRefsInput { readonly cwd: string; - readonly checkpointRefs: ReadonlyArray; + readonly checkpointRefs: ReadonlyArray; } export interface CheckpointStoreShape { @@ -63,13 +59,6 @@ export interface CheckpointStoreShape { input: Omit, ) => Effect.Effect; - /** - * Ensure a root checkpoint ref exists. - */ - readonly ensureRootCheckpoint: ( - input: EnsureRootCheckpointInput, - ) => Effect.Effect; - /** * Restore workspace/staging state to a checkpoint. */ diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 912f9b2db867aabd380a17fa2e83ab348dc97dd7..3bb317804562ef925972a4e8e205c9d21bbd0521 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -3,16 +3,22 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import readline from "node:readline"; -import type { - ProviderApprovalDecision, - ProviderEvent, +import { + ApprovalRequestId, + EventId, + ProviderItemId, ProviderRequestKind, - ProviderSendTurnInput, - ProviderSession, - ProviderSessionStartInput, - ProviderTurnStartResult, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + normalizeModelSlug, + type ProviderApprovalDecision, + type ProviderEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/contracts"; type PendingRequestKey = string; @@ -24,13 +30,13 @@ interface PendingRequest { } interface PendingApprovalRequest { - requestId: string; + requestId: ApprovalRequestId; jsonRpcId: string | number; method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; - threadId?: string; - turnId?: string; - itemId?: string; + threadId?: ProviderThreadId; + turnId?: ProviderTurnId; + itemId?: ProviderItemId; } interface CodexSessionContext { @@ -38,7 +44,7 @@ interface CodexSessionContext { child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; - pendingApprovals: Map; + pendingApprovals: Map; nextRequestId: number; stopping: boolean; } @@ -66,12 +72,12 @@ interface JsonRpcNotification { } export interface CodexThreadTurnSnapshot { - id: string; + id: ProviderTurnId; items: unknown[]; } export interface CodexThreadSnapshot { - threadId: string; + threadId: ProviderThreadId; turns: CodexThreadTurnSnapshot[]; } @@ -143,10 +149,10 @@ export interface CodexAppServerManagerEvents { } export class CodexAppServerManager extends EventEmitter { - private readonly sessions = new Map(); + private readonly sessions = new Map(); async startSession(input: ProviderSessionStartInput): Promise { - const sessionId = randomUUID(); + const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); const now = new Date().toISOString(); let context: CodexSessionContext | undefined; @@ -244,12 +250,13 @@ export class CodexAppServerManager extends EventEmitter; @@ -331,10 +338,11 @@ export class CodexAppServerManager extends EventEmitter { + async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { const context = this.requireSession(sessionId); const effectiveTurnId = turnId ?? context.session.activeTurnId; @@ -361,7 +369,7 @@ export class CodexAppServerManager extends EventEmitter { + async readThread(sessionId: ProviderSessionId): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { @@ -375,7 +383,7 @@ export class CodexAppServerManager extends EventEmitter { + async rollbackThread(sessionId: ProviderSessionId, numTurns: number): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { @@ -397,8 +405,8 @@ export class CodexAppServerManager extends EventEmitter { const context = this.requireSession(sessionId); @@ -416,7 +424,7 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); - const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; + const turnIdRaw = + this.readString(turn, "id") ?? + `${threadIdRaw}:turn:${index + 1}`; + const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { id: turnId, @@ -865,23 +882,27 @@ export class CodexAppServerManager extends EventEmitter( + value: string | undefined, + maker: (value: string) => T, +): T | undefined { + const normalized = value?.trim(); + return normalized?.length ? maker(normalized) : undefined; +} + +function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { + return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); +} + +function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { + return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); +} + +function toProviderItemId(value: string | undefined): ProviderItemId | undefined { + return brandIfNonEmpty(value, ProviderItemId.makeUnsafe); +} diff --git a/apps/server/src/filesystemCheckpointStore.test.ts b/apps/server/src/filesystemCheckpointStore.test.ts deleted file mode 100644 index 7e78ebdf8568ada263783cfa57fab74dd207d1bd..0000000000000000000000000000000000000000 --- a/apps/server/src/filesystemCheckpointStore.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore"; -import { runProcess } from "./processRunner"; - -async function runGit(cwd: string, args: readonly string[]): Promise { - const result = await runProcess("git", args, { - cwd, - allowNonZeroExit: true, - }); - if (result.code !== 0) { - throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); - } - return result.stdout.trim(); -} - -function exists(filePath: string): boolean { - return fs.existsSync(filePath); -} - -async function createRepo(): Promise { - const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-")); - await runGit(cwd, ["init", "--initial-branch=main"]); - await runGit(cwd, ["config", "user.email", "test@example.com"]); - await runGit(cwd, ["config", "user.name", "Test User"]); - await writeFile(path.join(cwd, "README.md"), "v1\n"); - await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n"); - await runGit(cwd, ["add", "."]); - await runGit(cwd, ["commit", "-m", "Initial"]); - return cwd; -} - -async function createRepoWithoutHead(): Promise { - const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-")); - await runGit(cwd, ["init", "--initial-branch=main"]); - return cwd; -} - -describe("FilesystemCheckpointStore", () => { - it("captures refs without mutating branch history and restores workspace state", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr_1"; - - try { - const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]); - - await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); - const rootRef = checkpointRefForThreadTurn(threadId, 0); - const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]); - expect(rootCommit.length).toBeGreaterThan(0); - expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore); - - await writeFile(path.join(cwd, "README.md"), "v2\n"); - await rm(path.join(cwd, "tracked.txt")); - await writeFile(path.join(cwd, "notes.md"), "notes\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); - - await writeFile(path.join(cwd, "README.md"), "v3\n"); - await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); - await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n"); - - const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 }); - expect(restoredRoot).toBe(true); - expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); - expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n"); - expect(exists(path.join(cwd, "notes.md"))).toBe(false); - expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); - - const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 }); - expect(restoredTurn1).toBe(true); - expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n"); - expect(exists(path.join(cwd, "tracked.txt"))).toBe(false); - expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n"); - expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("returns false when restoring a missing checkpoint", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - - try { - const restored = await store.restoreCheckpoint({ - cwd, - threadId: "thr_2", - turnCount: 999, - }); - expect(restored).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr_2b"; - - try { - await writeFile(path.join(cwd, "README.md"), "v2\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); - - await writeFile(path.join(cwd, "README.md"), "v3\n"); - await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); - - const restored = await store.restoreCheckpoint({ - cwd, - threadId, - turnCount: 0, - }); - - expect(restored).toBe(true); - expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); - expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("prunes refs newer than the retained turn", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr_3"; - - try { - await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); - await writeFile(path.join(cwd, "README.md"), "turn1\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); - await writeFile(path.join(cwd, "README.md"), "turn2\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 2 }); - - await store.pruneAfterTurn({ - cwd, - threadId, - maxTurnCount: 1, - }); - - expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true); - expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true); - expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("returns a unified patch between checkpoint turns", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr_4"; - - try { - await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); - await writeFile(path.join(cwd, "README.md"), "v2\n"); - await writeFile(path.join(cwd, "new.txt"), "new file\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); - - const diff = await store.diffCheckpoints({ - cwd, - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - - expect(diff).toContain("diff --git a/README.md b/README.md"); - expect(diff).toContain("diff --git a/new.txt b/new.txt"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => { - const cwd = await createRepo(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr_5"; - - try { - await writeFile(path.join(cwd, "README.md"), "v2\n"); - await writeFile(path.join(cwd, "new.txt"), "new file\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); - - const diff = await store.diffCheckpoints({ - cwd, - threadId, - fromTurnCount: 0, - toTurnCount: 1, - }); - - expect(diff).toContain("diff --git a/README.md b/README.md"); - expect(diff).toContain("diff --git a/new.txt b/new.txt"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("detects non-repo folders", async () => { - const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); - const store = new FilesystemCheckpointStore(); - - try { - expect(await store.isGitRepository(cwd)).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - it("returns false when repository path does not exist", async () => { - const store = new FilesystemCheckpointStore(); - const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`); - - expect(await store.isGitRepository(cwd)).toBe(false); - }); - - it("restores checkpoint without reset when repository has no HEAD commit", async () => { - const cwd = await createRepoWithoutHead(); - const store = new FilesystemCheckpointStore(); - const threadId = "thr-no-head"; - - try { - await writeFile(path.join(cwd, "README.md"), "first\n"); - await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); - - await writeFile(path.join(cwd, "README.md"), "second\n"); - const restored = await store.restoreCheckpoint({ - cwd, - threadId, - turnCount: 0, - }); - - expect(restored).toBe(true); - expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/server/src/filesystemCheckpointStore.ts b/apps/server/src/filesystemCheckpointStore.ts deleted file mode 100644 index 41ea5e3c27d5be19c8add20e4ee847fd7ffaf0dc..0000000000000000000000000000000000000000 --- a/apps/server/src/filesystemCheckpointStore.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; - -import { runProcess } from "./processRunner"; - -const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -function quoteGitCommand(args: readonly string[]): string { - return `git ${args.join(" ")}`; -} - -function checkpointRefThreadSegment(threadId: string): string { - return Buffer.from(threadId, "utf8").toString("base64url"); -} - -export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { - if (!Number.isInteger(turnCount) || turnCount < 0) { - throw new Error(`Invalid checkpoint turn count: ${turnCount}`); - } - return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; -} - -interface GitOptions { - env?: NodeJS.ProcessEnv; - stdin?: string; - allowNonZeroExit?: boolean; -} - -export class FilesystemCheckpointStore { - async isGitRepository(cwd: string): Promise { - try { - const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { - allowNonZeroExit: true, - }); - return result.code === 0 && result.stdout.trim() === "true"; - } catch { - return false; - } - } - - async captureCheckpoint(input: { - cwd: string; - threadId: string; - turnCount: number; - }): Promise { - const { cwd, threadId, turnCount } = input; - const ref = checkpointRefForThreadTurn(threadId, turnCount); - - const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); - - try { - const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); - const commitEnv: NodeJS.ProcessEnv = { - ...process.env, - GIT_INDEX_FILE: tempIndexPath, - GIT_AUTHOR_NAME: "T3 Code", - GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", - GIT_COMMITTER_NAME: "T3 Code", - GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", - }; - - const hasHead = await this.hasHeadCommit(cwd); - if (hasHead) { - await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv }); - } - - await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv }); - const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv }); - const treeOid = writeTreeResult.stdout.trim(); - if (!treeOid) { - throw new Error("git write-tree returned an empty tree oid."); - } - - const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`; - const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], { - env: commitEnv, - stdin: `${message}\n`, - }); - const commitOid = commitTreeResult.stdout.trim(); - if (!commitOid) { - throw new Error("git commit-tree returned an empty commit oid."); - } - - await this.runGit(cwd, ["update-ref", ref, commitOid]); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - } - - async hasCheckpoint(input: { - cwd: string; - threadId: string; - turnCount: number; - }): Promise { - const { cwd, threadId, turnCount } = input; - const ref = checkpointRefForThreadTurn(threadId, turnCount); - const commit = await this.resolveCheckpointCommit(cwd, ref); - return commit !== null; - } - - async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise { - const { cwd, threadId } = input; - const rootRef = checkpointRefForThreadTurn(threadId, 0); - const existing = await this.resolveCheckpointCommit(cwd, rootRef); - if (existing) { - return true; - } - - const headCommit = await this.resolveHeadCommit(cwd); - if (!headCommit) { - return false; - } - - await this.runGit(cwd, ["update-ref", rootRef, headCommit]); - return true; - } - - async restoreCheckpoint(input: { - cwd: string; - threadId: string; - turnCount: number; - }): Promise { - const { cwd, threadId, turnCount } = input; - const ref = checkpointRefForThreadTurn(threadId, turnCount); - let commitOid = await this.resolveCheckpointCommit(cwd, ref); - if (!commitOid && turnCount === 0) { - commitOid = await this.resolveHeadCommit(cwd); - } - if (!commitOid) { - return false; - } - - await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); - await this.runGit(cwd, ["clean", "-fd", "--", "."]); - if (await this.hasHeadCommit(cwd)) { - await this.runGit(cwd, ["reset", "--quiet", "--", "."]); - } - return true; - } - - async diffCheckpoints(input: { - cwd: string; - threadId: string; - fromTurnCount: number; - toTurnCount: number; - }): Promise { - const { cwd, threadId, fromTurnCount, toTurnCount } = input; - if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) { - throw new Error(`Invalid from turn count: ${fromTurnCount}`); - } - if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { - throw new Error(`Invalid to turn count: ${toTurnCount}`); - } - if (fromTurnCount > toTurnCount) { - throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`); - } - - const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount); - const toRef = checkpointRefForThreadTurn(threadId, toTurnCount); - let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef); - const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef); - if (!fromCommitOid && fromTurnCount === 0) { - const headCommit = await this.resolveHeadCommit(cwd); - if (headCommit) { - fromCommitOid = headCommit; - } - } - if (!fromCommitOid) { - throw new Error( - `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`, - ); - } - if (!toCommitOid) { - throw new Error( - `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, - ); - } - - const result = await this.runGit(cwd, [ - "diff", - "--patch", - "--minimal", - "--no-color", - fromCommitOid, - toCommitOid, - ]); - return result.stdout; - } - - private async resolveHeadCommit(cwd: string): Promise { - const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { - allowNonZeroExit: true, - }); - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - } - - async pruneAfterTurn(input: { - cwd: string; - threadId: string; - maxTurnCount: number; - }): Promise { - const { cwd, threadId, maxTurnCount } = input; - if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { - throw new Error(`Invalid max turn count: ${maxTurnCount}`); - } - - const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; - const result = await this.runGit( - cwd, - ["for-each-ref", "--format=%(refname)", threadRefPrefix], - { - allowNonZeroExit: true, - }, - ); - if (result.code !== 0) { - return; - } - - const refsToDelete = result.stdout - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((refName) => { - const turn = this.readRefTurnCount(refName); - return turn !== null && turn > maxTurnCount; - }); - - const batchSize = 32; - const batches = Array.from( - { length: Math.ceil(refsToDelete.length / batchSize) }, - (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), - ); - await batches.reduce>((pending, batch) => { - return pending.then(async () => { - await Promise.all( - batch.map((refName) => - this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), - ), - ); - }); - }, Promise.resolve()); - } - - private async hasHeadCommit(cwd: string): Promise { - const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], { - allowNonZeroExit: true, - }); - return result.code === 0; - } - - private async resolveCheckpointCommit(cwd: string, ref: string): Promise { - const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { - allowNonZeroExit: true, - }); - if (result.code !== 0) { - return null; - } - const commit = result.stdout.trim(); - return commit.length > 0 ? commit : null; - } - - private readRefTurnCount(ref: string): number | null { - const segments = ref.split("/"); - const rawTurn = segments.at(-1); - if (!rawTurn) { - return null; - } - const turn = Number.parseInt(rawTurn, 10); - return Number.isInteger(turn) && turn >= 0 ? turn : null; - } - - private async runGit( - cwd: string, - args: readonly string[], - options: GitOptions = {}, - ): Promise>> { - let result: Awaited>; - try { - result = await runProcess("git", args, { - cwd, - env: options.env, - stdin: options.stdin, - timeoutMs: DEFAULT_TIMEOUT_MS, - allowNonZeroExit: true, - maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, - outputMode: "truncate", - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error }); - } - - if (result.timedOut) { - throw new Error(`${quoteGitCommand(args)} timed out.`); - } - - if (result.stdoutTruncated || result.stderrTruncated) { - throw new Error( - `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, - ); - } - - if (!options.allowNonZeroExit && result.code !== 0) { - const stderr = result.stderr.trim(); - throw new Error( - stderr.length > 0 - ? `${quoteGitCommand(args)} failed: ${stderr}` - : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`, - ); - } - - return result; - } -} diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index bf84176a059338f67e54a14783d30fc9664a0668..d41c3577300fa3a746f164773bedc72df759c8ce 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import { - gitPullInputSchema, - gitPullResultSchema, - gitStatusInputSchema, - gitStatusResultSchema, + GitPullInput, + GitPullResult, + GitStatusInput, + GitStatusResult, type GitCheckoutInput, type GitCreateBranchInput, type GitCreateWorktreeInput, @@ -14,11 +14,7 @@ import { type GitInitInput, type GitListBranchesInput, type GitListBranchesResult, - type GitPullInput, - type GitPullResult, type GitRemoveWorktreeInput, - type GitStatusInput, - type GitStatusResult, } from "@t3tools/contracts"; import { type ProcessRunResult, runProcess } from "./processRunner"; @@ -29,8 +25,6 @@ export interface TerminalCommandInput { maxOutputBytes?: number; } - - export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -220,10 +214,9 @@ async function executeGit( export class GitCoreService { private readonly lastUpstreamRefreshAt = new Map(); - async status(raw: GitStatusInput): Promise { - const input = gitStatusInputSchema.parse(raw); + async status(input: GitStatusInput): Promise { const details = await this.statusDetails(input.cwd); - return gitStatusResultSchema.parse({ + return GitStatusResult.makeUnsafe({ branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, @@ -392,10 +385,10 @@ export class GitCoreService { }); const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); const refreshed = await this.statusDetails(cwd); - return gitPullResultSchema.parse({ + return GitPullResult.makeUnsafe({ status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", branch, - ...(refreshed.upstreamRef ? { upstreamBranch: refreshed.upstreamRef } : {}), + upstreamBranch: refreshed.upstreamRef, }); } @@ -678,9 +671,7 @@ export class GitCoreService { const defaultGitCoreService = new GitCoreService(); -export async function runTerminalCommand( - input: TerminalCommandInput, -): Promise { +export async function runTerminalCommand(input: TerminalCommandInput): Promise { const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const shellPath = process.platform === "win32" @@ -712,8 +703,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise { - const input = gitPullInputSchema.parse(raw); +export async function pullGitBranch(input: GitPullInput): Promise { return defaultGitCoreService.pullCurrentBranch(input.cwd); } diff --git a/apps/server/src/git/Layer.test.ts b/apps/server/src/git/Layer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1426e28705f2c833100a543c4855147d7f3876fa --- /dev/null +++ b/apps/server/src/git/Layer.test.ts @@ -0,0 +1,59 @@ +import { NodeServices } from "@effect/platform-node"; +import { it, assert } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { GitCommandError } from "./Errors.ts"; +import { GitServiceLive } from "./Layer.ts"; +import { GitService } from "./Service.ts"; + +const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); + +layer("GitServiceLive", (it) => { + it.effect("runGit executes successful git commands", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitProcess.test.version", + cwd: process.cwd(), + args: ["--version"], + }); + + assert.equal(result.code, 0); + assert.ok(result.stdout.toLowerCase().includes("git version")); + }), + ); + + it.effect("runGit can return non-zero exit codes when allowed", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitProcess.test.allowNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + allowNonZeroExit: true, + }); + + assert.notEqual(result.code, 0); + }), + ); + + it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* Effect.result( + gitService.execute({ + operation: "GitProcess.test.failOnNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.ok(result.failure instanceof GitCommandError); + assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); + assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); + } + }), + ); +}); diff --git a/apps/server/src/git/Layer.ts b/apps/server/src/git/Layer.ts new file mode 100644 index 0000000000000000000000000000000000000000..a367e86162003590e31820f91cbbe4469842d463 --- /dev/null +++ b/apps/server/src/git/Layer.ts @@ -0,0 +1,141 @@ +/** + * Git process helpers - Effect-native git execution with typed errors. + * + * Centralizes child-process git invocation for server modules. This module + * only executes git commands and reports structured failures. + * + * @module GitServiceLive + */ +import { Effect, Layer, Option, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { GitCommandError } from "./Errors.ts"; +import { ExecuteGitInput, ExecuteGitResult, GitService, GitServiceShape } from "./Service.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function toGitCommandError( + input: Pick, + detail: string, +) { + return (cause: unknown) => + cause instanceof GitCommandError + ? cause + : new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const collectOutput = Effect.fn(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, +): Effect.fn.Return { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } + text += decoder.decode(chunk, { stream: true }); + }), + ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); + + text += decoder.decode(); + return text; +}); + +const makeGitService = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { + const normalizedCwd = input.cwd.trim(); + const commandInput = { + ...input, + cwd: normalizedCwd, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + const commandEffect = Effect.gen(function* () { + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + ...(input.env ? { env: input.env } : {}), + }), + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput(commandInput, child.stdout, maxOutputBytes), + collectOutput(commandInput, child.stderr, maxOutputBytes), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + ], + { concurrency: "unbounded" }, + ); + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } + + return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + }); + + return yield* commandEffect.pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); + + return { + execute, + } satisfies GitServiceShape; +}); + +export const GitServiceLive = Layer.effect(GitService, makeGitService); diff --git a/apps/server/src/git/Service.ts b/apps/server/src/git/Service.ts new file mode 100644 index 0000000000000000000000000000000000000000..2619e13cd4f7e9183ac1251d8da6d25d9fd1f4b8 --- /dev/null +++ b/apps/server/src/git/Service.ts @@ -0,0 +1,42 @@ +/** + * GitService - Service for Git command execution. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * domain errors for Git command execution. + * + * @module GitService + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { GitCommandError } from "./Errors.ts"; + +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +export interface ExecuteGitResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface GitServiceShape { + /** + * Execute a Git command. + */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; +} + +/** + * GitService - Service for Git command execution. + */ +export class GitService extends ServiceMap.Service()( + "git/GitService", +) {} diff --git a/apps/server/src/git/process.test.ts b/apps/server/src/git/process.test.ts deleted file mode 100644 index 58242a4e7a561c68abe8b3b12aeba4f5b6e9cb43..0000000000000000000000000000000000000000 --- a/apps/server/src/git/process.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { it, assert } from "@effect/vitest"; -import { Effect } from "effect"; - -import { GitCommandError } from "./Errors.ts"; -import { runGit } from "./Process.ts"; - -it.effect("runGit executes successful git commands", () => - Effect.gen(function* () { - const result = yield* runGit({ - operation: "GitProcess.test.version", - cwd: process.cwd(), - args: ["--version"], - }); - - assert.equal(result.code, 0); - assert.ok(result.stdout.toLowerCase().includes("git version")); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect("runGit can return non-zero exit codes when allowed", () => - Effect.gen(function* () { - const result = yield* runGit({ - operation: "GitProcess.test.allowNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - allowNonZeroExit: true, - }); - - assert.notEqual(result.code, 0); - }).pipe(Effect.provide(NodeServices.layer)), -); - -it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => - Effect.gen(function* () { - const result = yield* Effect.result( - runGit({ - operation: "GitProcess.test.failOnNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - }), - ); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.ok(result.failure instanceof GitCommandError); - assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); - assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); - } - }).pipe(Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/git/process.ts b/apps/server/src/git/process.ts deleted file mode 100644 index d5ed04de081c951522d578eb9172756ad0a1d0d6..0000000000000000000000000000000000000000 --- a/apps/server/src/git/process.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Git process helpers - Effect-native git execution with typed errors. - * - * Centralizes child-process git invocation for server modules. This module - * only executes git commands and reports structured failures. - * - * @module GitProcess - */ -import { Effect, Option, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "./Errors.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -export interface RunGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; -} - -export interface GitRunResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -} - -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} - -function causeMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function gitError( - input: Pick, - detail: string, - cause?: unknown, -): GitCommandError { - return new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function collectOutput( - input: Pick, - stream: Stream.Stream, - maxOutputBytes: number, -): Effect.Effect { - return Effect.gen(function* () { - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - - yield* Stream.runForEach(stream, (chunk) => - Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { - return yield* Effect.fail( - gitError( - input, - `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - ), - ); - } - text += decoder.decode(chunk, { stream: true }); - }), - ); - - text += decoder.decode(); - return text; - }).pipe( - Effect.mapError((cause) => - gitError( - input, - causeMessage(cause, `${quoteGitCommand(input.args)} output stream failed.`), - cause, - ), - ), - ); -} - -export function runGit( - input: RunGitInput, -): Effect.Effect { - const normalizedCwd = input.cwd.trim(); - const commandInput = { - ...input, - cwd: normalizedCwd, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - - const commandEffect = Effect.gen(function* () { - const child = yield* ChildProcess.spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - ...(input.env ? { env: input.env } : {}), - }), - ).pipe( - Effect.mapError((cause) => - gitError( - commandInput, - causeMessage(cause, `Failed to spawn ${quoteGitCommand(commandInput.args)}.`), - cause, - ), - ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput(commandInput, child.stdout, maxOutputBytes), - collectOutput(commandInput, child.stderr, maxOutputBytes), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError((cause) => - gitError( - commandInput, - causeMessage( - cause, - `${quoteGitCommand(commandInput.args)} failed to report exit code.`, - ), - cause, - ), - ), - ), - ], - { concurrency: "unbounded" }, - ); - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); - return yield* Effect.fail( - gitError( - commandInput, - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - ), - ); - } - - return { - code: exitCode, - stdout, - stderr, - } satisfies GitRunResult; - }); - - return commandEffect.pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(gitError(commandInput, `${quoteGitCommand(commandInput.args)} timed out.`)), - onSome: Effect.succeed, - }), - ), - ); -} diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index a9d7689513a0fe0c8fac10763e9374bfa1dda7d1..0a39a942066275a37f95a9c20f3264592ba7d074 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -4,13 +4,10 @@ import os from "node:os"; import path from "node:path"; import { - gitRunStackedActionInputSchema, - gitRunStackedActionResultSchema, - gitStatusInputSchema, - type GitRunStackedActionInput, - type GitRunStackedActionResult, - type GitStatusInput, - type GitStatusResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStatusInput, + GitStatusResult, } from "@t3tools/contracts"; import type { @@ -187,8 +184,7 @@ export class GitManager { this.gitCore = deps.gitCore ?? new GitCoreService(); } - async status(raw: GitStatusInput): Promise { - const input = gitStatusInputSchema.parse(raw); + async status(input: GitStatusInput): Promise { const details = await this.gitCore.statusDetails(input.cwd); let openPr: ReturnType | null = null; @@ -214,8 +210,7 @@ export class GitManager { }; } - async runStackedAction(raw: GitRunStackedActionInput): Promise { - const input = gitRunStackedActionInputSchema.parse(raw); + async runStackedAction(input: GitRunStackedActionInput): Promise { const wantsPush = input.action !== "commit"; const wantsPr = input.action === "commit_push_pr"; @@ -237,12 +232,12 @@ export class GitManager { ? await this.runPrStep(input.cwd, initialStatus.branch) : { status: "skipped_not_requested" as const }; - return gitRunStackedActionResultSchema.parse({ + return { action: input.action, commit, push, pr, - }); + }; } private async runCommitStep( diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index dd5e401e9e756e060ff554b943efd0565f0f6a30..428662a7498cd719520d73a82d5515ae78c82eda 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { +import { KeybindingRule, KeybindingsConfig, KeybindingShortcut, @@ -10,7 +10,8 @@ import type { ResolvedKeybindingRule, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { keybindingRuleSchema } from "@t3tools/contracts"; +import { Mutable } from "effect/Types"; +import { Schema } from "effect"; interface KeybindingsLogger { warn(message: string, context?: Record): void; @@ -267,7 +268,7 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey export function compileResolvedKeybindingsConfig( config: KeybindingsConfig, ): ResolvedKeybindingsConfig { - const compiled: ResolvedKeybindingsConfig = []; + const compiled: Mutable = []; for (const rule of config) { const resolved = compileResolvedKeybindingRule(rule); if (!resolved) continue; @@ -277,7 +278,7 @@ export function compileResolvedKeybindingsConfig( } function compileDefaultKeybindings(): ResolvedKeybindingsConfig { - const resolved: ResolvedKeybindingsConfig = []; + const resolved: Mutable = []; for (const rule of DEFAULT_KEYBINDINGS) { const compiled = compileResolvedKeybindingRule(rule); if (!compiled) { @@ -322,16 +323,16 @@ function loadCustomKeybindingsConfig( return []; } - const sanitized: KeybindingsConfig = []; + const sanitized: Mutable = []; let invalidEntries = 0; for (const entry of parsed) { - const result = keybindingRuleSchema.safeParse(entry); - if (result.success) { - if (!compileResolvedKeybindingRule(result.data)) { + const result = Schema.decodeUnknownExit(KeybindingRule)(entry); + if (result._tag === "Success") { + if (!compileResolvedKeybindingRule(result.value)) { invalidEntries += 1; continue; } - sanitized.push(result.data); + sanitized.push(result.value); continue; } invalidEntries += 1; @@ -417,13 +418,8 @@ export function loadResolvedKeybindingsConfig( export function upsertKeybindingRule( logger: KeybindingsLogger, - rawRule: unknown, + rule: KeybindingRule, ): ResolvedKeybindingsConfig { - const rule = keybindingRuleSchema.parse(rawRule); - if (!compileResolvedKeybindingRule(rule)) { - throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); - } - const configPath = resolveKeybindingsConfigPath(); const customConfig = loadCustomKeybindingsConfig(logger, { throwOnUnreadableConfig: true, diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 35091b3eb33b1541d6b4d80b5695492561e10fc8..81e83751be59fb1d1b8a9488bf514021cf070e23 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,6 +1,10 @@ import { SchemaIssue, Schema } from "effect"; -import type { OrchestrationEventStoreError } from "../persistence/Errors.ts"; +import type { + OrchestrationCommandReceiptRepositoryError, + OrchestrationEventStoreError, + ProjectionRepositoryError, +} from "../persistence/Errors.ts"; export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass()( "OrchestrationCommandJsonParseError", @@ -39,6 +43,19 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( + "OrchestrationCommandPreviouslyRejectedError", + { + commandId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Command previously rejected (${this.commandId}): ${this.detail}`; + } +} + export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass()( "OrchestrationProjectorDecodeError", { @@ -67,7 +84,10 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | OrchestrationEventStoreError + | OrchestrationCommandReceiptRepositoryError + | ProjectionRepositoryError | OrchestrationCommandInvariantError + | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError | OrchestrationListenerCallbackError; diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index af5da282aea437e2f162076306122201b1bddc78..ac9df75cce21b0411ef6e353c55b6c8a000096ba 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -2,19 +2,23 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../persistence/Services/OrchestrationEventStore.ts"; -import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; -import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; -export async function createOrchestrationSystem() { +async function createOrchestrationSystem() { const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), ); const runtime = ManagedRuntime.make(orchestrationLayer); @@ -26,20 +30,33 @@ export async function createOrchestrationSystem() { }; } +function now() { + return new Date().toISOString(); +} + describe("OrchestrationEngine", () => { it("returns deterministic read models for repeated reads", async () => { - const createdAt = new Date().toISOString(); - const projectId = "project-1"; - const threadId = "thread-1"; - + const createdAt = now(); const system = await createOrchestrationSystem(); - const engine = system.engine; + const { engine } = system; + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-1-create", + projectId: "project-1", + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-1", - threadId, - projectId, + commandId: "cmd-thread-1-create", + threadId: "thread-1", + projectId: "project-1", title: "Thread", model: "gpt-5-codex", branch: null, @@ -49,16 +66,19 @@ describe("OrchestrationEngine", () => { ); await system.run( engine.dispatch({ - type: "message.send", - commandId: "cmd-3", - threadId, - messageId: "msg-1", - role: "user", - text: "hello", - streaming: false, + type: "thread.turn.start", + commandId: "cmd-turn-start-1", + threadId: "thread-1", + message: { + messageId: "msg-1", + role: "user", + text: "hello", + attachments: [], + }, createdAt, }), ); + const readModelA = await system.run(engine.getReadModel()); const readModelB = await system.run(engine.getReadModel()); expect(readModelB).toEqual(readModelA); @@ -67,42 +87,71 @@ describe("OrchestrationEngine", () => { it("replays append-only events from sequence", async () => { const system = await createOrchestrationSystem(); - const engine = system.engine; + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-replay-create", + projectId: "project-replay", + title: "Replay Project", + workspaceRoot: "/tmp/project-replay", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-a", + commandId: "cmd-thread-replay-create", threadId: "thread-replay", projectId: "project-replay", title: "replay", model: "gpt-5-codex", branch: null, worktreePath: null, - createdAt: new Date().toISOString(), + createdAt, }), ); await system.run( engine.dispatch({ type: "thread.delete", - commandId: "cmd-b", + commandId: "cmd-thread-replay-delete", threadId: "thread-replay", - createdAt: new Date().toISOString(), }), ); + const events = await system.run( Stream.runCollect(engine.readEvents(0)).pipe( Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), ), ); - expect(events.length).toBe(2); - expect(events[0]?.type).toBe("thread.created"); - expect(events[1]?.type).toBe("thread.deleted"); + expect(events.map((event) => event.type)).toEqual([ + "project.created", + "thread.created", + "thread.deleted", + ]); await system.dispose(); }); it("streams persisted domain events in order", async () => { const system = await createOrchestrationSystem(); - const engine = system.engine; + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-stream-create", + projectId: "project-stream", + title: "Stream Project", + workspaceRoot: "/tmp/project-stream", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); + const eventTypes: string[] = []; await system.run( Effect.gen(function* () { @@ -113,12 +162,11 @@ describe("OrchestrationEngine", () => { ), ); yield* Effect.sleep("10 millis"); - const createdAt = new Date().toISOString(); yield* engine.dispatch({ type: "thread.create", - commandId: "cmd-stream-domain-a", - threadId: "thread-stream-domain", - projectId: "project-stream-domain", + commandId: "cmd-stream-thread-create", + threadId: "thread-stream", + projectId: "project-stream", title: "domain-stream", model: "gpt-5-codex", branch: null, @@ -127,203 +175,80 @@ describe("OrchestrationEngine", () => { }); yield* engine.dispatch({ type: "thread.meta.update", - commandId: "cmd-stream-domain-b", - threadId: "thread-stream-domain", + commandId: "cmd-stream-thread-update", + threadId: "thread-stream", title: "domain-stream-updated", - createdAt, }); eventTypes.push((yield* Queue.take(eventQueue)).type); eventTypes.push((yield* Queue.take(eventQueue)).type); }).pipe(Effect.scoped), ); + expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); await system.dispose(); }); - it("stores completed turn summaries even when no files changed", async () => { + it("stores completed checkpoint summaries even when no files changed", async () => { const system = await createOrchestrationSystem(); - const engine = system.engine; - const completedAt = new Date().toISOString(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-turn-diff-create", + projectId: "project-turn-diff", + title: "Turn Diff Project", + workspaceRoot: "/tmp/project-turn-diff", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-turn-diff", + commandId: "cmd-thread-turn-diff-create", threadId: "thread-turn-diff", projectId: "project-turn-diff", title: "Turn diff thread", model: "gpt-5-codex", branch: null, worktreePath: null, - createdAt: completedAt, + createdAt, }), ); await system.run( engine.dispatch({ - type: "thread.turnDiff.complete", + type: "thread.turn.diff.complete", commandId: "cmd-turn-diff-complete", threadId: "thread-turn-diff", turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - createdAt: completedAt, - }), - ); - - const firstThread = (await system.run(engine.getReadModel())).threads.find( - (thread) => thread.id === "thread-turn-diff", - ); - expect(firstThread?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - }, - ]); - await system.dispose(); - }); - - it("reverts thread messages and turn summaries to a checkpoint", async () => { - const system = await createOrchestrationSystem(); - const engine = system.engine; - const createdAt = new Date().toISOString(); - await system.run( - engine.dispatch({ - type: "thread.create", - commandId: "cmd-thread-revert", - threadId: "thread-revert", - projectId: "project-revert", - title: "Revert thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt, - }), - ); - await system.run( - engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-1-user", - threadId: "thread-revert", - messageId: "user-1", - role: "user", - text: "first", - streaming: false, - createdAt, - }), - ); - await system.run( - engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-1-assistant", - threadId: "thread-revert", - messageId: "assistant:turn-1", - role: "assistant", - text: "first-response", - streaming: false, - createdAt, - }), - ); - await system.run( - engine.dispatch({ - type: "thread.turnDiff.complete", - commandId: "cmd-turn-1-complete", - threadId: "thread-revert", - turnId: "turn-1", completedAt: createdAt, - status: "completed", + checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", + status: "ready", files: [], - assistantMessageId: "assistant:turn-1", checkpointTurnCount: 1, createdAt, }), ); - const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); - await system.run( - engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-2-user", - threadId: "thread-revert", - messageId: "user-2", - role: "user", - text: "second", - streaming: false, - createdAt: createdAtSecond, - }), - ); - await system.run( - engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-2-assistant", - threadId: "thread-revert", - messageId: "assistant:turn-2", - role: "assistant", - text: "second-response", - streaming: false, - createdAt: createdAtSecond, - }), - ); - await system.run( - engine.dispatch({ - type: "thread.turnDiff.complete", - commandId: "cmd-turn-2-complete", - threadId: "thread-revert", - turnId: "turn-2", - completedAt: createdAtSecond, - status: "completed", - files: [], - assistantMessageId: "assistant:turn-2", - checkpointTurnCount: 2, - createdAt: createdAtSecond, - }), - ); - - await system.run( - engine.dispatch({ - type: "thread.revert", - commandId: "cmd-thread-revert-apply", - threadId: "thread-revert", - turnCount: 1, - messageCount: 2, - createdAt: createdAtSecond, - }), - ); - const thread = (await system.run(engine.getReadModel())).threads.find( - (entry) => entry.id === "thread-revert", + (entry) => entry.id === "thread-turn-diff", ); - expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); - expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - expect(thread?.latestTurnId).toBe("turn-1"); + expect(thread?.checkpoints).toEqual([ + { + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", + status: "ready", + files: [], + assistantMessageId: null, + completedAt: createdAt, + }, + ]); await system.dispose(); }); - it("allows stop to be called multiple times", async () => { - const inMemoryStore: OrchestrationEventStoreShape = { - append(event) { - return Effect.succeed({ ...event, sequence: 1 }); - }, - readFromSequence() { - return Stream.empty; - }, - readAll() { - return Stream.empty; - }, - }; - const runtime = ManagedRuntime.make( - OrchestrationEngineLive.pipe( - Layer.provide(Layer.succeed(OrchestrationEventStore, inMemoryStore)), - ), - ); - await runtime.runPromise(Effect.service(OrchestrationEngineService)); - await runtime.dispose(); - await expect(runtime.dispose()).resolves.toBeUndefined(); - }); - it("keeps processing queued commands after a storage failure", async () => { const events: OrchestrationEvent[] = []; let nextSequence = 1; @@ -331,7 +256,7 @@ describe("OrchestrationEngine", () => { const flakyStore: OrchestrationEventStoreShape = { append(event) { - if (shouldFailFirstAppend) { + if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") { shouldFailFirstAppend = false; return Effect.fail( new PersistenceSqlError({ @@ -358,11 +283,26 @@ describe("OrchestrationEngine", () => { const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(SqlitePersistenceMemory), ), ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const createdAt = new Date().toISOString(); + const createdAt = now(); + + await runtime.runPromise( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-flaky-create", + projectId: "project-flaky", + title: "Flaky Project", + workspaceRoot: "/tmp/project-flaky", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await expect( runtime.runPromise( @@ -394,28 +334,28 @@ describe("OrchestrationEngine", () => { }), ); - expect(result.sequence).toBe(1); - expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1); - + expect(result.sequence).toBe(2); + expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2); await runtime.dispose(); }); it("fails command dispatch when command invariants are violated", async () => { const system = await createOrchestrationSystem(); - const engine = system.engine; - const createdAt = new Date().toISOString(); + const { engine } = system; await expect( system.run( engine.dispatch({ - type: "message.send", + type: "thread.turn.start", commandId: "cmd-invariant-missing-thread", threadId: "thread-missing", - messageId: "msg-missing", - role: "user", - text: "hello", - streaming: false, - createdAt, + message: { + messageId: "msg-missing", + role: "user", + text: "hello", + attachments: [], + }, + createdAt: now(), }), ), ).rejects.toThrow("Thread 'thread-missing' does not exist"); @@ -425,8 +365,20 @@ describe("OrchestrationEngine", () => { it("rejects duplicate thread creation", async () => { const system = await createOrchestrationSystem(); - const engine = system.engine; - const createdAt = new Date().toISOString(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: "cmd-project-duplicate-create", + projectId: "project-duplicate", + title: "Duplicate Project", + workspaceRoot: "/tmp/project-duplicate", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await system.run( engine.dispatch({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..87619f26b690195d749e54f133b53745f39e3908 --- /dev/null +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -0,0 +1,610 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; +import { + CheckpointRef, + CommandId, + EventId, + MessageId, + ProjectId, + ProviderSessionId, + ProviderThreadId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { CheckpointReactorLive } from "./CheckpointReactor.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; +import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; + +const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); + +function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { + const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); + return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`); +} + +function createProviderServiceHarness(cwd: string, hasSession = true) { + const now = new Date().toISOString(); + const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const rollbackConversation = vi.fn(() => Effect.void); + + const unsupported = () => + Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect; + const listSessions = () => + hasSession + ? Effect.succeed([ + { + sessionId: asSessionId("sess-1"), + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + cwd, + createdAt: now, + updatedAt: now, + }, + ] satisfies ReadonlyArray) + : Effect.succeed([] as ReadonlyArray); + const service: ProviderServiceShape = { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + stopSession: () => unsupported(), + listSessions, + rollbackConversation, + stopAll: () => Effect.void, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + }; + + const emit = (event: ProviderRuntimeEvent): void => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); + }; + + return { + service, + rollbackConversation, + emit, + }; +} + +async function waitForThread( + engine: OrchestrationEngineShape, + predicate: (thread: { + latestTurnId: string | null; + checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; + activities: ReadonlyArray<{ kind: string }>; + }) => boolean, + timeoutMs = 2000, +) { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise<{ + latestTurnId: string | null; + checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; + activities: ReadonlyArray<{ kind: string }>; + }> => { + const readModel = await Effect.runPromise(engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); + if (thread && predicate(thread)) { + return thread; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for thread state."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + return poll(); +} + +async function waitForEvent( + engine: OrchestrationEngineShape, + predicate: (event: { type: string }) => boolean, + timeoutMs = 2000, +) { + const deadline = Date.now() + timeoutMs; + const poll = async () => { + const events = await Effect.runPromise( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + if (events.some(predicate)) { + return events; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for orchestration event."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + return poll(); +} + +async function waitForDiffBlob( + repository: CheckpointDiffBlobRepository["Service"], + input: { threadId: ThreadId; fromTurnCount: number; toTurnCount: number }, + timeoutMs = 2000, +) { + const deadline = Date.now() + timeoutMs; + const poll = async () => { + const blob = await Effect.runPromise(repository.get(input)); + if (blob._tag === "Some") { + return blob.value; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for checkpoint diff blob persistence."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + return poll(); +} + +function runGit(cwd: string, args: ReadonlyArray) { + return execFileSync("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); +} + +function createGitRepository() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3-checkpoint-handler-")); + runGit(cwd, ["init", "--initial-branch=main"]); + runGit(cwd, ["config", "user.email", "test@example.com"]); + runGit(cwd, ["config", "user.name", "Test User"]); + fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + runGit(cwd, ["add", "."]); + runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; +} + +function gitRefExists(cwd: string, ref: string): boolean { + try { + runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); + return true; + } catch { + return false; + } +} + +function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { + return runGit(cwd, ["show", `${ref}:${filePath}`]); +} + +async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise => { + if (gitRefExists(cwd, ref)) { + return; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for git ref '${ref}'.`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + return poll(); +} + +describe("CheckpointReactor", () => { + let runtime: ManagedRuntime.ManagedRuntime< + OrchestrationEngineService | CheckpointReactor | CheckpointDiffBlobRepository | CheckpointStore, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + const tempDirs: string[] = []; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + + async function createHarness(options?: { + readonly hasSession?: boolean; + readonly seedFilesystemCheckpoints?: boolean; + }) { + const cwd = createGitRepository(); + tempDirs.push(cwd); + const provider = createProviderServiceHarness(cwd, options?.hasSession ?? true); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(SqlitePersistenceMemory), + ); + const diffBlobLayer = CheckpointDiffBlobRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + const layer = CheckpointReactorLive.pipe( + Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(diffBlobLayer), + Layer.provideMerge(checkpointStoreLayer), + ); + + runtime = ManagedRuntime.make(layer); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); + const diffBlobRepository = await runtime.runPromise( + Effect.service(CheckpointDiffBlobRepository), + ); + const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await Effect.runPromise(Effect.sleep("10 millis")); + + const createdAt = new Date().toISOString(); + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: asCommandId("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Test Project", + workspaceRoot: "/tmp/test-project", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: asCommandId("cmd-thread-create"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: cwd, + createdAt, + }), + ); + + if (options?.seedFilesystemCheckpoints ?? true) { + await runtime.runPromise( + checkpointStore.captureCheckpoint({ + cwd, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0), + }), + ); + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + await runtime.runPromise( + checkpointStore.captureCheckpoint({ + cwd, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), + }), + ); + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + await runtime.runPromise( + checkpointStore.captureCheckpoint({ + cwd, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), + }), + ); + } + + return { + engine, + provider, + cwd, + diffBlobRepository, + }; + } + + it("captures pre-turn baseline on turn.started and post-turn checkpoint on turn.completed", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-capture"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "ready", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "turn.started", + eventId: EventId.makeUnsafe("evt-turn-started-1"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + turnId: asTurnId("turn-1"), + }); + await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-1"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + turnId: asTurnId("turn-1"), + status: "completed", + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + const thread = await waitForThread( + harness.engine, + (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, + ); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true); + expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), + ).toBe("v1\n"); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), + ).toBe("v2\n"); + }); + + it("captures pre-turn baseline from thread.turn.start domain flow before runtime turn events", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: asCommandId("cmd-turn-start-for-baseline"), + threadId: asThreadId("thread-1"), + message: { + messageId: MessageId.makeUnsafe("message-user-1"), + role: "user", + text: "start turn", + attachments: [], + }, + createdAt: new Date().toISOString(), + }), + ); + + await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), + ).toBe("v1\n"); + }); + + it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-checkpoint-captured"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "ready", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + harness.provider.emit({ + type: "checkpoint.captured", + eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + turnId: asTurnId("turn-3"), + turnCount: 3, + status: "completed", + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + ); + expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(true); + }); + + it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "ready", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: asCommandId("cmd-diff-1"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: asCommandId("cmd-diff-2"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-2"), + completedAt: createdAt, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), + status: "ready", + files: [], + checkpointTurnCount: 2, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: asCommandId("cmd-revert-request"), + threadId: asThreadId("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); + const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); + + expect(thread.latestTurnId).toBe("turn-1"); + expect(thread.checkpoints).toHaveLength(1); + expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ + sessionId: asSessionId("sess-1"), + numTurns: 1, + }); + expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); + expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 2))).toBe(false); + }); + + it("appends an error activity when revert is requested without an active session", async () => { + const harness = await createHarness({ hasSession: false }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: asCommandId("cmd-revert-no-session"), + threadId: asThreadId("thread-1"), + turnCount: 1, + createdAt, + }), + ); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), + ); + + expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( + true, + ); + expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); + }); + + it("persists full checkpoint diff blobs on thread.turn-diff-completed", async () => { + const harness = await createHarness(); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-diff"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "ready", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: asCommandId("cmd-diff-persist"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + completedAt: createdAt, + checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), + status: "ready", + files: [], + checkpointTurnCount: 1, + createdAt, + }), + ); + + const persistedBlob = await waitForDiffBlob(harness.diffBlobRepository, { + threadId: asThreadId("thread-1"), + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(persistedBlob.diff).toContain("diff --git"); + expect(persistedBlob.diff).toContain("README.md"); + }); +}); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c787967d78fd9a564f6530ac63bd8003c94719f --- /dev/null +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -0,0 +1,745 @@ +import { + CheckpointRef, + CommandId, + EventId, + MessageId, + ProviderSessionId, + ThreadId, + TurnId, + type OrchestrationEvent, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { Effect, Layer, Option, Queue, Stream } from "effect"; + +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import { OrchestrationDispatchError } from "../Errors.ts"; + +const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; + +type ReactorInput = + | { + readonly source: "runtime"; + readonly event: ProviderRuntimeEvent; + } + | { + readonly source: "domain"; + readonly event: OrchestrationEvent; + }; + +function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { + const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); + return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); +} + +function toTurnId(value: string | undefined): TurnId | null { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; +} + +function trimToNonEmpty(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { + switch (status) { + case "failed": + return "error"; + case "cancelled": + case "interrupted": + return "missing"; + case "completed": + default: + return "ready"; + } +} + +const serverCommandId = (tag: string): CommandId => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const checkpointStore = yield* CheckpointStore; + const checkpointDiffBlobRepository = yield* CheckpointDiffBlobRepository; + + const appendRevertFailureActivity = (input: { + readonly threadId: ThreadId; + readonly turnCount: number; + readonly detail: string; + readonly createdAt: string; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("checkpoint-revert-failure"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "error", + kind: "checkpoint.revert.failed", + summary: "Checkpoint revert failed", + payload: { + turnCount: input.turnCount, + detail: input.detail, + }, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const appendCaptureFailureActivity = (input: { + readonly sessionId: ProviderSessionId; + readonly turnId: TurnId | null; + readonly detail: string; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.session?.providerSessionId === input.sessionId, + ); + if (!thread) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("checkpoint-capture-failure"), + threadId: thread.id, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: { + detail: input.detail, + }, + turnId: input.turnId, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); + + const resolveSessionRuntimeForThread = Effect.fnUntraced(function* ( + threadId: ThreadId, + ): Effect.fn.Return< + Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> + > { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + const projectedSessionId = thread?.session?.providerSessionId ?? null; + + const sessions = yield* providerService.listSessions(); + + const findSessionWithCwd = ( + session: (typeof sessions)[number] | undefined, + ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { + const cwd = session?.cwd?.trim(); + if (!session || !cwd) { + return Option.none(); + } + return Option.some({ sessionId: session.sessionId, cwd }); + }; + + if (projectedSessionId !== null) { + const projectedSession = sessions.find((session) => session.sessionId === projectedSessionId); + const fromProjected = findSessionWithCwd(projectedSession); + if (Option.isSome(fromProjected)) { + return fromProjected; + } + } + + const matchedSession = sessions.find((session) => session.threadId?.trim() === threadId.trim()); + return findSessionWithCwd(matchedSession); + }); + + const persistDiffBlobFromTurnCompletion = Effect.fnUntraced(function* ( + event: Extract, + ) { + const toTurnCount = event.payload.checkpointTurnCount; + const fromTurnCount = Math.max(0, toTurnCount - 1); + + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId); + if (!thread) { + return; + } + + const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId); + if (Option.isNone(sessionRuntime)) { + yield* Effect.logWarning( + "checkpoint diff blob skipped: no active provider session cwd for thread", + { + threadId: event.payload.threadId, + fromTurnCount, + toTurnCount, + }, + ); + return; + } + + const fromCheckpointRef = + fromTurnCount === 0 + ? checkpointRefForThreadTurn(event.payload.threadId, 0) + : thread.checkpoints.find((checkpoint) => checkpoint.checkpointTurnCount === fromTurnCount) + ?.checkpointRef; + + if (!fromCheckpointRef) { + yield* Effect.logWarning("checkpoint diff blob skipped: missing from checkpoint ref", { + threadId: event.payload.threadId, + fromTurnCount, + toTurnCount, + }); + return; + } + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: sessionRuntime.value.cwd, + fromCheckpointRef, + toCheckpointRef: event.payload.checkpointRef, + fallbackFromToHead: false, + }); + + yield* checkpointDiffBlobRepository.upsert({ + threadId: event.payload.threadId, + fromTurnCount, + toTurnCount, + diff, + createdAt: event.payload.completedAt, + }); + }); + + const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( + event: Extract, + ) { + const turnId = toTurnId(event.turnId); + if (!turnId) { + return; + } + + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.session?.providerSessionId === event.sessionId, + ); + if (!thread) { + return; + } + + if (thread.checkpoints.some((checkpoint) => checkpoint.turnId === turnId)) { + return; + } + + const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); + if (Option.isNone(sessionRuntime)) { + yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", { + threadId: thread.id, + turnId, + }); + return; + } + + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const nextTurnCount = currentTurnCount + 1; + const fromTurnCount = Math.max(0, nextTurnCount - 1); + const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount); + const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount); + + const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({ + cwd: sessionRuntime.value.cwd, + checkpointRef: fromCheckpointRef, + }); + if (!fromCheckpointExists) { + yield* Effect.logWarning("checkpoint completion missing pre-turn baseline", { + threadId: thread.id, + turnId, + fromTurnCount, + }); + } + + yield* checkpointStore.captureCheckpoint({ + cwd: sessionRuntime.value.cwd, + checkpointRef: targetCheckpointRef, + }); + + const files = yield* checkpointStore + .diffCheckpoints({ + cwd: sessionRuntime.value.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + }) + .pipe( + Effect.map((diff) => + parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((error) => + Effect.logWarning("failed to derive checkpoint file summary", { + threadId: thread.id, + turnId, + turnCount: nextTurnCount, + detail: error.message, + }).pipe(Effect.as([])), + ), + ); + + const assistantMessageId = + thread.messages + .toReversed() + .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ?? + MessageId.makeUnsafe(`assistant:${turnId}`); + + const now = event.createdAt; + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: serverCommandId("checkpoint-turn-diff-complete"), + threadId: thread.id, + turnId, + completedAt: now, + checkpointRef: targetCheckpointRef, + status: checkpointStatusFromRuntime(event.status), + files, + assistantMessageId, + checkpointTurnCount: nextTurnCount, + createdAt: now, + }); + + yield* orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("checkpoint-captured-activity"), + threadId: thread.id, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "info", + kind: "checkpoint.captured", + summary: "Checkpoint captured", + payload: { + turnCount: nextTurnCount, + ...(event.status !== undefined ? { status: event.status } : {}), + }, + turnId, + createdAt: now, + }, + createdAt: now, + }); + }); + + const ensurePreTurnBaselineFromTurnStart = Effect.fnUntraced(function* ( + event: Extract, + ) { + const turnId = toTurnId(event.turnId); + if (!turnId) { + return; + } + + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.session?.providerSessionId === event.sessionId, + ); + if (!thread) { + return; + } + + const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath); + const checkpointCwd = + checkpointCwdFromThread ?? + Option.match(yield* resolveSessionRuntimeForThread(thread.id), { + onNone: () => undefined, + onSome: (runtime) => runtime.cwd, + }); + if (!checkpointCwd) { + yield* Effect.logWarning( + "checkpoint pre-turn capture skipped: no workspace cwd", + { + threadId: thread.id, + turnId, + }, + ); + return; + } + + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const baselineCheckpointRef = checkpointRefForThreadTurn(thread.id, currentTurnCount); + const baselineExists = yield* checkpointStore.hasCheckpointRef({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + if (baselineExists) { + return; + } + + yield* checkpointStore.captureCheckpoint({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + }); + + const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( + event: Extract, + ) { + if (event.type === "thread.message-sent") { + if (event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null) { + return; + } + } + + const threadId = event.payload.threadId; + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + if (!thread) { + return; + } + + const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath); + const checkpointCwd = + checkpointCwdFromThread ?? + Option.match(yield* resolveSessionRuntimeForThread(threadId), { + onNone: () => undefined, + onSome: (runtime) => runtime.cwd, + }); + if (!checkpointCwd) { + yield* Effect.logWarning( + "checkpoint pre-turn capture skipped: no workspace cwd", + { + threadId, + }, + ); + return; + } + + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + const baselineCheckpointRef = checkpointRefForThreadTurn(threadId, currentTurnCount); + const baselineExists = yield* checkpointStore.hasCheckpointRef({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + if (baselineExists) { + return; + } + + yield* checkpointStore.captureCheckpoint({ + cwd: checkpointCwd, + checkpointRef: baselineCheckpointRef, + }); + }); + + const handleRevertRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const now = new Date().toISOString(); + + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId); + if (!thread) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: "Thread was not found in read model.", + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId); + if (Option.isNone(sessionRuntime)) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: "No active provider session with workspace cwd is bound to this thread.", + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const currentTurnCount = thread.checkpoints.reduce( + (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), + 0, + ); + + if (event.payload.turnCount > currentTurnCount) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Checkpoint turn count ${event.payload.turnCount} exceeds current turn count ${currentTurnCount}.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const targetCheckpointRef = + event.payload.turnCount === 0 + ? checkpointRefForThreadTurn(event.payload.threadId, 0) + : thread.checkpoints.find( + (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount, + )?.checkpointRef; + + if (!targetCheckpointRef) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const restored = yield* checkpointStore.restoreCheckpoint({ + cwd: sessionRuntime.value.cwd, + checkpointRef: targetCheckpointRef, + fallbackToHead: event.payload.turnCount === 0, + }); + if (!restored) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`, + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } + + const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); + if (rolledBackTurns > 0) { + yield* providerService.rollbackConversation({ + sessionId: sessionRuntime.value.sessionId, + numTurns: rolledBackTurns, + }); + } + + const staleCheckpointRefs = thread.checkpoints + .filter((checkpoint) => checkpoint.checkpointTurnCount > event.payload.turnCount) + .map((checkpoint) => checkpoint.checkpointRef); + + if (staleCheckpointRefs.length > 0) { + yield* checkpointStore.deleteCheckpointRefs({ + cwd: sessionRuntime.value.cwd, + checkpointRefs: staleCheckpointRefs, + }); + } + + yield* orchestrationEngine + .dispatch({ + type: "thread.revert.complete", + commandId: serverCommandId("checkpoint-revert-complete"), + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + createdAt: now, + }) + .pipe( + Effect.catch((error) => + appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: error.message, + createdAt: now, + }), + ), + Effect.forkScoped, + Effect.asVoid, + ); + }); + + const processDomainEvent = Effect.fnUntraced(function* (event: OrchestrationEvent) { + if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") { + yield* ensurePreTurnBaselineFromDomainTurnStart(event); + return; + } + + if (event.type === "thread.turn-diff-completed") { + yield* persistDiffBlobFromTurnCompletion(event).pipe( + Effect.catch((error) => + Effect.logWarning("checkpoint diff blob persistence failed", { + threadId: event.payload.threadId, + turnId: event.payload.turnId, + checkpointTurnCount: event.payload.checkpointTurnCount, + detail: error.message, + }), + ), + ); + return; + } + + if (event.type === "thread.checkpoint-revert-requested") { + yield* handleRevertRequested(event).pipe( + Effect.catch((error) => + appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: error.message, + createdAt: new Date().toISOString(), + }), + ), + ); + } + }); + + const processRuntimeEvent = Effect.fnUntraced(function* (event: ProviderRuntimeEvent) { + if (event.type === "turn.started") { + yield* ensurePreTurnBaselineFromTurnStart(event); + return; + } + + if (event.type === "turn.completed") { + const turnId = toTurnId(event.turnId); + yield* captureCheckpointFromTurnCompletion(event).pipe( + Effect.catch((error) => + appendCaptureFailureActivity({ + sessionId: event.sessionId, + turnId, + detail: error.message, + createdAt: new Date().toISOString(), + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + return; + } + + if (event.type === "checkpoint.captured") { + const turnId = toTurnId(event.turnId); + if (!turnId) { + return; + } + + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.session?.providerSessionId === event.sessionId, + ); + if (!thread) { + return; + } + + if ( + thread.checkpoints.some( + (checkpoint) => + checkpoint.turnId === turnId || checkpoint.checkpointTurnCount === event.turnCount, + ) + ) { + return; + } + + const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); + if (Option.isNone(sessionRuntime)) { + return; + } + + const fromTurnCount = Math.max(0, event.turnCount - 1); + const fromCheckpointRef = checkpointRefForThreadTurn(thread.id, fromTurnCount); + const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, event.turnCount); + + const targetExists = yield* checkpointStore.hasCheckpointRef({ + cwd: sessionRuntime.value.cwd, + checkpointRef: targetCheckpointRef, + }); + if (!targetExists) { + yield* checkpointStore.captureCheckpoint({ + cwd: sessionRuntime.value.cwd, + checkpointRef: targetCheckpointRef, + }); + } + + const files = yield* checkpointStore + .diffCheckpoints({ + cwd: sessionRuntime.value.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + }) + .pipe( + Effect.map((diff) => + parseTurnDiffFilesFromUnifiedDiff(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch(() => Effect.succeed([])), + ); + + const assistantMessageId = + thread.messages + .toReversed() + .find((entry) => entry.role === "assistant" && entry.turnId === turnId)?.id ?? + MessageId.makeUnsafe(`assistant:${turnId}`); + + yield* orchestrationEngine.dispatch({ + type: "thread.turn.diff.complete", + commandId: serverCommandId("checkpoint-runtime-captured-diff-complete"), + threadId: thread.id, + turnId, + completedAt: event.createdAt, + checkpointRef: targetCheckpointRef, + status: checkpointStatusFromRuntime(event.status), + files, + assistantMessageId, + checkpointTurnCount: event.turnCount, + createdAt: event.createdAt, + }); + } + }); + + const processInput = ( + input: ReactorInput, + ): Effect.Effect => + input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event); + + const start: CheckpointReactorShape["start"] = Effect.gen(function* () { + const queue = yield* Queue.unbounded(); + yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); + + yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInput)))); + + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if ( + event.type !== "thread.turn-start-requested" && + event.type !== "thread.message-sent" && + event.type !== "thread.turn-diff-completed" && + event.type !== "thread.checkpoint-revert-requested" + ) { + return Effect.void; + } + return Queue.offer(queue, { source: "domain", event }).pipe(Effect.asVoid); + }), + ); + + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => { + if ( + event.type !== "turn.started" && + event.type !== "turn.completed" && + event.type !== "checkpoint.captured" + ) { + return Effect.void; + } + return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid); + }), + ); + }); + + return { + start, + } satisfies CheckpointReactorShape; +}); + +export const CheckpointReactorLive = Layer.effect(CheckpointReactor, make); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 8dcc809fe081ca84c522faf09debf8c9fc353c46..5017c177f5cce0bfb38661ee19b5d023119c4883 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,20 +1,24 @@ import type { - OrchestrationCommand, OrchestrationEvent, OrchestrationReadModel, + ProjectId, + ThreadId, } from "@t3tools/contracts"; -import { OrchestrationCommandSchema } from "@t3tools/contracts"; -import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; +import { OrchestrationCommand } from "@t3tools/contracts"; +import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; -import { createLogger } from "../../logger.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { toOrchestrationCommandDecodeError, toOrchestrationJsonParseError, + OrchestrationCommandInvariantError, + OrchestrationCommandPreviouslyRejectedError, type OrchestrationDispatchError, } from "../Errors.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -25,11 +29,39 @@ interface CommandEnvelope { result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; } -const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema); +const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand); + +function commandToAggregateRef(command: OrchestrationCommand): { + readonly aggregateKind: "project" | "thread"; + readonly aggregateId: ProjectId | ThreadId; +} { + switch (command.type) { + case "project.create": + case "project.meta.update": + case "project.delete": + return { + aggregateKind: "project", + aggregateId: command.projectId, + }; + default: + return { + aggregateKind: "thread", + aggregateId: command.threadId, + }; + } +} + +function formatDispatchError(error: OrchestrationDispatchError): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + return String(error); +} const makeOrchestrationEngine = Effect.gen(function* () { - const logger = createLogger("orchestration"); const eventStore = yield* OrchestrationEventStore; + const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; + const projectionPipeline = yield* OrchestrationProjectionPipeline; let readModel = createEmptyReadModel(new Date().toISOString()); @@ -38,18 +70,86 @@ const makeOrchestrationEngine = Effect.gen(function* () { const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => Effect.gen(function* () { + const existingReceipt = yield* commandReceiptRepository.getByCommandId({ + commandId: envelope.command.commandId, + }); + if (Option.isSome(existingReceipt)) { + if (existingReceipt.value.status === "accepted") { + yield* Deferred.succeed(envelope.result, { + sequence: existingReceipt.value.resultSequence, + }); + return; + } + yield* Deferred.fail( + envelope.result, + new OrchestrationCommandPreviouslyRejectedError({ + commandId: envelope.command.commandId, + detail: existingReceipt.value.error ?? "Previously rejected.", + }), + ); + return; + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel, }); - const savedEvent = yield* eventStore.append(eventBase); - readModel = yield* projectEvent(readModel, savedEvent); - yield* PubSub.publish(eventPubSub, savedEvent); + const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + let lastSavedEvent = null as OrchestrationEvent | null; - yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); - }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); + for (const nextEvent of eventBases) { + const savedEvent = yield* eventStore.append(nextEvent); + yield* projectionPipeline.projectEvent(savedEvent); + readModel = yield* projectEvent(readModel, savedEvent); + yield* PubSub.publish(eventPubSub, savedEvent); + lastSavedEvent = savedEvent; + } - // bootstrap read model from event store + if (lastSavedEvent === null) { + return yield* Deferred.fail( + envelope.result, + new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: "Command produced no events.", + }), + ); + } + + yield* commandReceiptRepository.upsert({ + commandId: envelope.command.commandId, + aggregateKind: lastSavedEvent.aggregateKind, + aggregateId: lastSavedEvent.aggregateId, + acceptedAt: lastSavedEvent.occurredAt, + resultSequence: lastSavedEvent.sequence, + status: "accepted", + error: null, + }); + yield* Deferred.succeed(envelope.result, { sequence: lastSavedEvent.sequence }); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (error instanceof OrchestrationCommandInvariantError) { + const aggregateRef = commandToAggregateRef(envelope.command); + yield* commandReceiptRepository + .upsert({ + commandId: envelope.command.commandId, + aggregateKind: aggregateRef.aggregateKind, + aggregateId: aggregateRef.aggregateId, + acceptedAt: new Date().toISOString(), + resultSequence: readModel.snapshotSequence, + status: "rejected", + error: formatDispatchError(error), + }) + .pipe(Effect.catch(() => Effect.void)); + } + yield* Deferred.fail(envelope.result, error); + }), + ), + ); + + yield* projectionPipeline.bootstrap; + + // bootstrap in-memory read model from event store yield* Stream.runForEach(eventStore.readAll(), (event) => Effect.gen(function* () { readModel = yield* projectEvent(readModel, event); @@ -58,9 +158,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); yield* Effect.forkScoped(worker); - logger.info("orchestration engine started", { - sequence: readModel.sequence, - }); + yield* Effect.log("orchestration engine started").pipe( + Effect.annotateLogs({ sequence: readModel.snapshotSequence }), + ); const getReadModel: OrchestrationEngineShape["getReadModel"] = () => Effect.sync((): OrchestrationReadModel => readModel); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44f726142c8719d692da7336eff238d10be9269b --- /dev/null +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -0,0 +1,61 @@ +import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; +import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; +import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; + +describe("OrchestrationReactor", () => { + let runtime: ManagedRuntime.ManagedRuntime | null = null; + + afterEach(async () => { + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + it("starts provider ingestion, provider command, and checkpoint reactors", async () => { + const started: string[] = []; + + runtime = ManagedRuntime.make( + Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe( + Layer.provideMerge( + Layer.succeed(ProviderRuntimeIngestionService, { + start: Effect.sync(() => { + started.push("provider-runtime-ingestion"); + }), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderCommandReactor, { + start: Effect.sync(() => { + started.push("provider-command-reactor"); + }), + }), + ), + Layer.provideMerge( + Layer.succeed(CheckpointReactor, { + start: Effect.sync(() => { + started.push("checkpoint-reactor"); + }), + }), + ), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(OrchestrationReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + + expect(started).toEqual([ + "provider-runtime-ingestion", + "provider-command-reactor", + "checkpoint-reactor", + ]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); +}); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9719c428c521676264421603affbc6e4b0245de --- /dev/null +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -0,0 +1,27 @@ +import { Effect, Layer } from "effect"; + +import { + OrchestrationReactor, + type OrchestrationReactorShape, +} from "../Services/OrchestrationReactor.ts"; +import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; +import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; + +export const makeOrchestrationReactor = Effect.gen(function* () { + const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; + const providerCommandReactor = yield* ProviderCommandReactor; + const checkpointReactor = yield* CheckpointReactor; + + const start: OrchestrationReactorShape["start"] = Effect.gen(function* () { + yield* providerRuntimeIngestion.start; + yield* providerCommandReactor.start; + yield* checkpointReactor.start; + }); + + return { + start, + } satisfies OrchestrationReactorShape; +}); + +export const OrchestrationReactorLive = Layer.effect(OrchestrationReactor, makeOrchestrationReactor); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ef91c23560d9cdcf282bb4d6168ac6a701ef3f9 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -0,0 +1,367 @@ +import { + CommandId, + CorrelationId, + EventId, + MessageId, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { + ORCHESTRATION_PROJECTOR_NAMES, + OrchestrationProjectionPipelineLive, +} from "./ProjectionPipeline.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; + +const projectionLayer = it.layer( + OrchestrationProjectionPipelineLive.pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +projectionLayer("OrchestrationProjectionPipeline", (it) => { + it.effect("bootstraps all projection states and writes projection rows", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-1"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread 1", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + messageId: MessageId.makeUnsafe("message-1"), + role: "assistant", + text: "hello", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + const projectRows = yield* sql<{ + readonly projectId: string; + readonly title: string; + readonly scriptsJson: string; + }>` + SELECT + project_id AS "projectId", + title, + scripts_json AS "scriptsJson" + FROM projection_projects + `; + assert.deepEqual(projectRows, [ + { projectId: "project-1", title: "Project 1", scriptsJson: "[]" }, + ]); + + const messageRows = yield* sql<{ + readonly messageId: string; + readonly text: string; + }>` + SELECT + message_id AS "messageId", + text + FROM projection_thread_messages + `; + assert.deepEqual(messageRows, [{ messageId: "message-1", text: "hello" }]); + + const stateRows = yield* sql<{ + readonly projector: string; + readonly lastAppliedSequence: number; + }>` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + ORDER BY projector ASC + `; + assert.equal(stateRows.length, Object.keys(ORCHESTRATION_PROJECTOR_NAMES).length); + for (const row of stateRows) { + assert.equal(row.lastAppliedSequence, 3); + } + }), + ); + + it.effect("resumes from projector last_applied_sequence without replaying older events", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-a1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-a"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-a1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-a1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-a"), + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-a2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-a"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-a2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-a2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-a"), + projectId: ProjectId.makeUnsafe("project-a"), + title: "Thread A", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-a3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-a"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-a3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-a3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-a"), + messageId: MessageId.makeUnsafe("message-a"), + role: "assistant", + text: "hello", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-a4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-a"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-a4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-a4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-a"), + messageId: MessageId.makeUnsafe("message-a"), + role: "assistant", + text: " world", + turnId: null, + streaming: true, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + const messageRows = yield* sql<{ readonly text: string }>` + SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + `; + assert.deepEqual(messageRows, [{ text: "hello world" }]); + + const stateRows = yield* sql<{ + readonly projector: string; + readonly lastAppliedSequence: number; + }>` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + `; + const maxSequenceRows = yield* sql<{ readonly maxSequence: number }>` + SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events + `; + const maxSequence = maxSequenceRows[0]?.maxSequence ?? 0; + for (const row of stateRows) { + assert.equal(row.lastAppliedSequence, maxSequence); + } + }), + ); +}); + +const engineLayer = it.layer( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { + it.effect("projects dispatched engine events immediately", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const createdAt = new Date().toISOString(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-live-project"), + projectId: ProjectId.makeUnsafe("project-live"), + title: "Live Project", + workspaceRoot: "/tmp/project-live", + defaultModel: "gpt-5-codex", + createdAt, + }); + + const projectRows = yield* sql<{ readonly title: string; readonly scriptsJson: string }>` + SELECT + title, + scripts_json AS "scriptsJson" + FROM projection_projects + WHERE project_id = 'project-live' + `; + assert.deepEqual(projectRows, [{ title: "Live Project", scriptsJson: "[]" }]); + + const projectorRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT + last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = 'projection.projects' + `; + assert.deepEqual(projectorRows, [{ lastAppliedSequence: 1 }]); + }), + ); + + it.effect("projects persist updated scripts from project.meta.update", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const createdAt = new Date().toISOString(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-scripts-project-create"), + projectId: ProjectId.makeUnsafe("project-scripts"), + title: "Scripts Project", + workspaceRoot: "/tmp/project-scripts", + defaultModel: "gpt-5-codex", + createdAt, + }); + + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.makeUnsafe("cmd-scripts-project-update"), + projectId: ProjectId.makeUnsafe("project-scripts"), + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + defaultModel: "gpt-5", + }); + + const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModel: string }>` + SELECT + scripts_json AS "scriptsJson", + default_model AS "defaultModel" + FROM projection_projects + WHERE project_id = 'project-scripts' + `; + assert.deepEqual(projectRows, [ + { + scriptsJson: + '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', + defaultModel: "gpt-5", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts new file mode 100644 index 0000000000000000000000000000000000000000..2280d15f27f4be927d688244b0e3872d2dbb00ff --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -0,0 +1,721 @@ +import { + ApprovalRequestId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { Effect, Layer, Option, Stream } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ProjectionCheckpointRepository } from "../../persistence/Services/ProjectionCheckpoints.ts"; +import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; +import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; +import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; +import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts"; +import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts"; +import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectionCheckpointRepositoryLive } from "../../persistence/Layers/ProjectionCheckpoints.ts"; +import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; +import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; +import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; +import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; +import { ProjectionThreadTurnRepositoryLive } from "../../persistence/Layers/ProjectionThreadTurns.ts"; +import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { + OrchestrationProjectionPipeline, + type OrchestrationProjectionPipelineShape, +} from "../Services/ProjectionPipeline.ts"; + +export const ORCHESTRATION_PROJECTOR_NAMES = { + projects: "projection.projects", + threads: "projection.threads", + threadMessages: "projection.thread-messages", + threadActivities: "projection.thread-activities", + threadSessions: "projection.thread-sessions", + threadTurns: "projection.thread-turns", + checkpoints: "projection.checkpoints", + pendingApprovals: "projection.pending-approvals", +} as const; + +type ProjectorName = + (typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES]; + +interface ProjectorDefinition { + readonly name: ProjectorName; + readonly apply: (event: OrchestrationEvent) => Effect.Effect; +} + +function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { + if (typeof payload !== "object" || payload === null) { + return null; + } + const requestId = (payload as Record).requestId; + return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; +} + +function nextTurnCount(turns: ReadonlyArray): number { + return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1; +} + +const makeOrchestrationProjectionPipeline = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const eventStore = yield* OrchestrationEventStore; + const projectionStateRepository = yield* ProjectionStateRepository; + const projectionProjectRepository = yield* ProjectionProjectRepository; + const projectionThreadRepository = yield* ProjectionThreadRepository; + const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; + const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; + const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; + const projectionThreadTurnRepository = yield* ProjectionThreadTurnRepository; + const projectionCheckpointRepository = yield* ProjectionCheckpointRepository; + const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; + const pendingTurnStartByThreadId = new Map< + string, + { + readonly messageId: NonNullable; + readonly createdAt: string; + } + >(); + + const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "project.created": + yield* projectionProjectRepository.upsert({ + projectId: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + defaultModel: event.payload.defaultModel, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }); + return; + + case "project.meta-updated": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.workspaceRoot !== undefined + ? { workspaceRoot: event.payload.workspaceRoot } + : {}), + ...(event.payload.defaultModel !== undefined + ? { defaultModel: event.payload.defaultModel } + : {}), + ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "project.deleted": { + const existingRow = yield* projectionProjectRepository.getById({ + projectId: event.payload.projectId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionProjectRepository.upsert({ + ...existingRow.value, + deletedAt: event.payload.deletedAt, + updatedAt: event.payload.deletedAt, + }); + return; + } + + default: + return; + } + }); + + const applyThreadsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "thread.created": + yield* projectionThreadRepository.upsert({ + threadId: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + model: event.payload.model, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurnId: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }); + return; + + case "thread.meta-updated": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), + ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), + ...(event.payload.worktreePath !== undefined + ? { worktreePath: event.payload.worktreePath } + : {}), + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.deleted": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + deletedAt: event.payload.deletedAt, + updatedAt: event.payload.deletedAt, + }); + return; + } + + case "thread.message-sent": + case "thread.activity-appended": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.occurredAt, + }); + return; + } + + case "thread.session-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestTurnId: event.payload.session.activeTurnId, + updatedAt: event.occurredAt, + }); + return; + } + + case "thread.turn-diff-completed": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestTurnId: event.payload.turnId, + updatedAt: event.occurredAt, + }); + return; + } + + case "thread.reverted": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestTurnId: null, + updatedAt: event.occurredAt, + }); + return; + } + + default: + return; + } + }); + + const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + if (event.type !== "thread.message-sent") { + return; + } + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId); + const nextText = + existingMessage && event.payload.streaming + ? `${existingMessage.text}${event.payload.text}` + : event.payload.text; + yield* projectionThreadMessageRepository.upsert({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: nextText, + isStreaming: event.payload.streaming, + createdAt: existingMessage?.createdAt ?? event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + }); + + const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + if (event.type !== "thread.activity-appended") { + return; + } + yield* projectionThreadActivityRepository.upsert({ + activityId: event.payload.activity.id, + threadId: event.payload.threadId, + turnId: event.payload.activity.turnId, + tone: event.payload.activity.tone, + kind: event.payload.activity.kind, + summary: event.payload.activity.summary, + payload: event.payload.activity.payload, + createdAt: event.payload.activity.createdAt, + }); + }); + + const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + if (event.type !== "thread.session-set") { + return; + } + yield* projectionThreadSessionRepository.upsert({ + threadId: event.payload.threadId, + status: event.payload.session.status, + providerName: event.payload.session.providerName, + providerSessionId: event.payload.session.providerSessionId, + providerThreadId: event.payload.session.providerThreadId, + activeTurnId: event.payload.session.activeTurnId, + lastError: event.payload.session.lastError, + updatedAt: event.payload.session.updatedAt, + }); + }); + + const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "thread.turn-start-requested": { + pendingTurnStartByThreadId.set(event.payload.threadId, { + messageId: event.payload.messageId, + createdAt: event.payload.createdAt, + }); + return; + } + + case "thread.session-set": { + const turnId = event.payload.session.activeTurnId; + if (turnId === null || event.payload.session.status !== "running") { + return; + } + + const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ turnId }); + const pendingTurnStart = pendingTurnStartByThreadId.get(event.payload.threadId); + if (Option.isSome(existingTurn)) { + yield* projectionThreadTurnRepository.upsert({ + ...existingTurn.value, + status: existingTurn.value.status === "completed" ? "completed" : "running", + userMessageId: existingTurn.value.userMessageId ?? (pendingTurnStart?.messageId ?? null), + startedAt: existingTurn.value.startedAt, + }); + } else { + const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionThreadTurnRepository.upsert({ + turnId, + threadId: event.payload.threadId, + turnCount: nextTurnCount(existingThreadTurns), + status: "running", + userMessageId: pendingTurnStart?.messageId ?? null, + assistantMessageId: null, + startedAt: pendingTurnStart?.createdAt ?? event.occurredAt, + completedAt: null, + }); + } + + pendingTurnStartByThreadId.delete(event.payload.threadId); + return; + } + + case "thread.message-sent": { + if (event.payload.turnId === null || event.payload.role !== "assistant") { + return; + } + const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ + turnId: event.payload.turnId, + }); + if (Option.isSome(existingTurn)) { + yield* projectionThreadTurnRepository.upsert({ + ...existingTurn.value, + assistantMessageId: event.payload.messageId, + status: event.payload.streaming + ? existingTurn.value.status + : existingTurn.value.status === "interrupted" + ? "interrupted" + : existingTurn.value.status === "error" + ? "error" + : "completed", + completedAt: event.payload.streaming + ? existingTurn.value.completedAt + : (existingTurn.value.completedAt ?? event.payload.updatedAt), + }); + return; + } + const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionThreadTurnRepository.upsert({ + turnId: event.payload.turnId, + threadId: event.payload.threadId, + turnCount: nextTurnCount(existingThreadTurns), + status: event.payload.streaming ? "running" : "completed", + userMessageId: null, + assistantMessageId: event.payload.messageId, + startedAt: event.payload.createdAt, + completedAt: event.payload.streaming ? null : event.payload.updatedAt, + }); + return; + } + + case "thread.turn-interrupt-requested": { + if (event.payload.turnId === undefined) { + return; + } + const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ + turnId: event.payload.turnId, + }); + if (Option.isSome(existingTurn)) { + yield* projectionThreadTurnRepository.upsert({ + ...existingTurn.value, + status: "interrupted", + completedAt: existingTurn.value.completedAt ?? event.payload.createdAt, + }); + return; + } + const existingThreadTurns = yield* projectionThreadTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionThreadTurnRepository.upsert({ + turnId: event.payload.turnId, + threadId: event.payload.threadId, + turnCount: nextTurnCount(existingThreadTurns), + status: "interrupted", + userMessageId: null, + assistantMessageId: null, + startedAt: event.payload.createdAt, + completedAt: event.payload.createdAt, + }); + return; + } + + case "thread.turn-diff-completed": { + const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ + turnId: event.payload.turnId, + }); + const nextStatus = event.payload.status === "error" ? "error" : "completed"; + if (Option.isSome(existingTurn)) { + yield* projectionThreadTurnRepository.upsert({ + ...existingTurn.value, + turnCount: event.payload.checkpointTurnCount, + status: nextStatus, + assistantMessageId: event.payload.assistantMessageId, + completedAt: event.payload.completedAt, + }); + return; + } + yield* projectionThreadTurnRepository.upsert({ + turnId: event.payload.turnId, + threadId: event.payload.threadId, + turnCount: event.payload.checkpointTurnCount, + status: nextStatus, + userMessageId: null, + assistantMessageId: event.payload.assistantMessageId, + startedAt: event.payload.completedAt, + completedAt: event.payload.completedAt, + }); + return; + } + + case "thread.reverted": { + const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const keptTurns = existingTurns.filter( + (turn) => turn.turnCount <= event.payload.turnCount, + ); + yield* projectionThreadTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach(keptTurns, projectionThreadTurnRepository.upsert, { + concurrency: 1, + }).pipe(Effect.asVoid); + return; + } + + default: + return; + } + }); + + const applyCheckpointsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "thread.turn-diff-completed": + yield* projectionCheckpointRepository.upsert({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + checkpointTurnCount: event.payload.checkpointTurnCount, + checkpointRef: event.payload.checkpointRef, + status: event.payload.status, + files: event.payload.files, + assistantMessageId: event.payload.assistantMessageId, + completedAt: event.payload.completedAt, + }); + return; + + case "thread.reverted": { + const existingCheckpoints = yield* projectionCheckpointRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const keptCheckpoints = existingCheckpoints.filter( + (checkpoint) => checkpoint.checkpointTurnCount <= event.payload.turnCount, + ); + yield* projectionCheckpointRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach(keptCheckpoints, projectionCheckpointRepository.upsert, { + concurrency: 1, + }).pipe(Effect.asVoid); + return; + } + + default: + return; + } + }); + + const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) => + Effect.gen(function* () { + switch (event.type) { + case "thread.activity-appended": { + const requestId = + extractActivityRequestId(event.payload.activity.payload) ?? + event.metadata.requestId ?? + null; + if (requestId === null) { + return; + } + const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({ + requestId, + }); + if (event.payload.activity.kind === "approval.resolved") { + const resolvedDecisionRaw = + typeof event.payload.activity.payload === "object" && + event.payload.activity.payload !== null && + "decision" in event.payload.activity.payload + ? (event.payload.activity.payload as { decision?: unknown }).decision + : null; + const resolvedDecision = + resolvedDecisionRaw === "accept" || + resolvedDecisionRaw === "acceptForSession" || + resolvedDecisionRaw === "decline" || + resolvedDecisionRaw === "cancel" + ? resolvedDecisionRaw + : null; + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: Option.isSome(existingRow) + ? existingRow.value.threadId + : event.payload.threadId, + turnId: Option.isSome(existingRow) + ? existingRow.value.turnId + : event.payload.activity.turnId, + status: "resolved", + decision: resolvedDecision, + createdAt: Option.isSome(existingRow) + ? existingRow.value.createdAt + : event.payload.activity.createdAt, + resolvedAt: event.payload.activity.createdAt, + }); + return; + } + if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { + return; + } + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: event.payload.threadId, + turnId: event.payload.activity.turnId, + status: "pending", + decision: null, + createdAt: Option.isSome(existingRow) + ? existingRow.value.createdAt + : event.payload.activity.createdAt, + resolvedAt: null, + }); + return; + } + + case "thread.approval-response-requested": { + const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({ + requestId: event.payload.requestId, + }); + yield* projectionPendingApprovalRepository.upsert({ + requestId: event.payload.requestId, + threadId: Option.isSome(existingRow) + ? existingRow.value.threadId + : event.payload.threadId, + turnId: Option.isSome(existingRow) ? existingRow.value.turnId : null, + status: "resolved", + decision: event.payload.decision, + createdAt: Option.isSome(existingRow) + ? existingRow.value.createdAt + : event.payload.createdAt, + resolvedAt: event.payload.createdAt, + }); + return; + } + + default: + return; + } + }); + + const projectors: ReadonlyArray = [ + { + name: ORCHESTRATION_PROJECTOR_NAMES.projects, + apply: applyProjectsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, + apply: applyThreadMessagesProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities, + apply: applyThreadActivitiesProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + apply: applyThreadSessionsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, + apply: applyThreadTurnsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints, + apply: applyCheckpointsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals, + apply: applyPendingApprovalsProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.threads, + apply: applyThreadsProjection, + }, + ]; + + const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => + sql.withTransaction( + projector.apply(event).pipe( + Effect.flatMap(() => + projectionStateRepository.upsert({ + projector: projector.name, + lastAppliedSequence: event.sequence, + updatedAt: event.occurredAt, + }), + ), + ), + ); + + const bootstrapProjector = (projector: ProjectorDefinition) => + projectionStateRepository + .getByProjector({ + projector: projector.name, + }) + .pipe( + Effect.flatMap((stateRow) => + Stream.runForEach( + eventStore.readFromSequence( + Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, + ), + (event) => runProjectorForEvent(projector, event), + ), + ), + ); + + const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => + Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { + concurrency: 1, + }).pipe( + Effect.asVoid, + Effect.catchTag("SqlError", (sqlError) => + Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), + ), + ); + + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( + projectors, + bootstrapProjector, + { + concurrency: 1, + }, + ).pipe( + Effect.asVoid, + Effect.tap(() => + Effect.log("orchestration projection pipeline bootstrapped").pipe( + Effect.annotateLogs({ projectors: projectors.length }), + ), + ), + Effect.catchTag("SqlError", (sqlError) => + Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)), + ), + ); + + return { + bootstrap, + projectEvent, + } satisfies OrchestrationProjectionPipelineShape; +}); + +export const OrchestrationProjectionPipelineLive = Layer.effect( + OrchestrationProjectionPipeline, + makeOrchestrationProjectionPipeline, +).pipe( + Layer.provideMerge(ProjectionProjectRepositoryLive), + Layer.provideMerge(ProjectionThreadRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(ProjectionThreadActivityRepositoryLive), + Layer.provideMerge(ProjectionThreadSessionRepositoryLive), + Layer.provideMerge(ProjectionThreadTurnRepositoryLive), + Layer.provideMerge(ProjectionCheckpointRepositoryLive), + Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), + Layer.provideMerge(ProjectionStateRepositoryLive), +); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f57264c3187945a3ca89aae9bfba49e9b39e4f8 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -0,0 +1,265 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; + +const projectionSnapshotLayer = it.layer( + OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { + it.effect("hydrates read model from projection tables and computes snapshot sequence", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + 'gpt-5-codex', + '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + 'gpt-5-codex', + NULL, + NULL, + 'turn-1', + '2026-02-24T00:00:02.000Z', + '2026-02-24T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-1', + 'thread-1', + 'turn-1', + 'assistant', + 'hello from projection', + 0, + '2026-02-24T00:00:04.000Z', + '2026-02-24T00:00:05.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES ( + 'activity-1', + 'thread-1', + 'turn-1', + 'info', + 'runtime.note', + 'provider started', + '{"stage":"start"}', + '2026-02-24T00:00:06.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + 'thread-1', + 'running', + 'codex', + 'provider-session-1', + 'provider-thread-1', + 'turn-1', + NULL, + '2026-02-24T00:00:07.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_checkpoints ( + thread_id, + turn_id, + checkpoint_turn_count, + checkpoint_ref, + status, + files_json, + assistant_message_id, + completed_at + ) + VALUES ( + 'thread-1', + 'turn-1', + 1, + 'checkpoint-1', + 'ready', + '[{"path":"README.md","kind":"modified","additions":2,"deletions":1}]', + 'message-1', + '2026-02-24T00:00:08.000Z' + ) + `; + + let sequence = 5; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state ( + projector, + last_applied_sequence, + updated_at + ) + VALUES ( + ${projector}, + ${sequence}, + '2026-02-24T00:00:09.000Z' + ) + `; + sequence += 1; + } + + const snapshot = yield* snapshotQuery.getSnapshot(); + + assert.equal(snapshot.snapshotSequence, 5); + assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z"); + assert.deepEqual(snapshot.projects, [ + { + id: "project-1", + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModel: "gpt-5-codex", + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + createdAt: "2026-02-24T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:01.000Z", + deletedAt: null, + }, + ]); + assert.deepEqual(snapshot.threads, [ + { + id: "thread-1", + projectId: "project-1", + title: "Thread 1", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + latestTurnId: "turn-1", + createdAt: "2026-02-24T00:00:02.000Z", + updatedAt: "2026-02-24T00:00:03.000Z", + deletedAt: null, + messages: [ + { + id: "message-1", + role: "assistant", + text: "hello from projection", + turnId: "turn-1", + streaming: false, + createdAt: "2026-02-24T00:00:04.000Z", + updatedAt: "2026-02-24T00:00:05.000Z", + }, + ], + activities: [ + { + id: "activity-1", + tone: "info", + kind: "runtime.note", + summary: "provider started", + payload: { stage: "start" }, + turnId: "turn-1", + createdAt: "2026-02-24T00:00:06.000Z", + }, + ], + checkpoints: [ + { + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "checkpoint-1", + status: "ready", + files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], + assistantMessageId: "message-1", + completedAt: "2026-02-24T00:00:08.000Z", + }, + ], + session: { + threadId: "thread-1", + status: "running", + providerName: "codex", + providerSessionId: "provider-session-1", + providerThreadId: "provider-thread-1", + activeTurnId: "turn-1", + lastError: null, + updatedAt: "2026-02-24T00:00:07.000Z", + }, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2817933f26043af35501d931a2a5ea32503a5b8 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -0,0 +1,435 @@ +import { + OrchestrationCheckpointFile, + OrchestrationReadModel, + ProjectScript, + type OrchestrationCheckpointSummary, + type OrchestrationMessage, + type OrchestrationProject, + type OrchestrationSession, + type OrchestrationThread, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { Effect, Layer, Schema, Struct } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + PersistenceDecodeError, + PersistenceSerializationError, + PersistenceSqlError, + toPersistenceDecodeError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; +import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; +import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; +import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; +import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; +import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../Services/ProjectionSnapshotQuery.ts"; + +const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( + Struct.assign({ + scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), + }), +); +const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + }), +); +const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + }), +); +const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; +const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( + Struct.assign({ + files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); +const ProjectionStateDbRowSchema = ProjectionState; + +const REQUIRED_SNAPSHOT_PROJECTORS = Object.values(ORCHESTRATION_PROJECTOR_NAMES); + +function maxIso(left: string | null, right: string): string { + if (left === null) { + return right; + } + return left > right ? left : right; +} + +function computeSnapshotSequence( + stateRows: ReadonlyArray>, +): number { + if (stateRows.length === 0) { + return 0; + } + const sequenceByProjector = new Map( + stateRows.map((row) => [row.projector, row.lastAppliedSequence] as const), + ); + + let minSequence = Number.POSITIVE_INFINITY; + for (const projector of REQUIRED_SNAPSHOT_PROJECTORS) { + const sequence = sequenceByProjector.get(projector); + if (sequence === undefined) { + return 0; + } + if (sequence < minSequence) { + minSequence = sequence; + } + } + + return Number.isFinite(minSequence) ? minSequence : 0; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): ProjectionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +function toProjectionRepositoryError(operation: string) { + return (cause: unknown): ProjectionRepositoryError => { + if ( + cause instanceof PersistenceSqlError || + cause instanceof PersistenceDecodeError || + cause instanceof PersistenceSerializationError + ) { + return cause; + } + return toPersistenceSqlError(operation)(cause); + }; +} + +const makeProjectionSnapshotQuery = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listProjectRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionProjectDbRowSchema, + execute: () => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model AS "defaultModel", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + ORDER BY created_at ASC, project_id ASC + `, + }); + + const listThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model, + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_threads + ORDER BY created_at ASC, thread_id ASC + `, + }); + + const listThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + ORDER BY thread_id ASC, created_at ASC, message_id ASC + `, + }); + + const listThreadActivityRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadActivityDbRowSchema, + execute: () => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + created_at AS "createdAt" + FROM projection_thread_activities + ORDER BY thread_id ASC, created_at ASC, activity_id ASC + `, + }); + + const listThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_session_id AS "providerSessionId", + provider_thread_id AS "providerThreadId", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + ORDER BY thread_id ASC + `, + }); + + const listCheckpointRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionCheckpointDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + status, + files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_checkpoints + ORDER BY thread_id ASC, checkpoint_turn_count ASC + `, + }); + + const listProjectionStateRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionStateDbRowSchema, + execute: () => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + `, + }); + + const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => + sql.withTransaction( + Effect.gen(function* () { + const [projectRows, threadRows, messageRows, activityRows, sessionRows, checkpointRows, stateRows] = + yield* Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", + ), + ), + ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", + ), + ), + ), + listThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]); + + const messagesByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); + + let updatedAt: string | null = null; + + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } + + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } + + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } + + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + providerSessionId: row.providerSessionId, + providerThreadId: row.providerThreadId, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } + + const projects: Array = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModel: row.defaultModel, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: Array = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + model: row.model, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")), + ); + }), + ).pipe(Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query"))); + + return { + getSnapshot, + } satisfies ProjectionSnapshotQueryShape; +}); + +export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( + ProjectionSnapshotQuery, + makeProjectionSnapshotQuery, +); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b35cc22af7790b3f6ef62548f5838417a6c3f245 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -0,0 +1,308 @@ +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import { + ApprovalRequestId, + CommandId, + ProjectId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, +} from "@t3tools/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; +import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; + +const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); + +async function waitFor( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise => { + if (predicate()) { + return; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for expectation."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + + return poll(); +} + +describe("ProviderCommandReactor", () => { + let runtime: ManagedRuntime.ManagedRuntime< + OrchestrationEngineService | ProviderCommandReactor, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + async function createHarness() { + const now = new Date().toISOString(); + const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const startSession = vi.fn((_: unknown) => + Effect.succeed({ + sessionId: asSessionId("sess-1"), + provider: "codex" as const, + status: "ready" as const, + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + createdAt: now, + updatedAt: now, + }), + ); + const sendTurn = vi.fn((_: unknown) => + Effect.succeed({ + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + turnId: asProviderTurnId("provider-turn-1"), + }), + ); + const interruptTurn = vi.fn((_: unknown) => Effect.void); + const respondToRequest = vi.fn((_: unknown) => Effect.void); + const stopSession = vi.fn((_: unknown) => Effect.void); + + const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const service: ProviderServiceShape = { + startSession: startSession as ProviderServiceShape["startSession"], + sendTurn: sendTurn as ProviderServiceShape["sendTurn"], + interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], + respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], + stopSession: stopSession as ProviderServiceShape["stopSession"], + listSessions: () => Effect.succeed([]), + rollbackConversation: () => unsupported(), + stopAll: () => Effect.void, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + }; + + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(SqlitePersistenceMemory), + ); + const layer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(Layer.succeed(ProviderService, service)), + ); + runtime = ManagedRuntime.make(layer); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await Effect.runPromise(Effect.sleep("10 millis")); + + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: asCommandId("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModel: "gpt-5-codex", + createdAt: now, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: asCommandId("cmd-thread-create"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + + return { + engine, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + stopSession, + }; + } + + it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: asCommandId("cmd-turn-start-1"), + threadId: asThreadId("thread-1"), + message: { + messageId: "user-message-1", + role: "user", + text: "hello reactor", + attachments: [], + }, + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); + expect(thread?.session?.providerSessionId).toBe("sess-1"); + }); + + it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "running", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: "turn-1", + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: asCommandId("cmd-turn-interrupt"), + threadId: asThreadId("thread-1"), + turnId: "turn-1", + createdAt: now, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({ + sessionId: "sess-1", + }); + }); + + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-for-approval"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "running", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: asCommandId("cmd-approval-respond"), + threadId: asThreadId("thread-1"), + requestId: asApprovalRequestId("approval-request-1"), + decision: "accept", + createdAt: now, + }), + ); + + await waitFor(() => harness.respondToRequest.mock.calls.length === 1); + expect(harness.respondToRequest.mock.calls[0]?.[0]).toEqual({ + sessionId: "sess-1", + requestId: "approval-request-1", + decision: "accept", + }); + }); + + it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-for-stop"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "ready", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.stop", + commandId: asCommandId("cmd-session-stop"), + threadId: asThreadId("thread-1"), + createdAt: now, + }), + ); + + await waitFor(() => harness.stopSession.mock.calls.length === 1); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); + expect(thread?.session).not.toBeNull(); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerSessionId).toBeNull(); + expect(thread?.session?.providerThreadId).toBeNull(); + expect(thread?.session?.activeTurnId).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dbaa514e53e35d2be4554e1dbd42b83eb1f9e50 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -0,0 +1,347 @@ +import { + type ChatAttachment, + CommandId, + EventId, + type OrchestrationEvent, + type ProviderKind, + type OrchestrationSession, + type ProviderSessionId, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + ProviderCommandReactor, + type ProviderCommandReactorShape, +} from "../Services/ProviderCommandReactor.ts"; + +type ProviderIntentEvent = Extract< + OrchestrationEvent, + { + type: + | "thread.turn-start-requested" + | "thread.turn-interrupt-requested" + | "thread.approval-response-requested" + | "thread.session-stop-requested"; + } +>; + +function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function mapProviderSessionStatusToOrchestrationStatus( + status: "connecting" | "ready" | "running" | "error" | "closed", +): OrchestrationSession["status"] { + switch (status) { + case "connecting": + return "starting"; + case "running": + return "running"; + case "error": + return "error"; + case "closed": + return "stopped"; + case "ready": + default: + return "ready"; + } +} + +const turnStartKeyForEvent = (event: ProviderIntentEvent): string => + event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; + +const serverCommandId = (tag: string): CommandId => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const handledTurnStartKeys = new Set(); + + const appendProviderFailureActivity = (input: { + readonly threadId: ThreadId; + readonly kind: + | "provider.turn.start.failed" + | "provider.turn.interrupt.failed" + | "provider.approval.respond.failed" + | "provider.session.stop.failed"; + readonly summary: string; + readonly detail: string; + readonly turnId: TurnId | null; + readonly createdAt: string; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("provider-failure-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: "error", + kind: input.kind, + summary: input.summary, + payload: { + detail: input.detail, + }, + turnId: input.turnId, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const setThreadSession = (input: { + readonly threadId: ThreadId; + readonly session: OrchestrationSession; + readonly createdAt: string; + }) => + orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: serverCommandId("provider-session-set"), + threadId: input.threadId, + session: input.session, + createdAt: input.createdAt, + }); + + const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const readModel = yield* orchestrationEngine.getReadModel(); + return readModel.threads.find((entry) => entry.id === threadId); + }); + + const ensureSessionForThread = Effect.fnUntraced(function* ( + threadId: ThreadId, + createdAt: string, + ) { + const thread = yield* resolveThread(threadId); + if (!thread) { + return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); + } + + const existingSessionId = thread.session?.providerSessionId; + if (existingSessionId) { + return existingSessionId; + } + + const preferredProvider: ProviderKind | undefined = + thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" + ? thread.session.providerName + : undefined; + + const startedSession = yield* providerService.startSession({ + ...(preferredProvider ? { provider: preferredProvider } : {}), + ...(trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) + ? { cwd: trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) } + : {}), + ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}), + }); + + yield* setThreadSession({ + threadId, + session: { + threadId, + status: mapProviderSessionStatusToOrchestrationStatus(startedSession.status), + providerName: startedSession.provider, + providerSessionId: startedSession.sessionId, + providerThreadId: startedSession.threadId ?? null, + // Provider turn ids are not orchestration turn ids. + activeTurnId: null, + lastError: startedSession.lastError ?? null, + updatedAt: startedSession.updatedAt, + }, + createdAt, + }); + + return startedSession.sessionId; + }); + + const sendTurnForThread = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + readonly model?: string; + readonly effort?: string; + readonly createdAt: string; + }) { + const thread = yield* resolveThread(input.threadId); + if (!thread) { + return; + } + const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt); + const normalizedInput = trimToNonEmptyOrUndefined(input.messageText); + const normalizedAttachments = input.attachments ?? []; + + yield* providerService.sendTurn({ + sessionId, + ...(normalizedInput ? { input: normalizedInput } : {}), + ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), + ...(trimToNonEmptyOrUndefined(input.model) ? { model: input.model } : {}), + ...(trimToNonEmptyOrUndefined(input.effort) ? { effort: input.effort } : {}), + }); + }); + + const processTurnStartRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const key = turnStartKeyForEvent(event); + if (handledTurnStartKeys.has(key)) { + return; + } + handledTurnStartKeys.add(key); + + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + + const message = thread.messages.find((entry) => entry.id === event.payload.messageId); + if (!message || message.role !== "user") { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail: `User message '${event.payload.messageId}' was not found for turn start request.`, + turnId: null, + createdAt: event.payload.createdAt, + }); + return; + } + + yield* sendTurnForThread({ + threadId: event.payload.threadId, + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), + ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), + createdAt: event.payload.createdAt, + }); + }); + + const processTurnInterruptRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const sessionId = thread.session?.providerSessionId; + if (!sessionId) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.interrupt.failed", + summary: "Provider turn interrupt failed", + detail: "No active provider session is bound to this thread.", + turnId: event.payload.turnId ?? null, + createdAt: event.payload.createdAt, + }); + } + + // Orchestration turn ids are not provider turn ids, so interrupt by session. + yield* providerService.interruptTurn({ sessionId }); + }); + + const processApprovalResponseRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const sessionId = thread.session?.providerSessionId; + if (!sessionId) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + }); + } + + yield* providerService.respondToRequest({ + sessionId, + requestId: event.payload.requestId, + decision: event.payload.decision, + }); + }); + + const processSessionStopRequested = Effect.fnUntraced(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + + const now = event.payload.createdAt; + const sessionId = thread.session?.providerSessionId; + + if (sessionId) { + yield* providerService.stopSession({ sessionId }); + } + + yield* setThreadSession({ + threadId: thread.id, + session: { + threadId: thread.id, + status: "stopped", + providerName: thread.session?.providerName ?? null, + providerSessionId: null, + providerThreadId: null, + activeTurnId: null, + lastError: thread.session?.lastError ?? null, + updatedAt: now, + }, + createdAt: now, + }); + }); + + const processDomainEvent = (event: ProviderIntentEvent) => + Effect.gen(function* () { + switch (event.type) { + case "thread.turn-start-requested": + yield* processTurnStartRequested(event); + return; + case "thread.turn-interrupt-requested": + yield* processTurnInterruptRequested(event); + return; + case "thread.approval-response-requested": + yield* processApprovalResponseRequested(event); + return; + case "thread.session-stop-requested": + yield* processSessionStopRequested(event); + return; + } + }); + + const start: ProviderCommandReactorShape["start"] = Effect.gen(function* () { + const queue = yield* Queue.unbounded(); + yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); + + yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent)))); + + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if ( + event.type !== "thread.turn-start-requested" && + event.type !== "thread.turn-interrupt-requested" && + event.type !== "thread.approval-response-requested" && + event.type !== "thread.session-stop-requested" + ) { + return Effect.void; + } + + return Queue.offer(queue, event).pipe(Effect.asVoid); + }), + ); + }); + + return { + start, + } satisfies ProviderCommandReactorShape; +}); + +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1bbd471a13f8e47706069ee1f96b12489ca425d2..dcf2c18e256e0e32a4fa46e69b30264199eaaa5c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,14 +1,26 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import { + CommandId, + EventId, + ProjectId, + ProviderItemId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, +} from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService, @@ -16,17 +28,16 @@ import { } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); + function createProviderServiceHarness() { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const getCheckpointDiff = vi.fn( - (input: { sessionId: string; fromTurnCount: number; toTurnCount: number }) => - Effect.succeed({ - threadId: "thread-1", - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff: "", - }), - ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -36,9 +47,7 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), - listCheckpoints: () => unsupported(), - getCheckpointDiff, - revertToCheckpoint: () => unsupported(), + rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -50,7 +59,6 @@ function createProviderServiceHarness() { return { service, emit, - getCheckpointDiff, }; } @@ -59,6 +67,7 @@ async function waitForThread( predicate: (thread: { session: { status: string; activeTurnId: string | null; lastError: string | null } | null; messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; + activities: ReadonlyArray<{ kind: string }>; }) => boolean, timeoutMs = 2000, ) { @@ -66,9 +75,10 @@ async function waitForThread( const poll = async (): Promise<{ session: { status: string; activeTurnId: string | null; lastError: string | null } | null; messages: ReadonlyArray<{ id: string; text: string; streaming: boolean }>; + activities: ReadonlyArray<{ kind: string }>; }> => { const readModel = await Effect.runPromise(engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === "thread-1"); + const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); if (thread && predicate(thread)) { return thread; } @@ -102,7 +112,9 @@ describe("ProviderRuntimeIngestion", () => { async function createHarness() { const provider = createProviderServiceHarness(); const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( @@ -117,12 +129,23 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(Effect.sleep("10 millis")); const createdAt = new Date().toISOString(); + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: asCommandId("cmd-provider-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModel: "gpt-5-codex", + createdAt, + }), + ); await Effect.runPromise( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-create", - threadId: "thread-1", - projectId: "project-1", + commandId: asCommandId("cmd-thread-create"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", branch: null, @@ -132,16 +155,16 @@ describe("ProviderRuntimeIngestion", () => { ); await Effect.runPromise( engine.dispatch({ - type: "thread.session", - commandId: "cmd-session-seed", - threadId: "thread-1", + type: "thread.session.set", + commandId: asCommandId("cmd-session-seed"), + threadId: asThreadId("thread-1"), session: { - sessionId: "sess-1", - provider: "codex", + threadId: asThreadId("thread-1"), status: "ready", - threadId: "thread-1", + providerName: "codex", + providerSessionId: asSessionId("sess-1"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), activeTurnId: null, - createdAt, updatedAt: createdAt, lastError: null, }, @@ -161,11 +184,11 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.started", - eventId: "evt-turn-started", + eventId: asEventId("evt-turn-started"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: now, - turnId: "turn-1", + turnId: asProviderTurnId("turn-1"), }); await waitForThread( @@ -175,11 +198,11 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "turn.completed", - eventId: "evt-turn-completed", + eventId: asEventId("evt-turn-completed"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), - turnId: "turn-1", + turnId: asProviderTurnId("turn-1"), status: "failed", errorMessage: "turn failed", }); @@ -187,11 +210,11 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread( harness.engine, (entry) => - entry.session?.status === "ready" && + entry.session?.status === "error" && entry.session?.activeTurnId === null && entry.session?.lastError === "turn failed", ); - expect(thread.session?.status).toBe("ready"); + expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("turn failed"); }); @@ -201,32 +224,32 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "message.delta", - eventId: "evt-message-delta-1", + eventId: asEventId("evt-message-delta-1"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: now, - turnId: "turn-2", - itemId: "item-1", + turnId: asProviderTurnId("turn-2"), + itemId: asItemId("item-1"), delta: "hello", }); harness.emit({ type: "message.delta", - eventId: "evt-message-delta-2", + eventId: asEventId("evt-message-delta-2"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: now, - turnId: "turn-2", - itemId: "item-1", + turnId: asProviderTurnId("turn-2"), + itemId: asItemId("item-1"), delta: " world", }); harness.emit({ type: "message.completed", - eventId: "evt-message-completed", + eventId: asEventId("evt-message-completed"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: now, - turnId: "turn-2", - itemId: "item-1", + turnId: asProviderTurnId("turn-2"), + itemId: asItemId("item-1"), }); const thread = await waitForThread(harness.engine, (entry) => @@ -243,11 +266,11 @@ describe("ProviderRuntimeIngestion", () => { harness.emit({ type: "runtime.error", - eventId: "evt-runtime-error", + eventId: asEventId("evt-runtime-error"), provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: now, - turnId: "turn-3", + turnId: asProviderTurnId("turn-3"), message: "runtime exploded", }); @@ -261,4 +284,49 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime exploded"); }); + + it("maps session/thread lifecycle and tool.started into session/activity projections", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "session.started", + eventId: asEventId("evt-session-started"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + message: "session started", + }); + harness.emit({ + type: "thread.started", + eventId: asEventId("evt-thread-started"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + threadId: ProviderThreadId.makeUnsafe("provider-thread-2"), + }); + harness.emit({ + type: "tool.started", + eventId: asEventId("evt-tool-started"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + turnId: asProviderTurnId("turn-9"), + toolKind: "read", + title: "Read file", + detail: "/tmp/file.ts", + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "ready" && + entry.session?.activeTurnId === null && + entry.activities.some((activity) => activity.kind === "tool.started"), + ); + + expect(thread.session?.status).toBe("ready"); + expect(thread.activities.some((activity) => activity.kind === "tool.started")).toBe(true); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e33fba3f31b1413bbbc42bf3a4d00d58d1f77658..5b622b442e5cdd4989654bee7aeea80142875099 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,11 +1,14 @@ -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + ProviderThreadId, + TurnId, + type OrchestrationThreadActivity, + type ProviderRuntimeEvent, + type ProviderSessionId, +} from "@t3tools/contracts"; import { Effect, Layer, Queue, Stream } from "effect"; -import { - parseTurnDiffFilesFromUnifiedDiff, - type TurnDiffFileSummary, -} from "../../checkpointing/Diffs.ts"; -import { createLogger } from "../../logger.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -13,87 +16,108 @@ import { type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; -const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`; +const providerTurnKey = (sessionId: ProviderSessionId, turnId: TurnId) => `${sessionId}:${turnId}`; +const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => + CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); -type ActivityTone = "thinking" | "tool" | "info" | "error"; - -interface ThreadActivityInput { - readonly id: string; - readonly createdAt: string; - readonly label: string; - readonly detail?: string; - readonly tone: ActivityTone; - readonly turnId?: string; - readonly requestId?: string; - readonly requestKind?: "command" | "file-change"; +function toTurnId(value: string | undefined): TurnId | undefined { + const normalized = value?.trim(); + return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined; } function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; } -function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray { +function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray { switch (event.type) { - case "approval.requested": + case "approval.requested": { return [ { id: event.eventId, createdAt: event.createdAt, - label: + tone: "approval", + kind: "approval.requested", + summary: event.requestKind === "command" ? "Command approval requested" : "File-change approval requested", - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), - tone: "tool", - ...(event.turnId ? { turnId: event.turnId } : {}), - requestId: event.requestId, - requestKind: event.requestKind, + payload: { + requestId: event.requestId, + requestKind: event.requestKind, + ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, }, ]; - case "approval.resolved": + } + + case "approval.resolved": { return [ { id: event.eventId, createdAt: event.createdAt, - label: "Approval resolved", - tone: "info", - ...(event.turnId ? { turnId: event.turnId } : {}), - requestId: event.requestId, - ...(event.requestKind ? { requestKind: event.requestKind } : {}), + tone: "approval", + kind: "approval.resolved", + summary: "Approval resolved", + payload: { + requestId: event.requestId, + ...(event.requestKind ? { requestKind: event.requestKind } : {}), + ...(event.decision ? { decision: event.decision } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, }, ]; - case "runtime.error": + } + + case "runtime.error": { return [ { id: event.eventId, createdAt: event.createdAt, - label: "Runtime error", - detail: truncateDetail(event.message), tone: "error", - ...(event.turnId ? { turnId: event.turnId } : {}), + kind: "runtime.error", + summary: "Runtime error", + payload: { + message: truncateDetail(event.message), + }, + turnId: toTurnId(event.turnId) ?? null, }, ]; - case "checkpoint.captured": + } + case "tool.completed": { return [ { id: event.eventId, createdAt: event.createdAt, - label: "Checkpoint captured", - tone: "info", - ...(event.turnId ? { turnId: event.turnId } : {}), + tone: "tool", + kind: "tool.completed", + summary: `${event.title} complete`, + payload: { + toolKind: event.toolKind, + ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, }, ]; - case "tool.completed": + } + case "tool.started": { return [ { id: event.eventId, createdAt: event.createdAt, - label: `${event.title} complete`, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), tone: "tool", - ...(event.turnId ? { turnId: event.turnId } : {}), + kind: "tool.started", + summary: `${event.title} started`, + payload: { + toolKind: event.toolKind, + ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, }, ]; + } + default: return []; } @@ -102,12 +126,15 @@ function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray>(); - const latestMessageIdByTurnKey = new Map(); + const turnMessageIdsByTurnKey = new Map>(); + const latestMessageIdByTurnKey = new Map(); - const rememberAssistantMessageId = (sessionId: string, turnId: string, messageId: string) => { + const rememberAssistantMessageId = ( + sessionId: ProviderSessionId, + turnId: TurnId, + messageId: MessageId, + ) => { const key = providerTurnKey(sessionId, turnId); const existingIds = turnMessageIdsByTurnKey.get(key); if (existingIds) { @@ -118,19 +145,15 @@ const make = Effect.gen(function* () { latestMessageIdByTurnKey.set(key, messageId); }; - const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { - return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set(); + const getAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { + return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set(); }; - const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { + const clearAssistantMessageIdsForTurn = (sessionId: ProviderSessionId, turnId: TurnId) => { turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId)); }; - const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => { - return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId)); - }; - - const clearTurnStateForSession = (sessionId: string) => { + const clearTurnStateForSession = (sessionId: ProviderSessionId) => { const prefix = `${sessionId}:`; for (const key of turnMessageIdsByTurnKey.keys()) { if (key.startsWith(prefix)) { @@ -148,144 +171,138 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find( - (entry) => entry.session?.sessionId === event.sessionId, + (entry) => entry.session?.providerSessionId === event.sessionId, ); if (!thread) return; const now = event.createdAt; - if (event.type === "turn.started" || event.type === "turn.completed") { + + if ( + event.type === "session.started" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ) { + const activeTurnId = event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; + const providerThreadIdFromEvent = + event.type === "thread.started" + ? ProviderThreadId.makeUnsafe(event.threadId) + : event.threadId !== undefined + ? ProviderThreadId.makeUnsafe(event.threadId) + : null; + const providerThreadId = providerThreadIdFromEvent ?? (thread.session?.providerThreadId ?? null); + const status = + event.type === "turn.started" + ? "running" + : event.type === "session.exited" + ? "stopped" + : event.type === "turn.completed" && event.status === "failed" + ? "error" + : "ready"; + const lastError = + event.type === "turn.completed" && event.status === "failed" + ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") + : status === "ready" + ? null + : (thread.session?.lastError ?? null); + yield* orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-set"), threadId: thread.id, session: { - sessionId: event.sessionId, - provider: event.provider, - status: event.type === "turn.started" ? "running" : "ready", threadId: thread.id, - activeTurnId: event.type === "turn.started" ? event.turnId : null, - createdAt: thread.session?.createdAt ?? now, + status, + providerName: event.provider, + providerSessionId: event.sessionId, + providerThreadId, + activeTurnId, + lastError, updatedAt: now, - lastError: - event.type === "turn.completed" && event.status === "failed" - ? (event.errorMessage ?? null) - : null, }, createdAt: now, }); } if (event.type === "message.delta" && event.delta.length > 0) { - const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`; - if (event.turnId) { - rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); + const assistantMessageId = MessageId.makeUnsafe( + `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, + ); + const turnId = toTurnId(event.turnId); + if (turnId) { + rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); } + yield* orchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), + type: "thread.message.assistant.delta", + commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - role: "assistant", - text: event.delta, - streaming: true, + delta: event.delta, + ...(turnId ? { turnId } : {}), createdAt: now, }); } if (event.type === "message.completed") { - const assistantMessageId = `assistant:${event.itemId}`; - if (event.turnId) { - rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); + const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); + const turnId = toTurnId(event.turnId); + if (turnId) { + rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); } + yield* orchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), + type: "thread.message.assistant.complete", + commandId: providerCommandId(event, "assistant-complete"), threadId: thread.id, messageId: assistantMessageId, - role: "assistant", - text: "", - streaming: false, + ...(turnId ? { turnId } : {}), createdAt: now, }); } - if (event.type === "turn.completed" && event.turnId) { - const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); - yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => - orchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: "", - streaming: false, - createdAt: now, - }), - ).pipe(Effect.asVoid); - clearAssistantMessageIdsForTurn(event.sessionId, event.turnId); + if (event.type === "turn.completed") { + const turnId = toTurnId(event.turnId); + if (turnId) { + const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, turnId); + yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => + orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: providerCommandId(event, "assistant-complete-finalize"), + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + }), + ).pipe(Effect.asVoid); + clearAssistantMessageIdsForTurn(event.sessionId, turnId); + } } if (event.type === "session.exited") { clearTurnStateForSession(event.sessionId); } - if (event.type === "checkpoint.captured" && event.turnId) { - const files: ReadonlyArray = yield* providerService - .getCheckpointDiff({ - sessionId: event.sessionId, - fromTurnCount: Math.max(0, event.turnCount - 1), - toTurnCount: event.turnCount, - }) - .pipe( - Effect.map((result) => parseTurnDiffFilesFromUnifiedDiff(result.diff)), - Effect.catch((error) => - Effect.sync(() => { - logger.warn("failed to derive checkpoint file summary", { - sessionId: event.sessionId, - turnId: event.turnId, - turnCount: event.turnCount, - error: - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "unknown error", - }); - }).pipe(Effect.as([] as ReadonlyArray)), - ), - ); - const assistantMessageId = - getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ?? - `assistant:${event.turnId}`; - yield* orchestrationEngine.dispatch({ - type: "thread.turnDiff.complete", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnId: event.turnId, - completedAt: now, - ...(event.status ? { status: event.status } : {}), - files, - assistantMessageId, - checkpointTurnCount: event.turnCount, - createdAt: now, - }); - } - if (event.type === "runtime.error") { + const providerThreadId = + event.threadId !== undefined + ? ProviderThreadId.makeUnsafe(event.threadId) + : (thread.session?.providerThreadId ?? null); + yield* orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), + type: "thread.session.set", + commandId: providerCommandId(event, "runtime-error-session-set"), threadId: thread.id, session: { - sessionId: event.sessionId, - provider: event.provider, + threadId: thread.id, status: "error", - threadId: thread.id, - activeTurnId: event.turnId ?? null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, + providerName: event.provider, + providerSessionId: event.sessionId, + providerThreadId, + activeTurnId: toTurnId(event.turnId) ?? null, lastError: event.message, + updatedAt: now, }, createdAt: now, }); @@ -295,7 +312,7 @@ const make = Effect.gen(function* () { yield* Effect.forEach(activities, (activity) => orchestrationEngine.dispatch({ type: "thread.activity.append", - commandId: crypto.randomUUID(), + commandId: providerCommandId(event, "thread-activity-append"), threadId: thread.id, activity, createdAt: activity.createdAt, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b67722e026cdd58ce36fefaa683f619a7f8645f --- /dev/null +++ b/apps/server/src/orchestration/Schemas.ts @@ -0,0 +1,41 @@ +import { + ProjectCreatedPayload as ContractsProjectCreatedPayloadSchema, + ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, + ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, + ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, + ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, + ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, + ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, + ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, + ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, + ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, + ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema, + ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema, + ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema, + ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, + ThreadCheckpointRevertRequestedPayload as ContractsThreadCheckpointRevertRequestedPayloadSchema, + ThreadSessionStopRequestedPayload as ContractsThreadSessionStopRequestedPayloadSchema, +} from "@t3tools/contracts"; + +// Server-internal alias surface, backed by contract schemas as the source of truth. +export const ProjectCreatedPayload = ContractsProjectCreatedPayloadSchema; +export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchema; +export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; + +export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; +export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; +export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; + +export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; +export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; +export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; +export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; +export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; + +export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; +export const ThreadTurnInterruptRequestedPayload = ContractsThreadTurnInterruptRequestedPayloadSchema; +export const ThreadApprovalResponseRequestedPayload = + ContractsThreadApprovalResponseRequestedPayloadSchema; +export const ThreadCheckpointRevertRequestedPayload = + ContractsThreadCheckpointRevertRequestedPayloadSchema; +export const ThreadSessionStopRequestedPayload = ContractsThreadSessionStopRequestedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/CheckpointReactor.ts b/apps/server/src/orchestration/Services/CheckpointReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..330b800b13aaf1f389bfab6d9d2097b3c59dc204 --- /dev/null +++ b/apps/server/src/orchestration/Services/CheckpointReactor.ts @@ -0,0 +1,17 @@ +import { ServiceMap } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface CheckpointReactorShape { + /** + * Start the checkpoint reactor. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: Effect.Effect; +} + +export class CheckpointReactor extends ServiceMap.Service< + CheckpointReactor, + CheckpointReactorShape +>()("orchestration/CheckpointReactor") {} diff --git a/apps/server/src/orchestration/Services/OrchestrationReactor.ts b/apps/server/src/orchestration/Services/OrchestrationReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..185750fb25beb53d608375fdeb39775fc71b8253 --- /dev/null +++ b/apps/server/src/orchestration/Services/OrchestrationReactor.ts @@ -0,0 +1,17 @@ +import { ServiceMap } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface OrchestrationReactorShape { + /** + * Start orchestration-side reactors for provider/runtime/checkpoint flows. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: Effect.Effect; +} + +export class OrchestrationReactor extends ServiceMap.Service< + OrchestrationReactor, + OrchestrationReactorShape +>()("orchestration/OrchestrationReactor") {} diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f8d649c1354fac07f64d244b7d39df3d6d1bfe3 --- /dev/null +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -0,0 +1,22 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { OrchestrationEventStoreError, ProjectionRepositoryError } from "../../persistence/Errors.ts"; + +export type OrchestrationProjectionPipelineError = + | OrchestrationEventStoreError + | ProjectionRepositoryError; + +export interface OrchestrationProjectionPipelineShape { + readonly bootstrap: Effect.Effect; + + readonly projectEvent: ( + event: OrchestrationEvent, + ) => Effect.Effect; +} + +export class OrchestrationProjectionPipeline extends ServiceMap.Service< + OrchestrationProjectionPipeline, + OrchestrationProjectionPipelineShape +>()("orchestration/ProjectionPipeline") {} diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..66e27a3fe426a3d2a78cb959816664818d2042a8 --- /dev/null +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -0,0 +1,14 @@ +import type { OrchestrationReadModel } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; + +export interface ProjectionSnapshotQueryShape { + readonly getSnapshot: () => Effect.Effect; +} + +export class ProjectionSnapshotQuery extends ServiceMap.Service< + ProjectionSnapshotQuery, + ProjectionSnapshotQueryShape +>()("orchestration/ProjectionSnapshotQuery") {} diff --git a/apps/server/src/orchestration/Services/ProviderCommandReactor.ts b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts new file mode 100644 index 0000000000000000000000000000000000000000..1069137abc654dca5df1334ea2aa7dbbe84da9c2 --- /dev/null +++ b/apps/server/src/orchestration/Services/ProviderCommandReactor.ts @@ -0,0 +1,17 @@ +import { ServiceMap } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface ProviderCommandReactorShape { + /** + * Start reacting to provider-intent orchestration domain events. + * + * The returned effect must be run in a scope so all worker fibers can be + * finalized on shutdown. + */ + readonly start: Effect.Effect; +} + +export class ProviderCommandReactor extends ServiceMap.Service< + ProviderCommandReactor, + ProviderCommandReactorShape +>()("orchestration/ProviderCommandReactor") {} diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 7ed1c7f84887cc335600525e0cc0ffcd407334da..87fbe951813ee25e6fa59cec0b46aa85e11a0232 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -13,9 +13,30 @@ import { const now = new Date().toISOString(); const readModel: OrchestrationReadModel = { - sequence: 2, + snapshotSequence: 2, updatedAt: now, - gitByProjectId: {}, + projects: [ + { + id: "project-a", + title: "Project A", + workspaceRoot: "/tmp/project-a", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + { + id: "project-b", + title: "Project B", + workspaceRoot: "/tmp/project-b", + defaultModel: "gpt-5-codex", + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], threads: [ { id: "thread-1", @@ -27,14 +48,10 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, messages: [], session: null, - turnDiffSummaries: [], activities: [], - error: null, + checkpoints: [], }, { id: "thread-2", @@ -46,26 +63,24 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, messages: [], session: null, - turnDiffSummaries: [], activities: [], - error: null, + checkpoints: [], }, ], }; const messageSendCommand: OrchestrationCommand = { - type: "message.send", + type: "thread.turn.start", commandId: "cmd-1", threadId: "thread-1", - messageId: "msg-1", - role: "user", - text: "hello", - streaming: false, + message: { + messageId: "msg-1", + role: "user", + text: "hello", + attachments: [], + }, createdAt: now, }; diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index e5e0f5875ebcc1a418ea492264be6fab9b207b5c..ca615b8cdb6d8174f91677fb4cb5fb452b867654 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -1,4 +1,11 @@ -import type { OrchestrationCommand, OrchestrationReadModel, OrchestrationThread } from "@t3tools/contracts"; +import type { + OrchestrationCommand, + OrchestrationProject, + OrchestrationReadModel, + OrchestrationThread, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -12,23 +19,67 @@ function invariantError(commandType: string, detail: string): OrchestrationComma export function findThreadById( readModel: OrchestrationReadModel, - threadId: string, + threadId: ThreadId, ): OrchestrationThread | undefined { return readModel.threads.find((thread) => thread.id === threadId); } +export function findProjectById( + readModel: OrchestrationReadModel, + projectId: ProjectId, +): OrchestrationProject | undefined { + return readModel.projects.find((project) => project.id === projectId); +} + export function listThreadsByProjectId( readModel: OrchestrationReadModel, - projectId: string, + projectId: ProjectId, ): ReadonlyArray { return readModel.threads.filter((thread) => thread.projectId === projectId); } +export function requireProject( + input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; + }, +): Effect.Effect { + const project = findProjectById(input.readModel, input.projectId); + if (project) { + return Effect.succeed(project); + } + return Effect.fail( + invariantError( + input.command.type, + `Project '${input.projectId}' does not exist for command '${input.command.type}'.`, + ), + ); +} + +export function requireProjectAbsent( + input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; + }, +): Effect.Effect { + if (!findProjectById(input.readModel, input.projectId)) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Project '${input.projectId}' already exists and cannot be created twice.`, + ), + ); +} + export function requireThread( input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; - readonly threadId: string; + readonly threadId: ThreadId; }, ): Effect.Effect { const thread = findThreadById(input.readModel, input.threadId); @@ -47,7 +98,7 @@ export function requireThreadAbsent( input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; - readonly threadId: string; + readonly threadId: ThreadId; }, ): Effect.Effect { if (!findThreadById(input.readModel, input.threadId)) { diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..65207dab7aaa5fa24d33c300b06d4ca6a26be157 --- /dev/null +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +describe("decider project scripts", () => { + it("emits empty scripts on project.create", async () => { + const now = new Date().toISOString(); + const readModel = createEmptyReadModel(now); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: "cmd-project-create-scripts", + projectId: "project-scripts", + title: "Scripts", + workspaceRoot: "/tmp/scripts", + createdAt: now, + }, + readModel, + }), + ); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.created"); + expect((event.payload as { scripts: unknown[] }).scripts).toEqual([]); + }); + + it("propagates scripts in project.meta.update payload", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const readModel = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: "evt-project-create-scripts", + aggregateType: "project", + aggregateId: "project-scripts", + type: "project.created", + occurredAt: now, + commandId: "cmd-project-create-scripts", + causationEventId: null, + correlationId: "cmd-project-create-scripts", + metadata: {}, + payload: { + projectId: "project-scripts", + title: "Scripts", + workspaceRoot: "/tmp/scripts", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + + const scripts = [ + { + id: "lint", + name: "Lint", + command: "bun run lint", + icon: "lint", + runOnWorktreeCreate: false, + }, + ] as const; + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: "cmd-project-update-scripts", + projectId: "project-scripts", + scripts: Array.from(scripts), + }, + readModel, + }), + ); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { scripts?: unknown[] }).scripts).toEqual(scripts); + }); + + it("emits user message and turn-start-requested events for thread.turn.start", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: "evt-project-create", + aggregateType: "project", + aggregateId: "project-1", + type: "project.created", + occurredAt: now, + commandId: "cmd-project-create", + causationEventId: null, + correlationId: "cmd-project-create", + metadata: {}, + payload: { + projectId: "project-1", + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: "evt-thread-create", + aggregateType: "thread", + aggregateId: "thread-1", + type: "thread.created", + occurredAt: now, + commandId: "cmd-thread-create", + causationEventId: null, + correlationId: "cmd-thread-create", + metadata: {}, + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: "cmd-turn-start", + threadId: "thread-1", + message: { + messageId: "message-user-1", + role: "user", + text: "hello", + attachments: [], + }, + model: "gpt-5", + effort: "high", + createdAt: now, + }, + readModel, + }), + ); + + expect(Array.isArray(result)).toBe(true); + const events = Array.isArray(result) ? result : [result]; + expect(events).toHaveLength(2); + expect(events[0]?.type).toBe("thread.message-sent"); + expect(events[1]?.type).toBe("thread.turn-start-requested"); + expect(events[1]?.causationEventId).toBe(events[0]?.eventId ?? null); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b1e4c5d79625ef8d19be4916f0d0d96c82ad33e6..d29258c153d95d3b1f86755e43a63189d2464cef 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -7,36 +7,152 @@ import { Effect } from "effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { - requireNonNegativeInteger, + requireProject, + requireProjectAbsent, requireThread, requireThreadAbsent, } from "./commandInvariants.ts"; +const nowIso = () => new Date().toISOString(); + +const defaultMetadata: Omit = { + eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], + aggregateKind: "thread", + aggregateId: "" as OrchestrationEvent["aggregateId"], + occurredAt: nowIso(), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, +}; + +function withEventBase( + input: Pick & { + readonly aggregateKind: OrchestrationEvent["aggregateKind"]; + readonly aggregateId: OrchestrationEvent["aggregateId"]; + readonly occurredAt: string; + readonly metadata?: OrchestrationEvent["metadata"]; + }, +): Omit { + return { + ...defaultMetadata, + eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], + aggregateKind: input.aggregateKind, + aggregateId: input.aggregateId, + occurredAt: input.occurredAt, + commandId: input.commandId, + correlationId: input.commandId, + metadata: input.metadata ?? {}, + }; +} + export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return, OrchestrationCommandInvariantError> { - const eventId = crypto.randomUUID(); - +}): Effect.fn.Return< + Omit | ReadonlyArray>, + OrchestrationCommandInvariantError +> { switch (command.type) { - case "thread.create": + case "project.create": { + yield* requireProjectAbsent({ + readModel, + command, + projectId: command.projectId, + }); + + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "project.created", + payload: { + projectId: command.projectId, + title: command.title, + workspaceRoot: command.workspaceRoot, + defaultModel: command.defaultModel ?? null, + scripts: [], + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + + case "project.meta.update": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.meta-updated", + payload: { + projectId: command.projectId, + ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), + ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), + ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), + updatedAt: occurredAt, + }, + }; + } + + case "project.delete": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "project.deleted", + payload: { + projectId: command.projectId, + deletedAt: occurredAt, + }, + }; + } + + case "thread.create": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); yield* requireThreadAbsent({ readModel, command, threadId: command.threadId, }); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), type: "thread.created", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { - id: command.threadId, + threadId: command.threadId, projectId: command.projectId, title: command.title, model: command.model, @@ -46,180 +162,356 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: command.createdAt, }, }; - case "thread.delete": + } + + case "thread.delete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); + const occurredAt = nowIso(); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), type: "thread.deleted", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { - id: command.threadId, - deletedAt: command.createdAt, + threadId: command.threadId, + deletedAt: occurredAt, }, }; - case "thread.meta.update": + } + + case "thread.meta.update": { yield* requireThread({ readModel, command, threadId: command.threadId, }); + const occurredAt = nowIso(); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), type: "thread.meta-updated", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.model !== undefined ? { model: command.model } : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + updatedAt: occurredAt, + }, + }; + } + + case "thread.turn.start": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const userMessageEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + role: "user", + text: command.message.text, + attachments: command.message.attachments, + turnId: null, + streaming: false, + createdAt: command.createdAt, updatedAt: command.createdAt, }, }; - case "message.send": + const turnStartRequestedEvent: Omit = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + causationEventId: userMessageEvent.eventId, + type: "thread.turn-start-requested", + payload: { + threadId: command.threadId, + messageId: command.message.messageId, + ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.effort !== undefined ? { effort: command.effort } : {}), + createdAt: command.createdAt, + }, + }; + return [userMessageEvent, turnStartRequestedEvent]; + } + + case "thread.turn.interrupt": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.turn-interrupt-requested", + payload: { + threadId: command.threadId, + ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), + createdAt: command.createdAt, + }, + }; + } + + case "thread.approval.respond": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { + requestId: command.requestId, + }, + }), + type: "thread.approval-response-requested", + payload: { + threadId: command.threadId, + requestId: command.requestId, + decision: command.decision, + createdAt: command.createdAt, + }, + }; + } + + case "thread.checkpoint.revert": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.checkpoint-revert-requested", + payload: { + threadId: command.threadId, + turnCount: command.turnCount, + createdAt: command.createdAt, + }, + }; + } + + case "thread.session.stop": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { - eventId, - type: "message.sent", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.session-stop-requested", payload: { - id: command.messageId, - role: command.role, - text: command.text, threadId: command.threadId, createdAt: command.createdAt, - streaming: command.streaming === true, }, }; - case "thread.session": + } + + case "thread.session.set": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { + ...(command.session.providerSessionId !== null + ? { providerSessionId: command.session.providerSessionId } + : {}), + ...(command.session.providerThreadId !== null + ? { providerThreadId: command.session.providerThreadId } + : {}), + }, + }), type: "thread.session-set", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { threadId: command.threadId, session: command.session, }, }; - case "git.readModel.upsert": + } + + case "thread.message.assistant.delta": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: "assistant", + text: command.delta, + turnId: command.turnId ?? null, + streaming: true, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + + case "thread.message.assistant.complete": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); return { - eventId, - type: "git.read-model-upsert", - aggregateType: "project", - aggregateId: command.projectId, - occurredAt: command.createdAt, - commandId: command.commandId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", payload: { - projectId: command.projectId, - branch: command.branch, - hasWorkingTreeChanges: command.hasWorkingTreeChanges, - aheadCount: command.aheadCount, - behindCount: command.behindCount, + threadId: command.threadId, + messageId: command.messageId, + role: "assistant", + text: "", + turnId: command.turnId ?? null, + streaming: false, + createdAt: command.createdAt, updatedAt: command.createdAt, }, }; - case "thread.turnDiff.complete": + } + + case "thread.turn.diff.complete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), type: "thread.turn-diff-completed", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { threadId: command.threadId, turnId: command.turnId, - completedAt: command.completedAt, - ...(command.status !== undefined ? { status: command.status } : {}), + checkpointTurnCount: command.checkpointTurnCount, + checkpointRef: command.checkpointRef, + status: command.status, files: command.files, - ...(command.assistantMessageId !== undefined - ? { assistantMessageId: command.assistantMessageId } - : {}), - ...(command.checkpointTurnCount !== undefined - ? { checkpointTurnCount: command.checkpointTurnCount } - : {}), + assistantMessageId: command.assistantMessageId ?? null, + completedAt: command.completedAt, }, }; - case "thread.activity.append": + } + + case "thread.revert.complete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { - eventId, + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.reverted", + payload: { + threadId: command.threadId, + turnCount: command.turnCount, + }, + }; + } + + case "thread.activity.append": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const requestId = + typeof command.activity.payload === "object" && + command.activity.payload !== null && + "requestId" in command.activity.payload && + typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" + ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"]) + : undefined; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + ...(requestId !== undefined ? { metadata: { requestId } } : {}), + }), type: "thread.activity-appended", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, payload: { threadId: command.threadId, activity: command.activity, }, }; - case "thread.revert": - yield* requireThread({ - readModel, - command, - threadId: command.threadId, - }); - yield* requireNonNegativeInteger({ - commandType: command.type, - field: "turnCount", - value: command.turnCount, - }); - yield* requireNonNegativeInteger({ - commandType: command.type, - field: "messageCount", - value: command.messageCount, - }); - return { - eventId, - type: "thread.reverted", - aggregateType: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - payload: { - threadId: command.threadId, - turnCount: command.turnCount, - messageCount: command.messageCount, - }, - }; + } + default: { command satisfies never; - const fallback = command as any; + const fallback = command as never as { type: string }; return yield* new OrchestrationCommandInvariantError({ commandType: fallback.type, detail: `Unknown command type: ${fallback.type}`, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 4be935ac5e23f9bb83758accd202756976350886..bf54b31a7e92e2208ca2a75616315af0d5f98a68 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -4,32 +4,60 @@ import { describe, expect, it } from "vitest"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +function makeEvent(input: { + sequence: number; + type: OrchestrationEvent["type"]; + occurredAt: string; + aggregateType: OrchestrationEvent["aggregateType"]; + aggregateId: string; + commandId: string | null; + payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: `event-${input.sequence}`, + type: input.type, + aggregateType: input.aggregateType, + aggregateId: input.aggregateId as OrchestrationEvent["aggregateId"], + occurredAt: input.occurredAt, + commandId: input.commandId as OrchestrationEvent["commandId"], + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload, + }; +} + describe("orchestration projector", () => { - it("applies thread.created events using runtime schema decoding", async () => { + it("applies thread.created events", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); - const event: OrchestrationEvent = { - sequence: 1, - eventId: "event-thread-created", - type: "thread.created", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: now, - commandId: "cmd-1", - payload: { - id: "thread-1", - projectId: "project-1", - title: "demo", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }; - const next = await Effect.runPromise(projectEvent(model, event)); - expect(next.sequence).toBe(1); + const next = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + expect(next.snapshotSequence).toBe(1); expect(next.threads).toEqual([ { id: "thread-1", @@ -38,17 +66,14 @@ describe("orchestration projector", () => { model: "gpt-5-codex", branch: null, worktreePath: null, - createdAt: now, - updatedAt: now, latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, + createdAt: now, + updatedAt: now, + deletedAt: null, messages: [], - session: null, activities: [], - turnDiffSummaries: [], - error: null, + checkpoints: [], + session: null, }, ]); }); @@ -56,203 +81,205 @@ describe("orchestration projector", () => { it("fails when event payload cannot be decoded by runtime schema", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); - const event: OrchestrationEvent = { - sequence: 1, - eventId: "event-message-invalid", - type: "message.sent", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: now, - commandId: "cmd-1", - payload: { - id: "message-1", - threadId: "thread-1", - text: "hello", - createdAt: now, - streaming: false, - }, - }; - await expect(Effect.runPromise(projectEvent(model, event))).rejects.toBeDefined(); + await expect( + Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-invalid", + payload: { + // missing required threadId + projectId: "project-1", + title: "demo", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ), + ).rejects.toBeDefined(); }); - it("keeps projector forward-compatible for unknown event types", async () => { + it("keeps projector forward-compatible for unhandled event types", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); - const event: OrchestrationEvent = { - sequence: 7, - eventId: "event-unknown", - type: "custom.event.ignored", - aggregateType: "unknown", - aggregateId: "aggregate-1", - occurredAt: "2026-01-01T00:00:00.000Z", - commandId: null, - payload: { test: true }, - }; - const next = await Effect.runPromise(projectEvent(model, event)); - expect(next.sequence).toBe(7); + const next = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 7, + type: "thread.turn-start-requested", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-unhandled", + payload: { + threadId: "thread-1", + messageId: "message-1", + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + ), + ); + + expect(next.snapshotSequence).toBe(7); expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); expect(next.threads).toEqual([]); }); - it("tracks latest turn timing from session lifecycle events", async () => { + it("tracks latest turn id from session lifecycle events", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; const startedAt = "2026-02-23T08:00:05.000Z"; - const completedAt = "2026-02-23T08:00:11.250Z"; const model = createEmptyReadModel(createdAt); - const createdEvent: OrchestrationEvent = { - sequence: 1, - eventId: "thread-created", - type: "thread.created", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - id: "thread-1", - projectId: "project-1", - title: "demo", - model: "gpt-5.3-codex", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }; + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); - const runningSessionEvent: OrchestrationEvent = { - sequence: 2, - eventId: "session-running", - type: "thread.session-set", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: startedAt, - commandId: "cmd-running", - payload: { - threadId: "thread-1", - session: { - sessionId: "session-1", - status: "running", - provider: "codex", - threadId: "thread-1", - activeTurnId: "turn-1", - createdAt, - updatedAt: startedAt, - lastError: null, - }, - }, - }; - - const readySessionEvent: OrchestrationEvent = { - sequence: 3, - eventId: "session-ready", - type: "thread.session-set", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: completedAt, - commandId: "cmd-ready", - payload: { - threadId: "thread-1", - session: { - sessionId: "session-1", - status: "ready", - provider: "codex", - threadId: "thread-1", - activeTurnId: null, - createdAt, - updatedAt: completedAt, - lastError: null, - }, - }, - }; - - const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); - const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent)); - const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent)); - const thread = afterReady.threads[0]; + const afterRunning = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: startedAt, + commandId: "cmd-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + activeTurnId: "turn-1", + lastError: null, + updatedAt: startedAt, + }, + }, + }), + ), + ); + const thread = afterRunning.threads[0]; expect(thread?.latestTurnId).toBe("turn-1"); - expect(thread?.latestTurnStartedAt).toBe(startedAt); - expect(thread?.latestTurnCompletedAt).toBe(completedAt); - expect(thread?.latestTurnDurationMs).toBe(6250); + expect(thread?.session?.status).toBe("running"); }); - it("marks assistant messages completed using non-streaming updates", async () => { + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; const completeAt = "2026-02-23T09:00:03.500Z"; const model = createEmptyReadModel(createdAt); - const createdEvent: OrchestrationEvent = { - sequence: 1, - eventId: "thread-created", - type: "thread.created", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - id: "thread-1", - projectId: "project-1", - title: "demo", - model: "gpt-5.3-codex", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }; + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); - const deltaEvent: OrchestrationEvent = { - sequence: 2, - eventId: "assistant-delta", - type: "message.sent", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: deltaAt, - commandId: "cmd-delta", - payload: { - id: "assistant:msg-1", - role: "assistant", - text: "hello", - threadId: "thread-1", - createdAt: deltaAt, - streaming: true, - }, - }; + const afterDelta = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: deltaAt, + commandId: "cmd-delta", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "hello", + turnId: "turn-1", + streaming: true, + createdAt: deltaAt, + updatedAt: deltaAt, + }, + }), + ), + ); - const completionEvent: OrchestrationEvent = { - sequence: 3, - eventId: "assistant-complete", - type: "message.sent", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: completeAt, - commandId: "cmd-complete", - payload: { - id: "assistant:msg-1", - role: "assistant", - text: "", - threadId: "thread-1", - createdAt: completeAt, - streaming: false, - }, - }; + const afterComplete = await Effect.runPromise( + projectEvent( + afterDelta, + makeEvent({ + sequence: 3, + type: "thread.message-sent", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-complete", + payload: { + threadId: "thread-1", + messageId: "assistant:msg-1", + role: "assistant", + text: "", + turnId: "turn-1", + streaming: false, + createdAt: completeAt, + updatedAt: completeAt, + }, + }), + ), + ); - const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); - const afterDelta = await Effect.runPromise(projectEvent(afterCreate, deltaEvent)); - const afterComplete = await Effect.runPromise(projectEvent(afterDelta, completionEvent)); const message = afterComplete.threads[0]?.messages[0]; - - expect(message).toEqual({ - id: "assistant:msg-1", - role: "assistant", - text: "hello", - createdAt: deltaAt, - completedAt: completeAt, - streaming: false, - }); + expect(message?.id).toBe("assistant:msg-1"); + expect(message?.text).toBe("hello"); + expect(message?.streaming).toBe(false); + expect(message?.updatedAt).toBe(completeAt); }); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 168e439d33d512409d0d6dec18cc00ffe5e3044b..6be9b130d09414bc02db7fbede765047893b7078 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,29 +1,36 @@ import type { OrchestrationEvent, - OrchestrationGitReadModel, - OrchestrationMessage, OrchestrationReadModel, + ThreadId, +} from "@t3tools/contracts"; +import { + OrchestrationCheckpointSummary, + OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; -import { - OrchestrationThreadActivitySchema, - OrchestrationGitReadModelSchema, - OrchestrationMessageSchema, - OrchestrationSessionSchema, - OrchestrationThreadSchema, - OrchestrationTurnDiffFileSchema, - OrchestrationTurnDiffSummarySchema, -} from "@t3tools/contracts"; import { Effect, Schema } from "effect"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; +import { + MessageSentPayloadSchema, + ProjectCreatedPayload, + ProjectDeletedPayload, + ProjectMetaUpdatedPayload, + ThreadActivityAppendedPayload, + ThreadCreatedPayload, + ThreadDeletedPayload, + ThreadMetaUpdatedPayload, + ThreadRevertedPayload, + ThreadSessionSetPayload, + ThreadTurnDiffCompletedPayload, +} from "./Schemas.ts"; type ThreadPatch = Partial>; function updateThread( threads: ReadonlyArray, - threadId: string, + threadId: ThreadId, patch: ThreadPatch, ): OrchestrationThread[] { return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); @@ -32,7 +39,7 @@ function updateThread( function decodeForEvent( schema: Schema.Schema, value: unknown, - eventType: string, + eventType: OrchestrationEvent["type"], field: string, ): Effect.Effect { return Schema.decodeUnknownEffect(schema)(value).pipe( @@ -40,66 +47,11 @@ function decodeForEvent( ) as Effect.Effect; } -const ThreadCreatedPayloadSchema = Schema.Struct({ - id: Schema.String, - projectId: Schema.String, - title: Schema.String, - model: Schema.String, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ - threadId: Schema.String, - title: Schema.optional(Schema.String), - model: Schema.optional(Schema.String), - branch: Schema.optional(Schema.NullOr(Schema.String)), - worktreePath: Schema.optional(Schema.NullOr(Schema.String)), - updatedAt: Schema.String, -}); - -const MessageSentPayloadSchema = Schema.Struct({ - id: Schema.String, - role: Schema.Literals(["user", "assistant"]), - text: Schema.String, - threadId: Schema.String, - createdAt: Schema.String, - streaming: Schema.Boolean, -}); - -const ThreadSessionSetPayloadSchema = Schema.Struct({ - threadId: Schema.String, - session: OrchestrationSessionSchema, -}); - -const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ - threadId: Schema.String, - turnId: Schema.String, - completedAt: Schema.String, - status: Schema.optional(Schema.String), - files: Schema.Array(OrchestrationTurnDiffFileSchema), - assistantMessageId: Schema.optional(Schema.String), - checkpointTurnCount: Schema.optional(Schema.Number), -}); - -const ThreadRevertedPayloadSchema = Schema.Struct({ - threadId: Schema.String, - turnCount: Schema.Number, - messageCount: Schema.Number, -}); - -const ThreadActivityAppendedPayloadSchema = Schema.Struct({ - threadId: Schema.String, - activity: OrchestrationThreadActivitySchema, -}); - export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { return { - sequence: 0, + snapshotSequence: 0, + projects: [], threads: [], - gitByProjectId: {}, updatedAt: nowIso, }; } @@ -110,39 +62,104 @@ export function projectEvent( ): Effect.Effect { const nextBase: OrchestrationReadModel = { ...model, - sequence: event.sequence, + snapshotSequence: event.sequence, updatedAt: event.occurredAt, }; switch (event.type) { + case "project.created": + return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const existing = nextBase.projects.find((entry) => entry.id === payload.projectId); + const nextProject = { + id: payload.projectId, + title: payload.title, + workspaceRoot: payload.workspaceRoot, + defaultModel: payload.defaultModel, + scripts: payload.scripts, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + deletedAt: null, + }; + + return { + ...nextBase, + projects: existing + ? nextBase.projects.map((entry) => (entry.id === payload.projectId ? nextProject : entry)) + : [...nextBase.projects, nextProject], + }; + }), + ); + + case "project.meta-updated": + return decodeForEvent( + ProjectMetaUpdatedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.workspaceRoot !== undefined + ? { workspaceRoot: payload.workspaceRoot } + : {}), + ...(payload.defaultModel !== undefined + ? { defaultModel: payload.defaultModel } + : {}), + ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), + updatedAt: payload.updatedAt, + } + : project, + ), + })), + ); + + case "project.deleted": + return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + projects: nextBase.projects.map((project) => + project.id === payload.projectId + ? { + ...project, + deletedAt: payload.deletedAt, + updatedAt: payload.deletedAt, + } + : project, + ), + })), + ); + case "thread.created": return Effect.gen(function* () { const payload = yield* decodeForEvent( - ThreadCreatedPayloadSchema, + ThreadCreatedPayload, event.payload, event.type, "payload", ); const thread: OrchestrationThread = yield* decodeForEvent( - OrchestrationThreadSchema, + OrchestrationThread, { - id: payload.id, + id: payload.threadId, projectId: payload.projectId, title: payload.title, model: payload.model, branch: payload.branch, worktreePath: payload.worktreePath, - createdAt: payload.createdAt, - updatedAt: payload.updatedAt, latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + deletedAt: null, messages: [], - session: null, - turnDiffSummaries: [], activities: [], - error: null, + checkpoints: [], + session: null, }, event.type, "thread", @@ -155,36 +172,38 @@ export function projectEvent( : [...nextBase.threads, thread], }; }); + case "thread.deleted": - return Effect.succeed({ - ...nextBase, - threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), - }); + return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + deletedAt: payload.deletedAt, + updatedAt: payload.deletedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent( - ThreadMetaUpdatedPayloadSchema, + ThreadMetaUpdatedPayload, event.payload, event.type, "payload", ).pipe( - Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); - if (!thread) { - return nextBase; - } - return { - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.model !== undefined ? { model: payload.model } : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - updatedAt: payload.updatedAt, - }), - }; - }), + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + updatedAt: payload.updatedAt, + }), + })), ); - case "message.sent": + + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( MessageSentPayloadSchema, @@ -192,27 +211,30 @@ export function projectEvent( event.type, "payload", ); - const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); - if (!targetThread) { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { return nextBase; } const message: OrchestrationMessage = yield* decodeForEvent( - OrchestrationMessageSchema, + OrchestrationMessage, { - id: payload.id, + id: payload.messageId, role: payload.role, text: payload.text, - createdAt: payload.createdAt, - ...(payload.streaming ? {} : { completedAt: event.occurredAt }), + ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), + turnId: payload.turnId, streaming: payload.streaming, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, }, event.type, "message", ); - const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); - const nextMessages = existingMessage - ? targetThread.messages.map((entry) => + + const existingMessage = thread.messages.find((entry) => entry.id === message.id); + const messages = existingMessage + ? thread.messages.map((entry) => entry.id === message.id ? { ...entry, @@ -222,29 +244,29 @@ export function projectEvent( ? message.text : entry.text, streaming: message.streaming, - createdAt: entry.createdAt, - ...(message.streaming - ? { completedAt: undefined } - : { completedAt: message.completedAt ?? event.occurredAt }), + updatedAt: message.updatedAt, + turnId: message.turnId, + ...(message.attachments !== undefined + ? { attachments: message.attachments } + : {}), } : entry, ) - : !message.streaming && message.text.length === 0 - ? targetThread.messages - : [...targetThread.messages, message]; + : [...thread.messages, message]; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { - messages: nextMessages, + messages, updatedAt: event.occurredAt, }), }; }); + case "thread.session-set": return Effect.gen(function* () { const payload = yield* decodeForEvent( - ThreadSessionSetPayloadSchema, + ThreadSessionSetPayload, event.payload, event.type, "payload", @@ -255,67 +277,26 @@ export function projectEvent( } const session: OrchestrationSession = yield* decodeForEvent( - OrchestrationSessionSchema, - { - ...payload.session, - threadId: payload.threadId, - }, + OrchestrationSession, + payload.session, event.type, "session", ); - const sessionUpdatedAt = session.updatedAt; - const turnTimingPatch: ThreadPatch = - session.status === "running" && session.activeTurnId - ? (() => { - const isSameTurn = thread.latestTurnId === session.activeTurnId; - return { - latestTurnId: session.activeTurnId, - latestTurnStartedAt: - isSameTurn && thread.latestTurnStartedAt - ? thread.latestTurnStartedAt - : sessionUpdatedAt, - ...(isSameTurn - ? {} - : { - latestTurnCompletedAt: null, - latestTurnDurationMs: null, - }), - }; - })() - : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt - ? (() => { - const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt; - const startedAtMs = Date.parse(thread.latestTurnStartedAt); - const completedAtMs = Date.parse(latestTurnCompletedAt); - const latestTurnDurationMs = - !Number.isNaN(startedAtMs) && - !Number.isNaN(completedAtMs) && - completedAtMs >= startedAtMs - ? completedAtMs - startedAtMs - : thread.latestTurnDurationMs; - - return { - latestTurnCompletedAt, - latestTurnDurationMs, - }; - })() - : {}; - return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { session, + latestTurnId: session.activeTurnId, updatedAt: event.occurredAt, - error: session.lastError, - ...turnTimingPatch, }), }; }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( - ThreadTurnDiffCompletedPayloadSchema, + ThreadTurnDiffCompletedPayload, event.payload, event.type, "payload", @@ -325,110 +306,65 @@ export function projectEvent( return nextBase; } - const nextSummary = yield* decodeForEvent( - OrchestrationTurnDiffSummarySchema, + const checkpoint = yield* decodeForEvent( + OrchestrationCheckpointSummary, { turnId: payload.turnId, - completedAt: payload.completedAt, - ...(payload.status !== undefined ? { status: payload.status } : {}), + checkpointTurnCount: payload.checkpointTurnCount, + checkpointRef: payload.checkpointRef, + status: payload.status, files: payload.files, - ...(payload.assistantMessageId !== undefined - ? { assistantMessageId: payload.assistantMessageId } - : {}), - ...(payload.checkpointTurnCount !== undefined - ? { checkpointTurnCount: payload.checkpointTurnCount } - : {}), + assistantMessageId: payload.assistantMessageId, + completedAt: payload.completedAt, }, event.type, - "turnDiffSummary", + "checkpoint", ); - const turnDiffSummaries = [ - ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), - nextSummary, - ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); - const startedAtMs = thread.latestTurnStartedAt - ? Date.parse(thread.latestTurnStartedAt) - : Number.NaN; - const completedAtMs = Date.parse(nextSummary.completedAt); - const latestTurnDurationMs = - !Number.isNaN(startedAtMs) && - !Number.isNaN(completedAtMs) && - completedAtMs >= startedAtMs - ? completedAtMs - startedAtMs - : thread.latestTurnDurationMs; + + const checkpoints = [ + ...thread.checkpoints.filter((entry) => entry.turnId !== checkpoint.turnId), + checkpoint, + ].toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { + checkpoints, latestTurnId: payload.turnId, - latestTurnCompletedAt: nextSummary.completedAt, - latestTurnDurationMs, - turnDiffSummaries, updatedAt: event.occurredAt, }), }; }); + case "thread.reverted": - return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( + return decodeForEvent(ThreadRevertedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => { const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); if (!thread) { return nextBase; } - const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); - const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); - const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => - left.completedAt.localeCompare(right.completedAt), - ); - const inferredTurnCountByTurnId = new Map(); - for (let index = 0; index < sortedSummaries.length; index += 1) { - const summary = sortedSummaries[index]; - if (!summary) continue; - inferredTurnCountByTurnId.set(summary.turnId, index + 1); - } + const checkpoints = thread.checkpoints + .filter((entry) => entry.checkpointTurnCount <= payload.turnCount) + .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount); - const turnDiffSummaries = thread.turnDiffSummaries - .filter((summary) => { - const checkpointTurnCount = - summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; - return checkpointTurnCount <= targetTurnCount; - }) - .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); - const latestSummary = - turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; - const activities = thread.activities.filter((activity) => { - if (!activity.turnId) { - return true; - } - const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); - if (checkpointTurnCount === undefined) { - return true; - } - return checkpointTurnCount <= targetTurnCount; - }); + const latestTurnId = + checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { - messages: thread.messages.slice( - 0, - Math.min(targetMessageCount, thread.messages.length), - ), - turnDiffSummaries, - latestTurnId: latestSummary?.turnId ?? null, - latestTurnStartedAt: null, - latestTurnCompletedAt: latestSummary?.completedAt ?? null, - latestTurnDurationMs: null, - activities, + checkpoints, + latestTurnId, updatedAt: event.occurredAt, }), }; }), ); + case "thread.activity-appended": return decodeForEvent( - ThreadActivityAppendedPayloadSchema, + ThreadActivityAppendedPayload, event.payload, event.type, "payload", @@ -438,8 +374,9 @@ export function projectEvent( if (!thread) { return nextBase; } + const activities = [ - ...thread.activities.filter((activity) => activity.id !== payload.activity.id), + ...thread.activities.filter((entry) => entry.id !== payload.activity.id), payload.activity, ] .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) @@ -454,21 +391,7 @@ export function projectEvent( }; }), ); - case "git.read-model-upsert": - return decodeForEvent( - OrchestrationGitReadModelSchema, - event.payload, - event.type, - "payload", - ).pipe( - Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ - ...nextBase, - gitByProjectId: { - ...nextBase.gitByProjectId, - [gitReadModel.projectId]: gitReadModel, - }, - })), - ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index a1c38e74c12a4567a4f89e2f4d059c02f5424873..86cfbf914d9c2ece851f7de4d5de836b5cc85b7c 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -200,3 +200,19 @@ export type CheckpointRepositoryError = export type ProviderSessionRepositoryError = | ProviderSessionRepositoryValidationError | ProviderSessionRepositoryPersistenceError; + +export type OrchestrationCommandReceiptRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError; + +export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError; + +export type ProviderSessionRuntimeRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError + | PersistenceSerializationError; + +export type ProjectionRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError + | PersistenceSerializationError; diff --git a/apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts b/apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts new file mode 100644 index 0000000000000000000000000000000000000000..1267f70923c325295c2716448ddf24679fab41d8 --- /dev/null +++ b/apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts @@ -0,0 +1,79 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + CheckpointDiffBlobRepository, + CheckpointDiffBlob, + GetCheckpointDiffBlobInput, + type CheckpointDiffBlobRepositoryShape, +} from "../Services/CheckpointDiffBlobs.ts"; + +const makeCheckpointDiffBlobRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertDiffBlobRow = SqlSchema.void({ + Request: CheckpointDiffBlob, + execute: (blob) => + sql` + INSERT INTO checkpoint_diff_blobs ( + thread_id, + from_turn_count, + to_turn_count, + diff, + created_at + ) + VALUES ( + ${blob.threadId}, + ${blob.fromTurnCount}, + ${blob.toTurnCount}, + ${blob.diff}, + ${blob.createdAt} + ) + ON CONFLICT (thread_id, from_turn_count, to_turn_count) + DO UPDATE SET + diff = excluded.diff, + created_at = excluded.created_at + `, + }); + + const getDiffBlobRow = SqlSchema.findOneOption({ + Request: GetCheckpointDiffBlobInput, + Result: CheckpointDiffBlob, + execute: (request) => + sql` + SELECT + thread_id AS "threadId", + from_turn_count AS "fromTurnCount", + to_turn_count AS "toTurnCount", + diff, + created_at AS "createdAt" + FROM checkpoint_diff_blobs + WHERE thread_id = ${request.threadId} + AND from_turn_count = ${request.fromTurnCount} + AND to_turn_count = ${request.toTurnCount} + `, + }); + + const upsert: CheckpointDiffBlobRepositoryShape["upsert"] = (blob) => + upsertDiffBlobRow(blob).pipe( + Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.upsert:query")), + ); + + const get: CheckpointDiffBlobRepositoryShape["get"] = (input) => + getDiffBlobRow(input).pipe( + Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.get:query")), + ); + + return { + upsert, + get, + } satisfies CheckpointDiffBlobRepositoryShape; +}); + +export const CheckpointDiffBlobRepositoryLive = Layer.effect( + CheckpointDiffBlobRepository, + makeCheckpointDiffBlobRepository, +); diff --git a/apps/server/src/persistence/Layers/Checkpoints.test.ts b/apps/server/src/persistence/Layers/Checkpoints.test.ts deleted file mode 100644 index 99f85748ce6259986a75fa57e44967038d4effc0..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Checkpoints.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { it, assert } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; - -import { CheckpointRepository } from "../Services/Checkpoints.ts"; -import { CheckpointRepositoryLive } from "./Checkpoints.ts"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; - -function makeCheckpointRepositoryLayer() { - return CheckpointRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); -} - -it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => - Effect.gen(function* () { - const repository = yield* CheckpointRepository; - - yield* repository.upsertCheckpoint({ - providerSessionId: "sess-1", - threadId: "thread-1", - checkpointId: "root", - checkpointRef: "refs/t3/checkpoints/thread-1/turn/0", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - createdAt: new Date().toISOString(), - }); - - yield* repository.upsertCheckpoint({ - providerSessionId: "sess-1", - threadId: "thread-1", - checkpointId: "turn-1", - checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", - turnCount: 1, - messageCount: 2, - label: "Turn 1", - preview: "hello", - createdAt: new Date().toISOString(), - }); - - yield* repository.upsertCheckpoint({ - providerSessionId: "sess-1", - threadId: "thread-1", - checkpointId: "turn-2", - checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", - turnCount: 2, - messageCount: 4, - label: "Turn 2", - preview: "done", - createdAt: new Date().toISOString(), - }); - - const checkpoints = yield* repository.listCheckpoints({ threadId: "thread-1" }); - assert.equal(checkpoints.length, 3); - assert.equal(checkpoints[0]?.id, "root"); - assert.equal(checkpoints[0]?.isCurrent, false); - assert.equal(checkpoints[2]?.id, "turn-2"); - assert.equal(checkpoints[2]?.isCurrent, true); - - const one = yield* repository.getCheckpoint({ threadId: "thread-1", turnCount: 1 }); - assert.equal(Option.isSome(one), true); - if (Option.isSome(one)) { - assert.equal(one.value.id, "turn-1"); - assert.equal(one.value.isCurrent, false); - assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1"); - } - - yield* repository.upsertCheckpoint({ - providerSessionId: "sess-2", - threadId: "thread-1", - checkpointId: "turn-3", - checkpointRef: "refs/t3/checkpoints/thread-1/turn/3", - turnCount: 3, - messageCount: 6, - label: "Turn 3", - preview: "new session", - createdAt: new Date().toISOString(), - }); - - const resumed = yield* repository.listCheckpoints({ threadId: "thread-1" }); - assert.equal(resumed.length, 4); - assert.equal(resumed[3]?.id, "turn-3"); - assert.equal(resumed[3]?.isCurrent, true); - - yield* repository.upsertCheckpoint({ - providerSessionId: "sess-3", - threadId: "thread-2", - checkpointId: "root", - checkpointRef: "refs/t3/checkpoints/thread-2/turn/0", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - createdAt: new Date().toISOString(), - }); - - const otherThread = yield* repository.listCheckpoints({ threadId: "thread-2" }); - assert.equal(otherThread.length, 1); - assert.equal(otherThread[0]?.id, "root"); - - yield* repository.deleteAfterTurn({ - threadId: "thread-1", - maxTurnCount: 1, - }); - - const pruned = yield* repository.listCheckpoints({ threadId: "thread-1" }); - assert.equal(pruned.length, 2); - assert.equal(pruned[1]?.turnCount, 1); - assert.equal(pruned[1]?.isCurrent, true); - - yield* repository.deleteAllForThread({ threadId: "thread-1" }); - const empty = yield* repository.listCheckpoints({ threadId: "thread-1" }); - assert.equal(empty.length, 0); - - const stillPresent = yield* repository.listCheckpoints({ threadId: "thread-2" }); - assert.equal(stillPresent.length, 1); - }).pipe(Effect.provide(makeCheckpointRepositoryLayer())), -); diff --git a/apps/server/src/persistence/Layers/Checkpoints.ts b/apps/server/src/persistence/Layers/Checkpoints.ts deleted file mode 100644 index b30f658243d9e63b841b4143aea7f808c65e2587..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Checkpoints.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * CheckpointRepositoryLive - SQLite-backed checkpoint metadata repository layer. - * - * Persists and queries checkpoint metadata (`turnCount`, labels, previews, - * message counts) and the associated hidden Git ref for each turn. - * - * This layer does not read/write Git refs and does not perform provider-thread - * rollback. Those responsibilities remain in `CheckpointStore` and - * `CheckpointService`. - * - * @module CheckpointRepositoryLive - */ -import type { ProviderCheckpoint } from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; - -import { - CheckpointRepositoryPersistenceError, - CheckpointRepositoryValidationError, - type CheckpointRepositoryError, -} from "../Errors.ts"; -import { - CheckpointRepository, - type CheckpointRepositoryEntry, - type CheckpointRepositoryShape, -} from "../Services/Checkpoints.ts"; - -const NonEmptyString = Schema.NonEmptyString; - -const CheckpointRowSchema = Schema.Struct({ - providerSessionId: Schema.NullOr(Schema.String), - threadId: Schema.String, - checkpointId: Schema.String, - checkpointRef: Schema.String, - turnCount: Schema.Number, - messageCount: Schema.Number, - label: Schema.String, - preview: Schema.NullOr(Schema.String), - createdAt: Schema.String, -}); - -const ThreadRequestSchema = Schema.Struct({ - threadId: NonEmptyString, -}); - -const UpsertRequestSchema = Schema.Struct({ - providerSessionId: Schema.NullOr(NonEmptyString), - threadId: NonEmptyString, - checkpointId: NonEmptyString, - checkpointRef: NonEmptyString, - turnCount: Schema.Int, - messageCount: Schema.Int, - label: NonEmptyString, - preview: Schema.NullOr(Schema.String), - createdAt: NonEmptyString, -}); - -const GetCheckpointRequestSchema = Schema.Struct({ - threadId: NonEmptyString, - turnCount: Schema.Int, -}); - -const DeleteAfterTurnRequestSchema = Schema.Struct({ - threadId: NonEmptyString, - maxTurnCount: Schema.Int, -}); - -function errorMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function toValidationError(operation: string, cause: unknown): CheckpointRepositoryValidationError { - return new CheckpointRepositoryValidationError({ - operation, - issue: errorMessage(cause, "Invalid checkpoint repository input."), - cause, - }); -} - -function decodeInput( - schema: Schema.Schema, - input: unknown, - operation: string, -): Effect.Effect { - return Schema.decodeUnknownEffect(schema)(input).pipe( - Effect.mapError((cause) => toValidationError(operation, cause)), - ) as Effect.Effect; -} - -function ensureNonNegativeInt( - operation: string, - field: string, - value: number, -): Effect.Effect { - if (value < 0) { - return Effect.fail( - new CheckpointRepositoryValidationError({ - operation, - issue: `${field} must be an integer >= 0.`, - }), - ); - } - return Effect.succeed(value); -} - -function toPersistenceError( - operation: string, - cause: unknown, -): CheckpointRepositoryPersistenceError { - return new CheckpointRepositoryPersistenceError({ - operation, - detail: `Failed to execute ${operation}.`, - cause, - }); -} - -function toCheckpointEntry( - row: Schema.Schema.Type, - isCurrent: boolean, -): CheckpointRepositoryEntry { - return { - id: row.checkpointId, - threadId: row.threadId, - checkpointRef: row.checkpointRef, - turnCount: row.turnCount, - messageCount: row.messageCount, - label: row.label, - ...(row.preview !== null ? { preview: row.preview } : {}), - createdAt: row.createdAt, - isCurrent, - }; -} - -function toProviderCheckpoint(entry: CheckpointRepositoryEntry): ProviderCheckpoint { - return { - id: entry.id, - turnCount: entry.turnCount, - messageCount: entry.messageCount, - label: entry.label, - ...(entry.preview !== undefined ? { preview: entry.preview } : {}), - isCurrent: entry.isCurrent, - }; -} - -const makeCheckpointRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const upsertCheckpointRow = SqlSchema.findOne({ - Request: UpsertRequestSchema, - Result: CheckpointRowSchema, - execute: (request) => - sql` - INSERT INTO provider_checkpoints ( - provider_session_id, - thread_id, - checkpoint_id, - checkpoint_ref, - turn_count, - message_count, - label, - preview, - created_at - ) - VALUES ( - ${request.providerSessionId}, - ${request.threadId}, - ${request.checkpointId}, - ${request.checkpointRef}, - ${request.turnCount}, - ${request.messageCount}, - ${request.label}, - ${request.preview}, - ${request.createdAt} - ) - ON CONFLICT (thread_id, turn_count) - DO UPDATE SET - provider_session_id = excluded.provider_session_id, - thread_id = excluded.thread_id, - checkpoint_id = excluded.checkpoint_id, - checkpoint_ref = excluded.checkpoint_ref, - message_count = excluded.message_count, - label = excluded.label, - preview = excluded.preview, - created_at = excluded.created_at - RETURNING - provider_session_id AS "providerSessionId", - thread_id AS "threadId", - checkpoint_id AS "checkpointId", - checkpoint_ref AS "checkpointRef", - turn_count AS "turnCount", - message_count AS "messageCount", - label, - preview, - created_at AS "createdAt" - `, - }); - - const listCheckpointRows = SqlSchema.findAll({ - Request: ThreadRequestSchema, - Result: CheckpointRowSchema, - execute: ({ threadId }) => - sql` - SELECT - provider_session_id AS "providerSessionId", - thread_id AS "threadId", - checkpoint_id AS "checkpointId", - checkpoint_ref AS "checkpointRef", - turn_count AS "turnCount", - message_count AS "messageCount", - label, - preview, - created_at AS "createdAt" - FROM provider_checkpoints - WHERE thread_id = ${threadId} - ORDER BY turn_count ASC - `, - }); - - const listForThread = ( - threadId: string, - ): Effect.Effect, CheckpointRepositoryError> => - listCheckpointRows({ threadId }).pipe( - Effect.mapError((cause) => - toPersistenceError("CheckpointRepository.listCheckpoints:query", cause), - ), - Effect.map((rows) => { - const currentTurnCount = rows[rows.length - 1]?.turnCount; - return rows.map((row) => toCheckpointEntry(row, row.turnCount === currentTurnCount)); - }), - ); - - const upsertCheckpoint: CheckpointRepositoryShape["upsertCheckpoint"] = (input) => - Effect.gen(function* () { - const operation = "CheckpointRepository.upsertCheckpoint"; - const parsed = yield* decodeInput( - UpsertRequestSchema, - { - providerSessionId: input.providerSessionId?.trim() || null, - threadId: input.threadId.trim(), - checkpointId: input.checkpointId.trim(), - checkpointRef: input.checkpointRef.trim(), - turnCount: input.turnCount, - messageCount: input.messageCount, - label: input.label.trim(), - preview: input.preview?.trim() || null, - createdAt: input.createdAt.trim(), - }, - operation, - ); - yield* ensureNonNegativeInt(operation, "turnCount", parsed.turnCount); - yield* ensureNonNegativeInt(operation, "messageCount", parsed.messageCount); - - yield* upsertCheckpointRow(parsed).pipe( - Effect.mapError((cause) => - toPersistenceError("CheckpointRepository.upsertCheckpoint:query", cause), - ), - Effect.asVoid, - ); - }); - - const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - ThreadRequestSchema, - { - threadId: input.threadId.trim(), - }, - "CheckpointRepository.listCheckpoints", - ); - - const entries = yield* listForThread(parsed.threadId); - return entries.map(toProviderCheckpoint); - }); - - const getCheckpoint: CheckpointRepositoryShape["getCheckpoint"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - GetCheckpointRequestSchema, - { - threadId: input.threadId.trim(), - turnCount: input.turnCount, - }, - "CheckpointRepository.getCheckpoint", - ); - yield* ensureNonNegativeInt( - "CheckpointRepository.getCheckpoint", - "turnCount", - parsed.turnCount, - ); - - const entries = yield* listForThread(parsed.threadId); - const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount); - return Option.fromNullishOr(checkpoint); - }); - - const deleteAfterTurn: CheckpointRepositoryShape["deleteAfterTurn"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - DeleteAfterTurnRequestSchema, - { - threadId: input.threadId.trim(), - maxTurnCount: input.maxTurnCount, - }, - "CheckpointRepository.deleteAfterTurn", - ); - yield* ensureNonNegativeInt( - "CheckpointRepository.deleteAfterTurn", - "maxTurnCount", - parsed.maxTurnCount, - ); - - yield* sql` - DELETE FROM provider_checkpoints - WHERE thread_id = ${parsed.threadId} - AND turn_count > ${parsed.maxTurnCount} - `.pipe( - Effect.mapError((cause) => - toPersistenceError("CheckpointRepository.deleteAfterTurn:query", cause), - ), - ); - }); - - const deleteAllForThread: CheckpointRepositoryShape["deleteAllForThread"] = (input) => - Effect.gen(function* () { - const parsed = yield* decodeInput( - ThreadRequestSchema, - { - threadId: input.threadId.trim(), - }, - "CheckpointRepository.deleteAllForThread", - ); - - yield* sql` - DELETE FROM provider_checkpoints - WHERE thread_id = ${parsed.threadId} - `.pipe( - Effect.mapError((cause) => - toPersistenceError("CheckpointRepository.deleteAllForThread:query", cause), - ), - ); - }); - - return { - upsertCheckpoint, - listCheckpoints, - getCheckpoint, - deleteAfterTurn, - deleteAllForThread, - } satisfies CheckpointRepositoryShape; -}); - -export const CheckpointRepositoryLive = Layer.effect( - CheckpointRepository, - makeCheckpointRepository, -); diff --git a/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts new file mode 100644 index 0000000000000000000000000000000000000000..33f28567428a341f75f0ac0c681f7a38d0afca30 --- /dev/null +++ b/apps/server/src/persistence/Layers/OrchestrationCommandReceipts.ts @@ -0,0 +1,89 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + GetByCommandIdInput, + OrchestrationCommandReceipt, + OrchestrationCommandReceiptRepository, + type OrchestrationCommandReceiptRepositoryShape, +} from "../Services/OrchestrationCommandReceipts.ts"; + +const makeOrchestrationCommandReceiptRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertReceiptRow = SqlSchema.void({ + Request: OrchestrationCommandReceipt, + execute: (receipt) => + sql` + INSERT INTO orchestration_command_receipts ( + command_id, + aggregate_kind, + aggregate_id, + accepted_at, + result_sequence, + status, + error + ) + VALUES ( + ${receipt.commandId}, + ${receipt.aggregateKind}, + ${receipt.aggregateId}, + ${receipt.acceptedAt}, + ${receipt.resultSequence}, + ${receipt.status}, + ${receipt.error} + ) + ON CONFLICT (command_id) + DO UPDATE SET + aggregate_kind = excluded.aggregate_kind, + aggregate_id = excluded.aggregate_id, + accepted_at = excluded.accepted_at, + result_sequence = excluded.result_sequence, + status = excluded.status, + error = excluded.error + `, + }); + + const findReceiptByCommandId = SqlSchema.findOneOption({ + Request: GetByCommandIdInput, + Result: OrchestrationCommandReceipt, + execute: ({ commandId }) => + sql` + SELECT + command_id AS "commandId", + aggregate_kind AS "aggregateKind", + aggregate_id AS "aggregateId", + accepted_at AS "acceptedAt", + result_sequence AS "resultSequence", + status, + error + FROM orchestration_command_receipts + WHERE command_id = ${commandId} + `, + }); + + const upsert: OrchestrationCommandReceiptRepositoryShape["upsert"] = (receipt) => + upsertReceiptRow(receipt).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationCommandReceiptRepository.upsert:query")), + ); + + const getByCommandId: OrchestrationCommandReceiptRepositoryShape["getByCommandId"] = (input) => + findReceiptByCommandId(input).pipe( + Effect.mapError( + toPersistenceSqlError("OrchestrationCommandReceiptRepository.getByCommandId:query"), + ), + ); + + return { + upsert, + getByCommandId, + } satisfies OrchestrationCommandReceiptRepositoryShape; +}); + +export const OrchestrationCommandReceiptRepositoryLive = Layer.effect( + OrchestrationCommandReceiptRepository, + makeOrchestrationCommandReceiptRepository, +); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 19f72ff4faf8a69ae14ed384088415208f1164f2..a99a307e3cbc806ba04fd4d4657d42dea332c462 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -1,39 +1,119 @@ -import { Effect, Layer, Stream } from "effect"; +import { CommandId, EventId, ProjectId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; +import { Effect, Layer, Stream } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { PersistenceDecodeError } from "../Errors.ts"; import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; -import { NodeServices } from "@effect/platform-node"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; const layer = it.layer( - Layer.mergeAll( - Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory), - NodeServices.layer, - ), + OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); layer("OrchestrationEventStore", (it) => { - it.effect("stores and replays events", () => + it.effect("stores json columns as strings and replays decoded events", () => Effect.gen(function* () { - const createdAt = new Date().toISOString(); - const eventStore = yield* OrchestrationEventStore; - const saved = yield* eventStore.append({ - eventId: "event-1", - type: "thread.created", - aggregateType: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-1", - payload: { id: "thread-1", projectId: "project-1", title: "demo" }, + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + const appended = yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-store-roundtrip"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-roundtrip"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-store-roundtrip"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-store-roundtrip"), + metadata: { + adapterKey: "codex", + }, + payload: { + projectId: ProjectId.makeUnsafe("project-roundtrip"), + title: "Roundtrip Project", + workspaceRoot: "/tmp/project-roundtrip", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, }); - assert.equal(saved.sequence, 1); - const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe( - Effect.map(Array.from), + const storedRows = yield* sql<{ + readonly payloadJson: string; + readonly metadataJson: string; + }>` + SELECT + payload_json AS "payloadJson", + metadata_json AS "metadataJson" + FROM orchestration_events + WHERE event_id = ${appended.eventId} + `; + assert.equal(storedRows.length, 1); + assert.equal(typeof storedRows[0]?.payloadJson, "string"); + assert.equal(typeof storedRows[0]?.metadataJson, "string"); + + const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0, 10)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(replayed.length, 1); + assert.equal(replayed[0]?.type, "project.created"); + assert.equal(replayed[0]?.metadata.adapterKey, "codex"); + }), + ); + + it.effect("fails with PersistenceDecodeError when stored json is invalid", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* sql` + INSERT INTO orchestration_events ( + 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 + ) + VALUES ( + ${EventId.makeUnsafe("evt-store-invalid-json")}, + ${"project"}, + ${ProjectId.makeUnsafe("project-invalid-json")}, + ${0}, + ${"project.created"}, + ${now}, + ${CommandId.makeUnsafe("cmd-store-invalid-json")}, + ${null}, + ${null}, + ${"server"}, + ${"{"}, + ${"{}"} + ) + `; + + const replayResult = yield* Effect.result( + Stream.runCollect(eventStore.readFromSequence(0, 10)), ); - assert.deepEqual(replayed, [saved]); + assert.equal(replayResult._tag, "Failure"); + if (replayResult._tag === "Failure") { + assert.ok(replayResult.failure instanceof PersistenceDecodeError); + assert.ok( + replayResult.failure.operation.includes( + "OrchestrationEventStore.readFromSequence:decodeRows", + ), + ); + } }), ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index c1a79890c2905a8c714cea11e7b92ed6581dd24c..ad36142afbf9b59f91e9f7155169f5053d78cd48 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -1,13 +1,22 @@ -import type { OrchestrationEvent } from "@t3tools/contracts"; -import { OrchestrationEventSchema } from "@t3tools/contracts"; +import { + CommandId, + EventId, + IsoDateTime, + NonNegativeInt, + OrchestrationActorKind, + OrchestrationAggregateKind, + OrchestrationEvent, + OrchestrationEventMetadata, + OrchestrationEventType, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Stream } from "effect"; import { - toPersistenceDecodeCauseError, toPersistenceDecodeError, - toPersistenceSerializationError, toPersistenceSqlError, type OrchestrationEventStoreError, } from "../Errors.ts"; @@ -16,57 +25,77 @@ import { type OrchestrationEventStoreShape, } from "../Services/OrchestrationEventStore.ts"; -const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema); - -const EventRowSchema = Schema.Struct({ - sequence: Schema.Number, - eventId: Schema.String, - type: Schema.String, - aggregateType: Schema.String, - aggregateId: Schema.String, - occurredAt: Schema.String, - commandId: Schema.NullOr(Schema.String), - payloadJson: Schema.String, -}); +const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEvent); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMetadata); const AppendEventRequestSchema = Schema.Struct({ - eventId: Schema.String, - type: Schema.String, - aggregateType: Schema.String, - aggregateId: Schema.String, - occurredAt: Schema.String, - commandId: Schema.NullOr(Schema.String), - payloadJson: Schema.String, + eventId: EventId, + aggregateKind: OrchestrationAggregateKind, + streamId: Schema.Union([ProjectId, ThreadId]), + type: OrchestrationEventType, + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + actorKind: OrchestrationActorKind, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + payloadJson: UnknownFromJsonString, + metadataJson: EventMetadataFromJsonString, +}); + +const OrchestrationEventPersistedRowSchema = Schema.Struct({ + sequence: NonNegativeInt, + eventId: EventId, + type: OrchestrationEventType, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + payload: UnknownFromJsonString, + metadata: EventMetadataFromJsonString, }); const ReadFromSequenceRequestSchema = Schema.Struct({ - sequenceExclusive: Schema.Number, + sequenceExclusive: NonNegativeInt, limit: Schema.Number, }); const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; const READ_PAGE_SIZE = 500; -function eventRowToOrchestrationEvent( - row: Schema.Schema.Type, - operation: string, -): Effect.Effect { - return Effect.try({ - try: () => JSON.parse(row.payloadJson) as unknown, - catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), - }).pipe( - Effect.flatMap((payload) => - decodeEvent({ - sequence: row.sequence, - eventId: row.eventId, - type: row.type, - aggregateType: row.aggregateType, - aggregateId: row.aggregateId, - occurredAt: row.occurredAt, - commandId: row.commandId, - payload, - }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), - ), - ); +function inferActorKind( + event: Omit, +): Schema.Schema.Type { + if (event.commandId !== null && event.commandId.startsWith("provider:")) { + return "provider"; + } + if (event.commandId !== null && event.commandId.startsWith("server:")) { + return "server"; + } + if ( + event.metadata.providerSessionId !== undefined || + event.metadata.providerThreadId !== undefined || + event.metadata.providerTurnId !== undefined || + event.metadata.providerItemId !== undefined || + event.metadata.adapterKey !== undefined + ) { + return "provider"; + } + if (event.commandId === null) { + return "server"; + } + return "client"; +} + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, +) { + return (cause: unknown): OrchestrationEventStoreError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); } const makeEventStore = Effect.gen(function* () { @@ -74,53 +103,79 @@ const makeEventStore = Effect.gen(function* () { const appendEventRow = SqlSchema.findOne({ Request: AppendEventRequestSchema, - Result: EventRowSchema, + Result: OrchestrationEventPersistedRowSchema, execute: (request) => sql` INSERT INTO orchestration_events ( event_id, + aggregate_kind, + stream_id, + stream_version, event_type, - aggregate_type, - aggregate_id, occurred_at, command_id, - payload_json + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json ) VALUES ( ${request.eventId}, + ${request.aggregateKind}, + ${request.streamId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.streamId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), ${request.type}, - ${request.aggregateType}, - ${request.aggregateId}, ${request.occurredAt}, ${request.commandId}, - ${request.payloadJson} + ${request.causationEventId}, + ${request.correlationId}, + ${request.actorKind}, + ${request.payloadJson}, + ${request.metadataJson} ) RETURNING sequence, event_id AS "eventId", event_type AS "type", - aggregate_type AS "aggregateType", - aggregate_id AS "aggregateId", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", occurred_at AS "occurredAt", command_id AS "commandId", - payload_json AS "payloadJson" + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" `, }); const readEventRowsFromSequence = SqlSchema.findAll({ Request: ReadFromSequenceRequestSchema, - Result: EventRowSchema, + Result: OrchestrationEventPersistedRowSchema, execute: (request) => sql` SELECT sequence, event_id AS "eventId", event_type AS "type", - aggregate_type AS "aggregateType", - aggregate_id AS "aggregateId", + aggregate_kind AS "aggregateKind", + stream_id AS "aggregateId", occurred_at AS "occurredAt", command_id AS "commandId", - payload_json AS "payloadJson" + causation_event_id AS "causationEventId", + correlation_id AS "correlationId", + payload_json AS "payload", + metadata_json AS "metadata" FROM orchestration_events WHERE sequence > ${request.sequenceExclusive} ORDER BY sequence ASC @@ -129,24 +184,28 @@ const makeEventStore = Effect.gen(function* () { }); const append: OrchestrationEventStoreShape["append"] = (event) => - Effect.try({ - try: () => JSON.stringify(event.payload), - catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"), + appendEventRow({ + eventId: event.eventId, + aggregateKind: event.aggregateKind, + streamId: event.aggregateId, + type: event.type, + causationEventId: event.causationEventId, + correlationId: event.correlationId, + actorKind: inferActorKind(event), + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: event.payload, + metadataJson: event.metadata, }).pipe( - Effect.flatMap((payloadJson) => - appendEventRow({ - eventId: event.eventId, - type: event.type, - aggregateType: event.aggregateType, - aggregateId: event.aggregateId, - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson, - }).pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")), - Effect.flatMap((row) => - eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"), - ), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.append:insert", + "OrchestrationEventStore.append:decodeRow", + ), + ), + Effect.flatMap((row) => + decodeEvent(row).pipe( + Effect.mapError(toPersistenceDecodeError("OrchestrationEventStore.append:rowToEvent")), ), ), ); @@ -159,21 +218,27 @@ const makeEventStore = Effect.gen(function* () { if (normalizedLimit === 0) { return Stream.empty; } - const readPage = (cursor: number, remaining: number): Stream.Stream< - OrchestrationEvent, - OrchestrationEventStoreError - > => + const readPage = ( + cursor: number, + remaining: number, + ): Stream.Stream => Stream.fromEffect( readEventRowsFromSequence({ sequenceExclusive: cursor, limit: Math.min(remaining, READ_PAGE_SIZE), }).pipe( - Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readFromSequence:query")), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.readFromSequence:query", + "OrchestrationEventStore.readFromSequence:decodeRows", + ), + ), Effect.flatMap((rows) => Effect.forEach(rows, (row) => - eventRowToOrchestrationEvent( - row, - "OrchestrationEventStore.readFromSequence:rowToEvent", + decodeEvent(row).pipe( + Effect.mapError( + toPersistenceDecodeError("OrchestrationEventStore.readFromSequence:rowToEvent"), + ), ), ), ), @@ -204,7 +269,4 @@ const makeEventStore = Effect.gen(function* () { } satisfies OrchestrationEventStoreShape; }); -export const OrchestrationEventStoreLive = Layer.effect( - OrchestrationEventStore, - makeEventStore, -); +export const OrchestrationEventStoreLive = Layer.effect(OrchestrationEventStore, makeEventStore); diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8d65914bb7b8347bf9cfee9cf880b342b94861e --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -0,0 +1,177 @@ +import { OrchestrationCheckpointFile } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, +} from "../Errors.ts"; +import { + DeleteByThreadIdInput, + GetByThreadAndTurnCountInput, + ListByThreadIdInput, + ProjectionCheckpoint, + ProjectionCheckpointRepository, + type ProjectionCheckpointRepositoryShape, +} from "../Services/ProjectionCheckpoints.ts"; + +const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( + Struct.assign({ + files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionCheckpointRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionCheckpointRow = SqlSchema.void({ + Request: ProjectionCheckpointDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_checkpoints ( + thread_id, + turn_id, + checkpoint_turn_count, + checkpoint_ref, + status, + files_json, + assistant_message_id, + completed_at + ) + VALUES ( + ${row.threadId}, + ${row.turnId}, + ${row.checkpointTurnCount}, + ${row.checkpointRef}, + ${row.status}, + ${row.files}, + ${row.assistantMessageId}, + ${row.completedAt} + ) + ON CONFLICT (thread_id, checkpoint_turn_count) + DO UPDATE SET + turn_id = excluded.turn_id, + checkpoint_ref = excluded.checkpoint_ref, + status = excluded.status, + files_json = excluded.files_json, + assistant_message_id = excluded.assistant_message_id, + completed_at = excluded.completed_at + `, + }); + + const listProjectionCheckpointRows = SqlSchema.findAll({ + Request: ListByThreadIdInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + status, + files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_checkpoints + WHERE thread_id = ${threadId} + ORDER BY checkpoint_turn_count ASC + `, + }); + + const getProjectionCheckpointRow = SqlSchema.findOneOption({ + Request: GetByThreadAndTurnCountInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId, checkpointTurnCount }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + status, + files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM projection_checkpoints + WHERE thread_id = ${threadId} + AND checkpoint_turn_count = ${checkpointTurnCount} + `, + }); + + const deleteProjectionCheckpointRows = SqlSchema.void({ + Request: DeleteByThreadIdInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_checkpoints + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) => + upsertProjectionCheckpointRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.upsert:query", + "ProjectionCheckpointRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) => + listProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.listByThreadId:query", + "ProjectionCheckpointRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = ( + input, + ) => + getProjectionCheckpointRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionCheckpointRepository.getByThreadAndTurnCount:query", + "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionCheckpointRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByThreadAndTurnCount, + deleteByThreadId, + } satisfies ProjectionCheckpointRepositoryShape; +}); + +export const ProjectionCheckpointRepositoryLive = Layer.effect( + ProjectionCheckpointRepository, + makeProjectionCheckpointRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts new file mode 100644 index 0000000000000000000000000000000000000000..25443a70dca2c6323453e24d7067a6bad54960ce --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -0,0 +1,136 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProjectionPendingApprovalInput, + DeleteProjectionPendingApprovalInput, + ListProjectionPendingApprovalsInput, + ProjectionPendingApproval, + ProjectionPendingApprovalRepository, + type ProjectionPendingApprovalRepositoryShape, +} from "../Services/ProjectionPendingApprovals.ts"; + +const makeProjectionPendingApprovalRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionPendingApprovalRow = SqlSchema.void({ + Request: ProjectionPendingApproval, + execute: (row) => + sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + ${row.requestId}, + ${row.threadId}, + ${row.turnId}, + ${row.status}, + ${row.decision}, + ${row.createdAt}, + ${row.resolvedAt} + ) + ON CONFLICT (request_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + status = excluded.status, + decision = excluded.decision, + created_at = excluded.created_at, + resolved_at = excluded.resolved_at + `, + }); + + const listProjectionPendingApprovalRows = SqlSchema.findAll({ + Request: ListProjectionPendingApprovalsInput, + Result: ProjectionPendingApproval, + execute: ({ threadId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, request_id ASC + `, + }); + + const getProjectionPendingApprovalRow = SqlSchema.findOneOption({ + Request: GetProjectionPendingApprovalInput, + Result: ProjectionPendingApproval, + execute: ({ requestId }) => + sql` + SELECT + request_id AS "requestId", + thread_id AS "threadId", + turn_id AS "turnId", + status, + decision, + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const deleteProjectionPendingApprovalRow = SqlSchema.void({ + Request: DeleteProjectionPendingApprovalInput, + execute: ({ requestId }) => + sql` + DELETE FROM projection_pending_approvals + WHERE request_id = ${requestId} + `, + }); + + const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => + upsertProjectionPendingApprovalRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), + ); + + const listByThreadId: ProjectionPendingApprovalRepositoryShape["listByThreadId"] = (input) => + listProjectionPendingApprovalRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.listByThreadId:query"), + ), + ); + + const getByRequestId: ProjectionPendingApprovalRepositoryShape["getByRequestId"] = (input) => + getProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.getByRequestId:query"), + ), + ); + + const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = ( + input, + ) => + deleteProjectionPendingApprovalRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByRequestId, + deleteByRequestId, + } satisfies ProjectionPendingApprovalRepositoryShape; +}); + +export const ProjectionPendingApprovalRepositoryLive = Layer.effect( + ProjectionPendingApprovalRepository, + makeProjectionPendingApprovalRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dbc8c2d1f63b2f0a9fda47260d305fd70b3e6e1 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -0,0 +1,168 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteProjectionProjectInput, + GetProjectionProjectInput, + ProjectionProject, + ProjectionProjectRepository, + type ProjectionProjectRepositoryShape, +} from "../Services/ProjectionProjects.ts"; +import { ProjectScript } from "@t3tools/contracts"; + +// Makes sure that the scripts are parsed from the JSON string the DB returns +const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( + Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionProjectRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionProjectRow = SqlSchema.void({ + Request: ProjectionProjectDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.projectId}, + ${row.title}, + ${row.workspaceRoot}, + ${row.defaultModel}, + ${row.scripts}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (project_id) + DO UPDATE SET + title = excluded.title, + workspace_root = excluded.workspace_root, + default_model = excluded.default_model, + scripts_json = excluded.scripts_json, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionProjectRow = SqlSchema.findOneOption({ + Request: GetProjectionProjectInput, + Result: ProjectionProjectDbRowSchema, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model AS "defaultModel", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const listProjectionProjectRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionProjectDbRowSchema, + execute: () => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model AS "defaultModel", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + ORDER BY created_at ASC, project_id ASC + `, + }); + + const deleteProjectionProjectRow = SqlSchema.void({ + Request: DeleteProjectionProjectInput, + execute: ({ projectId }) => + sql` + DELETE FROM projection_projects + WHERE project_id = ${projectId} + `, + }); + + const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => + upsertProjectionProjectRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionProjectRepository.upsert:query", + "ProjectionProjectRepository.upsert:encodeRequest", + ), + ), + ); + + const getById: ProjectionProjectRepositoryShape["getById"] = (input) => + getProjectionProjectRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionProjectRepository.getById:query", + "ProjectionProjectRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const listAll: ProjectionProjectRepositoryShape["listAll"] = () => + listProjectionProjectRows().pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionProjectRepository.listAll:query", + "ProjectionProjectRepository.listAll:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => + deleteProjectionProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listAll, + deleteById, + } satisfies ProjectionProjectRepositoryShape; +}); + +export const ProjectionProjectRepositoryLive = Layer.effect( + ProjectionProjectRepository, + makeProjectionProjectRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionState.ts b/apps/server/src/persistence/Layers/ProjectionState.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c2b8110c70fd484f625a80783fdaf97ad103fd4 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionState.ts @@ -0,0 +1,116 @@ +import { NonNegativeInt } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + ProjectionStateRepository, + type ProjectionStateRepositoryShape, + GetProjectionStateInput, + ProjectionState, +} from "../Services/ProjectionState.ts"; + +const MinLastAppliedSequenceRowSchema = Schema.Struct({ + minLastAppliedSequence: Schema.NullOr(NonNegativeInt), +}); + +const makeProjectionStateRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionStateRow = SqlSchema.void({ + Request: ProjectionState, + execute: (row) => + sql` + INSERT INTO projection_state ( + projector, + last_applied_sequence, + updated_at + ) + VALUES ( + ${row.projector}, + ${row.lastAppliedSequence}, + ${row.updatedAt} + ) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `, + }); + + const getProjectionStateRow = SqlSchema.findOneOption({ + Request: GetProjectionStateInput, + Result: ProjectionState, + execute: ({ projector }) => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + WHERE projector = ${projector} + `, + }); + + const listProjectionStateRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionState, + execute: () => + sql` + SELECT + projector, + last_applied_sequence AS "lastAppliedSequence", + updated_at AS "updatedAt" + FROM projection_state + ORDER BY projector ASC + `, + }); + + const readMinLastAppliedSequence = SqlSchema.findOne({ + Request: Schema.Void, + Result: MinLastAppliedSequenceRowSchema, + execute: () => + sql` + SELECT + MIN(last_applied_sequence) AS "minLastAppliedSequence" + FROM projection_state + `, + }); + + const upsert: ProjectionStateRepositoryShape["upsert"] = (row) => + upsertProjectionStateRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")), + ); + + const getByProjector: ProjectionStateRepositoryShape["getByProjector"] = (input) => + getProjectionStateRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.getByProjector:query")), + ); + + const listAll: ProjectionStateRepositoryShape["listAll"] = () => + listProjectionStateRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")), + ); + + const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () => + readMinLastAppliedSequence(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"), + ), + Effect.map((row) => row.minLastAppliedSequence), + ); + + return { + upsert, + getByProjector, + listAll, + minLastAppliedSequence, + } satisfies ProjectionStateRepositoryShape; +}); + +export const ProjectionStateRepositoryLive = Layer.effect( + ProjectionStateRepository, + makeProjectionStateRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts new file mode 100644 index 0000000000000000000000000000000000000000..21cf24cd88140e16b328ec216dbda7594ad1c9c5 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -0,0 +1,134 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema, Struct } from "effect"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteProjectionThreadActivitiesInput, + ListProjectionThreadActivitiesInput, + ProjectionThreadActivity, + ProjectionThreadActivityRepository, + type ProjectionThreadActivityRepositoryShape, +} from "../Services/ProjectionThreadActivities.ts"; + +const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + }), +); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProjectionThreadActivityRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadActivityRow = SqlSchema.void({ + Request: ProjectionThreadActivityDbRowSchema, + execute: (row) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES ( + ${row.activityId}, + ${row.threadId}, + ${row.turnId}, + ${row.tone}, + ${row.kind}, + ${row.summary}, + ${row.payload}, + ${row.createdAt} + ) + ON CONFLICT (activity_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + tone = excluded.tone, + kind = excluded.kind, + summary = excluded.summary, + payload_json = excluded.payload_json, + created_at = excluded.created_at + `, + }); + + const listProjectionThreadActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, activity_id ASC + `, + }); + + const deleteProjectionThreadActivityRows = SqlSchema.void({ + Request: DeleteProjectionThreadActivitiesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_activities + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadActivityRepositoryShape["upsert"] = (row) => + upsertProjectionThreadActivityRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.upsert:query", + "ProjectionThreadActivityRepository.upsert:encodeRequest", + ), + ), + ); + + const listByThreadId: ProjectionThreadActivityRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listByThreadId:query", + "ProjectionThreadActivityRepository.listByThreadId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadActivityRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadActivityRepositoryShape; +}); + +export const ProjectionThreadActivityRepositoryLive = Layer.effect( + ProjectionThreadActivityRepository, + makeProjectionThreadActivityRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts new file mode 100644 index 0000000000000000000000000000000000000000..13a671e536b4f83671f456df3172a157d114db31 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -0,0 +1,123 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema, Struct } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionThreadMessageRepository, + type ProjectionThreadMessageRepositoryShape, + DeleteProjectionThreadMessagesInput, + ListProjectionThreadMessagesInput, + ProjectionThreadMessage, +} from "../Services/ProjectionThreadMessages.ts"; + +const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + }), +); + +const makeProjectionThreadMessageRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadMessageRow = SqlSchema.void({ + Request: ProjectionThreadMessage, + execute: (row) => + sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${row.isStreaming ? 1 : 0}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = excluded.text, + is_streaming = excluded.is_streaming, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listProjectionThreadMessageRows = SqlSchema.findAll({ + Request: ListProjectionThreadMessagesInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const deleteProjectionThreadMessageRows = SqlSchema.void({ + Request: DeleteProjectionThreadMessagesInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => + upsertProjectionThreadMessageRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listByThreadId:query"), + ), + Effect.map((rows) => + rows.map((row) => ({ + ...row, + isStreaming: row.isStreaming === 1, + })), + ), + ); + + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadMessageRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadMessageRepositoryShape; +}); + +export const ProjectionThreadMessageRepositoryLive = Layer.effect( + ProjectionThreadMessageRepository, + makeProjectionThreadMessageRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a3b0806ee33a4f933d62ea4047789bffbf8b4cc --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadSessions.ts @@ -0,0 +1,111 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; + +import { + ProjectionThreadSession, + ProjectionThreadSessionRepository, + type ProjectionThreadSessionRepositoryShape, + DeleteProjectionThreadSessionInput, + GetProjectionThreadSessionInput, +} from "../Services/ProjectionThreadSessions.ts"; + +const makeProjectionThreadSessionRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadSessionRow = SqlSchema.void({ + Request: ProjectionThreadSession, + execute: (row) => + sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_session_id, + provider_thread_id, + active_turn_id, + last_error, + updated_at + ) + VALUES ( + ${row.threadId}, + ${row.status}, + ${row.providerName}, + ${row.providerSessionId}, + ${row.providerThreadId}, + ${row.activeTurnId}, + ${row.lastError}, + ${row.updatedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + status = excluded.status, + provider_name = excluded.provider_name, + provider_session_id = excluded.provider_session_id, + provider_thread_id = excluded.provider_thread_id, + active_turn_id = excluded.active_turn_id, + last_error = excluded.last_error, + updated_at = excluded.updated_at + `, + }); + + const getProjectionThreadSessionRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadSessionInput, + Result: ProjectionThreadSession, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + provider_session_id AS "providerSessionId", + provider_thread_id AS "providerThreadId", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadSessionRow = SqlSchema.void({ + Request: DeleteProjectionThreadSessionInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_sessions + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadSessionRepositoryShape["upsert"] = (row) => + upsertProjectionThreadSessionRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadSessionRepository.upsert:query")), + ); + + const getByThreadId: ProjectionThreadSessionRepositoryShape["getByThreadId"] = (input) => + getProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.getByThreadId:query"), + ), + ); + + const deleteByThreadId: ProjectionThreadSessionRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadSessionRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + getByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadSessionRepositoryShape; +}); + +export const ProjectionThreadSessionRepositoryLive = Layer.effect( + ProjectionThreadSessionRepository, + makeProjectionThreadSessionRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadTurns.ts b/apps/server/src/persistence/Layers/ProjectionThreadTurns.ts new file mode 100644 index 0000000000000000000000000000000000000000..f402ea93c64fc82ecc7d8b8e7c7bfbf711bc04fa --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadTurns.ts @@ -0,0 +1,135 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionThreadTurn, + ProjectionThreadTurnRepository, + type ProjectionThreadTurnRepositoryShape, + DeleteProjectionThreadTurnsInput, + GetProjectionThreadTurnInput, + ListProjectionThreadTurnsInput, +} from "../Services/ProjectionThreadTurns.ts"; + +const makeProjectionThreadTurnRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadTurnRow = SqlSchema.void({ + Request: ProjectionThreadTurn, + execute: (row) => + sql` + INSERT INTO projection_thread_turns ( + turn_id, + thread_id, + turn_count, + status, + user_message_id, + assistant_message_id, + started_at, + completed_at + ) + VALUES ( + ${row.turnId}, + ${row.threadId}, + ${row.turnCount}, + ${row.status}, + ${row.userMessageId}, + ${row.assistantMessageId}, + ${row.startedAt}, + ${row.completedAt} + ) + ON CONFLICT (turn_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_count = excluded.turn_count, + status = excluded.status, + user_message_id = excluded.user_message_id, + assistant_message_id = excluded.assistant_message_id, + started_at = excluded.started_at, + completed_at = excluded.completed_at + `, + }); + + const listProjectionThreadTurnRows = SqlSchema.findAll({ + Request: ListProjectionThreadTurnsInput, + Result: ProjectionThreadTurn, + execute: ({ threadId }) => + sql` + SELECT + turn_id AS "turnId", + thread_id AS "threadId", + turn_count AS "turnCount", + status, + user_message_id AS "userMessageId", + assistant_message_id AS "assistantMessageId", + started_at AS "startedAt", + completed_at AS "completedAt" + FROM projection_thread_turns + WHERE thread_id = ${threadId} + ORDER BY turn_count ASC + `, + }); + + const getProjectionThreadTurnRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadTurnInput, + Result: ProjectionThreadTurn, + execute: ({ turnId }) => + sql` + SELECT + turn_id AS "turnId", + thread_id AS "threadId", + turn_count AS "turnCount", + status, + user_message_id AS "userMessageId", + assistant_message_id AS "assistantMessageId", + started_at AS "startedAt", + completed_at AS "completedAt" + FROM projection_thread_turns + WHERE turn_id = ${turnId} + `, + }); + + const deleteProjectionThreadTurnRows = SqlSchema.void({ + Request: DeleteProjectionThreadTurnsInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_turns + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadTurnRepositoryShape["upsert"] = (row) => + upsertProjectionThreadTurnRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadTurnRepositoryShape["listByThreadId"] = (input) => + listProjectionThreadTurnRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.listByThreadId:query")), + ); + + const getByTurnId: ProjectionThreadTurnRepositoryShape["getByTurnId"] = (input) => + getProjectionThreadTurnRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadTurnRepository.getByTurnId:query")), + ); + + const deleteByThreadId: ProjectionThreadTurnRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionThreadTurnRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadTurnRepository.deleteByThreadId:query"), + ), + ); + + return { + upsert, + listByThreadId, + getByTurnId, + deleteByThreadId, + } satisfies ProjectionThreadTurnRepositoryShape; +}); + +export const ProjectionThreadTurnRepositoryLive = Layer.effect( + ProjectionThreadTurnRepository, + makeProjectionThreadTurnRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts new file mode 100644 index 0000000000000000000000000000000000000000..810deb3827dc097c573c7517bbfb4f2c60cb85b1 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -0,0 +1,143 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadInput, + GetProjectionThreadInput, + ListProjectionThreadsByProjectInput, + ProjectionThread, + ProjectionThreadRepository, + type ProjectionThreadRepositoryShape, +} from "../Services/ProjectionThreads.ts"; + +const makeProjectionThreadRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadRow = SqlSchema.void({ + Request: ProjectionThread, + execute: (row) => + sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${row.threadId}, + ${row.projectId}, + ${row.title}, + ${row.model}, + ${row.branch}, + ${row.worktreePath}, + ${row.latestTurnId}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.deletedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + project_id = excluded.project_id, + title = excluded.title, + model = excluded.model, + branch = excluded.branch, + worktree_path = excluded.worktree_path, + latest_turn_id = excluded.latest_turn_id, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getProjectionThreadRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadInput, + Result: ProjectionThread, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model, + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const listProjectionThreadRows = SqlSchema.findAll({ + Request: ListProjectionThreadsByProjectInput, + Result: ProjectionThread, + execute: ({ projectId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model, + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE project_id = ${projectId} + ORDER BY created_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadRow = SqlSchema.void({ + Request: DeleteProjectionThreadInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_threads + WHERE thread_id = ${threadId} + `, + }); + + const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) => + upsertProjectionThreadRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")), + ); + + const getById: ProjectionThreadRepositoryShape["getById"] = (input) => + getProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + ); + + const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => + listProjectionThreadRows(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + ); + + const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => + deleteProjectionThreadRow(input).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listByProjectId, + deleteById, + } satisfies ProjectionThreadRepositoryShape; +}); + +export const ProjectionThreadRepositoryLive = Layer.effect( + ProjectionThreadRepository, + makeProjectionThreadRepository, +); diff --git a/apps/server/src/persistence/Layers/Projects.test.ts b/apps/server/src/persistence/Layers/Projects.test.ts deleted file mode 100644 index 0eb5f2689fd32f73330e6f7078aa0a2c37da0e7c..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Projects.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Effect, Layer, FileSystem } from "effect"; -import { assert, it } from "@effect/vitest"; - -import { ProjectRepository } from "../Services/Projects.ts"; -import { ProjectRepositoryLive } from "./Projects.ts"; -import { SqlitePersistenceMemory } from "./Sqlite.ts"; -import { NodeServices } from "@effect/platform-node"; - -const layer = it.layer( - Layer.mergeAll(Layer.provide(ProjectRepositoryLive, SqlitePersistenceMemory), NodeServices.layer), -); - -layer("ProjectRepository", (it) => { - it.effect("stores projects and deduplicates adds by cwd", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const projectDir = yield* fs.makeTempDirectoryScoped(); - - const repository = yield* ProjectRepository; - - const created = yield* repository.add({ cwd: projectDir }); - assert.equal(created.created, true); - - const duplicate = yield* repository.add({ cwd: projectDir }); - assert.equal(duplicate.created, false); - assert.equal(duplicate.project.id, created.project.id); - - const listed = yield* repository.list(); - assert.equal(listed.length, 1); - assert.equal(listed[0]?.cwd, projectDir); - }), - ); - - it.effect("prunes missing project paths", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const existing = yield* fs.makeTempDirectoryScoped(); - const missing = yield* fs.makeTempDirectory(); - - const repository = yield* ProjectRepository; - - const existingProject = yield* repository.add({ cwd: existing }); - const missingProject = yield* repository.add({ cwd: missing }); - - assert.isTrue(existingProject.created); - assert.isTrue(missingProject.created); - - yield* fs.remove(missing, { recursive: true, force: true }); - - yield* repository.pruneMissing(); - const listed = yield* repository.list(); - assert.equal(listed.length, 1); - assert.equal(listed[0]?.cwd, existing); - }), - ); -}); diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts deleted file mode 100644 index 91ea0b695af7a91019c39e4acf0adc6687ea52f4..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/Projects.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import type { - ProjectAddInput, - ProjectAddResult, - ProjectListResult, - ProjectRecord, - ProjectScript, - ProjectUpdateScriptsInput, - ProjectUpdateScriptsResult, -} from "@t3tools/contracts"; -import { - normalizeProjectScripts, - projectAddInputSchema, - projectRecordSchema, - projectRemoveInputSchema, - projectScriptsSchema, - projectUpdateScriptsInputSchema, -} from "@t3tools/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; - -import { - PersistenceDecodeError, - ProjectNotFoundError, - ProjectPathMissingError, - ProjectValidationError, - toPersistenceDecodeCauseError, - toPersistenceSerializationError, - toPersistenceSqlError, - type ProjectRepositoryError, -} from "../Errors.ts"; -import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects.ts"; - -const ProjectRowSchema = Schema.Struct({ - id: Schema.String, - cwd: Schema.String, - name: Schema.String, - scriptsJson: Schema.String, - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const FindByIdRequestSchema = Schema.Struct({ - id: Schema.String, -}); - -const FindByCwdRequestSchema = Schema.Struct({ - cwd: Schema.String, -}); - -const InsertProjectRequestSchema = Schema.Struct({ - id: Schema.String, - cwd: Schema.String, - name: Schema.String, - scriptsJson: Schema.String, - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const UpdateScriptsRequestSchema = Schema.Struct({ - id: Schema.String, - scriptsJson: Schema.String, - updatedAt: Schema.String, -}); - -const DeleteProjectRequestSchema = Schema.Struct({ - id: Schema.String, -}); - -function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { - const cloned: ProjectScript[] = []; - for (const script of scripts) { - cloned.push({ - id: script.id, - name: script.name, - command: script.command, - icon: script.icon, - runOnWorktreeCreate: script.runOnWorktreeCreate, - }); - } - return cloned; -} - -function normalizeCwd(rawCwd: string): string { - const resolved = path.resolve(rawCwd.trim()); - const normalized = path.normalize(resolved); - if (process.platform === "win32") { - return normalized.toLowerCase(); - } - return normalized; -} - -function isDirectory(cwd: string): boolean { - try { - return fs.statSync(cwd).isDirectory(); - } catch { - return false; - } -} - -function inferProjectName(cwd: string): string { - const name = path.basename(cwd); - return name.length > 0 ? name : "project"; -} - -function parseProjectScripts( - value: unknown, - operation: string, -): Effect.Effect { - const parsed = projectScriptsSchema.safeParse(value); - if (!parsed.success) { - return Effect.fail( - new PersistenceDecodeError({ - operation, - issue: parsed.error.message, - cause: (parsed.error as { cause?: unknown }).cause, - }), - ); - } - return Effect.succeed(normalizeProjectScripts(parsed.data)); -} - -function decodeScriptsJson( - raw: string, - operation: string, -): Effect.Effect { - return Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: toPersistenceDecodeCauseError(`${operation}:parseScriptsJson`), - }).pipe(Effect.flatMap((value) => parseProjectScripts(value, `${operation}:decodeScripts`))); -} - -function rowToProjectRecord( - row: Schema.Schema.Type, - operation: string, -): Effect.Effect { - return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe( - Effect.flatMap((scripts) => { - const parsed = projectRecordSchema.safeParse({ - id: row.id, - cwd: row.cwd, - name: row.name, - scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - if (!parsed.success) { - return Effect.fail( - new PersistenceDecodeError({ - operation: `${operation}:projectRecord`, - issue: parsed.error.message, - cause: (parsed.error as { cause?: unknown }).cause, - }), - ); - } - return Effect.succeed(parsed.data); - }), - ); -} - -function decodeAddInput(rawInput: ProjectAddInput) { - const parsed = projectAddInputSchema.safeParse(rawInput); - if (!parsed.success) { - return Effect.fail( - new ProjectValidationError({ - operation: "ProjectRepository.add", - issue: parsed.error.message, - cause: (parsed.error as { cause?: unknown }).cause, - }), - ); - } - return Effect.succeed(parsed.data); -} - -function decodeUpdateScriptsInput(rawInput: ProjectUpdateScriptsInput) { - const parsed = projectUpdateScriptsInputSchema.safeParse(rawInput); - if (!parsed.success) { - return Effect.fail( - new ProjectValidationError({ - operation: "ProjectRepository.updateScripts", - issue: parsed.error.message, - cause: (parsed.error as { cause?: unknown }).cause, - }), - ); - } - return Effect.succeed(parsed.data); -} - -function decodeRemoveInput(rawInput: unknown) { - const parsed = projectRemoveInputSchema.safeParse(rawInput); - if (!parsed.success) { - return Effect.fail( - new ProjectValidationError({ - operation: "ProjectRepository.remove", - issue: parsed.error.message, - cause: (parsed.error as { cause?: unknown }).cause, - }), - ); - } - return Effect.succeed(parsed.data); -} - -const makeRepository = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - const listProjectRows = SqlSchema.findAll({ - Request: Schema.Void, - Result: ProjectRowSchema, - execute: () => - sql` - SELECT - id, - cwd, - name, - scripts_json AS "scriptsJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projects - ORDER BY created_at DESC - `, - }); - - const findProjectById = SqlSchema.findOneOption({ - Request: FindByIdRequestSchema, - Result: ProjectRowSchema, - execute: ({ id }) => - sql` - SELECT - id, - cwd, - name, - scripts_json AS "scriptsJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projects - WHERE id = ${id} - `, - }); - - const findProjectByCwd = SqlSchema.findOneOption({ - Request: FindByCwdRequestSchema, - Result: ProjectRowSchema, - execute: ({ cwd }) => - sql` - SELECT - id, - cwd, - name, - scripts_json AS "scriptsJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM projects - WHERE cwd = ${cwd} - `, - }); - - const insertProject = SqlSchema.findOne({ - Request: InsertProjectRequestSchema, - Result: ProjectRowSchema, - execute: (request) => - sql` - INSERT INTO projects ( - id, - cwd, - name, - scripts_json, - created_at, - updated_at - ) - VALUES ( - ${request.id}, - ${request.cwd}, - ${request.name}, - ${request.scriptsJson}, - ${request.createdAt}, - ${request.updatedAt} - ) - RETURNING - id, - cwd, - name, - scripts_json AS "scriptsJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - `, - }); - - const updateProjectScripts = SqlSchema.findOne({ - Request: UpdateScriptsRequestSchema, - Result: ProjectRowSchema, - execute: (request) => - sql` - UPDATE projects - SET - scripts_json = ${request.scriptsJson}, - updated_at = ${request.updatedAt} - WHERE id = ${request.id} - RETURNING - id, - cwd, - name, - scripts_json AS "scriptsJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - `, - }); - - const deleteProject = SqlSchema.void({ - Request: DeleteProjectRequestSchema, - execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`, - }); - - const list: ProjectRepositoryShape["list"] = () => - listProjectRows(undefined).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.list:query")), - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => - rowToProjectRecord(row, "ProjectRepository.list:rowToProject"), - ), - ), - Effect.map( - (projects): ProjectListResult => - projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), - ), - ); - - const add: ProjectRepositoryShape["add"] = (rawInput) => - Effect.gen(function* () { - const input = yield* decodeAddInput(rawInput); - const normalizedCwd = normalizeCwd(input.cwd); - if (!isDirectory(normalizedCwd)) { - return yield* new ProjectPathMissingError({ cwd: normalizedCwd }); - } - - const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.add:findByCwd")), - ); - if (Option.isSome(existing)) { - const project = yield* rowToProjectRecord(existing.value, "ProjectRepository.add:existing"); - const result: ProjectAddResult = { - project: { - ...project, - scripts: cloneScripts(project.scripts), - }, - created: false, - }; - return result; - } - - const now = new Date().toISOString(); - const row = yield* insertProject({ - id: randomUUID(), - cwd: normalizedCwd, - name: inferProjectName(normalizedCwd), - scriptsJson: "[]", - createdAt: now, - updatedAt: now, - }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.add:insert"))); - - const created = yield* rowToProjectRecord(row, "ProjectRepository.add:created"); - const result: ProjectAddResult = { - project: { - ...created, - scripts: cloneScripts(created.scripts), - }, - created: true, - }; - return result; - }); - - const remove: ProjectRepositoryShape["remove"] = (rawInput) => - Effect.gen(function* () { - const input = yield* decodeRemoveInput(rawInput); - const existing = yield* findProjectById({ id: input.id }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), - ); - if (Option.isNone(existing)) { - return yield* new ProjectNotFoundError({ projectId: input.id }); - } - - yield* deleteProject({ id: input.id }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:delete")), - ); - }); - - const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => - Effect.gen(function* () { - const input = yield* decodeUpdateScriptsInput(rawInput); - const existing = yield* findProjectById({ id: input.id }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), - ); - if (Option.isNone(existing)) { - return yield* new ProjectNotFoundError({ projectId: input.id }); - } - - const nextScripts = yield* parseProjectScripts( - input.scripts, - "ProjectRepository.updateScripts:decodeScripts", - ); - const scriptsJson = yield* Effect.try({ - try: () => JSON.stringify(nextScripts), - catch: toPersistenceSerializationError("ProjectRepository.updateScripts:encodeScripts"), - }); - const nextUpdatedAt = new Date().toISOString(); - const updatedRow = yield* updateProjectScripts({ - id: input.id, - scriptsJson, - updatedAt: nextUpdatedAt, - }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:update"))); - - const updated = yield* rowToProjectRecord( - updatedRow, - "ProjectRepository.updateScripts:updated", - ); - const result: ProjectUpdateScriptsResult = { - project: { - ...updated, - scripts: cloneScripts(updated.scripts), - }, - }; - return result; - }); - - const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => - listProjectRows(undefined).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:query")), - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => { - const normalizedCwd = normalizeCwd(row.cwd); - if (isDirectory(normalizedCwd)) { - return Effect.void; - } - return deleteProject({ id: row.id }).pipe( - Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:delete")), - ); - }), - ), - Effect.asVoid, - ); - - return { - list, - add, - remove, - updateScripts, - pruneMissing, - } satisfies ProjectRepositoryShape; -}); - -export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository); diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..85c9bc1caaad320afe09f84892c220d48b45df5c --- /dev/null +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -0,0 +1,204 @@ +import { ProviderSessionId } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProviderSessionRuntimeRepositoryError, +} from "../Errors.ts"; +import { + ProviderSessionRuntime, + ProviderSessionRuntimeRepository, + type ProviderSessionRuntimeRepositoryShape, +} from "../Services/ProviderSessionRuntime.ts"; + +const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( + Struct.assign({ + resumeCursor: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + runtimePayload: Schema.NullOr(Schema.fromJsonString(Schema.Unknown)), + }), +); + +const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); + +const GetRuntimeRequestSchema = Schema.Struct({ + providerSessionId: ProviderSessionId, +}); + +const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): ProviderSessionRuntimeRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeProviderSessionRuntimeRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + provider_session_id, + thread_id, + provider_name, + adapter_key, + provider_thread_id, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.providerSessionId}, + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.adapterKey}, + ${runtime.providerThreadId}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + ${runtime.runtimePayload} + ) + ON CONFLICT (provider_session_id) + DO UPDATE SET + thread_id = excluded.thread_id, + provider_name = excluded.provider_name, + adapter_key = excluded.adapter_key, + provider_thread_id = excluded.provider_thread_id, + status = excluded.status, + last_seen_at = excluded.last_seen_at, + resume_cursor_json = excluded.resume_cursor_json, + runtime_payload_json = excluded.runtime_payload_json + `, + }); + + const getRuntimeRowBySessionId = SqlSchema.findOneOption({ + Request: GetRuntimeRequestSchema, + Result: ProviderSessionRuntimeDbRowSchema, + execute: ({ providerSessionId }) => + sql` + SELECT + provider_session_id AS "providerSessionId", + thread_id AS "threadId", + provider_name AS "providerName", + adapter_key AS "adapterKey", + provider_thread_id AS "providerThreadId", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + WHERE provider_session_id = ${providerSessionId} + `, + }); + + const listRuntimeRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProviderSessionRuntimeDbRowSchema, + execute: () => + sql` + SELECT + provider_session_id AS "providerSessionId", + thread_id AS "threadId", + provider_name AS "providerName", + adapter_key AS "adapterKey", + provider_thread_id AS "providerThreadId", + status, + last_seen_at AS "lastSeenAt", + resume_cursor_json AS "resumeCursor", + runtime_payload_json AS "runtimePayload" + FROM provider_session_runtime + ORDER BY last_seen_at ASC, provider_session_id ASC + `, + }); + + const deleteRuntimeBySessionId = SqlSchema.void({ + Request: DeleteRuntimeRequestSchema, + execute: ({ providerSessionId }) => + sql` + DELETE FROM provider_session_runtime + WHERE provider_session_id = ${providerSessionId} + `, + }); + + const upsert: ProviderSessionRuntimeRepositoryShape["upsert"] = (runtime) => + upsertRuntimeRow(runtime).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.upsert:query", + "ProviderSessionRuntimeRepository.upsert:encodeRequest", + ), + ), + ); + + const getBySessionId: ProviderSessionRuntimeRepositoryShape["getBySessionId"] = (input) => + getRuntimeRowBySessionId(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.getBySessionId:query", + "ProviderSessionRuntimeRepository.getBySessionId:decodeRow", + ), + ), + Effect.flatMap((runtimeRowOption) => + Option.match(runtimeRowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeRuntime(row).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProviderSessionRuntimeRepository.getBySessionId:rowToRuntime", + ), + ), + Effect.map((runtime) => Option.some(runtime)), + ), + }), + ), + ); + + const list: ProviderSessionRuntimeRepositoryShape["list"] = () => + listRuntimeRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.list:query", + "ProviderSessionRuntimeRepository.list:decodeRows", + ), + ), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeRuntime(row).pipe( + Effect.mapError( + toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + ), + ), + { concurrency: "unbounded" }, + ), + ), + ); + + const deleteBySessionId: ProviderSessionRuntimeRepositoryShape["deleteBySessionId"] = (input) => + deleteRuntimeBySessionId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteBySessionId:query"), + ), + ); + + return { + upsert, + getBySessionId, + list, + deleteBySessionId, + } satisfies ProviderSessionRuntimeRepositoryShape; +}); + +export const ProviderSessionRuntimeRepositoryLive = Layer.effect( + ProviderSessionRuntimeRepository, + makeProviderSessionRuntimeRepository, +); diff --git a/apps/server/src/persistence/Layers/ProviderSessions.test.ts b/apps/server/src/persistence/Layers/ProviderSessions.test.ts deleted file mode 100644 index 60dbe0974c55bc01be2b63d37eb3fab3a3d36657..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Layers/ProviderSessions.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { NodeServices } from "@effect/platform-node"; -import { it, assert } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./Sqlite.ts"; -import { ProviderSessionRepository } from "../Services/ProviderSessions.ts"; -import { ProviderSessionRepositoryLive } from "./ProviderSessions.ts"; - -function makeRepositoryLayer( - persistenceLayer: Layer.Layer, -) { - return ProviderSessionRepositoryLive.pipe(Layer.provide(persistenceLayer)); -} - -it.effect("ProviderSessionRepositoryLive upserts, lists, gets, and deletes sessions", () => - Effect.gen(function* () { - const repository = yield* ProviderSessionRepository; - - yield* repository.upsertSession({ - sessionId: "sess-1", - provider: "codex", - threadId: "thread-1", - }); - yield* repository.upsertSession({ - sessionId: "sess-2", - provider: "claudeCode", - }); - - const all = yield* repository.listSessions(); - assert.equal(all.length, 2); - assert.equal(all[0]?.sessionId, "sess-1"); - assert.equal(all[1]?.sessionId, "sess-2"); - - const existing = yield* repository.getSession({ sessionId: "sess-1" }); - assert.equal(Option.isSome(existing), true); - if (Option.isSome(existing)) { - assert.equal(existing.value.provider, "codex"); - assert.equal(existing.value.threadId, "thread-1"); - } - - yield* repository.deleteSession({ sessionId: "sess-2" }); - const afterDelete = yield* repository.listSessions(); - assert.deepEqual(afterDelete.map((entry) => entry.sessionId), ["sess-1"]); - }).pipe(Effect.provide(makeRepositoryLayer(SqlitePersistenceMemory))), -); - -it.effect("ProviderSessionRepositoryLive persists sessions across layer restarts", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-sessions-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); - - const repositoryLayer = makeRepositoryLayer(makeSqlitePersistenceLive(dbPath)); - - yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRepository; - yield* repository.upsertSession({ - sessionId: "sess-persisted", - provider: "codex", - threadId: "thread-persisted", - }); - }).pipe(Effect.provide(repositoryLayer)); - - const persisted = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRepository; - return yield* repository.getSession({ sessionId: "sess-persisted" }); - }).pipe(Effect.provide(repositoryLayer)); - - assert.equal(Option.isSome(persisted), true); - if (Option.isSome(persisted)) { - assert.equal(persisted.value.provider, "codex"); - assert.equal(persisted.value.threadId, "thread-persisted"); - } - - fs.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts index e1a1510a4d50d9105362e4f41eba1ea532b0d9b8..c31a6a53fa5f9dad2fce2f876c0883ba1a777031 100644 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ b/apps/server/src/persistence/Layers/ProviderSessions.ts @@ -1,3 +1,4 @@ +import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema } from "effect"; @@ -12,26 +13,24 @@ import { type ProviderSessionRepositoryShape, } from "../Services/ProviderSessions.ts"; -const NonEmptyString = Schema.NonEmptyString; - -const ProviderKindSchema = Schema.Literals(["codex", "claudeCode"]); +const ProviderKind = Schema.Literals(["codex", "claudeCode"]); const ProviderSessionRowSchema = Schema.Struct({ - sessionId: Schema.String, - provider: ProviderKindSchema, - threadId: Schema.NullOr(Schema.String), + sessionId: ProviderSessionId, + provider: ProviderKind, + threadId: Schema.NullOr(ThreadId), createdAt: Schema.String, updatedAt: Schema.String, }); const SessionIdRequestSchema = Schema.Struct({ - sessionId: NonEmptyString, + sessionId: ProviderSessionId, }); const UpsertSessionRequestSchema = Schema.Struct({ - sessionId: NonEmptyString, - provider: ProviderKindSchema, - threadId: Schema.NullOr(NonEmptyString), + sessionId: ProviderSessionId, + provider: ProviderKind, + threadId: Schema.NullOr(ThreadId), }); function errorMessage(cause: unknown, fallback: string): string { @@ -163,9 +162,9 @@ const makeProviderSessionRepository = Effect.gen(function* () { const parsed = yield* decodeInput( UpsertSessionRequestSchema, { - sessionId: input.sessionId.trim(), + sessionId: input.sessionId, provider: input.provider, - threadId: input.threadId?.trim() || null, + threadId: input.threadId ?? null, }, "ProviderSessionRepository.upsertSession", ); @@ -182,7 +181,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { Effect.gen(function* () { const parsed = yield* decodeInput( SessionIdRequestSchema, - { sessionId: input.sessionId.trim() }, + { sessionId: input.sessionId }, "ProviderSessionRepository.getSession", ); @@ -207,7 +206,7 @@ const makeProviderSessionRepository = Effect.gen(function* () { Effect.gen(function* () { const parsed = yield* decodeInput( SessionIdRequestSchema, - { sessionId: input.sessionId.trim() }, + { sessionId: input.sessionId }, "ProviderSessionRepository.deleteSession", ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 05fb801eaa138c3dd883150bc3cfd5d97f7d02fa..3779c16d4f130bc3b3c62e8330b9b4b943e40a2d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -13,9 +13,10 @@ import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; -import Migration0002 from "./Migrations/002_Projects.ts"; -import Migration0003 from "./Migrations/003_Checkpoints.ts"; -import Migration0004 from "./Migrations/004_ProviderSessions.ts"; +import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; +import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; +import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; +import Migration0005 from "./Migrations/005_Projections.ts"; /** * Migration loader with all migrations defined inline. @@ -29,9 +30,10 @@ import Migration0004 from "./Migrations/004_ProviderSessions.ts"; */ const loader = Migrator.fromRecord({ "1_OrchestrationEvents": Migration0001, - "2_Projects": Migration0002, - "3_CheckpointCatalog": Migration0003, - "4_ProviderSessions": Migration0004, + "2_OrchestrationCommandReceipts": Migration0002, + "3_CheckpointDiffBlobs": Migration0003, + "4_ProviderSessionRuntime": Migration0004, + "5_Projections": Migration0005, }); /** diff --git a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts index 5e2ac44ee7d84be9775b8094b5fb36db4928fc4f..b8e0cd43fa0100f0990d929b4e0a3841beed2132 100644 --- a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts +++ b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts @@ -8,17 +8,37 @@ export default Effect.gen(function* () { CREATE TABLE IF NOT EXISTS orchestration_events ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, + aggregate_kind TEXT NOT NULL, + stream_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, event_type TEXT NOT NULL, - aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, occurred_at TEXT NOT NULL, command_id TEXT, - payload_json TEXT NOT NULL + causation_event_id TEXT, + correlation_id TEXT, + actor_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + metadata_json TEXT NOT NULL ) `; yield* sql` - CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate - ON orchestration_events(aggregate_type, aggregate_id, sequence) + CREATE UNIQUE INDEX IF NOT EXISTS idx_orch_events_stream_version + ON orchestration_events(aggregate_kind, stream_id, stream_version) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_stream_sequence + ON orchestration_events(aggregate_kind, stream_id, sequence) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_command_id + ON orchestration_events(command_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_correlation_id + ON orchestration_events(correlation_id) `; }); diff --git a/apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts new file mode 100644 index 0000000000000000000000000000000000000000..a81f309fbf90069cfc6818e5f51a9583123fc4c0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/002_OrchestrationCommandReceipts.ts @@ -0,0 +1,28 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS orchestration_command_receipts ( + command_id TEXT PRIMARY KEY, + aggregate_kind TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + accepted_at TEXT NOT NULL, + result_sequence INTEGER NOT NULL, + status TEXT NOT NULL, + error TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_aggregate + ON orchestration_command_receipts(aggregate_kind, aggregate_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_command_receipts_sequence + ON orchestration_command_receipts(result_sequence) + `; +}); diff --git a/apps/server/src/persistence/Migrations/002_Projects.ts b/apps/server/src/persistence/Migrations/002_Projects.ts deleted file mode 100644 index f061da2c9a48efca8b6430607bc8335d4d752fe9..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Migrations/002_Projects.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as Effect from "effect/Effect"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - cwd TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - scripts_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_projects_created_at - ON projects(created_at DESC) - `; -}); diff --git a/apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts b/apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a5d32a027eec8d19c6226e7259b0b4df38e7c79 --- /dev/null +++ b/apps/server/src/persistence/Migrations/003_CheckpointDiffBlobs.ts @@ -0,0 +1,22 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS checkpoint_diff_blobs ( + thread_id TEXT NOT NULL, + from_turn_count INTEGER NOT NULL, + to_turn_count INTEGER NOT NULL, + diff TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (thread_id, from_turn_count, to_turn_count) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_checkpoint_diff_blobs_thread_to_turn + ON checkpoint_diff_blobs(thread_id, to_turn_count) + `; +}); diff --git a/apps/server/src/persistence/Migrations/003_Checkpoints.ts b/apps/server/src/persistence/Migrations/003_Checkpoints.ts deleted file mode 100644 index 661bcb5ca158e97f5e876dece992b555002ea6da..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Migrations/003_Checkpoints.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as Effect from "effect/Effect"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS provider_checkpoints ( - provider_session_id TEXT, - thread_id TEXT NOT NULL, - checkpoint_id TEXT NOT NULL, - checkpoint_ref TEXT NOT NULL, - turn_count INTEGER NOT NULL, - message_count INTEGER NOT NULL, - label TEXT NOT NULL, - preview TEXT, - created_at TEXT NOT NULL, - PRIMARY KEY (thread_id, turn_count) - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_thread_turn - ON provider_checkpoints(thread_id, turn_count ASC) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session - ON provider_checkpoints(provider_session_id) - `; -}); diff --git a/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6adda0a870ff89be769a87fa3c6701a9bfc674c --- /dev/null +++ b/apps/server/src/persistence/Migrations/004_ProviderSessionRuntime.ts @@ -0,0 +1,35 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS provider_session_runtime ( + provider_session_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + provider_name TEXT NOT NULL, + adapter_key TEXT NOT NULL, + provider_thread_id TEXT, + status TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + resume_cursor_json TEXT, + runtime_payload_json TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_thread + ON provider_session_runtime(thread_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_status + ON provider_session_runtime(status) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_session_runtime_provider + ON provider_session_runtime(provider_name) + `; +}); diff --git a/apps/server/src/persistence/Migrations/004_ProviderSessions.ts b/apps/server/src/persistence/Migrations/004_ProviderSessions.ts deleted file mode 100644 index 72e308eba9af4a769505f996ff48f3571a7d3f74..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Migrations/004_ProviderSessions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as Effect from "effect/Effect"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS provider_sessions ( - session_id TEXT PRIMARY KEY, - provider TEXT NOT NULL, - thread_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `; - - yield* sql` - CREATE INDEX IF NOT EXISTS idx_provider_sessions_provider - ON provider_sessions(provider) - `; -}); diff --git a/apps/server/src/persistence/Migrations/005_Projections.ts b/apps/server/src/persistence/Migrations/005_Projections.ts new file mode 100644 index 0000000000000000000000000000000000000000..3231b3eafc5b024e5dfa276f3960d8e775522ae2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/005_Projections.ts @@ -0,0 +1,161 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_projects ( + project_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + workspace_root TEXT NOT NULL, + default_model TEXT, + scripts_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + model TEXT NOT NULL, + branch TEXT, + worktree_path TEXT, + latest_turn_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_messages ( + message_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + role TEXT NOT NULL, + text TEXT NOT NULL, + is_streaming INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_activities ( + activity_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + tone TEXT NOT NULL, + kind TEXT NOT NULL, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_sessions ( + thread_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + provider_name TEXT, + provider_session_id TEXT, + provider_thread_id TEXT, + active_turn_id TEXT, + last_error TEXT, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_turns ( + turn_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_count INTEGER NOT NULL, + status TEXT NOT NULL, + user_message_id TEXT, + assistant_message_id TEXT, + started_at TEXT NOT NULL, + completed_at TEXT, + UNIQUE (thread_id, turn_count) + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_checkpoints ( + thread_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + checkpoint_turn_count INTEGER NOT NULL, + checkpoint_ref TEXT NOT NULL, + status TEXT NOT NULL, + files_json TEXT NOT NULL, + assistant_message_id TEXT, + completed_at TEXT NOT NULL, + UNIQUE (thread_id, checkpoint_turn_count) + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_pending_approvals ( + request_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + status TEXT NOT NULL, + decision TEXT, + created_at TEXT NOT NULL, + resolved_at TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_state ( + projector TEXT PRIMARY KEY, + last_applied_sequence INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_projects_updated_at + ON projection_projects(updated_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_project_id + ON projection_threads(project_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created + ON projection_thread_messages(thread_id, created_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_created + ON projection_thread_activities(thread_id, created_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_sessions_provider_session + ON projection_thread_sessions(provider_session_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_turns_thread_turn_count + ON projection_thread_turns(thread_id, turn_count) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_checkpoints_thread_completed + ON projection_checkpoints(thread_id, completed_at) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_pending_approvals_thread_status + ON projection_pending_approvals(thread_id, status) + `; +}); diff --git a/apps/server/src/persistence/Services/CheckpointDiffBlobs.ts b/apps/server/src/persistence/Services/CheckpointDiffBlobs.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca0875f6d4a49b954fe2ec0d8c8fe834cd065f8c --- /dev/null +++ b/apps/server/src/persistence/Services/CheckpointDiffBlobs.ts @@ -0,0 +1,36 @@ +import { IsoDateTime, NonNegativeInt, ThreadId } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointDiffBlobRepositoryError } from "../Errors.ts"; + +export const CheckpointDiffBlob = Schema.Struct({ + threadId: ThreadId, + fromTurnCount: NonNegativeInt, + toTurnCount: NonNegativeInt, + diff: Schema.String, + createdAt: IsoDateTime, +}); +export type CheckpointDiffBlob = typeof CheckpointDiffBlob.Type; + +export const GetCheckpointDiffBlobInput = Schema.Struct({ + threadId: ThreadId, + fromTurnCount: NonNegativeInt, + toTurnCount: NonNegativeInt, +}); +export type GetCheckpointDiffBlobInput = typeof GetCheckpointDiffBlobInput.Type; + +export interface CheckpointDiffBlobRepositoryShape { + readonly upsert: ( + blob: CheckpointDiffBlob, + ) => Effect.Effect; + + readonly get: ( + input: GetCheckpointDiffBlobInput, + ) => Effect.Effect, CheckpointDiffBlobRepositoryError>; +} + +export class CheckpointDiffBlobRepository extends ServiceMap.Service< + CheckpointDiffBlobRepository, + CheckpointDiffBlobRepositoryShape +>()("persistence/CheckpointDiffBlobRepository") {} diff --git a/apps/server/src/persistence/Services/Checkpoints.ts b/apps/server/src/persistence/Services/Checkpoints.ts deleted file mode 100644 index f92bef6c16203c9252a11cae5addffdf0065568d..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Services/Checkpoints.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * CheckpointRepository - Repository interface for checkpoint metadata persistence. - * - * Stores and queries checkpoint metadata and checkpoint Git refs keyed by - * thread + turn count. It does not read/write Git refs itself and it - * does not coordinate provider rollback workflows. - * - * @module CheckpointRepository - */ -import type { ProviderCheckpoint } from "@t3tools/contracts"; -import { Option, ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { CheckpointRepositoryError } from "../Errors.ts"; - -export interface UpsertCheckpointInput { - readonly providerSessionId?: string; - readonly threadId: string; - readonly checkpointId: string; - readonly checkpointRef: string; - readonly turnCount: number; - readonly messageCount: number; - readonly label: string; - readonly preview?: string; - readonly createdAt: string; -} - -export interface CheckpointRepositoryEntry extends ProviderCheckpoint { - readonly threadId: string; - readonly checkpointRef: string; - readonly createdAt: string; -} - -export interface ListThreadCheckpointsInput { - readonly threadId: string; -} - -export interface GetCheckpointInput { - readonly threadId: string; - readonly turnCount: number; -} - -export interface DeleteAfterTurnInput { - readonly threadId: string; - readonly maxTurnCount: number; -} - -export interface DeleteAllForThreadInput { - readonly threadId: string; -} - -export interface CheckpointRepositoryShape { - /** - * Insert or update one checkpoint metadata row. - */ - readonly upsertCheckpoint: ( - input: UpsertCheckpointInput, - ) => Effect.Effect; - - /** - * List user-facing checkpoints for a thread. - */ - readonly listCheckpoints: ( - input: ListThreadCheckpointsInput, - ) => Effect.Effect, CheckpointRepositoryError>; - - /** - * Read one checkpoint record (including checkpoint ref) by turn count. - */ - readonly getCheckpoint: ( - input: GetCheckpointInput, - ) => Effect.Effect, CheckpointRepositoryError>; - - /** - * Delete checkpoint metadata newer than the provided turn count. - */ - readonly deleteAfterTurn: ( - input: DeleteAfterTurnInput, - ) => Effect.Effect; - - /** - * Delete all checkpoint metadata for a thread. - */ - readonly deleteAllForThread: ( - input: DeleteAllForThreadInput, - ) => Effect.Effect; -} - -/** - * CheckpointRepository - Service tag for checkpoint metadata persistence. - */ -export class CheckpointRepository extends ServiceMap.Service< - CheckpointRepository, - CheckpointRepositoryShape ->()("persistence/CheckpointRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts new file mode 100644 index 0000000000000000000000000000000000000000..95fbbf493495646b32e2342c4e59c4e612ca145f --- /dev/null +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -0,0 +1,47 @@ +import { + CommandId, + IsoDateTime, + NonNegativeInt, + OrchestrationAggregateKind, + OrchestrationCommandReceiptStatus, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; + +export const OrchestrationCommandReceipt = Schema.Struct({ + commandId: CommandId, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + acceptedAt: IsoDateTime, + resultSequence: NonNegativeInt, + status: OrchestrationCommandReceiptStatus, + error: Schema.NullOr(Schema.String), +}); +export type OrchestrationCommandReceipt = typeof OrchestrationCommandReceipt.Type; + +export const GetByCommandIdInput = Schema.Struct({ + commandId: CommandId, +}); +export type GetByCommandIdInput = typeof GetByCommandIdInput.Type; + +export interface OrchestrationCommandReceiptRepositoryShape { + readonly upsert: ( + receipt: OrchestrationCommandReceipt, + ) => Effect.Effect; + + readonly getByCommandId: ( + input: GetByCommandIdInput, + ) => Effect.Effect< + Option.Option, + OrchestrationCommandReceiptRepositoryError + >; +} + +export class OrchestrationCommandReceiptRepository extends ServiceMap.Service< + OrchestrationCommandReceiptRepository, + OrchestrationCommandReceiptRepositoryShape +>()("persistence/OrchestrationCommandReceiptRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 568866e55ef578f53b3e948d5209f42dde92251b..2ee8e8486ab6602338e188d0f1f3cb029bb4a1ef 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -9,7 +9,7 @@ * * @module OrchestrationEventStore */ -import type { OrchestrationEvent } from "@t3tools/contracts"; +import { OrchestrationEvent } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc5a93eb847b7f53a8848c14a418fdf52fe5d3d8 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts @@ -0,0 +1,63 @@ +import { + CheckpointRef, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationCheckpointFile, + OrchestrationCheckpointStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Option, ServiceMap, Schema } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionCheckpoint = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, +}); +export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type; + +export const ListByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListByThreadIdInput = typeof ListByThreadIdInput.Type; + +export const GetByThreadAndTurnCountInput = Schema.Struct({ + threadId: ThreadId, + checkpointTurnCount: NonNegativeInt, +}); +export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type; + +export const DeleteByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type; + +export interface ProjectionCheckpointRepositoryShape { + readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect; + + readonly listByThreadId: ( + input: ListByThreadIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly getByThreadAndTurnCount: ( + input: GetByThreadAndTurnCountInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByThreadId: ( + input: DeleteByThreadIdInput, + ) => Effect.Effect; +} + +export class ProjectionCheckpointRepository extends ServiceMap.Service< + ProjectionCheckpointRepository, + ProjectionCheckpointRepositoryShape +>()("persistence/ProjectionCheckpointRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d4083625d9177727734ff8e87f206b26ecb513e --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -0,0 +1,61 @@ +import { + ApprovalRequestId, + IsoDateTime, + ProjectionPendingApprovalDecision, + ProjectionPendingApprovalStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionPendingApproval = Schema.Struct({ + requestId: ApprovalRequestId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + status: ProjectionPendingApprovalStatus, + decision: ProjectionPendingApprovalDecision, + createdAt: IsoDateTime, + resolvedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionPendingApproval = typeof ProjectionPendingApproval.Type; + +export const ListProjectionPendingApprovalsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionPendingApprovalsInput = typeof ListProjectionPendingApprovalsInput.Type; + +export const GetProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type; + +export const DeleteProjectionPendingApprovalInput = Schema.Struct({ + requestId: ApprovalRequestId, +}); +export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type; + +export interface ProjectionPendingApprovalRepositoryShape { + readonly upsert: ( + row: ProjectionPendingApproval, + ) => Effect.Effect; + + readonly listByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly getByRequestId: ( + input: GetProjectionPendingApprovalInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByRequestId: ( + input: DeleteProjectionPendingApprovalInput, + ) => Effect.Effect; +} + +export class ProjectionPendingApprovalRepository extends ServiceMap.Service< + ProjectionPendingApprovalRepository, + ProjectionPendingApprovalRepositoryShape +>()("persistence/ProjectionPendingApprovalRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb51dc1c034b8a635ef4e3733525554b9b4e6d4e --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -0,0 +1,49 @@ +import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionProject = Schema.Struct({ + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModel: Schema.NullOr(Schema.String), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionProject = typeof ProjectionProject.Type; + +export const GetProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type; + +export const DeleteProjectionProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type; + +export interface ProjectionProjectRepositoryShape { + readonly upsert: (row: ProjectionProject) => Effect.Effect; + + readonly getById: ( + input: GetProjectionProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + + readonly deleteById: ( + input: DeleteProjectionProjectInput, + ) => Effect.Effect; +} + +export class ProjectionProjectRepository extends ServiceMap.Service< + ProjectionProjectRepository, + ProjectionProjectRepositoryShape +>()("persistence/ProjectionProjectRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts new file mode 100644 index 0000000000000000000000000000000000000000..c079f941b525e3408c9ee0f1f4a171b248a73948 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionState.ts @@ -0,0 +1,34 @@ +import { IsoDateTime, NonNegativeInt } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionState = Schema.Struct({ + projector: Schema.String, + lastAppliedSequence: NonNegativeInt, + updatedAt: IsoDateTime, +}); +export type ProjectionState = typeof ProjectionState.Type; + +export const GetProjectionStateInput = Schema.Struct({ + projector: Schema.String, +}); +export type GetProjectionStateInput = typeof GetProjectionStateInput.Type; + +export interface ProjectionStateRepositoryShape { + readonly upsert: (row: ProjectionState) => Effect.Effect; + + readonly getByProjector: ( + input: GetProjectionStateInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly listAll: () => Effect.Effect, ProjectionRepositoryError>; + + readonly minLastAppliedSequence: () => Effect.Effect; +} + +export class ProjectionStateRepository extends ServiceMap.Service< + ProjectionStateRepository, + ProjectionStateRepositoryShape +>()("persistence/ProjectionStateRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts new file mode 100644 index 0000000000000000000000000000000000000000..3284805c92082142f914ec740986fe1814c97481 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -0,0 +1,53 @@ +import { + EventId, + IsoDateTime, + OrchestrationThreadActivityTone, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadActivity = Schema.Struct({ + activityId: EventId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + tone: OrchestrationThreadActivityTone, + kind: Schema.String, + summary: Schema.String, + payload: Schema.Unknown, + createdAt: IsoDateTime, +}); +export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; + +export const ListProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; + +export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadActivitiesInput = + typeof DeleteProjectionThreadActivitiesInput.Type; + +export interface ProjectionThreadActivityRepositoryShape { + readonly upsert: ( + row: ProjectionThreadActivity, + ) => Effect.Effect; + + readonly listByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByThreadId: ( + input: DeleteProjectionThreadActivitiesInput, + ) => Effect.Effect; +} + +export class ProjectionThreadActivityRepository extends ServiceMap.Service< + ProjectionThreadActivityRepository, + ProjectionThreadActivityRepositoryShape +>()("persistence/ProjectionThreadActivityRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts new file mode 100644 index 0000000000000000000000000000000000000000..79bdb6237e51a8feb7746aa0282e5be1dd6f032c --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -0,0 +1,52 @@ +import { + OrchestrationMessageRole, + MessageId, + ThreadId, + TurnId, + IsoDateTime, +} from "@t3tools/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadMessage = Schema.Struct({ + messageId: MessageId, + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), + role: OrchestrationMessageRole, + text: Schema.String, + isStreaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; + +export const ListProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadMessagesInput = typeof ListProjectionThreadMessagesInput.Type; + +export const DeleteProjectionThreadMessagesInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; + +export interface ProjectionThreadMessageRepositoryShape { + readonly upsert: ( + message: ProjectionThreadMessage, + ) => Effect.Effect; + + readonly listByThreadId: ( + input: ListProjectionThreadMessagesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByThreadId: ( + input: DeleteProjectionThreadMessagesInput, + ) => Effect.Effect; +} + +export class ProjectionThreadMessageRepository extends ServiceMap.Service< + ProjectionThreadMessageRepository, + ProjectionThreadMessageRepositoryShape +>()("persistence/ProjectionThreadMessageRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadSessions.ts b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6b20a777f4fe8e9bf6b24e9cbd890a3712b004e --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadSessions.ts @@ -0,0 +1,51 @@ +import { + IsoDateTime, + OrchestrationSessionStatus, + ProviderSessionId, + ProviderThreadId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadSession = Schema.Struct({ + threadId: ThreadId, + status: OrchestrationSessionStatus, + providerName: Schema.NullOr(Schema.String), + providerSessionId: Schema.NullOr(ProviderSessionId), + providerThreadId: Schema.NullOr(ProviderThreadId), + activeTurnId: Schema.NullOr(TurnId), + lastError: Schema.NullOr(Schema.String), + updatedAt: IsoDateTime, +}); +export type ProjectionThreadSession = typeof ProjectionThreadSession.Type; + +export const GetProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadSessionInput = typeof GetProjectionThreadSessionInput.Type; + +export const DeleteProjectionThreadSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadSessionInput = typeof DeleteProjectionThreadSessionInput.Type; + +export interface ProjectionThreadSessionRepositoryShape { + readonly upsert: (row: ProjectionThreadSession) => Effect.Effect; + + readonly getByThreadId: ( + input: GetProjectionThreadSessionInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByThreadId: ( + input: DeleteProjectionThreadSessionInput, + ) => Effect.Effect; +} + +export class ProjectionThreadSessionRepository extends ServiceMap.Service< + ProjectionThreadSessionRepository, + ProjectionThreadSessionRepositoryShape +>()("persistence/ProjectionThreadSessionRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreadTurns.ts b/apps/server/src/persistence/Services/ProjectionThreadTurns.ts new file mode 100644 index 0000000000000000000000000000000000000000..7aad85ddffa34095f43069f9624fcb16e90a8bcd --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadTurns.ts @@ -0,0 +1,60 @@ +import { + IsoDateTime, + MessageId, + NonNegativeInt, + ProjectionThreadTurnStatus, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadTurn = Schema.Struct({ + turnId: TurnId, + threadId: ThreadId, + turnCount: NonNegativeInt, + status: ProjectionThreadTurnStatus, + userMessageId: Schema.NullOr(MessageId), + assistantMessageId: Schema.NullOr(MessageId), + startedAt: IsoDateTime, + completedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThreadTurn = typeof ProjectionThreadTurn.Type; + +export const ListProjectionThreadTurnsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadTurnsInput = typeof ListProjectionThreadTurnsInput.Type; + +export const GetProjectionThreadTurnInput = Schema.Struct({ + turnId: TurnId, +}); +export type GetProjectionThreadTurnInput = typeof GetProjectionThreadTurnInput.Type; + +export const DeleteProjectionThreadTurnsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadTurnsInput = typeof DeleteProjectionThreadTurnsInput.Type; + +export interface ProjectionThreadTurnRepositoryShape { + readonly upsert: (turn: ProjectionThreadTurn) => Effect.Effect; + + readonly listByThreadId: ( + input: ListProjectionThreadTurnsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly getByTurnId: ( + input: GetProjectionThreadTurnInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteByThreadId: ( + input: DeleteProjectionThreadTurnsInput, + ) => Effect.Effect; +} + +export class ProjectionThreadTurnRepository extends ServiceMap.Service< + ProjectionThreadTurnRepository, + ProjectionThreadTurnRepositoryShape +>()("persistence/ProjectionThreadTurnRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts new file mode 100644 index 0000000000000000000000000000000000000000..88f91bf749a87e4c0290f334ac568185bb84c1b5 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -0,0 +1,55 @@ +import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThread = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionThread = typeof ProjectionThread.Type; + +export const GetProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type; + +export const DeleteProjectionThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type; + +export const ListProjectionThreadsByProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type; + +export interface ProjectionThreadRepositoryShape { + readonly upsert: (thread: ProjectionThread) => Effect.Effect; + + readonly getById: ( + input: GetProjectionThreadInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly listByProjectId: ( + input: ListProjectionThreadsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + + readonly deleteById: ( + input: DeleteProjectionThreadInput, + ) => Effect.Effect; +} + +export class ProjectionThreadRepository extends ServiceMap.Service< + ProjectionThreadRepository, + ProjectionThreadRepositoryShape +>()("persistence/ProjectionThreadRepository") {} diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts deleted file mode 100644 index c27ddab045d4dfcc8b416f38078cbce7f16c4569..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/Services/Projects.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * ProjectRepository - Repository interface for project persistence. - * - * Owns persisted project records (cwd, scripts, and cleanup of missing paths). - * It does not manage provider sessions, orchestration events, or websocket APIs. - * - * Uses Effect `ServiceMap.Service` for dependency injection and returns typed - * domain errors for all operations. - * - * @module ProjectRepository - */ -import type { - ProjectAddInput, - ProjectAddResult, - ProjectListResult, - ProjectRemoveInput, - ProjectUpdateScriptsInput, - ProjectUpdateScriptsResult, -} from "@t3tools/contracts"; -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { ProjectRepositoryError } from "../Errors.ts"; - -export interface ProjectRepositoryShape { - /** - * List all persisted projects. - * - * @returns Effect containing all project records. - */ - readonly list: () => Effect.Effect; - - /** - * Add a project by cwd, or return the existing project when the cwd already exists. - * - * @param input - Project creation payload. - * @returns Effect containing the project and whether it was newly created. - */ - readonly add: (input: ProjectAddInput) => Effect.Effect; - - /** - * Remove a persisted project. - * - * @param input - Project removal payload. - * @returns Effect containing void. - */ - readonly remove: (input: ProjectRemoveInput) => Effect.Effect; - - /** - * Update the scripts for an existing project. - * - * @param input - Script update payload. - * @returns Effect containing the updated project. - */ - readonly updateScripts: ( - input: ProjectUpdateScriptsInput, - ) => Effect.Effect; - - /** - * Remove projects whose cwd no longer exists on disk. - * - * @returns Effect containing void. - */ - readonly pruneMissing: () => Effect.Effect; -} - -/** - * ProjectRepository - Service tag for project persistence dependency injection. - * - * @example - * ```ts - * const program = Effect.gen(function* () { - * const projects = yield* ProjectRepository - * return yield* projects.list() - * }) - * ``` - */ -export class ProjectRepository extends ServiceMap.Service< - ProjectRepository, - ProjectRepositoryShape ->()("persistence/ProjectRepository") {} diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cf2bc465ac850da2a37ff00cc702f2b30bba815 --- /dev/null +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -0,0 +1,58 @@ +import { + IsoDateTime, + ProviderSessionId, + ProviderSessionRuntimeStatus, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderSessionRuntimeRepositoryError } from "../Errors.ts"; + +export const ProviderSessionRuntime = Schema.Struct({ + providerSessionId: ProviderSessionId, + threadId: ThreadId, + providerName: Schema.String, + adapterKey: Schema.String, + providerThreadId: Schema.NullOr(ProviderThreadId), + status: ProviderSessionRuntimeStatus, + lastSeenAt: IsoDateTime, + resumeCursor: Schema.NullOr(Schema.Unknown), + runtimePayload: Schema.NullOr(Schema.Unknown), +}); +export type ProviderSessionRuntime = typeof ProviderSessionRuntime.Type; + +export const GetProviderSessionRuntimeInput = Schema.Struct({ + providerSessionId: ProviderSessionId, +}); +export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInput.Type; + +export const DeleteProviderSessionRuntimeInput = Schema.Struct({ + providerSessionId: ProviderSessionId, +}); +export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; + +export interface ProviderSessionRuntimeRepositoryShape { + readonly upsert: ( + runtime: ProviderSessionRuntime, + ) => Effect.Effect; + + readonly getBySessionId: ( + input: GetProviderSessionRuntimeInput, + ) => Effect.Effect, ProviderSessionRuntimeRepositoryError>; + + readonly list: () => Effect.Effect< + ReadonlyArray, + ProviderSessionRuntimeRepositoryError + >; + + readonly deleteBySessionId: ( + input: DeleteProviderSessionRuntimeInput, + ) => Effect.Effect; +} + +export class ProviderSessionRuntimeRepository extends ServiceMap.Service< + ProviderSessionRuntimeRepository, + ProviderSessionRuntimeRepositoryShape +>()("persistence/ProviderSessionRuntimeRepository") {} diff --git a/apps/server/src/persistence/Services/ProviderSessions.ts b/apps/server/src/persistence/Services/ProviderSessions.ts index 673d343a9f7421b6509a447df8e1b89c40ad6c43..445c63bc0ab2a7ce3a5fdeb953c508f9b33aa92f 100644 --- a/apps/server/src/persistence/Services/ProviderSessions.ts +++ b/apps/server/src/persistence/Services/ProviderSessions.ts @@ -1,29 +1,29 @@ -import type { ProviderKind } from "@t3tools/contracts"; +import type { ProviderKind, ProviderSessionId, ThreadId } from "@t3tools/contracts"; import { Option, ServiceMap } from "effect"; import type { Effect } from "effect"; import type { ProviderSessionRepositoryError } from "../Errors.ts"; export interface ProviderSessionEntry { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; readonly provider: ProviderKind; - readonly threadId?: string; + readonly threadId?: ThreadId; readonly createdAt: string; readonly updatedAt: string; } export interface UpsertProviderSessionInput { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; readonly provider: ProviderKind; - readonly threadId?: string; + readonly threadId?: ThreadId; } export interface DeleteProviderSessionInput { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; } export interface GetProviderSessionInput { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; } export interface ProviderSessionRepositoryShape { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index c3e88d112becc5ff041856efb4eb74559fd82748..0a38899302c7bff156dbaf79e2899bb96eafdd9d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -6,13 +6,18 @@ * * @module CodexAdapterLive */ -import type { - ProviderEvent, - ProviderRuntimeEvent, - ProviderRuntimeTurnStatus, - ProviderRuntimeToolKind, +import { + type ProviderEvent, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnStatus, + type ProviderRuntimeToolKind, + ProviderItemId, + ProviderApprovalDecision, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, } from "@t3tools/contracts"; -import { Effect, Layer, Queue, Stream } from "effect"; +import { Effect, Layer, Queue, Schema, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -42,7 +47,7 @@ function toMessage(cause: unknown, fallback: string): string { } function toSessionError( - sessionId: string, + sessionId: ProviderSessionId, cause: unknown, ): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { const normalized = toMessage(cause, "").toLowerCase(); @@ -63,7 +68,11 @@ function toSessionError( return undefined; } -function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError { +function toRequestError( + sessionId: ProviderSessionId, + method: string, + cause: unknown, +): ProviderAdapterError { const sessionError = toSessionError(sessionId, cause); if (sessionError) { return sessionError; @@ -188,7 +197,7 @@ function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | provider: event.provider, sessionId: event.sessionId, createdAt: event.createdAt, - itemId, + itemId: ProviderItemId.makeUnsafe(itemId), ...(event.threadId ? { threadId: event.threadId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), }; @@ -282,7 +291,7 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray Effect.gen(function* () { const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; - const byProvider = new Map>(); - for (const adapter of adapters) { - byProvider.set(adapter.provider, adapter); - } + const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { const adapter = byProvider.get(provider); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 2bd5f6b38d0711a8ce9a8e1a5db9a88d400d3b54..5e97f3538f8ec19baf9a267c14f29d52790ac970 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4,25 +4,26 @@ import path from "node:path"; import type { ProviderApprovalDecision, - ProviderGetCheckpointDiffInput, - ProviderGetCheckpointDiffResult, - ProviderListCheckpointsInput, - ProviderListCheckpointsResult, - ProviderRevertToCheckpointInput, ProviderRuntimeEvent, - ProviderRevertToCheckpointResult, ProviderSendTurnInput, ProviderSession, - ProviderSessionStartInput, ProviderTurnStartResult, } from "@t3tools/contracts"; -import { providerSessionStartInputSchema } from "@t3tools/contracts"; +import { + ApprovalRequestId, + EventId, + ProviderSessionStartInput, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, +} from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Layer, PubSub, Ref, Stream } from "effect"; +import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; import { ProviderAdapterSessionNotFoundError, ProviderSessionNotFoundError, @@ -34,28 +35,47 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import { ProviderServiceLive } from "./ProviderService.ts"; +import { makeProviderServiceLive } from "./ProviderService.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import { NodeServices } from "@effect/platform-node"; -import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +function decodeIssueMessage(cause: unknown): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + try { + return JSON.stringify(cause); + } catch { + return "Invalid provider input."; + } +} + +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); + function makeFakeCodexAdapter() { - const sessions = new Map(); + const sessions = new Map(); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSession = 1; const startSession = vi.fn((input: ProviderSessionStartInput) => Effect.sync(() => { const now = new Date().toISOString(); + const next = nextSession; const session: ProviderSession = { - sessionId: `sess-${nextSession}`, + sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), provider: "codex", status: "ready", - threadId: input.resumeThreadId ?? `thread-${nextSession}`, + threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -80,8 +100,8 @@ function makeFakeCodexAdapter() { } return Effect.succeed({ - threadId: "thread-1", - turnId: "turn-1", + threadId: ProviderThreadId.makeUnsafe("thread-1"), + turnId: ProviderTurnId.makeUnsafe("turn-1"), }); }, ); @@ -102,7 +122,7 @@ function makeFakeCodexAdapter() { const stopSession = vi.fn( (sessionId: string): Effect.Effect => Effect.sync(() => { - sessions.delete(sessionId); + sessions.delete(ProviderSessionId.makeUnsafe(sessionId)); }), ); @@ -112,22 +132,29 @@ function makeFakeCodexAdapter() { ); const hasSession = vi.fn( - (sessionId: string): Effect.Effect => Effect.succeed(sessions.has(sessionId)), + (sessionId: string): Effect.Effect => + Effect.succeed(sessions.has(ProviderSessionId.makeUnsafe(sessionId))), ); const readThread = vi.fn( ( - _sessionId: string, - ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => - Effect.succeed({ threadId: "thread-1", turns: [] }), + _sessionId: ProviderSessionId, + ): Effect.Effect< + { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> }, + ProviderAdapterError + > => + Effect.succeed({ + threadId: ProviderThreadId.makeUnsafe("thread-1"), + turns: [{ id: asTurnId("turn-1"), items: [] }], + }), ); const rollbackThread = vi.fn( ( - _sessionId: string, + _sessionId: ProviderSessionId, _numTurns: number, - ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => - Effect.succeed({ threadId: "thread-1", turns: [] }), + ): Effect.Effect<{ threadId: ProviderThreadId; turns: readonly [] }, ProviderAdapterError> => + Effect.succeed({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turns: [] }), ); const stopAll = vi.fn( @@ -172,80 +199,11 @@ function makeFakeCodexAdapter() { }; } -function makeCheckpointServiceDouble() { - const initializeForSession = vi.fn( - (_: { providerSessionId: string; cwd: string }) => Effect.void, - ); - const captureCurrentTurn = vi.fn( - (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void, - ); - const listCheckpoints = vi.fn( - (_input: ProviderListCheckpointsInput): Effect.Effect => - Effect.succeed({ - threadId: "thread-1", - checkpoints: [ - { - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: true, - }, - ], - }), - ); - const getCheckpointDiff = vi.fn( - (input: ProviderGetCheckpointDiffInput): Effect.Effect => - Effect.succeed({ - threadId: "thread-1", - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff: "diff --git a/a.ts b/a.ts", - }), - ); - const revertToCheckpoint = vi.fn( - (input: ProviderRevertToCheckpointInput): Effect.Effect => - Effect.succeed({ - threadId: "thread-1", - turnCount: input.turnCount, - messageCount: 0, - rolledBackTurns: 0, - checkpoints: [ - { - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: input.turnCount === 0, - }, - ], - }), - ); - const releaseSession = vi.fn((_input: { providerSessionId: string }) => Effect.void); - - const service: typeof CheckpointService.Service = { - initializeForSession, - captureCurrentTurn, - listCheckpoints, - getCheckpointDiff, - revertToCheckpoint, - releaseSession, - }; - - return { - service, - initializeForSession, - captureCurrentTurn, - listCheckpoints, - getCheckpointDiff, - revertToCheckpoint, - releaseSession, - }; -} +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); - const checkpoint = makeCheckpointServiceDouble(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" @@ -254,28 +212,26 @@ function makeProviderServiceLayer() { listProviders: () => Effect.succeed(["codex"]), }; - const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); - const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const layer = it.layer( Layer.mergeAll( - ProviderServiceLive.pipe( + makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(checkpointLayer), ), directoryLayer, + runtimeRepositoryLayer, NodeServices.layer, ), ); return { codex, - checkpoint, layer, }; } @@ -287,7 +243,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const dbPath = path.join(tempDir, "orchestration.sqlite"); const codex = makeFakeCodexAdapter(); - const checkpoint = makeCheckpointServiceDouble(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" @@ -297,49 +252,64 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }; const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const directoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId: "sess-stale", + sessionId: asSessionId("sess-stale"), provider: "codex", - threadId: "thread-stale", + threadId: ThreadId.makeUnsafe("thread-stale"), }); }).pipe(Effect.provide(directoryLayer)); - const providerLayer = ProviderServiceLive.pipe( + const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), - Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), ); - checkpoint.releaseSession.mockClear(); yield* Effect.gen(function* () { yield* ProviderService; }).pipe(Effect.provide(providerLayer)); - assert.deepEqual(checkpoint.releaseSession.mock.calls, []); const persistedProvider = yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - return yield* directory.getProvider("sess-stale"); + return yield* directory.getProvider(asSessionId("sess-stale")); }).pipe(Effect.provide(directoryLayer)); assert.equal(persistedProvider, "codex"); + const runtime = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntimeRepository; + return yield* repository.getBySessionId({ providerSessionId: asSessionId("sess-stale") }); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + assert.equal(Option.isSome(runtime), true); + + const legacyTableRows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'provider_sessions' + `; + }).pipe(Effect.provide(persistenceLayer)); + assert.equal(legacyTableRows.length, 0); + fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("ProviderServiceLive restores checkpoint RPC routing after restart using persisted thread mapping", () => +it.effect("ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); const dbPath = path.join(tempDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); - const checkpoint = makeCheckpointServiceDouble(); const firstCodex = makeFakeCodexAdapter(); const firstRegistry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => @@ -350,13 +320,11 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi }; const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), - Layer.provide(persistenceLayer), + Layer.provide(runtimeRepositoryLayer), ); - const firstProviderLayer = ProviderServiceLive.pipe( + const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), - Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), ); const startedSession = yield* Effect.gen(function* () { @@ -372,6 +340,20 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi yield* provider.stopAll(); }).pipe(Effect.provide(firstProviderLayer)); + const persistedAfterStopAll = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntimeRepository; + return yield* repository.getBySessionId({ + providerSessionId: startedSession.sessionId, + }); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + assert.equal(Option.isSome(persistedAfterStopAll), true); + if (Option.isSome(persistedAfterStopAll)) { + assert.equal(persistedAfterStopAll.value.status, "stopped"); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, { + resumeThreadId: startedSession.threadId, + }); + } + const secondCodex = makeFakeCodexAdapter(); const secondRegistry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => @@ -381,24 +363,21 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi listProviders: () => Effect.succeed(["codex"]), }; const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), - Layer.provide(persistenceLayer), + Layer.provide(runtimeRepositoryLayer), ); - const secondProviderLayer = ProviderServiceLive.pipe( + const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), - Layer.provide(Layer.succeed(CheckpointService, checkpoint.service)), ); - checkpoint.getCheckpointDiff.mockClear(); secondCodex.startSession.mockClear(); + secondCodex.rollbackThread.mockClear(); yield* Effect.gen(function* () { const provider = yield* ProviderService; - yield* provider.getCheckpointDiff({ + yield* provider.rollbackConversation({ sessionId: startedSession.sessionId, - fromTurnCount: 0, - toTurnCount: 0, + numTurns: 1, }); }).pipe(Effect.provide(secondProviderLayer)); @@ -410,19 +389,17 @@ it.effect("ProviderServiceLive restores checkpoint RPC routing after restart usi }, ], ]); - assert.equal(checkpoint.getCheckpointDiff.mock.calls.length, 1); - const recoveredDiffInput = checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; - assert.equal(typeof recoveredDiffInput?.sessionId, "string"); - assert.equal((recoveredDiffInput?.sessionId?.length ?? 0) > 0, true); - assert.equal(recoveredDiffInput?.fromTurnCount, 0); - assert.equal(recoveredDiffInput?.toTurnCount, 0); + assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); + const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; + assert.equal(typeof rollbackCall?.[0], "string"); + assert.equal(rollbackCall?.[1], 1); fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { - it.effect("routes provider operations and delegates checkpoint workflows", () => + it.effect("routes provider operations and rollback conversation", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -431,9 +408,6 @@ routing.layer("ProviderServiceLive routing", (it) => { cwd: "/tmp/project", }); assert.equal(session.provider, "codex"); - assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [ - [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }], - ]); const sessions = yield* provider.listSessions(); assert.equal(sessions.length, 1); @@ -450,38 +424,19 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.respondToRequest({ sessionId: session.sessionId, - requestId: "req-1", + requestId: asRequestId("req-1"), decision: "accept", }); assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ - [session.sessionId, "req-1", "accept"], + [session.sessionId, asRequestId("req-1"), "accept"], ]); - yield* provider.listCheckpoints({ sessionId: session.sessionId }); - yield* provider.getCheckpointDiff({ + yield* provider.rollbackConversation({ sessionId: session.sessionId, - fromTurnCount: 0, - toTurnCount: 0, + numTurns: 0, }); - yield* provider.revertToCheckpoint({ - sessionId: session.sessionId, - turnCount: 0, - }); - - assert.deepEqual(routing.checkpoint.listCheckpoints.mock.calls, [ - [{ sessionId: session.sessionId }], - ]); - assert.deepEqual(routing.checkpoint.getCheckpointDiff.mock.calls, [ - [{ sessionId: session.sessionId, fromTurnCount: 0, toTurnCount: 0 }], - ]); - assert.deepEqual(routing.checkpoint.revertToCheckpoint.mock.calls, [ - [{ sessionId: session.sessionId, turnCount: 0 }], - ]); yield* provider.stopSession({ sessionId: session.sessionId }); - assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [ - [{ providerSessionId: session.sessionId }], - ]); const sendAfterStop = yield* Effect.result( provider.sendTurn({ sessionId: session.sessionId, @@ -496,7 +451,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); - it.effect("recovers stale persisted sessions for checkpoint RPCs by resuming thread identity", () => + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -505,19 +460,12 @@ routing.layer("ProviderServiceLive routing", (it) => { cwd: "/tmp/project", }); yield* routing.codex.stopSession(initial.sessionId); - - routing.checkpoint.getCheckpointDiff.mockClear(); - routing.checkpoint.revertToCheckpoint.mockClear(); routing.codex.startSession.mockClear(); + routing.codex.rollbackThread.mockClear(); - yield* provider.getCheckpointDiff({ + yield* provider.rollbackConversation({ sessionId: initial.sessionId, - fromTurnCount: 0, - toTurnCount: 0, - }); - yield* provider.revertToCheckpoint({ - sessionId: initial.sessionId, - turnCount: 0, + numTurns: 1, }); assert.deepEqual(routing.codex.startSession.mock.calls, [ @@ -528,46 +476,79 @@ routing.layer("ProviderServiceLive routing", (it) => { }, ], ]); - assert.equal(routing.checkpoint.getCheckpointDiff.mock.calls.length, 1); - assert.equal(routing.checkpoint.revertToCheckpoint.mock.calls.length, 1); - - const diffInput = routing.checkpoint.getCheckpointDiff.mock.calls[0]?.[0]; - const revertInput = routing.checkpoint.revertToCheckpoint.mock.calls[0]?.[0]; - assert.equal(diffInput?.fromTurnCount, 0); - assert.equal(diffInput?.toTurnCount, 0); - assert.equal(diffInput?.sessionId === initial.sessionId, false); - assert.equal(revertInput?.sessionId, diffInput?.sessionId); - assert.equal(revertInput?.turnCount, 0); + assert.equal(routing.codex.rollbackThread.mock.calls.length, 1); + const rollbackCall = routing.codex.rollbackThread.mock.calls[0]; + assert.equal(rollbackCall?.[1], 1); }), ); - it.effect("releases checkpoint session state when stopping all sessions", () => + it.effect("stops all sessions and clears adapter runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; - const first = yield* provider.startSession({ + yield* provider.startSession({ provider: "codex", }); - const second = yield* provider.startSession({ + yield* provider.startSession({ provider: "codex", }); yield* provider.stopAll(); - assert.deepEqual(routing.checkpoint.releaseSession.mock.calls.slice(-2), [ - [{ providerSessionId: first.sessionId }], - [{ providerSessionId: second.sessionId }], - ]); - const remaining = yield* provider.listSessions(); assert.equal(remaining.length, 0); }), ); + + it.effect("persists runtime status transitions in provider_session_runtime", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const session = yield* provider.startSession({ + provider: "codex", + }); + yield* provider.sendTurn({ + sessionId: session.sessionId, + input: "hello", + attachments: [], + }); + + const runningRuntime = yield* runtimeRepository.getBySessionId({ + providerSessionId: session.sessionId, + }); + assert.equal(Option.isSome(runningRuntime), true); + if (Option.isSome(runningRuntime)) { + assert.equal(runningRuntime.value.status, "running"); + assert.deepEqual(runningRuntime.value.resumeCursor, { + resumeThreadId: "thread-1", + }); + const payload = runningRuntime.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal(payload.cwd, process.cwd()); + assert.equal(payload.model, null); + assert.equal(payload.activeTurnId, "turn-1"); + assert.equal(payload.lastError, null); + assert.equal(payload.lastRuntimeEvent, "provider.sendTurn"); + } + } + + yield* provider.stopAll(); + const stoppedRuntime = yield* runtimeRepository.getBySessionId({ + providerSessionId: session.sessionId, + }); + assert.equal(Option.isSome(stoppedRuntime), true); + if (Option.isSome(stoppedRuntime)) { + assert.equal(stoppedRuntime.value.status, "stopped"); + } + }), + ); }); const fanout = makeProviderServiceLayer(); fanout.layer("ProviderServiceLive fanout", (it) => { - it.effect("fans out adapter events and captures turn completion checkpoints", () => + it.effect("fans out adapter turn completion events", () => Effect.gen(function* () { const provider = yield* ProviderService; const session = yield* provider.startSession({ @@ -577,34 +558,27 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const eventsRef = yield* Ref.make>([]); const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => Ref.update(eventsRef, (current) => [...current, event]), - ).pipe( - Effect.forkChild, - ); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + ).pipe(Effect.forkChild); + yield* sleep(20); const completedEvent: ProviderRuntimeEvent = { type: "turn.completed", - eventId: "evt-1", + eventId: asEventId("evt-1"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: "thread-1", - turnId: "turn-1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), status: "completed", }; fanout.codex.emit(completedEvent); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* sleep(20); const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); - assert.equal(events.length >= 2, true); - assert.equal(events[0]?.type, "turn.completed"); - assert.equal(events[1]?.type, "checkpoint.captured"); - assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ - [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], - ]); + assert.equal(events.some((entry) => entry.type === "turn.completed"), true); }), ); @@ -614,9 +588,13 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const session = yield* provider.startSession({ provider: "codex", }); - fanout.checkpoint.captureCurrentTurn.mockClear(); const receivedByHealthy: string[] = []; + const expectedEventIds = new Set([ + "evt-ordered-1", + "evt-ordered-2", + "evt-ordered-3", + ]); const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( Stream.runForEach((event) => Effect.sync(() => { @@ -629,39 +607,39 @@ fanout.layer("ProviderServiceLive fanout", (it) => { Stream.runForEach(() => Effect.fail("listener crash")), Effect.forkChild, ); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + yield* sleep(20); const events: ReadonlyArray = [ { type: "tool.completed", - eventId: "evt-ordered-1", + eventId: asEventId("evt-ordered-1"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: "thread-1", - turnId: "turn-1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), toolKind: "command", title: "Command run", detail: "echo one", }, { type: "message.delta", - eventId: "evt-ordered-2", + eventId: asEventId("evt-ordered-2"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: "thread-1", - turnId: "turn-1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), delta: "hello", }, { type: "turn.completed", - eventId: "evt-ordered-3", + eventId: asEventId("evt-ordered-3"), provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), - threadId: "thread-1", - turnId: "turn-1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), status: "completed", }, ]; @@ -673,12 +651,14 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.equal(failingResult._tag, "Failure"); yield* Fiber.join(healthyFiber); - assert.deepEqual(receivedByHealthy.slice(0, 3), [ - "evt-ordered-1", - "evt-ordered-2", - "evt-ordered-3", - ]); - assert.equal(receivedByHealthy.length, 3); + assert.deepEqual( + receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), + [ + "evt-ordered-1", + "evt-ordered-2", + "evt-ordered-3", + ], + ); }), ); }); @@ -689,13 +669,17 @@ validation.layer("ProviderServiceLive validation", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; - const parse = providerSessionStartInputSchema.safeParse({ - provider: "invalid-provider", - }); - assert.equal(parse.success, false); - if (parse.success) { - return; + let parseCause: unknown; + let decodeFailed = false; + try { + Schema.decodeUnknownSync(ProviderSessionStartInput)({ + provider: "invalid-provider", + }); + } catch (cause) { + parseCause = cause; + decodeFailed = true; } + assert.equal(decodeFailed, true); const failure = yield* Effect.result( provider.startSession({ @@ -703,13 +687,12 @@ validation.layer("ProviderServiceLive validation", (it) => { } as never), ); - const cause = (parse.error as { cause?: unknown }).cause; assertFailure( failure, new ProviderValidationError({ operation: "ProviderService.startSession", - issue: parse.error.message, - ...(cause !== undefined ? { cause } : {}), + issue: decodeIssueMessage(parseCause), + cause: parseCause, }), ); }), @@ -723,7 +706,7 @@ validation.layer("ProviderServiceLive validation", (it) => { Effect.sync(() => { const now = new Date().toISOString(); return { - sessionId: "sess-missing-thread", + sessionId: asSessionId("sess-missing-thread"), provider: "codex", status: "ready", cwd: input.cwd ?? process.cwd(), @@ -743,10 +726,9 @@ validation.layer("ProviderServiceLive validation", (it) => { assertFailure( failure, new ProviderValidationError({ - operation: "ProviderService.startSession", - issue: - "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.", - }), + operation: "ProviderService.startSession", + issue: "Provider 'codex' returned a session without threadId.", + }), ); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f9a22e77395af920d4d441ecee5b67cd2a469e46..4cd5a1621b3af287b66b123307e5a2dd97d54191 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -5,29 +5,25 @@ * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a * unified provider event stream for subscribers. * - * It does not implement provider protocol details (adapter concern) and does - * not implement checkpoint persistence mechanics (checkpointing concern). + * It does not implement provider protocol details (adapter concern). * * @module ProviderServiceLive */ -import { randomUUID } from "node:crypto"; - import { - providerGetCheckpointDiffInputSchema, - providerInterruptTurnInputSchema, - providerListCheckpointsInputSchema, - providerRespondToRequestInputSchema, - providerRevertToCheckpointInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, - providerStopSessionInputSchema, + NonNegativeInt, + ProviderSessionId, + ProviderThreadId, + ThreadId, + ProviderInterruptTurnInput, + ProviderRespondToRequestInput, + ProviderSendTurnInput, + ProviderSessionStartInput, + ProviderStopSessionInput, type ProviderRuntimeEvent, - type ProviderRuntimeTurnCompletedEvent, type ProviderSession, } from "@t3tools/contracts"; -import { Effect, Layer, Option, PubSub, Queue, Ref, Stream } from "effect"; +import { Effect, Layer, Option, PubSub, Queue, Ref, Schema, Stream } from "effect"; -import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; @@ -38,40 +34,10 @@ export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; } -function toCheckpointCaptureErrorEvent( - event: ProviderRuntimeTurnCompletedEvent, - error: { readonly message: string }, -): ProviderRuntimeEvent { - return { - type: "runtime.error", - eventId: randomUUID(), - provider: event.provider, - sessionId: event.sessionId, - createdAt: new Date().toISOString(), - ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), - ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), - message: error.message, - }; -} - -function toCheckpointCapturedEvent(input: { - readonly event: ProviderRuntimeTurnCompletedEvent; - readonly threadId: string; - readonly turnCount: number; -}): ProviderRuntimeEvent { - const { event, threadId, turnCount } = input; - return { - type: "checkpoint.captured", - eventId: randomUUID(), - provider: event.provider, - sessionId: event.sessionId, - createdAt: new Date().toISOString(), - threadId, - ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), - turnCount, - ...(event.status !== undefined ? { status: event.status } : {}), - }; -} +const ProviderRollbackConversationInput = Schema.Struct({ + sessionId: ProviderSessionId, + numTurns: NonNegativeInt, +}); function toValidationError( operation: string, @@ -85,6 +51,63 @@ function toValidationError( }); } +function decodeIssueMessage(cause: unknown): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + try { + return JSON.stringify(cause); + } catch { + return "Invalid provider input."; + } +} + +function decodeInputOrValidationError(input: { + readonly operation: string; + readonly schema: S; + readonly payload: unknown; +}): Effect.Effect, ProviderValidationError> { + try { + return Effect.succeed( + Schema.decodeUnknownSync(input.schema as never)(input.payload) as Schema.Schema.Type, + ); + } catch (cause) { + return Effect.fail(toValidationError(input.operation, decodeIssueMessage(cause), cause)); + } +} + +function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "stopped" | "error" { + switch (session.status) { + case "connecting": + return "starting"; + case "error": + return "error"; + case "closed": + return "stopped"; + case "ready": + case "running": + default: + return "running"; + } +} + +function toRuntimePayloadFromSession(session: ProviderSession): Record { + return { + cwd: session.cwd ?? null, + model: session.model ?? null, + activeTurnId: session.activeTurnId ?? null, + lastError: session.lastError ?? null, + }; +} + +function asProviderSessionId(value: string | ProviderSessionId): ProviderSessionId { + return typeof value === "string" ? ProviderSessionId.makeUnsafe(value) : value; +} + +function asProviderThreadId(value: string | ProviderThreadId): ProviderThreadId { + return typeof value === "string" ? ProviderThreadId.makeUnsafe(value) : value; +} + const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const canonicalEventLogger = @@ -94,11 +117,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; - const checkpointService = yield* CheckpointService; - const runtimeEventQueue = yield* Queue.unbounded(); const runtimeEventPubSub = yield* PubSub.unbounded(); - const routedSessionAliasesRef = yield* Ref.make>(new Map()); + const routedSessionAliasesRef = yield* Ref.make>( + new Map(), + ); const canonicalizeRuntimeEventSession = ( event: ProviderRuntimeEvent, @@ -109,7 +132,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => if (liveSessionId === event.sessionId) { return { ...event, - sessionId: staleSessionId, + sessionId: ProviderSessionId.makeUnsafe(staleSessionId), } satisfies ProviderRuntimeEvent; } } @@ -131,29 +154,32 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.asVoid, ); - const upsertSessionBinding = ( - session: ProviderSession, - operation: string, - ) => + const upsertSessionBinding = (session: ProviderSession, operation: string) => Effect.gen(function* () { - const threadId = session.threadId?.trim(); - if (!threadId) { + const providerThreadId = session.threadId; + if (!providerThreadId) { return yield* toValidationError( operation, - `Provider '${session.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, + `Provider '${session.provider}' returned a session without threadId.`, ); } + const providerSessionId = asProviderSessionId(session.sessionId); + const brandedThreadId = ThreadId.makeUnsafe(providerThreadId); yield* directory.upsert({ - sessionId: session.sessionId, + sessionId: providerSessionId, provider: session.provider, - threadId, + threadId: brandedThreadId, + providerThreadId, + status: toRuntimeStatus(session), + resumeCursor: { resumeThreadId: providerThreadId }, + runtimePayload: toRuntimePayloadFromSession(session), }); - return threadId; + return brandedThreadId; }); - const clearAliasKey = (staleSessionId: string) => + const clearAliasKey = (staleSessionId: ProviderSessionId) => Ref.update(routedSessionAliasesRef, (current) => { if (!current.has(staleSessionId)) { return current; @@ -163,10 +189,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return next; }); - const clearAliasesReferencing = (sessionId: string) => + const clearAliasesReferencing = (sessionId: ProviderSessionId) => Ref.update(routedSessionAliasesRef, (current) => { let changed = false; - const next = new Map(); + const next = new Map(); for (const [key, value] of current) { if (key === sessionId || value === sessionId) { changed = true; @@ -177,7 +203,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => return changed ? next : current; }); - const setAlias = (staleSessionId: string, liveSessionId: string) => + const setAlias = (staleSessionId: ProviderSessionId, liveSessionId: ProviderSessionId) => Ref.update(routedSessionAliasesRef, (current) => { const existing = current.get(staleSessionId); if (existing === liveSessionId) { @@ -193,43 +219,8 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => registry.getByProvider(provider), ); - const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect => - checkpointService - .captureCurrentTurn({ - providerSessionId: event.sessionId, - ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), - ...(event.status !== undefined ? { status: event.status } : {}), - }) - .pipe( - Effect.flatMap(() => checkpointService.listCheckpoints({ sessionId: event.sessionId })), - Effect.flatMap((result) => { - const currentCheckpoint = - result.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? - result.checkpoints[result.checkpoints.length - 1]; - if (!currentCheckpoint) { - return Effect.void; - } - - return publishRuntimeEvent( - toCheckpointCapturedEvent({ - event, - threadId: result.threadId, - turnCount: currentCheckpoint.turnCount, - }), - ); - }), - Effect.catch((error) => publishRuntimeEvent(toCheckpointCaptureErrorEvent(event, error))), - ); - const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - publishRuntimeEvent(event).pipe( - Effect.flatMap(() => { - if (event.type !== "turn.completed") { - return Effect.void; - } - return onTurnCompleted(event); - }), - ); + publishRuntimeEvent(event); const worker = Effect.forever( Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), @@ -243,16 +234,16 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ).pipe(Effect.asVoid); const recoverSessionForThread = (input: { - readonly staleSessionId: string; + readonly staleSessionId: ProviderSessionId; readonly provider: ProviderSession["provider"]; - readonly threadId: string; + readonly threadId: ThreadId; readonly operation: string; }) => Effect.gen(function* () { const adapter = yield* registry.getByProvider(input.provider); const activeSessions = yield* adapter.listSessions(); const existing = activeSessions.find( - (session) => session.threadId?.trim() === input.threadId, + (session) => session.threadId?.trim() === input.threadId.trim(), ); if (existing) { const existingThreadId = yield* upsertSessionBinding( @@ -277,7 +268,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const resumed = yield* adapter.startSession({ provider: input.provider, - resumeThreadId: input.threadId, + resumeThreadId: ProviderThreadId.makeUnsafe(input.threadId), }); if (resumed.provider !== adapter.provider) { return yield* toValidationError( @@ -303,12 +294,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId: resumedThreadId, }); - const checkpointCwd = resumed.cwd ?? process.cwd(); - yield* checkpointService.initializeForSession({ - providerSessionId: resumed.sessionId, - cwd: checkpointCwd, - }); - if (resumed.sessionId !== input.staleSessionId) { yield* setAlias(input.staleSessionId, resumed.sessionId); } else { @@ -322,7 +307,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => }); const resolveRoutableSession = (input: { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; readonly operation: string; readonly allowRecovery: boolean; }) => @@ -364,7 +349,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } const threadIdOption = yield* directory.getThreadId(input.sessionId); - const threadId = Option.getOrUndefined(threadIdOption)?.trim(); + const threadId = Option.getOrUndefined(threadIdOption); if (!threadId) { return yield* toValidationError( input.operation, @@ -388,16 +373,18 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const startSession: ProviderServiceShape["startSession"] = (rawInput) => Effect.gen(function* () { - const parsed = providerSessionStartInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.startSession", - parsed.error.message, - parsed.error.cause, - ); - } + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderService.startSession", + schema: ProviderSessionStartInput, + payload: rawInput, + }); - const input = parsed.data; + const input = { + ...parsed, + provider: parsed.provider ?? "codex", + approvalPolicy: parsed.approvalPolicy ?? "never", + sandboxMode: parsed.sandboxMode ?? "workspace-write", + }; const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession(input); @@ -410,50 +397,68 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* upsertSessionBinding(session, "ProviderService.startSession"); - const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); - yield* checkpointService.initializeForSession({ - providerSessionId: session.sessionId, - cwd: checkpointCwd, - }); - return session; }); const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => Effect.gen(function* () { - const parsed = providerSendTurnInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.sendTurn", - parsed.error.message, - parsed.error.cause, - ); - } + const parsed = yield* decodeInputOrValidationError({ + operation: "ProviderService.sendTurn", + schema: ProviderSendTurnInput, + payload: rawInput, + }); - const input = parsed.data; + const input = { + ...parsed, + attachments: parsed.attachments ?? [], + }; + if (!input.input && input.attachments.length === 0) { + return yield* toValidationError( + "ProviderService.sendTurn", + "Either input text or at least one attachment is required", + ); + } const routed = yield* resolveRoutableSession({ sessionId: input.sessionId, operation: "ProviderService.sendTurn", allowRecovery: true, }); - return yield* routed.adapter.sendTurn({ + const turn = yield* routed.adapter.sendTurn({ ...input, sessionId: routed.sessionId, }); + const threadId = yield* directory + .getThreadId(asProviderSessionId(input.sessionId)) + .pipe(Effect.map(Option.getOrUndefined)); + if (!threadId) { + return yield* toValidationError( + "ProviderService.sendTurn", + `No thread id is tracked for provider session '${input.sessionId}'.`, + ); + } + yield* directory.upsert({ + sessionId: asProviderSessionId(input.sessionId), + provider: routed.adapter.provider, + threadId, + providerThreadId: asProviderThreadId(turn.threadId), + status: "running", + resumeCursor: { resumeThreadId: turn.threadId }, + runtimePayload: { + activeTurnId: turn.turnId, + lastRuntimeEvent: "provider.sendTurn", + lastRuntimeEventAt: new Date().toISOString(), + }, + }); + return turn; }); const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => Effect.gen(function* () { - const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.interruptTurn", - parsed.error.message, - parsed.error.cause, - ); - } - - const input = parsed.data; + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.interruptTurn", + schema: ProviderInterruptTurnInput, + payload: rawInput, + }); const routed = yield* resolveRoutableSession({ sessionId: input.sessionId, operation: "ProviderService.interruptTurn", @@ -464,16 +469,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => Effect.gen(function* () { - const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.respondToRequest", - parsed.error.message, - parsed.error.cause, - ); - } - - const input = parsed.data; + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.respondToRequest", + schema: ProviderRespondToRequestInput, + payload: rawInput, + }); const routed = yield* resolveRoutableSession({ sessionId: input.sessionId, operation: "ProviderService.respondToRequest", @@ -484,16 +484,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => Effect.gen(function* () { - const parsed = providerStopSessionInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.stopSession", - parsed.error.message, - parsed.error.cause, - ); - } - - const input = parsed.data; + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.stopSession", + schema: ProviderStopSessionInput, + payload: rawInput, + }); const routed = yield* resolveRoutableSession({ sessionId: input.sessionId, operation: "ProviderService.stopSession", @@ -503,12 +498,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* routed.adapter.stopSession(routed.sessionId); } if (routed.sessionId !== input.sessionId) { - yield* checkpointService.releaseSession({ providerSessionId: routed.sessionId }); - yield* directory.remove(routed.sessionId); + yield* directory.remove(asProviderSessionId(routed.sessionId)); yield* clearAliasesReferencing(routed.sessionId); } - yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); - yield* directory.remove(input.sessionId); + yield* directory.remove(asProviderSessionId(input.sessionId)); yield* clearAliasesReferencing(input.sessionId); }); @@ -517,84 +510,46 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), ); - const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => + const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => Effect.gen(function* () { - const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.listCheckpoints", - parsed.error.message, - parsed.error.cause, - ); - } - - const input = parsed.data; - const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, - operation: "ProviderService.listCheckpoints", - allowRecovery: true, - }); - return yield* checkpointService.listCheckpoints({ - ...input, - sessionId: routed.sessionId, - }); - }); - - const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => - Effect.gen(function* () { - const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.getCheckpointDiff", - parsed.error.message, - parsed.error.cause, - ); - } - - const input = parsed.data; - const routed = yield* resolveRoutableSession({ - sessionId: input.sessionId, - operation: "ProviderService.getCheckpointDiff", - allowRecovery: true, + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.rollbackConversation", + schema: ProviderRollbackConversationInput, + payload: rawInput, }); - return yield* checkpointService.getCheckpointDiff({ - ...input, - sessionId: routed.sessionId, - }); - }); - - const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => - Effect.gen(function* () { - const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); - if (!parsed.success) { - return yield* toValidationError( - "ProviderService.revertToCheckpoint", - parsed.error.message, - parsed.error.cause, - ); + if (input.numTurns === 0) { + return; } - - const input = parsed.data; const routed = yield* resolveRoutableSession({ sessionId: input.sessionId, - operation: "ProviderService.revertToCheckpoint", + operation: "ProviderService.rollbackConversation", allowRecovery: true, }); - return yield* checkpointService.revertToCheckpoint({ - ...input, - sessionId: routed.sessionId, - }); + yield* routed.adapter.rollbackThread(routed.sessionId, input.numTurns); }); const stopAll: ProviderServiceShape["stopAll"] = () => Effect.gen(function* () { const sessionIds = yield* directory.listSessionIds(); yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); - // Keep persisted session bindings so stale sessions can be resumed after - // process restart via resumeThreadId. yield* Effect.forEach(sessionIds, (sessionId) => - checkpointService.releaseSession({ providerSessionId: sessionId }), + directory.getProvider(sessionId).pipe( + Effect.flatMap((provider) => + directory.upsert({ + sessionId, + provider, + status: "stopped", + runtimePayload: { + activeTurnId: null, + lastRuntimeEvent: "provider.stopAll", + lastRuntimeEventAt: new Date().toISOString(), + }, + }), + ), + ), ).pipe(Effect.asVoid); + // Keep persisted session bindings so stale sessions can be resumed after + // process restart via resumeThreadId. yield* Ref.set(routedSessionAliasesRef, new Map()); }); @@ -605,9 +560,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => respondToRequest, stopSession, listSessions, - listCheckpoints, - getCheckpointDiff, - revertToCheckpoint, + rollbackConversation, stopAll, streamEvents: Stream.fromPubSub(runtimeEventPubSub), } satisfies ProviderServiceShape; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index bcdc5cad250436bcc499c459af62f4f60084976b..1df3f050312f991f1c8412e1ec4b56b20c764a52 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,22 +3,35 @@ import os from "node:os"; import path from "node:path"; import { NodeServices } from "@effect/platform-node"; +import { + ProviderSessionId, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertFailure, assertSome } from "@effect/vitest/utils"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRepositoryLive } from "../../persistence/Layers/ProviderSessions.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const sessionId = (value: string) => ProviderSessionId.makeUnsafe(value); +const threadId = (value: string) => ThreadId.makeUnsafe(value); +const providerThreadId = (value: string) => ProviderThreadId.makeUnsafe(value); + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - return ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + return Layer.mergeAll( + runtimeRepositoryLayer, + ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), + ); } const layer = it.layer(makeDirectoryLayer(SqlitePersistenceMemory)); @@ -27,31 +40,43 @@ layer("ProviderSessionDirectoryLive", (it) => { it("upserts, reads, and removes session bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; yield* directory.upsert({ - sessionId: "sess-1", + sessionId: sessionId("sess-1"), provider: "codex", - threadId: "thread-1", + threadId: threadId("thread-1"), }); - const provider = yield* directory.getProvider("sess-1"); + const provider = yield* directory.getProvider(sessionId("sess-1")); assert.equal(provider, "codex"); - const threadId = yield* directory.getThreadId("sess-1"); - assertSome(threadId, "thread-1"); + const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-1")); + assertSome(resolvedThreadId, threadId("thread-1")); yield* directory.upsert({ - sessionId: "sess-1", + sessionId: sessionId("sess-1"), provider: "codex", - threadId: "thread-2", + threadId: threadId("thread-2"), }); - const updatedThreadId = yield* directory.getThreadId("sess-1"); - assertSome(updatedThreadId, "thread-2"); + const updatedThreadId = yield* directory.getThreadId(sessionId("sess-1")); + assertSome(updatedThreadId, threadId("thread-2")); + + const runtime = yield* runtimeRepository.getBySessionId({ + providerSessionId: sessionId("sess-1"), + }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.threadId, "thread-2"); + assert.equal(runtime.value.providerThreadId, "thread-2"); + assert.equal(runtime.value.status, "running"); + assert.equal(runtime.value.adapterKey, "codex"); + } const sessionIds = yield* directory.listSessionIds(); - assert.deepEqual(sessionIds, ["sess-1"]); + assert.deepEqual(sessionIds, [sessionId("sess-1")]); - yield* directory.remove("sess-1"); - const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result); + yield* directory.remove(sessionId("sess-1")); + const missingProvider = yield* directory.getProvider(sessionId("sess-1")).pipe(Effect.result); assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); })); @@ -60,7 +85,7 @@ layer("ProviderSessionDirectoryLive", (it) => { const directory = yield* ProviderSessionDirectory; const result = yield* Effect.result( directory.upsert({ - sessionId: " ", + sessionId: sessionId(" "), provider: "codex", }), ); @@ -73,6 +98,54 @@ layer("ProviderSessionDirectoryLive", (it) => { ); })); + it("persists runtime fields and merges payload updates", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + yield* directory.upsert({ + sessionId: sessionId("sess-runtime"), + provider: "codex", + threadId: threadId("thread-runtime"), + providerThreadId: providerThreadId("provider-thread-runtime"), + status: "starting", + resumeCursor: { + resumeThreadId: "provider-thread-runtime", + }, + runtimePayload: { + cwd: "/tmp/project", + model: "gpt-5-codex", + }, + }); + + yield* directory.upsert({ + sessionId: sessionId("sess-runtime"), + provider: "codex", + status: "running", + runtimePayload: { + activeTurnId: "turn-1", + }, + }); + + const runtime = yield* runtimeRepository.getBySessionId({ + providerSessionId: sessionId("sess-runtime"), + }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.threadId, "thread-runtime"); + assert.equal(runtime.value.providerThreadId, "provider-thread-runtime"); + assert.equal(runtime.value.status, "running"); + assert.deepEqual(runtime.value.resumeCursor, { + resumeThreadId: "provider-thread-runtime", + }); + assert.deepEqual(runtime.value.runtimePayload, { + cwd: "/tmp/project", + model: "gpt-5-codex", + activeTurnId: "turn-1", + }); + } + })); + it("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-directory-")); @@ -82,19 +155,27 @@ layer("ProviderSessionDirectoryLive", (it) => { yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ - sessionId: "sess-restart", + sessionId: sessionId("sess-restart"), provider: "codex", - threadId: "thread-restart", + threadId: threadId("thread-restart"), }); }).pipe(Effect.provide(directoryLayer)); yield* Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const provider = yield* directory.getProvider("sess-restart"); + const sql = yield* SqlClient.SqlClient; + const provider = yield* directory.getProvider(sessionId("sess-restart")); assert.equal(provider, "codex"); - const threadId = yield* directory.getThreadId("sess-restart"); - assertSome(threadId, "thread-restart"); + const resolvedThreadId = yield* directory.getThreadId(sessionId("sess-restart")); + assertSome(resolvedThreadId, threadId("thread-restart")); + + const legacyTableRows = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'provider_sessions' + `; + assert.equal(legacyTableRows.length, 0); }).pipe(Effect.provide(directoryLayer)); fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index eae8a444746ce7e84da47a26177a094cd653eb89..74a4dc9f1804ad2a58efdd8b5c0a534086916f55 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,6 +1,12 @@ +import { + ProviderSessionId, + ProviderThreadId, + ThreadId, + type ProviderKind, +} from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; -import { ProviderSessionRepository } from "../../persistence/Services/ProviderSessions.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderSessionNotFoundError, @@ -22,51 +28,154 @@ function toPersistenceError(operation: string) { } function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding { + const sessionId = binding.sessionId.trim(); + const threadId = binding.threadId?.trim(); + const adapterKey = binding.adapterKey?.trim(); + const providerThreadId = + binding.providerThreadId === null ? null : binding.providerThreadId?.trim(); + return { - sessionId: binding.sessionId.trim(), + sessionId: ProviderSessionId.makeUnsafe(sessionId), provider: binding.provider, - ...(binding.threadId?.trim() ? { threadId: binding.threadId.trim() } : {}), + ...(adapterKey !== undefined && adapterKey.length > 0 ? { adapterKey } : {}), + ...(threadId !== undefined && threadId.length > 0 + ? { threadId: ThreadId.makeUnsafe(threadId) } + : {}), + ...(providerThreadId === null + ? { providerThreadId: null } + : providerThreadId !== undefined && providerThreadId.length > 0 + ? { providerThreadId: ProviderThreadId.makeUnsafe(providerThreadId) } + : {}), + ...(binding.status !== undefined ? { status: binding.status } : {}), + ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}), + ...(binding.runtimePayload !== undefined ? { runtimePayload: binding.runtimePayload } : {}), }; } +function decodeProviderKind( + providerName: string, + operation: string, +): Effect.Effect { + if (providerName === "codex" || providerName === "claudeCode") { + return Effect.succeed(providerName); + } + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + }), + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function mergeRuntimePayload( + existing: unknown | null, + next: unknown | null | undefined, +): unknown | null { + if (next === undefined) { + return existing ?? null; + } + if (isRecord(existing) && isRecord(next)) { + return { + ...existing, + ...next, + }; + } + return next; +} + const makeProviderSessionDirectory = Effect.gen(function* () { - const repository = yield* ProviderSessionRepository; + const repository = yield* ProviderSessionRuntimeRepository; const persistUpsert = (binding: ProviderSessionBinding) => - repository - .upsertSession({ - sessionId: binding.sessionId, - provider: binding.provider, - ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsertSession"))); + Effect.gen(function* () { + const existing = yield* repository + .getBySessionId({ + providerSessionId: binding.sessionId, + }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")), + ); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }), + ); + } + + const now = new Date().toISOString(); + yield* repository + .upsert({ + providerSessionId: binding.sessionId, + threadId: resolvedThreadId, + providerName: binding.provider, + adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, + providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null, + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }); - const persistDelete = (sessionId: string) => + const persistDelete = (sessionId: ProviderSessionId) => repository - .deleteSession({ sessionId }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteSession"))); + .deleteBySessionId({ providerSessionId: sessionId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), + ); - const getBinding = (sessionId: string) => - repository.getSession({ sessionId }).pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getSession")), - Effect.map((entry) => - Option.map(entry, (value) => ({ - sessionId: value.sessionId, - provider: value.provider, - ...(value.threadId !== undefined ? { threadId: value.threadId } : {}), - })), + const getBinding = (sessionId: ProviderSessionId) => + repository.getBySessionId({ providerSessionId: sessionId }).pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), + Effect.flatMap((runtime) => + Option.match(runtime, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((provider) => + Option.some({ + sessionId: value.providerSessionId, + provider, + threadId: value.threadId, + }), + ), + ), + }), ), ); const listBindings = () => - repository.listSessions().pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:listSessions")), - Effect.map((rows) => - rows.map((row) => ({ - sessionId: row.sessionId, - provider: row.provider, - ...(row.threadId !== undefined ? { threadId: row.threadId } : {}), - })), + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe( + Effect.map((provider) => ({ + sessionId: row.providerSessionId, + provider, + threadId: row.threadId, + })), + ), + { concurrency: "unbounded" }, + ), ), ); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index bff88ca30f350583ee7788ff1df637af77f84580..bb51ff8377e33c68bb15c7a15f09db282bc3d73a 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -8,24 +8,28 @@ * @module ProviderAdapter */ import type { + ApprovalRequestId, ProviderApprovalDecision, ProviderKind, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, + ProviderSessionId, ProviderSessionStartInput, + ProviderThreadId, + ProviderTurnId, ProviderTurnStartResult, } from "@t3tools/contracts"; import type { Effect } from "effect"; import type { Stream } from "effect"; export interface ProviderThreadTurnSnapshot { - readonly id: string; + readonly id: ProviderTurnId; readonly items: ReadonlyArray; } export interface ProviderThreadSnapshot { - readonly threadId: string; + readonly threadId: ProviderThreadId; readonly turns: ReadonlyArray; } @@ -52,21 +56,24 @@ export interface ProviderAdapterShape { /** * Interrupt an active turn. */ - readonly interruptTurn: (sessionId: string, turnId?: string) => Effect.Effect; + readonly interruptTurn: ( + sessionId: ProviderSessionId, + turnId?: ProviderTurnId, + ) => Effect.Effect; /** * Respond to an interactive approval request. */ readonly respondToRequest: ( - sessionId: string, - requestId: string, + sessionId: ProviderSessionId, + requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Effect.Effect; /** * Stop one provider session. */ - readonly stopSession: (sessionId: string) => Effect.Effect; + readonly stopSession: (sessionId: ProviderSessionId) => Effect.Effect; /** * List currently active provider sessions for this adapter. @@ -76,18 +83,20 @@ export interface ProviderAdapterShape { /** * Check whether this adapter owns an active session id. */ - readonly hasSession: (sessionId: string) => Effect.Effect; + readonly hasSession: (sessionId: ProviderSessionId) => Effect.Effect; /** * Read a provider thread snapshot. */ - readonly readThread: (sessionId: string) => Effect.Effect; + readonly readThread: ( + sessionId: ProviderSessionId, + ) => Effect.Effect; /** * Roll back a provider thread by N turns. */ readonly rollbackThread: ( - sessionId: string, + sessionId: ProviderSessionId, numTurns: number, ) => Effect.Effect; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 78fc1adf48c73e6f41c2705856b0d38859f463b1..d85010b5aa43fc42f707508852c79191cc5475e7 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -12,20 +12,15 @@ * @module ProviderService */ import type { - ProviderGetCheckpointDiffInput, - ProviderGetCheckpointDiffResult, ProviderInterruptTurnInput, - ProviderListCheckpointsInput, - ProviderListCheckpointsResult, ProviderRespondToRequestInput, - ProviderRevertToCheckpointInput, - ProviderRevertToCheckpointResult, + ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, + ProviderSessionId, ProviderSessionStartInput, ProviderStopSessionInput, ProviderTurnStartResult, - ProviderRuntimeEvent, } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; @@ -74,25 +69,12 @@ export interface ProviderServiceShape { readonly listSessions: () => Effect.Effect>; /** - * List checkpoints for a provider session. + * Roll back provider conversation state by a number of turns. */ - readonly listCheckpoints: ( - input: ProviderListCheckpointsInput, - ) => Effect.Effect; - - /** - * Diff two checkpoints for a provider session. - */ - readonly getCheckpointDiff: ( - input: ProviderGetCheckpointDiffInput, - ) => Effect.Effect; - - /** - * Revert a provider session to a checkpoint. - */ - readonly revertToCheckpoint: ( - input: ProviderRevertToCheckpointInput, - ) => Effect.Effect; + readonly rollbackConversation: (input: { + readonly sessionId: ProviderSessionId; + readonly numTurns: number; + }) => Effect.Effect; /** * Stop all active provider sessions. diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 5563663e71ad50b43b8002a394c4edf2b1b8d6aa..6d2a36c5f6e6889ee3ab4267939766be68538404 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -7,7 +7,13 @@ * * @module ProviderSessionDirectory */ -import type { ProviderKind } from "@t3tools/contracts"; +import type { + ProviderKind, + ProviderSessionId, + ProviderSessionRuntimeStatus, + ProviderThreadId, + ThreadId, +} from "@t3tools/contracts"; import { Option, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -18,9 +24,14 @@ import type { } from "../Errors.ts"; export interface ProviderSessionBinding { - readonly sessionId: string; + readonly sessionId: ProviderSessionId; readonly provider: ProviderKind; - readonly threadId?: string; + readonly threadId?: ThreadId; + readonly adapterKey?: string; + readonly providerThreadId?: ProviderThreadId | null; + readonly status?: ProviderSessionRuntimeStatus; + readonly resumeCursor?: unknown | null; + readonly runtimePayload?: unknown | null; } export type ProviderSessionDirectoryReadError = @@ -43,28 +54,28 @@ export interface ProviderSessionDirectoryShape { * Resolve the provider owner for a session id. */ readonly getProvider: ( - sessionId: string, + sessionId: ProviderSessionId, ) => Effect.Effect; /** * Resolve the tracked thread id for a session, if known. */ readonly getThreadId: ( - sessionId: string, - ) => Effect.Effect, ProviderSessionDirectoryReadError>; + sessionId: ProviderSessionId, + ) => Effect.Effect, ProviderSessionDirectoryReadError>; /** * Remove a session binding. */ readonly remove: ( - sessionId: string, + sessionId: ProviderSessionId, ) => Effect.Effect; /** * List tracked session ids. */ readonly listSessionIds: () => Effect.Effect< - ReadonlyArray, + ReadonlyArray, ProviderSessionDirectoryPersistenceError >; diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts deleted file mode 100644 index 08281bdee200323a2727085f68089e60208c3fe3..0000000000000000000000000000000000000000 --- a/apps/server/src/providerManager.test.ts +++ /dev/null @@ -1,892 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { ProviderManager } from "./providerManager"; - -describe("ProviderManager", () => { - it("detaches provider event listener and ends thread log streams on dispose", () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { listenerCount: (event: string) => number }; - onCodexEvent: (event: { - id: string; - kind: "notification"; - provider: "codex"; - sessionId: string; - createdAt: string; - method: string; - threadId: string; - }) => void; - threadLogStreams: Map; - }; - internals.onCodexEvent({ - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: new Date().toISOString(), - method: "thread/started", - threadId: "thread-1", - }); - const stream = internals.threadLogStreams.get("thread-1"); - expect(stream).toBeDefined(); - if (!stream) return; - - expect(internals.codex.listenerCount("event")).toBe(1); - manager.dispose(); - - expect(internals.codex.listenerCount("event")).toBe(0); - expect(stream.writableEnded || stream.destroyed).toBe(true); - expect(internals.threadLogStreams.size).toBe(0); - }); - - it("allows multiple dispose calls", () => { - const manager = new ProviderManager(); - - manager.dispose(); - expect(() => manager.dispose()).not.toThrow(); - }); - - it("rejects request responses for unknown sessions", async () => { - const manager = new ProviderManager(); - - await expect( - manager.respondToRequest({ - sessionId: "missing-session", - requestId: "req-1", - decision: "accept", - }), - ).rejects.toThrow("Unknown provider session: missing-session"); - - manager.dispose(); - }); - - it("rejects checkpoint operations for unknown sessions", async () => { - const manager = new ProviderManager(); - - await expect( - manager.listCheckpoints({ - sessionId: "missing-session", - }), - ).rejects.toThrow("Unknown provider session: missing-session"); - - await expect( - manager.revertToCheckpoint({ - sessionId: "missing-session", - turnCount: 0, - }), - ).rejects.toThrow("Unknown provider session: missing-session"); - - await expect( - manager.getCheckpointDiff({ - sessionId: "missing-session", - fromTurnCount: 0, - toTurnCount: 1, - }), - ).rejects.toThrow("Unknown provider session: missing-session"); - - manager.dispose(); - }); - - it("derives checkpoints from thread turns", async () => { - const manager = new ProviderManager(); - const codex = ( - manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - } - ).codex; - - codex.hasSession = () => true; - codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { - id: "turn_1", - items: [ - { - type: "userMessage", - content: [{ type: "text", text: "Refactor the logger" }], - }, - { - type: "agentMessage", - text: "I refactored it.", - }, - ], - }, - ], - }); - - const result = await manager.listCheckpoints({ - sessionId: "sess_1", - }); - - expect(result).toEqual({ - threadId: "thr_1", - checkpoints: [ - { - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: false, - }, - { - id: "turn_1", - turnCount: 1, - messageCount: 2, - label: "Turn 1", - preview: "Refactor the logger", - isCurrent: true, - }, - ], - }); - - manager.dispose(); - }); - - it("rolls back from a selected checkpoint turn count", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - hasCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - restoreCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - pruneAfterTurn: (input: { - cwd: string; - threadId: string; - maxTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; - internals.codex.hasSession = () => true; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - { id: "turn_3", items: [] }, - ], - }); - internals.codex.rollbackThread = async (sessionId, numTurns) => { - rollbackCalls.push({ sessionId, numTurns }); - return { - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - }; - }; - const hasCheckpoint = vi.fn(async () => true); - const restoreCheckpoint = vi.fn(async () => true); - const pruneAfterTurn = vi.fn(async () => undefined); - internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; - internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; - internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - const result = await manager.revertToCheckpoint({ - sessionId: "sess_1", - turnCount: 1, - }); - - expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]); - expect(result.threadId).toBe("thr_1"); - expect(result.turnCount).toBe(1); - expect(result.messageCount).toBe(0); - expect(result.rolledBackTurns).toBe(2); - expect(result.checkpoints).toHaveLength(2); - expect(hasCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(restoreCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(pruneAfterTurn).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - maxTurnCount: 1, - }); - - manager.dispose(); - }); - - it("returns diff text for a checkpoint range", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - diffCheckpoints: (input: { - cwd: string; - threadId: string; - fromTurnCount: number; - toTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - ], - }); - const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); - internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - const result = await manager.getCheckpointDiff({ - sessionId: "sess_1", - fromTurnCount: 1, - toTurnCount: 2, - }); - - expect(result).toEqual({ - threadId: "thr_1", - fromTurnCount: 1, - toTurnCount: 2, - diff: "diff --git a/a.ts b/a.ts", - }); - expect(diffCheckpoints).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - fromTurnCount: 1, - toTurnCount: 2, - }); - - manager.dispose(); - }); - - it("emits checkpoint/captured after turn completion checkpoint capture", async () => { - const manager = new ProviderManager(); - const events: Array<{ method: string; payload?: unknown; threadId?: string; turnId?: string }> = - []; - manager.on("event", (event) => { - events.push({ - method: event.method, - ...(event.payload !== undefined ? { payload: event.payload } : {}), - ...(event.threadId ? { threadId: event.threadId } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), - }); - }); - - const internals = manager as unknown as { - onCodexEvent: (event: { - id: string; - kind: "notification"; - provider: "codex"; - sessionId: string; - createdAt: string; - method: string; - threadId: string; - turnId?: string; - payload?: unknown; - }) => void; - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - captureCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - }); - const captureCheckpoint = vi.fn(async () => undefined); - internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - internals.onCodexEvent({ - id: "evt-turn-complete", - kind: "notification", - provider: "codex", - sessionId: "sess_1", - createdAt: "2026-02-19T00:00:00.000Z", - method: "turn/completed", - threadId: "thr_1", - turnId: "turn_1", - payload: { - turn: { - status: "completed", - }, - }, - }); - - await vi.waitFor(() => { - expect(captureCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true); - }); - - const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); - expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); - expect(checkpointCapturedEvent?.turnId).toBe("turn_1"); - expect(checkpointCapturedEvent?.payload).toEqual({ - threadId: "thr_1", - turnCount: 1, - turnId: "turn_1", - status: "completed", - }); - - manager.dispose(); - }); - - it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - listSessions: () => Array<{ - sessionId: string; - provider: "codex"; - status: "ready"; - createdAt: string; - updatedAt: string; - cwd?: string; - }>; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; - captureCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - diffCheckpoints: (input: { - cwd: string; - threadId: string; - fromTurnCount: number; - toTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - internals.codex.listSessions = () => [ - { - sessionId: "sess_1", - provider: "codex", - status: "ready", - createdAt: "2026-02-18T00:00:00.000Z", - updatedAt: "2026-02-18T00:00:00.000Z", - cwd: "/repo", - }, - ]; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - ], - }); - const isGitRepository = vi.fn(async () => true); - const ensureRootCheckpoint = vi.fn(async () => true); - const captureCheckpoint = vi.fn(async () => undefined); - const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); - internals.filesystemCheckpointStore.isGitRepository = isGitRepository; - internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; - internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; - internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; - internals.sessionCheckpointCwds.delete("sess_1"); - - const result = await manager.getCheckpointDiff({ - sessionId: "sess_1", - fromTurnCount: 1, - toTurnCount: 2, - }); - - expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); - expect(isGitRepository).toHaveBeenCalledWith("/repo"); - expect(captureCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 2, - }); - expect(ensureRootCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - }); - expect(diffCheckpoints).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - fromTurnCount: 1, - toTurnCount: 2, - }); - expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo"); - - manager.dispose(); - }); - - it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - listSessions: () => Array<{ - sessionId: string; - provider: "codex"; - status: "ready"; - createdAt: string; - updatedAt: string; - cwd?: string; - }>; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; - captureCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - diffCheckpoints: (input: { - cwd: string; - threadId: string; - fromTurnCount: number; - toTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); - try { - internals.codex.hasSession = () => true; - internals.codex.listSessions = () => [ - { - sessionId: "sess_1", - provider: "codex", - status: "ready", - createdAt: "2026-02-18T00:00:00.000Z", - updatedAt: "2026-02-18T00:00:00.000Z", - }, - ]; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - }); - const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); - const ensureRootCheckpoint = vi.fn(async () => true); - const captureCheckpoint = vi.fn(async () => undefined); - const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); - internals.filesystemCheckpointStore.isGitRepository = isGitRepository; - internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; - internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; - internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; - internals.sessionCheckpointCwds.delete("sess_1"); - - const result = await manager.getCheckpointDiff({ - sessionId: "sess_1", - fromTurnCount: 0, - toTurnCount: 1, - }); - - expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); - expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo"); - expect(captureCheckpoint).toHaveBeenCalledWith({ - cwd: "/fallback-repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(ensureRootCheckpoint).toHaveBeenCalledWith({ - cwd: "/fallback-repo", - threadId: "thr_1", - }); - expect(diffCheckpoints).toHaveBeenCalledWith({ - cwd: "/fallback-repo", - threadId: "thr_1", - fromTurnCount: 0, - toTurnCount: 1, - }); - expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo"); - } finally { - processCwd.mockRestore(); - manager.dispose(); - } - }); - - it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - listSessions: () => Array<{ - sessionId: string; - provider: "codex"; - status: "ready"; - createdAt: string; - updatedAt: string; - cwd?: string; - }>; - }; - filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); - try { - internals.codex.hasSession = () => true; - internals.codex.listSessions = () => [ - { - sessionId: "sess_1", - provider: "codex", - status: "ready", - createdAt: "2026-02-18T00:00:00.000Z", - updatedAt: "2026-02-18T00:00:00.000Z", - cwd: "/session-cwd", - }, - ]; - const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); - internals.filesystemCheckpointStore.isGitRepository = isGitRepository; - internals.sessionCheckpointCwds.delete("sess_1"); - - await expect( - manager.getCheckpointDiff({ - sessionId: "sess_1", - fromTurnCount: 0, - toTurnCount: 1, - }), - ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); - expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); - expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); - } finally { - processCwd.mockRestore(); - manager.dispose(); - } - }); - - it("fails revert when filesystem checkpoints are unavailable for the session", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - listSessions: () => Array<{ - sessionId: string; - provider: "codex"; - status: "ready"; - createdAt: string; - updatedAt: string; - cwd?: string; - }>; - }; - filesystemCheckpointStore: { - isGitRepository: (cwd: string) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - internals.codex.listSessions = () => [ - { - sessionId: "sess_1", - provider: "codex", - status: "ready", - createdAt: "2026-02-18T00:00:00.000Z", - updatedAt: "2026-02-18T00:00:00.000Z", - }, - ]; - internals.filesystemCheckpointStore.isGitRepository = async () => false; - internals.sessionCheckpointCwds.delete("sess_1"); - - await expect( - manager.revertToCheckpoint({ - sessionId: "sess_1", - turnCount: 1, - }), - ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); - - manager.dispose(); - }); - - it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - hasCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - restoreCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - pruneAfterTurn: (input: { - cwd: string; - threadId: string; - maxTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - const callOrder: string[] = []; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - ], - }); - internals.codex.rollbackThread = async () => { - callOrder.push("rollback"); - return { - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - }; - }; - const hasCheckpoint = vi.fn(async () => true); - const restoreCheckpoint = vi.fn(async () => { - callOrder.push("restore"); - return true; - }); - const pruneAfterTurn = vi.fn(async () => { - callOrder.push("prune"); - return undefined; - }); - internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; - internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; - internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - const result = await manager.revertToCheckpoint({ - sessionId: "sess_1", - turnCount: 1, - }); - - expect(result.turnCount).toBe(1); - expect(hasCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(restoreCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 1, - }); - expect(pruneAfterTurn).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - maxTurnCount: 1, - }); - expect(callOrder).toEqual(["restore", "rollback", "prune"]); - - manager.dispose(); - }); - - it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - hasCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - restoreCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - pruneAfterTurn: (input: { - cwd: string; - threadId: string; - maxTurnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - internals.codex.hasSession = () => true; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - ], - }); - internals.codex.rollbackThread = async () => ({ - threadId: "thr_1", - turns: [], - }); - const hasCheckpoint = vi.fn(async () => false); - const restoreCheckpoint = vi.fn(async () => true); - const pruneAfterTurn = vi.fn(async () => undefined); - internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; - internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; - internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - const result = await manager.revertToCheckpoint({ - sessionId: "sess_1", - turnCount: 0, - }); - - expect(result.turnCount).toBe(0); - expect(hasCheckpoint).not.toHaveBeenCalled(); - expect(restoreCheckpoint).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - turnCount: 0, - }); - expect(pruneAfterTurn).toHaveBeenCalledWith({ - cwd: "/repo", - threadId: "thr_1", - maxTurnCount: 0, - }); - - manager.dispose(); - }); - - it("fails before rollback when filesystem checkpoint is missing", async () => { - const manager = new ProviderManager(); - const internals = manager as unknown as { - codex: { - hasSession: (sessionId: string) => boolean; - readThread: (sessionId: string) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Promise<{ - threadId: string; - turns: Array<{ id: string; items: unknown[] }>; - }>; - }; - filesystemCheckpointStore: { - hasCheckpoint: (input: { - cwd: string; - threadId: string; - turnCount: number; - }) => Promise; - }; - sessionCheckpointCwds: Map; - }; - - const rollbackSpy = vi.fn(async () => ({ - threadId: "thr_1", - turns: [{ id: "turn_1", items: [] }], - })); - - internals.codex.hasSession = () => true; - internals.codex.readThread = async () => ({ - threadId: "thr_1", - turns: [ - { id: "turn_1", items: [] }, - { id: "turn_2", items: [] }, - ], - }); - internals.codex.rollbackThread = rollbackSpy; - internals.filesystemCheckpointStore.hasCheckpoint = async () => false; - internals.sessionCheckpointCwds.set("sess_1", "/repo"); - - await expect( - manager.revertToCheckpoint({ - sessionId: "sess_1", - turnCount: 1, - }), - ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1."); - expect(rollbackSpy).not.toHaveBeenCalled(); - - manager.dispose(); - }); -}); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts deleted file mode 100644 index 5844b172a39c5bf25ef2b1742cc576928279371c..0000000000000000000000000000000000000000 --- a/apps/server/src/providerManager.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; - -import { - type ProviderCheckpoint, - type ProviderEvent, - type ProviderGetCheckpointDiffInput, - type ProviderGetCheckpointDiffResult, - type ProviderInterruptTurnInput, - type ProviderListCheckpointsInput, - type ProviderListCheckpointsResult, - type ProviderRevertToCheckpointInput, - type ProviderRevertToCheckpointResult, - type ProviderRespondToRequestInput, - type ProviderSendTurnInput, - type ProviderSession, - type ProviderSessionStartInput, - type ProviderStopSessionInput, - type ProviderTurnStartResult, - providerInterruptTurnInputSchema, - providerGetCheckpointDiffInputSchema, - providerListCheckpointsInputSchema, - providerRevertToCheckpointInputSchema, - providerRespondToRequestInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, - providerStopSessionInputSchema, -} from "@t3tools/contracts"; -import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; -import { CodexAppServerManager } from "./codexAppServerManager"; -import { FilesystemCheckpointStore } from "./filesystemCheckpointStore"; - -export interface ProviderManagerEvents { - event: [event: ProviderEvent]; -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function trimToPreview(value: string): string { - const normalized = value.replace(/\s+/g, " ").trim(); - if (normalized.length <= 120) { - return normalized; - } - return `${normalized.slice(0, 117)}...`; -} - -function summarizeUserMessageContent(content: unknown[]): string | undefined { - const segments: string[] = []; - - for (const part of content) { - const record = asObject(part); - const type = asString(record?.type); - if (!type) continue; - - if (type === "text") { - const text = asString(record?.text); - if (text && text.trim().length > 0) { - segments.push(text.trim()); - } - continue; - } - - if (type === "image") { - segments.push("[Image attachment]"); - continue; - } - - if (type === "localImage") { - segments.push("[Local image attachment]"); - continue; - } - } - - if (segments.length === 0) { - return undefined; - } - return trimToPreview(segments.join(" ")); -} - -function summarizeTurn(turn: CodexThreadTurnSnapshot): { - messageCountDelta: number; - preview?: string; -} { - let messageCountDelta = 0; - let preview: string | undefined; - - for (const item of turn.items) { - const record = asObject(item); - const type = asString(record?.type); - if (!type) continue; - - if (type === "userMessage") { - messageCountDelta += 1; - if (!preview) { - const content = asArray(record?.content); - preview = summarizeUserMessageContent(content); - } - continue; - } - - if (type === "agentMessage") { - messageCountDelta += 1; - if (!preview) { - const text = asString(record?.text); - if (text && text.trim().length > 0) { - preview = trimToPreview(text); - } - } - } - } - - return { - messageCountDelta, - ...(preview ? { preview } : {}), - }; -} - -function readTurnCompletionStatus(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.status); -} - -function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { - const checkpoints: ProviderCheckpoint[] = []; - let messageCount = 0; - const isEmpty = turns.length === 0; - checkpoints.push({ - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: isEmpty, - }); - - for (let index = 0; index < turns.length; index += 1) { - const turn = turns[index]; - if (!turn) continue; - const turnSummary = summarizeTurn(turn); - messageCount += turnSummary.messageCountDelta; - checkpoints.push({ - id: turn.id, - turnCount: index + 1, - messageCount, - label: `Turn ${index + 1}`, - ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), - isCurrent: index === turns.length - 1, - }); - } - - return checkpoints; -} - -export class ProviderManager extends EventEmitter { - private readonly codex = new CodexAppServerManager(); - private readonly filesystemCheckpointStore = new FilesystemCheckpointStore(); - private readonly threadLogsDir: string; - private readonly threadLogStreams = new Map(); - private readonly sessionThreadIds = new Map(); - private readonly pendingEventsBySession = new Map(); - private readonly sessionCheckpointCwds = new Map(); - private readonly filesystemLocks = new Map>(); - private disposed = false; - private readonly onCodexEvent = (event: ProviderEvent) => { - if (this.disposed) { - return; - } - - this.routeEventToThreadLog(event); - this.emit("event", event); - this.maybeCaptureFilesystemCheckpoint(event); - }; - - constructor() { - super(); - - const logsDir = path.resolve(process.cwd(), ".logs"); - this.threadLogsDir = path.join(logsDir, "threads"); - fs.mkdirSync(this.threadLogsDir, { recursive: true }); - - this.codex.on("event", this.onCodexEvent); - } - - async startSession(raw: ProviderSessionStartInput): Promise { - const input = providerSessionStartInputSchema.parse(raw); - if (input.provider !== "codex") { - throw new Error(`Provider '${input.provider}' is not implemented yet.`); - } - - const session = await this.codex.startSession(input); - if (session.threadId) { - this.sessionThreadIds.set(session.sessionId, session.threadId); - this.flushPendingSessionEvents(session.sessionId, session.threadId); - } - await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => { - const message = - error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; - this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); - }); - return session; - } - - async sendTurn(raw: ProviderSendTurnInput): Promise { - const input = providerSendTurnInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - return this.codex.sendTurn(input); - } - - async interruptTurn(raw: ProviderInterruptTurnInput): Promise { - const input = providerInterruptTurnInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - await this.codex.interruptTurn(input.sessionId, input.turnId); - } - - async respondToRequest(raw: ProviderRespondToRequestInput): Promise { - const input = providerRespondToRequestInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision); - } - - stopSession(raw: ProviderStopSessionInput): void { - const input = providerStopSessionInputSchema.parse(raw); - this.codex.stopSession(input.sessionId); - this.sessionThreadIds.delete(input.sessionId); - this.pendingEventsBySession.delete(input.sessionId); - this.sessionCheckpointCwds.delete(input.sessionId); - this.filesystemLocks.delete(input.sessionId); - } - - listSessions(): ProviderSession[] { - return this.codex.listSessions(); - } - - async listCheckpoints(raw: ProviderListCheckpointsInput): Promise { - const input = providerListCheckpointsInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - const snapshot = await this.codex.readThread(input.sessionId); - return { - threadId: snapshot.threadId, - checkpoints: buildCheckpoints(snapshot.turns), - }; - } - - async getCheckpointDiff( - raw: ProviderGetCheckpointDiffInput, - ): Promise { - const input = providerGetCheckpointDiffInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); - if (!checkpointCwd) { - throw new Error("Filesystem checkpoints are unavailable for this session."); - } - - return this.withFilesystemLock(input.sessionId, async () => { - const snapshot = await this.codex.readThread(input.sessionId); - if (input.toTurnCount > snapshot.turns.length) { - throw new Error( - `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, - ); - } - - const diff = await this.filesystemCheckpointStore.diffCheckpoints({ - cwd: checkpointCwd, - threadId: snapshot.threadId, - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - }); - - return { - threadId: snapshot.threadId, - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff, - }; - }); - } - - async revertToCheckpoint( - raw: ProviderRevertToCheckpointInput, - ): Promise { - const input = providerRevertToCheckpointInputSchema.parse(raw); - if (!this.codex.hasSession(input.sessionId)) { - throw new Error(`Unknown provider session: ${input.sessionId}`); - } - - const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); - if (!checkpointCwd) { - throw new Error("Filesystem checkpoints are unavailable for this session."); - } - return this.withFilesystemLock(input.sessionId, async () => { - const beforeSnapshot = await this.codex.readThread(input.sessionId); - const currentTurnCount = beforeSnapshot.turns.length; - if (input.turnCount > currentTurnCount) { - throw new Error( - `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, - ); - } - - if (checkpointCwd && input.turnCount > 0) { - const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ - cwd: checkpointCwd, - threadId: beforeSnapshot.threadId, - turnCount: input.turnCount, - }); - if (!hasCheckpoint) { - throw new Error( - `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, - ); - } - } - - const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ - cwd: checkpointCwd, - threadId: beforeSnapshot.threadId, - turnCount: input.turnCount, - }); - if (!restored) { - throw new Error( - `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, - ); - } - - const requestedRollbackTurns = currentTurnCount - input.turnCount; - const afterSnapshot = - requestedRollbackTurns > 0 - ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) - : beforeSnapshot; - - await this.filesystemCheckpointStore.pruneAfterTurn({ - cwd: checkpointCwd, - threadId: afterSnapshot.threadId, - maxTurnCount: afterSnapshot.turns.length, - }); - - const checkpoints = buildCheckpoints(afterSnapshot.turns); - const currentCheckpoint = - checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? - checkpoints[checkpoints.length - 1] ?? - checkpoints[0]; - const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); - - return { - threadId: afterSnapshot.threadId, - turnCount: currentCheckpoint?.turnCount ?? 0, - messageCount: currentCheckpoint?.messageCount ?? 0, - rolledBackTurns, - checkpoints, - }; - }); - } - - stopAll(): void { - this.codex.stopAll(); - } - - dispose(): void { - if (this.disposed) { - return; - } - - this.disposed = true; - this.codex.off("event", this.onCodexEvent); - for (const stream of this.threadLogStreams.values()) { - stream.end(); - } - this.threadLogStreams.clear(); - this.sessionThreadIds.clear(); - this.pendingEventsBySession.clear(); - this.sessionCheckpointCwds.clear(); - this.filesystemLocks.clear(); - } - - private async initializeFilesystemCheckpointing( - session: ProviderSession, - preferredCwd?: string, - ): Promise { - const cwd = preferredCwd ?? session.cwd ?? process.cwd(); - - await this.withFilesystemLock(session.sessionId, async () => { - const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); - if (!supportsGit) { - this.sessionCheckpointCwds.delete(session.sessionId); - return; - } - - const snapshot = await this.codex.readThread(session.sessionId); - await this.filesystemCheckpointStore.ensureRootCheckpoint({ - cwd, - threadId: snapshot.threadId, - }); - await this.filesystemCheckpointStore.captureCheckpoint({ - cwd, - threadId: snapshot.threadId, - turnCount: snapshot.turns.length, - }); - if (this.codex.hasSession(session.sessionId)) { - this.sessionCheckpointCwds.set(session.sessionId, cwd); - } - }); - } - - private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise { - const existingCwd = this.sessionCheckpointCwds.get(sessionId); - if (existingCwd) { - return existingCwd; - } - - const session = this.codex - .listSessions() - .find((candidate) => candidate.sessionId === sessionId); - const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; - - await this.withFilesystemLock(sessionId, async () => { - const currentCwd = this.sessionCheckpointCwds.get(sessionId); - if (currentCwd) { - return; - } - - const cwdSupport = await Promise.all( - candidateCwds.map(async (cwd) => ({ - cwd, - supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), - })), - ); - const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; - if (supportedCwd) { - const snapshot = await this.codex.readThread(sessionId); - await this.filesystemCheckpointStore.ensureRootCheckpoint({ - cwd: supportedCwd, - threadId: snapshot.threadId, - }); - await this.filesystemCheckpointStore.captureCheckpoint({ - cwd: supportedCwd, - threadId: snapshot.threadId, - turnCount: snapshot.turns.length, - }); - if (this.codex.hasSession(sessionId)) { - this.sessionCheckpointCwds.set(sessionId, supportedCwd); - } - return; - } - - this.sessionCheckpointCwds.delete(sessionId); - }); - - return this.sessionCheckpointCwds.get(sessionId) ?? null; - } - - private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { - if (event.kind !== "notification" || event.method !== "turn/completed") { - return; - } - - const turnStatus = readTurnCompletionStatus(event); - const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); - if (!checkpointCwd) { - void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) - .then(async (initializedCwd) => { - if (!initializedCwd || !this.codex.hasSession(event.sessionId)) { - return; - } - const snapshot = await this.codex.readThread(event.sessionId); - this.emitCheckpointCaptured( - event.sessionId, - snapshot.threadId, - snapshot.turns.length, - event.turnId, - turnStatus, - ); - }) - .catch((error) => { - const message = - error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; - this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); - }); - return; - } - - void this.withFilesystemLock(event.sessionId, async () => { - if (!this.codex.hasSession(event.sessionId)) { - return; - } - - const snapshot = await this.codex.readThread(event.sessionId); - await this.filesystemCheckpointStore.captureCheckpoint({ - cwd: checkpointCwd, - threadId: snapshot.threadId, - turnCount: snapshot.turns.length, - }); - this.emitCheckpointCaptured( - event.sessionId, - snapshot.threadId, - snapshot.turns.length, - event.turnId, - turnStatus, - ); - }).catch((error) => { - const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; - this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); - }); - } - - private emitCheckpointCaptured( - sessionId: string, - threadId: string, - turnCount: number, - turnId?: string, - status?: string, - ): void { - this.emit("event", { - id: randomUUID(), - kind: "notification", - provider: "codex", - sessionId, - createdAt: new Date().toISOString(), - method: "checkpoint/captured", - threadId, - ...(turnId ? { turnId } : {}), - payload: { - threadId, - turnCount, - ...(turnId ? { turnId } : {}), - ...(status ? { status } : {}), - }, - }); - } - - private emitFilesystemCheckpointError( - sessionId: string, - message: string, - threadId?: string, - ): void { - this.emit("event", { - id: randomUUID(), - kind: "error", - provider: "codex", - sessionId, - createdAt: new Date().toISOString(), - method: "checkpoint/filesystemError", - message, - ...(threadId ? { threadId } : {}), - }); - } - - private withFilesystemLock(sessionId: string, fn: () => Promise): Promise { - const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); - const next = previous.catch(() => undefined).then(fn); - const completion = next.then( - () => undefined, - () => undefined, - ); - this.filesystemLocks.set(sessionId, completion); - return next.finally(() => { - const tracked = this.filesystemLocks.get(sessionId); - if (tracked === completion) { - this.filesystemLocks.delete(sessionId); - } - }); - } - - private routeEventToThreadLog(event: ProviderEvent): void { - const threadId = this.resolveThreadId(event); - if (!threadId) { - const pending = this.pendingEventsBySession.get(event.sessionId) ?? []; - pending.push(event); - this.pendingEventsBySession.set(event.sessionId, pending.slice(-100)); - return; - } - - this.flushPendingSessionEvents(event.sessionId, threadId); - this.writeThreadEvent(threadId, event); - } - - private flushPendingSessionEvents(sessionId: string, threadId: string): void { - const pending = this.pendingEventsBySession.get(sessionId); - if (!pending || pending.length === 0) { - return; - } - - for (const event of pending) { - this.writeThreadEvent(threadId, event); - } - this.pendingEventsBySession.delete(sessionId); - } - - private resolveThreadId(event: ProviderEvent): string | undefined { - const fromPayload = this.readThreadIdFromPayload(event.payload); - const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId); - - if (threadId) { - this.sessionThreadIds.set(event.sessionId, threadId); - } - - return threadId; - } - - private readThreadIdFromPayload(payload: unknown): string | undefined { - if (!payload || typeof payload !== "object") { - return undefined; - } - - const record = payload as Record; - const direct = record.threadId; - if (typeof direct === "string" && direct.length > 0) { - return direct; - } - - const thread = record.thread; - if (!thread || typeof thread !== "object") { - return undefined; - } - - const nested = (thread as Record).id; - if (typeof nested === "string" && nested.length > 0) { - return nested; - } - - return undefined; - } - - private writeThreadEvent(threadId: string, event: ProviderEvent): void { - const stream = this.getOrCreateThreadLogStream(threadId); - stream.write(`${JSON.stringify(event)}\n`); - } - - private getOrCreateThreadLogStream(threadId: string): fs.WriteStream { - const cached = this.threadLogStreams.get(threadId); - if (cached) { - return cached; - } - - const safeThreadId = threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); - const stream = fs.createWriteStream( - path.join(this.threadLogsDir, `${safeThreadId}.events.ndjson`), - { flags: "a" }, - ); - this.threadLogStreams.set(threadId, stream); - return stream; - } -} diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 05217e90a4b8629a839f8d57da2935774ecc2570..a9e6533141a9b28e58441a67ad38640266f12268 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -4,20 +4,16 @@ import path from "node:path"; import { DEFAULT_TERMINAL_ID, - type TerminalClearInput, - type TerminalCloseInput, + TerminalClearInput, + TerminalCloseInput, + TerminalOpenInput, + TerminalResizeInput, + TerminalWriteInput, type TerminalEvent, - type TerminalOpenInput, - type TerminalResizeInput, type TerminalSessionSnapshot, type TerminalSessionStatus, - type TerminalWriteInput, - terminalClearInputSchema, - terminalCloseInputSchema, - terminalOpenInputSchema, - terminalResizeInputSchema, - terminalWriteInputSchema, } from "@t3tools/contracts"; +import { Schema } from "effect"; import { createLogger } from "./logger"; import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; @@ -30,6 +26,12 @@ const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput); +const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput); +const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); +const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); +const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); + type TerminalSubprocessChecker = (terminalPid: number) => Promise; export interface TerminalManagerEvents { @@ -332,7 +334,7 @@ export class TerminalManager extends EventEmitter { } async open(raw: TerminalOpenInput): Promise { - const input = terminalOpenInputSchema.parse(raw); + const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); @@ -404,7 +406,7 @@ export class TerminalManager extends EventEmitter { } async write(raw: TerminalWriteInput): Promise { - const input = terminalWriteInputSchema.parse(raw); + const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { throw new Error( @@ -415,7 +417,7 @@ export class TerminalManager extends EventEmitter { } async resize(raw: TerminalResizeInput): Promise { - const input = terminalResizeInputSchema.parse(raw); + const input = decodeTerminalResizeInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { throw new Error( @@ -429,7 +431,7 @@ export class TerminalManager extends EventEmitter { } async clear(raw: TerminalClearInput): Promise { - const input = terminalClearInputSchema.parse(raw); + const input = decodeTerminalClearInput(raw); await this.runWithThreadLock(input.threadId, async () => { const session = this.requireSession(input.threadId, input.terminalId); session.history = ""; @@ -445,7 +447,7 @@ export class TerminalManager extends EventEmitter { } async restart(raw: TerminalOpenInput): Promise { - const input = terminalOpenInputSchema.parse(raw); + const input = decodeTerminalOpenInput(raw); return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); @@ -490,7 +492,7 @@ export class TerminalManager extends EventEmitter { } async close(raw: TerminalCloseInput): Promise { - const input = terminalCloseInputSchema.parse(raw); + const input = decodeTerminalCloseInput(raw); await this.runWithThreadLock(input.threadId, async () => { if (input.terminalId) { await this.closeSession(input.threadId, input.terminalId, input.deleteHistory === true); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index dae321ecb1c9a78c49f7d72d9bcf5b6a6a36c180..2c215bb6db57ff985140167b23157d37277522c5 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -4,10 +4,9 @@ import path from "node:path"; import { runProcess } from "./processRunner"; import { - projectSearchEntriesInputSchema, - type ProjectEntry, - type ProjectSearchEntriesInput, - type ProjectSearchEntriesResult, + ProjectEntry, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_CACHE_TTL_MS = 15_000; @@ -413,9 +412,8 @@ async function getWorkspaceIndex(cwd: string): Promise { export async function searchWorkspaceEntries( input: ProjectSearchEntriesInput, ): Promise { - const parsed = projectSearchEntriesInputSchema.parse(input); - const index = await getWorkspaceIndex(parsed.cwd); - const normalizedQuery = normalizeQuery(parsed.query); + const index = await getWorkspaceIndex(input.cwd); + const normalizedQuery = normalizeQuery(input.query); const candidates = normalizedQuery ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) : index.entries; @@ -427,7 +425,7 @@ export async function searchWorkspaceEntries( }); return { - entries: ranked.slice(0, parsed.limit), - truncated: index.truncated || ranked.length > parsed.limit, + entries: ranked.slice(0, input.limit), + truncated: index.truncated || ranked.length > input.limit, }; } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 23599b77016e9c5dcf7c1b514dc8888aa6a03987..834e408943611433145a64c13be302a159468a6d 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { EventEmitter } from "node:events"; -import { Effect, Layer, ManagedRuntime, PubSub, Stream } from "effect"; +import { Effect, Layer, PubSub, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; @@ -21,7 +21,6 @@ import { type WsResponse, } from "@t3tools/contracts"; import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; -import { ProjectRepository } from "./persistence/Services/Projects"; import type { TerminalClearInput, TerminalCloseInput, @@ -32,9 +31,7 @@ import type { TerminalWriteInput, } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; -import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; -import { NodeServices } from "@effect/platform-node"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; @@ -664,38 +661,6 @@ describe("WebSocket Server", () => { expect(response.error!.message).toContain("Unknown method"); }); - it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const ws = await connectWs(port); - connections.push(ws); - await waitForMessage(ws); - - const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, { - sessionId: "missing-session", - }); - expect(listResponse.result).toBeUndefined(); - expect(listResponse.error?.message).toContain("Unknown provider session"); - - const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, { - sessionId: "missing-session", - turnCount: 0, - }); - expect(revertResponse.result).toBeUndefined(); - expect(revertResponse.error?.message).toContain("Unknown provider session"); - - const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, { - sessionId: "missing-session", - fromTurnCount: 0, - toTurnCount: 1, - }); - expect(diffResponse.result).toBeUndefined(); - expect(diffResponse.error?.message).toContain("Unknown provider session"); - }); - it("keeps orchestration domain push behavior for provider runtime events", async () => { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { @@ -709,15 +674,7 @@ describe("WebSocket Server", () => { respondToRequest: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), - listCheckpoints: () => unsupported(), - getCheckpointDiff: (input) => - Effect.succeed({ - threadId: "thread-1", - fromTurnCount: input.fromTurnCount, - toTurnCount: input.toTurnCount, - diff: "", - }), - revertToCheckpoint: () => unsupported(), + rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; @@ -736,6 +693,16 @@ describe("WebSocket Server", () => { await waitForMessage(ws); const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-ws-project-create", + projectId: "project-1", + title: "WS Project", + workspaceRoot: "/tmp/ws-project", + defaultModel: "gpt-5-codex", + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { type: "thread.create", commandId: "cmd-ws-runtime-thread-create", @@ -928,11 +895,8 @@ describe("WebSocket Server", () => { expect(response!.error!.message).toContain("Invalid request format"); }); - it("supports projects list/add/dedupe/remove", async () => { - const stateDir = makeTempDir("t3code-ws-projects-state-"); - const firstProjectCwd = makeTempDir("t3code-ws-project-a-"); - - server = createTestServer({ stateDir, cwd: "/test" }); + it("returns unknown method for removed projects CRUD methods", async () => { + server = createTestServer({ cwd: "/test" }); await server.start(); const addr = server.httpServer.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -941,75 +905,21 @@ describe("WebSocket Server", () => { connections.push(ws); await waitForMessage(ws); - const emptyList = await sendRequest(ws, WS_METHODS.projectsList); - expect(emptyList.error).toBeUndefined(); - expect(emptyList.result).toEqual([]); + const listResponse = await sendRequest(ws, WS_METHODS.projectsList); + expect(listResponse.result).toBeUndefined(); + expect(listResponse.error?.message).toContain("Unknown method"); - const created = await sendRequest(ws, WS_METHODS.projectsAdd, { - cwd: firstProjectCwd, + const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { + cwd: "/tmp/project-a", }); - expect(created.error).toBeUndefined(); - expect((created.result as { created: boolean }).created).toBe(true); + expect(addResponse.result).toBeUndefined(); + expect(addResponse.error?.message).toContain("Unknown method"); - const duplicate = await sendRequest(ws, WS_METHODS.projectsAdd, { - cwd: firstProjectCwd, + const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { + id: "project-a", }); - expect(duplicate.error).toBeUndefined(); - expect((duplicate.result as { created: boolean }).created).toBe(false); - - const listed = await sendRequest(ws, WS_METHODS.projectsList); - expect(listed.error).toBeUndefined(); - const listedProjects = listed.result as Array<{ id: string; cwd: string }>; - expect(listedProjects).toHaveLength(1); - - const projectId = listedProjects[0]?.id; - expect(projectId).toBeTruthy(); - if (!projectId) return; - - const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { - id: projectId, - scripts: [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - }); - expect(updatedScripts.error).toBeUndefined(); - const scriptPayload = ( - updatedScripts.result as { - project: { - scripts: Array<{ - id: string; - name: string; - command: string; - icon: string; - runOnWorktreeCreate: boolean; - }>; - }; - } - ).project.scripts; - expect(scriptPayload).toEqual([ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ]); - - const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { - id: projectId, - }); - expect(removed.error).toBeUndefined(); - - const afterRemove = await sendRequest(ws, WS_METHODS.projectsList); - expect(afterRemove.error).toBeUndefined(); - expect(afterRemove.result).toEqual([]); + expect(removeResponse.result).toBeUndefined(); + expect(removeResponse.error?.message).toContain("Unknown method"); }); it("supports projects.searchEntries", async () => { @@ -1079,7 +989,7 @@ describe("WebSocket Server", () => { expect(pullResponse.error?.message).not.toContain("Unknown method"); }); - it("responds to git.status via the git manager", async () => { + it("returns unknown method for git.status", async () => { const gitManager = { status: vi.fn().mockResolvedValue({ branch: "feature/test", @@ -1109,21 +1019,9 @@ describe("WebSocket Server", () => { const response = await sendRequest(ws, WS_METHODS.gitStatus, { cwd: "/test", }); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ - branch: "feature/test", - hasWorkingTreeChanges: true, - workingTree: { - files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], - insertions: 7, - deletions: 2, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - openPr: null, - }); - expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Unknown method"); + expect(gitManager.status).not.toHaveBeenCalled(); }); it("returns errors from git.runStackedAction", async () => { @@ -1153,42 +1051,6 @@ describe("WebSocket Server", () => { }); }); - it("prunes missing projects on startup", async () => { - const stateDir = makeTempDir("t3code-ws-prune-state-"); - const existing = makeTempDir("t3code-ws-existing-project-"); - const missing = makeTempDir("t3code-ws-missing-project-"); - const dbPath = path.join(stateDir, "test-prune.sqlite"); - const persistenceLayer = Layer.provide(makeSqlitePersistenceLive(dbPath), NodeServices.layer); - - const projectRuntime = ManagedRuntime.make( - ProjectRepositoryLive.pipe(Layer.provide(persistenceLayer)), - ); - const projectRepository = await projectRuntime.runPromise(Effect.service(ProjectRepository)); - await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); - await projectRuntime.runPromise(projectRepository.add({ cwd: missing })); - await projectRuntime.dispose(); - fs.rmSync(missing, { recursive: true, force: true }); - - server = createTestServer({ - stateDir, - cwd: "/test", - persistenceLayer, - }); - await server.start(); - const addr = server.httpServer.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const ws = await connectWs(port); - connections.push(ws); - await waitForMessage(ws); - - const response = await sendRequest(ws, WS_METHODS.projectsList); - expect(response.error).toBeUndefined(); - const listed = response.result as Array<{ cwd: string }>; - expect(listed).toHaveLength(1); - expect(listed[0]?.cwd).toBe(existing); - }); - it("rejects websocket connections without a valid auth token", async () => { server = createTestServer({ cwd: "/test", authToken: "secret-token" }); await server.start(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 55618c7c0cde4642d492b809018a1b01bdccd683..3399b30578cef0c2d71e652b6753b77498dc6588 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,16 +9,15 @@ import { EDITORS, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + TerminalEvent, WS_CHANNELS, WS_METHODS, - type TerminalEvent, - type WsPush, - type WsRequest, - type WsResponse, - wsRequestSchema, + WebSocketRequest, + WsPush, + WsResponse, } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; -import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -36,30 +35,42 @@ import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; +import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./orchestration/Services/OrchestrationEngine"; -import { ProviderRuntimeIngestionService } from "./orchestration/Services/ProviderRuntimeIngestion"; -import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./orchestration/Services/ProjectionSnapshotQuery"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; -import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; import assert from "node:assert"; +import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; +import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; -import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; -import { ProviderSessionRepositoryLive } from "./persistence/Layers/ProviderSessions"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { + CheckpointDiffBlobRepository, + type CheckpointDiffBlobRepositoryShape, +} from "./persistence/Services/CheckpointDiffBlobs"; import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlError from "effect/unstable/sql/SqlError"; import * as Migrator from "effect/unstable/sql/Migrator"; +import { clamp } from "effect/Number"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -122,10 +133,11 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { } type EffectRuntime = ManagedRuntime.ManagedRuntime< - | ProjectRepository | OrchestrationEngineService + | ProjectionSnapshotQuery | ProviderService - | ProviderRuntimeIngestionService, + | CheckpointDiffBlobRepository + | OrchestrationReactor, unknown >; @@ -147,9 +159,10 @@ export function createServer(options: ServerOptions) { const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); let effectRuntime: EffectRuntime | null = null; - let projectRepository: ProjectRepositoryShape | null = null; let orchestrationEngine: OrchestrationEngineShape | null = null; + let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null; let providerService: ProviderServiceShape | null = null; + let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -173,24 +186,24 @@ export function createServer(options: ServerOptions) { return orchestrationEngine; }; - const getProjectRepository = () => { - assert(projectRepository, "Project repository is not started"); - return projectRepository; - }; - const getEffectRuntime = () => { assert(effectRuntime, "Effect runtime is not started"); return effectRuntime; }; - const getProviderService = () => { - assert(providerService, "Provider service is not started"); - return providerService; + const getProjectionSnapshotQuery = () => { + assert(projectionSnapshotQuery, "Projection snapshot query is not started"); + return projectionSnapshotQuery; + }; + + const getCheckpointDiffBlobRepository = () => { + assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started"); + return checkpointDiffBlobRepository; }; const attachSubscriptionss = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; - const runtimeIngestion = yield* ProviderRuntimeIngestionService; + const orchestrationReactor = yield* OrchestrationReactor; subscriptionsScope = yield* Scope.make("sequential"); yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => @@ -216,7 +229,7 @@ export function createServer(options: ServerOptions) { }), ).pipe(Effect.forkIn(subscriptionsScope)); - yield* runtimeIngestion.start.pipe(Scope.provide(subscriptionsScope)); + yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope)); }); const onTerminalEvent = (event: TerminalEvent) => { @@ -347,10 +360,9 @@ export function createServer(options: ServerOptions) { }); async function handleMessage(ws: WebSocket, raw: unknown) { - let request: WsRequest; + let request: WebSocketRequest; try { - const parsed = JSON.parse(String(raw)); - request = wsRequestSchema.parse(parsed); + request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw); } catch { const errorResponse: WsResponse = { id: "unknown", @@ -374,108 +386,67 @@ export function createServer(options: ServerOptions) { } } - async function routeRequest(request: WsRequest): Promise { + async function routeRequest(request: WebSocketRequest): Promise { const orchestrationEngine = getOrchestrationEngine(); - const projectRepository = getProjectRepository(); + const projectionReadModelQuery = getProjectionSnapshotQuery(); const effectRuntime = getEffectRuntime(); - const providerService = getProviderService(); + const diffBlobRepository = getCheckpointDiffBlobRepository(); - switch (request.method) { - case WS_METHODS.providersStartSession: - return effectRuntime.runPromise(providerService.startSession(request.params as never)); - - case WS_METHODS.providersSendTurn: - return effectRuntime.runPromise(providerService.sendTurn(request.params as never)); - - case WS_METHODS.providersInterruptTurn: - return effectRuntime.runPromise(providerService.interruptTurn(request.params as never)); - - case WS_METHODS.providersRespondToRequest: - return effectRuntime.runPromise(providerService.respondToRequest(request.params as never)); - - case WS_METHODS.providersStopSession: { - await effectRuntime.runPromise(providerService.stopSession(request.params as never)); - return undefined; + switch (request.body._tag) { + case ORCHESTRATION_WS_METHODS.getSnapshot: { + return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot()); } - case WS_METHODS.providersListCheckpoints: - return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never)); + case ORCHESTRATION_WS_METHODS.dispatchCommand: { + orchestrationCommandLogger?.write({ + observedAt: new Date().toISOString(), + command: request.body.command, + }); + return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command)); + } - case WS_METHODS.providersGetCheckpointDiff: - return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never)); + case ORCHESTRATION_WS_METHODS.getTurnDiff: { + const blob = await effectRuntime.runPromise(diffBlobRepository.get(request.body)); + if (Option.isNone(blob)) { + throw new Error( + `Turn diff not found for thread '${request.body.threadId}' from ${request.body.fromTurnCount} to ${request.body.toTurnCount}.`, + ); + } + + return { + threadId: request.body.threadId, + fromTurnCount: request.body.fromTurnCount, + toTurnCount: request.body.toTurnCount, + diff: blob.value?.diff, + }; + } - case WS_METHODS.providersRevertToCheckpoint: { - const result = await effectRuntime.runPromise( - providerService.revertToCheckpoint(request.params as never), - ); - const params = request.params as { sessionId?: string }; - const sessionId = params.sessionId; - if (typeof sessionId === "string") { - const readModel = await effectRuntime.runPromise(orchestrationEngine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.session?.sessionId === sessionId); - if (thread) { - const now = new Date().toISOString(); - await effectRuntime.runPromise( - orchestrationEngine.dispatch({ - type: "thread.revert", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnCount: result.turnCount, - messageCount: result.messageCount, - createdAt: now, + case ORCHESTRATION_WS_METHODS.replayEvents: + return effectRuntime.runPromise( + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(request.body.fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, }), - ); - if (thread.session) { - await effectRuntime.runPromise( - orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - ...thread.session, - status: "ready", - activeTurnId: null, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }), - ); - } - } - } - return result; - } - - case WS_METHODS.projectsList: - return effectRuntime.runPromise(projectRepository.list()); - - case WS_METHODS.projectsAdd: - return effectRuntime.runPromise(projectRepository.add(request.params as never)); - - case WS_METHODS.projectsRemove: - return effectRuntime.runPromise(projectRepository.remove(request.params as never)); + ), + ).pipe(Effect.map((events) => Array.from(events))), + ); case WS_METHODS.projectsSearchEntries: - return searchWorkspaceEntries(request.params as never); - case WS_METHODS.projectsUpdateScripts: - return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never)); + return searchWorkspaceEntries(request.body); case WS_METHODS.shellOpenInEditor: { - const params = request.params as { - cwd: string; - editor: string; - }; - if (!params?.cwd) throw new Error("cwd is required"); - const editorDef = EDITORS.find((e) => e.id === params.editor); - if (!editorDef) throw new Error(`Unknown editor: ${params.editor}`); + const body = request.body; + const editorDef = EDITORS.find((e) => e.id === body.editor); + if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`); let command: string; let args: string[]; if (editorDef.command) { command = editorDef.command; - args = [params.cwd]; + args = [body.cwd]; } else if (editorDef.id === "file-manager") { // Use platform-specific file manager command switch (process.platform) { @@ -489,7 +460,7 @@ export function createServer(options: ServerOptions) { command = "xdg-open"; break; } - args = [params.cwd]; + args = [body.cwd]; } else { return undefined; } @@ -505,71 +476,72 @@ export function createServer(options: ServerOptions) { return undefined; } - case WS_METHODS.gitStatus: { - const params = request.params as { cwd: string }; - const status = await gitManager.status(request.params as never); - const projects = await effectRuntime.runPromise(projectRepository.list()); - const project = projects.find((entry) => entry.cwd === params.cwd); - if (project) { - await effectRuntime.runPromise( - orchestrationEngine.dispatch({ - type: "git.readModel.upsert", - commandId: crypto.randomUUID(), - projectId: project.id, - branch: status.branch, - hasWorkingTreeChanges: status.hasWorkingTreeChanges, - aheadCount: status.aheadCount, - behindCount: status.behindCount, - createdAt: new Date().toISOString(), - }), - ); - } - return status; - } + // TODO: Bring back + // case WS_METHODS.gitStatus: { + // const params = request.params as { cwd: string }; + // const status = await gitManager.status(request.params as never); + // const projects = await effectRuntime.runPromise(projectRepository.list()); + // const project = projects.find((entry) => entry.cwd === params.cwd); + // if (project) { + // await effectRuntime.runPromise( + // orchestrationEngine.dispatch({ + // type: "git.readModel.upsert", + // commandId: crypto.randomUUID(), + // projectId: project.id, + // branch: status.branch, + // hasWorkingTreeChanges: status.hasWorkingTreeChanges, + // aheadCount: status.aheadCount, + // behindCount: status.behindCount, + // createdAt: new Date().toISOString(), + // }), + // ); + // } + // return status; + // } case WS_METHODS.gitPull: - return pullGitBranch(request.params as never); + return pullGitBranch(request.body); case WS_METHODS.gitRunStackedAction: - return gitManager.runStackedAction(request.params as never); + return gitManager.runStackedAction(request.body); case WS_METHODS.gitListBranches: - return listGitBranches(request.params as never); + return listGitBranches(request.body); case WS_METHODS.gitCreateWorktree: - return createGitWorktree(request.params as never); + return createGitWorktree(request.body); case WS_METHODS.gitRemoveWorktree: - return removeGitWorktree(request.params as never); + return removeGitWorktree(request.body); case WS_METHODS.gitCreateBranch: - return createGitBranch(request.params as never); + return createGitBranch(request.body); case WS_METHODS.gitCheckout: - return checkoutGitBranch(request.params as never); + return checkoutGitBranch(request.body); case WS_METHODS.gitInit: - return initGitRepo(request.params as never); + return initGitRepo(request.body); case WS_METHODS.terminalOpen: - return terminalManager.open(request.params as never); + return terminalManager.open(request.body); case WS_METHODS.terminalWrite: - await terminalManager.write(request.params as never); + await terminalManager.write(request.body); return undefined; case WS_METHODS.terminalResize: - await terminalManager.resize(request.params as never); + await terminalManager.resize(request.body); return undefined; case WS_METHODS.terminalClear: - await terminalManager.clear(request.params as never); + await terminalManager.clear(request.body); return undefined; case WS_METHODS.terminalRestart: - return terminalManager.restart(request.params as never); + return terminalManager.restart(request.body); case WS_METHODS.terminalClose: - await terminalManager.close(request.params as never); + await terminalManager.close(request.body); return undefined; case WS_METHODS.serverGetConfig: @@ -579,41 +551,13 @@ export function createServer(options: ServerOptions) { }; case WS_METHODS.serverUpsertKeybinding: - keybindingsConfig = upsertKeybindingRule(logger, request.params); + keybindingsConfig = upsertKeybindingRule(logger, request.body); return { keybindings: keybindingsConfig, }; - case ORCHESTRATION_WS_METHODS.getSnapshot: - return effectRuntime.runPromise(orchestrationEngine.getReadModel()); - - case ORCHESTRATION_WS_METHODS.dispatchCommand: - orchestrationCommandLogger?.write({ - observedAt: new Date().toISOString(), - requestId: request.id, - command: request.params, - }); - return effectRuntime.runPromise(orchestrationEngine.dispatchUnknownCommand(request.params)); - - case ORCHESTRATION_WS_METHODS.replayEvents: - return effectRuntime.runPromise( - Stream.runCollect( - orchestrationEngine.readEvents( - Math.max( - 0, - Math.floor( - Number( - (request.params as { fromSequenceExclusive?: number } | undefined) - ?.fromSequenceExclusive ?? 0, - ), - ), - ), - ), - ).pipe(Effect.map((events) => Array.from(events))), - ); - default: - throw new Error(`Unknown method: ${request.method}`); + throw new Error(`Unknown method: ${request.body._tag}`); } } @@ -628,7 +572,12 @@ export function createServer(options: ServerOptions) { path.join(orchestrationLogsDir, "orchestration-command.ndjson"), ); const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); - const orchestrationLayer = Layer.provide(OrchestrationEngineLive, OrchestrationEventStoreLive); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); const providerLayer = customProviderLayer ?? (() => { @@ -639,45 +588,59 @@ export function createServer(options: ServerOptions) { Layer.provide(codexAdapterLayer), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRepositoryLive), - ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); - const checkpointDependenciesLayer = Layer.mergeAll( - checkpointStoreLayer, - CheckpointRepositoryLive, - adapterRegistryLayer, - providerSessionDirectoryLayer, - ); - const checkpointServiceLayer = CheckpointServiceLive.pipe( - Layer.provideMerge(checkpointDependenciesLayer), + Layer.provide(ProviderSessionRuntimeRepositoryLive), ); return makeProviderServiceLive({ canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe(Layer.provideMerge(checkpointServiceLayer)); + }).pipe( + Layer.provide(adapterRegistryLayer), + Layer.provide(providerSessionDirectoryLayer), + ); })(); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, - ProjectRepositoryLive, + OrchestrationProjectionSnapshotQueryLive, + CheckpointDiffBlobRepositoryLive, + checkpointStoreLayer, providerLayer, ); - const layer = ProviderRuntimeIngestionLive.pipe( + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), + ); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); try { - const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([ + const [ + nextOrchestrationEngine, + nextProjectionSnapshotQuery, + nextCheckpointDiffBlobRepository, + nextProviderService, + ] = await Promise.all([ runtime.runPromise(Effect.service(OrchestrationEngineService)), - runtime.runPromise(Effect.service(ProjectRepository)), + runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), + runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)), runtime.runPromise(Effect.service(ProviderService)), ]); orchestrationEngine = nextOrchestrationEngine; - projectRepository = repository; + projectionSnapshotQuery = nextProjectionSnapshotQuery; + checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository; providerService = nextProviderService; - await runtime.runPromise(repository.pruneMissing()); await runtime.runPromise(attachSubscriptionss); } catch (error) { await runtime.dispose().catch(() => undefined); @@ -701,7 +664,8 @@ export function createServer(options: ServerOptions) { const liveProviderService = providerService; effectRuntime = null; orchestrationEngine = null; - projectRepository = null; + projectionSnapshotQuery = null; + checkpointDiffBlobRepository = null; providerService = null; if (!runtime) { diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 586f8c560c471b0c3f2f7b8b0bae3d295c725d07..f136dcf9dee28ba74c6340a9b3f9189b33c007b6 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -12,5 +12,6 @@ } ] }, - "include": ["src", "integration", "tsdown.config.ts", "scripts"] + "include": ["src", "tsdown.config.ts", "scripts"], + "exclude": ["src/**/*.test.ts", "integration"] } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index fe90f1d888cac61258af038b540e78c3f89e82e9..6efb7dbe016a0c21add59c54fe2fea1c95c25894 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -24,8 +24,7 @@ "test": "vitest run" }, "dependencies": { - "effect": "4.0.0-beta.10", - "zod": "^3.24.1" + "effect": "4.0.0-beta.10" }, "devDependencies": { "@effect/language-service": "0.75.1", diff --git a/packages/contracts/src/agent.ts b/packages/contracts/src/agent.ts deleted file mode 100644 index 2231f2e0025b164d999bab64da51ee32a242b0a2..0000000000000000000000000000000000000000 --- a/packages/contracts/src/agent.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from "zod"; - -export const agentSessionIdSchema = z.string().min(1); - -export const agentConfigSchema = z.object({ - command: z.string().min(1), - args: z.array(z.string()).default([]), - cwd: z.string().optional(), - env: z.record(z.string()).optional(), - usePty: z.boolean().optional(), -}); - -export const outputChunkSchema = z.object({ - sessionId: z.string(), - stream: z.enum(["stdout", "stderr"]), - data: z.string(), -}); - -export const agentExitSchema = z.object({ - sessionId: z.string(), - code: z.number().nullable(), - signal: z.string().nullable(), -}); - -export type AgentConfig = z.infer; -export type OutputChunk = z.infer; -export type AgentExit = z.infer; diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4c4874c715778f790edcec42275c0fbaa33b831 --- /dev/null +++ b/packages/contracts/src/editor.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect"; + +export const EDITORS = [ + { id: "cursor", label: "Cursor", command: "cursor" }, + { id: "file-manager", label: "File Manager", command: null }, +] as const; + +export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); +export type EditorId = typeof EditorId.Type; + +export class OpenInEditorInput extends Schema.Class("OpenInEditorInput")({ + cwd: Schema.String, + editor: EditorId, +}) {} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts deleted file mode 100644 index 596f9c8aa96b099ee9a7abc1b23e2ac91e0dc644..0000000000000000000000000000000000000000 --- a/packages/contracts/src/git.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - gitPullInputSchema, - gitPullResultSchema, - gitRunStackedActionInputSchema, - gitRunStackedActionResultSchema, - gitStackedActionSchema, - gitStatusInputSchema, - gitStatusResultSchema, -} from "./git"; - -describe("git contracts", () => { - it("parses git status input and trims cwd", () => { - const parsed = gitStatusInputSchema.parse({ cwd: " /tmp/repo " }); - expect(parsed.cwd).toBe("/tmp/repo"); - }); - - it("parses git pull input and trims cwd", () => { - const parsed = gitPullInputSchema.parse({ cwd: " /tmp/repo " }); - expect(parsed.cwd).toBe("/tmp/repo"); - }); - - it("parses git status result", () => { - const parsed = gitStatusResultSchema.parse({ - branch: "feature/git-actions", - hasWorkingTreeChanges: true, - workingTree: { - files: [ - { path: "src/git.ts", insertions: 8, deletions: 2 }, - { path: "src/git.test.ts", insertions: 4, deletions: 1 }, - ], - insertions: 12, - deletions: 3, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - openPr: null, - }); - expect(parsed.branch).toBe("feature/git-actions"); - expect(parsed.hasWorkingTreeChanges).toBe(true); - expect(parsed.workingTree.files[0]?.insertions).toBe(8); - expect(parsed.workingTree.files).toHaveLength(2); - }); - - it("accepts supported stacked actions", () => { - expect(gitStackedActionSchema.parse("commit")).toBe("commit"); - expect(gitStackedActionSchema.parse("commit_push")).toBe("commit_push"); - expect(gitStackedActionSchema.parse("commit_push_pr")).toBe("commit_push_pr"); - }); - - it("parses stacked action input", () => { - const parsed = gitRunStackedActionInputSchema.parse({ - cwd: "/tmp/repo", - action: "commit_push", - }); - expect(parsed.action).toBe("commit_push"); - }); - - it("accepts custom commit message input", () => { - const parsed = gitRunStackedActionInputSchema.parse({ - cwd: "/tmp/repo", - action: "commit", - commitMessage: " chore: update git action modal ", - }); - expect(parsed.commitMessage).toBe("chore: update git action modal"); - }); - - it("parses stacked action result with metadata", () => { - const parsed = gitRunStackedActionResultSchema.parse({ - action: "commit_push_pr", - commit: { - status: "created", - commitSha: "abc123", - subject: "Implement stacked git actions", - }, - push: { - status: "pushed", - branch: "feature/git-actions", - upstreamBranch: "origin/feature/git-actions", - setUpstream: true, - }, - pr: { - status: "created", - url: "https://github.com/pingdotgg/codething-mvp/pull/123", - number: 123, - baseBranch: "main", - headBranch: "feature/git-actions", - title: "Add stacked git actions workflow", - }, - }); - - expect(parsed.commit.status).toBe("created"); - expect(parsed.pr.number).toBe(123); - }); - - it("parses git pull result", () => { - const parsed = gitPullResultSchema.parse({ - status: "pulled", - branch: "feature/my-branch", - upstreamBranch: "origin/feature/my-branch", - }); - expect(parsed.status).toBe("pulled"); - expect(parsed.branch).toBe("feature/my-branch"); - }); -}); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index fad26b834b16f297dd68bebf53cd3bc9d37bcc66..769480c6e9c77d281ebe4544628c5555ad6d5054 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,152 +1,159 @@ -import { z } from "zod"; - -// ── Existing branch/worktree Git API ───────────────────────────────── - -export const gitListBranchesInputSchema = z.object({ - cwd: z.string().min(1), -}); - -export const gitCreateWorktreeInputSchema = z.object({ - cwd: z.string().min(1), - branch: z.string().min(1), - newBranch: z.string().min(1), - path: z.string().min(1).optional(), -}); - -export const gitRemoveWorktreeInputSchema = z.object({ - cwd: z.string().min(1), - path: z.string().min(1), - force: z.boolean().optional(), -}); - -export const gitCreateBranchInputSchema = z.object({ - cwd: z.string().min(1), - branch: z.string().min(1), -}); - -export const gitCheckoutInputSchema = z.object({ - cwd: z.string().min(1), - branch: z.string().min(1), -}); - -export const gitInitInputSchema = z.object({ - cwd: z.string().min(1), -}); - -export const gitBranchSchema = z.object({ - name: z.string().min(1), - current: z.boolean(), - isDefault: z.boolean(), - worktreePath: z.string().min(1).nullable(), -}); - -export const gitWorktreeSchema = z.object({ - path: z.string().min(1), - branch: z.string().min(1), -}); - -export type GitListBranchesInput = z.infer; -export type GitBranch = z.infer; -export type GitCreateWorktreeInput = z.infer; -export type GitWorktree = z.infer; -export type GitRemoveWorktreeInput = z.infer; -export type GitCreateBranchInput = z.infer; -export type GitCheckoutInput = z.infer; -export type GitInitInput = z.infer; - -export interface GitListBranchesResult { - branches: GitBranch[]; - isRepo: boolean; -} - -export interface GitCreateWorktreeResult { - worktree: GitWorktree; -} - -// ── Stacked action Git API ─────────────────────────────────────────── - -export const gitStatusInputSchema = z.object({ - cwd: z.string().trim().min(1), -}); - -export const gitPullInputSchema = z.object({ - cwd: z.string().trim().min(1), -}); - -export const gitStatusResultSchema = z.object({ - branch: z.string().min(1).nullable(), - hasWorkingTreeChanges: z.boolean(), - workingTree: z.object({ - files: z.array( - z.object({ - path: z.string().min(1), - insertions: z.number().int().nonnegative(), - deletions: z.number().int().nonnegative(), +import { Schema } from "effect"; + +const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); + +// Domain Types + +export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); +export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); +export const GitPushStepStatus = Schema.Literals([ + "pushed", + "skipped_not_requested", + "skipped_up_to_date", +]); +export const GitPrStepStatus = Schema.Literals([ + "created", + "opened_existing", + "skipped_not_requested", +]); + +export class GitBranch extends Schema.Class("GitBranch")({ + name: TrimmedNonEmptyString, + current: Schema.Boolean, + isDefault: Schema.Boolean, + worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), +}) {} + +export class GitWorktree extends Schema.Class("GitWorktree")({ + path: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, +}) {} + +// RPC Inputs + +export class GitStatusInput extends Schema.Class("GitStatusInput")({ + cwd: TrimmedNonEmptyString, +}) {} + +export class GitPullInput extends Schema.Class("GitPullInput")({ + cwd: TrimmedNonEmptyString, +}) {} + +export class GitRunStackedActionInput extends Schema.Class( + "GitRunStackedActionInput", +)({ + cwd: TrimmedNonEmptyString, + action: GitStackedAction, + commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), +}) {} + +export class GitListBranchesInput extends Schema.Class( + "GitListBranchesInput", +)({ + cwd: TrimmedNonEmptyString, +}) {} + +export class GitCreateWorktreeInput extends Schema.Class( + "GitCreateWorktreeInput", +)({ + cwd: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, + newBranch: TrimmedNonEmptyString, + path: TrimmedNonEmptyString.pipe(Schema.NullOr), +}) {} + +export class GitRemoveWorktreeInput extends Schema.Class( + "GitRemoveWorktreeInput", +)({ + cwd: TrimmedNonEmptyString, + path: TrimmedNonEmptyString, + force: Schema.Boolean.pipe(Schema.NullOr), +}) {} + +export class GitCreateBranchInput extends Schema.Class( + "GitCreateBranchInput", +)({ + cwd: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, +}) {} + +export class GitCheckoutInput extends Schema.Class("GitCheckoutInput")({ + cwd: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, +}) {} + +export class GitInitInput extends Schema.Class("GitInitInput")({ + cwd: TrimmedNonEmptyString, +}) {} + +// RPC Results + +export class GitStatusResult extends Schema.Class("GitStatusResult")({ + branch: TrimmedNonEmptyString.pipe(Schema.NullOr), + hasWorkingTreeChanges: Schema.Boolean, + workingTree: Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + path: TrimmedNonEmptyString, + insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), }), ), - insertions: z.number().int().nonnegative(), - deletions: z.number().int().nonnegative(), + insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), }), - hasUpstream: z.boolean(), - aheadCount: z.number().int().nonnegative(), - behindCount: z.number().int().nonnegative(), - openPr: z - .object({ - number: z.number().int().positive(), - title: z.string().min(1), - url: z.string().url(), - baseBranch: z.string().min(1), - headBranch: z.string().min(1), - }) - .nullable(), -}); + hasUpstream: Schema.Boolean, + aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + openPr: Schema.Struct({ + number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + title: TrimmedNonEmptyString, + url: Schema.String, + baseBranch: TrimmedNonEmptyString, + headBranch: TrimmedNonEmptyString, + }).pipe(Schema.NullOr), +}) {} -export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); +export class GitListBranchesResult extends Schema.Class( + "GitListBranchesResult", +)({ + branches: Schema.Array(GitBranch), + isRepo: Schema.Boolean, +}) {} -const gitCommitStepStatusSchema = z.enum(["created", "skipped_no_changes"]); -const gitPushStepStatusSchema = z.enum(["pushed", "skipped_not_requested", "skipped_up_to_date"]); -const gitPrStepStatusSchema = z.enum(["created", "opened_existing", "skipped_not_requested"]); +export class GitCreateWorktreeResult extends Schema.Class( + "GitCreateWorktreeResult", +)({ + worktree: GitWorktree, +}) {} -export const gitRunStackedActionInputSchema = z.object({ - cwd: z.string().trim().min(1), - action: gitStackedActionSchema, - commitMessage: z.string().trim().min(1).max(10_000).optional(), -}); - -export const gitRunStackedActionResultSchema = z.object({ - action: gitStackedActionSchema, - commit: z.object({ - status: gitCommitStepStatusSchema, - commitSha: z.string().min(1).optional(), - subject: z.string().min(1).optional(), +export class GitRunStackedActionResult extends Schema.Class( + "GitRunStackedActionResult", +)({ + action: GitStackedAction, + commit: Schema.Struct({ + status: GitCommitStepStatus, + commitSha: Schema.optional(TrimmedNonEmptyString), + subject: Schema.optional(TrimmedNonEmptyString), }), - push: z.object({ - status: gitPushStepStatusSchema, - branch: z.string().min(1).optional(), - upstreamBranch: z.string().min(1).optional(), - setUpstream: z.boolean().optional(), + push: Schema.Struct({ + status: GitPushStepStatus, + branch: Schema.optional(TrimmedNonEmptyString), + upstreamBranch: Schema.optional(TrimmedNonEmptyString), + setUpstream: Schema.optional(Schema.Boolean), }), - pr: z.object({ - status: gitPrStepStatusSchema, - url: z.string().url().optional(), - number: z.number().int().positive().optional(), - baseBranch: z.string().min(1).optional(), - headBranch: z.string().min(1).optional(), - title: z.string().min(1).optional(), + pr: Schema.Struct({ + status: GitPrStepStatus, + url: Schema.optional(Schema.String), + number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + baseBranch: Schema.optional(TrimmedNonEmptyString), + headBranch: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), }), -}); - -export type GitStatusInput = z.input; -export type GitPullInput = z.input; -export type GitStatusResult = z.infer; -export type GitStackedAction = z.infer; -export type GitRunStackedActionInput = z.input; -export type GitRunStackedActionResult = z.infer; - -export const gitPullResultSchema = z.object({ - status: z.enum(["pulled", "skipped_up_to_date"]), - branch: z.string().min(1), - upstreamBranch: z.string().min(1).optional(), -}); +}) {} -export type GitPullResult = z.infer; +export class GitPullResult extends Schema.Class("GitPullResult")({ + status: Schema.Literals(["pulled", "skipped_up_to_date"]), + branch: TrimmedNonEmptyString, + upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), +}) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 252cf2e3e2a3037af6af87b502736e5ed7f3d3f9..fbbf25ed95ed0f8c926278ecbc1df07c063055ba 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,4 @@ -export * from "./todo"; export * from "./ipc"; -export * from "./agent"; export * from "./terminal"; export * from "./provider"; export * from "./providerRuntime"; @@ -8,6 +6,7 @@ export * from "./model"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; -export * from "./project"; export * from "./git"; export * from "./orchestration"; +export * from "./editor"; +export * from "./project"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8f199dda1429e8b3f0072adb3afbaab6edce2210..85661ed8fb2c52745e091dcbd591fa4bb133fd00 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,3 @@ -import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; import type { GitCheckoutInput, GitCreateBranchInput, @@ -15,31 +14,7 @@ import type { GitStatusInput, GitStatusResult, } from "./git"; -import type { - ProviderGetCheckpointDiffInput, - ProviderGetCheckpointDiffResult, - ProviderInterruptTurnInput, - ProviderListCheckpointsInput, - ProviderListCheckpointsResult, - ProviderRevertToCheckpointInput, - ProviderRevertToCheckpointResult, - ProviderRespondToRequestInput, - ProviderSendTurnInput, - ProviderSession, - ProviderSessionStartInput, - ProviderStopSessionInput, - ProviderTurnStartResult, -} from "./provider"; -import type { - ProjectAddInput, - ProjectAddResult, - ProjectListResult, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, - ProjectRemoveInput, - ProjectUpdateScriptsInput, - ProjectUpdateScriptsResult, -} from "./project"; +import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "./project"; import type { ServerConfig } from "./server"; import type { TerminalClearInput, @@ -50,28 +25,17 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal"; -import type { NewTodoInput, Todo } from "./todo"; import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import type { - OrchestrationCommand, + ClientOrchestrationCommand, + OrchestrationGetTurnDiffInput, + OrchestrationGetTurnDiffResult, OrchestrationEvent, OrchestrationReadModel, } from "./orchestration"; - -export const EDITORS = [ - { id: "cursor", label: "Cursor", command: "cursor" }, - { id: "file-manager", label: "File Manager", command: null }, -] as const; - -export type EditorId = (typeof EDITORS)[number]["id"]; +import { EditorId } from "./editor"; export interface NativeApi { - todos: { - list: () => Promise; - add: (input: NewTodoInput) => Promise; - toggle: (id: string) => Promise; - remove: (id: string) => Promise; - }; dialogs: { pickFolder: () => Promise; confirm: (message: string) => Promise; @@ -85,35 +49,8 @@ export interface NativeApi { close: (input: TerminalCloseInput) => Promise; onEvent: (callback: (event: TerminalEvent) => void) => () => void; }; - agent: { - spawn: (config: AgentConfig) => Promise; - kill: (sessionId: string) => Promise; - write: (sessionId: string, data: string) => Promise; - onOutput: (callback: (chunk: OutputChunk) => void) => () => void; - onExit: (callback: (exit: AgentExit) => void) => () => void; - }; - providers: { - startSession: (input: ProviderSessionStartInput) => Promise; - sendTurn: (input: ProviderSendTurnInput) => Promise; - interruptTurn: (input: ProviderInterruptTurnInput) => Promise; - respondToRequest: (input: ProviderRespondToRequestInput) => Promise; - stopSession: (input: ProviderStopSessionInput) => Promise; - listCheckpoints: ( - input: ProviderListCheckpointsInput, - ) => Promise; - getCheckpointDiff: ( - input: ProviderGetCheckpointDiffInput, - ) => Promise; - revertToCheckpoint: ( - input: ProviderRevertToCheckpointInput, - ) => Promise; - }; projects: { - list: () => Promise; - add: (input: ProjectAddInput) => Promise; - remove: (input: ProjectRemoveInput) => Promise; searchEntries: (input: ProjectSearchEntriesInput) => Promise; - updateScripts: (input: ProjectUpdateScriptsInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; @@ -144,7 +81,8 @@ export interface NativeApi { }; orchestration: { getSnapshot: () => Promise; - dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>; + dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; + getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; replayEvents: (fromSequenceExclusive: number) => Promise; onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; }; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 42a75a0e81f10a14a552ab379d0fb908f28b5b83..212514b1975649d86c78f8b277da8dfe7133c2c2 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -1,27 +1,36 @@ +import { Schema } from "effect"; import { assert, describe, it } from "vitest"; import { - keybindingRuleSchema, - keybindingsConfigSchema, - resolvedKeybindingRuleSchema, - resolvedKeybindingsConfigSchema, + KeybindingsConfig, + KeybindingRule, + ResolvedKeybindingRule, + ResolvedKeybindingsConfig, } from "./keybindings"; +const decode = (schema: S, input: unknown): Schema.Schema.Type => + Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; + +const decodeResolvedRuleStrict = (input: unknown) => + Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, { + onExcessProperty: "error", + }); + describe("keybindings contracts", () => { it("parses keybinding rules", () => { - const parsed = keybindingRuleSchema.parse({ + const parsed = decode(KeybindingRule, { key: "mod+j", command: "terminal.toggle", }); assert.strictEqual(parsed.command, "terminal.toggle"); - const parsedClose = keybindingRuleSchema.parse({ + const parsedClose = decode(KeybindingRule, { key: "mod+w", command: "terminal.close", }); assert.strictEqual(parsedClose.command, "terminal.close"); - const parsedLocal = keybindingRuleSchema.parse({ + const parsedLocal = decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", }); @@ -30,7 +39,7 @@ describe("keybindings contracts", () => { it("rejects invalid command values", () => { assert.throws(() => - keybindingRuleSchema.parse({ + decode(KeybindingRule, { key: "mod+j", command: "script.Test.run", }), @@ -38,7 +47,7 @@ describe("keybindings contracts", () => { }); it("accepts dynamic script run commands", () => { - const parsed = keybindingRuleSchema.parse({ + const parsed = decode(KeybindingRule, { key: "mod+r", command: "script.setup.run", }); @@ -46,7 +55,7 @@ describe("keybindings contracts", () => { }); it("parses keybindings array payload", () => { - const parsed = keybindingsConfigSchema.parse([ + const parsed = decode(KeybindingsConfig, [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, ]); @@ -54,7 +63,7 @@ describe("keybindings contracts", () => { }); it("parses resolved keybinding rules", () => { - const parsed = resolvedKeybindingRuleSchema.parse({ + const parsed = decode(ResolvedKeybindingRule, { command: "terminal.split", shortcut: { key: "d", @@ -77,7 +86,7 @@ describe("keybindings contracts", () => { }); it("parses resolved keybindings arrays", () => { - const parsed = resolvedKeybindingsConfigSchema.parse([ + const parsed = decode(ResolvedKeybindingsConfig, [ { command: "terminal.toggle", shortcut: { @@ -95,7 +104,7 @@ describe("keybindings contracts", () => { it("rejects unknown fields in resolved keybinding rules", () => { assert.throws(() => - resolvedKeybindingRuleSchema.parse({ + decodeResolvedRuleStrict({ command: "terminal.toggle", shortcut: { key: "j", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 0a2d558c000af5cf4e2880e53362ff5662f473f8..fa71cfbd7469eebb69b8194c49cae45dc5ab2b64 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { Schema } from "effect"; export const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", @@ -10,80 +10,73 @@ export const STATIC_KEYBINDING_COMMANDS = [ "editor.openFavorite", ] as const; -export const keybindingCommandSchema = z.union([ - z.enum(STATIC_KEYBINDING_COMMANDS), - z - .string() - .trim() - .min(1) - .max(96) - .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/), +export const KeybindingCommand = Schema.Union([ + Schema.Literals(STATIC_KEYBINDING_COMMANDS), + Schema.NonEmptyString.check( + Schema.isMaxLength(96), + Schema.isPattern(/^script\.[a-z0-9][a-z0-9-]*\.run$/), + ), ]); +export type KeybindingCommand = typeof KeybindingCommand.Type; -const keybindingValueSchema = z.string().trim().min(1).max(64); -const keybindingWhenSchema = z.string().trim().min(1).max(256); +export const KeybindingValue = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(64)); +export type KeybindingValue = typeof KeybindingValue.Type; -export const keybindingRuleSchema = z.object({ - key: keybindingValueSchema, - command: keybindingCommandSchema, - when: keybindingWhenSchema.optional(), -}); +export const KeybindingWhen = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(256)); +export type KeybindingWhen = typeof KeybindingWhen.Type; +export class KeybindingRule extends Schema.Class("KeybindingRule")({ + key: KeybindingValue, + command: KeybindingCommand, + when: Schema.optional(KeybindingWhen), +}) {} -export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); +export const KeybindingsConfig = Schema.Array(KeybindingRule).check(Schema.isMaxLength(256)); +export type KeybindingsConfig = typeof KeybindingsConfig.Type; -export const keybindingShortcutSchema = z - .object({ - key: z.string().trim().min(1).max(32), - metaKey: z.boolean(), - ctrlKey: z.boolean(), - shiftKey: z.boolean(), - altKey: z.boolean(), - modKey: z.boolean(), - }) - .strict(); +export class KeybindingShortcut extends Schema.Class("KeybindingShortcut")({ + key: KeybindingValue, + metaKey: Schema.Boolean, + ctrlKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + modKey: Schema.Boolean, +}) {} +export const KeybindingWhenNode: Schema.Schema = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("identifier"), + name: Schema.NonEmptyString, + }), + Schema.Struct({ + type: Schema.Literal("not"), + node: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + }), + Schema.Struct({ + type: Schema.Literal("and"), + left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + }), + Schema.Struct({ + type: Schema.Literal("or"), + left: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + right: Schema.suspend((): Schema.Schema => KeybindingWhenNode), + }), +]); export type KeybindingWhenNode = | { type: "identifier"; name: string } | { type: "not"; node: KeybindingWhenNode } | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; -export const keybindingWhenNodeSchema: z.ZodType = z.lazy(() => - z.union([ - z.object({ - type: z.literal("identifier"), - name: z.string().min(1), - }), - z.object({ - type: z.literal("not"), - node: keybindingWhenNodeSchema, - }), - z.object({ - type: z.literal("and"), - left: keybindingWhenNodeSchema, - right: keybindingWhenNodeSchema, - }), - z.object({ - type: z.literal("or"), - left: keybindingWhenNodeSchema, - right: keybindingWhenNodeSchema, - }), - ]), +export class ResolvedKeybindingRule extends Schema.Class( + "ResolvedKeybindingRule", +)({ + command: KeybindingCommand, + shortcut: KeybindingShortcut, + whenAst: Schema.optional(KeybindingWhenNode), +}) {} + +export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( + Schema.isMaxLength(256), ); - -export const resolvedKeybindingRuleSchema = z - .object({ - command: keybindingCommandSchema, - shortcut: keybindingShortcutSchema, - whenAst: keybindingWhenNodeSchema.optional(), - }) - .strict(); - -export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); - -export type KeybindingCommand = z.infer; -export type KeybindingRule = z.infer; -export type KeybindingsConfig = z.infer; -export type KeybindingShortcut = z.infer; -export type ResolvedKeybindingRule = z.infer; -export type ResolvedKeybindingsConfig = z.infer; +export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4733fc6417955d6317293c025f1bfff69d25ea44..531a20e2304cb661686cc92741a99f0388b29c55 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -3,6 +3,7 @@ import { Schema } from "effect"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", dispatchCommand: "orchestration.dispatchCommand", + getTurnDiff: "orchestration.getTurnDiff", replayEvents: "orchestration.replayEvents", } as const; @@ -10,234 +11,854 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -const IsoDateTimeSchema = Schema.String; -const IdSchema = Schema.String; +export const IsoDateTime = Schema.String; +export type IsoDateTime = typeof IsoDateTime.Type; -export const OrchestrationMessageSchema = Schema.Struct({ - id: IdSchema, - role: Schema.Literals(["user", "assistant"]), +export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); +export type ThreadId = typeof ThreadId.Type; +export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); +export type ProjectId = typeof ProjectId.Type; +export const CommandId = Schema.String.pipe(Schema.brand("CommandId")); +export type CommandId = typeof CommandId.Type; +export const EventId = Schema.String.pipe(Schema.brand("EventId")); +export type EventId = typeof EventId.Type; +export const MessageId = Schema.String.pipe(Schema.brand("MessageId")); +export type MessageId = typeof MessageId.Type; +export const TurnId = Schema.String.pipe(Schema.brand("TurnId")); +export type TurnId = typeof TurnId.Type; + +export const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); +export type ProviderSessionId = typeof ProviderSessionId.Type; +export const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); +export type ProviderThreadId = typeof ProviderThreadId.Type; +export const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); +export type ProviderTurnId = typeof ProviderTurnId.Type; +export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); +export type ProviderItemId = typeof ProviderItemId.Type; +export const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); +export type ApprovalRequestId = typeof ApprovalRequestId.Type; +export const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); +export type CheckpointRef = typeof CheckpointRef.Type; + +export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); +export type ProviderKind = typeof ProviderKind.Type; +export const ProviderApprovalPolicy = Schema.Literals([ + "untrusted", + "on-failure", + "on-request", + "never", +]); +export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; +export const ProviderSandboxMode = Schema.Literals([ + "read-only", + "workspace-write", + "danger-full-access", +]); +export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; +export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); +export type ProviderRequestKind = typeof ProviderRequestKind.Type; +export const ProviderApprovalDecision = Schema.Literals([ + "accept", + "acceptForSession", + "decline", + "cancel", +]); +export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; + +export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; +export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; +export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; + +// Correlation id is command id by design in this model. +export const CorrelationId = CommandId; +export type CorrelationId = typeof CorrelationId.Type; + +export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check( + Schema.isGreaterThanOrEqualTo(0), +); + +const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); + +export const ChatImageAttachment = Schema.Struct({ + type: Schema.Literal("image"), + name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyStringSchema.check( + Schema.isMaxLength(100), + Schema.isPattern(/^image\//i), + ), + sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), + dataUrl: TrimmedNonEmptyStringSchema.check( + Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), + ), +}); +export type ChatImageAttachment = typeof ChatImageAttachment.Type; + +export const ChatAttachment = Schema.Union([ChatImageAttachment]); +export type ChatAttachment = typeof ChatAttachment.Type; + +export const ProjectScriptIcon = Schema.Literals([ + "play", + "test", + "lint", + "configure", + "build", + "debug", +]); + +export const ProjectScript = Schema.Struct({ + id: Schema.String, + name: Schema.String, + command: Schema.String, + icon: ProjectScriptIcon, + runOnWorktreeCreate: Schema.Boolean, +}); +export type ProjectScript = typeof ProjectScript.Type; + +export const OrchestrationProject = Schema.Struct({ + id: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModel: Schema.NullOr(Schema.String), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationProject = typeof OrchestrationProject.Type; + +export const OrchestrationMessageRole = Schema.Literals(["user", "assistant", "system"]); +export type OrchestrationMessageRole = typeof OrchestrationMessageRole.Type; + +export const OrchestrationMessage = Schema.Struct({ + id: MessageId, + role: OrchestrationMessageRole, text: Schema.String, - createdAt: IsoDateTimeSchema, - completedAt: Schema.optional(IsoDateTimeSchema), + attachments: Schema.optional(Schema.Array(ChatAttachment)), + turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, }); +export type OrchestrationMessage = typeof OrchestrationMessage.Type; -export type OrchestrationMessage = Schema.Schema.Type; +export const OrchestrationSessionStatus = Schema.Literals([ + "idle", + "starting", + "running", + "ready", + "interrupted", + "stopped", + "error", +]); +export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; -export const OrchestrationSessionSchema = Schema.Struct({ - sessionId: IdSchema, - status: Schema.Literals(["connecting", "ready", "running", "error", "closed"]), - provider: Schema.Literals(["codex", "claudeCode"]), - threadId: IdSchema, - activeTurnId: Schema.NullOr(Schema.String), - createdAt: IsoDateTimeSchema, - updatedAt: IsoDateTimeSchema, +export const OrchestrationSession = Schema.Struct({ + threadId: ThreadId, + status: OrchestrationSessionStatus, + providerName: Schema.NullOr(Schema.String), + providerSessionId: Schema.NullOr(ProviderSessionId), + providerThreadId: Schema.NullOr(ProviderThreadId), + activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(Schema.String), + updatedAt: IsoDateTime, }); +export type OrchestrationSession = typeof OrchestrationSession.Type; -export type OrchestrationSession = Schema.Schema.Type; - -export const OrchestrationTurnDiffFileSchema = Schema.Struct({ +export const OrchestrationCheckpointFile = Schema.Struct({ path: Schema.String, - kind: Schema.optional(Schema.String), - additions: Schema.optional(Schema.Number), - deletions: Schema.optional(Schema.Number), + kind: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, }); +export type OrchestrationCheckpointFile = typeof OrchestrationCheckpointFile.Type; -export type OrchestrationTurnDiffFile = Schema.Schema.Type; +export const OrchestrationCheckpointStatus = Schema.Literals(["ready", "missing", "error"]); +export type OrchestrationCheckpointStatus = typeof OrchestrationCheckpointStatus.Type; -export const OrchestrationTurnDiffSummarySchema = Schema.Struct({ - turnId: IdSchema, - completedAt: IsoDateTimeSchema, - status: Schema.optional(Schema.String), - files: Schema.Array(OrchestrationTurnDiffFileSchema), - assistantMessageId: Schema.optional(Schema.String), - checkpointTurnCount: Schema.optional(Schema.Number), +export const OrchestrationCheckpointSummary = Schema.Struct({ + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, }); +export type OrchestrationCheckpointSummary = typeof OrchestrationCheckpointSummary.Type; -export type OrchestrationTurnDiffSummary = Schema.Schema.Type< - typeof OrchestrationTurnDiffSummarySchema ->; - -export const OrchestrationThreadActivityToneSchema = Schema.Literals([ - "thinking", - "tool", +export const OrchestrationThreadActivityTone = Schema.Literals([ "info", + "tool", + "approval", "error", ]); +export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; -export type OrchestrationThreadActivityTone = Schema.Schema.Type< - typeof OrchestrationThreadActivityToneSchema ->; - -export const OrchestrationThreadActivitySchema = Schema.Struct({ - id: IdSchema, - createdAt: IsoDateTimeSchema, - label: Schema.String, - detail: Schema.optional(Schema.String), - tone: OrchestrationThreadActivityToneSchema, - turnId: Schema.optional(Schema.String), - requestId: Schema.optional(Schema.String), - requestKind: Schema.optional(Schema.Literals(["command", "file-change"])), +export const OrchestrationThreadActivity = Schema.Struct({ + id: EventId, + tone: OrchestrationThreadActivityTone, + kind: Schema.String, + summary: Schema.String, + payload: Schema.Unknown, + turnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, }); +export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; -export type OrchestrationThreadActivity = Schema.Schema.Type; - -export const OrchestrationThreadSchema = Schema.Struct({ - id: IdSchema, - projectId: IdSchema, +export const OrchestrationThread = Schema.Struct({ + id: ThreadId, + projectId: ProjectId, title: Schema.String, model: Schema.String, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), - createdAt: IsoDateTimeSchema, - updatedAt: IsoDateTimeSchema, - latestTurnId: Schema.NullOr(Schema.String), - latestTurnStartedAt: Schema.NullOr(Schema.String), - latestTurnCompletedAt: Schema.NullOr(Schema.String), - latestTurnDurationMs: Schema.NullOr(Schema.Number), - messages: Schema.Array(OrchestrationMessageSchema), - session: Schema.NullOr(OrchestrationSessionSchema), - turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), - activities: Schema.Array(OrchestrationThreadActivitySchema), - error: Schema.NullOr(Schema.String), + latestTurnId: Schema.NullOr(TurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), + messages: Schema.Array(OrchestrationMessage), + activities: Schema.Array(OrchestrationThreadActivity), + checkpoints: Schema.Array(OrchestrationCheckpointSummary), + session: Schema.NullOr(OrchestrationSession), }); +export type OrchestrationThread = typeof OrchestrationThread.Type; -export type OrchestrationThread = Schema.Schema.Type; - -export const OrchestrationGitReadModelSchema = Schema.Struct({ - projectId: IdSchema, - branch: Schema.NullOr(Schema.String), - hasWorkingTreeChanges: Schema.Boolean, - aheadCount: Schema.Number, - behindCount: Schema.Number, - updatedAt: IsoDateTimeSchema, +export const OrchestrationReadModel = Schema.Struct({ + snapshotSequence: NonNegativeInt, + projects: Schema.Array(OrchestrationProject), + threads: Schema.Array(OrchestrationThread), + updatedAt: IsoDateTime, }); +export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; -export type OrchestrationGitReadModel = Schema.Schema.Type; +export const ProjectCreateCommand = Schema.Struct({ + type: Schema.Literal("project.create"), + commandId: CommandId, + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModel: Schema.optional(Schema.String), + createdAt: IsoDateTime, +}); -export const OrchestrationReadModelSchema = Schema.Struct({ - sequence: Schema.Number, - threads: Schema.Array(OrchestrationThreadSchema), - gitByProjectId: Schema.Record( Schema.String, OrchestrationGitReadModelSchema), - updatedAt: IsoDateTimeSchema, +export const ProjectMetaUpdateCommand = Schema.Struct({ + type: Schema.Literal("project.meta.update"), + commandId: CommandId, + projectId: ProjectId, + title: Schema.optional(Schema.String), + workspaceRoot: Schema.optional(Schema.String), + defaultModel: Schema.optional(Schema.String), + scripts: Schema.optional(Schema.Array(ProjectScript)), }); -export type OrchestrationReadModel = Schema.Schema.Type; +export const ProjectDeleteCommand = Schema.Struct({ + type: Schema.Literal("project.delete"), + commandId: CommandId, + projectId: ProjectId, +}); -export const CreateThreadCommandSchema = Schema.Struct({ +export const ThreadCreateCommand = Schema.Struct({ type: Schema.Literal("thread.create"), - commandId: IdSchema, - threadId: IdSchema, - projectId: IdSchema, + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, title: Schema.String, model: Schema.String, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), - createdAt: IsoDateTimeSchema, + createdAt: IsoDateTime, }); -export const DeleteThreadCommandSchema = Schema.Struct({ +export const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), - commandId: IdSchema, - threadId: IdSchema, - createdAt: IsoDateTimeSchema, + commandId: CommandId, + threadId: ThreadId, }); -export const UpdateThreadMetaCommandSchema = Schema.Struct({ +export const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), - commandId: IdSchema, - threadId: IdSchema, + commandId: CommandId, + threadId: ThreadId, title: Schema.optional(Schema.String), model: Schema.optional(Schema.String), branch: Schema.optional(Schema.NullOr(Schema.String)), worktreePath: Schema.optional(Schema.NullOr(Schema.String)), - createdAt: IsoDateTimeSchema, -}); - -export const SendMessageCommandSchema = Schema.Struct({ - type: Schema.Literal("message.send"), - commandId: IdSchema, - threadId: IdSchema, - messageId: IdSchema, - role: Schema.Literals(["user", "assistant"]), - text: Schema.String, - streaming: Schema.optional(Schema.Boolean), - createdAt: IsoDateTimeSchema, -}); - -export const SetThreadSessionSchema = Schema.Struct({ - type: Schema.Literal("thread.session"), - commandId: IdSchema, - threadId: IdSchema, - session: OrchestrationSessionSchema, - createdAt: IsoDateTimeSchema, -}); - -export const UpsertGitReadModelCommandSchema = Schema.Struct({ - type: Schema.Literal("git.readModel.upsert"), - commandId: IdSchema, - projectId: IdSchema, - branch: Schema.NullOr(Schema.String), - hasWorkingTreeChanges: Schema.Boolean, - aheadCount: Schema.Number, - behindCount: Schema.Number, - createdAt: IsoDateTimeSchema, -}); - -export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({ - type: Schema.Literal("thread.turnDiff.complete"), - commandId: IdSchema, - threadId: IdSchema, - turnId: IdSchema, - completedAt: IsoDateTimeSchema, - status: Schema.optional(Schema.String), - files: Schema.Array(OrchestrationTurnDiffFileSchema), - assistantMessageId: Schema.optional(Schema.String), - checkpointTurnCount: Schema.optional(Schema.Number), - createdAt: IsoDateTimeSchema, -}); - -export const RevertThreadCommandSchema = Schema.Struct({ - type: Schema.Literal("thread.revert"), - commandId: IdSchema, - threadId: IdSchema, - turnCount: Schema.Number, - messageCount: Schema.Number, - createdAt: IsoDateTimeSchema, -}); - -export const AppendThreadActivityCommandSchema = Schema.Struct({ +}); + +export const ThreadTurnStartCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.start"), + commandId: CommandId, + threadId: ThreadId, + message: Schema.Struct({ + messageId: MessageId, + role: Schema.Literal("user"), + text: Schema.String, + attachments: Schema.Array(ChatAttachment), + }), + model: Schema.optional(Schema.String), + effort: Schema.optional(Schema.String), + createdAt: IsoDateTime, +}); + +export const ThreadTurnInterruptCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.interrupt"), + commandId: CommandId, + threadId: ThreadId, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + +export const ThreadApprovalRespondCommand = Schema.Struct({ + type: Schema.Literal("thread.approval.respond"), + commandId: CommandId, + threadId: ThreadId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + createdAt: IsoDateTime, +}); + +export const ThreadCheckpointRevertCommand = Schema.Struct({ + type: Schema.Literal("thread.checkpoint.revert"), + commandId: CommandId, + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + +export const ThreadSessionStopCommand = Schema.Struct({ + type: Schema.Literal("thread.session.stop"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); + +export const ClientOrchestrationCommand = Schema.Union([ + ProjectCreateCommand, + ProjectMetaUpdateCommand, + ProjectDeleteCommand, + ThreadCreateCommand, + ThreadDeleteCommand, + ThreadMetaUpdateCommand, + ThreadTurnStartCommand, + ThreadTurnInterruptCommand, + ThreadApprovalRespondCommand, + ThreadCheckpointRevertCommand, + ThreadSessionStopCommand, +]); +export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; + +export const ThreadSessionSetCommand = Schema.Struct({ + type: Schema.Literal("thread.session.set"), + commandId: CommandId, + threadId: ThreadId, + session: OrchestrationSession, + createdAt: IsoDateTime, +}); + +export const ThreadMessageAssistantDeltaCommand = Schema.Struct({ + type: Schema.Literal("thread.message.assistant.delta"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + delta: Schema.String, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + +export const ThreadMessageAssistantCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.message.assistant.complete"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + +export const ThreadTurnDiffCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.diff.complete"), + commandId: CommandId, + threadId: ThreadId, + turnId: TurnId, + completedAt: IsoDateTime, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.optional(MessageId), + checkpointTurnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + +export const ThreadActivityAppendCommand = Schema.Struct({ type: Schema.Literal("thread.activity.append"), - commandId: IdSchema, - threadId: IdSchema, - activity: OrchestrationThreadActivitySchema, - createdAt: IsoDateTimeSchema, -}); - -export const OrchestrationCommandSchema = Schema.Union([ - CreateThreadCommandSchema, - DeleteThreadCommandSchema, - UpdateThreadMetaCommandSchema, - SendMessageCommandSchema, - SetThreadSessionSchema, - UpsertGitReadModelCommandSchema, - CompleteThreadTurnDiffCommandSchema, - AppendThreadActivityCommandSchema, - RevertThreadCommandSchema,] -); - -export type OrchestrationCommand = Schema.Schema.Type; - -export const OrchestrationEventSchema = Schema.Struct({ - sequence: Schema.Number, - eventId: IdSchema, - type: Schema.String, - aggregateType: Schema.String, - aggregateId: Schema.String, - occurredAt: IsoDateTimeSchema, - commandId: Schema.NullOr(Schema.String), - payload: Schema.Unknown, -}); - -export type OrchestrationEvent = Schema.Schema.Type; + commandId: CommandId, + threadId: ThreadId, + activity: OrchestrationThreadActivity, + createdAt: IsoDateTime, +}); + +export const ThreadRevertCompleteCommand = Schema.Struct({ + type: Schema.Literal("thread.revert.complete"), + commandId: CommandId, + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + +export const InternalOrchestrationCommand = Schema.Union([ + ThreadSessionSetCommand, + ThreadMessageAssistantDeltaCommand, + ThreadMessageAssistantCompleteCommand, + ThreadTurnDiffCompleteCommand, + ThreadActivityAppendCommand, + ThreadRevertCompleteCommand, +]); +export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; + +export const OrchestrationCommand = Schema.Union([ + ClientOrchestrationCommand, + InternalOrchestrationCommand, +]); +export type OrchestrationCommand = typeof OrchestrationCommand.Type; + +export const OrchestrationEventType = Schema.Literals([ + "project.created", + "project.meta-updated", + "project.deleted", + "thread.created", + "thread.deleted", + "thread.meta-updated", + "thread.message-sent", + "thread.turn-start-requested", + "thread.turn-interrupt-requested", + "thread.approval-response-requested", + "thread.checkpoint-revert-requested", + "thread.reverted", + "thread.session-stop-requested", + "thread.session-set", + "thread.turn-diff-completed", + "thread.activity-appended", +]); +export type OrchestrationEventType = typeof OrchestrationEventType.Type; + +export const OrchestrationAggregateKind = Schema.Literals(["project", "thread"]); +export type OrchestrationAggregateKind = typeof OrchestrationAggregateKind.Type; +export const OrchestrationActorKind = Schema.Literals(["client", "server", "provider"]); + +export const ProjectCreatedPayload = Schema.Struct({ + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + defaultModel: Schema.NullOr(Schema.String), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ProjectMetaUpdatedPayload = Schema.Struct({ + projectId: ProjectId, + title: Schema.optional(Schema.String), + workspaceRoot: Schema.optional(Schema.String), + defaultModel: Schema.optional(Schema.NullOr(Schema.String)), + scripts: Schema.optional(Schema.Array(ProjectScript)), + updatedAt: IsoDateTime, +}); + +export const ProjectDeletedPayload = Schema.Struct({ + projectId: ProjectId, + deletedAt: IsoDateTime, +}); + +export const ThreadCreatedPayload = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadDeletedPayload = Schema.Struct({ + threadId: ThreadId, + deletedAt: IsoDateTime, +}); + +export const ThreadMetaUpdatedPayload = Schema.Struct({ + threadId: ThreadId, + title: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + branch: Schema.optional(Schema.NullOr(Schema.String)), + worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: IsoDateTime, +}); + +export const ThreadMessageSentPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + role: OrchestrationMessageRole, + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + turnId: Schema.NullOr(TurnId), + streaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadTurnStartRequestedPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + model: Schema.optional(Schema.String), + effort: Schema.optional(Schema.String), + createdAt: IsoDateTime, +}); + +export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ + threadId: ThreadId, + turnId: Schema.optional(TurnId), + createdAt: IsoDateTime, +}); + +export const ThreadApprovalResponseRequestedPayload = Schema.Struct({ + threadId: ThreadId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + createdAt: IsoDateTime, +}); + +export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ + threadId: ThreadId, + turnCount: NonNegativeInt, + createdAt: IsoDateTime, +}); + +export const ThreadRevertedPayload = Schema.Struct({ + threadId: ThreadId, + turnCount: NonNegativeInt, +}); + +export const ThreadSessionStopRequestedPayload = Schema.Struct({ + threadId: ThreadId, + createdAt: IsoDateTime, +}); + +export const ThreadSessionSetPayload = Schema.Struct({ + threadId: ThreadId, + session: OrchestrationSession, +}); + +export const ThreadTurnDiffCompletedPayload = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, +}); + +export const ThreadActivityAppendedPayload = Schema.Struct({ + threadId: ThreadId, + activity: OrchestrationThreadActivity, +}); + +export const OrchestrationEventMetadata = Schema.Struct({ + providerSessionId: Schema.optional(ProviderSessionId), + providerThreadId: Schema.optional(ProviderThreadId), + providerTurnId: Schema.optional(ProviderTurnId), + providerItemId: Schema.optional(ProviderItemId), + adapterKey: Schema.optional(Schema.String), + requestId: Schema.optional(ApprovalRequestId), + ingestedAt: Schema.optional(IsoDateTime), +}); +export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; + +const EventBaseFields = { + sequence: NonNegativeInt, + eventId: EventId, + aggregateKind: OrchestrationAggregateKind, + aggregateId: Schema.Union([ProjectId, ThreadId]), + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + metadata: OrchestrationEventMetadata, +} as const; + +const PersistedEventBaseFields = { + sequence: NonNegativeInt, + eventId: EventId, + aggregateKind: OrchestrationAggregateKind, + streamId: Schema.Union([ProjectId, ThreadId]), + streamVersion: NonNegativeInt, + occurredAt: IsoDateTime, + commandId: Schema.NullOr(CommandId), + causationEventId: Schema.NullOr(EventId), + correlationId: Schema.NullOr(CommandId), + actorKind: OrchestrationActorKind, + metadata: OrchestrationEventMetadata, +} as const; + +export const OrchestrationEvent = Schema.Union([ + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("project.created"), + payload: ProjectCreatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("project.meta-updated"), + payload: ProjectMetaUpdatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("project.deleted"), + payload: ProjectDeletedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.created"), + payload: ThreadCreatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.deleted"), + payload: ThreadDeletedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.meta-updated"), + payload: ThreadMetaUpdatedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.message-sent"), + payload: ThreadMessageSentPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-start-requested"), + payload: ThreadTurnStartRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-interrupt-requested"), + payload: ThreadTurnInterruptRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.approval-response-requested"), + payload: ThreadApprovalResponseRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.checkpoint-revert-requested"), + payload: ThreadCheckpointRevertRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.reverted"), + payload: ThreadRevertedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.session-stop-requested"), + payload: ThreadSessionStopRequestedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.session-set"), + payload: ThreadSessionSetPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-diff-completed"), + payload: ThreadTurnDiffCompletedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.activity-appended"), + payload: ThreadActivityAppendedPayload, + }), +]); +export type OrchestrationEvent = typeof OrchestrationEvent.Type; + +export const OrchestrationPersistedEvent = Schema.Union([ + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("project.created"), + payload: ProjectCreatedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("project.meta-updated"), + payload: ProjectMetaUpdatedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("project.deleted"), + payload: ProjectDeletedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.created"), + payload: ThreadCreatedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.deleted"), + payload: ThreadDeletedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.meta-updated"), + payload: ThreadMetaUpdatedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.message-sent"), + payload: ThreadMessageSentPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.turn-start-requested"), + payload: ThreadTurnStartRequestedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.turn-interrupt-requested"), + payload: ThreadTurnInterruptRequestedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.approval-response-requested"), + payload: ThreadApprovalResponseRequestedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.checkpoint-revert-requested"), + payload: ThreadCheckpointRevertRequestedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.reverted"), + payload: ThreadRevertedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.session-stop-requested"), + payload: ThreadSessionStopRequestedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.session-set"), + payload: ThreadSessionSetPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.turn-diff-completed"), + payload: ThreadTurnDiffCompletedPayload, + }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.activity-appended"), + payload: ThreadActivityAppendedPayload, + }), +]); +export type OrchestrationPersistedEvent = typeof OrchestrationPersistedEvent.Type; + +export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); +export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; + +export const TurnCountRange = Schema.Struct({ + fromTurnCount: NonNegativeInt, + toTurnCount: NonNegativeInt, +}); + +export const ThreadTurnDiff = Schema.Struct({ + threadId: ThreadId, + ...TurnCountRange.fields, + diff: Schema.String, +}); + +export const ProviderSessionRuntimeStatus = Schema.Literals([ + "starting", + "running", + "stopped", + "error", +]); +export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type; + +export const ProjectionThreadTurnStatus = Schema.Literals([ + "running", + "completed", + "interrupted", + "error", +]); +export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type; + +export const ProjectionCheckpointRow = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + checkpointTurnCount: NonNegativeInt, + checkpointRef: CheckpointRef, + status: OrchestrationCheckpointStatus, + files: Schema.Array(OrchestrationCheckpointFile), + assistantMessageId: Schema.NullOr(MessageId), + completedAt: IsoDateTime, +}); +export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type; + +export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]); +export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type; + +export const ProjectionPendingApprovalDecision = Schema.NullOr(ProviderApprovalDecision); +export type ProjectionPendingApprovalDecision = typeof ProjectionPendingApprovalDecision.Type; + +export const DispatchResult = Schema.Struct({ + sequence: NonNegativeInt, +}); +export type DispatchResult = typeof DispatchResult.Type; + +export const OrchestrationGetSnapshotInput = Schema.Struct({}); +export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; +export const OrchestrationGetSnapshotResult = OrchestrationReadModel; +export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; + +export const OrchestrationGetTurnDiffInput = Schema.Struct({ + threadId: ThreadId, + ...TurnCountRange.fields, +}); +export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; + +export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; +export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; + +export const OrchestrationReplayEventsInput = Schema.Struct({ + fromSequenceExclusive: NonNegativeInt, +}); +export type OrchestrationReplayEventsInput = typeof OrchestrationReplayEventsInput.Type; + +export const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); +export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; + +export const OrchestrationRpcSchemas = { + getSnapshot: { + input: OrchestrationGetSnapshotInput, + output: OrchestrationGetSnapshotResult, + }, + dispatchCommand: { + input: ClientOrchestrationCommand, + output: DispatchResult, + }, + getTurnDiff: { + input: OrchestrationGetTurnDiffInput, + output: OrchestrationGetTurnDiffResult, + }, + replayEvents: { + input: OrchestrationReplayEventsInput, + output: OrchestrationReplayEventsResult, + }, +} as const; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts deleted file mode 100644 index b5b16d93bc51d0b791b5d44bf14ec07f286bbd1b..0000000000000000000000000000000000000000 --- a/packages/contracts/src/project.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - projectAddInputSchema, - projectAddResultSchema, - projectSearchEntriesInputSchema, - projectSearchEntriesResultSchema, - projectListResultSchema, - projectRemoveInputSchema, - projectUpdateScriptsInputSchema, - projectUpdateScriptsResultSchema, -} from "./project"; - -describe("project contracts", () => { - it("parses project list result", () => { - const result = projectListResultSchema.parse([ - { - id: "project-1", - cwd: "/tmp/project", - name: "project", - scripts: [], - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - ]); - - expect(result).toHaveLength(1); - expect(result[0]?.id).toBe("project-1"); - }); - - it("trims add input cwd", () => { - const parsed = projectAddInputSchema.parse({ cwd: " /tmp/project " }); - expect(parsed.cwd).toBe("/tmp/project"); - }); - - it("requires add result created flag", () => { - const parsed = projectAddResultSchema.parse({ - project: { - id: "project-1", - cwd: "/tmp/project", - name: "project", - scripts: [], - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - created: true, - }); - expect(parsed.created).toBe(true); - }); - - it("parses remove input", () => { - const parsed = projectRemoveInputSchema.parse({ id: "project-1" }); - expect(parsed.id).toBe("project-1"); - }); - - it("parses workspace entry search input with defaults", () => { - const parsed = projectSearchEntriesInputSchema.parse({ - cwd: " /tmp/project ", - query: " src ", - }); - - expect(parsed).toEqual({ - cwd: "/tmp/project", - query: "src", - limit: 80, - }); - }); - - it("parses workspace entry search result", () => { - const parsed = projectSearchEntriesResultSchema.parse({ - entries: [ - { - path: "src/components", - kind: "directory", - parentPath: "src", - }, - { - path: "src/components/Button.tsx", - kind: "file", - parentPath: "src/components", - }, - ], - truncated: false, - }); - - expect(parsed.entries).toHaveLength(2); - expect(parsed.entries[0]?.kind).toBe("directory"); - }); - - it("parses update scripts input", () => { - const parsed = projectUpdateScriptsInputSchema.parse({ - id: "project-1", - scripts: [ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - }); - - expect(parsed.scripts).toHaveLength(1); - expect(parsed.scripts[0]?.id).toBe("test"); - }); - - it("parses debug icon in scripts", () => { - const parsed = projectUpdateScriptsInputSchema.parse({ - id: "project-1", - scripts: [ - { - id: "debug", - name: "Debug", - command: "bun --inspect test", - icon: "debug", - runOnWorktreeCreate: false, - }, - ], - }); - - expect(parsed.scripts[0]?.icon).toBe("debug"); - }); - - it("parses update scripts result", () => { - const parsed = projectUpdateScriptsResultSchema.parse({ - project: { - id: "project-1", - cwd: "/tmp/project", - name: "project", - scripts: [ - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - createdAt: "2026-02-10T00:00:00.000Z", - updatedAt: "2026-02-10T00:00:00.000Z", - }, - }); - - expect(parsed.project.scripts[0]?.id).toBe("setup"); - }); -}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index ae04f1240bcb45a77064b77e63c078019b7da5b4..30b2f3f42bc720eb9f9c65f220f40bae78900308 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,134 +1,30 @@ -import { z } from "zod"; - -export const PROJECT_SCRIPT_ICON_VALUES = [ - "play", - "test", - "lint", - "configure", - "build", - "debug", -] as const; - -export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES); - -export const projectScriptSchema = z - .object({ - id: z - .string() - .trim() - .min(1) - .max(64) - .regex(/^[a-z0-9][a-z0-9-]*$/), - name: z.string().trim().min(1).max(64), - command: z.string().trim().min(1).max(10_000), - icon: projectScriptIconSchema, - runOnWorktreeCreate: z.boolean().default(false), - }) - .strict(); - -export const projectScriptsSchema = z.array(projectScriptSchema).max(64); - -export const projectRecordSchema = z.object({ - id: z.string().min(1), - cwd: z.string().min(1), - name: z.string().min(1), - scripts: projectScriptsSchema.default([]), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -export const projectListResultSchema = z.array(projectRecordSchema); - -export const projectAddInputSchema = z.object({ - cwd: z.string().trim().min(1), -}); - -export const projectAddResultSchema = z.object({ - project: projectRecordSchema, - created: z.boolean(), -}); - -export const projectRemoveInputSchema = z.object({ - id: z.string().min(1), -}); +import { Schema } from "effect"; export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; - -export const projectSearchEntriesInputSchema = z.object({ - cwd: z.string().trim().min(1), - query: z.string().trim().max(256).default(""), - limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80), -}); - -export const projectEntryKindSchema = z.enum(["file", "directory"]); - -export const projectEntrySchema = z.object({ - path: z.string().min(1), - kind: projectEntryKindSchema, - parentPath: z.string().optional(), -}); - -export const projectSearchEntriesResultSchema = z.object({ - entries: z.array(projectEntrySchema), - truncated: z.boolean(), -}); - -export const projectUpdateScriptsInputSchema = z.object({ - id: z.string().min(1), - scripts: projectScriptsSchema, -}); - -export const projectUpdateScriptsResultSchema = z.object({ - project: projectRecordSchema, -}); - -export type ProjectScriptIcon = z.infer; -export type ProjectScript = z.infer; - -/** - * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true` - * flag (the first one wins). Fields are trimmed defensively so this is safe to - * call on data that hasn't been through Zod parsing. - */ -export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] { - const seenIds = new Set(); - const deduped: ProjectScript[] = []; - for (const script of scripts) { - const id = script.id.trim(); - if (id.length === 0 || seenIds.has(id)) continue; - seenIds.add(id); - deduped.push({ - ...script, - id, - name: script.name.trim(), - command: script.command.trim(), - }); - } - - let setupAssigned = false; - const normalized: ProjectScript[] = []; - for (const script of deduped) { - if (!script.runOnWorktreeCreate) { - normalized.push(script); - continue; - } - if (!setupAssigned) { - setupAssigned = true; - normalized.push(script); - continue; - } - normalized.push({ ...script, runOnWorktreeCreate: false }); - } - return normalized; -} -export type ProjectRecord = z.infer; -export type ProjectListResult = z.infer; -export type ProjectAddInput = z.input; -export type ProjectAddResult = z.infer; -export type ProjectRemoveInput = z.input; -export type ProjectSearchEntriesInput = z.input; -export type ProjectEntryKind = z.infer; -export type ProjectEntry = z.infer; -export type ProjectSearchEntriesResult = z.infer; -export type ProjectUpdateScriptsInput = z.input; -export type ProjectUpdateScriptsResult = z.infer; +const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); + +export class ProjectSearchEntriesInput extends Schema.Class( + "ProjectSearchEntriesInput", +)({ + cwd: TrimmedNonEmptyString, + query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), + limit: Schema.Int.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), + ), +}) {} + +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); + +export class ProjectEntry extends Schema.Class("ProjectEntry")({ + path: TrimmedNonEmptyString, + kind: ProjectEntryKind, + parentPath: Schema.optional(TrimmedNonEmptyString), +}) {} + +export class ProjectSearchEntriesResult extends Schema.Class( + "ProjectSearchEntriesResult", +)({ + entries: Schema.Array(ProjectEntry), + truncated: Schema.Boolean, +}) {} diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts deleted file mode 100644 index 15910a432b3bc51f29082749f2b51500f3a0f852..0000000000000000000000000000000000000000 --- a/packages/contracts/src/provider.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - providerCheckpointSchema, - providerEventSchema, - providerGetCheckpointDiffInputSchema, - providerGetCheckpointDiffResultSchema, - providerListCheckpointsInputSchema, - providerListCheckpointsResultSchema, - providerRevertToCheckpointInputSchema, - providerRevertToCheckpointResultSchema, - providerRespondToRequestInputSchema, - providerSendTurnInputSchema, - providerSessionStartInputSchema, -} from "./provider"; - -describe("providerSessionStartInputSchema", () => { - it("defaults to codex with safe policies", () => { - const parsed = providerSessionStartInputSchema.parse({}); - expect(parsed.provider).toBe("codex"); - expect(parsed.approvalPolicy).toBe("never"); - expect(parsed.sandboxMode).toBe("workspace-write"); - }); - - it("accepts optional resumeThreadId", () => { - const parsed = providerSessionStartInputSchema.parse({ - resumeThreadId: "thread_123", - }); - expect(parsed.resumeThreadId).toBe("thread_123"); - }); - - it("rejects blank resumeThreadId", () => { - expect(() => - providerSessionStartInputSchema.parse({ - resumeThreadId: " ", - }), - ).toThrow(); - }); - - it("accepts optional codex binary and home path overrides", () => { - const parsed = providerSessionStartInputSchema.parse({ - codexBinaryPath: "/opt/codex/bin/codex", - codexHomePath: "/Users/theo/.codex", - }); - expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); - expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); - }); - - it("rejects blank codex overrides", () => { - expect(() => - providerSessionStartInputSchema.parse({ - codexBinaryPath: " ", - }), - ).toThrow(); - expect(() => - providerSessionStartInputSchema.parse({ - codexHomePath: " ", - }), - ).toThrow(); - }); -}); - -describe("providerSendTurnInputSchema", () => { - it("trims input text and optional model/effort", () => { - const parsed = providerSendTurnInputSchema.parse({ - sessionId: "sess_1", - input: " summarize this repo ", - model: " gpt-5.2-codex ", - effort: " high ", - }); - expect(parsed.input).toBe("summarize this repo"); - expect(parsed.attachments).toEqual([]); - expect(parsed.model).toBe("gpt-5.2-codex"); - expect(parsed.effort).toBe("high"); - }); - - it("accepts image-only turns", () => { - const parsed = providerSendTurnInputSchema.parse({ - sessionId: "sess_1", - attachments: [ - { - type: "image", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 1_024, - dataUrl: "data:image/png;base64,AAAA", - }, - ], - }); - expect(parsed.input).toBeUndefined(); - expect(parsed.attachments).toHaveLength(1); - }); - - it("rejects turns with neither text nor attachments", () => { - expect(() => - providerSendTurnInputSchema.parse({ - sessionId: "sess_1", - }), - ).toThrow(); - }); - - it("rejects non-image data urls", () => { - expect(() => - providerSendTurnInputSchema.parse({ - sessionId: "sess_1", - attachments: [ - { - type: "image", - name: "not-image.txt", - mimeType: "text/plain", - sizeBytes: 25, - dataUrl: "data:text/plain;base64,QQ==", - }, - ], - }), - ).toThrow(); - }); - - it("rejects more than the max attachment count", () => { - expect(() => - providerSendTurnInputSchema.parse({ - sessionId: "sess_1", - attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ - type: "image" as const, - name: `image-${index}.png`, - mimeType: "image/png", - sizeBytes: 1_024, - dataUrl: "data:image/png;base64,AAAA", - })), - }), - ).toThrow(); - }); -}); - -describe("providerEventSchema", () => { - it("accepts notification events with routing metadata", () => { - const parsed = providerEventSchema.parse({ - id: "evt_1", - kind: "notification", - provider: "codex", - sessionId: "sess_1", - createdAt: "2026-01-01T00:00:00.000Z", - method: "item/agentMessage/delta", - threadId: "thr_1", - turnId: "turn_1", - itemId: "item_1", - textDelta: "hi", - }); - expect(parsed.method).toBe("item/agentMessage/delta"); - }); - - it("accepts request approval metadata", () => { - const parsed = providerEventSchema.parse({ - id: "evt_2", - kind: "request", - provider: "codex", - sessionId: "sess_1", - createdAt: "2026-01-01T00:00:00.000Z", - method: "item/commandExecution/requestApproval", - requestId: "req_123", - requestKind: "command", - }); - expect(parsed.requestId).toBe("req_123"); - expect(parsed.requestKind).toBe("command"); - }); -}); - -describe("providerRespondToRequestInputSchema", () => { - it("accepts valid decisions", () => { - const parsed = providerRespondToRequestInputSchema.parse({ - sessionId: "sess_1", - requestId: "req_1", - decision: "acceptForSession", - }); - expect(parsed.decision).toBe("acceptForSession"); - }); - - it("rejects unknown decisions", () => { - expect(() => - providerRespondToRequestInputSchema.parse({ - sessionId: "sess_1", - requestId: "req_1", - decision: "always", - }), - ).toThrow(); - }); -}); - -describe("provider checkpoint schemas", () => { - it("accepts list checkpoint inputs", () => { - const parsed = providerListCheckpointsInputSchema.parse({ - sessionId: "sess_1", - }); - expect(parsed.sessionId).toBe("sess_1"); - }); - - it("accepts checkpoint records and list results", () => { - const checkpoint = providerCheckpointSchema.parse({ - id: "turn_1", - turnCount: 1, - messageCount: 2, - label: "Turn 1", - preview: "Summarize this file", - isCurrent: true, - }); - expect(checkpoint.turnCount).toBe(1); - - const listResult = providerListCheckpointsResultSchema.parse({ - threadId: "thr_1", - checkpoints: [checkpoint], - }); - expect(listResult.checkpoints).toHaveLength(1); - }); - - it("accepts revert checkpoint input/result", () => { - const input = providerRevertToCheckpointInputSchema.parse({ - sessionId: "sess_1", - turnCount: 2, - }); - expect(input.turnCount).toBe(2); - - const result = providerRevertToCheckpointResultSchema.parse({ - threadId: "thr_1", - turnCount: 2, - messageCount: 4, - rolledBackTurns: 1, - checkpoints: [ - { - id: "root", - turnCount: 0, - messageCount: 0, - label: "Start of conversation", - isCurrent: false, - }, - { - id: "turn_2", - turnCount: 2, - messageCount: 4, - label: "Turn 2", - isCurrent: true, - }, - ], - }); - expect(result.rolledBackTurns).toBe(1); - }); - - it("rejects negative turn counts", () => { - expect(() => - providerRevertToCheckpointInputSchema.parse({ - sessionId: "sess_1", - turnCount: -1, - }), - ).toThrow(); - }); - - it("accepts checkpoint diff input/result", () => { - const input = providerGetCheckpointDiffInputSchema.parse({ - sessionId: "sess_1", - fromTurnCount: 1, - toTurnCount: 2, - }); - expect(input.fromTurnCount).toBe(1); - - const result = providerGetCheckpointDiffResultSchema.parse({ - threadId: "thr_1", - fromTurnCount: 1, - toTurnCount: 2, - diff: "diff --git a/src/app.ts b/src/app.ts", - }); - expect(result.toTurnCount).toBe(2); - }); - - it("rejects checkpoint diff ranges where start is after end", () => { - expect(() => - providerGetCheckpointDiffInputSchema.parse({ - sessionId: "sess_1", - fromTurnCount: 3, - toTurnCount: 2, - }), - ).toThrow(); - }); -}); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 06a7582a8b2de5d8ef3afde0e780b0130ccd56a1..47533cf6aac0a7f9d95179b1074f1f7178661241 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,221 +1,169 @@ -import { z } from "zod"; +import { Schema } from "effect"; -export const providerKindSchema = z.enum(["codex", "claudeCode"]); +import { + ApprovalRequestId, + ChatAttachment, + EventId, + IsoDateTime, + NonNegativeInt, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ProviderApprovalDecision, + ProviderApprovalPolicy, + ProviderKind, + ProviderItemId, + ProviderRequestKind, + ProviderSandboxMode, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TurnCountRange, +} from "./orchestration"; -export const providerApprovalPolicySchema = z.enum([ - "untrusted", - "on-failure", - "on-request", - "never", -]); +const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); -export const providerSandboxModeSchema = z.enum([ - "read-only", - "workspace-write", - "danger-full-access", -]); - -export const providerRequestKindSchema = z.enum(["command", "file-change"]); - -export const providerApprovalDecisionSchema = z.enum([ - "accept", - "acceptForSession", - "decline", - "cancel", -]); - -export const providerSessionStatusSchema = z.enum([ +export const ProviderSessionStatus = Schema.Literals([ "connecting", "ready", "running", "error", "closed", ]); - -export const providerSessionSchema = z.object({ - sessionId: z.string().min(1), - provider: providerKindSchema, - status: providerSessionStatusSchema, - cwd: z.string().min(1).optional(), - model: z.string().min(1).optional(), - threadId: z.string().min(1).optional(), - activeTurnId: z.string().min(1).optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - lastError: z.string().min(1).optional(), +export type ProviderSessionStatus = typeof ProviderSessionStatus.Type; + +export const ProviderSession = Schema.Struct({ + sessionId: ProviderSessionId, + provider: ProviderKind, + status: ProviderSessionStatus, + cwd: Schema.optional(TrimmedNonEmptyStringSchema), + model: Schema.optional(TrimmedNonEmptyStringSchema), + threadId: Schema.optional(ProviderThreadId), + activeTurnId: Schema.optional(ProviderTurnId), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + lastError: Schema.optional(TrimmedNonEmptyStringSchema), }); - -export const providerSessionStartInputSchema = z.object({ - provider: providerKindSchema.default("codex"), - cwd: z.string().min(1).optional(), - model: z.string().trim().min(1).optional(), - resumeThreadId: z.string().trim().min(1).optional(), - codexBinaryPath: z.string().trim().min(1).optional(), - codexHomePath: z.string().trim().min(1).optional(), - approvalPolicy: providerApprovalPolicySchema.default("never"), - sandboxMode: providerSandboxModeSchema.default("workspace-write"), +export type ProviderSession = typeof ProviderSession.Type; + +export const ProviderSessionStartInput = Schema.Struct({ + provider: Schema.optional(ProviderKind), + cwd: Schema.optional(TrimmedNonEmptyStringSchema), + model: Schema.optional(TrimmedNonEmptyStringSchema), + resumeThreadId: Schema.optional(ProviderThreadId), + codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), + approvalPolicy: Schema.optional(ProviderApprovalPolicy), + sandboxMode: Schema.optional(ProviderSandboxMode), }); - -export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; -export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; -export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; -export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; - -export const providerSendTurnImageAttachmentSchema = z.object({ - type: z.literal("image"), - name: z.string().trim().min(1).max(255), - mimeType: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^image\//i, "mimeType must be an image/* MIME type"), - sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), - dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), +export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; + +export const ProviderSendTurnInput = Schema.Struct({ + sessionId: ProviderSessionId, + input: Schema.optional( + TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), + ), + attachments: Schema.optional( + Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), + ), + model: Schema.optional(TrimmedNonEmptyStringSchema), + effort: Schema.optional(TrimmedNonEmptyStringSchema), }); +export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; -export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ - providerSendTurnImageAttachmentSchema, -]); - -export const providerSendTurnInputSchema = z - .object({ - sessionId: z.string().min(1), - input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), - attachments: z - .array(providerSendTurnAttachmentInputSchema) - .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS) - .default([]), - model: z.string().trim().min(1).optional(), - effort: z.string().trim().min(1).optional(), - }) - .superRefine((value, ctx) => { - if (!value.input && value.attachments.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Either input text or at least one attachment is required", - path: ["input"], - }); - } - }); - -export const providerTurnStartResultSchema = z.object({ - threadId: z.string().min(1), - turnId: z.string().min(1), +export const ProviderTurnStartResult = Schema.Struct({ + threadId: ProviderThreadId, + turnId: ProviderTurnId, }); +export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; -export const providerInterruptTurnInputSchema = z.object({ - sessionId: z.string().min(1), - turnId: z.string().min(1).optional(), +export const ProviderInterruptTurnInput = Schema.Struct({ + sessionId: ProviderSessionId, + turnId: Schema.optional(ProviderTurnId), }); +export type ProviderInterruptTurnInput = typeof ProviderInterruptTurnInput.Type; -export const providerStopSessionInputSchema = z.object({ - sessionId: z.string().min(1), +export const ProviderStopSessionInput = Schema.Struct({ + sessionId: ProviderSessionId, }); +export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; -export const providerListCheckpointsInputSchema = z.object({ - sessionId: z.string().min(1), +export const ProviderListCheckpointsInput = Schema.Struct({ + sessionId: ProviderSessionId, }); - -export const providerCheckpointSchema = z.object({ - id: z.string().min(1), - turnCount: z.number().int().min(0), - messageCount: z.number().int().min(0), - label: z.string().min(1), - preview: z.string().min(1).optional(), - isCurrent: z.boolean(), +export type ProviderListCheckpointsInput = typeof ProviderListCheckpointsInput.Type; + +export const ProviderCheckpoint = Schema.Struct({ + id: TrimmedNonEmptyStringSchema, + turnCount: NonNegativeInt, + messageCount: NonNegativeInt, + label: TrimmedNonEmptyStringSchema, + preview: Schema.optional(TrimmedNonEmptyStringSchema), + isCurrent: Schema.Boolean, }); +export type ProviderCheckpoint = typeof ProviderCheckpoint.Type; -export const providerListCheckpointsResultSchema = z.object({ - threadId: z.string().min(1), - checkpoints: z.array(providerCheckpointSchema), +export const ProviderListCheckpointsResult = Schema.Struct({ + threadId: ThreadId, + checkpoints: Schema.Array(ProviderCheckpoint), }); +export type ProviderListCheckpointsResult = typeof ProviderListCheckpointsResult.Type; -export const providerRevertToCheckpointInputSchema = z.object({ - sessionId: z.string().min(1), - turnCount: z.number().int().min(0), +export const ProviderRevertToCheckpointInput = Schema.Struct({ + sessionId: ProviderSessionId, + turnCount: NonNegativeInt, }); - -export const providerRevertToCheckpointResultSchema = z.object({ - threadId: z.string().min(1), - turnCount: z.number().int().min(0), - messageCount: z.number().int().min(0), - rolledBackTurns: z.number().int().min(0), - checkpoints: z.array(providerCheckpointSchema), +export type ProviderRevertToCheckpointInput = typeof ProviderRevertToCheckpointInput.Type; + +export const ProviderRevertToCheckpointResult = Schema.Struct({ + threadId: ThreadId, + turnCount: NonNegativeInt, + messageCount: NonNegativeInt, + rolledBackTurns: NonNegativeInt, + checkpoints: Schema.Array(ProviderCheckpoint), }); +export type ProviderRevertToCheckpointResult = typeof ProviderRevertToCheckpointResult.Type; -export const providerGetCheckpointDiffInputSchema = z - .object({ - sessionId: z.string().min(1), - fromTurnCount: z.number().int().min(0), - toTurnCount: z.number().int().min(0), - }) - .superRefine((value, ctx) => { - if (value.fromTurnCount > value.toTurnCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "fromTurnCount must be less than or equal to toTurnCount", - path: ["fromTurnCount"], - }); - } - }); - -export const providerGetCheckpointDiffResultSchema = z.object({ - threadId: z.string().min(1), - fromTurnCount: z.number().int().min(0), - toTurnCount: z.number().int().min(0), - diff: z.string(), +export const ProviderGetCheckpointDiffInput = Schema.Struct({ + sessionId: ProviderSessionId, + ...TurnCountRange.fields, }); +export type ProviderGetCheckpointDiffInput = typeof ProviderGetCheckpointDiffInput.Type; -export const providerRespondToRequestInputSchema = z.object({ - sessionId: z.string().min(1), - requestId: z.string().min(1), - decision: providerApprovalDecisionSchema, +export const ProviderGetCheckpointDiffResult = Schema.Struct({ + threadId: ThreadId, + ...TurnCountRange.fields, + diff: Schema.String, }); +export type ProviderGetCheckpointDiffResult = typeof ProviderGetCheckpointDiffResult.Type; -export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]); - -export const providerEventSchema = z.object({ - id: z.string().min(1), - kind: providerEventKindSchema, - provider: providerKindSchema, - sessionId: z.string().min(1), - createdAt: z.string().datetime(), - method: z.string().min(1), - message: z.string().min(1).optional(), - threadId: z.string().min(1).optional(), - turnId: z.string().min(1).optional(), - itemId: z.string().min(1).optional(), - requestId: z.string().min(1).optional(), - requestKind: providerRequestKindSchema.optional(), - textDelta: z.string().optional(), - payload: z.unknown().optional(), +export const ProviderRespondToRequestInput = Schema.Struct({ + sessionId: ProviderSessionId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, +}); +export type ProviderRespondToRequestInput = typeof ProviderRespondToRequestInput.Type; + +export const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); +export type ProviderEventKind = typeof ProviderEventKind.Type; + +export const ProviderEvent = Schema.Struct({ + id: EventId, + kind: ProviderEventKind, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + method: TrimmedNonEmptyStringSchema, + message: Schema.optional(TrimmedNonEmptyStringSchema), + threadId: Schema.optional(ProviderThreadId), + turnId: Schema.optional(ProviderTurnId), + itemId: Schema.optional(ProviderItemId), + requestId: Schema.optional(ApprovalRequestId), + requestKind: Schema.optional(ProviderRequestKind), + textDelta: Schema.optional(Schema.String), + payload: Schema.optional(Schema.Unknown), }); +export type ProviderEvent = typeof ProviderEvent.Type; -export type ProviderKind = z.infer; -export type ProviderApprovalPolicy = z.infer; -export type ProviderSandboxMode = z.infer; -export type ProviderRequestKind = z.infer; -export type ProviderApprovalDecision = z.infer; -export type ProviderSessionStatus = z.infer; -export type ProviderSession = z.infer; -export type ProviderSessionStartInput = z.input; -export type ProviderSendTurnImageAttachment = z.infer; -export type ProviderSendTurnAttachment = z.infer; -export type ProviderSendTurnAttachmentInput = z.input; -export type ProviderSendTurnInput = z.input; -export type ProviderTurnStartResult = z.infer; -export type ProviderInterruptTurnInput = z.input; -export type ProviderStopSessionInput = z.input; -export type ProviderListCheckpointsInput = z.input; -export type ProviderCheckpoint = z.infer; -export type ProviderListCheckpointsResult = z.infer; -export type ProviderRevertToCheckpointInput = z.input; -export type ProviderRevertToCheckpointResult = z.infer< - typeof providerRevertToCheckpointResultSchema ->; -export type ProviderGetCheckpointDiffInput = z.input; -export type ProviderGetCheckpointDiffResult = z.infer; -export type ProviderRespondToRequestInput = z.input; -export type ProviderEventKind = z.infer; -export type ProviderEvent = z.infer; +export type ProviderSendTurnAttachment = typeof ChatAttachment.Type; +export type ProviderSendTurnAttachmentInput = typeof ChatAttachment.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 4284ad17ed6e35bad80807c194c2304d59119fd2..ec415da5a37f568fc10a96874998ca784e05ef03 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,232 +1,219 @@ import { Schema } from "effect"; -const IsoDateTimeSchema = Schema.String; -const IdSchema = Schema.String; +import { + ApprovalRequestId, + EventId, + IsoDateTime, + NonNegativeInt, + ProviderApprovalDecision, + ProviderItemId, + ProviderKind, + ProviderRequestKind, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TurnId, +} from "./orchestration"; -export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]); -export type ProviderRuntimeProvider = Schema.Schema.Type; +const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]); +const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]); -export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]); -export type ProviderRuntimeApprovalKind = Schema.Schema.Type< - typeof ProviderRuntimeApprovalKindSchema ->; +export const ProviderRuntimeToolKind = Schema.Union([ + ProviderRequestKind, + Schema.Literal("other"), +]); +export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; -export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]); -export type ProviderRuntimeToolKind = Schema.Schema.Type; - -export const ProviderRuntimeTurnStatusSchema = Schema.Literals([ +export const ProviderRuntimeTurnStatus = Schema.Literals([ "completed", "failed", "cancelled", "interrupted", ]); -export type ProviderRuntimeTurnStatus = Schema.Schema.Type; +export type ProviderRuntimeTurnStatus = typeof ProviderRuntimeTurnStatus.Type; -export const ProviderRuntimeSessionStartedEventSchema = Schema.Struct({ +export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ type: Schema.Literal("session.started"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), message: Schema.optional(Schema.String), }); +export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; -export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({ +export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ type: Schema.Literal("session.exited"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), message: Schema.optional(Schema.String), }); +export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; -export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({ +export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ type: Schema.Literal("thread.started"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: IdSchema, + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: RuntimeThreadIdSchema, }); +export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; -export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({ +export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ type: Schema.Literal("turn.started"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: IdSchema, + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: RuntimeTurnIdSchema, }); +export type ProviderRuntimeTurnStartedEvent = typeof ProviderRuntimeTurnStartedEvent.Type; -export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({ +export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ type: Schema.Literal("turn.completed"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - status: Schema.optional(ProviderRuntimeTurnStatusSchema), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + status: Schema.optional(ProviderRuntimeTurnStatus), errorMessage: Schema.optional(Schema.String), }); +export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; -export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({ +export const ProviderRuntimeMessageDeltaEvent = Schema.Struct({ type: Schema.Literal("message.delta"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), delta: Schema.String, }); +export type ProviderRuntimeMessageDeltaEvent = typeof ProviderRuntimeMessageDeltaEvent.Type; -export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({ +export const ProviderRuntimeMessageCompletedEvent = Schema.Struct({ type: Schema.Literal("message.completed"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - itemId: IdSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + itemId: ProviderItemId, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), }); +export type ProviderRuntimeMessageCompletedEvent = typeof ProviderRuntimeMessageCompletedEvent.Type; -export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ +export const ProviderRuntimeToolStartedEvent = Schema.Struct({ type: Schema.Literal("tool.started"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), - toolKind: ProviderRuntimeToolKindSchema, + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), + toolKind: ProviderRuntimeToolKind, title: Schema.String, detail: Schema.optional(Schema.String), }); +export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; -export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({ +export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ type: Schema.Literal("tool.completed"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), - toolKind: ProviderRuntimeToolKindSchema, + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), + toolKind: ProviderRuntimeToolKind, title: Schema.String, detail: Schema.optional(Schema.String), }); +export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; -export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({ +export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ type: Schema.Literal("approval.requested"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), - requestId: IdSchema, - requestKind: ProviderRuntimeApprovalKindSchema, + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), + requestId: ApprovalRequestId, + requestKind: ProviderRequestKind, detail: Schema.optional(Schema.String), }); +export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type; -export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({ +export const ProviderRuntimeApprovalResolvedEvent = Schema.Struct({ type: Schema.Literal("approval.resolved"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), - requestId: IdSchema, - requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema), - decision: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), + requestId: ApprovalRequestId, + requestKind: Schema.optional(ProviderRequestKind), + decision: Schema.optional(ProviderApprovalDecision), }); +export type ProviderRuntimeApprovalResolvedEvent = typeof ProviderRuntimeApprovalResolvedEvent.Type; -export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({ +export const ProviderRuntimeCheckpointCapturedEvent = Schema.Struct({ type: Schema.Literal("checkpoint.captured"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: IdSchema, - turnId: Schema.optional(Schema.String), - turnCount: Schema.Number, - status: Schema.optional(ProviderRuntimeTurnStatusSchema), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: RuntimeThreadIdSchema, + turnId: Schema.optional(RuntimeTurnIdSchema), + turnCount: NonNegativeInt, + status: Schema.optional(ProviderRuntimeTurnStatus), }); +export type ProviderRuntimeCheckpointCapturedEvent = typeof ProviderRuntimeCheckpointCapturedEvent.Type; -export const ProviderRuntimeErrorEventSchema = Schema.Struct({ +export const ProviderRuntimeErrorEvent = Schema.Struct({ type: Schema.Literal("runtime.error"), - eventId: IdSchema, - provider: ProviderRuntimeProviderSchema, - sessionId: IdSchema, - createdAt: IsoDateTimeSchema, - threadId: Schema.optional(Schema.String), - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), + eventId: EventId, + provider: ProviderKind, + sessionId: ProviderSessionId, + createdAt: IsoDateTime, + threadId: Schema.optional(RuntimeThreadIdSchema), + turnId: Schema.optional(RuntimeTurnIdSchema), + itemId: Schema.optional(ProviderItemId), message: Schema.String, }); +export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; -export const ProviderRuntimeEventSchema = Schema.Union([ - ProviderRuntimeSessionStartedEventSchema, - ProviderRuntimeSessionExitedEventSchema, - ProviderRuntimeThreadStartedEventSchema, - ProviderRuntimeTurnStartedEventSchema, - ProviderRuntimeTurnCompletedEventSchema, - ProviderRuntimeMessageDeltaEventSchema, - ProviderRuntimeMessageCompletedEventSchema, - ProviderRuntimeToolStartedEventSchema, - ProviderRuntimeToolCompletedEventSchema, - ProviderRuntimeApprovalRequestedEventSchema, - ProviderRuntimeApprovalResolvedEventSchema, - ProviderRuntimeCheckpointCapturedEventSchema, - ProviderRuntimeErrorEventSchema, +export const ProviderRuntimeEvent = Schema.Union([ + ProviderRuntimeSessionStartedEvent, + ProviderRuntimeSessionExitedEvent, + ProviderRuntimeThreadStartedEvent, + ProviderRuntimeTurnStartedEvent, + ProviderRuntimeTurnCompletedEvent, + ProviderRuntimeMessageDeltaEvent, + ProviderRuntimeMessageCompletedEvent, + ProviderRuntimeToolStartedEvent, + ProviderRuntimeToolCompletedEvent, + ProviderRuntimeApprovalRequestedEvent, + ProviderRuntimeApprovalResolvedEvent, + ProviderRuntimeCheckpointCapturedEvent, + ProviderRuntimeErrorEvent, ]); +export type ProviderRuntimeEvent = typeof ProviderRuntimeEvent.Type; -export type ProviderRuntimeSessionStartedEvent = Schema.Schema.Type< - typeof ProviderRuntimeSessionStartedEventSchema ->; -export type ProviderRuntimeSessionExitedEvent = Schema.Schema.Type< - typeof ProviderRuntimeSessionExitedEventSchema ->; -export type ProviderRuntimeThreadStartedEvent = Schema.Schema.Type< - typeof ProviderRuntimeThreadStartedEventSchema ->; -export type ProviderRuntimeTurnStartedEvent = Schema.Schema.Type< - typeof ProviderRuntimeTurnStartedEventSchema ->; -export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type< - typeof ProviderRuntimeTurnCompletedEventSchema ->; -export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< - typeof ProviderRuntimeMessageDeltaEventSchema ->; -export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type< - typeof ProviderRuntimeMessageCompletedEventSchema ->; -export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< - typeof ProviderRuntimeToolStartedEventSchema ->; -export type ProviderRuntimeToolCompletedEvent = Schema.Schema.Type< - typeof ProviderRuntimeToolCompletedEventSchema ->; -export type ProviderRuntimeApprovalRequestedEvent = Schema.Schema.Type< - typeof ProviderRuntimeApprovalRequestedEventSchema ->; -export type ProviderRuntimeApprovalResolvedEvent = Schema.Schema.Type< - typeof ProviderRuntimeApprovalResolvedEventSchema ->; -export type ProviderRuntimeCheckpointCapturedEvent = Schema.Schema.Type< - typeof ProviderRuntimeCheckpointCapturedEventSchema ->; -export type ProviderRuntimeErrorEvent = Schema.Schema.Type; -export type ProviderRuntimeEvent = Schema.Schema.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6303d9cfca971f40db5d4d682f4127eb3748fada..e2d28c759f4d3a3533034e76ae2129a66be87c88 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,17 +1,21 @@ -import { z } from "zod"; -import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings"; +import { Schema } from "effect"; +import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; -export const serverConfigSchema = z.object({ - cwd: z.string().min(1), - keybindings: resolvedKeybindingsConfigSchema.default([]), +export const ServerConfig = Schema.Struct({ + cwd: Schema.NonEmptyString, + keybindings: ResolvedKeybindingsConfig, }); +export type ServerConfig = Schema.Codec.Encoded; -export const serverUpsertKeybindingInputSchema = keybindingRuleSchema; +export const ServerUpsertKeybindingInput = KeybindingRule; +export type ServerUpsertKeybindingInput = Schema.Codec.Encoded< + typeof ServerUpsertKeybindingInput +>; -export const serverUpsertKeybindingResultSchema = z.object({ - keybindings: resolvedKeybindingsConfigSchema.default([]), +export const ServerUpsertKeybindingResult = Schema.Struct({ + keybindings: ResolvedKeybindingsConfig, }); +export type ServerUpsertKeybindingResult = Schema.Codec.Encoded< + typeof ServerUpsertKeybindingResult +>; -export type ServerConfig = z.infer; -export type ServerUpsertKeybindingInput = z.infer; -export type ServerUpsertKeybindingResult = z.infer; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 6d7691b29a27cf10fdc3c0ad4ccd465faedd8663..614df05141fd0979da6f50954a37992927883083 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,40 +1,56 @@ +import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { DEFAULT_TERMINAL_ID, - terminalClearInputSchema, - terminalCloseInputSchema, - terminalEventSchema, - terminalOpenInputSchema, - terminalResizeInputSchema, - terminalSessionSnapshotSchema, - terminalThreadInputSchema, - terminalWriteInputSchema, + TerminalClearInput, + TerminalCloseInput, + TerminalEvent, + TerminalOpenInput, + TerminalResizeInput, + TerminalSessionSnapshot, + TerminalThreadInput, + TerminalWriteInput, } from "./terminal"; -describe("terminalOpenInputSchema", () => { +function decodeSync(schema: S, input: unknown): Schema.Schema.Type { + return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; +} + +function decodes(schema: S, input: unknown): boolean { + try { + Schema.decodeUnknownSync(schema as never)(input); + return true; + } catch { + return false; + } +} + +describe("TerminalOpenInput", () => { it("accepts valid open input", () => { - const result = terminalOpenInputSchema.safeParse({ - threadId: "thread-1", - cwd: "/tmp/project", - cols: 120, - rows: 40, - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 120, + rows: 40, + }), + ).toBe(true); }); it("rejects invalid bounds", () => { - const result = terminalOpenInputSchema.safeParse({ - threadId: "thread-1", - cwd: "/tmp/project", - cols: 10, - rows: 2, - }); - expect(result.success).toBe(false); + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 10, + rows: 2, + }), + ).toBe(false); }); it("defaults terminalId when missing", () => { - const parsed = terminalOpenInputSchema.parse({ + const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", cwd: "/tmp/project", cols: 100, @@ -44,7 +60,7 @@ describe("terminalOpenInputSchema", () => { }); it("accepts optional env overrides", () => { - const parsed = terminalOpenInputSchema.parse({ + const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", cwd: "/tmp/project", cols: 100, @@ -61,123 +77,132 @@ describe("terminalOpenInputSchema", () => { }); it("rejects invalid env keys", () => { - const result = terminalOpenInputSchema.safeParse({ - threadId: "thread-1", - cwd: "/tmp/project", - cols: 100, - rows: 24, - env: { - "bad-key": "1", - }, - }); - expect(result.success).toBe(false); + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + cwd: "/tmp/project", + cols: 100, + rows: 24, + env: { + "bad-key": "1", + }, + }), + ).toBe(false); }); }); -describe("terminalWriteInputSchema", () => { +describe("TerminalWriteInput", () => { it("accepts non-empty data", () => { - const result = terminalWriteInputSchema.safeParse({ - threadId: "thread-1", - data: "echo hello\n", - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalWriteInput, { + threadId: "thread-1", + data: "echo hello\n", + }), + ).toBe(true); }); it("rejects empty data", () => { - const result = terminalWriteInputSchema.safeParse({ - threadId: "thread-1", - data: "", - }); - expect(result.success).toBe(false); + expect( + decodes(TerminalWriteInput, { + threadId: "thread-1", + data: "", + }), + ).toBe(false); }); }); -describe("terminalThreadInputSchema", () => { +describe("TerminalThreadInput", () => { it("trims thread ids", () => { - const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " }); + const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " }); expect(parsed.threadId).toBe("thread-1"); }); }); -describe("terminalResizeInputSchema", () => { +describe("TerminalResizeInput", () => { it("accepts valid size", () => { - const result = terminalResizeInputSchema.safeParse({ - threadId: "thread-1", - cols: 80, - rows: 24, - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalResizeInput, { + threadId: "thread-1", + cols: 80, + rows: 24, + }), + ).toBe(true); }); }); -describe("terminalClearInputSchema", () => { +describe("TerminalClearInput", () => { it("defaults terminal id", () => { - const parsed = terminalClearInputSchema.parse({ + const parsed = decodeSync(TerminalClearInput, { threadId: "thread-1", }); expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); }); }); -describe("terminalCloseInputSchema", () => { +describe("TerminalCloseInput", () => { it("accepts optional deleteHistory", () => { - const result = terminalCloseInputSchema.safeParse({ - threadId: "thread-1", - deleteHistory: true, - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalCloseInput, { + threadId: "thread-1", + deleteHistory: true, + }), + ).toBe(true); }); }); -describe("terminalSessionSnapshotSchema", () => { +describe("TerminalSessionSnapshot", () => { it("accepts running snapshots", () => { - const result = terminalSessionSnapshotSchema.safeParse({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: "/tmp/project", - status: "running", - pid: 1234, - history: "hello\n", - exitCode: null, - exitSignal: null, - updatedAt: new Date().toISOString(), - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalSessionSnapshot, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + status: "running", + pid: 1234, + history: "hello\n", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + }), + ).toBe(true); }); }); -describe("terminalEventSchema", () => { +describe("TerminalEvent", () => { it("accepts output events", () => { - const result = terminalEventSchema.safeParse({ - type: "output", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - createdAt: new Date().toISOString(), - data: "line\n", - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalEvent, { + type: "output", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + data: "line\n", + }), + ).toBe(true); }); it("accepts exited events", () => { - const result = terminalEventSchema.safeParse({ - type: "exited", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - createdAt: new Date().toISOString(), - exitCode: 0, - exitSignal: null, - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalEvent, { + type: "exited", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + exitCode: 0, + exitSignal: null, + }), + ).toBe(true); }); it("accepts activity events", () => { - const result = terminalEventSchema.safeParse({ - type: "activity", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - createdAt: new Date().toISOString(), - hasRunningSubprocess: true, - }); - expect(result.success).toBe(true); + expect( + decodes(TerminalEvent, { + type: "activity", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + hasRunningSubprocess: true, + }), + ).toBe(true); }); }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index d173b7d0a2179c264bf5f4d6cd5c4cfea8e9cb5a..d256febd8dbe91d1a4eebf3797354229b7220705 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,119 +1,155 @@ -import { z } from "zod"; +import { Schema } from "effect"; export const DEFAULT_TERMINAL_ID = "default"; -const terminalColsSchema = z.number().int().min(20).max(400); -const terminalRowsSchema = z.number().int().min(5).max(200); -const terminalIdSchema = z.string().trim().min(1).max(128); -const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128); -const terminalEnvValueSchema = z.string().max(8_192); -const terminalEnvSchema = z - .record(terminalEnvKeySchema, terminalEnvValueSchema) - .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars"); - -export const terminalThreadInputSchema = z.object({ - threadId: z.string().trim().min(1), +const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty()); +const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( + Schema.isLessThanOrEqualTo(400), +); +const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( + Schema.isLessThanOrEqualTo(200), +); +const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128)); +const TerminalEnvKeySchema = Schema.String.check(Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/)).check( + Schema.isMaxLength(128), +); +const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192)); +const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check( + Schema.makeFilter>((env) => { + if (Object.keys(env).length > 128) { + return { path: [], message: "Too many terminal env vars" }; + } + return true; + }), +); + +const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( + Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID), +); + +export const TerminalThreadInput = Schema.Struct({ + threadId: TrimmedNonEmptyStringSchema, }); +export type TerminalThreadInput = Schema.Codec.Encoded; -export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ - terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), +export const TerminalSessionInput = Schema.Struct({ + ...TerminalThreadInput.fields, + terminalId: TerminalIdWithDefaultSchema, }); - -export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ - cwd: z.string().trim().min(1), - cols: terminalColsSchema.optional(), - rows: terminalRowsSchema.optional(), - env: terminalEnvSchema.optional(), +export type TerminalSessionInput = Schema.Codec.Encoded; + +export const TerminalOpenInput = Schema.Struct({ + ...TerminalSessionInput.fields, + cwd: TrimmedNonEmptyStringSchema, + cols: Schema.optional(TerminalColsSchema), + rows: Schema.optional(TerminalRowsSchema), + env: Schema.optional(TerminalEnvSchema), }); +export type TerminalOpenInput = Schema.Codec.Encoded; -export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ - data: z.string().min(1).max(65_536), +export const TerminalWriteInput = Schema.Struct({ + ...TerminalSessionInput.fields, + data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), }); +export type TerminalWriteInput = Schema.Codec.Encoded; -export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ - cols: terminalColsSchema, - rows: terminalRowsSchema, +export const TerminalResizeInput = Schema.Struct({ + ...TerminalSessionInput.fields, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, }); +export type TerminalResizeInput = Schema.Codec.Encoded; -export const terminalClearInputSchema = terminalSessionInputSchema; +export const TerminalClearInput = TerminalSessionInput; +export type TerminalClearInput = Schema.Codec.Encoded; -export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ - terminalId: terminalIdSchema.optional(), - deleteHistory: z.boolean().optional(), +export const TerminalRestartInput = Schema.Struct({ + ...TerminalThreadInput.fields, + cwd: TrimmedNonEmptyStringSchema, + cols: TerminalColsSchema, + rows: TerminalRowsSchema, + env: Schema.optional(TerminalEnvSchema), }); -export const terminalSessionStatusSchema = z.enum(["starting", "running", "exited", "error"]); - -export const terminalSessionSnapshotSchema = z.object({ - threadId: z.string().min(1), - terminalId: z.string().min(1), - cwd: z.string().min(1), - status: terminalSessionStatusSchema, - pid: z.number().int().positive().nullable(), - history: z.string(), - exitCode: z.number().int().nullable(), - exitSignal: z.number().int().nullable(), - updatedAt: z.string().datetime(), +export const TerminalCloseInput = Schema.Struct({ + ...TerminalThreadInput.fields, + terminalId: Schema.optional(TerminalIdSchema), + deleteHistory: Schema.optional(Schema.Boolean), +}); +export type TerminalCloseInput = Schema.Codec.Encoded; + +export const TerminalSessionStatus = Schema.Literals(["starting", "running", "exited", "error"]); +export type TerminalSessionStatus = typeof TerminalSessionStatus.Type; + +export const TerminalSessionSnapshot = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), + terminalId: Schema.String.check(Schema.isNonEmpty()), + cwd: Schema.String.check(Schema.isNonEmpty()), + status: TerminalSessionStatus, + pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), + history: Schema.String, + exitCode: Schema.NullOr(Schema.Int), + exitSignal: Schema.NullOr(Schema.Int), + updatedAt: Schema.String, }); +export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; -const terminalEventBaseSchema = z.object({ - threadId: z.string().min(1), - terminalId: z.string().min(1), - createdAt: z.string().datetime(), +const TerminalEventBaseSchema = Schema.Struct({ + threadId: Schema.String.check(Schema.isNonEmpty()), + terminalId: Schema.String.check(Schema.isNonEmpty()), + createdAt: Schema.String, }); -export const terminalStartedEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("started"), - snapshot: terminalSessionSnapshotSchema, +export const TerminalStartedEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("started"), + snapshot: TerminalSessionSnapshot, }); -export const terminalOutputEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("output"), - data: z.string(), +export const TerminalOutputEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("output"), + data: Schema.String, }); -export const terminalExitedEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("exited"), - exitCode: z.number().int().nullable(), - exitSignal: z.number().int().nullable(), +export const TerminalExitedEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("exited"), + exitCode: Schema.NullOr(Schema.Int), + exitSignal: Schema.NullOr(Schema.Int), }); -export const terminalErrorEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("error"), - message: z.string().min(1), +export const TerminalErrorEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("error"), + message: Schema.String.check(Schema.isNonEmpty()), }); -export const terminalClearedEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("cleared"), +export const TerminalClearedEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("cleared"), }); -export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("restarted"), - snapshot: terminalSessionSnapshotSchema, +export const TerminalRestartedEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("restarted"), + snapshot: TerminalSessionSnapshot, }); -export const terminalActivityEventSchema = terminalEventBaseSchema.extend({ - type: z.literal("activity"), - hasRunningSubprocess: z.boolean(), +export const TerminalActivityEvent = Schema.Struct({ + ...TerminalEventBaseSchema.fields, + type: Schema.Literal("activity"), + hasRunningSubprocess: Schema.Boolean, }); -export const terminalEventSchema = z.discriminatedUnion("type", [ - terminalStartedEventSchema, - terminalOutputEventSchema, - terminalExitedEventSchema, - terminalErrorEventSchema, - terminalClearedEventSchema, - terminalRestartedEventSchema, - terminalActivityEventSchema, +export const TerminalEvent = Schema.Union([ + TerminalStartedEvent, + TerminalOutputEvent, + TerminalExitedEvent, + TerminalErrorEvent, + TerminalClearedEvent, + TerminalRestartedEvent, + TerminalActivityEvent, ]); +export type TerminalEvent = typeof TerminalEvent.Type; -export type TerminalThreadInput = z.input; -export type TerminalSessionInput = z.input; -export type TerminalOpenInput = z.input; -export type TerminalWriteInput = z.input; -export type TerminalResizeInput = z.input; -export type TerminalClearInput = z.input; -export type TerminalCloseInput = z.input; -export type TerminalSessionStatus = z.infer; -export type TerminalSessionSnapshot = z.infer; -export type TerminalEvent = z.infer; diff --git a/packages/contracts/src/todo.test.ts b/packages/contracts/src/todo.test.ts deleted file mode 100644 index 181edb151fd7c3684de03ed885fbcd63a3bdec35..0000000000000000000000000000000000000000 --- a/packages/contracts/src/todo.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { newTodoInputSchema, todoSchema } from "./todo"; - -describe("todoSchema", () => { - it("accepts a valid todo", () => { - const result = todoSchema.safeParse({ - id: "abc-123", - title: "Buy milk", - completed: false, - createdAt: "2025-01-01T00:00:00.000Z", - }); - expect(result.success).toBe(true); - }); - - it("rejects a todo with empty id", () => { - const result = todoSchema.safeParse({ - id: "", - title: "Buy milk", - completed: false, - createdAt: "2025-01-01T00:00:00.000Z", - }); - expect(result.success).toBe(false); - }); - - it("rejects a todo with empty title", () => { - const result = todoSchema.safeParse({ - id: "abc-123", - title: "", - completed: false, - createdAt: "2025-01-01T00:00:00.000Z", - }); - expect(result.success).toBe(false); - }); -}); - -describe("newTodoInputSchema", () => { - it("accepts valid input", () => { - const result = newTodoInputSchema.safeParse({ title: "Buy milk" }); - expect(result.success).toBe(true); - }); - - it("trims whitespace", () => { - const result = newTodoInputSchema.parse({ title: " Buy milk " }); - expect(result.title).toBe("Buy milk"); - }); - - it("rejects empty title after trimming", () => { - const result = newTodoInputSchema.safeParse({ title: " " }); - expect(result.success).toBe(false); - }); -}); diff --git a/packages/contracts/src/todo.ts b/packages/contracts/src/todo.ts deleted file mode 100644 index 9157ca93c80966e8b4828b64d185d10123006cf4..0000000000000000000000000000000000000000 --- a/packages/contracts/src/todo.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod"; - -export const todoSchema = z.object({ - id: z.string().min(1), - title: z.string().min(1).max(280), - completed: z.boolean(), - createdAt: z.string().datetime(), -}); - -export const todoListSchema = z.array(todoSchema); - -export const newTodoInputSchema = z.object({ - title: z.string().trim().min(1).max(280), -}); - -export const todoIdSchema = z.string().min(1); - -export type Todo = z.infer; -export type NewTodoInput = z.infer; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 22804a98cc59c7367c55960b2e27525b018354a8..5f5d26b866ff2c4d94975556ce903c117ee622b3 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -1,24 +1,43 @@ -import { z } from "zod"; +import { Schema } from "effect"; + +import { + ClientOrchestrationCommand, + ORCHESTRATION_WS_METHODS, + OrchestrationGetSnapshotInput, + OrchestrationGetTurnDiffInput, + OrchestrationReplayEventsInput, +} from "./orchestration"; +import { + GitCheckoutInput, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitInitInput, + GitListBranchesInput, + GitPullInput, + GitRemoveWorktreeInput, + GitRunStackedActionInput, + GitStatusInput, +} from "./git"; +import { + TerminalClearInput, + TerminalCloseInput, + TerminalOpenInput, + TerminalResizeInput, + TerminalRestartInput, + TerminalWriteInput, +} from "./terminal"; +import { KeybindingRule } from "./keybindings"; +import { ProjectSearchEntriesInput } from "./project"; +import { OpenInEditorInput } from "./editor"; // ── WebSocket RPC Method Names ─────────────────────────────────────── export const WS_METHODS = { - // Provider methods (mirrors NativeApi.providers) - providersStartSession: "providers.startSession", - providersSendTurn: "providers.sendTurn", - providersInterruptTurn: "providers.interruptTurn", - providersRespondToRequest: "providers.respondToRequest", - providersStopSession: "providers.stopSession", - providersListCheckpoints: "providers.listCheckpoints", - providersGetCheckpointDiff: "providers.getCheckpointDiff", - providersRevertToCheckpoint: "providers.revertToCheckpoint", - // Project registry methods projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", - projectsUpdateScripts: "projects.updateScripts", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -54,49 +73,76 @@ export const WS_CHANNELS = { serverWelcome: "server.welcome", } as const; -// ── Client → Server (request) ──────────────────────────────────────── - -export const wsRequestSchema = z.object({ - id: z.string().min(1), - method: z.string().min(1), - params: z.unknown().optional(), -}); - -export type WsRequest = z.infer; - -// ── Server → Client (response to request) ──────────────────────────── - -export const wsResponseSchema = z.object({ - id: z.string().min(1), - result: z.unknown().optional(), - error: z - .object({ - message: z.string(), - }) - .optional(), +// -- Tagged Union of all request body schemas ───────────────────────── + +export const WebSocketRequestBody = Schema.TaggedUnion({ + // Orchestration methods + [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, + [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, + [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, + [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, + + // Project Search + [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields, + + // Shell methods + [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields, + + // Git methods + [WS_METHODS.gitPull]: GitPullInput.fields, + [WS_METHODS.gitStatus]: GitStatusInput.fields, + [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields, + [WS_METHODS.gitListBranches]: GitListBranchesInput.fields, + [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields, + [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields, + [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields, + [WS_METHODS.gitCheckout]: GitCheckoutInput.fields, + [WS_METHODS.gitInit]: GitInitInput.fields, + + // Terminal methods + [WS_METHODS.terminalOpen]: TerminalOpenInput.fields, + [WS_METHODS.terminalWrite]: TerminalWriteInput.fields, + [WS_METHODS.terminalResize]: TerminalResizeInput.fields, + [WS_METHODS.terminalClear]: TerminalClearInput.fields, + [WS_METHODS.terminalRestart]: TerminalRestartInput.fields, + [WS_METHODS.terminalClose]: TerminalCloseInput.fields, + + // Server meta + [WS_METHODS.serverGetConfig]: {}, + [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields, }); -export type WsResponse = z.infer; - -// ── Server → Client (push event) ───────────────────────────────────── - -export const wsPushSchema = z.object({ - type: z.literal("push"), - channel: z.string().min(1), - data: z.unknown(), +export const WebSocketRequest = Schema.Struct({ + id: Schema.String, + body: WebSocketRequestBody, }); - -export type WsPush = z.infer; +export type WebSocketRequest = typeof WebSocketRequest.Type; + + +export class WebSocketResponse extends Schema.Class("WebSocketResponse")({ + id: Schema.String, + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + message: Schema.String, + }), + ), +}) {} + +export class WsPush extends Schema.Class("WsPushEvent")({ + type: Schema.Literal("push"), + channel: Schema.String, + data: Schema.Unknown, +}) {} // ── Union of all server → client messages ───────────────────────────── -export type WsServerMessage = WsResponse | WsPush; +export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); +export type WsResponse = typeof WsResponse.Type; // ── Server welcome payload ─────────────────────────────────────────── -export const wsWelcomePayloadSchema = z.object({ - cwd: z.string().min(1), - projectName: z.string().min(1), -}); - -export type WsWelcomePayload = z.infer; +export class WsWelcomePayload extends Schema.Class("WsWelcomePayload")({ + cwd: Schema.String, + projectName: Schema.String, +}) {}