Unified · Split
| @@ -0,0 +1,439 @@ | ||
| 1 | +import fs from "node:fs"; | |
| 2 | +import os from "node:os"; | |
| 3 | +import path from "node:path"; | |
| 4 | + | |
| 5 | +import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 6 | +import { Effect } from "effect"; | |
| 7 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + OrchestrationEventRepository, | |
| 11 | + type OrchestrationEventRepositoryShape, | |
| 12 | +} from "../persistence/Services/OrchestrationEvents"; | |
| 13 | + | |
| 14 | +import { Layer, ManagedRuntime } from "effect"; | |
| 15 | + | |
| 16 | +import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; | |
| 17 | + | |
| 18 | +import { OrchestrationEngineLive } from "./Layer"; | |
| 19 | +import { OrchestrationEngineService } from "./Service"; | |
| 20 | +import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; | |
| 21 | + | |
| 22 | +const tempDirs: string[] = []; | |
| 23 | + | |
| 24 | +function makeTempDir(prefix: string): string { | |
| 25 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 26 | + tempDirs.push(dir); | |
| 27 | + return dir; | |
| 28 | +} | |
| 29 | + | |
| 30 | +afterEach(() => { | |
| 31 | + for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 32 | + fs.rmSync(dir, { recursive: true, force: true }); | |
| 33 | + } | |
| 34 | +}); | |
| 35 | + | |
| 36 | +export async function createOrchestrationSystem(stateDir: string) { | |
| 37 | + const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 38 | + const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 39 | + Layer.provide(OrchestrationEventRepositoryLive), | |
| 40 | + Layer.provide(makeSqlitePersistenceLive(dbPath)), | |
| 41 | + ); | |
| 42 | + const runtime = ManagedRuntime.make(orchestrationLayer); | |
| 43 | + const engine = await runtime.runPromise(OrchestrationEngineService); | |
| 44 | + return { | |
| 45 | + engine, | |
| 46 | + run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect), | |
| 47 | + dispose: () => runtime.dispose(), | |
| 48 | + }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +describe("OrchestrationEngine", () => { | |
| 52 | + it("replays to the same deterministic snapshot", async () => { | |
| 53 | + const stateDir = makeTempDir("t3code-orchestration-"); | |
| 54 | + const createdAt = new Date().toISOString(); | |
| 55 | + const projectId = "project-1"; | |
| 56 | + const threadId = "thread-1"; | |
| 57 | + | |
| 58 | + const firstSystem = await createOrchestrationSystem(stateDir); | |
| 59 | + const engineA = firstSystem.engine; | |
| 60 | + await firstSystem.run( | |
| 61 | + engineA.dispatch({ | |
| 62 | + type: "thread.create", | |
| 63 | + commandId: "cmd-1", | |
| 64 | + threadId, | |
| 65 | + projectId, | |
| 66 | + title: "Thread", | |
| 67 | + model: "gpt-5-codex", | |
| 68 | + branch: null, | |
| 69 | + worktreePath: null, | |
| 70 | + createdAt, | |
| 71 | + }), | |
| 72 | + ); | |
| 73 | + await firstSystem.run( | |
| 74 | + engineA.dispatch({ | |
| 75 | + type: "message.send", | |
| 76 | + commandId: "cmd-3", | |
| 77 | + threadId, | |
| 78 | + messageId: "msg-1", | |
| 79 | + role: "user", | |
| 80 | + text: "hello", | |
| 81 | + streaming: false, | |
| 82 | + createdAt, | |
| 83 | + }), | |
| 84 | + ); | |
| 85 | + const snapshotA = await firstSystem.run(engineA.getSnapshot()); | |
| 86 | + await firstSystem.dispose(); | |
| 87 | + | |
| 88 | + const secondSystem = await createOrchestrationSystem(stateDir); | |
| 89 | + const engineB = secondSystem.engine; | |
| 90 | + const snapshotB = await secondSystem.run(engineB.getSnapshot()); | |
| 91 | + expect(snapshotB).toEqual(snapshotA); | |
| 92 | + await secondSystem.dispose(); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("fans out read-model updates to subscribers", async () => { | |
| 96 | + const stateDir = makeTempDir("t3code-orchestration-fanout-"); | |
| 97 | + const system = await createOrchestrationSystem(stateDir); | |
| 98 | + const engine = system.engine; | |
| 99 | + const updates: number[] = []; | |
| 100 | + const unsubscribe = await system.run( | |
| 101 | + engine.subscribeToReadModel((snapshot) => { | |
| 102 | + updates.push(snapshot.sequence); | |
| 103 | + }), | |
| 104 | + ); | |
| 105 | + await system.run( | |
| 106 | + engine.dispatch({ | |
| 107 | + type: "thread.create", | |
| 108 | + commandId: "cmd-thread", | |
| 109 | + threadId: "thread-2", | |
| 110 | + projectId: "project-2", | |
| 111 | + title: "fanout", | |
| 112 | + model: "gpt-5-codex", | |
| 113 | + branch: null, | |
| 114 | + worktreePath: null, | |
| 115 | + createdAt: new Date().toISOString(), | |
| 116 | + }), | |
| 117 | + ); | |
| 118 | + unsubscribe(); | |
| 119 | + expect(updates.length).toBeGreaterThan(0); | |
| 120 | + await system.dispose(); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("replays append-only events from sequence", async () => { | |
| 124 | + const stateDir = makeTempDir("t3code-orchestration-replay-"); | |
| 125 | + const system = await createOrchestrationSystem(stateDir); | |
| 126 | + const engine = system.engine; | |
| 127 | + await system.run( | |
| 128 | + engine.dispatch({ | |
| 129 | + type: "thread.create", | |
| 130 | + commandId: "cmd-a", | |
| 131 | + threadId: "thread-replay", | |
| 132 | + projectId: "project-replay", | |
| 133 | + title: "replay", | |
| 134 | + model: "gpt-5-codex", | |
| 135 | + branch: null, | |
| 136 | + worktreePath: null, | |
| 137 | + createdAt: new Date().toISOString(), | |
| 138 | + }), | |
| 139 | + ); | |
| 140 | + await system.run( | |
| 141 | + engine.dispatch({ | |
| 142 | + type: "thread.delete", | |
| 143 | + commandId: "cmd-b", | |
| 144 | + threadId: "thread-replay", | |
| 145 | + createdAt: new Date().toISOString(), | |
| 146 | + }), | |
| 147 | + ); | |
| 148 | + const events = await system.run(engine.replayEvents(0)); | |
| 149 | + expect(events.length).toBe(2); | |
| 150 | + expect(events[0]?.type).toBe("thread.created"); | |
| 151 | + expect(events[1]?.type).toBe("thread.deleted"); | |
| 152 | + await system.dispose(); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + it("stores completed turn summaries even when no files changed", async () => { | |
| 156 | + const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); | |
| 157 | + const firstSystem = await createOrchestrationSystem(stateDir); | |
| 158 | + const engine = firstSystem.engine; | |
| 159 | + const completedAt = new Date().toISOString(); | |
| 160 | + await firstSystem.run( | |
| 161 | + engine.dispatch({ | |
| 162 | + type: "thread.create", | |
| 163 | + commandId: "cmd-thread-turn-diff", | |
| 164 | + threadId: "thread-turn-diff", | |
| 165 | + projectId: "project-turn-diff", | |
| 166 | + title: "Turn diff thread", | |
| 167 | + model: "gpt-5-codex", | |
| 168 | + branch: null, | |
| 169 | + worktreePath: null, | |
| 170 | + createdAt: completedAt, | |
| 171 | + }), | |
| 172 | + ); | |
| 173 | + await firstSystem.run( | |
| 174 | + engine.dispatch({ | |
| 175 | + type: "thread.turnDiff.complete", | |
| 176 | + commandId: "cmd-turn-diff-complete", | |
| 177 | + threadId: "thread-turn-diff", | |
| 178 | + turnId: "turn-1", | |
| 179 | + completedAt, | |
| 180 | + status: "completed", | |
| 181 | + files: [], | |
| 182 | + checkpointTurnCount: 1, | |
| 183 | + createdAt: completedAt, | |
| 184 | + }), | |
| 185 | + ); | |
| 186 | + | |
| 187 | + const firstThread = (await firstSystem.run(engine.getSnapshot())).threads.find( | |
| 188 | + (thread) => thread.id === "thread-turn-diff", | |
| 189 | + ); | |
| 190 | + expect(firstThread?.turnDiffSummaries).toEqual([ | |
| 191 | + { | |
| 192 | + turnId: "turn-1", | |
| 193 | + completedAt, | |
| 194 | + status: "completed", | |
| 195 | + files: [], | |
| 196 | + checkpointTurnCount: 1, | |
| 197 | + }, | |
| 198 | + ]); | |
| 199 | + await firstSystem.dispose(); | |
| 200 | + | |
| 201 | + const secondSystem = await createOrchestrationSystem(stateDir); | |
| 202 | + const restartedEngine = secondSystem.engine; | |
| 203 | + const restartedThread = (await secondSystem.run(restartedEngine.getSnapshot())).threads.find( | |
| 204 | + (thread) => thread.id === "thread-turn-diff", | |
| 205 | + ); | |
| 206 | + expect(restartedThread?.turnDiffSummaries).toEqual([ | |
| 207 | + { | |
| 208 | + turnId: "turn-1", | |
| 209 | + completedAt, | |
| 210 | + status: "completed", | |
| 211 | + files: [], | |
| 212 | + checkpointTurnCount: 1, | |
| 213 | + }, | |
| 214 | + ]); | |
| 215 | + await secondSystem.dispose(); | |
| 216 | + }); | |
| 217 | + | |
| 218 | + it("reverts thread messages and turn summaries to a checkpoint", async () => { | |
| 219 | + const stateDir = makeTempDir("t3code-orchestration-revert-"); | |
| 220 | + const firstSystem = await createOrchestrationSystem(stateDir); | |
| 221 | + const engine = firstSystem.engine; | |
| 222 | + const createdAt = new Date().toISOString(); | |
| 223 | + await firstSystem.run( | |
| 224 | + engine.dispatch({ | |
| 225 | + type: "thread.create", | |
| 226 | + commandId: "cmd-thread-revert", | |
| 227 | + threadId: "thread-revert", | |
| 228 | + projectId: "project-revert", | |
| 229 | + title: "Revert thread", | |
| 230 | + model: "gpt-5-codex", | |
| 231 | + branch: null, | |
| 232 | + worktreePath: null, | |
| 233 | + createdAt, | |
| 234 | + }), | |
| 235 | + ); | |
| 236 | + await firstSystem.run( | |
| 237 | + engine.dispatch({ | |
| 238 | + type: "message.send", | |
| 239 | + commandId: "cmd-msg-1-user", | |
| 240 | + threadId: "thread-revert", | |
| 241 | + messageId: "user-1", | |
| 242 | + role: "user", | |
| 243 | + text: "first", | |
| 244 | + streaming: false, | |
| 245 | + createdAt, | |
| 246 | + }), | |
| 247 | + ); | |
| 248 | + await firstSystem.run( | |
| 249 | + engine.dispatch({ | |
| 250 | + type: "message.send", | |
| 251 | + commandId: "cmd-msg-1-assistant", | |
| 252 | + threadId: "thread-revert", | |
| 253 | + messageId: "assistant:turn-1", | |
| 254 | + role: "assistant", | |
| 255 | + text: "first-response", | |
| 256 | + streaming: false, | |
| 257 | + createdAt, | |
| 258 | + }), | |
| 259 | + ); | |
| 260 | + await firstSystem.run( | |
| 261 | + engine.dispatch({ | |
| 262 | + type: "thread.turnDiff.complete", | |
| 263 | + commandId: "cmd-turn-1-complete", | |
| 264 | + threadId: "thread-revert", | |
| 265 | + turnId: "turn-1", | |
| 266 | + completedAt: createdAt, | |
| 267 | + status: "completed", | |
| 268 | + files: [], | |
| 269 | + assistantMessageId: "assistant:turn-1", | |
| 270 | + checkpointTurnCount: 1, | |
| 271 | + createdAt, | |
| 272 | + }), | |
| 273 | + ); | |
| 274 | + | |
| 275 | + const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); | |
| 276 | + await firstSystem.run( | |
| 277 | + engine.dispatch({ | |
| 278 | + type: "message.send", | |
| 279 | + commandId: "cmd-msg-2-user", | |
| 280 | + threadId: "thread-revert", | |
| 281 | + messageId: "user-2", | |
| 282 | + role: "user", | |
| 283 | + text: "second", | |
| 284 | + streaming: false, | |
| 285 | + createdAt: createdAtSecond, | |
| 286 | + }), | |
| 287 | + ); | |
| 288 | + await firstSystem.run( | |
| 289 | + engine.dispatch({ | |
| 290 | + type: "message.send", | |
| 291 | + commandId: "cmd-msg-2-assistant", | |
| 292 | + threadId: "thread-revert", | |
| 293 | + messageId: "assistant:turn-2", | |
| 294 | + role: "assistant", | |
| 295 | + text: "second-response", | |
| 296 | + streaming: false, | |
| 297 | + createdAt: createdAtSecond, | |
| 298 | + }), | |
| 299 | + ); | |
| 300 | + await firstSystem.run( | |
| 301 | + engine.dispatch({ | |
| 302 | + type: "thread.turnDiff.complete", | |
| 303 | + commandId: "cmd-turn-2-complete", | |
| 304 | + threadId: "thread-revert", | |
| 305 | + turnId: "turn-2", | |
| 306 | + completedAt: createdAtSecond, | |
| 307 | + status: "completed", | |
| 308 | + files: [], | |
| 309 | + assistantMessageId: "assistant:turn-2", | |
| 310 | + checkpointTurnCount: 2, | |
| 311 | + createdAt: createdAtSecond, | |
| 312 | + }), | |
| 313 | + ); | |
| 314 | + | |
| 315 | + await firstSystem.run( | |
| 316 | + engine.dispatch({ | |
| 317 | + type: "thread.revert", | |
| 318 | + commandId: "cmd-thread-revert-apply", | |
| 319 | + threadId: "thread-revert", | |
| 320 | + turnCount: 1, | |
| 321 | + messageCount: 2, | |
| 322 | + createdAt: createdAtSecond, | |
| 323 | + }), | |
| 324 | + ); | |
| 325 | + | |
| 326 | + const thread = (await firstSystem.run(engine.getSnapshot())).threads.find( | |
| 327 | + (entry) => entry.id === "thread-revert", | |
| 328 | + ); | |
| 329 | + expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); | |
| 330 | + expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); | |
| 331 | + expect(thread?.latestTurnId).toBe("turn-1"); | |
| 332 | + await firstSystem.dispose(); | |
| 333 | + | |
| 334 | + const secondSystem = await createOrchestrationSystem(stateDir); | |
| 335 | + const restarted = secondSystem.engine; | |
| 336 | + const restartedThread = (await secondSystem.run(restarted.getSnapshot())).threads.find( | |
| 337 | + (entry) => entry.id === "thread-revert", | |
| 338 | + ); | |
| 339 | + expect(restartedThread?.messages.map((message) => message.id)).toEqual([ | |
| 340 | + "user-1", | |
| 341 | + "assistant:turn-1", | |
| 342 | + ]); | |
| 343 | + expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); | |
| 344 | + await secondSystem.dispose(); | |
| 345 | + }); | |
| 346 | + | |
| 347 | + it("allows stop to be called multiple times", async () => { | |
| 348 | + const inMemoryStore: OrchestrationEventRepositoryShape = { | |
| 349 | + append(event) { | |
| 350 | + return Effect.succeed({ ...event, sequence: 1 }); | |
| 351 | + }, | |
| 352 | + readFromSequence() { | |
| 353 | + return Effect.succeed([]); | |
| 354 | + }, | |
| 355 | + readAll() { | |
| 356 | + return Effect.succeed([]); | |
| 357 | + }, | |
| 358 | + }; | |
| 359 | + const runtime = ManagedRuntime.make( | |
| 360 | + OrchestrationEngineLive.pipe( | |
| 361 | + Layer.provide(Layer.succeed(OrchestrationEventRepository, inMemoryStore)), | |
| 362 | + ), | |
| 363 | + ); | |
| 364 | + await runtime.runPromise(OrchestrationEngineService); | |
| 365 | + await runtime.dispose(); | |
| 366 | + await expect(runtime.dispose()).resolves.toBeUndefined(); | |
| 367 | + }); | |
| 368 | + | |
| 369 | + it("keeps processing queued commands after a storage failure", async () => { | |
| 370 | + const events: OrchestrationEvent[] = []; | |
| 371 | + let nextSequence = 1; | |
| 372 | + let shouldFailFirstAppend = true; | |
| 373 | + | |
| 374 | + const flakyStore: OrchestrationEventRepositoryShape = { | |
| 375 | + append(event) { | |
| 376 | + if (shouldFailFirstAppend) { | |
| 377 | + shouldFailFirstAppend = false; | |
| 378 | + return Effect.die(new Error("append failed")); | |
| 379 | + } | |
| 380 | + const savedEvent = { | |
| 381 | + ...event, | |
| 382 | + sequence: nextSequence, | |
| 383 | + } satisfies OrchestrationEvent; | |
| 384 | + nextSequence += 1; | |
| 385 | + events.push(savedEvent); | |
| 386 | + return Effect.succeed(savedEvent); | |
| 387 | + }, | |
| 388 | + readFromSequence(sequenceExclusive) { | |
| 389 | + return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 390 | + }, | |
| 391 | + readAll() { | |
| 392 | + return Effect.succeed(events); | |
| 393 | + }, | |
| 394 | + }; | |
| 395 | + | |
| 396 | + const runtime = ManagedRuntime.make( | |
| 397 | + OrchestrationEngineLive.pipe( | |
| 398 | + Layer.provide(Layer.succeed(OrchestrationEventRepository, flakyStore)), | |
| 399 | + ), | |
| 400 | + ); | |
| 401 | + const engine = await runtime.runPromise(OrchestrationEngineService); | |
| 402 | + const createdAt = new Date().toISOString(); | |
| 403 | + | |
| 404 | + await expect( | |
| 405 | + runtime.runPromise( | |
| 406 | + engine.dispatch({ | |
| 407 | + type: "thread.create", | |
| 408 | + commandId: "cmd-flaky-1", | |
| 409 | + threadId: "thread-flaky-fail", | |
| 410 | + projectId: "project-flaky", | |
| 411 | + title: "flaky-fail", | |
| 412 | + model: "gpt-5-codex", | |
| 413 | + branch: null, | |
| 414 | + worktreePath: null, | |
| 415 | + createdAt, | |
| 416 | + }), | |
| 417 | + ), | |
| 418 | + ).rejects.toThrow("append failed"); | |
| 419 | + | |
| 420 | + const result = await runtime.runPromise( | |
| 421 | + engine.dispatch({ | |
| 422 | + type: "thread.create", | |
| 423 | + commandId: "cmd-flaky-2", | |
| 424 | + threadId: "thread-flaky-ok", | |
| 425 | + projectId: "project-flaky", | |
| 426 | + title: "flaky-ok", | |
| 427 | + model: "gpt-5-codex", | |
| 428 | + branch: null, | |
| 429 | + worktreePath: null, | |
| 430 | + createdAt, | |
| 431 | + }), | |
| 432 | + ); | |
| 433 | + | |
| 434 | + expect(result.sequence).toBe(1); | |
| 435 | + expect((await runtime.runPromise(engine.getSnapshot())).sequence).toBe(1); | |
| 436 | + | |
| 437 | + await runtime.dispose(); | |
| 438 | + }); | |
| 439 | +}); | |
| @@ -5,21 +5,22 @@ import type { · 4 unmodified lines | ||
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| 8 | − PubSub, | |
| 9 | − Queue, | |
| 10 | − Schema, | |
| 11 | − Stream, | |
| 8 | + Cause, | |
| 9 | + Deferred, | |
| 12 | 10 | Effect, |
| 11 | + Either, | |
| 13 | 12 | Fiber, |
| 13 | + Layer, | |
| 14 | + PubSub, | |
| 15 | + Queue, | |
| 14 | 16 | Runtime, |
| 15 | − Either, | |
| 16 | − Deferred, | |
| 17 | − Cause, | |
| 17 | + Schema, | |
| 18 | 18 | } from "effect"; |
| 19 | 19 | |
| 20 | 20 | import { createLogger } from "../logger"; |
| 21 | −import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | |
| 21 | +import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; | |
| 22 | 22 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 23 | +import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; | |
| 23 | 24 | |
| 24 | 25 | type CommandEnvelope = { |
| 25 | 26 | command: OrchestrationCommand; |
| @@ -168,45 +169,38 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve · 142 unmodified lines | ||
| 168 | 169 | } |
| 169 | 170 | } |
| 170 | 171 | |
| 171 | −export class OrchestrationEngine { | |
| 172 | − private readonly logger = createLogger("orchestration"); | |
| 173 | − private readonly runtime = Runtime.defaultRuntime; | |
| 174 | − private readonly eventStore: OrchestrationEventRepositoryShape; | |
| 172 | +const decodeUnknownCommand = Schema.decodeUnknownEither(OrchestrationCommandSchema); | |
| 175 | 173 | |
| 176 | − private readModel: OrchestrationReadModel; | |
| 177 | − private commandQueue: Queue.Queue<CommandEnvelope>; | |
| 178 | − private readModelPubSub: PubSub.PubSub<OrchestrationReadModel>; | |
| 179 | − private eventPubSub: PubSub.PubSub<OrchestrationEvent>; | |
| 180 | − private workerFiber: Fiber.RuntimeFiber<void, unknown> | null = null; | |
| 181 | − private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); | |
| 182 | − private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 174 | +export const makeOrchestrationEngine = Effect.gen(function* () { | |
| 175 | + const logger = createLogger("orchestration"); | |
| 176 | + const eventStore = yield* OrchestrationEventRepository; | |
| 177 | + const runtime = Runtime.defaultRuntime; | |
| 183 | 178 | |
| 184 | − constructor(eventStore: OrchestrationEventRepositoryShape) { | |
| 185 | − this.eventStore = eventStore; | |
| 186 | − this.readModel = createEmptyReadModel(new Date().toISOString()); | |
| 187 | − this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded<CommandEnvelope>()); | |
| 188 | − this.readModelPubSub = Runtime.runSync(this.runtime)( | |
| 189 | − PubSub.unbounded<OrchestrationReadModel>(), | |
| 190 | − ); | |
| 191 | − this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded<OrchestrationEvent>()); | |
| 192 | − } | |
| 179 | + let readModel = createEmptyReadModel(new Date().toISOString()); | |
| 180 | + | |
| 181 | + const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); | |
| 182 | + const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>(); | |
| 183 | + const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); | |
| 193 | 184 | |
| 194 | − private processEnvelope(envelope: CommandEnvelope): Effect.Effect<void> { | |
| 195 | − return Effect.gen(this, function* () { | |
| 185 | + const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); | |
| 186 | + const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 187 | + | |
| 188 | + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => | |
| 189 | + Effect.gen(function* () { | |
| 196 | 190 | const eventBase = mapCommandToEvent(envelope.command); |
| 197 | − const savedEvent = yield* this.eventStore.append(eventBase); | |
| 198 | − this.readModel = yield* reduceEvent(this.readModel, savedEvent); | |
| 191 | + const savedEvent = yield* eventStore.append(eventBase); | |
| 192 | + readModel = yield* reduceEvent(readModel, savedEvent); | |
| 199 | 193 | |
| 200 | − const snapshot = this.readModel; | |
| 194 | + const snapshot = readModel; | |
| 201 | 195 | yield* Effect.all([ |
| 202 | − PubSub.publish(this.eventPubSub, savedEvent), | |
| 203 | − PubSub.publish(this.readModelPubSub, snapshot), | |
| 196 | + PubSub.publish(eventPubSub, savedEvent), | |
| 197 | + PubSub.publish(readModelPubSub, snapshot), | |
| 204 | 198 | ]); |
| 205 | 199 | |
| 206 | − for (const listener of this.domainEventListeners) { | |
| 200 | + for (const listener of domainEventListeners) { | |
| 207 | 201 | listener(savedEvent); |
| 208 | 202 | } |
| 209 | − for (const listener of this.readModelListeners) { | |
| 203 | + for (const listener of readModelListeners) { | |
| 210 | 204 | listener(snapshot); |
| 211 | 205 | } |
| 212 | 206 | |
| @@ -219,91 +213,85 @@ export class OrchestrationEngine { · 6 unmodified lines | ||
| 219 | 213 | ).pipe(Effect.asVoid), |
| 220 | 214 | ), |
| 221 | 215 | ); |
| 222 | − } | |
| 223 | 216 | |
| 224 | − private bootstrapReadModel(): Effect.Effect<void, unknown> { | |
| 225 | − return Effect.gen(this, function* () { | |
| 226 | − const existingEvents = yield* this.eventStore.readAll(); | |
| 227 | − for (const event of existingEvents) { | |
| 228 | − this.readModel = yield* reduceEvent(this.readModel, event); | |
| 229 | − } | |
| 230 | − }); | |
| 231 | − } | |
| 232 | − | |
| 233 | − async start(): Promise<void> { | |
| 234 | − if (this.workerFiber) { | |
| 235 | − return; | |
| 217 | + const bootstrapReadModel: Effect.Effect<void, unknown> = Effect.gen(function* () { | |
| 218 | + const existingEvents = yield* eventStore.readAll(); | |
| 219 | + for (const event of existingEvents) { | |
| 220 | + readModel = yield* reduceEvent(readModel, event); | |
| 236 | 221 | } |
| 222 | + }); | |
| 237 | 223 | |
| 238 | − await Runtime.runPromise(this.runtime)(this.bootstrapReadModel()); | |
| 224 | + yield* bootstrapReadModel; | |
| 239 | 225 | |
| 240 | − const worker = Stream.fromQueue(this.commandQueue).pipe( | |
| 241 | − Stream.runForEach((envelope) => this.processEnvelope(envelope)), | |
| 242 | − ); | |
| 226 | + const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); | |
| 227 | + const workerFiber: Fiber.RuntimeFiber<void, unknown> = Runtime.runFork(runtime)(worker); | |
| 228 | + logger.info("orchestration engine started", { | |
| 229 | + sequence: readModel.sequence, | |
| 230 | + }); | |
| 243 | 231 | |
| 244 | − this.workerFiber = Runtime.runFork(this.runtime)(worker); | |
| 245 | − this.logger.info("orchestration engine started", { | |
| 246 | − sequence: this.readModel.sequence, | |
| 247 | − }); | |
| 248 | − } | |
| 232 | + yield* Effect.addFinalizer(() => Fiber.interrupt(workerFiber).pipe(Effect.orDie)); | |
| 249 | 233 | |
| 250 | − async stop(): Promise<void> { | |
| 251 | − if (this.workerFiber) { | |
| 252 | − await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); | |
| 253 | − this.workerFiber = null; | |
| 254 | − } | |
| 255 | − } | |
| 234 | + const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => | |
| 235 | + Effect.sync((): OrchestrationReadModel => readModel); | |
| 256 | 236 | |
| 257 | − getSnapshot(): OrchestrationReadModel { | |
| 258 | − return this.readModel; | |
| 259 | − } | |
| 237 | + const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => | |
| 238 | + eventStore.readFromSequence(fromSequenceExclusive); | |
| 260 | 239 | |
| 261 | − async replayEvents(fromSequenceExclusive: number): Promise<OrchestrationEvent[]> { | |
| 262 | − return Runtime.runPromise(this.runtime)( | |
| 263 | − this.eventStore.readFromSequence(fromSequenceExclusive), | |
| 264 | − ); | |
| 265 | − } | |
| 240 | + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | |
| 241 | + Effect.gen(function* () { | |
| 242 | + const result = yield* Deferred.make<{ sequence: number }, Error>(); | |
| 243 | + yield* Queue.offer(commandQueue, { command, result }); | |
| 244 | + return yield* Deferred.await(result); | |
| 245 | + }).pipe(Effect.mapError((error) => asError(error, "Queue offer failed"))); | |
| 266 | 246 | |
| 267 | − async dispatchUnknown(command: unknown): Promise<{ sequence: number }> { | |
| 268 | − const decode = Schema.decodeUnknownEither(OrchestrationCommandSchema); | |
| 269 | − let payload: unknown = command; | |
| 270 | − if (typeof command === "string") { | |
| 271 | − try { | |
| 272 | − payload = JSON.parse(command) as unknown; | |
| 273 | − } catch { | |
| 274 | − throw new Error("Invalid orchestration command: payload is not valid JSON"); | |
| 247 | + const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => | |
| 248 | + Effect.gen(function* () { | |
| 249 | + let payload: unknown = command; | |
| 250 | + if (typeof command === "string") { | |
| 251 | + try { | |
| 252 | + payload = JSON.parse(command) as unknown; | |
| 253 | + } catch { | |
| 254 | + return yield* Effect.fail( | |
| 255 | + new Error("Invalid orchestration command: payload is not valid JSON"), | |
| 256 | + ); | |
| 257 | + } | |
| 275 | 258 | } |
| 276 | − } | |
| 277 | − const decoded = decode(payload); | |
| 278 | − if (Either.isLeft(decoded)) { | |
| 279 | − const issues = decoded.left.toString(); | |
| 280 | − throw new Error(`Invalid orchestration command: ${issues}`); | |
| 281 | − } | |
| 282 | − return this.dispatch(decoded.right); | |
| 283 | − } | |
| 259 | + const decoded = decodeUnknownCommand(payload); | |
| 260 | + if (Either.isLeft(decoded)) { | |
| 261 | + return yield* Effect.fail( | |
| 262 | + new Error(`Invalid orchestration command: ${decoded.left.toString()}`), | |
| 263 | + ); | |
| 264 | + } | |
| 265 | + return yield* dispatch(decoded.right); | |
| 266 | + }); | |
| 284 | 267 | |
| 285 | − async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> { | |
| 286 | − const program = Effect.gen(this, function* () { | |
| 287 | − const result = yield* Deferred.make<{ sequence: number }, Error>(); | |
| 288 | − yield* Queue.offer(this.commandQueue, { command, result }); | |
| 289 | − return yield* Deferred.await(result); | |
| 268 | + const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => | |
| 269 | + Effect.sync(() => { | |
| 270 | + readModelListeners.add(callback); | |
| 271 | + return () => { | |
| 272 | + readModelListeners.delete(callback); | |
| 273 | + }; | |
| 290 | 274 | }); |
| 291 | − return Runtime.runPromise(this.runtime)(program).catch((error) => { | |
| 292 | − throw asError(error, "Queue offer failed"); | |
| 275 | + | |
| 276 | + const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => | |
| 277 | + Effect.sync(() => { | |
| 278 | + domainEventListeners.add(callback); | |
| 279 | + return () => { | |
| 280 | + domainEventListeners.delete(callback); | |
| 281 | + }; | |
| 293 | 282 | }); |
| 294 | − } | |
| 295 | 283 | |
| 296 | − subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void { | |
| 297 | − this.readModelListeners.add(callback); | |
| 298 | − return () => { | |
| 299 | − this.readModelListeners.delete(callback); | |
| 300 | − }; | |
| 301 | − } | |
| 284 | + return { | |
| 285 | + getSnapshot, | |
| 286 | + replayEvents, | |
| 287 | + dispatchUnknown, | |
| 288 | + dispatch, | |
| 289 | + subscribeToReadModel, | |
| 290 | + subscribeToDomainEvents, | |
| 291 | + } satisfies OrchestrationEngineShape; | |
| 292 | +}); | |
| 302 | 293 | |
| 303 | − subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void { | |
| 304 | − this.domainEventListeners.add(callback); | |
| 305 | − return () => { | |
| 306 | − this.domainEventListeners.delete(callback); | |
| 307 | − }; | |
| 308 | − } | |
| 309 | −} | |
| 294 | +export const OrchestrationEngineLive = Layer.scoped( | |
| 295 | + OrchestrationEngineService, | |
| 296 | + makeOrchestrationEngine, | |
| 297 | +); | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +import type { | |
| 2 | + OrchestrationCommand, | |
| 3 | + OrchestrationEvent, | |
| 4 | + OrchestrationReadModel, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { Context } from "effect"; | |
| 7 | +import type { Effect } from "effect"; | |
| 8 | + | |
| 9 | +export interface OrchestrationEngineShape { | |
| 10 | + readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel>; | |
| 11 | + readonly replayEvents: (fromSequenceExclusive: number) => Effect.Effect<OrchestrationEvent[]>; | |
| 12 | + readonly dispatchUnknown: (command: unknown) => Effect.Effect<{ sequence: number }, Error>; | |
| 13 | + readonly dispatch: (command: OrchestrationCommand) => Effect.Effect<{ sequence: number }, Error>; | |
| 14 | + readonly subscribeToReadModel: ( | |
| 15 | + callback: (snapshot: OrchestrationReadModel) => void, | |
| 16 | + ) => Effect.Effect<() => void>; | |
| 17 | + readonly subscribeToDomainEvents: ( | |
| 18 | + callback: (event: OrchestrationEvent) => void, | |
| 19 | + ) => Effect.Effect<() => void>; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< | |
| 23 | + OrchestrationEngineService, | |
| 24 | + OrchestrationEngineShape | |
| 25 | +>() {} | |
| @@ -1,388 +0,0 @@ | ||
| 1 | −import fs from "node:fs"; | |
| 2 | −import os from "node:os"; | |
| 3 | −import path from "node:path"; | |
| 4 | − | |
| 5 | −import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 6 | −import { Effect } from "effect"; | |
| 7 | −import { afterEach, describe, expect, it } from "vitest"; | |
| 8 | − | |
| 9 | −import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | |
| 10 | −import { OrchestrationEngine } from "./engine"; | |
| 11 | − | |
| 12 | −import { Layer, ManagedRuntime } from "effect"; | |
| 13 | − | |
| 14 | −import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; | |
| 15 | − | |
| 16 | −import { OrchestrationLive } from "./layers"; | |
| 17 | −import { OrchestrationEngineService } from "./services"; | |
| 18 | − | |
| 19 | −const tempDirs: string[] = []; | |
| 20 | − | |
| 21 | −function makeTempDir(prefix: string): string { | |
| 22 | − const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 23 | − tempDirs.push(dir); | |
| 24 | − return dir; | |
| 25 | −} | |
| 26 | − | |
| 27 | −afterEach(() => { | |
| 28 | − for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 29 | − fs.rmSync(dir, { recursive: true, force: true }); | |
| 30 | − } | |
| 31 | −}); | |
| 32 | − | |
| 33 | −export async function createOrchestrationSystem(stateDir: string) { | |
| 34 | − const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 35 | − const orchestrationLayer = OrchestrationLive.pipe( | |
| 36 | − Layer.provide(makeSqlitePersistenceLive(dbPath)), | |
| 37 | − ); | |
| 38 | − const runtime = ManagedRuntime.make(orchestrationLayer); | |
| 39 | − const engine = await runtime.runPromise(OrchestrationEngineService); | |
| 40 | − return { | |
| 41 | − engine, | |
| 42 | − dispose: () => runtime.dispose(), | |
| 43 | − }; | |
| 44 | −} | |
| 45 | − | |
| 46 | −describe("OrchestrationEngine", () => { | |
| 47 | − it("replays to the same deterministic snapshot", async () => { | |
| 48 | − const stateDir = makeTempDir("t3code-orchestration-"); | |
| 49 | − const createdAt = new Date().toISOString(); | |
| 50 | − const projectId = "project-1"; | |
| 51 | − const threadId = "thread-1"; | |
| 52 | − | |
| 53 | − const firstSystem = await createOrchestrationSystem(stateDir); | |
| 54 | − const engineA = firstSystem.engine; | |
| 55 | − await engineA.dispatch({ | |
| 56 | − type: "thread.create", | |
| 57 | − commandId: "cmd-1", | |
| 58 | − threadId, | |
| 59 | − projectId, | |
| 60 | − title: "Thread", | |
| 61 | − model: "gpt-5-codex", | |
| 62 | − branch: null, | |
| 63 | − worktreePath: null, | |
| 64 | − createdAt, | |
| 65 | − }); | |
| 66 | − await engineA.dispatch({ | |
| 67 | − type: "message.send", | |
| 68 | − commandId: "cmd-3", | |
| 69 | − threadId, | |
| 70 | − messageId: "msg-1", | |
| 71 | − role: "user", | |
| 72 | − text: "hello", | |
| 73 | − streaming: false, | |
| 74 | − createdAt, | |
| 75 | − }); | |
| 76 | − const snapshotA = engineA.getSnapshot(); | |
| 77 | − await firstSystem.dispose(); | |
| 78 | − | |
| 79 | − const secondSystem = await createOrchestrationSystem(stateDir); | |
| 80 | − const engineB = secondSystem.engine; | |
| 81 | − const snapshotB = engineB.getSnapshot(); | |
| 82 | − expect(snapshotB).toEqual(snapshotA); | |
| 83 | − await secondSystem.dispose(); | |
| 84 | − }); | |
| 85 | − | |
| 86 | − it("fans out read-model updates to subscribers", async () => { | |
| 87 | − const stateDir = makeTempDir("t3code-orchestration-fanout-"); | |
| 88 | − const system = await createOrchestrationSystem(stateDir); | |
| 89 | − const engine = system.engine; | |
| 90 | − const updates: number[] = []; | |
| 91 | − const unsubscribe = engine.subscribeToReadModel((snapshot) => { | |
| 92 | − updates.push(snapshot.sequence); | |
| 93 | − }); | |
| 94 | − await engine.dispatch({ | |
| 95 | − type: "thread.create", | |
| 96 | − commandId: "cmd-thread", | |
| 97 | − threadId: "thread-2", | |
| 98 | − projectId: "project-2", | |
| 99 | − title: "fanout", | |
| 100 | − model: "gpt-5-codex", | |
| 101 | − branch: null, | |
| 102 | − worktreePath: null, | |
| 103 | − createdAt: new Date().toISOString(), | |
| 104 | − }); | |
| 105 | − unsubscribe(); | |
| 106 | − expect(updates.length).toBeGreaterThan(0); | |
| 107 | − await system.dispose(); | |
| 108 | − }); | |
| 109 | − | |
| 110 | − it("replays append-only events from sequence", async () => { | |
| 111 | − const stateDir = makeTempDir("t3code-orchestration-replay-"); | |
| 112 | − const system = await createOrchestrationSystem(stateDir); | |
| 113 | − const engine = system.engine; | |
| 114 | − await engine.dispatch({ | |
| 115 | − type: "thread.create", | |
| 116 | − commandId: "cmd-a", | |
| 117 | − threadId: "thread-replay", | |
| 118 | − projectId: "project-replay", | |
| 119 | − title: "replay", | |
| 120 | − model: "gpt-5-codex", | |
| 121 | − branch: null, | |
| 122 | − worktreePath: null, | |
| 123 | − createdAt: new Date().toISOString(), | |
| 124 | − }); | |
| 125 | − await engine.dispatch({ | |
| 126 | − type: "thread.delete", | |
| 127 | − commandId: "cmd-b", | |
| 128 | − threadId: "thread-replay", | |
| 129 | − createdAt: new Date().toISOString(), | |
| 130 | − }); | |
| 131 | − const events = await engine.replayEvents(0); | |
| 132 | − expect(events.length).toBe(2); | |
| 133 | − expect(events[0]?.type).toBe("thread.created"); | |
| 134 | − expect(events[1]?.type).toBe("thread.deleted"); | |
| 135 | − await system.dispose(); | |
| 136 | − }); | |
| 137 | − | |
| 138 | − it("stores completed turn summaries even when no files changed", async () => { | |
| 139 | − const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); | |
| 140 | − const firstSystem = await createOrchestrationSystem(stateDir); | |
| 141 | − const engine = firstSystem.engine; | |
| 142 | − const completedAt = new Date().toISOString(); | |
| 143 | − await engine.dispatch({ | |
| 144 | − type: "thread.create", | |
| 145 | − commandId: "cmd-thread-turn-diff", | |
| 146 | − threadId: "thread-turn-diff", | |
| 147 | − projectId: "project-turn-diff", | |
| 148 | − title: "Turn diff thread", | |
| 149 | − model: "gpt-5-codex", | |
| 150 | − branch: null, | |
| 151 | − worktreePath: null, | |
| 152 | − createdAt: completedAt, | |
| 153 | − }); | |
| 154 | − await engine.dispatch({ | |
| 155 | − type: "thread.turnDiff.complete", | |
| 156 | − commandId: "cmd-turn-diff-complete", | |
| 157 | − threadId: "thread-turn-diff", | |
| 158 | − turnId: "turn-1", | |
| 159 | − completedAt, | |
| 160 | − status: "completed", | |
| 161 | − files: [], | |
| 162 | − checkpointTurnCount: 1, | |
| 163 | − createdAt: completedAt, | |
| 164 | − }); | |
| 165 | − | |
| 166 | − const firstThread = engine | |
| 167 | − .getSnapshot() | |
| 168 | − .threads.find((thread) => thread.id === "thread-turn-diff"); | |
| 169 | − expect(firstThread?.turnDiffSummaries).toEqual([ | |
| 170 | − { | |
| 171 | − turnId: "turn-1", | |
| 172 | − completedAt, | |
| 173 | − status: "completed", | |
| 174 | − files: [], | |
| 175 | − checkpointTurnCount: 1, | |
| 176 | − }, | |
| 177 | − ]); | |
| 178 | − await firstSystem.dispose(); | |
| 179 | − | |
| 180 | − const secondSystem = await createOrchestrationSystem(stateDir); | |
| 181 | − const restartedEngine = secondSystem.engine; | |
| 182 | − const restartedThread = restartedEngine | |
| 183 | − .getSnapshot() | |
| 184 | − .threads.find((thread) => thread.id === "thread-turn-diff"); | |
| 185 | − expect(restartedThread?.turnDiffSummaries).toEqual([ | |
| 186 | − { | |
| 187 | − turnId: "turn-1", | |
| 188 | − completedAt, | |
| 189 | − status: "completed", | |
| 190 | − files: [], | |
| 191 | − checkpointTurnCount: 1, | |
| 192 | − }, | |
| 193 | − ]); | |
| 194 | − await secondSystem.dispose(); | |
| 195 | − }); | |
| 196 | − | |
| 197 | − it("reverts thread messages and turn summaries to a checkpoint", async () => { | |
| 198 | − const stateDir = makeTempDir("t3code-orchestration-revert-"); | |
| 199 | − const firstSystem = await createOrchestrationSystem(stateDir); | |
| 200 | − const engine = firstSystem.engine; | |
| 201 | − const createdAt = new Date().toISOString(); | |
| 202 | − await engine.dispatch({ | |
| 203 | − type: "thread.create", | |
| 204 | − commandId: "cmd-thread-revert", | |
| 205 | − threadId: "thread-revert", | |
| 206 | − projectId: "project-revert", | |
| 207 | − title: "Revert thread", | |
| 208 | − model: "gpt-5-codex", | |
| 209 | − branch: null, | |
| 210 | − worktreePath: null, | |
| 211 | − createdAt, | |
| 212 | − }); | |
| 213 | − await engine.dispatch({ | |
| 214 | − type: "message.send", | |
| 215 | − commandId: "cmd-msg-1-user", | |
| 216 | − threadId: "thread-revert", | |
| 217 | − messageId: "user-1", | |
| 218 | − role: "user", | |
| 219 | − text: "first", | |
| 220 | − streaming: false, | |
| 221 | − createdAt, | |
| 222 | − }); | |
| 223 | − await engine.dispatch({ | |
| 224 | − type: "message.send", | |
| 225 | − commandId: "cmd-msg-1-assistant", | |
| 226 | − threadId: "thread-revert", | |
| 227 | − messageId: "assistant:turn-1", | |
| 228 | − role: "assistant", | |
| 229 | − text: "first-response", | |
| 230 | − streaming: false, | |
| 231 | − createdAt, | |
| 232 | − }); | |
| 233 | − await engine.dispatch({ | |
| 234 | − type: "thread.turnDiff.complete", | |
| 235 | − commandId: "cmd-turn-1-complete", | |
| 236 | − threadId: "thread-revert", | |
| 237 | − turnId: "turn-1", | |
| 238 | − completedAt: createdAt, | |
| 239 | − status: "completed", | |
| 240 | − files: [], | |
| 241 | − assistantMessageId: "assistant:turn-1", | |
| 242 | − checkpointTurnCount: 1, | |
| 243 | − createdAt, | |
| 244 | − }); | |
| 245 | − | |
| 246 | − const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); | |
| 247 | − await engine.dispatch({ | |
| 248 | − type: "message.send", | |
| 249 | − commandId: "cmd-msg-2-user", | |
| 250 | − threadId: "thread-revert", | |
| 251 | − messageId: "user-2", | |
| 252 | − role: "user", | |
| 253 | − text: "second", | |
| 254 | − streaming: false, | |
| 255 | − createdAt: createdAtSecond, | |
| 256 | − }); | |
| 257 | − await engine.dispatch({ | |
| 258 | − type: "message.send", | |
| 259 | − commandId: "cmd-msg-2-assistant", | |
| 260 | − threadId: "thread-revert", | |
| 261 | − messageId: "assistant:turn-2", | |
| 262 | − role: "assistant", | |
| 263 | − text: "second-response", | |
| 264 | − streaming: false, | |
| 265 | − createdAt: createdAtSecond, | |
| 266 | − }); | |
| 267 | − await engine.dispatch({ | |
| 268 | − type: "thread.turnDiff.complete", | |
| 269 | − commandId: "cmd-turn-2-complete", | |
| 270 | − threadId: "thread-revert", | |
| 271 | − turnId: "turn-2", | |
| 272 | − completedAt: createdAtSecond, | |
| 273 | − status: "completed", | |
| 274 | − files: [], | |
| 275 | − assistantMessageId: "assistant:turn-2", | |
| 276 | − checkpointTurnCount: 2, | |
| 277 | − createdAt: createdAtSecond, | |
| 278 | − }); | |
| 279 | − | |
| 280 | − await engine.dispatch({ | |
| 281 | − type: "thread.revert", | |
| 282 | − commandId: "cmd-thread-revert-apply", | |
| 283 | − threadId: "thread-revert", | |
| 284 | − turnCount: 1, | |
| 285 | − messageCount: 2, | |
| 286 | − createdAt: createdAtSecond, | |
| 287 | − }); | |
| 288 | − | |
| 289 | − const thread = engine.getSnapshot().threads.find((entry) => entry.id === "thread-revert"); | |
| 290 | − expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); | |
| 291 | − expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); | |
| 292 | − expect(thread?.latestTurnId).toBe("turn-1"); | |
| 293 | − await firstSystem.dispose(); | |
| 294 | − | |
| 295 | − const secondSystem = await createOrchestrationSystem(stateDir); | |
| 296 | − const restarted = secondSystem.engine; | |
| 297 | − const restartedThread = restarted | |
| 298 | − .getSnapshot() | |
| 299 | − .threads.find((entry) => entry.id === "thread-revert"); | |
| 300 | − expect(restartedThread?.messages.map((message) => message.id)).toEqual([ | |
| 301 | − "user-1", | |
| 302 | − "assistant:turn-1", | |
| 303 | − ]); | |
| 304 | − expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); | |
| 305 | − await secondSystem.dispose(); | |
| 306 | − }); | |
| 307 | − | |
| 308 | − it("allows stop to be called multiple times", async () => { | |
| 309 | − const inMemoryStore: OrchestrationEventRepositoryShape = { | |
| 310 | − append(event) { | |
| 311 | − return Effect.succeed({ ...event, sequence: 1 }); | |
| 312 | − }, | |
| 313 | − readFromSequence() { | |
| 314 | − return Effect.succeed([]); | |
| 315 | − }, | |
| 316 | − readAll() { | |
| 317 | − return Effect.succeed([]); | |
| 318 | − }, | |
| 319 | − }; | |
| 320 | − const engine = new OrchestrationEngine(inMemoryStore); | |
| 321 | − await engine.start(); | |
| 322 | − await engine.stop(); | |
| 323 | − await expect(engine.stop()).resolves.toBeUndefined(); | |
| 324 | − }); | |
| 325 | − | |
| 326 | − it("keeps processing queued commands after a storage failure", async () => { | |
| 327 | − const events: OrchestrationEvent[] = []; | |
| 328 | − let nextSequence = 1; | |
| 329 | − let shouldFailFirstAppend = true; | |
| 330 | − | |
| 331 | − const flakyStore: OrchestrationEventRepositoryShape = { | |
| 332 | − append(event) { | |
| 333 | − if (shouldFailFirstAppend) { | |
| 334 | − shouldFailFirstAppend = false; | |
| 335 | − return Effect.die(new Error("append failed")); | |
| 336 | − } | |
| 337 | − const savedEvent = { | |
| 338 | − ...event, | |
| 339 | − sequence: nextSequence, | |
| 340 | − } satisfies OrchestrationEvent; | |
| 341 | − nextSequence += 1; | |
| 342 | − events.push(savedEvent); | |
| 343 | − return Effect.succeed(savedEvent); | |
| 344 | − }, | |
| 345 | − readFromSequence(sequenceExclusive) { | |
| 346 | − return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); | |
| 347 | − }, | |
| 348 | − readAll() { | |
| 349 | − return Effect.succeed(events); | |
| 350 | − }, | |
| 351 | − }; | |
| 352 | − | |
| 353 | − const engine = new OrchestrationEngine(flakyStore); | |
| 354 | − const createdAt = new Date().toISOString(); | |
| 355 | − await engine.start(); | |
| 356 | − | |
| 357 | − await expect( | |
| 358 | − engine.dispatch({ | |
| 359 | − type: "thread.create", | |
| 360 | − commandId: "cmd-flaky-1", | |
| 361 | − threadId: "thread-flaky-fail", | |
| 362 | − projectId: "project-flaky", | |
| 363 | − title: "flaky-fail", | |
| 364 | − model: "gpt-5-codex", | |
| 365 | − branch: null, | |
| 366 | − worktreePath: null, | |
| 367 | − createdAt, | |
| 368 | − }), | |
| 369 | − ).rejects.toThrow("append failed"); | |
| 370 | − | |
| 371 | − const result = await engine.dispatch({ | |
| 372 | − type: "thread.create", | |
| 373 | − commandId: "cmd-flaky-2", | |
| 374 | − threadId: "thread-flaky-ok", | |
| 375 | − projectId: "project-flaky", | |
| 376 | − title: "flaky-ok", | |
| 377 | − model: "gpt-5-codex", | |
| 378 | − branch: null, | |
| 379 | − worktreePath: null, | |
| 380 | − createdAt, | |
| 381 | − }); | |
| 382 | − | |
| 383 | − expect(result.sequence).toBe(1); | |
| 384 | − expect(engine.getSnapshot().sequence).toBe(1); | |
| 385 | − | |
| 386 | − await engine.stop(); | |
| 387 | − }); | |
| 388 | −}); | |
| @@ -1,23 +0,0 @@ | ||
| 1 | −import { Effect, Layer } from "effect"; | |
| 2 | − | |
| 3 | −import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; | |
| 4 | −import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; | |
| 5 | −import { OrchestrationEngine } from "./engine"; | |
| 6 | −import { OrchestrationEngineService } from "./services"; | |
| 7 | − | |
| 8 | −export const OrchestrationEngineLive = Layer.scoped( | |
| 9 | − OrchestrationEngineService, | |
| 10 | − Effect.acquireRelease( | |
| 11 | − Effect.gen(function* () { | |
| 12 | − const eventRepository = yield* OrchestrationEventRepository; | |
| 13 | − const engine = new OrchestrationEngine(eventRepository); | |
| 14 | − yield* Effect.promise(() => engine.start()); | |
| 15 | − return engine; | |
| 16 | − }), | |
| 17 | − (engine) => Effect.promise(() => engine.stop()), | |
| 18 | − ), | |
| 19 | −); | |
| 20 | − | |
| 21 | −export const OrchestrationLive = OrchestrationEngineLive.pipe( | |
| 22 | − Layer.provide(OrchestrationEventRepositoryLive), | |
| 23 | −); | |
| @@ -1,8 +0,0 @@ | ||
| 1 | −import { Context } from "effect"; | |
| 2 | − | |
| 3 | −import { OrchestrationEngine } from "./engine"; | |
| 4 | − | |
| 5 | −export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< | |
| 6 | − OrchestrationEngineService, | |
| 7 | − OrchestrationEngine | |
| 8 | −>() {} | |
| @@ -36,13 +36,13 @@ import { · 35 unmodified lines | ||
| 36 | 36 | import { TerminalManager } from "./terminalManager"; |
| 37 | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 38 | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | −import type { OrchestrationEngine } from "./orchestration/engine"; | |
| 40 | −import { OrchestrationLive } from "./orchestration/layers"; | |
| 41 | −import { OrchestrationEngineService } from "./orchestration/services"; | |
| 39 | +import { OrchestrationEngineLive } from "./orchestration/Layer"; | |
| 40 | +import { OrchestrationEngineService, type OrchestrationEngineShape } from "./orchestration/Service"; | |
| 42 | 41 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; |
| 43 | 42 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; |
| 44 | 43 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; |
| 45 | 44 | import assert from "node:assert"; |
| 45 | +import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; | |
| 46 | 46 | |
| 47 | 47 | const MIME_TYPES: Record<string, string> = { |
| 48 | 48 | ".html": "text/html; charset=utf-8", |
| @@ -136,7 +136,7 @@ export function createServer(options: ServerOptions) { · 87 unmodified lines | ||
| 136 | 136 | unknown |
| 137 | 137 | > | null = null; |
| 138 | 138 | let projectRepository: ProjectRepositoryShape | null = null; |
| 139 | − let orchestrationEngine: OrchestrationEngine | null = null; | |
| 139 | + let orchestrationEngine: OrchestrationEngineShape | null = null; | |
| 140 | 140 | const clients = new Set<WebSocket>(); |
| 141 | 141 | const logger = createLogger("ws"); |
| 142 | 142 | const logWebSocketEvents = |
| @@ -169,141 +169,157 @@ export function createServer(options: ServerOptions) { · 26 unmodified lines | ||
| 169 | 169 | return effectRuntime; |
| 170 | 170 | }; |
| 171 | 171 | |
| 172 | − function attachOrchestrationSubscriptions() { | |
| 173 | − const orchestrationEngine = getOrchestrationEngine(); | |
| 174 | − | |
| 175 | − unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { | |
| 176 | − const push: WsPush = { | |
| 177 | − type: "push", | |
| 178 | − channel: ORCHESTRATION_WS_CHANNELS.readModel, | |
| 179 | − data: { | |
| 180 | − sequence: snapshot.sequence, | |
| 181 | − snapshot, | |
| 182 | − } satisfies OrchestrationReadModelPush, | |
| 183 | − }; | |
| 184 | − const message = JSON.stringify(push); | |
| 185 | − let recipients = 0; | |
| 186 | − for (const client of clients) { | |
| 187 | − if (client.readyState === client.OPEN) { | |
| 188 | − client.send(message); | |
| 189 | − recipients += 1; | |
| 172 | + async function attachOrchestrationSubscriptions( | |
| 173 | + runtime: ManagedRuntime.ManagedRuntime<ProjectRepository | OrchestrationEngineService, unknown>, | |
| 174 | + orchestrationEngine: OrchestrationEngineShape, | |
| 175 | + ) { | |
| 176 | + unsubscribeReadModel = await runtime.runPromise( | |
| 177 | + orchestrationEngine.subscribeToReadModel((snapshot) => { | |
| 178 | + const push: WsPush = { | |
| 179 | + type: "push", | |
| 180 | + channel: ORCHESTRATION_WS_CHANNELS.readModel, | |
| 181 | + data: { | |
| 182 | + sequence: snapshot.sequence, | |
| 183 | + snapshot, | |
| 184 | + } satisfies OrchestrationReadModelPush, | |
| 185 | + }; | |
| 186 | + const message = JSON.stringify(push); | |
| 187 | + let recipients = 0; | |
| 188 | + for (const client of clients) { | |
| 189 | + if (client.readyState === client.OPEN) { | |
| 190 | + client.send(message); | |
| 191 | + recipients += 1; | |
| 192 | + } | |
| 190 | 193 | } |
| 191 | − } | |
| 192 | − logOutgoingPush(push, recipients); | |
| 193 | − }); | |
| 194 | + logOutgoingPush(push, recipients); | |
| 195 | + }), | |
| 196 | + ); | |
| 194 | 197 | |
| 195 | − unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { | |
| 196 | − const push: WsPush = { | |
| 197 | − type: "push", | |
| 198 | − channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 199 | − data: event, | |
| 200 | − }; | |
| 201 | − const message = JSON.stringify(push); | |
| 202 | − let recipients = 0; | |
| 203 | − for (const client of clients) { | |
| 204 | − if (client.readyState === client.OPEN) { | |
| 205 | − client.send(message); | |
| 206 | − recipients += 1; | |
| 198 | + unsubscribeDomainEvents = await runtime.runPromise( | |
| 199 | + orchestrationEngine.subscribeToDomainEvents((event) => { | |
| 200 | + const push: WsPush = { | |
| 201 | + type: "push", | |
| 202 | + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 203 | + data: event, | |
| 204 | + }; | |
| 205 | + const message = JSON.stringify(push); | |
| 206 | + let recipients = 0; | |
| 207 | + for (const client of clients) { | |
| 208 | + if (client.readyState === client.OPEN) { | |
| 209 | + client.send(message); | |
| 210 | + recipients += 1; | |
| 211 | + } | |
| 207 | 212 | } |
| 208 | − } | |
| 209 | − logOutgoingPush(push, recipients); | |
| 210 | − }); | |
| 213 | + logOutgoingPush(push, recipients); | |
| 214 | + }), | |
| 215 | + ); | |
| 211 | 216 | } |
| 212 | 217 | |
| 213 | 218 | // Forward provider events to all connected WebSocket clients |
| 214 | 219 | providerManager.on("event", (event) => { |
| 215 | 220 | void (async () => { |
| 216 | 221 | const liveOrchestrationEngine = orchestrationEngine; |
| 217 | − if (!liveOrchestrationEngine) { | |
| 222 | + const runtime = effectRuntime; | |
| 223 | + if (!liveOrchestrationEngine || !runtime) { | |
| 218 | 224 | return; |
| 219 | 225 | } |
| 220 | − const snapshot = liveOrchestrationEngine.getSnapshot(); | |
| 226 | + const snapshot = await runtime.runPromise(liveOrchestrationEngine.getSnapshot()); | |
| 221 | 227 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); |
| 222 | 228 | if (!thread) return; |
| 223 | 229 | const now = event.createdAt; |
| 224 | 230 | if (event.method === "turn/started" || event.method === "turn/completed") { |
| 225 | − await liveOrchestrationEngine.dispatch({ | |
| 226 | − type: "thread.session", | |
| 227 | − commandId: crypto.randomUUID(), | |
| 228 | − threadId: thread.id, | |
| 229 | − session: { | |
| 230 | − sessionId: event.sessionId, | |
| 231 | − provider: event.provider, | |
| 232 | − status: event.method === "turn/started" ? "running" : "ready", | |
| 231 | + await runtime.runPromise( | |
| 232 | + liveOrchestrationEngine.dispatch({ | |
| 233 | + type: "thread.session", | |
| 234 | + commandId: crypto.randomUUID(), | |
| 233 | 235 | threadId: thread.id, |
| 234 | − activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, | |
| 235 | − createdAt: thread.session?.createdAt ?? now, | |
| 236 | − updatedAt: now, | |
| 237 | − lastError: null, | |
| 238 | − }, | |
| 239 | − createdAt: now, | |
| 240 | − }); | |
| 236 | + session: { | |
| 237 | + sessionId: event.sessionId, | |
| 238 | + provider: event.provider, | |
| 239 | + status: event.method === "turn/started" ? "running" : "ready", | |
| 240 | + threadId: thread.id, | |
| 241 | + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, | |
| 242 | + createdAt: thread.session?.createdAt ?? now, | |
| 243 | + updatedAt: now, | |
| 244 | + lastError: null, | |
| 245 | + }, | |
| 246 | + createdAt: now, | |
| 247 | + }), | |
| 248 | + ); | |
| 241 | 249 | } |
| 242 | 250 | if (event.textDelta && event.textDelta.length > 0) { |
| 243 | 251 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 244 | − await liveOrchestrationEngine.dispatch({ | |
| 245 | − type: "message.send", | |
| 246 | − commandId: crypto.randomUUID(), | |
| 247 | − threadId: thread.id, | |
| 248 | − messageId: assistantMessageId, | |
| 249 | − role: "assistant", | |
| 250 | − text: event.textDelta, | |
| 251 | − streaming: true, | |
| 252 | − createdAt: now, | |
| 253 | − }); | |
| 252 | + await runtime.runPromise( | |
| 253 | + liveOrchestrationEngine.dispatch({ | |
| 254 | + type: "message.send", | |
| 255 | + commandId: crypto.randomUUID(), | |
| 256 | + threadId: thread.id, | |
| 257 | + messageId: assistantMessageId, | |
| 258 | + role: "assistant", | |
| 259 | + text: event.textDelta, | |
| 260 | + streaming: true, | |
| 261 | + createdAt: now, | |
| 262 | + }), | |
| 263 | + ); | |
| 254 | 264 | } |
| 255 | 265 | if (event.method === "turn/completed") { |
| 256 | 266 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 257 | − await liveOrchestrationEngine.dispatch({ | |
| 258 | − type: "message.send", | |
| 259 | − commandId: crypto.randomUUID(), | |
| 260 | − threadId: thread.id, | |
| 261 | − messageId: assistantMessageId, | |
| 262 | − role: "assistant", | |
| 263 | − text: "", | |
| 264 | − streaming: false, | |
| 265 | − createdAt: now, | |
| 266 | − }); | |
| 267 | + await runtime.runPromise( | |
| 268 | + liveOrchestrationEngine.dispatch({ | |
| 269 | + type: "message.send", | |
| 270 | + commandId: crypto.randomUUID(), | |
| 271 | + threadId: thread.id, | |
| 272 | + messageId: assistantMessageId, | |
| 273 | + role: "assistant", | |
| 274 | + text: "", | |
| 275 | + streaming: false, | |
| 276 | + createdAt: now, | |
| 277 | + }), | |
| 278 | + ); | |
| 267 | 279 | } |
| 268 | 280 | if (event.method === "checkpoint/captured") { |
| 269 | 281 | const payload = asObject(event.payload); |
| 270 | 282 | const turnId = event.turnId ?? asString(payload?.turnId); |
| 271 | 283 | if (turnId) { |
| 272 | − await liveOrchestrationEngine.dispatch({ | |
| 273 | − type: "thread.turnDiff.complete", | |
| 274 | − commandId: crypto.randomUUID(), | |
| 275 | − threadId: thread.id, | |
| 276 | − turnId, | |
| 277 | − completedAt: now, | |
| 278 | − ...(asString(payload?.status) !== null | |
| 279 | − ? { status: asString(payload?.status) ?? undefined } | |
| 280 | − : {}), | |
| 281 | − files: [], | |
| 282 | − assistantMessageId: `assistant:${turnId}`, | |
| 283 | − ...(asNumber(payload?.turnCount) !== null | |
| 284 | − ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } | |
| 285 | − : {}), | |
| 286 | − createdAt: now, | |
| 287 | − }); | |
| 284 | + await runtime.runPromise( | |
| 285 | + liveOrchestrationEngine.dispatch({ | |
| 286 | + type: "thread.turnDiff.complete", | |
| 287 | + commandId: crypto.randomUUID(), | |
| 288 | + threadId: thread.id, | |
| 289 | + turnId, | |
| 290 | + completedAt: now, | |
| 291 | + ...(asString(payload?.status) !== null | |
| 292 | + ? { status: asString(payload?.status) ?? undefined } | |
| 293 | + : {}), | |
| 294 | + files: [], | |
| 295 | + assistantMessageId: `assistant:${turnId}`, | |
| 296 | + ...(asNumber(payload?.turnCount) !== null | |
| 297 | + ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } | |
| 298 | + : {}), | |
| 299 | + createdAt: now, | |
| 300 | + }), | |
| 301 | + ); | |
| 288 | 302 | } |
| 289 | 303 | } |
| 290 | 304 | if (event.kind === "error" && event.message) { |
| 291 | − await liveOrchestrationEngine.dispatch({ | |
| 292 | − type: "thread.session", | |
| 293 | − commandId: crypto.randomUUID(), | |
| 294 | − threadId: thread.id, | |
| 295 | − session: { | |
| 296 | − sessionId: event.sessionId, | |
| 297 | − provider: event.provider, | |
| 298 | − status: "error", | |
| 305 | + await runtime.runPromise( | |
| 306 | + liveOrchestrationEngine.dispatch({ | |
| 307 | + type: "thread.session", | |
| 308 | + commandId: crypto.randomUUID(), | |
| 299 | 309 | threadId: thread.id, |
| 300 | − activeTurnId: event.turnId ?? null, | |
| 301 | − createdAt: thread.session?.createdAt ?? now, | |
| 302 | − updatedAt: now, | |
| 303 | − lastError: event.message, | |
| 304 | − }, | |
| 305 | − createdAt: now, | |
| 306 | − }); | |
| 310 | + session: { | |
| 311 | + sessionId: event.sessionId, | |
| 312 | + provider: event.provider, | |
| 313 | + status: "error", | |
| 314 | + threadId: thread.id, | |
| 315 | + activeTurnId: event.turnId ?? null, | |
| 316 | + createdAt: thread.session?.createdAt ?? now, | |
| 317 | + updatedAt: now, | |
| 318 | + lastError: event.message, | |
| 319 | + }, | |
| 320 | + createdAt: now, | |
| 321 | + }), | |
| 322 | + ); | |
| 307 | 323 | } |
| 308 | 324 | })().catch(() => undefined); |
| 309 | 325 | }); |
| @@ -410,6 +426,7 @@ export function createServer(options: ServerOptions) { · 100 unmodified lines | ||
| 410 | 426 | wss.on("connection", (ws) => { |
| 411 | 427 | clients.add(ws); |
| 412 | 428 | const orchestrationEngine = getOrchestrationEngine(); |
| 429 | + const runtime = getEffectRuntime(); | |
| 413 | 430 | |
| 414 | 431 | // Send welcome message with project info |
| 415 | 432 | const segments = cwd.split(/[/\\]/).filter(Boolean); |
| @@ -423,16 +440,24 @@ export function createServer(options: ServerOptions) { · 7 unmodified lines | ||
| 423 | 440 | logOutgoingPush(welcome, 1); |
| 424 | 441 | ws.send(JSON.stringify(welcome)); |
| 425 | 442 | |
| 426 | − const snapshotPush: WsPush = { | |
| 427 | − type: "push", | |
| 428 | − channel: ORCHESTRATION_WS_CHANNELS.readModel, | |
| 429 | − data: { | |
| 430 | − sequence: orchestrationEngine.getSnapshot().sequence, | |
| 431 | − snapshot: orchestrationEngine.getSnapshot(), | |
| 432 | − } satisfies OrchestrationReadModelPush, | |
| 433 | − }; | |
| 434 | − logOutgoingPush(snapshotPush, 1); | |
| 435 | − ws.send(JSON.stringify(snapshotPush)); | |
| 443 | + void runtime | |
| 444 | + .runPromise(orchestrationEngine.getSnapshot()) | |
| 445 | + .then((snapshot) => { | |
| 446 | + if (ws.readyState !== ws.OPEN) { | |
| 447 | + return; | |
| 448 | + } | |
| 449 | + const snapshotPush: WsPush = { | |
| 450 | + type: "push", | |
| 451 | + channel: ORCHESTRATION_WS_CHANNELS.readModel, | |
| 452 | + data: { | |
| 453 | + sequence: snapshot.sequence, | |
| 454 | + snapshot, | |
| 455 | + } satisfies OrchestrationReadModelPush, | |
| 456 | + }; | |
| 457 | + logOutgoingPush(snapshotPush, 1); | |
| 458 | + ws.send(JSON.stringify(snapshotPush)); | |
| 459 | + }) | |
| 460 | + .catch(() => undefined); | |
| 436 | 461 | |
| 437 | 462 | ws.on("message", (raw) => { |
| 438 | 463 | void handleMessage(ws, raw); |
| @@ -509,32 +534,36 @@ export function createServer(options: ServerOptions) { · 70 unmodified lines | ||
| 509 | 534 | const params = request.params as { sessionId?: string }; |
| 510 | 535 | const sessionId = params.sessionId; |
| 511 | 536 | if (typeof sessionId === "string") { |
| 512 | − const snapshot = orchestrationEngine.getSnapshot(); | |
| 537 | + const snapshot = await effectRuntime.runPromise(orchestrationEngine.getSnapshot()); | |
| 513 | 538 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); |
| 514 | 539 | if (thread) { |
| 515 | 540 | const now = new Date().toISOString(); |
| 516 | − await orchestrationEngine.dispatch({ | |
| 517 | − type: "thread.revert", | |
| 518 | − commandId: crypto.randomUUID(), | |
| 519 | − threadId: thread.id, | |
| 520 | − turnCount: result.turnCount, | |
| 521 | − messageCount: result.messageCount, | |
| 522 | − createdAt: now, | |
| 523 | − }); | |
| 524 | − if (thread.session) { | |
| 525 | − await orchestrationEngine.dispatch({ | |
| 526 | − type: "thread.session", | |
| 541 | + await effectRuntime.runPromise( | |
| 542 | + orchestrationEngine.dispatch({ | |
| 543 | + type: "thread.revert", | |
| 527 | 544 | commandId: crypto.randomUUID(), |
| 528 | 545 | threadId: thread.id, |
| 529 | − session: { | |
| 530 | − ...thread.session, | |
| 531 | − status: "ready", | |
| 532 | − activeTurnId: null, | |
| 533 | − updatedAt: now, | |
| 534 | − lastError: null, | |
| 535 | − }, | |
| 546 | + turnCount: result.turnCount, | |
| 547 | + messageCount: result.messageCount, | |
| 536 | 548 | createdAt: now, |
| 537 | − }); | |
| 549 | + }), | |
| 550 | + ); | |
| 551 | + if (thread.session) { | |
| 552 | + await effectRuntime.runPromise( | |
| 553 | + orchestrationEngine.dispatch({ | |
| 554 | + type: "thread.session", | |
| 555 | + commandId: crypto.randomUUID(), | |
| 556 | + threadId: thread.id, | |
| 557 | + session: { | |
| 558 | + ...thread.session, | |
| 559 | + status: "ready", | |
| 560 | + activeTurnId: null, | |
| 561 | + updatedAt: now, | |
| 562 | + lastError: null, | |
| 563 | + }, | |
| 564 | + createdAt: now, | |
| 565 | + }), | |
| 566 | + ); | |
| 538 | 567 | } |
| 539 | 568 | } |
| 540 | 569 | } |
| @@ -605,16 +634,18 @@ export function createServer(options: ServerOptions) { · 64 unmodified lines | ||
| 605 | 634 | const projects = await effectRuntime.runPromise(projectRepository.list()); |
| 606 | 635 | const project = projects.find((entry) => entry.cwd === params.cwd); |
| 607 | 636 | if (project) { |
| 608 | − await orchestrationEngine.dispatch({ | |
| 609 | − type: "git.readModel.upsert", | |
| 610 | − commandId: crypto.randomUUID(), | |
| 611 | − projectId: project.id, | |
| 612 | − branch: status.branch, | |
| 613 | − hasWorkingTreeChanges: status.hasWorkingTreeChanges, | |
| 614 | − aheadCount: status.aheadCount, | |
| 615 | − behindCount: status.behindCount, | |
| 616 | − createdAt: new Date().toISOString(), | |
| 617 | − }); | |
| 637 | + await effectRuntime.runPromise( | |
| 638 | + orchestrationEngine.dispatch({ | |
| 639 | + type: "git.readModel.upsert", | |
| 640 | + commandId: crypto.randomUUID(), | |
| 641 | + projectId: project.id, | |
| 642 | + branch: status.branch, | |
| 643 | + hasWorkingTreeChanges: status.hasWorkingTreeChanges, | |
| 644 | + aheadCount: status.aheadCount, | |
| 645 | + behindCount: status.behindCount, | |
| 646 | + createdAt: new Date().toISOString(), | |
| 647 | + }), | |
| 648 | + ); | |
| 618 | 649 | } |
| 619 | 650 | return status; |
| 620 | 651 | } |
| @@ -677,19 +708,21 @@ export function createServer(options: ServerOptions) { · 56 unmodified lines | ||
| 677 | 708 | }; |
| 678 | 709 | |
| 679 | 710 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 680 | − return orchestrationEngine.getSnapshot(); | |
| 711 | + return effectRuntime.runPromise(orchestrationEngine.getSnapshot()); | |
| 681 | 712 | |
| 682 | 713 | case ORCHESTRATION_WS_METHODS.dispatchCommand: |
| 683 | − return orchestrationEngine.dispatchUnknown(request.params); | |
| 714 | + return effectRuntime.runPromise(orchestrationEngine.dispatchUnknown(request.params)); | |
| 684 | 715 | |
| 685 | 716 | case ORCHESTRATION_WS_METHODS.replayEvents: |
| 686 | − return orchestrationEngine.replayEvents( | |
| 687 | − Math.max( | |
| 688 | − 0, | |
| 689 | − Math.floor( | |
| 690 | − Number( | |
| 691 | − (request.params as { fromSequenceExclusive?: number } | undefined) | |
| 692 | − ?.fromSequenceExclusive ?? 0, | |
| 717 | + return effectRuntime.runPromise( | |
| 718 | + orchestrationEngine.replayEvents( | |
| 719 | + Math.max( | |
| 720 | + 0, | |
| 721 | + Math.floor( | |
| 722 | + Number( | |
| 723 | + (request.params as { fromSequenceExclusive?: number } | undefined) | |
| 724 | + ?.fromSequenceExclusive ?? 0, | |
| 725 | + ), | |
| 693 | 726 | ), |
| 694 | 727 | ), |
| 695 | 728 | ), |
| @@ -703,11 +736,14 @@ export function createServer(options: ServerOptions) { · 7 unmodified lines | ||
| 703 | 736 | async function createEffectRuntime() { |
| 704 | 737 | const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); |
| 705 | 738 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 706 | − const runtime = ManagedRuntime.make( | |
| 707 | − Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe( | |
| 708 | − Layer.provide(persistenceLayer), | |
| 709 | − ), | |
| 739 | + const orchestrationLayer = Layer.provide( | |
| 740 | + OrchestrationEngineLive, | |
| 741 | + OrchestrationEventRepositoryLive, | |
| 742 | + ); | |
| 743 | + const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive).pipe( | |
| 744 | + Layer.provide(persistenceLayer), | |
| 710 | 745 | ); |
| 746 | + const runtime = ManagedRuntime.make(layer); | |
| 711 | 747 | |
| 712 | 748 | try { |
| 713 | 749 | const [nextOrchestrationEngine, repository] = await Promise.all([ |
| @@ -717,7 +753,7 @@ export function createServer(options: ServerOptions) { · 3 unmodified lines | ||
| 717 | 753 | orchestrationEngine = nextOrchestrationEngine; |
| 718 | 754 | projectRepository = repository; |
| 719 | 755 | await runtime.runPromise(repository.pruneMissing()); |
| 720 | − attachOrchestrationSubscriptions(); | |
| 756 | + await attachOrchestrationSubscriptions(runtime, nextOrchestrationEngine); | |
| 721 | 757 | } catch (error) { |
| 722 | 758 | await runtime.dispose().catch(() => undefined); |
| 723 | 759 | throw error; |