| author | |
| committer | |
| log | e219efa22a4a8a6cc1424af54b04c58be9a8b68d |
| tree | df54987927a3bd242b63e9890e740ca909b1a307 |
| parent | 9c070efface28ef266dc9736f9c1df4e9d61066d |
8 files changed, 959 insertions(+), 890 deletions(-)
apps/server/src/orchestration/Layer.test.ts created+439| ... | @@ -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 | }); | ||
apps/server/src/orchestration/Layer.ts created+297| ... | @@ -0,0 +1,297 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationCommand, | ||
| 3 | OrchestrationEvent, | ||
| 4 | OrchestrationReadModel, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | ||
| 7 | import { | ||
| 8 | Cause, | ||
| 9 | Deferred, | ||
| 10 | Effect, | ||
| 11 | Either, | ||
| 12 | Fiber, | ||
| 13 | Layer, | ||
| 14 | PubSub, | ||
| 15 | Queue, | ||
| 16 | Runtime, | ||
| 17 | Schema, | ||
| 18 | } from "effect"; | ||
| 19 | |||
| 20 | import { createLogger } from "../logger"; | ||
| 21 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; | ||
| 22 | import { createEmptyReadModel, reduceEvent } from "./reducer"; | ||
| 23 | import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; | ||
| 24 | |||
| 25 | type CommandEnvelope = { | ||
| 26 | command: OrchestrationCommand; | ||
| 27 | result: Deferred.Deferred<{ sequence: number }, Error>; | ||
| 28 | }; | ||
| 29 | |||
| 30 | function asError(error: unknown, fallbackMessage: string): Error { | ||
| 31 | return error instanceof Error ? error : new Error(fallbackMessage); | ||
| 32 | } | ||
| 33 | |||
| 34 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { | ||
| 35 | const eventId = crypto.randomUUID(); | ||
| 36 | switch (command.type) { | ||
| 37 | case "thread.create": | ||
| 38 | return { | ||
| 39 | eventId, | ||
| 40 | type: "thread.created", | ||
| 41 | aggregateType: "thread", | ||
| 42 | aggregateId: command.threadId, | ||
| 43 | occurredAt: command.createdAt, | ||
| 44 | commandId: command.commandId, | ||
| 45 | payload: { | ||
| 46 | id: command.threadId, | ||
| 47 | projectId: command.projectId, | ||
| 48 | title: command.title, | ||
| 49 | model: command.model, | ||
| 50 | branch: command.branch, | ||
| 51 | worktreePath: command.worktreePath, | ||
| 52 | createdAt: command.createdAt, | ||
| 53 | updatedAt: command.createdAt, | ||
| 54 | }, | ||
| 55 | }; | ||
| 56 | case "thread.delete": | ||
| 57 | return { | ||
| 58 | eventId, | ||
| 59 | type: "thread.deleted", | ||
| 60 | aggregateType: "thread", | ||
| 61 | aggregateId: command.threadId, | ||
| 62 | occurredAt: command.createdAt, | ||
| 63 | commandId: command.commandId, | ||
| 64 | payload: { | ||
| 65 | id: command.threadId, | ||
| 66 | deletedAt: command.createdAt, | ||
| 67 | }, | ||
| 68 | }; | ||
| 69 | case "thread.meta.update": | ||
| 70 | return { | ||
| 71 | eventId, | ||
| 72 | type: "thread.meta-updated", | ||
| 73 | aggregateType: "thread", | ||
| 74 | aggregateId: command.threadId, | ||
| 75 | occurredAt: command.createdAt, | ||
| 76 | commandId: command.commandId, | ||
| 77 | payload: { | ||
| 78 | threadId: command.threadId, | ||
| 79 | ...(command.title !== undefined ? { title: command.title } : {}), | ||
| 80 | ...(command.model !== undefined ? { model: command.model } : {}), | ||
| 81 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | ||
| 82 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | ||
| 83 | updatedAt: command.createdAt, | ||
| 84 | }, | ||
| 85 | }; | ||
| 86 | case "message.send": | ||
| 87 | return { | ||
| 88 | eventId, | ||
| 89 | type: "message.sent", | ||
| 90 | aggregateType: "thread", | ||
| 91 | aggregateId: command.threadId, | ||
| 92 | occurredAt: command.createdAt, | ||
| 93 | commandId: command.commandId, | ||
| 94 | payload: { | ||
| 95 | id: command.messageId, | ||
| 96 | role: command.role, | ||
| 97 | text: command.text, | ||
| 98 | threadId: command.threadId, | ||
| 99 | createdAt: command.createdAt, | ||
| 100 | streaming: command.streaming === true, | ||
| 101 | }, | ||
| 102 | }; | ||
| 103 | case "thread.session": | ||
| 104 | return { | ||
| 105 | eventId, | ||
| 106 | type: "thread.session-set", | ||
| 107 | aggregateType: "thread", | ||
| 108 | aggregateId: command.threadId, | ||
| 109 | occurredAt: command.createdAt, | ||
| 110 | commandId: command.commandId, | ||
| 111 | payload: { | ||
| 112 | threadId: command.threadId, | ||
| 113 | session: command.session, | ||
| 114 | }, | ||
| 115 | }; | ||
| 116 | case "git.readModel.upsert": | ||
| 117 | return { | ||
| 118 | eventId, | ||
| 119 | type: "git.read-model-upsert", | ||
| 120 | aggregateType: "project", | ||
| 121 | aggregateId: command.projectId, | ||
| 122 | occurredAt: command.createdAt, | ||
| 123 | commandId: command.commandId, | ||
| 124 | payload: { | ||
| 125 | projectId: command.projectId, | ||
| 126 | branch: command.branch, | ||
| 127 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | ||
| 128 | aheadCount: command.aheadCount, | ||
| 129 | behindCount: command.behindCount, | ||
| 130 | updatedAt: command.createdAt, | ||
| 131 | }, | ||
| 132 | }; | ||
| 133 | case "thread.turnDiff.complete": | ||
| 134 | return { | ||
| 135 | eventId, | ||
| 136 | type: "thread.turn-diff-completed", | ||
| 137 | aggregateType: "thread", | ||
| 138 | aggregateId: command.threadId, | ||
| 139 | occurredAt: command.createdAt, | ||
| 140 | commandId: command.commandId, | ||
| 141 | payload: { | ||
| 142 | threadId: command.threadId, | ||
| 143 | turnId: command.turnId, | ||
| 144 | completedAt: command.completedAt, | ||
| 145 | ...(command.status !== undefined ? { status: command.status } : {}), | ||
| 146 | files: command.files, | ||
| 147 | ...(command.assistantMessageId !== undefined | ||
| 148 | ? { assistantMessageId: command.assistantMessageId } | ||
| 149 | : {}), | ||
| 150 | ...(command.checkpointTurnCount !== undefined | ||
| 151 | ? { checkpointTurnCount: command.checkpointTurnCount } | ||
| 152 | : {}), | ||
| 153 | }, | ||
| 154 | }; | ||
| 155 | case "thread.revert": | ||
| 156 | return { | ||
| 157 | eventId, | ||
| 158 | type: "thread.reverted", | ||
| 159 | aggregateType: "thread", | ||
| 160 | aggregateId: command.threadId, | ||
| 161 | occurredAt: command.createdAt, | ||
| 162 | commandId: command.commandId, | ||
| 163 | payload: { | ||
| 164 | threadId: command.threadId, | ||
| 165 | turnCount: command.turnCount, | ||
| 166 | messageCount: command.messageCount, | ||
| 167 | }, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | const decodeUnknownCommand = Schema.decodeUnknownEither(OrchestrationCommandSchema); | ||
| 173 | |||
| 174 | export const makeOrchestrationEngine = Effect.gen(function* () { | ||
| 175 | const logger = createLogger("orchestration"); | ||
| 176 | const eventStore = yield* OrchestrationEventRepository; | ||
| 177 | const runtime = Runtime.defaultRuntime; | ||
| 178 | |||
| 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>(); | ||
| 184 | |||
| 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* () { | ||
| 190 | const eventBase = mapCommandToEvent(envelope.command); | ||
| 191 | const savedEvent = yield* eventStore.append(eventBase); | ||
| 192 | readModel = yield* reduceEvent(readModel, savedEvent); | ||
| 193 | |||
| 194 | const snapshot = readModel; | ||
| 195 | yield* Effect.all([ | ||
| 196 | PubSub.publish(eventPubSub, savedEvent), | ||
| 197 | PubSub.publish(readModelPubSub, snapshot), | ||
| 198 | ]); | ||
| 199 | |||
| 200 | for (const listener of domainEventListeners) { | ||
| 201 | listener(savedEvent); | ||
| 202 | } | ||
| 203 | for (const listener of readModelListeners) { | ||
| 204 | listener(snapshot); | ||
| 205 | } | ||
| 206 | |||
| 207 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | ||
| 208 | }).pipe( | ||
| 209 | Effect.catchAllCause((cause) => | ||
| 210 | Deferred.fail( | ||
| 211 | envelope.result, | ||
| 212 | asError(Cause.squash(cause), "Unknown command processing error"), | ||
| 213 | ).pipe(Effect.asVoid), | ||
| 214 | ), | ||
| 215 | ); | ||
| 216 | |||
| 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); | ||
| 221 | } | ||
| 222 | }); | ||
| 223 | |||
| 224 | yield* bootstrapReadModel; | ||
| 225 | |||
| 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 | }); | ||
| 231 | |||
| 232 | yield* Effect.addFinalizer(() => Fiber.interrupt(workerFiber).pipe(Effect.orDie)); | ||
| 233 | |||
| 234 | const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => | ||
| 235 | Effect.sync((): OrchestrationReadModel => readModel); | ||
| 236 | |||
| 237 | const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => | ||
| 238 | eventStore.readFromSequence(fromSequenceExclusive); | ||
| 239 | |||
| 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"))); | ||
| 246 | |||
| 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 | } | ||
| 258 | } | ||
| 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 | }); | ||
| 267 | |||
| 268 | const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => | ||
| 269 | Effect.sync(() => { | ||
| 270 | readModelListeners.add(callback); | ||
| 271 | return () => { | ||
| 272 | readModelListeners.delete(callback); | ||
| 273 | }; | ||
| 274 | }); | ||
| 275 | |||
| 276 | const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => | ||
| 277 | Effect.sync(() => { | ||
| 278 | domainEventListeners.add(callback); | ||
| 279 | return () => { | ||
| 280 | domainEventListeners.delete(callback); | ||
| 281 | }; | ||
| 282 | }); | ||
| 283 | |||
| 284 | return { | ||
| 285 | getSnapshot, | ||
| 286 | replayEvents, | ||
| 287 | dispatchUnknown, | ||
| 288 | dispatch, | ||
| 289 | subscribeToReadModel, | ||
| 290 | subscribeToDomainEvents, | ||
| 291 | } satisfies OrchestrationEngineShape; | ||
| 292 | }); | ||
| 293 | |||
| 294 | export const OrchestrationEngineLive = Layer.scoped( | ||
| 295 | OrchestrationEngineService, | ||
| 296 | makeOrchestrationEngine, | ||
| 297 | ); | ||
apps/server/src/orchestration/Service.ts created+25| ... | @@ -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 | >() {} | ||
apps/server/src/orchestration/engine.test.ts deleted-388| ... | @@ -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 | }); | ||
apps/server/src/orchestration/engine.ts deleted-309| ... | @@ -1,309 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationCommand, | ||
| 3 | OrchestrationEvent, | ||
| 4 | OrchestrationReadModel, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | ||
| 7 | import { | ||
| 8 | PubSub, | ||
| 9 | Queue, | ||
| 10 | Schema, | ||
| 11 | Stream, | ||
| 12 | Effect, | ||
| 13 | Fiber, | ||
| 14 | Runtime, | ||
| 15 | Either, | ||
| 16 | Deferred, | ||
| 17 | Cause, | ||
| 18 | } from "effect"; | ||
| 19 | |||
| 20 | import { createLogger } from "../logger"; | ||
| 21 | import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; | ||
| 22 | import { createEmptyReadModel, reduceEvent } from "./reducer"; | ||
| 23 | |||
| 24 | type CommandEnvelope = { | ||
| 25 | command: OrchestrationCommand; | ||
| 26 | result: Deferred.Deferred<{ sequence: number }, Error>; | ||
| 27 | }; | ||
| 28 | |||
| 29 | function asError(error: unknown, fallbackMessage: string): Error { | ||
| 30 | return error instanceof Error ? error : new Error(fallbackMessage); | ||
| 31 | } | ||
| 32 | |||
| 33 | function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> { | ||
| 34 | const eventId = crypto.randomUUID(); | ||
| 35 | switch (command.type) { | ||
| 36 | case "thread.create": | ||
| 37 | return { | ||
| 38 | eventId, | ||
| 39 | type: "thread.created", | ||
| 40 | aggregateType: "thread", | ||
| 41 | aggregateId: command.threadId, | ||
| 42 | occurredAt: command.createdAt, | ||
| 43 | commandId: command.commandId, | ||
| 44 | payload: { | ||
| 45 | id: command.threadId, | ||
| 46 | projectId: command.projectId, | ||
| 47 | title: command.title, | ||
| 48 | model: command.model, | ||
| 49 | branch: command.branch, | ||
| 50 | worktreePath: command.worktreePath, | ||
| 51 | createdAt: command.createdAt, | ||
| 52 | updatedAt: command.createdAt, | ||
| 53 | }, | ||
| 54 | }; | ||
| 55 | case "thread.delete": | ||
| 56 | return { | ||
| 57 | eventId, | ||
| 58 | type: "thread.deleted", | ||
| 59 | aggregateType: "thread", | ||
| 60 | aggregateId: command.threadId, | ||
| 61 | occurredAt: command.createdAt, | ||
| 62 | commandId: command.commandId, | ||
| 63 | payload: { | ||
| 64 | id: command.threadId, | ||
| 65 | deletedAt: command.createdAt, | ||
| 66 | }, | ||
| 67 | }; | ||
| 68 | case "thread.meta.update": | ||
| 69 | return { | ||
| 70 | eventId, | ||
| 71 | type: "thread.meta-updated", | ||
| 72 | aggregateType: "thread", | ||
| 73 | aggregateId: command.threadId, | ||
| 74 | occurredAt: command.createdAt, | ||
| 75 | commandId: command.commandId, | ||
| 76 | payload: { | ||
| 77 | threadId: command.threadId, | ||
| 78 | ...(command.title !== undefined ? { title: command.title } : {}), | ||
| 79 | ...(command.model !== undefined ? { model: command.model } : {}), | ||
| 80 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | ||
| 81 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | ||
| 82 | updatedAt: command.createdAt, | ||
| 83 | }, | ||
| 84 | }; | ||
| 85 | case "message.send": | ||
| 86 | return { | ||
| 87 | eventId, | ||
| 88 | type: "message.sent", | ||
| 89 | aggregateType: "thread", | ||
| 90 | aggregateId: command.threadId, | ||
| 91 | occurredAt: command.createdAt, | ||
| 92 | commandId: command.commandId, | ||
| 93 | payload: { | ||
| 94 | id: command.messageId, | ||
| 95 | role: command.role, | ||
| 96 | text: command.text, | ||
| 97 | threadId: command.threadId, | ||
| 98 | createdAt: command.createdAt, | ||
| 99 | streaming: command.streaming === true, | ||
| 100 | }, | ||
| 101 | }; | ||
| 102 | case "thread.session": | ||
| 103 | return { | ||
| 104 | eventId, | ||
| 105 | type: "thread.session-set", | ||
| 106 | aggregateType: "thread", | ||
| 107 | aggregateId: command.threadId, | ||
| 108 | occurredAt: command.createdAt, | ||
| 109 | commandId: command.commandId, | ||
| 110 | payload: { | ||
| 111 | threadId: command.threadId, | ||
| 112 | session: command.session, | ||
| 113 | }, | ||
| 114 | }; | ||
| 115 | case "git.readModel.upsert": | ||
| 116 | return { | ||
| 117 | eventId, | ||
| 118 | type: "git.read-model-upsert", | ||
| 119 | aggregateType: "project", | ||
| 120 | aggregateId: command.projectId, | ||
| 121 | occurredAt: command.createdAt, | ||
| 122 | commandId: command.commandId, | ||
| 123 | payload: { | ||
| 124 | projectId: command.projectId, | ||
| 125 | branch: command.branch, | ||
| 126 | hasWorkingTreeChanges: command.hasWorkingTreeChanges, | ||
| 127 | aheadCount: command.aheadCount, | ||
| 128 | behindCount: command.behindCount, | ||
| 129 | updatedAt: command.createdAt, | ||
| 130 | }, | ||
| 131 | }; | ||
| 132 | case "thread.turnDiff.complete": | ||
| 133 | return { | ||
| 134 | eventId, | ||
| 135 | type: "thread.turn-diff-completed", | ||
| 136 | aggregateType: "thread", | ||
| 137 | aggregateId: command.threadId, | ||
| 138 | occurredAt: command.createdAt, | ||
| 139 | commandId: command.commandId, | ||
| 140 | payload: { | ||
| 141 | threadId: command.threadId, | ||
| 142 | turnId: command.turnId, | ||
| 143 | completedAt: command.completedAt, | ||
| 144 | ...(command.status !== undefined ? { status: command.status } : {}), | ||
| 145 | files: command.files, | ||
| 146 | ...(command.assistantMessageId !== undefined | ||
| 147 | ? { assistantMessageId: command.assistantMessageId } | ||
| 148 | : {}), | ||
| 149 | ...(command.checkpointTurnCount !== undefined | ||
| 150 | ? { checkpointTurnCount: command.checkpointTurnCount } | ||
| 151 | : {}), | ||
| 152 | }, | ||
| 153 | }; | ||
| 154 | case "thread.revert": | ||
| 155 | return { | ||
| 156 | eventId, | ||
| 157 | type: "thread.reverted", | ||
| 158 | aggregateType: "thread", | ||
| 159 | aggregateId: command.threadId, | ||
| 160 | occurredAt: command.createdAt, | ||
| 161 | commandId: command.commandId, | ||
| 162 | payload: { | ||
| 163 | threadId: command.threadId, | ||
| 164 | turnCount: command.turnCount, | ||
| 165 | messageCount: command.messageCount, | ||
| 166 | }, | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | export class OrchestrationEngine { | ||
| 172 | private readonly logger = createLogger("orchestration"); | ||
| 173 | private readonly runtime = Runtime.defaultRuntime; | ||
| 174 | private readonly eventStore: OrchestrationEventRepositoryShape; | ||
| 175 | |||
| 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>(); | ||
| 183 | |||
| 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 | } | ||
| 193 | |||
| 194 | private processEnvelope(envelope: CommandEnvelope): Effect.Effect<void> { | ||
| 195 | return Effect.gen(this, function* () { | ||
| 196 | const eventBase = mapCommandToEvent(envelope.command); | ||
| 197 | const savedEvent = yield* this.eventStore.append(eventBase); | ||
| 198 | this.readModel = yield* reduceEvent(this.readModel, savedEvent); | ||
| 199 | |||
| 200 | const snapshot = this.readModel; | ||
| 201 | yield* Effect.all([ | ||
| 202 | PubSub.publish(this.eventPubSub, savedEvent), | ||
| 203 | PubSub.publish(this.readModelPubSub, snapshot), | ||
| 204 | ]); | ||
| 205 | |||
| 206 | for (const listener of this.domainEventListeners) { | ||
| 207 | listener(savedEvent); | ||
| 208 | } | ||
| 209 | for (const listener of this.readModelListeners) { | ||
| 210 | listener(snapshot); | ||
| 211 | } | ||
| 212 | |||
| 213 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); | ||
| 214 | }).pipe( | ||
| 215 | Effect.catchAllCause((cause) => | ||
| 216 | Deferred.fail( | ||
| 217 | envelope.result, | ||
| 218 | asError(Cause.squash(cause), "Unknown command processing error"), | ||
| 219 | ).pipe(Effect.asVoid), | ||
| 220 | ), | ||
| 221 | ); | ||
| 222 | } | ||
| 223 | |||
| 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; | ||
| 236 | } | ||
| 237 | |||
| 238 | await Runtime.runPromise(this.runtime)(this.bootstrapReadModel()); | ||
| 239 | |||
| 240 | const worker = Stream.fromQueue(this.commandQueue).pipe( | ||
| 241 | Stream.runForEach((envelope) => this.processEnvelope(envelope)), | ||
| 242 | ); | ||
| 243 | |||
| 244 | this.workerFiber = Runtime.runFork(this.runtime)(worker); | ||
| 245 | this.logger.info("orchestration engine started", { | ||
| 246 | sequence: this.readModel.sequence, | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | |||
| 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 | } | ||
| 256 | |||
| 257 | getSnapshot(): OrchestrationReadModel { | ||
| 258 | return this.readModel; | ||
| 259 | } | ||
| 260 | |||
| 261 | async replayEvents(fromSequenceExclusive: number): Promise<OrchestrationEvent[]> { | ||
| 262 | return Runtime.runPromise(this.runtime)( | ||
| 263 | this.eventStore.readFromSequence(fromSequenceExclusive), | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | |||
| 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"); | ||
| 275 | } | ||
| 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 | } | ||
| 284 | |||
| 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); | ||
| 290 | }); | ||
| 291 | return Runtime.runPromise(this.runtime)(program).catch((error) => { | ||
| 292 | throw asError(error, "Queue offer failed"); | ||
| 293 | }); | ||
| 294 | } | ||
| 295 | |||
| 296 | subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void { | ||
| 297 | this.readModelListeners.add(callback); | ||
| 298 | return () => { | ||
| 299 | this.readModelListeners.delete(callback); | ||
| 300 | }; | ||
| 301 | } | ||
| 302 | |||
| 303 | subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void { | ||
| 304 | this.domainEventListeners.add(callback); | ||
| 305 | return () => { | ||
| 306 | this.domainEventListeners.delete(callback); | ||
| 307 | }; | ||
| 308 | } | ||
| 309 | } | ||
apps/server/src/orchestration/layers.ts deleted-23| ... | @@ -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 | ); | ||
apps/server/src/orchestration/services.ts deleted-8| ... | @@ -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 | >() {} | ||
apps/server/src/wsServer.ts+198-162| ... | @@ -36,13 +36,13 @@ import { | ... | @@ -36,13 +36,13 @@ import { |
| 36 | import { TerminalManager } from "./terminalManager"; | 36 | import { TerminalManager } from "./terminalManager"; |
| 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | import type { OrchestrationEngine } from "./orchestration/engine"; | 39 | import { OrchestrationEngineLive } from "./orchestration/Layer"; |
| 40 | import { OrchestrationLive } from "./orchestration/layers"; | 40 | import { OrchestrationEngineService, type OrchestrationEngineShape } from "./orchestration/Service"; |
| 41 | import { OrchestrationEngineService } from "./orchestration/services"; | ||
| 42 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; | 41 | import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; |
| 43 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; | 42 | import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; |
| 44 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; | 43 | import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; |
| 45 | import assert from "node:assert"; | 44 | import assert from "node:assert"; |
| 45 | import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; | ||
| 46 | 46 | ||
| 47 | const MIME_TYPES: Record<string, string> = { | 47 | const MIME_TYPES: Record<string, string> = { |
| 48 | ".html": "text/html; charset=utf-8", | 48 | ".html": "text/html; charset=utf-8", |
| ... | @@ -136,7 +136,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -136,7 +136,7 @@ export function createServer(options: ServerOptions) { |
| 136 | unknown | 136 | unknown |
| 137 | > | null = null; | 137 | > | null = null; |
| 138 | let projectRepository: ProjectRepositoryShape | null = null; | 138 | let projectRepository: ProjectRepositoryShape | null = null; |
| 139 | let orchestrationEngine: OrchestrationEngine | null = null; | 139 | let orchestrationEngine: OrchestrationEngineShape | null = null; |
| 140 | const clients = new Set<WebSocket>(); | 140 | const clients = new Set<WebSocket>(); |
| 141 | const logger = createLogger("ws"); | 141 | const logger = createLogger("ws"); |
| 142 | const logWebSocketEvents = | 142 | const logWebSocketEvents = |
| ... | @@ -169,141 +169,157 @@ export function createServer(options: ServerOptions) { | ... | @@ -169,141 +169,157 @@ export function createServer(options: ServerOptions) { |
| 169 | return effectRuntime; | 169 | return effectRuntime; |
| 170 | }; | 170 | }; |
| 171 | 171 | ||
| 172 | function attachOrchestrationSubscriptions() { | 172 | async function attachOrchestrationSubscriptions( |
| 173 | const orchestrationEngine = getOrchestrationEngine(); | 173 | runtime: ManagedRuntime.ManagedRuntime<ProjectRepository | OrchestrationEngineService, unknown>, |
| 174 | 174 | orchestrationEngine: OrchestrationEngineShape, | |
| 175 | unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { | 175 | ) { |
| 176 | const push: WsPush = { | 176 | unsubscribeReadModel = await runtime.runPromise( |
| 177 | type: "push", | 177 | orchestrationEngine.subscribeToReadModel((snapshot) => { |
| 178 | channel: ORCHESTRATION_WS_CHANNELS.readModel, | 178 | const push: WsPush = { |
| 179 | data: { | 179 | type: "push", |
| 180 | sequence: snapshot.sequence, | 180 | channel: ORCHESTRATION_WS_CHANNELS.readModel, |
| 181 | snapshot, | 181 | data: { |
| 182 | } satisfies OrchestrationReadModelPush, | 182 | sequence: snapshot.sequence, |
| 183 | }; | 183 | snapshot, |
| 184 | const message = JSON.stringify(push); | 184 | } satisfies OrchestrationReadModelPush, |
| 185 | let recipients = 0; | 185 | }; |
| 186 | for (const client of clients) { | 186 | const message = JSON.stringify(push); |
| 187 | if (client.readyState === client.OPEN) { | 187 | let recipients = 0; |
| 188 | client.send(message); | 188 | for (const client of clients) { |
| 189 | recipients += 1; | 189 | if (client.readyState === client.OPEN) { |
| 190 | client.send(message); | ||
| 191 | recipients += 1; | ||
| 192 | } | ||
| 190 | } | 193 | } |
| 191 | } | 194 | logOutgoingPush(push, recipients); |
| 192 | logOutgoingPush(push, recipients); | 195 | }), |
| 193 | }); | 196 | ); |
| 194 | 197 | ||
| 195 | unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { | 198 | unsubscribeDomainEvents = await runtime.runPromise( |
| 196 | const push: WsPush = { | 199 | orchestrationEngine.subscribeToDomainEvents((event) => { |
| 197 | type: "push", | 200 | const push: WsPush = { |
| 198 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | 201 | type: "push", |
| 199 | data: event, | 202 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, |
| 200 | }; | 203 | data: event, |
| 201 | const message = JSON.stringify(push); | 204 | }; |
| 202 | let recipients = 0; | 205 | const message = JSON.stringify(push); |
| 203 | for (const client of clients) { | 206 | let recipients = 0; |
| 204 | if (client.readyState === client.OPEN) { | 207 | for (const client of clients) { |
| 205 | client.send(message); | 208 | if (client.readyState === client.OPEN) { |
| 206 | recipients += 1; | 209 | client.send(message); |
| 210 | recipients += 1; | ||
| 211 | } | ||
| 207 | } | 212 | } |
| 208 | } | 213 | logOutgoingPush(push, recipients); |
| 209 | logOutgoingPush(push, recipients); | 214 | }), |
| 210 | }); | 215 | ); |
| 211 | } | 216 | } |
| 212 | 217 | ||
| 213 | // Forward provider events to all connected WebSocket clients | 218 | // Forward provider events to all connected WebSocket clients |
| 214 | providerManager.on("event", (event) => { | 219 | providerManager.on("event", (event) => { |
| 215 | void (async () => { | 220 | void (async () => { |
| 216 | const liveOrchestrationEngine = orchestrationEngine; | 221 | const liveOrchestrationEngine = orchestrationEngine; |
| 217 | if (!liveOrchestrationEngine) { | 222 | const runtime = effectRuntime; |
| 223 | if (!liveOrchestrationEngine || !runtime) { | ||
| 218 | return; | 224 | return; |
| 219 | } | 225 | } |
| 220 | const snapshot = liveOrchestrationEngine.getSnapshot(); | 226 | const snapshot = await runtime.runPromise(liveOrchestrationEngine.getSnapshot()); |
| 221 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); | 227 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); |
| 222 | if (!thread) return; | 228 | if (!thread) return; |
| 223 | const now = event.createdAt; | 229 | const now = event.createdAt; |
| 224 | if (event.method === "turn/started" || event.method === "turn/completed") { | 230 | if (event.method === "turn/started" || event.method === "turn/completed") { |
| 225 | await liveOrchestrationEngine.dispatch({ | 231 | await runtime.runPromise( |
| 226 | type: "thread.session", | 232 | liveOrchestrationEngine.dispatch({ |
| 227 | commandId: crypto.randomUUID(), | 233 | type: "thread.session", |
| 228 | threadId: thread.id, | 234 | commandId: crypto.randomUUID(), |
| 229 | session: { | ||
| 230 | sessionId: event.sessionId, | ||
| 231 | provider: event.provider, | ||
| 232 | status: event.method === "turn/started" ? "running" : "ready", | ||
| 233 | threadId: thread.id, | 235 | threadId: thread.id, |
| 234 | activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, | 236 | session: { |
| 235 | createdAt: thread.session?.createdAt ?? now, | 237 | sessionId: event.sessionId, |
| 236 | updatedAt: now, | 238 | provider: event.provider, |
| 237 | lastError: null, | 239 | status: event.method === "turn/started" ? "running" : "ready", |
| 238 | }, | 240 | threadId: thread.id, |
| 239 | createdAt: now, | 241 | activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, |
| 240 | }); | 242 | createdAt: thread.session?.createdAt ?? now, |
| 243 | updatedAt: now, | ||
| 244 | lastError: null, | ||
| 245 | }, | ||
| 246 | createdAt: now, | ||
| 247 | }), | ||
| 248 | ); | ||
| 241 | } | 249 | } |
| 242 | if (event.textDelta && event.textDelta.length > 0) { | 250 | if (event.textDelta && event.textDelta.length > 0) { |
| 243 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; | 251 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 244 | await liveOrchestrationEngine.dispatch({ | 252 | await runtime.runPromise( |
| 245 | type: "message.send", | 253 | liveOrchestrationEngine.dispatch({ |
| 246 | commandId: crypto.randomUUID(), | 254 | type: "message.send", |
| 247 | threadId: thread.id, | 255 | commandId: crypto.randomUUID(), |
| 248 | messageId: assistantMessageId, | 256 | threadId: thread.id, |
| 249 | role: "assistant", | 257 | messageId: assistantMessageId, |
| 250 | text: event.textDelta, | 258 | role: "assistant", |
| 251 | streaming: true, | 259 | text: event.textDelta, |
| 252 | createdAt: now, | 260 | streaming: true, |
| 253 | }); | 261 | createdAt: now, |
| 262 | }), | ||
| 263 | ); | ||
| 254 | } | 264 | } |
| 255 | if (event.method === "turn/completed") { | 265 | if (event.method === "turn/completed") { |
| 256 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; | 266 | const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; |
| 257 | await liveOrchestrationEngine.dispatch({ | 267 | await runtime.runPromise( |
| 258 | type: "message.send", | 268 | liveOrchestrationEngine.dispatch({ |
| 259 | commandId: crypto.randomUUID(), | 269 | type: "message.send", |
| 260 | threadId: thread.id, | 270 | commandId: crypto.randomUUID(), |
| 261 | messageId: assistantMessageId, | 271 | threadId: thread.id, |
| 262 | role: "assistant", | 272 | messageId: assistantMessageId, |
| 263 | text: "", | 273 | role: "assistant", |
| 264 | streaming: false, | 274 | text: "", |
| 265 | createdAt: now, | 275 | streaming: false, |
| 266 | }); | 276 | createdAt: now, |
| 277 | }), | ||
| 278 | ); | ||
| 267 | } | 279 | } |
| 268 | if (event.method === "checkpoint/captured") { | 280 | if (event.method === "checkpoint/captured") { |
| 269 | const payload = asObject(event.payload); | 281 | const payload = asObject(event.payload); |
| 270 | const turnId = event.turnId ?? asString(payload?.turnId); | 282 | const turnId = event.turnId ?? asString(payload?.turnId); |
| 271 | if (turnId) { | 283 | if (turnId) { |
| 272 | await liveOrchestrationEngine.dispatch({ | 284 | await runtime.runPromise( |
| 273 | type: "thread.turnDiff.complete", | 285 | liveOrchestrationEngine.dispatch({ |
| 274 | commandId: crypto.randomUUID(), | 286 | type: "thread.turnDiff.complete", |
| 275 | threadId: thread.id, | 287 | commandId: crypto.randomUUID(), |
| 276 | turnId, | 288 | threadId: thread.id, |
| 277 | completedAt: now, | 289 | turnId, |
| 278 | ...(asString(payload?.status) !== null | 290 | completedAt: now, |
| 279 | ? { status: asString(payload?.status) ?? undefined } | 291 | ...(asString(payload?.status) !== null |
| 280 | : {}), | 292 | ? { status: asString(payload?.status) ?? undefined } |
| 281 | files: [], | 293 | : {}), |
| 282 | assistantMessageId: `assistant:${turnId}`, | 294 | files: [], |
| 283 | ...(asNumber(payload?.turnCount) !== null | 295 | assistantMessageId: `assistant:${turnId}`, |
| 284 | ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } | 296 | ...(asNumber(payload?.turnCount) !== null |
| 285 | : {}), | 297 | ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } |
| 286 | createdAt: now, | 298 | : {}), |
| 287 | }); | 299 | createdAt: now, |
| 300 | }), | ||
| 301 | ); | ||
| 288 | } | 302 | } |
| 289 | } | 303 | } |
| 290 | if (event.kind === "error" && event.message) { | 304 | if (event.kind === "error" && event.message) { |
| 291 | await liveOrchestrationEngine.dispatch({ | 305 | await runtime.runPromise( |
| 292 | type: "thread.session", | 306 | liveOrchestrationEngine.dispatch({ |
| 293 | commandId: crypto.randomUUID(), | 307 | type: "thread.session", |
| 294 | threadId: thread.id, | 308 | commandId: crypto.randomUUID(), |
| 295 | session: { | ||
| 296 | sessionId: event.sessionId, | ||
| 297 | provider: event.provider, | ||
| 298 | status: "error", | ||
| 299 | threadId: thread.id, | 309 | threadId: thread.id, |
| 300 | activeTurnId: event.turnId ?? null, | 310 | session: { |
| 301 | createdAt: thread.session?.createdAt ?? now, | 311 | sessionId: event.sessionId, |
| 302 | updatedAt: now, | 312 | provider: event.provider, |
| 303 | lastError: event.message, | 313 | status: "error", |
| 304 | }, | 314 | threadId: thread.id, |
| 305 | createdAt: now, | 315 | activeTurnId: event.turnId ?? null, |
| 306 | }); | 316 | createdAt: thread.session?.createdAt ?? now, |
| 317 | updatedAt: now, | ||
| 318 | lastError: event.message, | ||
| 319 | }, | ||
| 320 | createdAt: now, | ||
| 321 | }), | ||
| 322 | ); | ||
| 307 | } | 323 | } |
| 308 | })().catch(() => undefined); | 324 | })().catch(() => undefined); |
| 309 | }); | 325 | }); |
| ... | @@ -410,6 +426,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -410,6 +426,7 @@ export function createServer(options: ServerOptions) { |
| 410 | wss.on("connection", (ws) => { | 426 | wss.on("connection", (ws) => { |
| 411 | clients.add(ws); | 427 | clients.add(ws); |
| 412 | const orchestrationEngine = getOrchestrationEngine(); | 428 | const orchestrationEngine = getOrchestrationEngine(); |
| 429 | const runtime = getEffectRuntime(); | ||
| 413 | 430 | ||
| 414 | // Send welcome message with project info | 431 | // Send welcome message with project info |
| 415 | const segments = cwd.split(/[/\\]/).filter(Boolean); | 432 | const segments = cwd.split(/[/\\]/).filter(Boolean); |
| ... | @@ -423,16 +440,24 @@ export function createServer(options: ServerOptions) { | ... | @@ -423,16 +440,24 @@ export function createServer(options: ServerOptions) { |
| 423 | logOutgoingPush(welcome, 1); | 440 | logOutgoingPush(welcome, 1); |
| 424 | ws.send(JSON.stringify(welcome)); | 441 | ws.send(JSON.stringify(welcome)); |
| 425 | 442 | ||
| 426 | const snapshotPush: WsPush = { | 443 | void runtime |
| 427 | type: "push", | 444 | .runPromise(orchestrationEngine.getSnapshot()) |
| 428 | channel: ORCHESTRATION_WS_CHANNELS.readModel, | 445 | .then((snapshot) => { |
| 429 | data: { | 446 | if (ws.readyState !== ws.OPEN) { |
| 430 | sequence: orchestrationEngine.getSnapshot().sequence, | 447 | return; |
| 431 | snapshot: orchestrationEngine.getSnapshot(), | 448 | } |
| 432 | } satisfies OrchestrationReadModelPush, | 449 | const snapshotPush: WsPush = { |
| 433 | }; | 450 | type: "push", |
| 434 | logOutgoingPush(snapshotPush, 1); | 451 | channel: ORCHESTRATION_WS_CHANNELS.readModel, |
| 435 | ws.send(JSON.stringify(snapshotPush)); | 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 | ws.on("message", (raw) => { | 462 | ws.on("message", (raw) => { |
| 438 | void handleMessage(ws, raw); | 463 | void handleMessage(ws, raw); |
| ... | @@ -509,32 +534,36 @@ export function createServer(options: ServerOptions) { | ... | @@ -509,32 +534,36 @@ export function createServer(options: ServerOptions) { |
| 509 | const params = request.params as { sessionId?: string }; | 534 | const params = request.params as { sessionId?: string }; |
| 510 | const sessionId = params.sessionId; | 535 | const sessionId = params.sessionId; |
| 511 | if (typeof sessionId === "string") { | 536 | if (typeof sessionId === "string") { |
| 512 | const snapshot = orchestrationEngine.getSnapshot(); | 537 | const snapshot = await effectRuntime.runPromise(orchestrationEngine.getSnapshot()); |
| 513 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); | 538 | const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); |
| 514 | if (thread) { | 539 | if (thread) { |
| 515 | const now = new Date().toISOString(); | 540 | const now = new Date().toISOString(); |
| 516 | await orchestrationEngine.dispatch({ | 541 | await effectRuntime.runPromise( |
| 517 | type: "thread.revert", | 542 | orchestrationEngine.dispatch({ |
| 518 | commandId: crypto.randomUUID(), | 543 | type: "thread.revert", |
| 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", | ||
| 527 | commandId: crypto.randomUUID(), | 544 | commandId: crypto.randomUUID(), |
| 528 | threadId: thread.id, | 545 | threadId: thread.id, |
| 529 | session: { | 546 | turnCount: result.turnCount, |
| 530 | ...thread.session, | 547 | messageCount: result.messageCount, |
| 531 | status: "ready", | ||
| 532 | activeTurnId: null, | ||
| 533 | updatedAt: now, | ||
| 534 | lastError: null, | ||
| 535 | }, | ||
| 536 | createdAt: now, | 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) { | ... | @@ -605,16 +634,18 @@ export function createServer(options: ServerOptions) { |
| 605 | const projects = await effectRuntime.runPromise(projectRepository.list()); | 634 | const projects = await effectRuntime.runPromise(projectRepository.list()); |
| 606 | const project = projects.find((entry) => entry.cwd === params.cwd); | 635 | const project = projects.find((entry) => entry.cwd === params.cwd); |
| 607 | if (project) { | 636 | if (project) { |
| 608 | await orchestrationEngine.dispatch({ | 637 | await effectRuntime.runPromise( |
| 609 | type: "git.readModel.upsert", | 638 | orchestrationEngine.dispatch({ |
| 610 | commandId: crypto.randomUUID(), | 639 | type: "git.readModel.upsert", |
| 611 | projectId: project.id, | 640 | commandId: crypto.randomUUID(), |
| 612 | branch: status.branch, | 641 | projectId: project.id, |
| 613 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, | 642 | branch: status.branch, |
| 614 | aheadCount: status.aheadCount, | 643 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, |
| 615 | behindCount: status.behindCount, | 644 | aheadCount: status.aheadCount, |
| 616 | createdAt: new Date().toISOString(), | 645 | behindCount: status.behindCount, |
| 617 | }); | 646 | createdAt: new Date().toISOString(), |
| 647 | }), | ||
| 648 | ); | ||
| 618 | } | 649 | } |
| 619 | return status; | 650 | return status; |
| 620 | } | 651 | } |
| ... | @@ -677,19 +708,21 @@ export function createServer(options: ServerOptions) { | ... | @@ -677,19 +708,21 @@ export function createServer(options: ServerOptions) { |
| 677 | }; | 708 | }; |
| 678 | 709 | ||
| 679 | case ORCHESTRATION_WS_METHODS.getSnapshot: | 710 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 680 | return orchestrationEngine.getSnapshot(); | 711 | return effectRuntime.runPromise(orchestrationEngine.getSnapshot()); |
| 681 | 712 | ||
| 682 | case ORCHESTRATION_WS_METHODS.dispatchCommand: | 713 | case ORCHESTRATION_WS_METHODS.dispatchCommand: |
| 683 | return orchestrationEngine.dispatchUnknown(request.params); | 714 | return effectRuntime.runPromise(orchestrationEngine.dispatchUnknown(request.params)); |
| 684 | 715 | ||
| 685 | case ORCHESTRATION_WS_METHODS.replayEvents: | 716 | case ORCHESTRATION_WS_METHODS.replayEvents: |
| 686 | return orchestrationEngine.replayEvents( | 717 | return effectRuntime.runPromise( |
| 687 | Math.max( | 718 | orchestrationEngine.replayEvents( |
| 688 | 0, | 719 | Math.max( |
| 689 | Math.floor( | 720 | 0, |
| 690 | Number( | 721 | Math.floor( |
| 691 | (request.params as { fromSequenceExclusive?: number } | undefined) | 722 | Number( |
| 692 | ?.fromSequenceExclusive ?? 0, | 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) { | ... | @@ -703,11 +736,14 @@ export function createServer(options: ServerOptions) { |
| 703 | async function createEffectRuntime() { | 736 | async function createEffectRuntime() { |
| 704 | const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); | 737 | const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); |
| 705 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | 738 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); |
| 706 | const runtime = ManagedRuntime.make( | 739 | const orchestrationLayer = Layer.provide( |
| 707 | Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe( | 740 | OrchestrationEngineLive, |
| 708 | Layer.provide(persistenceLayer), | 741 | OrchestrationEventRepositoryLive, |
| 709 | ), | 742 | ); |
| 743 | const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive).pipe( | ||
| 744 | Layer.provide(persistenceLayer), | ||
| 710 | ); | 745 | ); |
| 746 | const runtime = ManagedRuntime.make(layer); | ||
| 711 | 747 | ||
| 712 | try { | 748 | try { |
| 713 | const [nextOrchestrationEngine, repository] = await Promise.all([ | 749 | const [nextOrchestrationEngine, repository] = await Promise.all([ |
| ... | @@ -717,7 +753,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -717,7 +753,7 @@ export function createServer(options: ServerOptions) { |
| 717 | orchestrationEngine = nextOrchestrationEngine; | 753 | orchestrationEngine = nextOrchestrationEngine; |
| 718 | projectRepository = repository; | 754 | projectRepository = repository; |
| 719 | await runtime.runPromise(repository.pruneMissing()); | 755 | await runtime.runPromise(repository.pruneMissing()); |
| 720 | attachOrchestrationSubscriptions(); | 756 | await attachOrchestrationSubscriptions(runtime, nextOrchestrationEngine); |
| 721 | } catch (error) { | 757 | } catch (error) { |
| 722 | await runtime.dispose().catch(() => undefined); | 758 | await runtime.dispose().catch(() => undefined); |
| 723 | throw error; | 759 | throw error; |