| author | |
| committer | |
| log | 7934639aa1379f745af7db7c05c964bf6d7a7cc8 |
| tree | a9f0fde49d03d0433d8747ee7bf46e89af65e712 |
| parent | 20668e49a934ce0bb02fe1eb8b57846fe36959ad |
22 files changed, 2050 insertions(+), 1404 deletions(-)
.plans/14-server-authoritative-event-sourcing-cleanup.md created+206| ... | ... | @@ -0,0 +1,206 @@ |
| 1 | # Server-Authoritative Event-Sourcing Cleanup Plan | |
| 2 | ||
| 3 | Goal: | |
| 4 | - Move to a cleaner service architecture with: | |
| 5 | - durable, server-authoritative event sourcing | |
| 6 | - strict command routing/validation | |
| 7 | - pluggable provider adapters | |
| 8 | - explicit separation between transport, domain orchestration, provider runtime, and persistence | |
| 9 | ||
| 10 | ## Target Service Graph (ASCII) | |
| 11 | ||
| 12 | ```text | |
| 13 | +---------------------------+ | |
| 14 | | wsServer | | |
| 15 | | transport | | |
| 16 | +---------------------------+ | |
| 17 | | orchestration.dispatchCommand | |
| 18 | v | |
| 19 | +-------------------------------------------+ | |
| 20 | | OrchestrationCommandRouter | | |
| 21 | +-------------------------------------------+ | |
| 22 | | | |
| 23 | v | |
| 24 | +-------------------------------------------+ | |
| 25 | | OrchestrationCommandHandlers | | |
| 26 | +-------------------------------------------+ | |
| 27 | | | |
| 28 | v | |
| 29 | +-------------------------------------------+ | |
| 30 | | OrchestrationEventStore | | |
| 31 | +-------------------------------------------+ | |
| 32 | | | |
| 33 | v | |
| 34 | +-------------------------------------------+ | |
| 35 | | OrchestrationProjectionService | | |
| 36 | +-------------------------------------------+ | |
| 37 | | snapshot/replay | |
| 38 | +---------------------------> wsServer | |
| 39 | ||
| 40 | ||
| 41 | wsServer -- providers.* RPC --> +---------------------------+ | |
| 42 | | ProviderService | | |
| 43 | +---------------------------+ | |
| 44 | | | | |
| 45 | v v | |
| 46 | +-------------------+ +-------------------------+ | |
| 47 | | ProviderSession | | ProviderAdapterRegistry | | |
| 48 | | Registry (durable)| +-------------------------+ | |
| 49 | +-------------------+ | | |
| 50 | ^ v | |
| 51 | | +-------------------------+ | |
| 52 | | | ProviderAdapter(s) | | |
| 53 | | +-------------------------+ | |
| 54 | | | | |
| 55 | | runtime events v | |
| 56 | | +---------------------------+ | |
| 57 | +----------| ProviderRuntimeIngestion | | |
| 58 | +---------------------------+ | |
| 59 | | | | | |
| 60 | v v v | |
| 61 | Router Session Checkpoint | |
| 62 | Registry Service | |
| 63 | ||
| 64 | +-------------------------------------------+ | |
| 65 | | CheckpointService | | |
| 66 | +-------------------------------------------+ | |
| 67 | | | | | |
| 68 | v v v | |
| 69 | +--------------------+ +-------------+ +-------------------+ | |
| 70 | | CheckpointCatalog | | Checkpoint | | ProviderAdapter(s)| | |
| 71 | | (durable) | | Store (git) | | (read/rollback) | | |
| 72 | +--------------------+ +-------------+ +-------------------+ | |
| 73 | | | |
| 74 | v | |
| 75 | +------+ | |
| 76 | |SQLite| | |
| 77 | +------+ | |
| 78 | ||
| 79 | OrchestrationEventStore ------> SQLite | |
| 80 | OrchestrationProjectionService -> SQLite | |
| 81 | ProviderSessionRegistry ------> SQLite | |
| 82 | CheckpointCatalog ------> SQLite | |
| 83 | ``` | |
| 84 | ||
| 85 | ## Commit Series | |
| 86 | ||
| 87 | ### Commit 1: Split public vs system orchestration command contracts | |
| 88 | - Create separate schemas/types: | |
| 89 | - `ClientOrchestrationCommandSchema` | |
| 90 | - `SystemOrchestrationCommandSchema` | |
| 91 | - `OrchestrationCommandSchema = union(client, system)` | |
| 92 | - Ensure client transport can only submit client commands. | |
| 93 | - Keep system commands for server-internal workflows only. | |
| 94 | - Expected files: | |
| 95 | - `packages/contracts/src/orchestration.ts` | |
| 96 | - `apps/server/src/wsServer.ts` | |
| 97 | - orchestration/service tests | |
| 98 | - Tests: | |
| 99 | - reject system-only command via WS dispatch path | |
| 100 | - preserve internal dispatch functionality for system commands | |
| 101 | ||
| 102 | ### Commit 2: Introduce `OrchestrationCommandRouter` + handler boundary | |
| 103 | - Add dedicated router service to validate, authorize, and route commands. | |
| 104 | - Move command-to-event mapping out of `orchestration/Layer.ts` into handlers. | |
| 105 | - Add aggregate-level invariant checks before append (thread exists, project exists, etc.). | |
| 106 | - Expected files: | |
| 107 | - `apps/server/src/orchestration/Services/CommandRouter.ts` (new) | |
| 108 | - `apps/server/src/orchestration/Layers/CommandRouter.ts` (new) | |
| 109 | - `apps/server/src/orchestration/Layer.ts` | |
| 110 | - `apps/server/src/orchestration/reducer.ts` (only if needed for event payload changes) | |
| 111 | - Tests: | |
| 112 | - router validation and invariant failures | |
| 113 | - handler happy-path tests per command type | |
| 114 | ||
| 115 | ### Commit 3: Harden event store for idempotency + optimistic append metadata | |
| 116 | - Add DB-level idempotency guard for `command_id` (`UNIQUE` where non-null). | |
| 117 | - Extend append API to support idempotent replays and deterministic return of prior event on duplicate `commandId`. | |
| 118 | - Add optional aggregate version metadata for future optimistic concurrency. | |
| 119 | - Expected files: | |
| 120 | - `apps/server/src/persistence/Migrations/00x_*.ts` (new migration) | |
| 121 | - `apps/server/src/persistence/Services/OrchestrationEvents.ts` | |
| 122 | - `apps/server/src/persistence/Layers/OrchestrationEvents.ts` | |
| 123 | - Tests: | |
| 124 | - duplicate command ID append returns same event/sequence (or explicit idempotent behavior) | |
| 125 | - concurrent append behavior stays ordered and deterministic | |
| 126 | ||
| 127 | ### Commit 4: Extract provider-runtime -> orchestration bridge from `wsServer` | |
| 128 | - Create `ProviderRuntimeIngestionService` that: | |
| 129 | - subscribes to `ProviderService.streamEvents` | |
| 130 | - translates runtime events into orchestration commands | |
| 131 | - dispatches through router/engine | |
| 132 | - Remove provider-to-orchestration state mutation logic from `wsServer`. | |
| 133 | - Expected files: | |
| 134 | - `apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts` (new) | |
| 135 | - `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (new) | |
| 136 | - `apps/server/src/wsServer.ts` | |
| 137 | - Tests: | |
| 138 | - ingestion service mapping tests (turn started/completed, message delta/completed, runtime error) | |
| 139 | - ws integration confirms same external push behavior | |
| 140 | ||
| 141 | ### Commit 5: Make session directory durable (`ProviderSessionRegistry`) | |
| 142 | - Replace in-memory-only `ProviderSessionDirectoryLive` with persistence-backed registry. | |
| 143 | - Keep in-memory cache optional, but source of truth must be persistent. | |
| 144 | - Add startup reconciliation to prune dead sessions / keep known thread mapping. | |
| 145 | - Expected files: | |
| 146 | - `apps/server/src/provider/Services/ProviderSessionDirectory.ts` (or new SessionRegistry service) | |
| 147 | - `apps/server/src/provider/Layers/ProviderSessionDirectory.ts` | |
| 148 | - `apps/server/src/persistence/Migrations/00x_*.ts` (new table/indexes) | |
| 149 | - provider persistence tests | |
| 150 | - Tests: | |
| 151 | - survives server restart with correct mapping | |
| 152 | - stale session cleanup semantics | |
| 153 | ||
| 154 | ### Commit 6: Re-key checkpoint metadata from session to thread identity | |
| 155 | - Change checkpoint catalog primary identity from `provider_session_id` to durable `thread_id`. | |
| 156 | - Keep `session_id` as nullable metadata only. | |
| 157 | - Update checkpoint flows (`initialize`, `capture`, `list`, `diff`, `revert`) to use thread identity. | |
| 158 | - Expected files: | |
| 159 | - `apps/server/src/persistence/Migrations/00x_*.ts` (checkpoint schema migration) | |
| 160 | - `apps/server/src/persistence/Services/Checkpoints.ts` | |
| 161 | - `apps/server/src/persistence/Layers/Checkpoints.ts` | |
| 162 | - `apps/server/src/checkpointing/Layers/CheckpointService.ts` | |
| 163 | - Tests: | |
| 164 | - resume/new session over same thread sees same checkpoint history | |
| 165 | - revert/diff still work after session churn | |
| 166 | ||
| 167 | ### Commit 7: Add durable projection persistence for orchestration read models | |
| 168 | - Introduce projection tables/snapshots persisted in DB to avoid full replay dependency. | |
| 169 | - Keep event stream as source of truth; projection rebuild stays deterministic. | |
| 170 | - `getSnapshot` reads from projection store (memory cache optional). | |
| 171 | - Expected files: | |
| 172 | - `apps/server/src/persistence/Migrations/00x_*.ts` (projection tables) | |
| 173 | - `apps/server/src/orchestration/*` projection service/layer | |
| 174 | - `apps/server/src/wsServer.ts` (snapshot/replay path wiring) | |
| 175 | - Tests: | |
| 176 | - cold boot snapshot load without replaying full history in process | |
| 177 | - projection rebuild from events yields same result as previous reducer semantics | |
| 178 | ||
| 179 | ### Commit 8: Narrow `ProviderService` responsibilities | |
| 180 | - Keep `ProviderService` focused on provider RPC/session lifecycle + unified runtime stream. | |
| 181 | - Move checkpoint-capture side effects out of provider event worker into dedicated ingestion/checkpoint pipeline service. | |
| 182 | - Preserve adapter pluggability and provider-neutral contracts. | |
| 183 | - Expected files: | |
| 184 | - `apps/server/src/provider/Layers/ProviderService.ts` | |
| 185 | - new orchestration/checkpoint runtime coordinator service(s) | |
| 186 | - Tests: | |
| 187 | - provider service routing stays intact | |
| 188 | - checkpoint capture still triggered by turn completion through new coordinator | |
| 189 | ||
| 190 | ### Commit 9: Remove dead legacy path and finalize docs | |
| 191 | - Remove unused legacy manager/store path from active architecture: | |
| 192 | - `providerManager.ts` | |
| 193 | - `filesystemCheckpointStore.ts` (if no longer needed by tests/tools) | |
| 194 | - Update architecture docs with final service boundaries and boot/runtime graph. | |
| 195 | - Expected files: | |
| 196 | - legacy files + references | |
| 197 | - `AGENTS.md`/docs as needed | |
| 198 | - `.plans` docs linkage | |
| 199 | - Tests: | |
| 200 | - full server integration suite passes on Effect-only path | |
| 201 | - no regressions in WS protocol behavior | |
| 202 | ||
| 203 | ## Risk Controls | |
| 204 | - Keep WS method names and payload contracts stable throughout. | |
| 205 | - Gate each commit with targeted integration tests before moving forward. | |
| 206 | - Avoid broad event-type churn in one step; migrate schemas incrementally with clear compatibility windows. |
apps/server/src/orchestration/Errors.ts+23-9| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { SchemaIssue, Schema } from "effect"; |
| 2 | 2 | |
| 3 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; | |
| 3 | import type { OrchestrationEventStoreError } from "../persistence/Errors.ts"; | |
| 4 | 4 | |
| 5 | 5 | export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<OrchestrationCommandJsonParseError>()( |
| 6 | 6 | "OrchestrationCommandJsonParseError", |
| ... | ... | @@ -26,8 +26,21 @@ export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass<Orc |
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | export class OrchestrationReducerDecodeError extends Schema.TaggedErrorClass<OrchestrationReducerDecodeError>()( | |
| 30 | "OrchestrationReducerDecodeError", | |
| 29 | export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<OrchestrationCommandInvariantError>()( | |
| 30 | "OrchestrationCommandInvariantError", | |
| 31 | { | |
| 32 | commandType: Schema.String, | |
| 33 | detail: Schema.String, | |
| 34 | cause: Schema.optional(Schema.Defect), | |
| 35 | }, | |
| 36 | ) { | |
| 37 | override get message(): string { | |
| 38 | return `Orchestration command invariant failed (${this.commandType}): ${this.detail}`; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<OrchestrationProjectorDecodeError>()( | |
| 43 | "OrchestrationProjectorDecodeError", | |
| 31 | 44 | { |
| 32 | 45 | eventType: Schema.String, |
| 33 | 46 | issue: Schema.String, |
| ... | ... | @@ -35,7 +48,7 @@ export class OrchestrationReducerDecodeError extends Schema.TaggedErrorClass<Orc |
| 35 | 48 | }, |
| 36 | 49 | ) { |
| 37 | 50 | override get message(): string { |
| 38 | return `Reducer decode failed for ${this.eventType}: ${this.issue}`; | |
| 51 | return `Projector decode failed for ${this.eventType}: ${this.issue}`; | |
| 39 | 52 | } |
| 40 | 53 | } |
| 41 | 54 | |
| ... | ... | @@ -53,8 +66,9 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< |
| 53 | 66 | } |
| 54 | 67 | |
| 55 | 68 | export type OrchestrationDispatchError = |
| 56 | | OrchestrationEventRepositoryError | |
| 57 | | OrchestrationReducerDecodeError | |
| 69 | | OrchestrationEventStoreError | |
| 70 | | OrchestrationCommandInvariantError | |
| 71 | | OrchestrationProjectorDecodeError | |
| 58 | 72 | | OrchestrationListenerCallbackError; |
| 59 | 73 | |
| 60 | 74 | export type OrchestrationEngineError = |
| ... | ... | @@ -69,9 +83,9 @@ export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) { |
| 69 | 83 | }); |
| 70 | 84 | } |
| 71 | 85 | |
| 72 | export function toReducerDecodeError(eventType: string) { | |
| 73 | return (error: Schema.SchemaError): OrchestrationReducerDecodeError => | |
| 74 | new OrchestrationReducerDecodeError({ | |
| 86 | export function toProjectorDecodeError(eventType: string) { | |
| 87 | return (error: Schema.SchemaError): OrchestrationProjectorDecodeError => | |
| 88 | new OrchestrationProjectorDecodeError({ | |
| 75 | 89 | eventType, |
| 76 | 90 | issue: SchemaIssue.makeFormatterDefault()(error.issue), |
| 77 | 91 | cause: error, |
apps/server/src/orchestration/Layer.test.ts+88-23| ... | ... | @@ -3,18 +3,18 @@ import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; |
| 3 | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | OrchestrationEventRepository, | |
| 7 | type OrchestrationEventRepositoryShape, | |
| 8 | } from "../persistence/Services/OrchestrationEvents.ts"; | |
| 6 | OrchestrationEventStore, | |
| 7 | type OrchestrationEventStoreShape, | |
| 8 | } from "../persistence/Services/OrchestrationEventStore.ts"; | |
| 9 | 9 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 10 | 10 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 11 | import { OrchestrationEngineLive } from "./Layer.ts"; | |
| 12 | import { OrchestrationEngineService } from "./Service.ts"; | |
| 13 | import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents.ts"; | |
| 11 | import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; | |
| 12 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | |
| 13 | import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; | |
| 14 | 14 | |
| 15 | 15 | export async function createOrchestrationSystem() { |
| 16 | 16 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 17 | Layer.provide(OrchestrationEventRepositoryLive), | |
| 17 | Layer.provide(OrchestrationEventStoreLive), | |
| 18 | 18 | Layer.provide(SqlitePersistenceMemory), |
| 19 | 19 | ); |
| 20 | 20 | const runtime = ManagedRuntime.make(orchestrationLayer); |
| ... | ... | @@ -27,7 +27,7 @@ export async function createOrchestrationSystem() { |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | describe("OrchestrationEngine", () => { |
| 30 | it("returns deterministic snapshots for repeated reads", async () => { | |
| 30 | it("returns deterministic read models for repeated reads", async () => { | |
| 31 | 31 | const createdAt = new Date().toISOString(); |
| 32 | 32 | const projectId = "project-1"; |
| 33 | 33 | const threadId = "thread-1"; |
| ... | ... | @@ -59,9 +59,9 @@ describe("OrchestrationEngine", () => { |
| 59 | 59 | createdAt, |
| 60 | 60 | }), |
| 61 | 61 | ); |
| 62 | const snapshotA = await system.run(engine.getSnapshot()); | |
| 63 | const snapshotB = await system.run(engine.getSnapshot()); | |
| 64 | expect(snapshotB).toEqual(snapshotA); | |
| 62 | const readModelA = await system.run(engine.getReadModel()); | |
| 63 | const readModelB = await system.run(engine.getReadModel()); | |
| 64 | expect(readModelB).toEqual(readModelA); | |
| 65 | 65 | await system.dispose(); |
| 66 | 66 | }); |
| 67 | 67 | |
| ... | ... | @@ -89,7 +89,11 @@ describe("OrchestrationEngine", () => { |
| 89 | 89 | createdAt: new Date().toISOString(), |
| 90 | 90 | }), |
| 91 | 91 | ); |
| 92 | const events = await system.run(engine.replayEvents(0)); | |
| 92 | const events = await system.run( | |
| 93 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 94 | Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)), | |
| 95 | ), | |
| 96 | ); | |
| 93 | 97 | expect(events.length).toBe(2); |
| 94 | 98 | expect(events[0]?.type).toBe("thread.created"); |
| 95 | 99 | expect(events[1]?.type).toBe("thread.deleted"); |
| ... | ... | @@ -167,7 +171,7 @@ describe("OrchestrationEngine", () => { |
| 167 | 171 | }), |
| 168 | 172 | ); |
| 169 | 173 | |
| 170 | const firstThread = (await system.run(engine.getSnapshot())).threads.find( | |
| 174 | const firstThread = (await system.run(engine.getReadModel())).threads.find( | |
| 171 | 175 | (thread) => thread.id === "thread-turn-diff", |
| 172 | 176 | ); |
| 173 | 177 | expect(firstThread?.turnDiffSummaries).toEqual([ |
| ... | ... | @@ -289,7 +293,7 @@ describe("OrchestrationEngine", () => { |
| 289 | 293 | }), |
| 290 | 294 | ); |
| 291 | 295 | |
| 292 | const thread = (await system.run(engine.getSnapshot())).threads.find( | |
| 296 | const thread = (await system.run(engine.getReadModel())).threads.find( | |
| 293 | 297 | (entry) => entry.id === "thread-revert", |
| 294 | 298 | ); |
| 295 | 299 | expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); |
| ... | ... | @@ -299,20 +303,20 @@ describe("OrchestrationEngine", () => { |
| 299 | 303 | }); |
| 300 | 304 | |
| 301 | 305 | it("allows stop to be called multiple times", async () => { |
| 302 | const inMemoryStore: OrchestrationEventRepositoryShape = { | |
| 306 | const inMemoryStore: OrchestrationEventStoreShape = { | |
| 303 | 307 | append(event) { |
| 304 | 308 | return Effect.succeed({ ...event, sequence: 1 }); |
| 305 | 309 | }, |
| 306 | 310 | readFromSequence() { |
| 307 | return Effect.succeed([]); | |
| 311 | return Stream.empty; | |
| 308 | 312 | }, |
| 309 | 313 | readAll() { |
| 310 | return Effect.succeed([]); | |
| 314 | return Stream.empty; | |
| 311 | 315 | }, |
| 312 | 316 | }; |
| 313 | 317 | const runtime = ManagedRuntime.make( |
| 314 | 318 | OrchestrationEngineLive.pipe( |
| 315 | Layer.provide(Layer.succeed(OrchestrationEventRepository, inMemoryStore)), | |
| 319 | Layer.provide(Layer.succeed(OrchestrationEventStore, inMemoryStore)), | |
| 316 | 320 | ), |
| 317 | 321 | ); |
| 318 | 322 | await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| ... | ... | @@ -325,7 +329,7 @@ describe("OrchestrationEngine", () => { |
| 325 | 329 | let nextSequence = 1; |
| 326 | 330 | let shouldFailFirstAppend = true; |
| 327 | 331 | |
| 328 | const flakyStore: OrchestrationEventRepositoryShape = { | |
| 332 | const flakyStore: OrchestrationEventStoreShape = { | |
| 329 | 333 | append(event) { |
| 330 | 334 | if (shouldFailFirstAppend) { |
| 331 | 335 | shouldFailFirstAppend = false; |
| ... | ... | @@ -345,16 +349,16 @@ describe("OrchestrationEngine", () => { |
| 345 | 349 | return Effect.succeed(savedEvent); |
| 346 | 350 | }, |
| 347 | 351 | readFromSequence(sequenceExclusive) { |
| 348 | return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 352 | return Stream.fromIterable(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 349 | 353 | }, |
| 350 | 354 | readAll() { |
| 351 | return Effect.succeed(events); | |
| 355 | return Stream.fromIterable(events); | |
| 352 | 356 | }, |
| 353 | 357 | }; |
| 354 | 358 | |
| 355 | 359 | const runtime = ManagedRuntime.make( |
| 356 | 360 | OrchestrationEngineLive.pipe( |
| 357 | Layer.provide(Layer.succeed(OrchestrationEventRepository, flakyStore)), | |
| 361 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), | |
| 358 | 362 | ), |
| 359 | 363 | ); |
| 360 | 364 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| ... | ... | @@ -391,8 +395,69 @@ describe("OrchestrationEngine", () => { |
| 391 | 395 | ); |
| 392 | 396 | |
| 393 | 397 | expect(result.sequence).toBe(1); |
| 394 | expect((await runtime.runPromise(engine.getSnapshot())).sequence).toBe(1); | |
| 398 | expect((await runtime.runPromise(engine.getReadModel())).sequence).toBe(1); | |
| 395 | 399 | |
| 396 | 400 | await runtime.dispose(); |
| 397 | 401 | }); |
| 402 | ||
| 403 | it("fails command dispatch when command invariants are violated", async () => { | |
| 404 | const system = await createOrchestrationSystem(); | |
| 405 | const engine = system.engine; | |
| 406 | const createdAt = new Date().toISOString(); | |
| 407 | ||
| 408 | await expect( | |
| 409 | system.run( | |
| 410 | engine.dispatch({ | |
| 411 | type: "message.send", | |
| 412 | commandId: "cmd-invariant-missing-thread", | |
| 413 | threadId: "thread-missing", | |
| 414 | messageId: "msg-missing", | |
| 415 | role: "user", | |
| 416 | text: "hello", | |
| 417 | streaming: false, | |
| 418 | createdAt, | |
| 419 | }), | |
| 420 | ), | |
| 421 | ).rejects.toThrow("Thread 'thread-missing' does not exist"); | |
| 422 | ||
| 423 | await system.dispose(); | |
| 424 | }); | |
| 425 | ||
| 426 | it("rejects duplicate thread creation", async () => { | |
| 427 | const system = await createOrchestrationSystem(); | |
| 428 | const engine = system.engine; | |
| 429 | const createdAt = new Date().toISOString(); | |
| 430 | ||
| 431 | await system.run( | |
| 432 | engine.dispatch({ | |
| 433 | type: "thread.create", | |
| 434 | commandId: "cmd-thread-duplicate-1", | |
| 435 | threadId: "thread-duplicate", | |
| 436 | projectId: "project-duplicate", | |
| 437 | title: "duplicate", | |
| 438 | model: "gpt-5-codex", | |
| 439 | branch: null, | |
| 440 | worktreePath: null, | |
| 441 | createdAt, | |
| 442 | }), | |
| 443 | ); | |
| 444 | ||
| 445 | await expect( | |
| 446 | system.run( | |
| 447 | engine.dispatch({ | |
| 448 | type: "thread.create", | |
| 449 | commandId: "cmd-thread-duplicate-2", | |
| 450 | threadId: "thread-duplicate", | |
| 451 | projectId: "project-duplicate", | |
| 452 | title: "duplicate", | |
| 453 | model: "gpt-5-codex", | |
| 454 | branch: null, | |
| 455 | worktreePath: null, | |
| 456 | createdAt, | |
| 457 | }), | |
| 458 | ), | |
| 459 | ).rejects.toThrow("already exists"); | |
| 460 | ||
| 461 | await system.dispose(); | |
| 462 | }); | |
| 398 | 463 | }); |
apps/server/src/orchestration/Layer.ts deleted-258| ... | ... | @@ -1,258 +0,0 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; | |
| 8 | ||
| 9 | import { createLogger } from "../logger.ts"; | |
| 10 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; | |
| 11 | import { | |
| 12 | toOrchestrationCommandDecodeError, | |
| 13 | toOrchestrationJsonParseError, | |
| 14 | type OrchestrationDispatchError, | |
| 15 | } from "./Errors.ts"; | |
| 16 | import { createEmptyReadModel, reduceEvent } from "./reducer.ts"; | |
| 17 | import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service.ts"; | |
| 18 | ||
| 19 | interface CommandEnvelope { | |
| 20 | command: OrchestrationCommand; | |
| 21 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | |
| 22 | } | |
| 23 | ||
| 24 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { | |
| 25 | const eventId = crypto.randomUUID(); | |
| 26 | switch (command.type) { | |
| 27 | case "thread.create": | |
| 28 | return { | |
| 29 | eventId, | |
| 30 | type: "thread.created", | |
| 31 | aggregateType: "thread", | |
| 32 | aggregateId: command.threadId, | |
| 33 | occurredAt: command.createdAt, | |
| 34 | commandId: command.commandId, | |
| 35 | payload: { | |
| 36 | id: command.threadId, | |
| 37 | projectId: command.projectId, | |
| 38 | title: command.title, | |
| 39 | model: command.model, | |
| 40 | branch: command.branch, | |
| 41 | worktreePath: command.worktreePath, | |
| 42 | createdAt: command.createdAt, | |
| 43 | updatedAt: command.createdAt, | |
| 44 | }, | |
| 45 | }; | |
| 46 | case "thread.delete": | |
| 47 | return { | |
| 48 | eventId, | |
| 49 | type: "thread.deleted", | |
| 50 | aggregateType: "thread", | |
| 51 | aggregateId: command.threadId, | |
| 52 | occurredAt: command.createdAt, | |
| 53 | commandId: command.commandId, | |
| 54 | payload: { | |
| 55 | id: command.threadId, | |
| 56 | deletedAt: command.createdAt, | |
| 57 | }, | |
| 58 | }; | |
| 59 | case "thread.meta.update": | |
| 60 | return { | |
| 61 | eventId, | |
| 62 | type: "thread.meta-updated", | |
| 63 | aggregateType: "thread", | |
| 64 | aggregateId: command.threadId, | |
| 65 | occurredAt: command.createdAt, | |
| 66 | commandId: command.commandId, | |
| 67 | payload: { | |
| 68 | threadId: command.threadId, | |
| 69 | ...(command.title !== undefined ? { title: command.title } : {}), | |
| 70 | ...(command.model !== undefined ? { model: command.model } : {}), | |
| 71 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | |
| 72 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | |
| 73 | updatedAt: command.createdAt, | |
| 74 | }, | |
| 75 | }; | |
| 76 | case "message.send": | |
| 77 | return { | |
| 78 | eventId, | |
| 79 | type: "message.sent", | |
| 80 | aggregateType: "thread", | |
| 81 | aggregateId: command.threadId, | |
| 82 | occurredAt: command.createdAt, | |
| 83 | commandId: command.commandId, | |
| 84 | payload: { | |
| 85 | id: command.messageId, | |
| 86 | role: command.role, | |
| 87 | text: command.text, | |
| 88 | threadId: command.threadId, | |
| 89 | createdAt: command.createdAt, | |
| 90 | streaming: command.streaming === true, | |
| 91 | }, | |
| 92 | }; | |
| 93 | case "thread.session": | |
| 94 | return { | |
| 95 | eventId, | |
| 96 | type: "thread.session-set", | |
| 97 | aggregateType: "thread", | |
| 98 | aggregateId: command.threadId, | |
| 99 | occurredAt: command.createdAt, | |
| 100 | commandId: command.commandId, | |
| 101 | payload: { | |
| 102 | threadId: command.threadId, | |
| 103 | session: command.session, | |
| 104 | }, | |
| 105 | }; | |
| 106 | case "git.readModel.upsert": | |
| 107 | return { | |
| 108 | eventId, | |
| 109 | type: "git.read-model-upsert", | |
| 110 | aggregateType: "project", | |
| 111 | aggregateId: command.projectId, | |
| 112 | occurredAt: command.createdAt, | |
| 113 | commandId: command.commandId, | |
| 114 | payload: { | |
| 115 | projectId: command.projectId, | |
| 116 | branch: command.branch, | |
| 117 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | |
| 118 | aheadCount: command.aheadCount, | |
| 119 | behindCount: command.behindCount, | |
| 120 | updatedAt: command.createdAt, | |
| 121 | }, | |
| 122 | }; | |
| 123 | case "thread.turnDiff.complete": | |
| 124 | return { | |
| 125 | eventId, | |
| 126 | type: "thread.turn-diff-completed", | |
| 127 | aggregateType: "thread", | |
| 128 | aggregateId: command.threadId, | |
| 129 | occurredAt: command.createdAt, | |
| 130 | commandId: command.commandId, | |
| 131 | payload: { | |
| 132 | threadId: command.threadId, | |
| 133 | turnId: command.turnId, | |
| 134 | completedAt: command.completedAt, | |
| 135 | ...(command.status !== undefined ? { status: command.status } : {}), | |
| 136 | files: command.files, | |
| 137 | ...(command.assistantMessageId !== undefined | |
| 138 | ? { assistantMessageId: command.assistantMessageId } | |
| 139 | : {}), | |
| 140 | ...(command.checkpointTurnCount !== undefined | |
| 141 | ? { checkpointTurnCount: command.checkpointTurnCount } | |
| 142 | : {}), | |
| 143 | }, | |
| 144 | }; | |
| 145 | case "thread.activity.append": | |
| 146 | return { | |
| 147 | eventId, | |
| 148 | type: "thread.activity-appended", | |
| 149 | aggregateType: "thread", | |
| 150 | aggregateId: command.threadId, | |
| 151 | occurredAt: command.createdAt, | |
| 152 | commandId: command.commandId, | |
| 153 | payload: { | |
| 154 | threadId: command.threadId, | |
| 155 | activity: command.activity, | |
| 156 | }, | |
| 157 | }; | |
| 158 | case "thread.revert": | |
| 159 | return { | |
| 160 | eventId, | |
| 161 | type: "thread.reverted", | |
| 162 | aggregateType: "thread", | |
| 163 | aggregateId: command.threadId, | |
| 164 | occurredAt: command.createdAt, | |
| 165 | commandId: command.commandId, | |
| 166 | payload: { | |
| 167 | threadId: command.threadId, | |
| 168 | turnCount: command.turnCount, | |
| 169 | messageCount: command.messageCount, | |
| 170 | }, | |
| 171 | }; | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema); | |
| 176 | ||
| 177 | const makeOrchestrationEngine = Effect.gen(function* () { | |
| 178 | const logger = createLogger("orchestration"); | |
| 179 | const eventStore = yield* OrchestrationEventRepository; | |
| 180 | ||
| 181 | let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 182 | ||
| 183 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 184 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 185 | ||
| 186 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | |
| 187 | Effect.gen(function* () { | |
| 188 | const eventBase = mapCommandToEvent(envelope.command); | |
| 189 | const savedEvent = yield* eventStore.append(eventBase); | |
| 190 | readModel = yield* reduceEvent(readModel, savedEvent); | |
| 191 | yield* PubSub.publish(eventPubSub, savedEvent); | |
| 192 | ||
| 193 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | |
| 194 | }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); | |
| 195 | ||
| 196 | const bootstrapReadModel: Effect.Effect<void, OrchestrationDispatchError> = Effect.gen( | |
| 197 | function* () { | |
| 198 | const existingEvents = yield* eventStore.readAll(); | |
| 199 | for (const event of existingEvents) { | |
| 200 | readModel = yield* reduceEvent(readModel, event); | |
| 201 | } | |
| 202 | }, | |
| 203 | ); | |
| 204 | ||
| 205 | yield* bootstrapReadModel; | |
| 206 | ||
| 207 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 208 | yield* Effect.forkScoped(worker); | |
| 209 | logger.info("orchestration engine started", { | |
| 210 | sequence: readModel.sequence, | |
| 211 | }); | |
| 212 | ||
| 213 | const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => | |
| 214 | Effect.sync((): OrchestrationReadModel => readModel); | |
| 215 | ||
| 216 | const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => | |
| 217 | eventStore.readFromSequence(fromSequenceExclusive); | |
| 218 | ||
| 219 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 220 | Effect.gen(function* () { | |
| 221 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); | |
| 222 | yield* Queue.offer(commandQueue, { command, result }); | |
| 223 | return yield* Deferred.await(result); | |
| 224 | }); | |
| 225 | ||
| 226 | const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => | |
| 227 | Effect.gen(function* () { | |
| 228 | const payload = | |
| 229 | typeof command === "string" | |
| 230 | ? yield* Effect.try({ | |
| 231 | try: () => JSON.parse(command) as unknown, | |
| 232 | catch: toOrchestrationJsonParseError, | |
| 233 | }) | |
| 234 | : command; | |
| 235 | ||
| 236 | const decoded = yield* decodeUnknownCommand(payload).pipe( | |
| 237 | Effect.mapError(toOrchestrationCommandDecodeError), | |
| 238 | ); | |
| 239 | ||
| 240 | return yield* dispatch(decoded); | |
| 241 | }); | |
| 242 | ||
| 243 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = | |
| 244 | Stream.fromPubSub(eventPubSub); | |
| 245 | ||
| 246 | return { | |
| 247 | getSnapshot, | |
| 248 | replayEvents, | |
| 249 | dispatchUnknown, | |
| 250 | dispatch, | |
| 251 | streamDomainEvents, | |
| 252 | } satisfies OrchestrationEngineShape; | |
| 253 | }); | |
| 254 | ||
| 255 | export const OrchestrationEngineLive = Layer.effect( | |
| 256 | OrchestrationEngineService, | |
| 257 | makeOrchestrationEngine, | |
| 258 | ); |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts created+110| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; | |
| 8 | ||
| 9 | import { createLogger } from "../../logger.ts"; | |
| 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | |
| 11 | import { | |
| 12 | toOrchestrationCommandDecodeError, | |
| 13 | toOrchestrationJsonParseError, | |
| 14 | type OrchestrationDispatchError, | |
| 15 | } from "../Errors.ts"; | |
| 16 | import { decideOrchestrationCommand } from "../decider.ts"; | |
| 17 | import { createEmptyReadModel, projectEvent } from "../projector.ts"; | |
| 18 | import { | |
| 19 | OrchestrationEngineService, | |
| 20 | type OrchestrationEngineShape, | |
| 21 | } from "../Services/OrchestrationEngine.ts"; | |
| 22 | ||
| 23 | interface CommandEnvelope { | |
| 24 | command: OrchestrationCommand; | |
| 25 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | |
| 26 | } | |
| 27 | ||
| 28 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommandSchema); | |
| 29 | ||
| 30 | const makeOrchestrationEngine = Effect.gen(function* () { | |
| 31 | const logger = createLogger("orchestration"); | |
| 32 | const eventStore = yield* OrchestrationEventStore; | |
| 33 | ||
| 34 | let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 35 | ||
| 36 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 37 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 38 | ||
| 39 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | |
| 40 | Effect.gen(function* () { | |
| 41 | const eventBase = yield* decideOrchestrationCommand({ | |
| 42 | command: envelope.command, | |
| 43 | readModel, | |
| 44 | }); | |
| 45 | const savedEvent = yield* eventStore.append(eventBase); | |
| 46 | readModel = yield* projectEvent(readModel, savedEvent); | |
| 47 | yield* PubSub.publish(eventPubSub, savedEvent); | |
| 48 | ||
| 49 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | |
| 50 | }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); | |
| 51 | ||
| 52 | // bootstrap read model from event store | |
| 53 | yield* Stream.runForEach(eventStore.readAll(), (event) => | |
| 54 | Effect.gen(function* () { | |
| 55 | readModel = yield* projectEvent(readModel, event); | |
| 56 | }), | |
| 57 | ); | |
| 58 | ||
| 59 | const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 60 | yield* Effect.forkScoped(worker); | |
| 61 | logger.info("orchestration engine started", { | |
| 62 | sequence: readModel.sequence, | |
| 63 | }); | |
| 64 | ||
| 65 | const getReadModel: OrchestrationEngineShape["getReadModel"] = () => | |
| 66 | Effect.sync((): OrchestrationReadModel => readModel); | |
| 67 | ||
| 68 | const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) => | |
| 69 | eventStore.readFromSequence(fromSequenceExclusive); | |
| 70 | ||
| 71 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 72 | Effect.gen(function* () { | |
| 73 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); | |
| 74 | yield* Queue.offer(commandQueue, { command, result }); | |
| 75 | return yield* Deferred.await(result); | |
| 76 | }); | |
| 77 | ||
| 78 | const dispatchUnknownCommand: OrchestrationEngineShape["dispatchUnknownCommand"] = (command) => | |
| 79 | Effect.gen(function* () { | |
| 80 | const payload = | |
| 81 | typeof command === "string" | |
| 82 | ? yield* Effect.try({ | |
| 83 | try: () => JSON.parse(command) as unknown, | |
| 84 | catch: toOrchestrationJsonParseError, | |
| 85 | }) | |
| 86 | : command; | |
| 87 | ||
| 88 | const decoded = yield* decodeUnknownCommand(payload).pipe( | |
| 89 | Effect.mapError(toOrchestrationCommandDecodeError), | |
| 90 | ); | |
| 91 | ||
| 92 | return yield* dispatch(decoded); | |
| 93 | }); | |
| 94 | ||
| 95 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = | |
| 96 | Stream.fromPubSub(eventPubSub); | |
| 97 | ||
| 98 | return { | |
| 99 | getReadModel, | |
| 100 | readEvents, | |
| 101 | dispatchUnknownCommand, | |
| 102 | dispatch, | |
| 103 | streamDomainEvents, | |
| 104 | } satisfies OrchestrationEngineShape; | |
| 105 | }); | |
| 106 | ||
| 107 | export const OrchestrationEngineLive = Layer.effect( | |
| 108 | OrchestrationEngineService, | |
| 109 | makeOrchestrationEngine, | |
| 110 | ); |
apps/server/src/orchestration/Service.ts deleted-84| ... | ... | @@ -1,84 +0,0 @@ |
| 1 | /** | |
| 2 | * OrchestrationEngineService - Service interface for orchestration command handling. | |
| 3 | * | |
| 4 | * Owns command validation/dispatch and in-memory read-model updates backed by | |
| 5 | * `OrchestrationEventRepository` persistence. It does not own provider process | |
| 6 | * management or transport concerns (e.g. websocket request parsing). | |
| 7 | * | |
| 8 | * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, | |
| 9 | * replay, and unknown-input decoding all return typed domain errors. | |
| 10 | * | |
| 11 | * @module OrchestrationEngineService | |
| 12 | */ | |
| 13 | import type { | |
| 14 | OrchestrationCommand, | |
| 15 | OrchestrationEvent, | |
| 16 | OrchestrationReadModel, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { ServiceMap } from "effect"; | |
| 19 | import type { Effect, Stream } from "effect"; | |
| 20 | ||
| 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; | |
| 22 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; | |
| 23 | ||
| 24 | export interface OrchestrationEngineShape { | |
| 25 | /** | |
| 26 | * Read the current in-memory orchestration snapshot. | |
| 27 | * | |
| 28 | * @returns Effect containing the latest read model. | |
| 29 | */ | |
| 30 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, never, never>; | |
| 31 | ||
| 32 | /** | |
| 33 | * Replay persisted orchestration events from an exclusive sequence cursor. | |
| 34 | * | |
| 35 | * @param fromSequenceExclusive - Sequence cursor (exclusive). | |
| 36 | * @returns Effect containing ordered events. | |
| 37 | */ | |
| 38 | readonly replayEvents: ( | |
| 39 | fromSequenceExclusive: number, | |
| 40 | ) => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError, never>; | |
| 41 | ||
| 42 | /** | |
| 43 | * Validate and dispatch an unknown command payload. | |
| 44 | * | |
| 45 | * Accepts either parsed values or raw JSON strings. | |
| 46 | * | |
| 47 | * @param command - Unknown command payload. | |
| 48 | * @returns Effect containing the sequence of the persisted event. | |
| 49 | */ | |
| 50 | readonly dispatchUnknown: ( | |
| 51 | command: unknown, | |
| 52 | ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>; | |
| 53 | ||
| 54 | /** | |
| 55 | * Dispatch a validated orchestration command. | |
| 56 | * | |
| 57 | * @param command - Valid orchestration command. | |
| 58 | * @returns Effect containing the sequence of the persisted event. | |
| 59 | */ | |
| 60 | readonly dispatch: ( | |
| 61 | command: OrchestrationCommand, | |
| 62 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; | |
| 63 | ||
| 64 | /** | |
| 65 | * Stream persisted domain events in dispatch order. | |
| 66 | */ | |
| 67 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * OrchestrationEngineService - Service tag for orchestration engine access. | |
| 72 | * | |
| 73 | * @example | |
| 74 | * ```ts | |
| 75 | * const program = Effect.gen(function* () { | |
| 76 | * const engine = yield* OrchestrationEngineService | |
| 77 | * return yield* engine.getSnapshot() | |
| 78 | * }) | |
| 79 | * ``` | |
| 80 | */ | |
| 81 | export class OrchestrationEngineService extends ServiceMap.Service< | |
| 82 | OrchestrationEngineService, | |
| 83 | OrchestrationEngineShape | |
| 84 | >()("orchestration/Engine") {} |
apps/server/src/orchestration/Services/OrchestrationEngine.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | /** | |
| 2 | * OrchestrationEngineService - Service interface for orchestration command handling. | |
| 3 | * | |
| 4 | * Owns command validation/dispatch and in-memory read-model updates backed by | |
| 5 | * `OrchestrationEventStore` persistence. It does not own provider process | |
| 6 | * management or transport concerns (e.g. websocket request parsing). | |
| 7 | * | |
| 8 | * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, | |
| 9 | * replay, and unknown-input decoding all return typed domain errors. | |
| 10 | * | |
| 11 | * @module OrchestrationEngineService | |
| 12 | */ | |
| 13 | import type { | |
| 14 | OrchestrationCommand, | |
| 15 | OrchestrationEvent, | |
| 16 | OrchestrationReadModel, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { ServiceMap } from "effect"; | |
| 19 | import type { Effect, Stream } from "effect"; | |
| 20 | ||
| 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "../Errors.ts"; | |
| 22 | import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; | |
| 23 | ||
| 24 | export interface OrchestrationEngineShape { | |
| 25 | /** | |
| 26 | * Read the current in-memory orchestration read model. | |
| 27 | * | |
| 28 | * @returns Effect containing the latest read model. | |
| 29 | */ | |
| 30 | readonly getReadModel: () => Effect.Effect<OrchestrationReadModel, never, never>; | |
| 31 | ||
| 32 | /** | |
| 33 | * Replay persisted orchestration events from an exclusive sequence cursor. | |
| 34 | * | |
| 35 | * @param fromSequenceExclusive - Sequence cursor (exclusive). | |
| 36 | * @returns Stream containing ordered events. | |
| 37 | */ | |
| 38 | readonly readEvents: ( | |
| 39 | fromSequenceExclusive: number, | |
| 40 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>; | |
| 41 | ||
| 42 | /** | |
| 43 | * Validate and dispatch an unknown command payload. | |
| 44 | * | |
| 45 | * Accepts either parsed values or raw JSON strings. | |
| 46 | * | |
| 47 | * @param command - Unknown command payload. | |
| 48 | * @returns Effect containing the sequence of the persisted event. | |
| 49 | */ | |
| 50 | readonly dispatchUnknownCommand: ( | |
| 51 | command: unknown, | |
| 52 | ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>; | |
| 53 | ||
| 54 | /** | |
| 55 | * Dispatch a validated orchestration command. | |
| 56 | * | |
| 57 | * @param command - Valid orchestration command. | |
| 58 | * @returns Effect containing the sequence of the persisted event. | |
| 59 | */ | |
| 60 | readonly dispatch: ( | |
| 61 | command: OrchestrationCommand, | |
| 62 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; | |
| 63 | ||
| 64 | /** | |
| 65 | * Stream persisted domain events in dispatch order. | |
| 66 | */ | |
| 67 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * OrchestrationEngineService - Service tag for orchestration engine access. | |
| 72 | * | |
| 73 | * @example | |
| 74 | * ```ts | |
| 75 | * const program = Effect.gen(function* () { | |
| 76 | * const engine = yield* OrchestrationEngineService | |
| 77 | * return yield* engine.getReadModel() | |
| 78 | * }) | |
| 79 | * ``` | |
| 80 | */ | |
| 81 | export class OrchestrationEngineService extends ServiceMap.Service< | |
| 82 | OrchestrationEngineService, | |
| 83 | OrchestrationEngineShape | |
| 84 | >()("orchestration/Engine") {} |
apps/server/src/orchestration/commandInvariants.test.ts created+161| ... | ... | @@ -0,0 +1,161 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts"; | |
| 3 | import { Effect } from "effect"; | |
| 4 | ||
| 5 | import { | |
| 6 | findThreadById, | |
| 7 | listThreadsByProjectId, | |
| 8 | requireNonNegativeInteger, | |
| 9 | requireThread, | |
| 10 | requireThreadAbsent, | |
| 11 | } from "./commandInvariants.ts"; | |
| 12 | ||
| 13 | const now = new Date().toISOString(); | |
| 14 | ||
| 15 | const readModel: OrchestrationReadModel = { | |
| 16 | sequence: 2, | |
| 17 | updatedAt: now, | |
| 18 | gitByProjectId: {}, | |
| 19 | threads: [ | |
| 20 | { | |
| 21 | id: "thread-1", | |
| 22 | projectId: "project-a", | |
| 23 | title: "Thread A", | |
| 24 | model: "gpt-5-codex", | |
| 25 | branch: null, | |
| 26 | worktreePath: null, | |
| 27 | createdAt: now, | |
| 28 | updatedAt: now, | |
| 29 | latestTurnId: null, | |
| 30 | latestTurnStartedAt: null, | |
| 31 | latestTurnCompletedAt: null, | |
| 32 | latestTurnDurationMs: null, | |
| 33 | messages: [], | |
| 34 | session: null, | |
| 35 | turnDiffSummaries: [], | |
| 36 | activities: [], | |
| 37 | error: null, | |
| 38 | }, | |
| 39 | { | |
| 40 | id: "thread-2", | |
| 41 | projectId: "project-b", | |
| 42 | title: "Thread B", | |
| 43 | model: "gpt-5-codex", | |
| 44 | branch: null, | |
| 45 | worktreePath: null, | |
| 46 | createdAt: now, | |
| 47 | updatedAt: now, | |
| 48 | latestTurnId: null, | |
| 49 | latestTurnStartedAt: null, | |
| 50 | latestTurnCompletedAt: null, | |
| 51 | latestTurnDurationMs: null, | |
| 52 | messages: [], | |
| 53 | session: null, | |
| 54 | turnDiffSummaries: [], | |
| 55 | activities: [], | |
| 56 | error: null, | |
| 57 | }, | |
| 58 | ], | |
| 59 | }; | |
| 60 | ||
| 61 | const messageSendCommand: OrchestrationCommand = { | |
| 62 | type: "message.send", | |
| 63 | commandId: "cmd-1", | |
| 64 | threadId: "thread-1", | |
| 65 | messageId: "msg-1", | |
| 66 | role: "user", | |
| 67 | text: "hello", | |
| 68 | streaming: false, | |
| 69 | createdAt: now, | |
| 70 | }; | |
| 71 | ||
| 72 | describe("commandInvariants", () => { | |
| 73 | it("finds threads by id and project", () => { | |
| 74 | expect(findThreadById(readModel, "thread-1")?.projectId).toBe("project-a"); | |
| 75 | expect(findThreadById(readModel, "missing")).toBeUndefined(); | |
| 76 | expect(listThreadsByProjectId(readModel, "project-b").map((thread) => thread.id)).toEqual([ | |
| 77 | "thread-2", | |
| 78 | ]); | |
| 79 | }); | |
| 80 | ||
| 81 | it("requires existing thread", async () => { | |
| 82 | const thread = await Effect.runPromise( | |
| 83 | requireThread({ | |
| 84 | readModel, | |
| 85 | command: messageSendCommand, | |
| 86 | threadId: "thread-1", | |
| 87 | }), | |
| 88 | ); | |
| 89 | expect(thread.id).toBe("thread-1"); | |
| 90 | ||
| 91 | await expect( | |
| 92 | Effect.runPromise( | |
| 93 | requireThread({ | |
| 94 | readModel, | |
| 95 | command: messageSendCommand, | |
| 96 | threadId: "missing", | |
| 97 | }), | |
| 98 | ), | |
| 99 | ).rejects.toThrow("does not exist"); | |
| 100 | }); | |
| 101 | ||
| 102 | it("requires missing thread for create flows", async () => { | |
| 103 | await Effect.runPromise( | |
| 104 | requireThreadAbsent({ | |
| 105 | readModel, | |
| 106 | command: { | |
| 107 | type: "thread.create", | |
| 108 | commandId: "cmd-2", | |
| 109 | threadId: "thread-3", | |
| 110 | projectId: "project-a", | |
| 111 | title: "new", | |
| 112 | model: "gpt-5-codex", | |
| 113 | branch: null, | |
| 114 | worktreePath: null, | |
| 115 | createdAt: now, | |
| 116 | }, | |
| 117 | threadId: "thread-3", | |
| 118 | }), | |
| 119 | ); | |
| 120 | ||
| 121 | await expect( | |
| 122 | Effect.runPromise( | |
| 123 | requireThreadAbsent({ | |
| 124 | readModel, | |
| 125 | command: { | |
| 126 | type: "thread.create", | |
| 127 | commandId: "cmd-3", | |
| 128 | threadId: "thread-1", | |
| 129 | projectId: "project-a", | |
| 130 | title: "dup", | |
| 131 | model: "gpt-5-codex", | |
| 132 | branch: null, | |
| 133 | worktreePath: null, | |
| 134 | createdAt: now, | |
| 135 | }, | |
| 136 | threadId: "thread-1", | |
| 137 | }), | |
| 138 | ), | |
| 139 | ).rejects.toThrow("already exists"); | |
| 140 | }); | |
| 141 | ||
| 142 | it("requires non-negative integers", async () => { | |
| 143 | await Effect.runPromise( | |
| 144 | requireNonNegativeInteger({ | |
| 145 | commandType: "thread.revert", | |
| 146 | field: "turnCount", | |
| 147 | value: 0, | |
| 148 | }), | |
| 149 | ); | |
| 150 | ||
| 151 | await expect( | |
| 152 | Effect.runPromise( | |
| 153 | requireNonNegativeInteger({ | |
| 154 | commandType: "thread.revert", | |
| 155 | field: "turnCount", | |
| 156 | value: -1, | |
| 157 | }), | |
| 158 | ), | |
| 159 | ).rejects.toThrow("greater than or equal to 0"); | |
| 160 | }); | |
| 161 | }); |
apps/server/src/orchestration/commandInvariants.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import type { OrchestrationCommand, OrchestrationReadModel, OrchestrationThread } from "@t3tools/contracts"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | ||
| 4 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | |
| 5 | ||
| 6 | function invariantError(commandType: string, detail: string): OrchestrationCommandInvariantError { | |
| 7 | return new OrchestrationCommandInvariantError({ | |
| 8 | commandType, | |
| 9 | detail, | |
| 10 | }); | |
| 11 | } | |
| 12 | ||
| 13 | export function findThreadById( | |
| 14 | readModel: OrchestrationReadModel, | |
| 15 | threadId: string, | |
| 16 | ): OrchestrationThread | undefined { | |
| 17 | return readModel.threads.find((thread) => thread.id === threadId); | |
| 18 | } | |
| 19 | ||
| 20 | export function listThreadsByProjectId( | |
| 21 | readModel: OrchestrationReadModel, | |
| 22 | projectId: string, | |
| 23 | ): ReadonlyArray<OrchestrationThread> { | |
| 24 | return readModel.threads.filter((thread) => thread.projectId === projectId); | |
| 25 | } | |
| 26 | ||
| 27 | export function requireThread( | |
| 28 | input: { | |
| 29 | readonly readModel: OrchestrationReadModel; | |
| 30 | readonly command: OrchestrationCommand; | |
| 31 | readonly threadId: string; | |
| 32 | }, | |
| 33 | ): Effect.Effect<OrchestrationThread, OrchestrationCommandInvariantError> { | |
| 34 | const thread = findThreadById(input.readModel, input.threadId); | |
| 35 | if (thread) { | |
| 36 | return Effect.succeed(thread); | |
| 37 | } | |
| 38 | return Effect.fail( | |
| 39 | invariantError( | |
| 40 | input.command.type, | |
| 41 | `Thread '${input.threadId}' does not exist for command '${input.command.type}'.`, | |
| 42 | ), | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export function requireThreadAbsent( | |
| 47 | input: { | |
| 48 | readonly readModel: OrchestrationReadModel; | |
| 49 | readonly command: OrchestrationCommand; | |
| 50 | readonly threadId: string; | |
| 51 | }, | |
| 52 | ): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 53 | if (!findThreadById(input.readModel, input.threadId)) { | |
| 54 | return Effect.void; | |
| 55 | } | |
| 56 | return Effect.fail( | |
| 57 | invariantError( | |
| 58 | input.command.type, | |
| 59 | `Thread '${input.threadId}' already exists and cannot be created twice.`, | |
| 60 | ), | |
| 61 | ); | |
| 62 | } | |
| 63 | ||
| 64 | export function requireNonNegativeInteger( | |
| 65 | input: { | |
| 66 | readonly commandType: OrchestrationCommand["type"]; | |
| 67 | readonly field: string; | |
| 68 | readonly value: number; | |
| 69 | }, | |
| 70 | ): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 71 | if (Number.isInteger(input.value) && input.value >= 0) { | |
| 72 | return Effect.void; | |
| 73 | } | |
| 74 | return Effect.fail( | |
| 75 | invariantError( | |
| 76 | input.commandType, | |
| 77 | `${input.field} must be an integer greater than or equal to 0.`, | |
| 78 | ), | |
| 79 | ); | |
| 80 | } |
apps/server/src/orchestration/decider.ts created+229| ... | ... | @@ -0,0 +1,229 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { Effect } from "effect"; | |
| 7 | ||
| 8 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | |
| 9 | import { | |
| 10 | requireNonNegativeInteger, | |
| 11 | requireThread, | |
| 12 | requireThreadAbsent, | |
| 13 | } from "./commandInvariants.ts"; | |
| 14 | ||
| 15 | export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ | |
| 16 | command, | |
| 17 | readModel, | |
| 18 | }: { | |
| 19 | readonly command: OrchestrationCommand; | |
| 20 | readonly readModel: OrchestrationReadModel; | |
| 21 | }): Effect.fn.Return<Omit<OrchestrationEvent, "sequence">, OrchestrationCommandInvariantError> { | |
| 22 | const eventId = crypto.randomUUID(); | |
| 23 | ||
| 24 | switch (command.type) { | |
| 25 | case "thread.create": | |
| 26 | yield* requireThreadAbsent({ | |
| 27 | readModel, | |
| 28 | command, | |
| 29 | threadId: command.threadId, | |
| 30 | }); | |
| 31 | return { | |
| 32 | eventId, | |
| 33 | type: "thread.created", | |
| 34 | aggregateType: "thread", | |
| 35 | aggregateId: command.threadId, | |
| 36 | occurredAt: command.createdAt, | |
| 37 | commandId: command.commandId, | |
| 38 | payload: { | |
| 39 | id: command.threadId, | |
| 40 | projectId: command.projectId, | |
| 41 | title: command.title, | |
| 42 | model: command.model, | |
| 43 | branch: command.branch, | |
| 44 | worktreePath: command.worktreePath, | |
| 45 | createdAt: command.createdAt, | |
| 46 | updatedAt: command.createdAt, | |
| 47 | }, | |
| 48 | }; | |
| 49 | case "thread.delete": | |
| 50 | yield* requireThread({ | |
| 51 | readModel, | |
| 52 | command, | |
| 53 | threadId: command.threadId, | |
| 54 | }); | |
| 55 | return { | |
| 56 | eventId, | |
| 57 | type: "thread.deleted", | |
| 58 | aggregateType: "thread", | |
| 59 | aggregateId: command.threadId, | |
| 60 | occurredAt: command.createdAt, | |
| 61 | commandId: command.commandId, | |
| 62 | payload: { | |
| 63 | id: command.threadId, | |
| 64 | deletedAt: command.createdAt, | |
| 65 | }, | |
| 66 | }; | |
| 67 | case "thread.meta.update": | |
| 68 | yield* requireThread({ | |
| 69 | readModel, | |
| 70 | command, | |
| 71 | threadId: command.threadId, | |
| 72 | }); | |
| 73 | return { | |
| 74 | eventId, | |
| 75 | type: "thread.meta-updated", | |
| 76 | aggregateType: "thread", | |
| 77 | aggregateId: command.threadId, | |
| 78 | occurredAt: command.createdAt, | |
| 79 | commandId: command.commandId, | |
| 80 | payload: { | |
| 81 | threadId: command.threadId, | |
| 82 | ...(command.title !== undefined ? { title: command.title } : {}), | |
| 83 | ...(command.model !== undefined ? { model: command.model } : {}), | |
| 84 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | |
| 85 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | |
| 86 | updatedAt: command.createdAt, | |
| 87 | }, | |
| 88 | }; | |
| 89 | case "message.send": | |
| 90 | yield* requireThread({ | |
| 91 | readModel, | |
| 92 | command, | |
| 93 | threadId: command.threadId, | |
| 94 | }); | |
| 95 | return { | |
| 96 | eventId, | |
| 97 | type: "message.sent", | |
| 98 | aggregateType: "thread", | |
| 99 | aggregateId: command.threadId, | |
| 100 | occurredAt: command.createdAt, | |
| 101 | commandId: command.commandId, | |
| 102 | payload: { | |
| 103 | id: command.messageId, | |
| 104 | role: command.role, | |
| 105 | text: command.text, | |
| 106 | threadId: command.threadId, | |
| 107 | createdAt: command.createdAt, | |
| 108 | streaming: command.streaming === true, | |
| 109 | }, | |
| 110 | }; | |
| 111 | case "thread.session": | |
| 112 | yield* requireThread({ | |
| 113 | readModel, | |
| 114 | command, | |
| 115 | threadId: command.threadId, | |
| 116 | }); | |
| 117 | return { | |
| 118 | eventId, | |
| 119 | type: "thread.session-set", | |
| 120 | aggregateType: "thread", | |
| 121 | aggregateId: command.threadId, | |
| 122 | occurredAt: command.createdAt, | |
| 123 | commandId: command.commandId, | |
| 124 | payload: { | |
| 125 | threadId: command.threadId, | |
| 126 | session: command.session, | |
| 127 | }, | |
| 128 | }; | |
| 129 | case "git.readModel.upsert": | |
| 130 | return { | |
| 131 | eventId, | |
| 132 | type: "git.read-model-upsert", | |
| 133 | aggregateType: "project", | |
| 134 | aggregateId: command.projectId, | |
| 135 | occurredAt: command.createdAt, | |
| 136 | commandId: command.commandId, | |
| 137 | payload: { | |
| 138 | projectId: command.projectId, | |
| 139 | branch: command.branch, | |
| 140 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | |
| 141 | aheadCount: command.aheadCount, | |
| 142 | behindCount: command.behindCount, | |
| 143 | updatedAt: command.createdAt, | |
| 144 | }, | |
| 145 | }; | |
| 146 | case "thread.turnDiff.complete": | |
| 147 | yield* requireThread({ | |
| 148 | readModel, | |
| 149 | command, | |
| 150 | threadId: command.threadId, | |
| 151 | }); | |
| 152 | return { | |
| 153 | eventId, | |
| 154 | type: "thread.turn-diff-completed", | |
| 155 | aggregateType: "thread", | |
| 156 | aggregateId: command.threadId, | |
| 157 | occurredAt: command.createdAt, | |
| 158 | commandId: command.commandId, | |
| 159 | payload: { | |
| 160 | threadId: command.threadId, | |
| 161 | turnId: command.turnId, | |
| 162 | completedAt: command.completedAt, | |
| 163 | ...(command.status !== undefined ? { status: command.status } : {}), | |
| 164 | files: command.files, | |
| 165 | ...(command.assistantMessageId !== undefined | |
| 166 | ? { assistantMessageId: command.assistantMessageId } | |
| 167 | : {}), | |
| 168 | ...(command.checkpointTurnCount !== undefined | |
| 169 | ? { checkpointTurnCount: command.checkpointTurnCount } | |
| 170 | : {}), | |
| 171 | }, | |
| 172 | }; | |
| 173 | case "thread.activity.append": | |
| 174 | yield* requireThread({ | |
| 175 | readModel, | |
| 176 | command, | |
| 177 | threadId: command.threadId, | |
| 178 | }); | |
| 179 | return { | |
| 180 | eventId, | |
| 181 | type: "thread.activity-appended", | |
| 182 | aggregateType: "thread", | |
| 183 | aggregateId: command.threadId, | |
| 184 | occurredAt: command.createdAt, | |
| 185 | commandId: command.commandId, | |
| 186 | payload: { | |
| 187 | threadId: command.threadId, | |
| 188 | activity: command.activity, | |
| 189 | }, | |
| 190 | }; | |
| 191 | case "thread.revert": | |
| 192 | yield* requireThread({ | |
| 193 | readModel, | |
| 194 | command, | |
| 195 | threadId: command.threadId, | |
| 196 | }); | |
| 197 | yield* requireNonNegativeInteger({ | |
| 198 | commandType: command.type, | |
| 199 | field: "turnCount", | |
| 200 | value: command.turnCount, | |
| 201 | }); | |
| 202 | yield* requireNonNegativeInteger({ | |
| 203 | commandType: command.type, | |
| 204 | field: "messageCount", | |
| 205 | value: command.messageCount, | |
| 206 | }); | |
| 207 | return { | |
| 208 | eventId, | |
| 209 | type: "thread.reverted", | |
| 210 | aggregateType: "thread", | |
| 211 | aggregateId: command.threadId, | |
| 212 | occurredAt: command.createdAt, | |
| 213 | commandId: command.commandId, | |
| 214 | payload: { | |
| 215 | threadId: command.threadId, | |
| 216 | turnCount: command.turnCount, | |
| 217 | messageCount: command.messageCount, | |
| 218 | }, | |
| 219 | }; | |
| 220 | default: { | |
| 221 | command satisfies never; | |
| 222 | const fallback = command as any; | |
| 223 | return yield* new OrchestrationCommandInvariantError({ | |
| 224 | commandType: fallback.type, | |
| 225 | detail: `Unknown command type: ${fallback.type}`, | |
| 226 | }); | |
| 227 | } | |
| 228 | } | |
| 229 | }); |
apps/server/src/orchestration/projector.test.ts created+258| ... | ... | @@ -0,0 +1,258 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | |
| 6 | ||
| 7 | describe("orchestration projector", () => { | |
| 8 | it("applies thread.created events using runtime schema decoding", async () => { | |
| 9 | const now = new Date().toISOString(); | |
| 10 | const model = createEmptyReadModel(now); | |
| 11 | const event: OrchestrationEvent = { | |
| 12 | sequence: 1, | |
| 13 | eventId: "event-thread-created", | |
| 14 | type: "thread.created", | |
| 15 | aggregateType: "thread", | |
| 16 | aggregateId: "thread-1", | |
| 17 | occurredAt: now, | |
| 18 | commandId: "cmd-1", | |
| 19 | payload: { | |
| 20 | id: "thread-1", | |
| 21 | projectId: "project-1", | |
| 22 | title: "demo", | |
| 23 | model: "gpt-5-codex", | |
| 24 | branch: null, | |
| 25 | worktreePath: null, | |
| 26 | createdAt: now, | |
| 27 | updatedAt: now, | |
| 28 | }, | |
| 29 | }; | |
| 30 | ||
| 31 | const next = await Effect.runPromise(projectEvent(model, event)); | |
| 32 | expect(next.sequence).toBe(1); | |
| 33 | expect(next.threads).toEqual([ | |
| 34 | { | |
| 35 | id: "thread-1", | |
| 36 | projectId: "project-1", | |
| 37 | title: "demo", | |
| 38 | model: "gpt-5-codex", | |
| 39 | branch: null, | |
| 40 | worktreePath: null, | |
| 41 | createdAt: now, | |
| 42 | updatedAt: now, | |
| 43 | latestTurnId: null, | |
| 44 | latestTurnStartedAt: null, | |
| 45 | latestTurnCompletedAt: null, | |
| 46 | latestTurnDurationMs: null, | |
| 47 | messages: [], | |
| 48 | session: null, | |
| 49 | activities: [], | |
| 50 | turnDiffSummaries: [], | |
| 51 | error: null, | |
| 52 | }, | |
| 53 | ]); | |
| 54 | }); | |
| 55 | ||
| 56 | it("fails when event payload cannot be decoded by runtime schema", async () => { | |
| 57 | const now = new Date().toISOString(); | |
| 58 | const model = createEmptyReadModel(now); | |
| 59 | const event: OrchestrationEvent = { | |
| 60 | sequence: 1, | |
| 61 | eventId: "event-message-invalid", | |
| 62 | type: "message.sent", | |
| 63 | aggregateType: "thread", | |
| 64 | aggregateId: "thread-1", | |
| 65 | occurredAt: now, | |
| 66 | commandId: "cmd-1", | |
| 67 | payload: { | |
| 68 | id: "message-1", | |
| 69 | threadId: "thread-1", | |
| 70 | text: "hello", | |
| 71 | createdAt: now, | |
| 72 | streaming: false, | |
| 73 | }, | |
| 74 | }; | |
| 75 | ||
| 76 | await expect(Effect.runPromise(projectEvent(model, event))).rejects.toBeDefined(); | |
| 77 | }); | |
| 78 | ||
| 79 | it("keeps projector forward-compatible for unknown event types", async () => { | |
| 80 | const now = new Date().toISOString(); | |
| 81 | const model = createEmptyReadModel(now); | |
| 82 | const event: OrchestrationEvent = { | |
| 83 | sequence: 7, | |
| 84 | eventId: "event-unknown", | |
| 85 | type: "custom.event.ignored", | |
| 86 | aggregateType: "unknown", | |
| 87 | aggregateId: "aggregate-1", | |
| 88 | occurredAt: "2026-01-01T00:00:00.000Z", | |
| 89 | commandId: null, | |
| 90 | payload: { test: true }, | |
| 91 | }; | |
| 92 | ||
| 93 | const next = await Effect.runPromise(projectEvent(model, event)); | |
| 94 | expect(next.sequence).toBe(7); | |
| 95 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); | |
| 96 | expect(next.threads).toEqual([]); | |
| 97 | }); | |
| 98 | ||
| 99 | it("tracks latest turn timing from session lifecycle events", async () => { | |
| 100 | const createdAt = "2026-02-23T08:00:00.000Z"; | |
| 101 | const startedAt = "2026-02-23T08:00:05.000Z"; | |
| 102 | const completedAt = "2026-02-23T08:00:11.250Z"; | |
| 103 | const model = createEmptyReadModel(createdAt); | |
| 104 | ||
| 105 | const createdEvent: OrchestrationEvent = { | |
| 106 | sequence: 1, | |
| 107 | eventId: "thread-created", | |
| 108 | type: "thread.created", | |
| 109 | aggregateType: "thread", | |
| 110 | aggregateId: "thread-1", | |
| 111 | occurredAt: createdAt, | |
| 112 | commandId: "cmd-create", | |
| 113 | payload: { | |
| 114 | id: "thread-1", | |
| 115 | projectId: "project-1", | |
| 116 | title: "demo", | |
| 117 | model: "gpt-5.3-codex", | |
| 118 | branch: null, | |
| 119 | worktreePath: null, | |
| 120 | createdAt, | |
| 121 | updatedAt: createdAt, | |
| 122 | }, | |
| 123 | }; | |
| 124 | ||
| 125 | const runningSessionEvent: OrchestrationEvent = { | |
| 126 | sequence: 2, | |
| 127 | eventId: "session-running", | |
| 128 | type: "thread.session-set", | |
| 129 | aggregateType: "thread", | |
| 130 | aggregateId: "thread-1", | |
| 131 | occurredAt: startedAt, | |
| 132 | commandId: "cmd-running", | |
| 133 | payload: { | |
| 134 | threadId: "thread-1", | |
| 135 | session: { | |
| 136 | sessionId: "session-1", | |
| 137 | status: "running", | |
| 138 | provider: "codex", | |
| 139 | threadId: "thread-1", | |
| 140 | activeTurnId: "turn-1", | |
| 141 | createdAt, | |
| 142 | updatedAt: startedAt, | |
| 143 | lastError: null, | |
| 144 | }, | |
| 145 | }, | |
| 146 | }; | |
| 147 | ||
| 148 | const readySessionEvent: OrchestrationEvent = { | |
| 149 | sequence: 3, | |
| 150 | eventId: "session-ready", | |
| 151 | type: "thread.session-set", | |
| 152 | aggregateType: "thread", | |
| 153 | aggregateId: "thread-1", | |
| 154 | occurredAt: completedAt, | |
| 155 | commandId: "cmd-ready", | |
| 156 | payload: { | |
| 157 | threadId: "thread-1", | |
| 158 | session: { | |
| 159 | sessionId: "session-1", | |
| 160 | status: "ready", | |
| 161 | provider: "codex", | |
| 162 | threadId: "thread-1", | |
| 163 | activeTurnId: null, | |
| 164 | createdAt, | |
| 165 | updatedAt: completedAt, | |
| 166 | lastError: null, | |
| 167 | }, | |
| 168 | }, | |
| 169 | }; | |
| 170 | ||
| 171 | const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); | |
| 172 | const afterRunning = await Effect.runPromise(projectEvent(afterCreate, runningSessionEvent)); | |
| 173 | const afterReady = await Effect.runPromise(projectEvent(afterRunning, readySessionEvent)); | |
| 174 | const thread = afterReady.threads[0]; | |
| 175 | ||
| 176 | expect(thread?.latestTurnId).toBe("turn-1"); | |
| 177 | expect(thread?.latestTurnStartedAt).toBe(startedAt); | |
| 178 | expect(thread?.latestTurnCompletedAt).toBe(completedAt); | |
| 179 | expect(thread?.latestTurnDurationMs).toBe(6250); | |
| 180 | }); | |
| 181 | ||
| 182 | it("marks assistant messages completed using non-streaming updates", async () => { | |
| 183 | const createdAt = "2026-02-23T09:00:00.000Z"; | |
| 184 | const deltaAt = "2026-02-23T09:00:01.000Z"; | |
| 185 | const completeAt = "2026-02-23T09:00:03.500Z"; | |
| 186 | const model = createEmptyReadModel(createdAt); | |
| 187 | ||
| 188 | const createdEvent: OrchestrationEvent = { | |
| 189 | sequence: 1, | |
| 190 | eventId: "thread-created", | |
| 191 | type: "thread.created", | |
| 192 | aggregateType: "thread", | |
| 193 | aggregateId: "thread-1", | |
| 194 | occurredAt: createdAt, | |
| 195 | commandId: "cmd-create", | |
| 196 | payload: { | |
| 197 | id: "thread-1", | |
| 198 | projectId: "project-1", | |
| 199 | title: "demo", | |
| 200 | model: "gpt-5.3-codex", | |
| 201 | branch: null, | |
| 202 | worktreePath: null, | |
| 203 | createdAt, | |
| 204 | updatedAt: createdAt, | |
| 205 | }, | |
| 206 | }; | |
| 207 | ||
| 208 | const deltaEvent: OrchestrationEvent = { | |
| 209 | sequence: 2, | |
| 210 | eventId: "assistant-delta", | |
| 211 | type: "message.sent", | |
| 212 | aggregateType: "thread", | |
| 213 | aggregateId: "thread-1", | |
| 214 | occurredAt: deltaAt, | |
| 215 | commandId: "cmd-delta", | |
| 216 | payload: { | |
| 217 | id: "assistant:msg-1", | |
| 218 | role: "assistant", | |
| 219 | text: "hello", | |
| 220 | threadId: "thread-1", | |
| 221 | createdAt: deltaAt, | |
| 222 | streaming: true, | |
| 223 | }, | |
| 224 | }; | |
| 225 | ||
| 226 | const completionEvent: OrchestrationEvent = { | |
| 227 | sequence: 3, | |
| 228 | eventId: "assistant-complete", | |
| 229 | type: "message.sent", | |
| 230 | aggregateType: "thread", | |
| 231 | aggregateId: "thread-1", | |
| 232 | occurredAt: completeAt, | |
| 233 | commandId: "cmd-complete", | |
| 234 | payload: { | |
| 235 | id: "assistant:msg-1", | |
| 236 | role: "assistant", | |
| 237 | text: "", | |
| 238 | threadId: "thread-1", | |
| 239 | createdAt: completeAt, | |
| 240 | streaming: false, | |
| 241 | }, | |
| 242 | }; | |
| 243 | ||
| 244 | const afterCreate = await Effect.runPromise(projectEvent(model, createdEvent)); | |
| 245 | const afterDelta = await Effect.runPromise(projectEvent(afterCreate, deltaEvent)); | |
| 246 | const afterComplete = await Effect.runPromise(projectEvent(afterDelta, completionEvent)); | |
| 247 | const message = afterComplete.threads[0]?.messages[0]; | |
| 248 | ||
| 249 | expect(message).toEqual({ | |
| 250 | id: "assistant:msg-1", | |
| 251 | role: "assistant", | |
| 252 | text: "hello", | |
| 253 | createdAt: deltaAt, | |
| 254 | completedAt: completeAt, | |
| 255 | streaming: false, | |
| 256 | }); | |
| 257 | }); | |
| 258 | }); |
apps/server/src/orchestration/projector.ts created+475| ... | ... | @@ -0,0 +1,475 @@ |
| 1 | import type { | |
| 2 | OrchestrationEvent, | |
| 3 | OrchestrationGitReadModel, | |
| 4 | OrchestrationMessage, | |
| 5 | OrchestrationReadModel, | |
| 6 | OrchestrationSession, | |
| 7 | OrchestrationThread, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | OrchestrationThreadActivitySchema, | |
| 11 | OrchestrationGitReadModelSchema, | |
| 12 | OrchestrationMessageSchema, | |
| 13 | OrchestrationSessionSchema, | |
| 14 | OrchestrationThreadSchema, | |
| 15 | OrchestrationTurnDiffFileSchema, | |
| 16 | OrchestrationTurnDiffSummarySchema, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { Effect, Schema } from "effect"; | |
| 19 | ||
| 20 | import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; | |
| 21 | ||
| 22 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | |
| 23 | ||
| 24 | function updateThread( | |
| 25 | threads: ReadonlyArray<OrchestrationThread>, | |
| 26 | threadId: string, | |
| 27 | patch: ThreadPatch, | |
| 28 | ): OrchestrationThread[] { | |
| 29 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); | |
| 30 | } | |
| 31 | ||
| 32 | function decodeForEvent<A>( | |
| 33 | schema: Schema.Schema<A>, | |
| 34 | value: unknown, | |
| 35 | eventType: string, | |
| 36 | field: string, | |
| 37 | ): Effect.Effect<A, OrchestrationProjectorDecodeError> { | |
| 38 | return Schema.decodeUnknownEffect(schema)(value).pipe( | |
| 39 | Effect.mapError(toProjectorDecodeError(`${eventType}:${field}`)), | |
| 40 | ) as Effect.Effect<A, OrchestrationProjectorDecodeError>; | |
| 41 | } | |
| 42 | ||
| 43 | const ThreadCreatedPayloadSchema = Schema.Struct({ | |
| 44 | id: Schema.String, | |
| 45 | projectId: Schema.String, | |
| 46 | title: Schema.String, | |
| 47 | model: Schema.String, | |
| 48 | branch: Schema.NullOr(Schema.String), | |
| 49 | worktreePath: Schema.NullOr(Schema.String), | |
| 50 | createdAt: Schema.String, | |
| 51 | updatedAt: Schema.String, | |
| 52 | }); | |
| 53 | ||
| 54 | const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ | |
| 55 | threadId: Schema.String, | |
| 56 | title: Schema.optional(Schema.String), | |
| 57 | model: Schema.optional(Schema.String), | |
| 58 | branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 59 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), | |
| 60 | updatedAt: Schema.String, | |
| 61 | }); | |
| 62 | ||
| 63 | const MessageSentPayloadSchema = Schema.Struct({ | |
| 64 | id: Schema.String, | |
| 65 | role: Schema.Literals(["user", "assistant"]), | |
| 66 | text: Schema.String, | |
| 67 | threadId: Schema.String, | |
| 68 | createdAt: Schema.String, | |
| 69 | streaming: Schema.Boolean, | |
| 70 | }); | |
| 71 | ||
| 72 | const ThreadSessionSetPayloadSchema = Schema.Struct({ | |
| 73 | threadId: Schema.String, | |
| 74 | session: OrchestrationSessionSchema, | |
| 75 | }); | |
| 76 | ||
| 77 | const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ | |
| 78 | threadId: Schema.String, | |
| 79 | turnId: Schema.String, | |
| 80 | completedAt: Schema.String, | |
| 81 | status: Schema.optional(Schema.String), | |
| 82 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | |
| 83 | assistantMessageId: Schema.optional(Schema.String), | |
| 84 | checkpointTurnCount: Schema.optional(Schema.Number), | |
| 85 | }); | |
| 86 | ||
| 87 | const ThreadRevertedPayloadSchema = Schema.Struct({ | |
| 88 | threadId: Schema.String, | |
| 89 | turnCount: Schema.Number, | |
| 90 | messageCount: Schema.Number, | |
| 91 | }); | |
| 92 | ||
| 93 | const ThreadActivityAppendedPayloadSchema = Schema.Struct({ | |
| 94 | threadId: Schema.String, | |
| 95 | activity: OrchestrationThreadActivitySchema, | |
| 96 | }); | |
| 97 | ||
| 98 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | |
| 99 | return { | |
| 100 | sequence: 0, | |
| 101 | threads: [], | |
| 102 | gitByProjectId: {}, | |
| 103 | updatedAt: nowIso, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | export function projectEvent( | |
| 108 | model: OrchestrationReadModel, | |
| 109 | event: OrchestrationEvent, | |
| 110 | ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> { | |
| 111 | const nextBase: OrchestrationReadModel = { | |
| 112 | ...model, | |
| 113 | sequence: event.sequence, | |
| 114 | updatedAt: event.occurredAt, | |
| 115 | }; | |
| 116 | ||
| 117 | switch (event.type) { | |
| 118 | case "thread.created": | |
| 119 | return Effect.gen(function* () { | |
| 120 | const payload = yield* decodeForEvent( | |
| 121 | ThreadCreatedPayloadSchema, | |
| 122 | event.payload, | |
| 123 | event.type, | |
| 124 | "payload", | |
| 125 | ); | |
| 126 | const thread: OrchestrationThread = yield* decodeForEvent( | |
| 127 | OrchestrationThreadSchema, | |
| 128 | { | |
| 129 | id: payload.id, | |
| 130 | projectId: payload.projectId, | |
| 131 | title: payload.title, | |
| 132 | model: payload.model, | |
| 133 | branch: payload.branch, | |
| 134 | worktreePath: payload.worktreePath, | |
| 135 | createdAt: payload.createdAt, | |
| 136 | updatedAt: payload.updatedAt, | |
| 137 | latestTurnId: null, | |
| 138 | latestTurnStartedAt: null, | |
| 139 | latestTurnCompletedAt: null, | |
| 140 | latestTurnDurationMs: null, | |
| 141 | messages: [], | |
| 142 | session: null, | |
| 143 | turnDiffSummaries: [], | |
| 144 | activities: [], | |
| 145 | error: null, | |
| 146 | }, | |
| 147 | event.type, | |
| 148 | "thread", | |
| 149 | ); | |
| 150 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); | |
| 151 | return { | |
| 152 | ...nextBase, | |
| 153 | threads: existing | |
| 154 | ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) | |
| 155 | : [...nextBase.threads, thread], | |
| 156 | }; | |
| 157 | }); | |
| 158 | case "thread.deleted": | |
| 159 | return Effect.succeed({ | |
| 160 | ...nextBase, | |
| 161 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), | |
| 162 | }); | |
| 163 | case "thread.meta-updated": | |
| 164 | return decodeForEvent( | |
| 165 | ThreadMetaUpdatedPayloadSchema, | |
| 166 | event.payload, | |
| 167 | event.type, | |
| 168 | "payload", | |
| 169 | ).pipe( | |
| 170 | Effect.map((payload) => { | |
| 171 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 172 | if (!thread) { | |
| 173 | return nextBase; | |
| 174 | } | |
| 175 | return { | |
| 176 | ...nextBase, | |
| 177 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 178 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 179 | ...(payload.model !== undefined ? { model: payload.model } : {}), | |
| 180 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | |
| 181 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | |
| 182 | updatedAt: payload.updatedAt, | |
| 183 | }), | |
| 184 | }; | |
| 185 | }), | |
| 186 | ); | |
| 187 | case "message.sent": | |
| 188 | return Effect.gen(function* () { | |
| 189 | const payload = yield* decodeForEvent( | |
| 190 | MessageSentPayloadSchema, | |
| 191 | event.payload, | |
| 192 | event.type, | |
| 193 | "payload", | |
| 194 | ); | |
| 195 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); | |
| 196 | if (!targetThread) { | |
| 197 | return nextBase; | |
| 198 | } | |
| 199 | ||
| 200 | const message: OrchestrationMessage = yield* decodeForEvent( | |
| 201 | OrchestrationMessageSchema, | |
| 202 | { | |
| 203 | id: payload.id, | |
| 204 | role: payload.role, | |
| 205 | text: payload.text, | |
| 206 | createdAt: payload.createdAt, | |
| 207 | ...(payload.streaming ? {} : { completedAt: event.occurredAt }), | |
| 208 | streaming: payload.streaming, | |
| 209 | }, | |
| 210 | event.type, | |
| 211 | "message", | |
| 212 | ); | |
| 213 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); | |
| 214 | const nextMessages = existingMessage | |
| 215 | ? targetThread.messages.map((entry) => | |
| 216 | entry.id === message.id | |
| 217 | ? { | |
| 218 | ...entry, | |
| 219 | text: message.streaming | |
| 220 | ? `${entry.text}${message.text}` | |
| 221 | : message.text.length > 0 | |
| 222 | ? message.text | |
| 223 | : entry.text, | |
| 224 | streaming: message.streaming, | |
| 225 | createdAt: entry.createdAt, | |
| 226 | ...(message.streaming | |
| 227 | ? { completedAt: undefined } | |
| 228 | : { completedAt: message.completedAt ?? event.occurredAt }), | |
| 229 | } | |
| 230 | : entry, | |
| 231 | ) | |
| 232 | : !message.streaming && message.text.length === 0 | |
| 233 | ? targetThread.messages | |
| 234 | : [...targetThread.messages, message]; | |
| 235 | ||
| 236 | return { | |
| 237 | ...nextBase, | |
| 238 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 239 | messages: nextMessages, | |
| 240 | updatedAt: event.occurredAt, | |
| 241 | }), | |
| 242 | }; | |
| 243 | }); | |
| 244 | case "thread.session-set": | |
| 245 | return Effect.gen(function* () { | |
| 246 | const payload = yield* decodeForEvent( | |
| 247 | ThreadSessionSetPayloadSchema, | |
| 248 | event.payload, | |
| 249 | event.type, | |
| 250 | "payload", | |
| 251 | ); | |
| 252 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 253 | if (!thread) { | |
| 254 | return nextBase; | |
| 255 | } | |
| 256 | ||
| 257 | const session: OrchestrationSession = yield* decodeForEvent( | |
| 258 | OrchestrationSessionSchema, | |
| 259 | { | |
| 260 | ...payload.session, | |
| 261 | threadId: payload.threadId, | |
| 262 | }, | |
| 263 | event.type, | |
| 264 | "session", | |
| 265 | ); | |
| 266 | ||
| 267 | const sessionUpdatedAt = session.updatedAt; | |
| 268 | const turnTimingPatch: ThreadPatch = | |
| 269 | session.status === "running" && session.activeTurnId | |
| 270 | ? (() => { | |
| 271 | const isSameTurn = thread.latestTurnId === session.activeTurnId; | |
| 272 | return { | |
| 273 | latestTurnId: session.activeTurnId, | |
| 274 | latestTurnStartedAt: | |
| 275 | isSameTurn && thread.latestTurnStartedAt | |
| 276 | ? thread.latestTurnStartedAt | |
| 277 | : sessionUpdatedAt, | |
| 278 | ...(isSameTurn | |
| 279 | ? {} | |
| 280 | : { | |
| 281 | latestTurnCompletedAt: null, | |
| 282 | latestTurnDurationMs: null, | |
| 283 | }), | |
| 284 | }; | |
| 285 | })() | |
| 286 | : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt | |
| 287 | ? (() => { | |
| 288 | const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt; | |
| 289 | const startedAtMs = Date.parse(thread.latestTurnStartedAt); | |
| 290 | const completedAtMs = Date.parse(latestTurnCompletedAt); | |
| 291 | const latestTurnDurationMs = | |
| 292 | !Number.isNaN(startedAtMs) && | |
| 293 | !Number.isNaN(completedAtMs) && | |
| 294 | completedAtMs >= startedAtMs | |
| 295 | ? completedAtMs - startedAtMs | |
| 296 | : thread.latestTurnDurationMs; | |
| 297 | ||
| 298 | return { | |
| 299 | latestTurnCompletedAt, | |
| 300 | latestTurnDurationMs, | |
| 301 | }; | |
| 302 | })() | |
| 303 | : {}; | |
| 304 | ||
| 305 | return { | |
| 306 | ...nextBase, | |
| 307 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 308 | session, | |
| 309 | updatedAt: event.occurredAt, | |
| 310 | error: session.lastError, | |
| 311 | ...turnTimingPatch, | |
| 312 | }), | |
| 313 | }; | |
| 314 | }); | |
| 315 | case "thread.turn-diff-completed": | |
| 316 | return Effect.gen(function* () { | |
| 317 | const payload = yield* decodeForEvent( | |
| 318 | ThreadTurnDiffCompletedPayloadSchema, | |
| 319 | event.payload, | |
| 320 | event.type, | |
| 321 | "payload", | |
| 322 | ); | |
| 323 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 324 | if (!thread) { | |
| 325 | return nextBase; | |
| 326 | } | |
| 327 | ||
| 328 | const nextSummary = yield* decodeForEvent( | |
| 329 | OrchestrationTurnDiffSummarySchema, | |
| 330 | { | |
| 331 | turnId: payload.turnId, | |
| 332 | completedAt: payload.completedAt, | |
| 333 | ...(payload.status !== undefined ? { status: payload.status } : {}), | |
| 334 | files: payload.files, | |
| 335 | ...(payload.assistantMessageId !== undefined | |
| 336 | ? { assistantMessageId: payload.assistantMessageId } | |
| 337 | : {}), | |
| 338 | ...(payload.checkpointTurnCount !== undefined | |
| 339 | ? { checkpointTurnCount: payload.checkpointTurnCount } | |
| 340 | : {}), | |
| 341 | }, | |
| 342 | event.type, | |
| 343 | "turnDiffSummary", | |
| 344 | ); | |
| 345 | const turnDiffSummaries = [ | |
| 346 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), | |
| 347 | nextSummary, | |
| 348 | ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 349 | const startedAtMs = thread.latestTurnStartedAt | |
| 350 | ? Date.parse(thread.latestTurnStartedAt) | |
| 351 | : Number.NaN; | |
| 352 | const completedAtMs = Date.parse(nextSummary.completedAt); | |
| 353 | const latestTurnDurationMs = | |
| 354 | !Number.isNaN(startedAtMs) && | |
| 355 | !Number.isNaN(completedAtMs) && | |
| 356 | completedAtMs >= startedAtMs | |
| 357 | ? completedAtMs - startedAtMs | |
| 358 | : thread.latestTurnDurationMs; | |
| 359 | ||
| 360 | return { | |
| 361 | ...nextBase, | |
| 362 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 363 | latestTurnId: payload.turnId, | |
| 364 | latestTurnCompletedAt: nextSummary.completedAt, | |
| 365 | latestTurnDurationMs, | |
| 366 | turnDiffSummaries, | |
| 367 | updatedAt: event.occurredAt, | |
| 368 | }), | |
| 369 | }; | |
| 370 | }); | |
| 371 | case "thread.reverted": | |
| 372 | return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( | |
| 373 | Effect.map((payload) => { | |
| 374 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 375 | if (!thread) { | |
| 376 | return nextBase; | |
| 377 | } | |
| 378 | ||
| 379 | const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); | |
| 380 | const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); | |
| 381 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => | |
| 382 | left.completedAt.localeCompare(right.completedAt), | |
| 383 | ); | |
| 384 | const inferredTurnCountByTurnId = new Map<string, number>(); | |
| 385 | for (let index = 0; index < sortedSummaries.length; index += 1) { | |
| 386 | const summary = sortedSummaries[index]; | |
| 387 | if (!summary) continue; | |
| 388 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); | |
| 389 | } | |
| 390 | ||
| 391 | const turnDiffSummaries = thread.turnDiffSummaries | |
| 392 | .filter((summary) => { | |
| 393 | const checkpointTurnCount = | |
| 394 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; | |
| 395 | return checkpointTurnCount <= targetTurnCount; | |
| 396 | }) | |
| 397 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 398 | const latestSummary = | |
| 399 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; | |
| 400 | const activities = thread.activities.filter((activity) => { | |
| 401 | if (!activity.turnId) { | |
| 402 | return true; | |
| 403 | } | |
| 404 | const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); | |
| 405 | if (checkpointTurnCount === undefined) { | |
| 406 | return true; | |
| 407 | } | |
| 408 | return checkpointTurnCount <= targetTurnCount; | |
| 409 | }); | |
| 410 | ||
| 411 | return { | |
| 412 | ...nextBase, | |
| 413 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 414 | messages: thread.messages.slice( | |
| 415 | 0, | |
| 416 | Math.min(targetMessageCount, thread.messages.length), | |
| 417 | ), | |
| 418 | turnDiffSummaries, | |
| 419 | latestTurnId: latestSummary?.turnId ?? null, | |
| 420 | latestTurnStartedAt: null, | |
| 421 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | |
| 422 | latestTurnDurationMs: null, | |
| 423 | activities, | |
| 424 | updatedAt: event.occurredAt, | |
| 425 | }), | |
| 426 | }; | |
| 427 | }), | |
| 428 | ); | |
| 429 | case "thread.activity-appended": | |
| 430 | return decodeForEvent( | |
| 431 | ThreadActivityAppendedPayloadSchema, | |
| 432 | event.payload, | |
| 433 | event.type, | |
| 434 | "payload", | |
| 435 | ).pipe( | |
| 436 | Effect.map((payload) => { | |
| 437 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 438 | if (!thread) { | |
| 439 | return nextBase; | |
| 440 | } | |
| 441 | const activities = [ | |
| 442 | ...thread.activities.filter((activity) => activity.id !== payload.activity.id), | |
| 443 | payload.activity, | |
| 444 | ] | |
| 445 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) | |
| 446 | .slice(-500); | |
| 447 | ||
| 448 | return { | |
| 449 | ...nextBase, | |
| 450 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 451 | activities, | |
| 452 | updatedAt: event.occurredAt, | |
| 453 | }), | |
| 454 | }; | |
| 455 | }), | |
| 456 | ); | |
| 457 | case "git.read-model-upsert": | |
| 458 | return decodeForEvent( | |
| 459 | OrchestrationGitReadModelSchema, | |
| 460 | event.payload, | |
| 461 | event.type, | |
| 462 | "payload", | |
| 463 | ).pipe( | |
| 464 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ | |
| 465 | ...nextBase, | |
| 466 | gitByProjectId: { | |
| 467 | ...nextBase.gitByProjectId, | |
| 468 | [gitReadModel.projectId]: gitReadModel, | |
| 469 | }, | |
| 470 | })), | |
| 471 | ); | |
| 472 | default: | |
| 473 | return Effect.succeed(nextBase); | |
| 474 | } | |
| 475 | } |
apps/server/src/orchestration/reducer.test.ts deleted-258| ... | ... | @@ -1,258 +0,0 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { Effect } from "effect"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { createEmptyReadModel, reduceEvent } from "./reducer.ts"; | |
| 6 | ||
| 7 | describe("orchestration reducer", () => { | |
| 8 | it("applies thread.created events using runtime schema decoding", async () => { | |
| 9 | const now = new Date().toISOString(); | |
| 10 | const model = createEmptyReadModel(now); | |
| 11 | const event: OrchestrationEvent = { | |
| 12 | sequence: 1, | |
| 13 | eventId: "event-thread-created", | |
| 14 | type: "thread.created", | |
| 15 | aggregateType: "thread", | |
| 16 | aggregateId: "thread-1", | |
| 17 | occurredAt: now, | |
| 18 | commandId: "cmd-1", | |
| 19 | payload: { | |
| 20 | id: "thread-1", | |
| 21 | projectId: "project-1", | |
| 22 | title: "demo", | |
| 23 | model: "gpt-5-codex", | |
| 24 | branch: null, | |
| 25 | worktreePath: null, | |
| 26 | createdAt: now, | |
| 27 | updatedAt: now, | |
| 28 | }, | |
| 29 | }; | |
| 30 | ||
| 31 | const next = await Effect.runPromise(reduceEvent(model, event)); | |
| 32 | expect(next.sequence).toBe(1); | |
| 33 | expect(next.threads).toEqual([ | |
| 34 | { | |
| 35 | id: "thread-1", | |
| 36 | projectId: "project-1", | |
| 37 | title: "demo", | |
| 38 | model: "gpt-5-codex", | |
| 39 | branch: null, | |
| 40 | worktreePath: null, | |
| 41 | createdAt: now, | |
| 42 | updatedAt: now, | |
| 43 | latestTurnId: null, | |
| 44 | latestTurnStartedAt: null, | |
| 45 | latestTurnCompletedAt: null, | |
| 46 | latestTurnDurationMs: null, | |
| 47 | messages: [], | |
| 48 | session: null, | |
| 49 | activities: [], | |
| 50 | turnDiffSummaries: [], | |
| 51 | error: null, | |
| 52 | }, | |
| 53 | ]); | |
| 54 | }); | |
| 55 | ||
| 56 | it("fails when event payload cannot be decoded by runtime schema", async () => { | |
| 57 | const now = new Date().toISOString(); | |
| 58 | const model = createEmptyReadModel(now); | |
| 59 | const event: OrchestrationEvent = { | |
| 60 | sequence: 1, | |
| 61 | eventId: "event-message-invalid", | |
| 62 | type: "message.sent", | |
| 63 | aggregateType: "thread", | |
| 64 | aggregateId: "thread-1", | |
| 65 | occurredAt: now, | |
| 66 | commandId: "cmd-1", | |
| 67 | payload: { | |
| 68 | id: "message-1", | |
| 69 | threadId: "thread-1", | |
| 70 | text: "hello", | |
| 71 | createdAt: now, | |
| 72 | streaming: false, | |
| 73 | }, | |
| 74 | }; | |
| 75 | ||
| 76 | await expect(Effect.runPromise(reduceEvent(model, event))).rejects.toBeDefined(); | |
| 77 | }); | |
| 78 | ||
| 79 | it("keeps reducer forward-compatible for unknown event types", async () => { | |
| 80 | const now = new Date().toISOString(); | |
| 81 | const model = createEmptyReadModel(now); | |
| 82 | const event: OrchestrationEvent = { | |
| 83 | sequence: 7, | |
| 84 | eventId: "event-unknown", | |
| 85 | type: "custom.event.ignored", | |
| 86 | aggregateType: "unknown", | |
| 87 | aggregateId: "aggregate-1", | |
| 88 | occurredAt: "2026-01-01T00:00:00.000Z", | |
| 89 | commandId: null, | |
| 90 | payload: { test: true }, | |
| 91 | }; | |
| 92 | ||
| 93 | const next = await Effect.runPromise(reduceEvent(model, event)); | |
| 94 | expect(next.sequence).toBe(7); | |
| 95 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); | |
| 96 | expect(next.threads).toEqual([]); | |
| 97 | }); | |
| 98 | ||
| 99 | it("tracks latest turn timing from session lifecycle events", async () => { | |
| 100 | const createdAt = "2026-02-23T08:00:00.000Z"; | |
| 101 | const startedAt = "2026-02-23T08:00:05.000Z"; | |
| 102 | const completedAt = "2026-02-23T08:00:11.250Z"; | |
| 103 | const model = createEmptyReadModel(createdAt); | |
| 104 | ||
| 105 | const createdEvent: OrchestrationEvent = { | |
| 106 | sequence: 1, | |
| 107 | eventId: "thread-created", | |
| 108 | type: "thread.created", | |
| 109 | aggregateType: "thread", | |
| 110 | aggregateId: "thread-1", | |
| 111 | occurredAt: createdAt, | |
| 112 | commandId: "cmd-create", | |
| 113 | payload: { | |
| 114 | id: "thread-1", | |
| 115 | projectId: "project-1", | |
| 116 | title: "demo", | |
| 117 | model: "gpt-5.3-codex", | |
| 118 | branch: null, | |
| 119 | worktreePath: null, | |
| 120 | createdAt, | |
| 121 | updatedAt: createdAt, | |
| 122 | }, | |
| 123 | }; | |
| 124 | ||
| 125 | const runningSessionEvent: OrchestrationEvent = { | |
| 126 | sequence: 2, | |
| 127 | eventId: "session-running", | |
| 128 | type: "thread.session-set", | |
| 129 | aggregateType: "thread", | |
| 130 | aggregateId: "thread-1", | |
| 131 | occurredAt: startedAt, | |
| 132 | commandId: "cmd-running", | |
| 133 | payload: { | |
| 134 | threadId: "thread-1", | |
| 135 | session: { | |
| 136 | sessionId: "session-1", | |
| 137 | status: "running", | |
| 138 | provider: "codex", | |
| 139 | threadId: "thread-1", | |
| 140 | activeTurnId: "turn-1", | |
| 141 | createdAt, | |
| 142 | updatedAt: startedAt, | |
| 143 | lastError: null, | |
| 144 | }, | |
| 145 | }, | |
| 146 | }; | |
| 147 | ||
| 148 | const readySessionEvent: OrchestrationEvent = { | |
| 149 | sequence: 3, | |
| 150 | eventId: "session-ready", | |
| 151 | type: "thread.session-set", | |
| 152 | aggregateType: "thread", | |
| 153 | aggregateId: "thread-1", | |
| 154 | occurredAt: completedAt, | |
| 155 | commandId: "cmd-ready", | |
| 156 | payload: { | |
| 157 | threadId: "thread-1", | |
| 158 | session: { | |
| 159 | sessionId: "session-1", | |
| 160 | status: "ready", | |
| 161 | provider: "codex", | |
| 162 | threadId: "thread-1", | |
| 163 | activeTurnId: null, | |
| 164 | createdAt, | |
| 165 | updatedAt: completedAt, | |
| 166 | lastError: null, | |
| 167 | }, | |
| 168 | }, | |
| 169 | }; | |
| 170 | ||
| 171 | const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent)); | |
| 172 | const afterRunning = await Effect.runPromise(reduceEvent(afterCreate, runningSessionEvent)); | |
| 173 | const afterReady = await Effect.runPromise(reduceEvent(afterRunning, readySessionEvent)); | |
| 174 | const thread = afterReady.threads[0]; | |
| 175 | ||
| 176 | expect(thread?.latestTurnId).toBe("turn-1"); | |
| 177 | expect(thread?.latestTurnStartedAt).toBe(startedAt); | |
| 178 | expect(thread?.latestTurnCompletedAt).toBe(completedAt); | |
| 179 | expect(thread?.latestTurnDurationMs).toBe(6250); | |
| 180 | }); | |
| 181 | ||
| 182 | it("marks assistant messages completed using non-streaming updates", async () => { | |
| 183 | const createdAt = "2026-02-23T09:00:00.000Z"; | |
| 184 | const deltaAt = "2026-02-23T09:00:01.000Z"; | |
| 185 | const completeAt = "2026-02-23T09:00:03.500Z"; | |
| 186 | const model = createEmptyReadModel(createdAt); | |
| 187 | ||
| 188 | const createdEvent: OrchestrationEvent = { | |
| 189 | sequence: 1, | |
| 190 | eventId: "thread-created", | |
| 191 | type: "thread.created", | |
| 192 | aggregateType: "thread", | |
| 193 | aggregateId: "thread-1", | |
| 194 | occurredAt: createdAt, | |
| 195 | commandId: "cmd-create", | |
| 196 | payload: { | |
| 197 | id: "thread-1", | |
| 198 | projectId: "project-1", | |
| 199 | title: "demo", | |
| 200 | model: "gpt-5.3-codex", | |
| 201 | branch: null, | |
| 202 | worktreePath: null, | |
| 203 | createdAt, | |
| 204 | updatedAt: createdAt, | |
| 205 | }, | |
| 206 | }; | |
| 207 | ||
| 208 | const deltaEvent: OrchestrationEvent = { | |
| 209 | sequence: 2, | |
| 210 | eventId: "assistant-delta", | |
| 211 | type: "message.sent", | |
| 212 | aggregateType: "thread", | |
| 213 | aggregateId: "thread-1", | |
| 214 | occurredAt: deltaAt, | |
| 215 | commandId: "cmd-delta", | |
| 216 | payload: { | |
| 217 | id: "assistant:msg-1", | |
| 218 | role: "assistant", | |
| 219 | text: "hello", | |
| 220 | threadId: "thread-1", | |
| 221 | createdAt: deltaAt, | |
| 222 | streaming: true, | |
| 223 | }, | |
| 224 | }; | |
| 225 | ||
| 226 | const completionEvent: OrchestrationEvent = { | |
| 227 | sequence: 3, | |
| 228 | eventId: "assistant-complete", | |
| 229 | type: "message.sent", | |
| 230 | aggregateType: "thread", | |
| 231 | aggregateId: "thread-1", | |
| 232 | occurredAt: completeAt, | |
| 233 | commandId: "cmd-complete", | |
| 234 | payload: { | |
| 235 | id: "assistant:msg-1", | |
| 236 | role: "assistant", | |
| 237 | text: "", | |
| 238 | threadId: "thread-1", | |
| 239 | createdAt: completeAt, | |
| 240 | streaming: false, | |
| 241 | }, | |
| 242 | }; | |
| 243 | ||
| 244 | const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent)); | |
| 245 | const afterDelta = await Effect.runPromise(reduceEvent(afterCreate, deltaEvent)); | |
| 246 | const afterComplete = await Effect.runPromise(reduceEvent(afterDelta, completionEvent)); | |
| 247 | const message = afterComplete.threads[0]?.messages[0]; | |
| 248 | ||
| 249 | expect(message).toEqual({ | |
| 250 | id: "assistant:msg-1", | |
| 251 | role: "assistant", | |
| 252 | text: "hello", | |
| 253 | createdAt: deltaAt, | |
| 254 | completedAt: completeAt, | |
| 255 | streaming: false, | |
| 256 | }); | |
| 257 | }); | |
| 258 | }); |
apps/server/src/orchestration/reducer.ts deleted-475| ... | ... | @@ -1,475 +0,0 @@ |
| 1 | import type { | |
| 2 | OrchestrationEvent, | |
| 3 | OrchestrationGitReadModel, | |
| 4 | OrchestrationMessage, | |
| 5 | OrchestrationReadModel, | |
| 6 | OrchestrationSession, | |
| 7 | OrchestrationThread, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { | |
| 10 | OrchestrationThreadActivitySchema, | |
| 11 | OrchestrationGitReadModelSchema, | |
| 12 | OrchestrationMessageSchema, | |
| 13 | OrchestrationSessionSchema, | |
| 14 | OrchestrationThreadSchema, | |
| 15 | OrchestrationTurnDiffFileSchema, | |
| 16 | OrchestrationTurnDiffSummarySchema, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { Effect, Schema } from "effect"; | |
| 19 | ||
| 20 | import { toReducerDecodeError, type OrchestrationReducerDecodeError } from "./Errors.ts"; | |
| 21 | ||
| 22 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | |
| 23 | ||
| 24 | function updateThread( | |
| 25 | threads: ReadonlyArray<OrchestrationThread>, | |
| 26 | threadId: string, | |
| 27 | patch: ThreadPatch, | |
| 28 | ): OrchestrationThread[] { | |
| 29 | return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); | |
| 30 | } | |
| 31 | ||
| 32 | function decodeForEvent<A>( | |
| 33 | schema: Schema.Schema<A>, | |
| 34 | value: unknown, | |
| 35 | eventType: string, | |
| 36 | field: string, | |
| 37 | ): Effect.Effect<A, OrchestrationReducerDecodeError> { | |
| 38 | return Schema.decodeUnknownEffect(schema)(value).pipe( | |
| 39 | Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)), | |
| 40 | ) as Effect.Effect<A, OrchestrationReducerDecodeError>; | |
| 41 | } | |
| 42 | ||
| 43 | const ThreadCreatedPayloadSchema = Schema.Struct({ | |
| 44 | id: Schema.String, | |
| 45 | projectId: Schema.String, | |
| 46 | title: Schema.String, | |
| 47 | model: Schema.String, | |
| 48 | branch: Schema.NullOr(Schema.String), | |
| 49 | worktreePath: Schema.NullOr(Schema.String), | |
| 50 | createdAt: Schema.String, | |
| 51 | updatedAt: Schema.String, | |
| 52 | }); | |
| 53 | ||
| 54 | const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ | |
| 55 | threadId: Schema.String, | |
| 56 | title: Schema.optional(Schema.String), | |
| 57 | model: Schema.optional(Schema.String), | |
| 58 | branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 59 | worktreePath: Schema.optional(Schema.NullOr(Schema.String)), | |
| 60 | updatedAt: Schema.String, | |
| 61 | }); | |
| 62 | ||
| 63 | const MessageSentPayloadSchema = Schema.Struct({ | |
| 64 | id: Schema.String, | |
| 65 | role: Schema.Literals(["user", "assistant"]), | |
| 66 | text: Schema.String, | |
| 67 | threadId: Schema.String, | |
| 68 | createdAt: Schema.String, | |
| 69 | streaming: Schema.Boolean, | |
| 70 | }); | |
| 71 | ||
| 72 | const ThreadSessionSetPayloadSchema = Schema.Struct({ | |
| 73 | threadId: Schema.String, | |
| 74 | session: OrchestrationSessionSchema, | |
| 75 | }); | |
| 76 | ||
| 77 | const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ | |
| 78 | threadId: Schema.String, | |
| 79 | turnId: Schema.String, | |
| 80 | completedAt: Schema.String, | |
| 81 | status: Schema.optional(Schema.String), | |
| 82 | files: Schema.Array(OrchestrationTurnDiffFileSchema), | |
| 83 | assistantMessageId: Schema.optional(Schema.String), | |
| 84 | checkpointTurnCount: Schema.optional(Schema.Number), | |
| 85 | }); | |
| 86 | ||
| 87 | const ThreadRevertedPayloadSchema = Schema.Struct({ | |
| 88 | threadId: Schema.String, | |
| 89 | turnCount: Schema.Number, | |
| 90 | messageCount: Schema.Number, | |
| 91 | }); | |
| 92 | ||
| 93 | const ThreadActivityAppendedPayloadSchema = Schema.Struct({ | |
| 94 | threadId: Schema.String, | |
| 95 | activity: OrchestrationThreadActivitySchema, | |
| 96 | }); | |
| 97 | ||
| 98 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | |
| 99 | return { | |
| 100 | sequence: 0, | |
| 101 | threads: [], | |
| 102 | gitByProjectId: {}, | |
| 103 | updatedAt: nowIso, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | export function reduceEvent( | |
| 108 | model: OrchestrationReadModel, | |
| 109 | event: OrchestrationEvent, | |
| 110 | ): Effect.Effect<OrchestrationReadModel, OrchestrationReducerDecodeError> { | |
| 111 | const nextBase: OrchestrationReadModel = { | |
| 112 | ...model, | |
| 113 | sequence: event.sequence, | |
| 114 | updatedAt: event.occurredAt, | |
| 115 | }; | |
| 116 | ||
| 117 | switch (event.type) { | |
| 118 | case "thread.created": | |
| 119 | return Effect.gen(function* () { | |
| 120 | const payload = yield* decodeForEvent( | |
| 121 | ThreadCreatedPayloadSchema, | |
| 122 | event.payload, | |
| 123 | event.type, | |
| 124 | "payload", | |
| 125 | ); | |
| 126 | const thread: OrchestrationThread = yield* decodeForEvent( | |
| 127 | OrchestrationThreadSchema, | |
| 128 | { | |
| 129 | id: payload.id, | |
| 130 | projectId: payload.projectId, | |
| 131 | title: payload.title, | |
| 132 | model: payload.model, | |
| 133 | branch: payload.branch, | |
| 134 | worktreePath: payload.worktreePath, | |
| 135 | createdAt: payload.createdAt, | |
| 136 | updatedAt: payload.updatedAt, | |
| 137 | latestTurnId: null, | |
| 138 | latestTurnStartedAt: null, | |
| 139 | latestTurnCompletedAt: null, | |
| 140 | latestTurnDurationMs: null, | |
| 141 | messages: [], | |
| 142 | session: null, | |
| 143 | turnDiffSummaries: [], | |
| 144 | activities: [], | |
| 145 | error: null, | |
| 146 | }, | |
| 147 | event.type, | |
| 148 | "thread", | |
| 149 | ); | |
| 150 | const existing = nextBase.threads.find((entry) => entry.id === thread.id); | |
| 151 | return { | |
| 152 | ...nextBase, | |
| 153 | threads: existing | |
| 154 | ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) | |
| 155 | : [...nextBase.threads, thread], | |
| 156 | }; | |
| 157 | }); | |
| 158 | case "thread.deleted": | |
| 159 | return Effect.succeed({ | |
| 160 | ...nextBase, | |
| 161 | threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), | |
| 162 | }); | |
| 163 | case "thread.meta-updated": | |
| 164 | return decodeForEvent( | |
| 165 | ThreadMetaUpdatedPayloadSchema, | |
| 166 | event.payload, | |
| 167 | event.type, | |
| 168 | "payload", | |
| 169 | ).pipe( | |
| 170 | Effect.map((payload) => { | |
| 171 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 172 | if (!thread) { | |
| 173 | return nextBase; | |
| 174 | } | |
| 175 | return { | |
| 176 | ...nextBase, | |
| 177 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 178 | ...(payload.title !== undefined ? { title: payload.title } : {}), | |
| 179 | ...(payload.model !== undefined ? { model: payload.model } : {}), | |
| 180 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | |
| 181 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | |
| 182 | updatedAt: payload.updatedAt, | |
| 183 | }), | |
| 184 | }; | |
| 185 | }), | |
| 186 | ); | |
| 187 | case "message.sent": | |
| 188 | return Effect.gen(function* () { | |
| 189 | const payload = yield* decodeForEvent( | |
| 190 | MessageSentPayloadSchema, | |
| 191 | event.payload, | |
| 192 | event.type, | |
| 193 | "payload", | |
| 194 | ); | |
| 195 | const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); | |
| 196 | if (!targetThread) { | |
| 197 | return nextBase; | |
| 198 | } | |
| 199 | ||
| 200 | const message: OrchestrationMessage = yield* decodeForEvent( | |
| 201 | OrchestrationMessageSchema, | |
| 202 | { | |
| 203 | id: payload.id, | |
| 204 | role: payload.role, | |
| 205 | text: payload.text, | |
| 206 | createdAt: payload.createdAt, | |
| 207 | ...(payload.streaming ? {} : { completedAt: event.occurredAt }), | |
| 208 | streaming: payload.streaming, | |
| 209 | }, | |
| 210 | event.type, | |
| 211 | "message", | |
| 212 | ); | |
| 213 | const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); | |
| 214 | const nextMessages = existingMessage | |
| 215 | ? targetThread.messages.map((entry) => | |
| 216 | entry.id === message.id | |
| 217 | ? { | |
| 218 | ...entry, | |
| 219 | text: message.streaming | |
| 220 | ? `${entry.text}${message.text}` | |
| 221 | : message.text.length > 0 | |
| 222 | ? message.text | |
| 223 | : entry.text, | |
| 224 | streaming: message.streaming, | |
| 225 | createdAt: entry.createdAt, | |
| 226 | ...(message.streaming | |
| 227 | ? { completedAt: undefined } | |
| 228 | : { completedAt: message.completedAt ?? event.occurredAt }), | |
| 229 | } | |
| 230 | : entry, | |
| 231 | ) | |
| 232 | : !message.streaming && message.text.length === 0 | |
| 233 | ? targetThread.messages | |
| 234 | : [...targetThread.messages, message]; | |
| 235 | ||
| 236 | return { | |
| 237 | ...nextBase, | |
| 238 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 239 | messages: nextMessages, | |
| 240 | updatedAt: event.occurredAt, | |
| 241 | }), | |
| 242 | }; | |
| 243 | }); | |
| 244 | case "thread.session-set": | |
| 245 | return Effect.gen(function* () { | |
| 246 | const payload = yield* decodeForEvent( | |
| 247 | ThreadSessionSetPayloadSchema, | |
| 248 | event.payload, | |
| 249 | event.type, | |
| 250 | "payload", | |
| 251 | ); | |
| 252 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 253 | if (!thread) { | |
| 254 | return nextBase; | |
| 255 | } | |
| 256 | ||
| 257 | const session: OrchestrationSession = yield* decodeForEvent( | |
| 258 | OrchestrationSessionSchema, | |
| 259 | { | |
| 260 | ...payload.session, | |
| 261 | threadId: payload.threadId, | |
| 262 | }, | |
| 263 | event.type, | |
| 264 | "session", | |
| 265 | ); | |
| 266 | ||
| 267 | const sessionUpdatedAt = session.updatedAt; | |
| 268 | const turnTimingPatch: ThreadPatch = | |
| 269 | session.status === "running" && session.activeTurnId | |
| 270 | ? (() => { | |
| 271 | const isSameTurn = thread.latestTurnId === session.activeTurnId; | |
| 272 | return { | |
| 273 | latestTurnId: session.activeTurnId, | |
| 274 | latestTurnStartedAt: | |
| 275 | isSameTurn && thread.latestTurnStartedAt | |
| 276 | ? thread.latestTurnStartedAt | |
| 277 | : sessionUpdatedAt, | |
| 278 | ...(isSameTurn | |
| 279 | ? {} | |
| 280 | : { | |
| 281 | latestTurnCompletedAt: null, | |
| 282 | latestTurnDurationMs: null, | |
| 283 | }), | |
| 284 | }; | |
| 285 | })() | |
| 286 | : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt | |
| 287 | ? (() => { | |
| 288 | const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt; | |
| 289 | const startedAtMs = Date.parse(thread.latestTurnStartedAt); | |
| 290 | const completedAtMs = Date.parse(latestTurnCompletedAt); | |
| 291 | const latestTurnDurationMs = | |
| 292 | !Number.isNaN(startedAtMs) && | |
| 293 | !Number.isNaN(completedAtMs) && | |
| 294 | completedAtMs >= startedAtMs | |
| 295 | ? completedAtMs - startedAtMs | |
| 296 | : thread.latestTurnDurationMs; | |
| 297 | ||
| 298 | return { | |
| 299 | latestTurnCompletedAt, | |
| 300 | latestTurnDurationMs, | |
| 301 | }; | |
| 302 | })() | |
| 303 | : {}; | |
| 304 | ||
| 305 | return { | |
| 306 | ...nextBase, | |
| 307 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 308 | session, | |
| 309 | updatedAt: event.occurredAt, | |
| 310 | error: session.lastError, | |
| 311 | ...turnTimingPatch, | |
| 312 | }), | |
| 313 | }; | |
| 314 | }); | |
| 315 | case "thread.turn-diff-completed": | |
| 316 | return Effect.gen(function* () { | |
| 317 | const payload = yield* decodeForEvent( | |
| 318 | ThreadTurnDiffCompletedPayloadSchema, | |
| 319 | event.payload, | |
| 320 | event.type, | |
| 321 | "payload", | |
| 322 | ); | |
| 323 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 324 | if (!thread) { | |
| 325 | return nextBase; | |
| 326 | } | |
| 327 | ||
| 328 | const nextSummary = yield* decodeForEvent( | |
| 329 | OrchestrationTurnDiffSummarySchema, | |
| 330 | { | |
| 331 | turnId: payload.turnId, | |
| 332 | completedAt: payload.completedAt, | |
| 333 | ...(payload.status !== undefined ? { status: payload.status } : {}), | |
| 334 | files: payload.files, | |
| 335 | ...(payload.assistantMessageId !== undefined | |
| 336 | ? { assistantMessageId: payload.assistantMessageId } | |
| 337 | : {}), | |
| 338 | ...(payload.checkpointTurnCount !== undefined | |
| 339 | ? { checkpointTurnCount: payload.checkpointTurnCount } | |
| 340 | : {}), | |
| 341 | }, | |
| 342 | event.type, | |
| 343 | "turnDiffSummary", | |
| 344 | ); | |
| 345 | const turnDiffSummaries = [ | |
| 346 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), | |
| 347 | nextSummary, | |
| 348 | ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 349 | const startedAtMs = thread.latestTurnStartedAt | |
| 350 | ? Date.parse(thread.latestTurnStartedAt) | |
| 351 | : Number.NaN; | |
| 352 | const completedAtMs = Date.parse(nextSummary.completedAt); | |
| 353 | const latestTurnDurationMs = | |
| 354 | !Number.isNaN(startedAtMs) && | |
| 355 | !Number.isNaN(completedAtMs) && | |
| 356 | completedAtMs >= startedAtMs | |
| 357 | ? completedAtMs - startedAtMs | |
| 358 | : thread.latestTurnDurationMs; | |
| 359 | ||
| 360 | return { | |
| 361 | ...nextBase, | |
| 362 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 363 | latestTurnId: payload.turnId, | |
| 364 | latestTurnCompletedAt: nextSummary.completedAt, | |
| 365 | latestTurnDurationMs, | |
| 366 | turnDiffSummaries, | |
| 367 | updatedAt: event.occurredAt, | |
| 368 | }), | |
| 369 | }; | |
| 370 | }); | |
| 371 | case "thread.reverted": | |
| 372 | return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( | |
| 373 | Effect.map((payload) => { | |
| 374 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 375 | if (!thread) { | |
| 376 | return nextBase; | |
| 377 | } | |
| 378 | ||
| 379 | const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); | |
| 380 | const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); | |
| 381 | const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => | |
| 382 | left.completedAt.localeCompare(right.completedAt), | |
| 383 | ); | |
| 384 | const inferredTurnCountByTurnId = new Map<string, number>(); | |
| 385 | for (let index = 0; index < sortedSummaries.length; index += 1) { | |
| 386 | const summary = sortedSummaries[index]; | |
| 387 | if (!summary) continue; | |
| 388 | inferredTurnCountByTurnId.set(summary.turnId, index + 1); | |
| 389 | } | |
| 390 | ||
| 391 | const turnDiffSummaries = thread.turnDiffSummaries | |
| 392 | .filter((summary) => { | |
| 393 | const checkpointTurnCount = | |
| 394 | summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; | |
| 395 | return checkpointTurnCount <= targetTurnCount; | |
| 396 | }) | |
| 397 | .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); | |
| 398 | const latestSummary = | |
| 399 | turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; | |
| 400 | const activities = thread.activities.filter((activity) => { | |
| 401 | if (!activity.turnId) { | |
| 402 | return true; | |
| 403 | } | |
| 404 | const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); | |
| 405 | if (checkpointTurnCount === undefined) { | |
| 406 | return true; | |
| 407 | } | |
| 408 | return checkpointTurnCount <= targetTurnCount; | |
| 409 | }); | |
| 410 | ||
| 411 | return { | |
| 412 | ...nextBase, | |
| 413 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 414 | messages: thread.messages.slice( | |
| 415 | 0, | |
| 416 | Math.min(targetMessageCount, thread.messages.length), | |
| 417 | ), | |
| 418 | turnDiffSummaries, | |
| 419 | latestTurnId: latestSummary?.turnId ?? null, | |
| 420 | latestTurnStartedAt: null, | |
| 421 | latestTurnCompletedAt: latestSummary?.completedAt ?? null, | |
| 422 | latestTurnDurationMs: null, | |
| 423 | activities, | |
| 424 | updatedAt: event.occurredAt, | |
| 425 | }), | |
| 426 | }; | |
| 427 | }), | |
| 428 | ); | |
| 429 | case "thread.activity-appended": | |
| 430 | return decodeForEvent( | |
| 431 | ThreadActivityAppendedPayloadSchema, | |
| 432 | event.payload, | |
| 433 | event.type, | |
| 434 | "payload", | |
| 435 | ).pipe( | |
| 436 | Effect.map((payload) => { | |
| 437 | const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); | |
| 438 | if (!thread) { | |
| 439 | return nextBase; | |
| 440 | } | |
| 441 | const activities = [ | |
| 442 | ...thread.activities.filter((activity) => activity.id !== payload.activity.id), | |
| 443 | payload.activity, | |
| 444 | ] | |
| 445 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) | |
| 446 | .slice(-500); | |
| 447 | ||
| 448 | return { | |
| 449 | ...nextBase, | |
| 450 | threads: updateThread(nextBase.threads, payload.threadId, { | |
| 451 | activities, | |
| 452 | updatedAt: event.occurredAt, | |
| 453 | }), | |
| 454 | }; | |
| 455 | }), | |
| 456 | ); | |
| 457 | case "git.read-model-upsert": | |
| 458 | return decodeForEvent( | |
| 459 | OrchestrationGitReadModelSchema, | |
| 460 | event.payload, | |
| 461 | event.type, | |
| 462 | "payload", | |
| 463 | ).pipe( | |
| 464 | Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ | |
| 465 | ...nextBase, | |
| 466 | gitByProjectId: { | |
| 467 | ...nextBase.gitByProjectId, | |
| 468 | [gitReadModel.projectId]: gitReadModel, | |
| 469 | }, | |
| 470 | })), | |
| 471 | ); | |
| 472 | default: | |
| 473 | return Effect.succeed(nextBase); | |
| 474 | } | |
| 475 | } |
apps/server/src/persistence/Errors.ts+1-1| ... | ... | @@ -158,7 +158,7 @@ export type ProjectRepositoryError = |
| 158 | 158 | | ProjectPathMissingError |
| 159 | 159 | | ProjectNotFoundError; |
| 160 | 160 | |
| 161 | export type OrchestrationEventRepositoryError = | |
| 161 | export type OrchestrationEventStoreError = | |
| 162 | 162 | | PersistenceSqlError |
| 163 | 163 | | PersistenceDecodeError |
| 164 | 164 | | PersistenceSerializationError; |
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts created+39| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | import { Effect, Layer, Stream } from "effect"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | ||
| 4 | import { OrchestrationEventStore } from "../Services/OrchestrationEventStore.ts"; | |
| 5 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 6 | import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; | |
| 7 | import { NodeServices } from "@effect/platform-node"; | |
| 8 | ||
| 9 | const layer = it.layer( | |
| 10 | Layer.mergeAll( | |
| 11 | Layer.provide(OrchestrationEventStoreLive, SqlitePersistenceMemory), | |
| 12 | NodeServices.layer, | |
| 13 | ), | |
| 14 | ); | |
| 15 | ||
| 16 | layer("OrchestrationEventStore", (it) => { | |
| 17 | it.effect("stores and replays events", () => | |
| 18 | Effect.gen(function* () { | |
| 19 | const createdAt = new Date().toISOString(); | |
| 20 | ||
| 21 | const eventStore = yield* OrchestrationEventStore; | |
| 22 | const saved = yield* eventStore.append({ | |
| 23 | eventId: "event-1", | |
| 24 | type: "thread.created", | |
| 25 | aggregateType: "thread", | |
| 26 | aggregateId: "thread-1", | |
| 27 | occurredAt: createdAt, | |
| 28 | commandId: "cmd-1", | |
| 29 | payload: { id: "thread-1", projectId: "project-1", title: "demo" }, | |
| 30 | }); | |
| 31 | assert.equal(saved.sequence, 1); | |
| 32 | ||
| 33 | const replayed = yield* Stream.runCollect(eventStore.readFromSequence(0)).pipe( | |
| 34 | Effect.map(Array.from), | |
| 35 | ); | |
| 36 | assert.deepEqual(replayed, [saved]); | |
| 37 | }), | |
| 38 | ); | |
| 39 | }); |
apps/server/src/persistence/Layers/OrchestrationEventStore.ts created+210| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 5 | import { Effect, Layer, Schema, Stream } from "effect"; | |
| 6 | ||
| 7 | import { | |
| 8 | toPersistenceDecodeCauseError, | |
| 9 | toPersistenceDecodeError, | |
| 10 | toPersistenceSerializationError, | |
| 11 | toPersistenceSqlError, | |
| 12 | type OrchestrationEventStoreError, | |
| 13 | } from "../Errors.ts"; | |
| 14 | import { | |
| 15 | OrchestrationEventStore, | |
| 16 | type OrchestrationEventStoreShape, | |
| 17 | } from "../Services/OrchestrationEventStore.ts"; | |
| 18 | ||
| 19 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema); | |
| 20 | ||
| 21 | const EventRowSchema = Schema.Struct({ | |
| 22 | sequence: Schema.Number, | |
| 23 | eventId: Schema.String, | |
| 24 | type: Schema.String, | |
| 25 | aggregateType: Schema.String, | |
| 26 | aggregateId: Schema.String, | |
| 27 | occurredAt: Schema.String, | |
| 28 | commandId: Schema.NullOr(Schema.String), | |
| 29 | payloadJson: Schema.String, | |
| 30 | }); | |
| 31 | ||
| 32 | const AppendEventRequestSchema = Schema.Struct({ | |
| 33 | eventId: Schema.String, | |
| 34 | type: Schema.String, | |
| 35 | aggregateType: Schema.String, | |
| 36 | aggregateId: Schema.String, | |
| 37 | occurredAt: Schema.String, | |
| 38 | commandId: Schema.NullOr(Schema.String), | |
| 39 | payloadJson: Schema.String, | |
| 40 | }); | |
| 41 | ||
| 42 | const ReadFromSequenceRequestSchema = Schema.Struct({ | |
| 43 | sequenceExclusive: Schema.Number, | |
| 44 | limit: Schema.Number, | |
| 45 | }); | |
| 46 | const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000; | |
| 47 | const READ_PAGE_SIZE = 500; | |
| 48 | ||
| 49 | function eventRowToOrchestrationEvent( | |
| 50 | row: Schema.Schema.Type<typeof EventRowSchema>, | |
| 51 | operation: string, | |
| 52 | ): Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError> { | |
| 53 | return Effect.try({ | |
| 54 | try: () => JSON.parse(row.payloadJson) as unknown, | |
| 55 | catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), | |
| 56 | }).pipe( | |
| 57 | Effect.flatMap((payload) => | |
| 58 | decodeEvent({ | |
| 59 | sequence: row.sequence, | |
| 60 | eventId: row.eventId, | |
| 61 | type: row.type, | |
| 62 | aggregateType: row.aggregateType, | |
| 63 | aggregateId: row.aggregateId, | |
| 64 | occurredAt: row.occurredAt, | |
| 65 | commandId: row.commandId, | |
| 66 | payload, | |
| 67 | }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), | |
| 68 | ), | |
| 69 | ); | |
| 70 | } | |
| 71 | ||
| 72 | const makeEventStore = Effect.gen(function* () { | |
| 73 | const sql = yield* SqlClient.SqlClient; | |
| 74 | ||
| 75 | const appendEventRow = SqlSchema.findOne({ | |
| 76 | Request: AppendEventRequestSchema, | |
| 77 | Result: EventRowSchema, | |
| 78 | execute: (request) => | |
| 79 | sql` | |
| 80 | INSERT INTO orchestration_events ( | |
| 81 | event_id, | |
| 82 | event_type, | |
| 83 | aggregate_type, | |
| 84 | aggregate_id, | |
| 85 | occurred_at, | |
| 86 | command_id, | |
| 87 | payload_json | |
| 88 | ) | |
| 89 | VALUES ( | |
| 90 | ${request.eventId}, | |
| 91 | ${request.type}, | |
| 92 | ${request.aggregateType}, | |
| 93 | ${request.aggregateId}, | |
| 94 | ${request.occurredAt}, | |
| 95 | ${request.commandId}, | |
| 96 | ${request.payloadJson} | |
| 97 | ) | |
| 98 | RETURNING | |
| 99 | sequence, | |
| 100 | event_id AS "eventId", | |
| 101 | event_type AS "type", | |
| 102 | aggregate_type AS "aggregateType", | |
| 103 | aggregate_id AS "aggregateId", | |
| 104 | occurred_at AS "occurredAt", | |
| 105 | command_id AS "commandId", | |
| 106 | payload_json AS "payloadJson" | |
| 107 | `, | |
| 108 | }); | |
| 109 | ||
| 110 | const readEventRowsFromSequence = SqlSchema.findAll({ | |
| 111 | Request: ReadFromSequenceRequestSchema, | |
| 112 | Result: EventRowSchema, | |
| 113 | execute: (request) => | |
| 114 | sql` | |
| 115 | SELECT | |
| 116 | sequence, | |
| 117 | event_id AS "eventId", | |
| 118 | event_type AS "type", | |
| 119 | aggregate_type AS "aggregateType", | |
| 120 | aggregate_id AS "aggregateId", | |
| 121 | occurred_at AS "occurredAt", | |
| 122 | command_id AS "commandId", | |
| 123 | payload_json AS "payloadJson" | |
| 124 | FROM orchestration_events | |
| 125 | WHERE sequence > ${request.sequenceExclusive} | |
| 126 | ORDER BY sequence ASC | |
| 127 | LIMIT ${request.limit} | |
| 128 | `, | |
| 129 | }); | |
| 130 | ||
| 131 | const append: OrchestrationEventStoreShape["append"] = (event) => | |
| 132 | Effect.try({ | |
| 133 | try: () => JSON.stringify(event.payload), | |
| 134 | catch: toPersistenceSerializationError("OrchestrationEventStore.append:encodePayload"), | |
| 135 | }).pipe( | |
| 136 | Effect.flatMap((payloadJson) => | |
| 137 | appendEventRow({ | |
| 138 | eventId: event.eventId, | |
| 139 | type: event.type, | |
| 140 | aggregateType: event.aggregateType, | |
| 141 | aggregateId: event.aggregateId, | |
| 142 | occurredAt: event.occurredAt, | |
| 143 | commandId: event.commandId, | |
| 144 | payloadJson, | |
| 145 | }).pipe( | |
| 146 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.append:insert")), | |
| 147 | Effect.flatMap((row) => | |
| 148 | eventRowToOrchestrationEvent(row, "OrchestrationEventStore.append:rowToEvent"), | |
| 149 | ), | |
| 150 | ), | |
| 151 | ), | |
| 152 | ); | |
| 153 | ||
| 154 | const readFromSequence: OrchestrationEventStoreShape["readFromSequence"] = ( | |
| 155 | sequenceExclusive, | |
| 156 | limit = DEFAULT_READ_FROM_SEQUENCE_LIMIT, | |
| 157 | ) => { | |
| 158 | const normalizedLimit = Math.max(0, Math.floor(limit)); | |
| 159 | if (normalizedLimit === 0) { | |
| 160 | return Stream.empty; | |
| 161 | } | |
| 162 | const readPage = (cursor: number, remaining: number): Stream.Stream< | |
| 163 | OrchestrationEvent, | |
| 164 | OrchestrationEventStoreError | |
| 165 | > => | |
| 166 | Stream.fromEffect( | |
| 167 | readEventRowsFromSequence({ | |
| 168 | sequenceExclusive: cursor, | |
| 169 | limit: Math.min(remaining, READ_PAGE_SIZE), | |
| 170 | }).pipe( | |
| 171 | Effect.mapError(toPersistenceSqlError("OrchestrationEventStore.readFromSequence:query")), | |
| 172 | Effect.flatMap((rows) => | |
| 173 | Effect.forEach(rows, (row) => | |
| 174 | eventRowToOrchestrationEvent( | |
| 175 | row, | |
| 176 | "OrchestrationEventStore.readFromSequence:rowToEvent", | |
| 177 | ), | |
| 178 | ), | |
| 179 | ), | |
| 180 | ), | |
| 181 | ).pipe( | |
| 182 | Stream.flatMap((events) => { | |
| 183 | if (events.length === 0) { | |
| 184 | return Stream.empty; | |
| 185 | } | |
| 186 | const nextRemaining = remaining - events.length; | |
| 187 | if (nextRemaining <= 0) { | |
| 188 | return Stream.fromIterable(events); | |
| 189 | } | |
| 190 | return Stream.concat( | |
| 191 | Stream.fromIterable(events), | |
| 192 | readPage(events[events.length - 1]!.sequence, nextRemaining), | |
| 193 | ); | |
| 194 | }), | |
| 195 | ); | |
| 196 | ||
| 197 | return readPage(sequenceExclusive, normalizedLimit); | |
| 198 | }; | |
| 199 | ||
| 200 | return { | |
| 201 | append, | |
| 202 | readFromSequence, | |
| 203 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), | |
| 204 | } satisfies OrchestrationEventStoreShape; | |
| 205 | }); | |
| 206 | ||
| 207 | export const OrchestrationEventStoreLive = Layer.effect( | |
| 208 | OrchestrationEventStore, | |
| 209 | makeEventStore, | |
| 210 | ); |
apps/server/src/persistence/Layers/OrchestrationEvents.test.ts deleted-37| ... | ... | @@ -1,37 +0,0 @@ |
| 1 | import { Effect, Layer } from "effect"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | ||
| 4 | import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts"; | |
| 5 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | |
| 6 | import { OrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; | |
| 7 | import { NodeServices } from "@effect/platform-node"; | |
| 8 | ||
| 9 | const layer = it.layer( | |
| 10 | Layer.mergeAll( | |
| 11 | Layer.provide(OrchestrationEventRepositoryLive, SqlitePersistenceMemory), | |
| 12 | NodeServices.layer, | |
| 13 | ), | |
| 14 | ); | |
| 15 | ||
| 16 | layer("OrchestrationEventRepository", (it) => { | |
| 17 | it.effect("stores and replays events", () => | |
| 18 | Effect.gen(function* () { | |
| 19 | const createdAt = new Date().toISOString(); | |
| 20 | ||
| 21 | const repository = yield* OrchestrationEventRepository; | |
| 22 | const saved = yield* repository.append({ | |
| 23 | eventId: "event-1", | |
| 24 | type: "thread.created", | |
| 25 | aggregateType: "thread", | |
| 26 | aggregateId: "thread-1", | |
| 27 | occurredAt: createdAt, | |
| 28 | commandId: "cmd-1", | |
| 29 | payload: { id: "thread-1", projectId: "project-1", title: "demo" }, | |
| 30 | }); | |
| 31 | assert.equal(saved.sequence, 1); | |
| 32 | ||
| 33 | const replayed = yield* repository.readFromSequence(0); | |
| 34 | assert.deepEqual(replayed, [saved]); | |
| 35 | }), | |
| 36 | ); | |
| 37 | }); |
apps/server/src/persistence/Layers/OrchestrationEvents.ts deleted-178| ... | ... | @@ -1,178 +0,0 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | import { OrchestrationEventSchema } from "@t3tools/contracts"; | |
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 4 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 5 | import { Effect, Layer, Schema } from "effect"; | |
| 6 | ||
| 7 | import { | |
| 8 | toPersistenceDecodeCauseError, | |
| 9 | toPersistenceDecodeError, | |
| 10 | toPersistenceSerializationError, | |
| 11 | toPersistenceSqlError, | |
| 12 | type OrchestrationEventRepositoryError, | |
| 13 | } from "../Errors.ts"; | |
| 14 | import { | |
| 15 | OrchestrationEventRepository, | |
| 16 | type OrchestrationEventRepositoryShape, | |
| 17 | } from "../Services/OrchestrationEvents.ts"; | |
| 18 | ||
| 19 | const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema); | |
| 20 | ||
| 21 | const EventRowSchema = Schema.Struct({ | |
| 22 | sequence: Schema.Number, | |
| 23 | eventId: Schema.String, | |
| 24 | type: Schema.String, | |
| 25 | aggregateType: Schema.String, | |
| 26 | aggregateId: Schema.String, | |
| 27 | occurredAt: Schema.String, | |
| 28 | commandId: Schema.NullOr(Schema.String), | |
| 29 | payloadJson: Schema.String, | |
| 30 | }); | |
| 31 | ||
| 32 | const AppendEventRequestSchema = Schema.Struct({ | |
| 33 | eventId: Schema.String, | |
| 34 | type: Schema.String, | |
| 35 | aggregateType: Schema.String, | |
| 36 | aggregateId: Schema.String, | |
| 37 | occurredAt: Schema.String, | |
| 38 | commandId: Schema.NullOr(Schema.String), | |
| 39 | payloadJson: Schema.String, | |
| 40 | }); | |
| 41 | ||
| 42 | const ReadFromSequenceRequestSchema = Schema.Struct({ | |
| 43 | sequenceExclusive: Schema.Number, | |
| 44 | limit: Schema.Number, | |
| 45 | }); | |
| 46 | ||
| 47 | function eventRowToOrchestrationEvent( | |
| 48 | row: Schema.Schema.Type<typeof EventRowSchema>, | |
| 49 | operation: string, | |
| 50 | ): Effect.Effect<OrchestrationEvent, OrchestrationEventRepositoryError> { | |
| 51 | return Effect.try({ | |
| 52 | try: () => JSON.parse(row.payloadJson) as unknown, | |
| 53 | catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), | |
| 54 | }).pipe( | |
| 55 | Effect.flatMap((payload) => | |
| 56 | decodeEvent({ | |
| 57 | sequence: row.sequence, | |
| 58 | eventId: row.eventId, | |
| 59 | type: row.type, | |
| 60 | aggregateType: row.aggregateType, | |
| 61 | aggregateId: row.aggregateId, | |
| 62 | occurredAt: row.occurredAt, | |
| 63 | commandId: row.commandId, | |
| 64 | payload, | |
| 65 | }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), | |
| 66 | ), | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | const makeRepository = Effect.gen(function* () { | |
| 71 | const sql = yield* SqlClient.SqlClient; | |
| 72 | ||
| 73 | const appendEventRow = SqlSchema.findOne({ | |
| 74 | Request: AppendEventRequestSchema, | |
| 75 | Result: EventRowSchema, | |
| 76 | execute: (request) => | |
| 77 | sql` | |
| 78 | INSERT INTO orchestration_events ( | |
| 79 | event_id, | |
| 80 | event_type, | |
| 81 | aggregate_type, | |
| 82 | aggregate_id, | |
| 83 | occurred_at, | |
| 84 | command_id, | |
| 85 | payload_json | |
| 86 | ) | |
| 87 | VALUES ( | |
| 88 | ${request.eventId}, | |
| 89 | ${request.type}, | |
| 90 | ${request.aggregateType}, | |
| 91 | ${request.aggregateId}, | |
| 92 | ${request.occurredAt}, | |
| 93 | ${request.commandId}, | |
| 94 | ${request.payloadJson} | |
| 95 | ) | |
| 96 | RETURNING | |
| 97 | sequence, | |
| 98 | event_id AS "eventId", | |
| 99 | event_type AS "type", | |
| 100 | aggregate_type AS "aggregateType", | |
| 101 | aggregate_id AS "aggregateId", | |
| 102 | occurred_at AS "occurredAt", | |
| 103 | command_id AS "commandId", | |
| 104 | payload_json AS "payloadJson" | |
| 105 | `, | |
| 106 | }); | |
| 107 | ||
| 108 | const readEventRowsFromSequence = SqlSchema.findAll({ | |
| 109 | Request: ReadFromSequenceRequestSchema, | |
| 110 | Result: EventRowSchema, | |
| 111 | execute: (request) => | |
| 112 | sql` | |
| 113 | SELECT | |
| 114 | sequence, | |
| 115 | event_id AS "eventId", | |
| 116 | event_type AS "type", | |
| 117 | aggregate_type AS "aggregateType", | |
| 118 | aggregate_id AS "aggregateId", | |
| 119 | occurred_at AS "occurredAt", | |
| 120 | command_id AS "commandId", | |
| 121 | payload_json AS "payloadJson" | |
| 122 | FROM orchestration_events | |
| 123 | WHERE sequence > ${request.sequenceExclusive} | |
| 124 | ORDER BY sequence ASC | |
| 125 | LIMIT ${request.limit} | |
| 126 | `, | |
| 127 | }); | |
| 128 | ||
| 129 | const append: OrchestrationEventRepositoryShape["append"] = (event) => | |
| 130 | Effect.try({ | |
| 131 | try: () => JSON.stringify(event.payload), | |
| 132 | catch: toPersistenceSerializationError("OrchestrationEventRepository.append:encodePayload"), | |
| 133 | }).pipe( | |
| 134 | Effect.flatMap((payloadJson) => | |
| 135 | appendEventRow({ | |
| 136 | eventId: event.eventId, | |
| 137 | type: event.type, | |
| 138 | aggregateType: event.aggregateType, | |
| 139 | aggregateId: event.aggregateId, | |
| 140 | occurredAt: event.occurredAt, | |
| 141 | commandId: event.commandId, | |
| 142 | payloadJson, | |
| 143 | }).pipe( | |
| 144 | Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.append:insert")), | |
| 145 | Effect.flatMap((row) => | |
| 146 | eventRowToOrchestrationEvent(row, "OrchestrationEventRepository.append:rowToEvent"), | |
| 147 | ), | |
| 148 | ), | |
| 149 | ), | |
| 150 | ); | |
| 151 | ||
| 152 | const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( | |
| 153 | sequenceExclusive, | |
| 154 | limit = 1_000, | |
| 155 | ) => | |
| 156 | readEventRowsFromSequence({ sequenceExclusive, limit }).pipe( | |
| 157 | Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.readFromSequence:query")), | |
| 158 | Effect.flatMap((rows) => | |
| 159 | Effect.forEach(rows, (row) => | |
| 160 | eventRowToOrchestrationEvent( | |
| 161 | row, | |
| 162 | "OrchestrationEventRepository.readFromSequence:rowToEvent", | |
| 163 | ), | |
| 164 | ), | |
| 165 | ), | |
| 166 | ); | |
| 167 | ||
| 168 | return { | |
| 169 | append, | |
| 170 | readFromSequence, | |
| 171 | readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), | |
| 172 | } satisfies OrchestrationEventRepositoryShape; | |
| 173 | }); | |
| 174 | ||
| 175 | export const OrchestrationEventRepositoryLive = Layer.effect( | |
| 176 | OrchestrationEventRepository, | |
| 177 | makeRepository, | |
| 178 | ); |
apps/server/src/persistence/Services/OrchestrationEventStore.ts created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | /** | |
| 2 | * OrchestrationEventStore - Event store interface for orchestration events. | |
| 3 | * | |
| 4 | * Owns durable append/replay access to the orchestration event stream. It does | |
| 5 | * not reduce events into read models or apply command validation rules. | |
| 6 | * | |
| 7 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 8 | * persistence/decode errors for event append and replay operations. | |
| 9 | * | |
| 10 | * @module OrchestrationEventStore | |
| 11 | */ | |
| 12 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | import type { Effect, Stream } from "effect"; | |
| 15 | ||
| 16 | import type { OrchestrationEventStoreError } from "../Errors.ts"; | |
| 17 | ||
| 18 | export interface OrchestrationEventStoreShape { | |
| 19 | /** | |
| 20 | * Persist a new orchestration event. | |
| 21 | * | |
| 22 | * @param event - Event payload without sequence (assigned by storage). | |
| 23 | * @returns Effect containing the stored event with assigned sequence. | |
| 24 | */ | |
| 25 | readonly append: ( | |
| 26 | event: Omit<OrchestrationEvent, "sequence">, | |
| 27 | ) => Effect.Effect<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 28 | ||
| 29 | /** | |
| 30 | * Replay events after the provided sequence. | |
| 31 | * | |
| 32 | * @param sequenceExclusive - Sequence cursor (exclusive). | |
| 33 | * @param limit - Maximum number of events to emit. | |
| 34 | * @returns Stream containing ordered events. | |
| 35 | */ | |
| 36 | readonly readFromSequence: ( | |
| 37 | sequenceExclusive: number, | |
| 38 | limit?: number, | |
| 39 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 40 | ||
| 41 | /** | |
| 42 | * Read all events from the beginning of the stream. | |
| 43 | * | |
| 44 | * @returns Stream containing all stored events. | |
| 45 | */ | |
| 46 | readonly readAll: () => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * OrchestrationEventStore - Service tag for orchestration event persistence. | |
| 51 | * | |
| 52 | * @example | |
| 53 | * ```ts | |
| 54 | * const program = Effect.gen(function* () { | |
| 55 | * const events = yield* OrchestrationEventStore | |
| 56 | * return yield* Stream.runCollect(events.readAll()) | |
| 57 | * }) | |
| 58 | * ``` | |
| 59 | */ | |
| 60 | export class OrchestrationEventStore extends ServiceMap.Service< | |
| 61 | OrchestrationEventStore, | |
| 62 | OrchestrationEventStoreShape | |
| 63 | >()("orchestration/EventStore") {} |
apps/server/src/persistence/Services/OrchestrationEvents.ts deleted-63| ... | ... | @@ -1,63 +0,0 @@ |
| 1 | /** | |
| 2 | * OrchestrationEventRepository - Repository interface for orchestration events. | |
| 3 | * | |
| 4 | * Owns durable append/replay access to the orchestration event stream. It does | |
| 5 | * not reduce events into read models or apply command validation rules. | |
| 6 | * | |
| 7 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 8 | * persistence/decode errors for event append and replay operations. | |
| 9 | * | |
| 10 | * @module OrchestrationEventRepository | |
| 11 | */ | |
| 12 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 13 | import { ServiceMap } from "effect"; | |
| 14 | import type { Effect } from "effect"; | |
| 15 | ||
| 16 | import type { OrchestrationEventRepositoryError } from "../Errors.ts"; | |
| 17 | ||
| 18 | export interface OrchestrationEventRepositoryShape { | |
| 19 | /** | |
| 20 | * Persist a new orchestration event. | |
| 21 | * | |
| 22 | * @param event - Event payload without sequence (assigned by storage). | |
| 23 | * @returns Effect containing the stored event with assigned sequence. | |
| 24 | */ | |
| 25 | readonly append: ( | |
| 26 | event: Omit<OrchestrationEvent, "sequence">, | |
| 27 | ) => Effect.Effect<OrchestrationEvent, OrchestrationEventRepositoryError>; | |
| 28 | ||
| 29 | /** | |
| 30 | * Replay events after the provided sequence. | |
| 31 | * | |
| 32 | * @param sequenceExclusive - Sequence cursor (exclusive). | |
| 33 | * @param limit - Maximum number of events to return. | |
| 34 | * @returns Effect containing ordered events. | |
| 35 | */ | |
| 36 | readonly readFromSequence: ( | |
| 37 | sequenceExclusive: number, | |
| 38 | limit?: number, | |
| 39 | ) => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError>; | |
| 40 | ||
| 41 | /** | |
| 42 | * Read all events from the beginning of the stream. | |
| 43 | * | |
| 44 | * @returns Effect containing all stored events. | |
| 45 | */ | |
| 46 | readonly readAll: () => Effect.Effect<OrchestrationEvent[], OrchestrationEventRepositoryError>; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * OrchestrationEventRepository - Service tag for orchestration event persistence. | |
| 51 | * | |
| 52 | * @example | |
| 53 | * ```ts | |
| 54 | * const program = Effect.gen(function* () { | |
| 55 | * const events = yield* OrchestrationEventRepository | |
| 56 | * return yield* events.readAll() | |
| 57 | * }) | |
| 58 | * ``` | |
| 59 | */ | |
| 60 | export class OrchestrationEventRepository extends ServiceMap.Service< | |
| 61 | OrchestrationEventRepository, | |
| 62 | OrchestrationEventRepositoryShape | |
| 63 | >()("orchestration/EventRepository") {} |
apps/server/src/wsServer.ts+23-18| ... | ... | @@ -36,13 +36,16 @@ import { |
| 36 | 36 | import { TerminalManager } from "./terminalManager"; |
| 37 | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 38 | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | import { OrchestrationEngineLive } from "./orchestration/Layer"; | |
| 40 | import { OrchestrationEngineService, type OrchestrationEngineShape } from "./orchestration/Service"; | |
| 39 | import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; | |
| 40 | import { | |
| 41 | OrchestrationEngineService, | |
| 42 | type OrchestrationEngineShape, | |
| 43 | } from "./orchestration/Services/OrchestrationEngine"; | |
| 41 | 44 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; |
| 42 | 45 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; |
| 43 | 46 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; |
| 44 | 47 | import assert from "node:assert"; |
| 45 | import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; | |
| 48 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | |
| 46 | 49 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 47 | 50 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| 48 | 51 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; |
| ... | ... | @@ -358,8 +361,8 @@ export function createServer(options: ServerOptions) { |
| 358 | 361 | |
| 359 | 362 | const processEvent = (event: ProviderRuntimeEvent) => |
| 360 | 363 | Effect.gen(function* () { |
| 361 | const snapshot = yield* orchestrationEngine.getSnapshot(); | |
| 362 | const thread = snapshot.threads.find( | |
| 364 | const readModel = yield* orchestrationEngine.getReadModel(); | |
| 365 | const thread = readModel.threads.find( | |
| 363 | 366 | (entry) => entry.session?.sessionId === event.sessionId, |
| 364 | 367 | ); |
| 365 | 368 | if (!thread) return; |
| ... | ... | @@ -735,8 +738,8 @@ export function createServer(options: ServerOptions) { |
| 735 | 738 | const params = request.params as { sessionId?: string }; |
| 736 | 739 | const sessionId = params.sessionId; |
| 737 | 740 | if (typeof sessionId === "string") { |
| 738 | const snapshot = await effectRuntime.runPromise(orchestrationEngine.getSnapshot()); | |
| 739 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); | |
| 741 | const readModel = await effectRuntime.runPromise(orchestrationEngine.getReadModel()); | |
| 742 | const thread = readModel.threads.find((entry) => entry.session?.sessionId === sessionId); | |
| 740 | 743 | if (thread) { |
| 741 | 744 | const now = new Date().toISOString(); |
| 742 | 745 | await effectRuntime.runPromise( |
| ... | ... | @@ -909,7 +912,7 @@ export function createServer(options: ServerOptions) { |
| 909 | 912 | }; |
| 910 | 913 | |
| 911 | 914 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 912 | return effectRuntime.runPromise(orchestrationEngine.getSnapshot()); | |
| 915 | return effectRuntime.runPromise(orchestrationEngine.getReadModel()); | |
| 913 | 916 | |
| 914 | 917 | case ORCHESTRATION_WS_METHODS.dispatchCommand: |
| 915 | 918 | orchestrationCommandLogger?.write({ |
| ... | ... | @@ -917,21 +920,23 @@ export function createServer(options: ServerOptions) { |
| 917 | 920 | requestId: request.id, |
| 918 | 921 | command: request.params, |
| 919 | 922 | }); |
| 920 | return effectRuntime.runPromise(orchestrationEngine.dispatchUnknown(request.params)); | |
| 923 | return effectRuntime.runPromise(orchestrationEngine.dispatchUnknownCommand(request.params)); | |
| 921 | 924 | |
| 922 | 925 | case ORCHESTRATION_WS_METHODS.replayEvents: |
| 923 | 926 | return effectRuntime.runPromise( |
| 924 | orchestrationEngine.replayEvents( | |
| 925 | Math.max( | |
| 926 | 0, | |
| 927 | Math.floor( | |
| 928 | Number( | |
| 929 | (request.params as { fromSequenceExclusive?: number } | undefined) | |
| 930 | ?.fromSequenceExclusive ?? 0, | |
| 927 | Stream.runCollect( | |
| 928 | orchestrationEngine.readEvents( | |
| 929 | Math.max( | |
| 930 | 0, | |
| 931 | Math.floor( | |
| 932 | Number( | |
| 933 | (request.params as { fromSequenceExclusive?: number } | undefined) | |
| 934 | ?.fromSequenceExclusive ?? 0, | |
| 935 | ), | |
| 931 | 936 | ), |
| 932 | 937 | ), |
| 933 | 938 | ), |
| 934 | ), | |
| 939 | ).pipe(Effect.map((events) => Array.from(events))), | |
| 935 | 940 | ); |
| 936 | 941 | |
| 937 | 942 | default: |
| ... | ... | @@ -952,7 +957,7 @@ export function createServer(options: ServerOptions) { |
| 952 | 957 | const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); |
| 953 | 958 | const orchestrationLayer = Layer.provide( |
| 954 | 959 | OrchestrationEngineLive, |
| 955 | OrchestrationEventRepositoryLive, | |
| 960 | OrchestrationEventStoreLive, | |
| 956 | 961 | ); |
| 957 | 962 | const codexAdapterLayer = makeCodexAdapterLive({ |
| 958 | 963 | nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), |