authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-05-04 21:56:01+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-04 13:56:01-07:00
logaca0fa4e5b0458ab640c2f479da0a1587c125e4a
tree4767f813988ab797fc14eca12d7a11f214dab3ed
parent0576820755fe12e32e50f03259035c863913b29f
signature Signed by PGP key B5690EEEBB952194

reduce startup time by ~47% and memory usage by up to 150-300MB (#2204)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

30 files changed, 1850 insertions(+), 458 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+9
...@@ -86,10 +86,13 @@ describe("CheckpointDiffQueryLive", () => {...@@ -86,10 +86,13 @@ describe("CheckpointDiffQueryLive", () => {
86 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),86 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
87 Layer.provideMerge(87 Layer.provideMerge(
88 Layer.succeed(ProjectionSnapshotQuery, {88 Layer.succeed(ProjectionSnapshotQuery, {
89 getCommandReadModel: () =>
90 Effect.die("CheckpointDiffQuery should not request the command read model"),
89 getSnapshot: () =>91 getSnapshot: () =>
90 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),92 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
91 getShellSnapshot: () =>93 getShellSnapshot: () =>
92 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),94 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
95 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
93 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),96 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
94 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),97 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
95 getProjectShellById: () => Effect.succeed(Option.none()),98 getProjectShellById: () => Effect.succeed(Option.none()),
...@@ -163,10 +166,13 @@ describe("CheckpointDiffQueryLive", () => {...@@ -163,10 +166,13 @@ describe("CheckpointDiffQueryLive", () => {
163 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),166 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
164 Layer.provideMerge(167 Layer.provideMerge(
165 Layer.succeed(ProjectionSnapshotQuery, {168 Layer.succeed(ProjectionSnapshotQuery, {
169 getCommandReadModel: () =>
170 Effect.die("CheckpointDiffQuery should not request the command read model"),
166 getSnapshot: () =>171 getSnapshot: () =>
167 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),172 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
168 getShellSnapshot: () =>173 getShellSnapshot: () =>
169 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),174 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
175 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
170 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),176 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
171 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),177 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
172 getProjectShellById: () => Effect.succeed(Option.none()),178 getProjectShellById: () => Effect.succeed(Option.none()),
...@@ -208,10 +214,13 @@ describe("CheckpointDiffQueryLive", () => {...@@ -208,10 +214,13 @@ describe("CheckpointDiffQueryLive", () => {
208 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),214 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
209 Layer.provideMerge(215 Layer.provideMerge(
210 Layer.succeed(ProjectionSnapshotQuery, {216 Layer.succeed(ProjectionSnapshotQuery, {
217 getCommandReadModel: () =>
218 Effect.die("CheckpointDiffQuery should not request the command read model"),
211 getSnapshot: () =>219 getSnapshot: () =>
212 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),220 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
213 getShellSnapshot: () =>221 getShellSnapshot: () =>
214 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),222 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
223 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
215 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),224 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
216 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),225 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
217 getProjectShellById: () => Effect.succeed(Option.none()),226 getProjectShellById: () => Effect.succeed(Option.none()),
apps/server/src/cli.test.ts+2-3
...@@ -17,7 +17,6 @@ import { Command } from "effect/unstable/cli";...@@ -17,7 +17,6 @@ import { Command } from "effect/unstable/cli";
1717
18import { cli } from "./cli.ts";18import { cli } from "./cli.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
21import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";20import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";21import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {22import {
...@@ -316,8 +315,8 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {...@@ -316,8 +315,8 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
316 "--base-dir",315 "--base-dir",
317 baseDir,316 baseDir,
318 ]);317 ]);
319 const orchestrationEngine = yield* OrchestrationEngineService;318 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
320 const readModel = yield* orchestrationEngine.getReadModel();319 const readModel = yield* projectionSnapshotQuery.getSnapshot();
321 const addedProject = readModel.projects.find(320 const addedProject = readModel.projects.find(
322 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,321 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
323 );322 );
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+30-12
...@@ -41,6 +41,7 @@ import {...@@ -41,6 +41,7 @@ import {
41 type OrchestrationEngineShape,41 type OrchestrationEngineShape,
42} from "../Services/OrchestrationEngine.ts";42} from "../Services/OrchestrationEngine.ts";
43import { CheckpointReactor } from "../Services/CheckpointReactor.ts";43import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
44import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
44import {45import {
45 ProviderService,46 ProviderService,
46 type ProviderServiceShape,47 type ProviderServiceShape,
...@@ -132,7 +133,14 @@ function createProviderServiceHarness(...@@ -132,7 +133,14 @@ function createProviderServiceHarness(
132}133}
133134
134async function waitForThread(135async function waitForThread(
135 engine: OrchestrationEngineShape,136 readModel: () => Promise<{
137 readonly threads: ReadonlyArray<{
138 readonly id: ThreadId;
139 readonly latestTurn: { readonly turnId: string } | null;
140 readonly checkpoints: ReadonlyArray<{ readonly checkpointTurnCount: number }>;
141 readonly activities: ReadonlyArray<{ readonly kind: string }>;
142 }>;
143 }>,
136 predicate: (thread: {144 predicate: (thread: {
137 latestTurn: { turnId: string } | null;145 latestTurn: { turnId: string } | null;
138 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;146 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
...@@ -146,8 +154,8 @@ async function waitForThread(...@@ -146,8 +154,8 @@ async function waitForThread(
146 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;154 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
147 activities: ReadonlyArray<{ kind: string }>;155 activities: ReadonlyArray<{ kind: string }>;
148 }> => {156 }> => {
149 const readModel = await Effect.runPromise(engine.getReadModel());157 const snapshot = await readModel();
150 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));158 const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
151 if (thread && predicate(thread)) {159 if (thread && predicate(thread)) {
152 return thread;160 return thread;
153 }161 }
...@@ -231,7 +239,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000)...@@ -231,7 +239,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000)
231239
232describe("CheckpointReactor", () => {240describe("CheckpointReactor", () => {
233 let runtime: ManagedRuntime.ManagedRuntime<241 let runtime: ManagedRuntime.ManagedRuntime<
234 OrchestrationEngineService | CheckpointReactor | CheckpointStore,242 OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery,
235 unknown243 unknown
236 > | null = null;244 > | null = null;
237 let scope: Scope.Closeable | null = null;245 let scope: Scope.Closeable | null = null;
...@@ -279,6 +287,10 @@ describe("CheckpointReactor", () => {...@@ -279,6 +287,10 @@ describe("CheckpointReactor", () => {
279 Layer.provide(RepositoryIdentityResolverLive),287 Layer.provide(RepositoryIdentityResolverLive),
280 Layer.provide(SqlitePersistenceMemory),288 Layer.provide(SqlitePersistenceMemory),
281 );289 );
290 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
291 Layer.provide(RepositoryIdentityResolverLive),
292 Layer.provide(SqlitePersistenceMemory),
293 );
282294
283 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {295 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
284 prefix: "t3-checkpoint-reactor-test-",296 prefix: "t3-checkpoint-reactor-test-",
...@@ -304,6 +316,7 @@ describe("CheckpointReactor", () => {...@@ -304,6 +316,7 @@ describe("CheckpointReactor", () => {
304316
305 const layer = CheckpointReactorLive.pipe(317 const layer = CheckpointReactorLive.pipe(
306 Layer.provideMerge(orchestrationLayer),318 Layer.provideMerge(orchestrationLayer),
319 Layer.provideMerge(projectionSnapshotLayer),
307 Layer.provideMerge(RuntimeReceiptBusLive),320 Layer.provideMerge(RuntimeReceiptBusLive),
308 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),321 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
309 Layer.provideMerge(vcsStatusBroadcasterLayer),322 Layer.provideMerge(vcsStatusBroadcasterLayer),
...@@ -322,6 +335,7 @@ describe("CheckpointReactor", () => {...@@ -322,6 +335,7 @@ describe("CheckpointReactor", () => {
322335
323 runtime = ManagedRuntime.make(layer);336 runtime = ManagedRuntime.make(layer);
324 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));337 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
338 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
325 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));339 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
326 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));340 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
327 scope = await Effect.runPromise(Scope.make("sequential"));341 scope = await Effect.runPromise(Scope.make("sequential"));
...@@ -387,6 +401,7 @@ describe("CheckpointReactor", () => {...@@ -387,6 +401,7 @@ describe("CheckpointReactor", () => {
387401
388 return {402 return {
389 engine,403 engine,
404 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
390 provider,405 provider,
391 cwd,406 cwd,
392 drain,407 drain,
...@@ -443,7 +458,7 @@ describe("CheckpointReactor", () => {...@@ -443,7 +458,7 @@ describe("CheckpointReactor", () => {
443458
444 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");459 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
445 const thread = await waitForThread(460 const thread = await waitForThread(
446 harness.engine,461 harness.readModel,
447 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,462 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,
448 );463 );
449 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);464 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
...@@ -541,7 +556,7 @@ describe("CheckpointReactor", () => {...@@ -541,7 +556,7 @@ describe("CheckpointReactor", () => {
541 });556 });
542557
543 await harness.drain();558 await harness.drain();
544 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());559 const midReadModel = await harness.readModel();
545 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));560 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
546 expect(midThread?.checkpoints).toHaveLength(0);561 expect(midThread?.checkpoints).toHaveLength(0);
547562
...@@ -557,7 +572,7 @@ describe("CheckpointReactor", () => {...@@ -557,7 +572,7 @@ describe("CheckpointReactor", () => {
557 });572 });
558573
559 const thread = await waitForThread(574 const thread = await waitForThread(
560 harness.engine,575 harness.readModel,
561 (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1,576 (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1,
562 );577 );
563 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);578 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
...@@ -614,7 +629,7 @@ describe("CheckpointReactor", () => {...@@ -614,7 +629,7 @@ describe("CheckpointReactor", () => {
614629
615 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");630 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
616 const thread = await waitForThread(631 const thread = await waitForThread(
617 harness.engine,632 harness.readModel,
618 (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1,633 (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1,
619 );634 );
620635
...@@ -659,7 +674,7 @@ describe("CheckpointReactor", () => {...@@ -659,7 +674,7 @@ describe("CheckpointReactor", () => {
659674
660 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");675 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
661 const thread = await waitForThread(676 const thread = await waitForThread(
662 harness.engine,677 harness.readModel,
663 (entry) =>678 (entry) =>
664 entry.checkpoints.length === 1 &&679 entry.checkpoints.length === 1 &&
665 entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),680 entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
...@@ -794,7 +809,7 @@ describe("CheckpointReactor", () => {...@@ -794,7 +809,7 @@ describe("CheckpointReactor", () => {
794 });809 });
795810
796 await harness.drain();811 await harness.drain();
797 const readModel = await Effect.runPromise(harness.engine.getReadModel());812 const readModel = await harness.readModel();
798 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));813 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
799 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(814 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(
800 false,815 false,
...@@ -923,7 +938,10 @@ describe("CheckpointReactor", () => {...@@ -923,7 +938,10 @@ describe("CheckpointReactor", () => {
923 );938 );
924939
925 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");940 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
926 const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1);941 const thread = await waitForThread(
942 harness.readModel,
943 (entry) => entry.checkpoints.length === 1,
944 );
927945
928 expect(thread.latestTurn?.turnId).toBe("turn-1");946 expect(thread.latestTurn?.turnId).toBe("turn-1");
929 expect(thread.checkpoints).toHaveLength(1);947 expect(thread.checkpoints).toHaveLength(1);
...@@ -1105,7 +1123,7 @@ describe("CheckpointReactor", () => {...@@ -1105,7 +1123,7 @@ describe("CheckpointReactor", () => {
1105 }),1123 }),
1106 );1124 );
11071125
1108 const thread = await waitForThread(harness.engine, (entry) =>1126 const thread = await waitForThread(harness.readModel, (entry) =>
1109 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),1127 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),
1110 );1128 );
11111129
apps/server/src/orchestration/Layers/CheckpointReactor.ts+32-34
...@@ -20,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts...@@ -20,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
20import { ProviderService } from "../../provider/Services/ProviderService.ts";20import { ProviderService } from "../../provider/Services/ProviderService.ts";
21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
23import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";24import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
24import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";25import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
25import type { OrchestrationDispatchError } from "../Errors.ts";26import type { OrchestrationDispatchError } from "../Errors.ts";
...@@ -66,6 +67,7 @@ const serverCommandId = (tag: string): CommandId =>...@@ -66,6 +67,7 @@ const serverCommandId = (tag: string): CommandId =>
6667
67const make = Effect.gen(function* () {68const make = Effect.gen(function* () {
68 const orchestrationEngine = yield* OrchestrationEngineService;69 const orchestrationEngine = yield* OrchestrationEngineService;
70 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
69 const providerService = yield* ProviderService;71 const providerService = yield* ProviderService;
70 const checkpointStore = yield* CheckpointStore;72 const checkpointStore = yield* CheckpointStore;
71 const receiptBus = yield* RuntimeReceiptBus;73 const receiptBus = yield* RuntimeReceiptBus;
...@@ -124,29 +126,26 @@ const make = Effect.gen(function* () {...@@ -124,29 +126,26 @@ const make = Effect.gen(function* () {
124 const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* (126 const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* (
125 threadId: ThreadId,127 threadId: ThreadId,
126 ): Effect.fn.Return<Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>> {128 ): Effect.fn.Return<Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>> {
127 const readModel = yield* orchestrationEngine.getReadModel();
128 const thread = readModel.threads.find((entry) => entry.id === threadId);
129
130 const sessions = yield* providerService.listSessions();129 const sessions = yield* providerService.listSessions();
130 const session = sessions.find((entry) => entry.threadId === threadId);
131 return session?.cwd
132 ? Option.some({ threadId: session.threadId, cwd: session.cwd })
133 : Option.none();
134 });
131135
132 const findSessionWithCwd = (136 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
133 session: (typeof sessions)[number] | undefined,137 return yield* projectionSnapshotQuery
134 ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => {138 .getThreadDetailById(threadId)
135 if (!session?.cwd) {139 .pipe(Effect.map(Option.getOrUndefined));
136 return Option.none();140 });
137 }
138 return Option.some({ threadId: session.threadId, cwd: session.cwd });
139 };
140
141 if (thread) {
142 const projectedSession = sessions.find((session) => session.threadId === thread.id);
143 const fromProjected = findSessionWithCwd(projectedSession);
144 if (Option.isSome(fromProjected)) {
145 return fromProjected;
146 }
147 }
148141
149 return Option.none();142 const resolveThreadProjects = Effect.fn("resolveThreadProjects")(function* (
143 projectId: ProjectId,
144 ) {
145 const project = yield* projectionSnapshotQuery
146 .getProjectShellById(projectId)
147 .pipe(Effect.map(Option.getOrUndefined));
148 return project ? [project] : [];
150 });149 });
151150
152 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);151 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
...@@ -331,8 +330,7 @@ const make = Effect.gen(function* () {...@@ -331,8 +330,7 @@ const make = Effect.gen(function* () {
331 return;330 return;
332 }331 }
333332
334 const readModel = yield* orchestrationEngine.getReadModel();333 const thread = yield* resolveThreadDetail(event.threadId);
335 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
336 if (!thread) {334 if (!thread) {
337 return;335 return;
338 }336 }
...@@ -353,10 +351,11 @@ const make = Effect.gen(function* () {...@@ -353,10 +351,11 @@ const make = Effect.gen(function* () {
353 return;351 return;
354 }352 }
355353
354 const projects = yield* resolveThreadProjects(thread.projectId);
356 const checkpointCwd = yield* resolveCheckpointCwd({355 const checkpointCwd = yield* resolveCheckpointCwd({
357 threadId: thread.id,356 threadId: thread.id,
358 thread,357 thread,
359 projects: readModel.projects,358 projects,
360 preferSessionRuntime: true,359 preferSessionRuntime: true,
361 });360 });
362 if (!checkpointCwd) {361 if (!checkpointCwd) {
...@@ -407,8 +406,7 @@ const make = Effect.gen(function* () {...@@ -407,8 +406,7 @@ const make = Effect.gen(function* () {
407 return;406 return;
408 }407 }
409408
410 const readModel = yield* orchestrationEngine.getReadModel();409 const thread = yield* resolveThreadDetail(threadId);
411 const thread = readModel.threads.find((entry) => entry.id === threadId);
412 if (!thread) {410 if (!thread) {
413 yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", {411 yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", {
414 threadId,412 threadId,
...@@ -429,10 +427,11 @@ const make = Effect.gen(function* () {...@@ -429,10 +427,11 @@ const make = Effect.gen(function* () {
429 return;427 return;
430 }428 }
431429
430 const projects = yield* resolveThreadProjects(thread.projectId);
432 const checkpointCwd = yield* resolveCheckpointCwd({431 const checkpointCwd = yield* resolveCheckpointCwd({
433 threadId,432 threadId,
434 thread,433 thread,
435 projects: readModel.projects,434 projects,
436 preferSessionRuntime: true,435 preferSessionRuntime: true,
437 });436 });
438 if (!checkpointCwd) {437 if (!checkpointCwd) {
...@@ -458,16 +457,16 @@ const make = Effect.gen(function* () {...@@ -458,16 +457,16 @@ const make = Effect.gen(function* () {
458 return;457 return;
459 }458 }
460459
461 const readModel = yield* orchestrationEngine.getReadModel();460 const thread = yield* resolveThreadDetail(event.threadId);
462 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
463 if (!thread) {461 if (!thread) {
464 return;462 return;
465 }463 }
466464
465 const projects = yield* resolveThreadProjects(thread.projectId);
467 const checkpointCwd = yield* resolveCheckpointCwd({466 const checkpointCwd = yield* resolveCheckpointCwd({
468 threadId: thread.id,467 threadId: thread.id,
469 thread,468 thread,
470 projects: readModel.projects,469 projects,
471 preferSessionRuntime: false,470 preferSessionRuntime: false,
472 });471 });
473 if (!checkpointCwd) {472 if (!checkpointCwd) {
...@@ -540,16 +539,16 @@ const make = Effect.gen(function* () {...@@ -540,16 +539,16 @@ const make = Effect.gen(function* () {
540 }539 }
541540
542 const threadId = event.payload.threadId;541 const threadId = event.payload.threadId;
543 const readModel = yield* orchestrationEngine.getReadModel();542 const thread = yield* resolveThreadDetail(threadId);
544 const thread = readModel.threads.find((entry) => entry.id === threadId);
545 if (!thread) {543 if (!thread) {
546 return;544 return;
547 }545 }
548546
547 const projects = yield* resolveThreadProjects(thread.projectId);
549 const checkpointCwd = yield* resolveCheckpointCwd({548 const checkpointCwd = yield* resolveCheckpointCwd({
550 threadId,549 threadId,
551 thread,550 thread,
552 projects: readModel.projects,551 projects,
553 preferSessionRuntime: false,552 preferSessionRuntime: false,
554 });553 });
555 if (!checkpointCwd) {554 if (!checkpointCwd) {
...@@ -587,8 +586,7 @@ const make = Effect.gen(function* () {...@@ -587,8 +586,7 @@ const make = Effect.gen(function* () {
587 ) {586 ) {
588 const now = new Date().toISOString();587 const now = new Date().toISOString();
589588
590 const readModel = yield* orchestrationEngine.getReadModel();589 const thread = yield* resolveThreadDetail(event.payload.threadId);
591 const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
592 if (!thread) {590 if (!thread) {
593 yield* appendRevertFailureActivity({591 yield* appendRevertFailureActivity({
594 threadId: event.payload.threadId,592 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+82-43
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 TurnId,8 TurnId,
9 type OrchestrationEvent,9 type OrchestrationEvent,
10 ProviderInstanceId,10 ProviderInstanceId,
11 type OrchestrationReadModel,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";13import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
13import { describe, expect, it } from "vitest";14import { describe, expect, it } from "vitest";
...@@ -42,9 +43,13 @@ async function createOrchestrationSystem() {...@@ -42,9 +43,13 @@ async function createOrchestrationSystem() {
42 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {43 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
43 prefix: "t3-orchestration-engine-test-",44 prefix: "t3-orchestration-engine-test-",
44 });45 });
45 const orchestrationLayer = OrchestrationEngineLive.pipe(46 const orchestrationLayer = Layer.mergeAll(
46 Layer.provide(OrchestrationProjectionSnapshotQueryLive),47 OrchestrationEngineLive.pipe(
47 Layer.provide(OrchestrationProjectionPipelineLive),48 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
49 Layer.provide(OrchestrationProjectionPipelineLive),
50 ),
51 OrchestrationProjectionSnapshotQueryLive,
52 ).pipe(
48 Layer.provide(OrchestrationEventStoreLive),53 Layer.provide(OrchestrationEventStoreLive),
49 Layer.provide(OrchestrationCommandReceiptRepositoryLive),54 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
50 Layer.provide(RepositoryIdentityResolverLive),55 Layer.provide(RepositoryIdentityResolverLive),
...@@ -54,8 +59,10 @@ async function createOrchestrationSystem() {...@@ -54,8 +59,10 @@ async function createOrchestrationSystem() {
54 );59 );
55 const runtime = ManagedRuntime.make(orchestrationLayer);60 const runtime = ManagedRuntime.make(orchestrationLayer);
56 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));61 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
62 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
57 return {63 return {
58 engine,64 engine,
65 readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()),
59 run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect),66 run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect),
60 dispose: () => runtime.dispose(),67 dispose: () => runtime.dispose(),
61 };68 };
...@@ -77,15 +84,18 @@ const hasMetricSnapshot = (...@@ -77,15 +84,18 @@ const hasMetricSnapshot = (
77 );84 );
7885
79describe("OrchestrationEngine", () => {86describe("OrchestrationEngine", () => {
80 it("bootstraps the in-memory read model from persisted projections", async () => {87 it("bootstraps command handling from persisted projections without reading the full snapshot", async () => {
81 const failOnHistoricalReplayStore: OrchestrationEventStoreShape = {88 let nextSequence = 8;
82 append: () =>89 const eventStore: OrchestrationEventStoreShape = {
83 Effect.fail(90 append: (event) =>
84 new PersistenceSqlError({91 Effect.sync(() => {
85 operation: "test.append",92 const savedEvent = {
86 detail: "append should not be called during bootstrap",93 ...event,
87 }),94 sequence: nextSequence,
88 ),95 } as OrchestrationEvent;
96 nextSequence += 1;
97 return savedEvent;
98 }),
89 readFromSequence: () => Stream.empty,99 readFromSequence: () => Stream.empty,
90 readAll: () =>100 readAll: () =>
91 Stream.fail(101 Stream.fail(
...@@ -140,11 +150,27 @@ describe("OrchestrationEngine", () => {...@@ -140,11 +150,27 @@ describe("OrchestrationEngine", () => {
140 },150 },
141 ],151 ],
142 };152 };
153 const commandReadModel = {
154 ...projectionSnapshot,
155 threads: projectionSnapshot.threads.map((thread) => ({
156 ...thread,
157 messages: [],
158 proposedPlans: [],
159 activities: [],
160 checkpoints: [],
161 })),
162 };
163 let fullSnapshotReadCount = 0;
143164
144 const layer = OrchestrationEngineLive.pipe(165 const layer = OrchestrationEngineLive.pipe(
145 Layer.provide(166 Layer.provide(
146 Layer.succeed(ProjectionSnapshotQuery, {167 Layer.succeed(ProjectionSnapshotQuery, {
147 getSnapshot: () => Effect.succeed(projectionSnapshot),168 getCommandReadModel: () => Effect.succeed(commandReadModel),
169 getSnapshot: () =>
170 Effect.sync(() => {
171 fullSnapshotReadCount += 1;
172 return projectionSnapshot;
173 }),
148 getShellSnapshot: () =>174 getShellSnapshot: () =>
149 Effect.succeed({175 Effect.succeed({
150 snapshotSequence: projectionSnapshot.snapshotSequence,176 snapshotSequence: projectionSnapshot.snapshotSequence,
...@@ -152,6 +178,8 @@ describe("OrchestrationEngine", () => {...@@ -152,6 +178,8 @@ describe("OrchestrationEngine", () => {
152 threads: [],178 threads: [],
153 updatedAt: projectionSnapshot.updatedAt,179 updatedAt: projectionSnapshot.updatedAt,
154 }),180 }),
181 getSnapshotSequence: () =>
182 Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }),
155 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),183 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
156 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),184 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
157 getProjectShellById: () => Effect.succeed(Option.none()),185 getProjectShellById: () => Effect.succeed(Option.none()),
...@@ -167,7 +195,7 @@ describe("OrchestrationEngine", () => {...@@ -167,7 +195,7 @@ describe("OrchestrationEngine", () => {
167 projectEvent: () => Effect.void,195 projectEvent: () => Effect.void,
168 } satisfies OrchestrationProjectionPipelineShape),196 } satisfies OrchestrationProjectionPipelineShape),
169 ),197 ),
170 Layer.provide(Layer.succeed(OrchestrationEventStore, failOnHistoricalReplayStore)),198 Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),
171 Layer.provide(OrchestrationCommandReceiptRepositoryLive),199 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
172 Layer.provide(SqlitePersistenceMemory),200 Layer.provide(SqlitePersistenceMemory),
173 );201 );
...@@ -175,18 +203,22 @@ describe("OrchestrationEngine", () => {...@@ -175,18 +203,22 @@ describe("OrchestrationEngine", () => {
175 const runtime = ManagedRuntime.make(layer);203 const runtime = ManagedRuntime.make(layer);
176204
177 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));205 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
178 const readModel = await runtime.runPromise(engine.getReadModel());206 const result = await runtime.runPromise(
207 engine.dispatch({
208 type: "thread.meta.update",
209 commandId: CommandId.make("cmd-bootstrap-thread-update"),
210 threadId: ThreadId.make("thread-bootstrap"),
211 title: "Updated Bootstrap Thread",
212 }),
213 );
179214
180 expect(readModel.snapshotSequence).toBe(7);215 expect(result.sequence).toBe(8);
181 expect(readModel.projects).toHaveLength(1);216 expect(fullSnapshotReadCount).toBe(0);
182 expect(readModel.projects[0]?.title).toBe("Bootstrap Project");
183 expect(readModel.threads).toHaveLength(1);
184 expect(readModel.threads[0]?.title).toBe("Bootstrap Thread");
185217
186 await runtime.dispose();218 await runtime.dispose();
187 });219 });
188220
189 it("returns deterministic read models for repeated reads", async () => {221 it("persists deterministic read models for repeated snapshot reads", async () => {
190 const createdAt = now();222 const createdAt = now();
191 const system = await createOrchestrationSystem();223 const system = await createOrchestrationSystem();
192 const { engine } = system;224 const { engine } = system;
...@@ -240,8 +272,8 @@ describe("OrchestrationEngine", () => {...@@ -240,8 +272,8 @@ describe("OrchestrationEngine", () => {
240 }),272 }),
241 );273 );
242274
243 const readModelA = await system.run(engine.getReadModel());275 const readModelA = await system.readModel();
244 const readModelB = await system.run(engine.getReadModel());276 const readModelB = await system.readModel();
245 expect(readModelB).toEqual(readModelA);277 expect(readModelB).toEqual(readModelA);
246 await system.dispose();278 await system.dispose();
247 });279 });
...@@ -292,9 +324,8 @@ describe("OrchestrationEngine", () => {...@@ -292,9 +324,8 @@ describe("OrchestrationEngine", () => {
292 }),324 }),
293 );325 );
294 expect(326 expect(
295 (await system.run(engine.getReadModel())).threads.find(327 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
296 (thread) => thread.id === "thread-archive",328 ?.archivedAt,
297 )?.archivedAt,
298 ).not.toBeNull();329 ).not.toBeNull();
299330
300 await system.run(331 await system.run(
...@@ -305,9 +336,8 @@ describe("OrchestrationEngine", () => {...@@ -305,9 +336,8 @@ describe("OrchestrationEngine", () => {
305 }),336 }),
306 );337 );
307 expect(338 expect(
308 (await system.run(engine.getReadModel())).threads.find(339 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
309 (thread) => thread.id === "thread-archive",340 ?.archivedAt,
310 )?.archivedAt,
311 ).toBeNull();341 ).toBeNull();
312342
313 await system.dispose();343 await system.dispose();
...@@ -573,7 +603,7 @@ describe("OrchestrationEngine", () => {...@@ -573,7 +603,7 @@ describe("OrchestrationEngine", () => {
573 }),603 }),
574 );604 );
575605
576 const thread = (await system.run(engine.getReadModel())).threads.find(606 const thread = (await system.readModel()).threads.find(
577 (entry) => entry.id === "thread-turn-diff",607 (entry) => entry.id === "thread-turn-diff",
578 );608 );
579 expect(thread?.checkpoints).toEqual([609 expect(thread?.checkpoints).toEqual([
...@@ -701,7 +731,15 @@ describe("OrchestrationEngine", () => {...@@ -701,7 +731,15 @@ describe("OrchestrationEngine", () => {
701 );731 );
702732
703 expect(result.sequence).toBe(2);733 expect(result.sequence).toBe(2);
704 expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);734 const eventsAfterRetry = await runtime.runPromise(
735 Stream.runCollect(engine.readEvents(0)).pipe(
736 Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)),
737 ),
738 );
739 expect(eventsAfterRetry.map((event) => event.type)).toEqual([
740 "project.created",
741 "thread.created",
742 ]);
705 await runtime.dispose();743 await runtime.dispose();
706 });744 });
707745
...@@ -801,7 +839,6 @@ describe("OrchestrationEngine", () => {...@@ -801,7 +839,6 @@ describe("OrchestrationEngine", () => {
801 "project.created",839 "project.created",
802 "thread.created",840 "thread.created",
803 ]);841 ]);
804 expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);
805842
806 const retryResult = await runtime.runPromise(engine.dispatch(turnStartCommand));843 const retryResult = await runtime.runPromise(engine.dispatch(turnStartCommand));
807 expect(retryResult.sequence).toBe(4);844 expect(retryResult.sequence).toBe(4);
...@@ -824,7 +861,7 @@ describe("OrchestrationEngine", () => {...@@ -824,7 +861,7 @@ describe("OrchestrationEngine", () => {
824 await runtime.dispose();861 await runtime.dispose();
825 });862 });
826863
827 it("reconciles in-memory state when append persists but projection fails", async () => {864 it("reconciles command state when append persists but projection fails", async () => {
828 type StoredEvent =865 type StoredEvent =
829 ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any>866 ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any>
830 ? A867 ? A
...@@ -856,7 +893,7 @@ describe("OrchestrationEngine", () => {...@@ -856,7 +893,7 @@ describe("OrchestrationEngine", () => {
856 projectEvent: (event) => {893 projectEvent: (event) => {
857 if (894 if (
858 shouldFailProjection &&895 shouldFailProjection &&
859 event.commandId === CommandId.make("cmd-thread-meta-sync-fail")896 event.commandId === CommandId.make("cmd-thread-archive-sync-fail")
860 ) {897 ) {
861 shouldFailProjection = false;898 shouldFailProjection = false;
862 return Effect.fail(899 return Effect.fail(
...@@ -919,20 +956,22 @@ describe("OrchestrationEngine", () => {...@@ -919,20 +956,22 @@ describe("OrchestrationEngine", () => {
919 await expect(956 await expect(
920 runtime.runPromise(957 runtime.runPromise(
921 engine.dispatch({958 engine.dispatch({
922 type: "thread.meta.update",959 type: "thread.archive",
923 commandId: CommandId.make("cmd-thread-meta-sync-fail"),960 commandId: CommandId.make("cmd-thread-archive-sync-fail"),
924 threadId: ThreadId.make("thread-sync"),961 threadId: ThreadId.make("thread-sync"),
925 title: "sync-after-failed-projection",
926 }),962 }),
927 ),963 ),
928 ).rejects.toThrow("projection failed");964 ).rejects.toThrow("projection failed");
929965
930 const readModelAfterFailure = await runtime.runPromise(engine.getReadModel());966 await expect(
931 const updatedThread = readModelAfterFailure.threads.find(967 runtime.runPromise(
932 (thread) => thread.id === "thread-sync",968 engine.dispatch({
933 );969 type: "thread.archive",
934 expect(readModelAfterFailure.snapshotSequence).toBe(3);970 commandId: CommandId.make("cmd-thread-archive-sync-retry"),
935 expect(updatedThread?.title).toBe("sync-after-failed-projection");971 threadId: ThreadId.make("thread-sync"),
972 }),
973 ),
974 ).rejects.toThrow("already archived");
936975
937 await runtime.dispose();976 await runtime.dispose();
938 });977 });
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+25-20
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 OrchestrationCommandInvariantError,34 OrchestrationCommandInvariantError,
35 OrchestrationCommandPreviouslyRejectedError,35 OrchestrationCommandPreviouslyRejectedError,
36 type OrchestrationDispatchError,36 type OrchestrationDispatchError,
37 type OrchestrationProjectorDecodeError,
37} from "../Errors.ts";38} from "../Errors.ts";
38import { decideOrchestrationCommand } from "../decider.ts";39import { decideOrchestrationCommand } from "../decider.ts";
39import { createEmptyReadModel, projectEvent } from "../projector.ts";40import { createEmptyReadModel, projectEvent } from "../projector.ts";
...@@ -77,13 +78,25 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -77,13 +78,25 @@ const makeOrchestrationEngine = Effect.gen(function* () {
77 const projectionPipeline = yield* OrchestrationProjectionPipeline;78 const projectionPipeline = yield* OrchestrationProjectionPipeline;
78 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;79 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
7980
80 let readModel = createEmptyReadModel(new Date().toISOString());81 let commandReadModel = createEmptyReadModel(new Date().toISOString());
8182
82 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();83 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();
83 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();84 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();
8485
86 const projectEventsOntoReadModel = (
87 baseReadModel: OrchestrationReadModel,
88 events: ReadonlyArray<OrchestrationEvent>,
89 ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError, never> =>
90 Effect.gen(function* () {
91 let nextReadModel = baseReadModel;
92 for (const event of events) {
93 nextReadModel = yield* projectEvent(nextReadModel, event);
94 }
95 return nextReadModel;
96 });
97
85 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => {98 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => {
86 const dispatchStartSequence = readModel.snapshotSequence;99 const dispatchStartSequence = commandReadModel.snapshotSequence;
87 const processingStartedAtMs = Date.now();100 const processingStartedAtMs = Date.now();
88 const aggregateRef = commandToAggregateRef(envelope.command);101 const aggregateRef = commandToAggregateRef(envelope.command);
89 const baseMetricAttributes = {102 const baseMetricAttributes = {
...@@ -98,11 +111,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -98,11 +111,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
98 return;111 return;
99 }112 }
100113
101 let nextReadModel = readModel;114 commandReadModel = yield* projectEventsOntoReadModel(commandReadModel, persistedEvents);
102 for (const persistedEvent of persistedEvents) {
103 nextReadModel = yield* projectEvent(nextReadModel, persistedEvent);
104 }
105 readModel = nextReadModel;
106115
107 for (const persistedEvent of persistedEvents) {116 for (const persistedEvent of persistedEvents) {
108 yield* PubSub.publish(eventPubSub, persistedEvent);117 yield* PubSub.publish(eventPubSub, persistedEvent);
...@@ -135,18 +144,18 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -135,18 +144,18 @@ const makeOrchestrationEngine = Effect.gen(function* () {
135144
136 const eventBase = yield* decideOrchestrationCommand({145 const eventBase = yield* decideOrchestrationCommand({
137 command: envelope.command,146 command: envelope.command,
138 readModel,147 readModel: commandReadModel,
139 });148 });
140 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];149 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
141 const committedCommand = yield* sql150 const committedCommand = yield* sql
142 .withTransaction(151 .withTransaction(
143 Effect.gen(function* () {152 Effect.gen(function* () {
144 const committedEvents: OrchestrationEvent[] = [];153 const committedEvents: OrchestrationEvent[] = [];
145 let nextReadModel = readModel;154 let nextCommandReadModel = commandReadModel;
146155
147 for (const nextEvent of eventBases) {156 for (const nextEvent of eventBases) {
148 const savedEvent = yield* eventStore.append(nextEvent);157 const savedEvent = yield* eventStore.append(nextEvent);
149 nextReadModel = yield* projectEvent(nextReadModel, savedEvent);158 nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent);
150 yield* projectionPipeline.projectEvent(savedEvent);159 yield* projectionPipeline.projectEvent(savedEvent);
151 committedEvents.push(savedEvent);160 committedEvents.push(savedEvent);
152 }161 }
...@@ -172,7 +181,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -172,7 +181,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
172 return {181 return {
173 committedEvents,182 committedEvents,
174 lastSequence: lastSavedEvent.sequence,183 lastSequence: lastSavedEvent.sequence,
175 nextReadModel,184 nextCommandReadModel,
176 } as const;185 } as const;
177 }),186 }),
178 )187 )
...@@ -184,7 +193,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -184,7 +193,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
184 ),193 ),
185 );194 );
186195
187 readModel = committedCommand.nextReadModel;196 commandReadModel = committedCommand.nextCommandReadModel;
188 for (const [index, event] of committedCommand.committedEvents.entries()) {197 for (const [index, event] of committedCommand.committedEvents.entries()) {
189 yield* PubSub.publish(eventPubSub, event);198 yield* PubSub.publish(eventPubSub, event);
190 if (index === 0) {199 if (index === 0) {
...@@ -242,7 +251,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -242,7 +251,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
242 ).pipe(251 ).pipe(
243 Effect.annotateLogs({252 Effect.annotateLogs({
244 commandId: envelope.command.commandId,253 commandId: envelope.command.commandId,
245 snapshotSequence: readModel.snapshotSequence,254 snapshotSequence: commandReadModel.snapshotSequence,
246 }),255 }),
247 ),256 ),
248 ),257 ),
...@@ -255,7 +264,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -255,7 +264,7 @@ const makeOrchestrationEngine = Effect.gen(function* () {
255 aggregateKind: aggregateRef.aggregateKind,264 aggregateKind: aggregateRef.aggregateKind,
256 aggregateId: aggregateRef.aggregateId,265 aggregateId: aggregateRef.aggregateId,
257 acceptedAt: new Date().toISOString(),266 acceptedAt: new Date().toISOString(),
258 resultSequence: readModel.snapshotSequence,267 resultSequence: commandReadModel.snapshotSequence,
259 status: "rejected",268 status: "rejected",
260 error: error.message,269 error: error.message,
261 })270 })
...@@ -270,17 +279,14 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -270,17 +279,14 @@ const makeOrchestrationEngine = Effect.gen(function* () {
270 };279 };
271280
272 yield* projectionPipeline.bootstrap;281 yield* projectionPipeline.bootstrap;
273 readModel = yield* projectionSnapshotQuery.getSnapshot();282 commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel();
274283
275 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));284 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
276 yield* Effect.forkScoped(worker);285 yield* Effect.forkScoped(worker);
277 yield* Effect.logDebug("orchestration engine started").pipe(286 yield* Effect.logDebug("orchestration engine started").pipe(
278 Effect.annotateLogs({ sequence: readModel.snapshotSequence }),287 Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),
279 );288 );
280289
281 const getReadModel: OrchestrationEngineShape["getReadModel"] = () =>
282 Effect.sync((): OrchestrationReadModel => readModel);
283
284 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>290 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>
285 eventStore.readFromSequence(fromSequenceExclusive);291 eventStore.readFromSequence(fromSequenceExclusive);
286292
...@@ -292,7 +298,6 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -292,7 +298,6 @@ const makeOrchestrationEngine = Effect.gen(function* () {
292 });298 });
293299
294 return {300 return {
295 getReadModel,
296 readEvents,301 readEvents,
297 dispatch,302 dispatch,
298 // Each access creates a fresh PubSub subscription so that multiple303 // Each access creates a fresh PubSub subscription so that multiple
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+23-1
...@@ -739,9 +739,31 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -739,9 +739,31 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
739 if (Option.isNone(existingRow)) {739 if (Option.isNone(existingRow)) {
740 return;740 return;
741 }741 }
742
743 const retainedTurns = yield* projectionTurnRepository.listByThreadId({
744 threadId: event.payload.threadId,
745 });
746 let latestTurnId: ProjectionTurn["turnId"] = null;
747 let latestCheckpointTurnCount = -1;
748 for (let index = 0; index < retainedTurns.length; index += 1) {
749 const turn = retainedTurns[index];
750 if (
751 !turn ||
752 turn.turnId === null ||
753 turn.checkpointTurnCount === null ||
754 turn.checkpointTurnCount > event.payload.turnCount
755 ) {
756 continue;
757 }
758 if (turn.checkpointTurnCount > latestCheckpointTurnCount) {
759 latestCheckpointTurnCount = turn.checkpointTurnCount;
760 latestTurnId = turn.turnId;
761 }
762 }
763
742 yield* projectionThreadRepository.upsert({764 yield* projectionThreadRepository.upsert({
743 ...existingRow.value,765 ...existingRow.value,
744 latestTurnId: null,766 latestTurnId,
745 updatedAt: event.occurredAt,767 updatedAt: event.occurredAt,
746 });768 });
747 yield* refreshThreadShellSummary(event.payload.threadId);769 yield* refreshThreadShellSummary(event.payload.threadId);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+375
...@@ -12,6 +12,7 @@ import { Effect, Layer } from "effect";...@@ -12,6 +12,7 @@ import { Effect, Layer } from "effect";
12import * as SqlClient from "effect/unstable/sql/SqlClient";12import * as SqlClient from "effect/unstable/sql/SqlClient";
1313
14import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";14import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
15import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
15import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";16import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
16import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";17import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
17import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";18import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
...@@ -1029,4 +1030,378 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -1029,4 +1030,378 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
1029 }1030 }
1030 }),1031 }),
1031 );1032 );
1033
1034 it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () =>
1035 Effect.gen(function* () {
1036 const snapshotQuery = yield* ProjectionSnapshotQuery;
1037 const sql = yield* SqlClient.SqlClient;
1038
1039 yield* sql`DELETE FROM projection_projects`;
1040 yield* sql`DELETE FROM projection_threads`;
1041 yield* sql`DELETE FROM projection_turns`;
1042 yield* sql`DELETE FROM projection_state`;
1043
1044 yield* sql`
1045 INSERT INTO projection_projects (
1046 project_id,
1047 title,
1048 workspace_root,
1049 default_model_selection_json,
1050 scripts_json,
1051 created_at,
1052 updated_at,
1053 deleted_at
1054 )
1055 VALUES (
1056 'project-1',
1057 'Project 1',
1058 '/tmp/project-1',
1059 '{"provider":"codex","model":"gpt-5-codex"}',
1060 '[]',
1061 '2026-04-03T00:00:00.000Z',
1062 '2026-04-03T00:00:01.000Z',
1063 NULL
1064 )
1065 `;
1066
1067 yield* sql`
1068 INSERT INTO projection_threads (
1069 thread_id,
1070 project_id,
1071 title,
1072 model_selection_json,
1073 runtime_mode,
1074 interaction_mode,
1075 branch,
1076 worktree_path,
1077 latest_turn_id,
1078 latest_user_message_at,
1079 pending_approval_count,
1080 pending_user_input_count,
1081 has_actionable_proposed_plan,
1082 created_at,
1083 updated_at,
1084 archived_at,
1085 deleted_at
1086 )
1087 VALUES (
1088 'thread-1',
1089 'project-1',
1090 'Thread 1',
1091 '{"provider":"codex","model":"gpt-5-codex"}',
1092 'full-access',
1093 'default',
1094 NULL,
1095 NULL,
1096 'turn-running',
1097 '2026-04-03T00:00:04.000Z',
1098 0,
1099 0,
1100 0,
1101 '2026-04-03T00:00:02.000Z',
1102 '2026-04-03T00:00:03.000Z',
1103 NULL,
1104 NULL
1105 )
1106 `;
1107
1108 yield* sql`
1109 INSERT INTO projection_turns (
1110 thread_id,
1111 turn_id,
1112 pending_message_id,
1113 source_proposed_plan_thread_id,
1114 source_proposed_plan_id,
1115 assistant_message_id,
1116 state,
1117 requested_at,
1118 started_at,
1119 completed_at,
1120 checkpoint_turn_count,
1121 checkpoint_ref,
1122 checkpoint_status,
1123 checkpoint_files_json
1124 )
1125 VALUES
1126 (
1127 'thread-1',
1128 'turn-running',
1129 'message-user-2',
1130 NULL,
1131 NULL,
1132 NULL,
1133 'running',
1134 '2026-04-03T00:00:30.000Z',
1135 '2026-04-03T00:00:30.000Z',
1136 NULL,
1137 NULL,
1138 NULL,
1139 NULL,
1140 '[]'
1141 ),
1142 (
1143 'thread-1',
1144 'turn-completed',
1145 'message-user-1',
1146 NULL,
1147 NULL,
1148 'message-assistant-1',
1149 'completed',
1150 '2026-04-03T00:00:05.000Z',
1151 '2026-04-03T00:00:06.000Z',
1152 '2026-04-03T00:00:20.000Z',
1153 NULL,
1154 NULL,
1155 NULL,
1156 '[]'
1157 )
1158 `;
1159
1160 yield* sql`
1161 INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
1162 VALUES
1163 (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 3, '2026-04-03T00:00:40.000Z'),
1164 (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 3, '2026-04-03T00:00:40.000Z'),
1165 (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 3, '2026-04-03T00:00:40.000Z'),
1166 (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 3, '2026-04-03T00:00:40.000Z'),
1167 (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 3, '2026-04-03T00:00:40.000Z'),
1168 (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 3, '2026-04-03T00:00:40.000Z'),
1169 (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 3, '2026-04-03T00:00:40.000Z')
1170 `;
1171
1172 const commandReadModel = yield* snapshotQuery.getCommandReadModel();
1173 assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1174 assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running");
1175
1176 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1177 assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1178 assert.equal(shellSnapshot.threads[0]?.latestTurn?.state, "running");
1179
1180 const fullSnapshot = yield* snapshotQuery.getSnapshot();
1181 assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1182 assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "running");
1183 }),
1184 );
1185
1186 it.effect("keeps deleted project and thread tombstones in the command read model", () =>
1187 Effect.gen(function* () {
1188 const snapshotQuery = yield* ProjectionSnapshotQuery;
1189 const sql = yield* SqlClient.SqlClient;
1190
1191 yield* sql`DELETE FROM projection_projects`;
1192 yield* sql`DELETE FROM projection_threads`;
1193 yield* sql`DELETE FROM projection_turns`;
1194 yield* sql`DELETE FROM projection_state`;
1195
1196 yield* sql`
1197 INSERT INTO projection_projects (
1198 project_id,
1199 title,
1200 workspace_root,
1201 default_model_selection_json,
1202 scripts_json,
1203 created_at,
1204 updated_at,
1205 deleted_at
1206 )
1207 VALUES (
1208 'project-deleted',
1209 'Deleted Project',
1210 '/tmp/deleted-project',
1211 '{"provider":"codex","model":"gpt-5-codex"}',
1212 '[]',
1213 '2026-04-05T00:00:00.000Z',
1214 '2026-04-05T00:00:01.000Z',
1215 '2026-04-05T00:00:02.000Z'
1216 )
1217 `;
1218
1219 yield* sql`
1220 INSERT INTO projection_threads (
1221 thread_id,
1222 project_id,
1223 title,
1224 model_selection_json,
1225 runtime_mode,
1226 interaction_mode,
1227 branch,
1228 worktree_path,
1229 latest_turn_id,
1230 latest_user_message_at,
1231 pending_approval_count,
1232 pending_user_input_count,
1233 has_actionable_proposed_plan,
1234 created_at,
1235 updated_at,
1236 archived_at,
1237 deleted_at
1238 )
1239 VALUES (
1240 'thread-deleted',
1241 'project-deleted',
1242 'Deleted Thread',
1243 '{"provider":"codex","model":"gpt-5-codex"}',
1244 'full-access',
1245 'default',
1246 NULL,
1247 NULL,
1248 'turn-deleted',
1249 NULL,
1250 0,
1251 0,
1252 0,
1253 '2026-04-05T00:00:03.000Z',
1254 '2026-04-05T00:00:04.000Z',
1255 NULL,
1256 '2026-04-05T00:00:05.000Z'
1257 )
1258 `;
1259
1260 yield* sql`
1261 INSERT INTO projection_turns (
1262 thread_id,
1263 turn_id,
1264 pending_message_id,
1265 source_proposed_plan_thread_id,
1266 source_proposed_plan_id,
1267 assistant_message_id,
1268 state,
1269 requested_at,
1270 started_at,
1271 completed_at,
1272 checkpoint_turn_count,
1273 checkpoint_ref,
1274 checkpoint_status,
1275 checkpoint_files_json
1276 )
1277 VALUES (
1278 'thread-deleted',
1279 'turn-deleted',
1280 'message-deleted-user',
1281 NULL,
1282 NULL,
1283 'message-deleted-assistant',
1284 'completed',
1285 '2026-04-05T00:00:04.100Z',
1286 '2026-04-05T00:00:04.200Z',
1287 '2026-04-05T00:00:04.300Z',
1288 NULL,
1289 NULL,
1290 NULL,
1291 '[]'
1292 )
1293 `;
1294
1295 const commandReadModel = yield* snapshotQuery.getCommandReadModel();
1296 assert.equal(commandReadModel.projects[0]?.id, asProjectId("project-deleted"));
1297 assert.equal(commandReadModel.projects[0]?.deletedAt, "2026-04-05T00:00:02.000Z");
1298 assert.equal(commandReadModel.threads[0]?.id, ThreadId.make("thread-deleted"));
1299 assert.equal(commandReadModel.threads[0]?.deletedAt, "2026-04-05T00:00:05.000Z");
1300 assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted"));
1301 assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "completed");
1302
1303 const fullSnapshot = yield* snapshotQuery.getSnapshot();
1304 assert.equal(fullSnapshot.threads[0]?.id, ThreadId.make("thread-deleted"));
1305 assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted"));
1306 assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "completed");
1307
1308 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1309 assert.equal(shellSnapshot.projects.length, 0);
1310 assert.equal(shellSnapshot.threads.length, 0);
1311 }),
1312 );
1032});1313});
1314
1315it.effect(
1316 "ProjectionSnapshotQuery dedupes repository identity resolution by workspace root and skips deleted projects for shell snapshots",
1317 () => {
1318 const resolveCalls: string[] = [];
1319 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1320 Layer.provideMerge(
1321 Layer.succeed(RepositoryIdentityResolver, {
1322 resolve: (cwd: string) =>
1323 Effect.sync(() => {
1324 resolveCalls.push(cwd);
1325 return {
1326 canonicalKey: `github.com/acme${cwd}`,
1327 locator: {
1328 source: "git-remote" as const,
1329 remoteName: "origin",
1330 remoteUrl: `https://github.com/acme${cwd}.git`,
1331 },
1332 rootPath: cwd,
1333 };
1334 }),
1335 }),
1336 ),
1337 Layer.provideMerge(SqlitePersistenceMemory),
1338 );
1339
1340 return Effect.gen(function* () {
1341 const snapshotQuery = yield* ProjectionSnapshotQuery;
1342 const sql = yield* SqlClient.SqlClient;
1343
1344 yield* sql`DELETE FROM projection_projects`;
1345 yield* sql`DELETE FROM projection_threads`;
1346 yield* sql`DELETE FROM projection_turns`;
1347 yield* sql`DELETE FROM projection_state`;
1348
1349 yield* sql`
1350 INSERT INTO projection_projects (
1351 project_id,
1352 title,
1353 workspace_root,
1354 default_model_selection_json,
1355 scripts_json,
1356 created_at,
1357 updated_at,
1358 deleted_at
1359 )
1360 VALUES
1361 (
1362 'project-1',
1363 'Shared Project 1',
1364 '/tmp/shared-root',
1365 '{"provider":"codex","model":"gpt-5-codex"}',
1366 '[]',
1367 '2026-04-04T00:00:00.000Z',
1368 '2026-04-04T00:00:01.000Z',
1369 NULL
1370 ),
1371 (
1372 'project-2',
1373 'Shared Project 2',
1374 '/tmp/shared-root',
1375 '{"provider":"codex","model":"gpt-5-codex"}',
1376 '[]',
1377 '2026-04-04T00:00:02.000Z',
1378 '2026-04-04T00:00:03.000Z',
1379 NULL
1380 ),
1381 (
1382 'project-3',
1383 'Deleted Project',
1384 '/tmp/deleted-root',
1385 '{"provider":"codex","model":"gpt-5-codex"}',
1386 '[]',
1387 '2026-04-04T00:00:04.000Z',
1388 '2026-04-04T00:00:05.000Z',
1389 '2026-04-04T00:00:06.000Z'
1390 )
1391 `;
1392
1393 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1394 assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/shared-root"]);
1395 assert.equal(shellSnapshot.projects.length, 2);
1396 assert.equal(shellSnapshot.projects[0]?.repositoryIdentity?.rootPath, "/tmp/shared-root");
1397 assert.equal(shellSnapshot.projects[1]?.repositoryIdentity?.rootPath, "/tmp/shared-root");
1398
1399 resolveCalls.length = 0;
1400
1401 const fullSnapshot = yield* snapshotQuery.getSnapshot();
1402 assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/deleted-root", "/tmp/shared-root"]);
1403 assert.equal(fullSnapshot.projects.length, 3);
1404 assert.equal(fullSnapshot.projects[2]?.repositoryIdentity?.rootPath, "/tmp/deleted-root");
1405 }).pipe(Effect.provide(layer));
1406 },
1407);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+299-43
...@@ -219,6 +219,20 @@ function mapProjectShellRow(...@@ -219,6 +219,20 @@ function mapProjectShellRow(
219 };219 };
220}220}
221221
222function mapProposedPlanRow(
223 row: Schema.Schema.Type<typeof ProjectionThreadProposedPlanDbRowSchema>,
224): OrchestrationProposedPlan {
225 return {
226 id: row.planId,
227 turnId: row.turnId,
228 planMarkdown: row.planMarkdown,
229 implementedAt: row.implementedAt,
230 implementationThreadId: row.implementationThreadId,
231 createdAt: row.createdAt,
232 updatedAt: row.updatedAt,
233 };
234}
235
222function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {236function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
223 return (cause: unknown): ProjectionRepositoryError =>237 return (cause: unknown): ProjectionRepositoryError =>
224 Schema.isSchemaError(cause)238 Schema.isSchemaError(cause)
...@@ -230,6 +244,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -230,6 +244,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
230 const sql = yield* SqlClient.SqlClient;244 const sql = yield* SqlClient.SqlClient;
231 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;245 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
232 const repositoryIdentityResolutionConcurrency = 4;246 const repositoryIdentityResolutionConcurrency = 4;
247 const resolveRepositoryIdentitiesForProjects = Effect.fn(
248 "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects",
249 )(function* (
250 projectRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>>,
251 options?: {
252 readonly includeDeleted?: boolean;
253 },
254 ) {
255 const filteredProjectRows =
256 options?.includeDeleted === true
257 ? projectRows
258 : projectRows.filter((row) => row.deletedAt === null);
259 const uniqueWorkspaceRoots = [...new Set(filteredProjectRows.map((row) => row.workspaceRoot))];
260 const repositoryIdentityByWorkspaceRoot = new Map(
261 yield* Effect.forEach(
262 uniqueWorkspaceRoots,
263 (workspaceRoot) =>
264 repositoryIdentityResolver
265 .resolve(workspaceRoot)
266 .pipe(Effect.map((identity) => [workspaceRoot, identity] as const)),
267 { concurrency: repositoryIdentityResolutionConcurrency },
268 ),
269 );
270
271 return new Map(
272 filteredProjectRows.map((row) => [
273 row.projectId,
274 repositoryIdentityByWorkspaceRoot.get(row.workspaceRoot) ?? null,
275 ]),
276 );
277 });
233278
234 const listProjectRows = SqlSchema.findAll({279 const listProjectRows = SqlSchema.findAll({
235 Request: Schema.Void,280 Request: Schema.Void,
...@@ -335,7 +380,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -335,7 +380,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
335 FROM projection_thread_activities380 FROM projection_thread_activities
336 ORDER BY381 ORDER BY
337 thread_id ASC,382 thread_id ASC,
338 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
339 sequence ASC,383 sequence ASC,
340 created_at ASC,384 created_at ASC,
341 activity_id ASC385 activity_id ASC
...@@ -389,18 +433,46 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -389,18 +433,46 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
389 execute: () =>433 execute: () =>
390 sql`434 sql`
391 SELECT435 SELECT
392 thread_id AS "threadId",436 turns.thread_id AS "threadId",
393 turn_id AS "turnId",437 turns.turn_id AS "turnId",
394 state,438 turns.state,
395 requested_at AS "requestedAt",439 turns.requested_at AS "requestedAt",
396 started_at AS "startedAt",440 turns.started_at AS "startedAt",
397 completed_at AS "completedAt",441 turns.completed_at AS "completedAt",
398 assistant_message_id AS "assistantMessageId",442 turns.assistant_message_id AS "assistantMessageId",
399 source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",443 turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
400 source_proposed_plan_id AS "sourceProposedPlanId"444 turns.source_proposed_plan_id AS "sourceProposedPlanId"
401 FROM projection_turns445 FROM projection_threads threads
402 WHERE turn_id IS NOT NULL446 JOIN projection_turns turns
403 ORDER BY thread_id ASC, requested_at DESC, turn_id DESC447 ON turns.thread_id = threads.thread_id
448 AND turns.turn_id = threads.latest_turn_id
449 WHERE threads.latest_turn_id IS NOT NULL
450 ORDER BY turns.thread_id ASC
451 `,
452 });
453
454 const listActiveLatestTurnRows = SqlSchema.findAll({
455 Request: Schema.Void,
456 Result: ProjectionLatestTurnDbRowSchema,
457 execute: () =>
458 sql`
459 SELECT
460 turns.thread_id AS "threadId",
461 turns.turn_id AS "turnId",
462 turns.state,
463 turns.requested_at AS "requestedAt",
464 turns.started_at AS "startedAt",
465 turns.completed_at AS "completedAt",
466 turns.assistant_message_id AS "assistantMessageId",
467 turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
468 turns.source_proposed_plan_id AS "sourceProposedPlanId"
469 FROM projection_threads threads
470 JOIN projection_turns turns
471 ON turns.thread_id = threads.thread_id
472 AND turns.turn_id = threads.latest_turn_id
473 WHERE threads.deleted_at IS NULL
474 AND threads.latest_turn_id IS NOT NULL
475 ORDER BY turns.thread_id ASC
404 `,476 `,
405 });477 });
406478
...@@ -594,7 +666,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -594,7 +666,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
594 FROM projection_thread_activities666 FROM projection_thread_activities
595 WHERE thread_id = ${threadId}667 WHERE thread_id = ${threadId}
596 ORDER BY668 ORDER BY
597 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
598 sequence ASC,669 sequence ASC,
599 created_at ASC,670 created_at ASC,
600 activity_id ASC671 activity_id ASC
...@@ -892,15 +963,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -892,15 +963,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
892 });963 });
893 }964 }
894965
895 const repositoryIdentities = new Map(966 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
896 yield* Effect.forEach(967 projectRows,
897 projectRows,968 { includeDeleted: true },
898 (row) =>
899 repositoryIdentityResolver
900 .resolve(row.workspaceRoot)
901 .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
902 { concurrency: repositoryIdentityResolutionConcurrency },
903 ),
904 );969 );
905970
906 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({971 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
...@@ -958,6 +1023,199 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -958,6 +1023,199 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
958 }),1023 }),
959 );1024 );
9601025
1026 const getCommandReadModel: ProjectionSnapshotQueryShape["getCommandReadModel"] = () =>
1027 sql
1028 .withTransaction(
1029 Effect.all([
1030 listProjectRows(undefined).pipe(
1031 Effect.mapError(
1032 toPersistenceSqlOrDecodeError(
1033 "ProjectionSnapshotQuery.getCommandReadModel:listProjects:query",
1034 "ProjectionSnapshotQuery.getCommandReadModel:listProjects:decodeRows",
1035 ),
1036 ),
1037 ),
1038 listThreadRows(undefined).pipe(
1039 Effect.mapError(
1040 toPersistenceSqlOrDecodeError(
1041 "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query",
1042 "ProjectionSnapshotQuery.getCommandReadModel:listThreads:decodeRows",
1043 ),
1044 ),
1045 ),
1046 listThreadProposedPlanRows(undefined).pipe(
1047 Effect.mapError(
1048 toPersistenceSqlOrDecodeError(
1049 "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:query",
1050 "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:decodeRows",
1051 ),
1052 ),
1053 ),
1054 listThreadSessionRows(undefined).pipe(
1055 Effect.mapError(
1056 toPersistenceSqlOrDecodeError(
1057 "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:query",
1058 "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:decodeRows",
1059 ),
1060 ),
1061 ),
1062 listLatestTurnRows(undefined).pipe(
1063 Effect.mapError(
1064 toPersistenceSqlOrDecodeError(
1065 "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:query",
1066 "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:decodeRows",
1067 ),
1068 ),
1069 ),
1070 listProjectionStateRows(undefined).pipe(
1071 Effect.mapError(
1072 toPersistenceSqlOrDecodeError(
1073 "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:query",
1074 "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:decodeRows",
1075 ),
1076 ),
1077 ),
1078 ]),
1079 )
1080 .pipe(
1081 Effect.flatMap(
1082 ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) =>
1083 Effect.sync(() => {
1084 let updatedAt: string | null = null;
1085 const projects: OrchestrationProject[] = [];
1086 const threads: OrchestrationThread[] = [];
1087
1088 for (let index = 0; index < projectRows.length; index += 1) {
1089 const row = projectRows[index];
1090 if (!row) {
1091 continue;
1092 }
1093 updatedAt = maxIso(updatedAt, row.updatedAt);
1094 projects.push({
1095 id: row.projectId,
1096 title: row.title,
1097 workspaceRoot: row.workspaceRoot,
1098 defaultModelSelection: row.defaultModelSelection,
1099 scripts: row.scripts,
1100 createdAt: row.createdAt,
1101 updatedAt: row.updatedAt,
1102 deletedAt: row.deletedAt,
1103 });
1104 }
1105 for (let index = 0; index < threadRows.length; index += 1) {
1106 const row = threadRows[index];
1107 if (!row) {
1108 continue;
1109 }
1110 updatedAt = maxIso(updatedAt, row.updatedAt);
1111 }
1112 for (let index = 0; index < proposedPlanRows.length; index += 1) {
1113 const row = proposedPlanRows[index];
1114 if (!row) {
1115 continue;
1116 }
1117 updatedAt = maxIso(updatedAt, row.updatedAt);
1118 }
1119 for (let index = 0; index < sessionRows.length; index += 1) {
1120 const row = sessionRows[index];
1121 if (!row) {
1122 continue;
1123 }
1124 updatedAt = maxIso(updatedAt, row.updatedAt);
1125 }
1126 for (let index = 0; index < latestTurnRows.length; index += 1) {
1127 const row = latestTurnRows[index];
1128 if (!row) {
1129 continue;
1130 }
1131 updatedAt = maxIso(updatedAt, row.requestedAt);
1132 if (row.startedAt !== null) {
1133 updatedAt = maxIso(updatedAt, row.startedAt);
1134 }
1135 if (row.completedAt !== null) {
1136 updatedAt = maxIso(updatedAt, row.completedAt);
1137 }
1138 }
1139 for (let index = 0; index < stateRows.length; index += 1) {
1140 const row = stateRows[index];
1141 if (!row) {
1142 continue;
1143 }
1144 updatedAt = maxIso(updatedAt, row.updatedAt);
1145 }
1146
1147 const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
1148 for (let index = 0; index < latestTurnRows.length; index += 1) {
1149 const row = latestTurnRows[index];
1150 if (!row) {
1151 continue;
1152 }
1153 latestTurnByThread.set(row.threadId, mapLatestTurn(row));
1154 }
1155 const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
1156 const sessionByThread = new Map<string, OrchestrationSession>();
1157
1158 for (let index = 0; index < sessionRows.length; index += 1) {
1159 const row = sessionRows[index];
1160 if (!row) {
1161 continue;
1162 }
1163 sessionByThread.set(row.threadId, mapSessionRow(row));
1164 }
1165
1166 for (let index = 0; index < proposedPlanRows.length; index += 1) {
1167 const row = proposedPlanRows[index];
1168 if (!row) {
1169 continue;
1170 }
1171 const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
1172 threadProposedPlans.push(mapProposedPlanRow(row));
1173 proposedPlansByThread.set(row.threadId, threadProposedPlans);
1174 }
1175
1176 for (let index = 0; index < threadRows.length; index += 1) {
1177 const row = threadRows[index];
1178 if (!row) {
1179 continue;
1180 }
1181 threads.push({
1182 id: row.threadId,
1183 projectId: row.projectId,
1184 title: row.title,
1185 modelSelection: row.modelSelection,
1186 runtimeMode: row.runtimeMode,
1187 interactionMode: row.interactionMode,
1188 branch: row.branch,
1189 worktreePath: row.worktreePath,
1190 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1191 createdAt: row.createdAt,
1192 updatedAt: row.updatedAt,
1193 archivedAt: row.archivedAt,
1194 deletedAt: row.deletedAt,
1195 messages: [],
1196 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
1197 activities: [],
1198 checkpoints: [],
1199 session: sessionByThread.get(row.threadId) ?? null,
1200 });
1201 }
1202
1203 return {
1204 snapshotSequence: computeSnapshotSequence(stateRows),
1205 projects,
1206 threads,
1207 updatedAt: updatedAt ?? new Date(0).toISOString(),
1208 } satisfies OrchestrationReadModel;
1209 }),
1210 ),
1211 Effect.mapError((error) => {
1212 if (isPersistenceError(error)) {
1213 return error;
1214 }
1215 return toPersistenceSqlError("ProjectionSnapshotQuery.getCommandReadModel:query")(error);
1216 }),
1217 );
1218
961 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () =>1219 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () =>
962 sql1220 sql
963 .withTransaction(1221 .withTransaction(
...@@ -986,7 +1244,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -986,7 +1244,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
986 ),1244 ),
987 ),1245 ),
988 ),1246 ),
989 listLatestTurnRows(undefined).pipe(1247 listActiveLatestTurnRows(undefined).pipe(
990 Effect.mapError(1248 Effect.mapError(
991 toPersistenceSqlOrDecodeError(1249 toPersistenceSqlOrDecodeError(
992 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query",1250 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query",
...@@ -1030,16 +1288,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1030,16 +1288,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1030 updatedAt = maxIso(updatedAt, row.updatedAt);1288 updatedAt = maxIso(updatedAt, row.updatedAt);
1031 }1289 }
10321290
1033 const repositoryIdentities = new Map(1291 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows);
1034 yield* Effect.forEach(
1035 projectRows,
1036 (row) =>
1037 repositoryIdentityResolver
1038 .resolve(row.workspaceRoot)
1039 .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
1040 { concurrency: repositoryIdentityResolutionConcurrency },
1041 ),
1042 );
1043 const latestTurnByThread = new Map(1292 const latestTurnByThread = new Map(
1044 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),1293 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
1045 );1294 );
...@@ -1097,6 +1346,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1097,6 +1346,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1097 }),1346 }),
1098 );1347 );
10991348
1349 const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () =>
1350 listProjectionStateRows(undefined).pipe(
1351 Effect.mapError(
1352 toPersistenceSqlOrDecodeError(
1353 "ProjectionSnapshotQuery.getSnapshotSequence:query",
1354 "ProjectionSnapshotQuery.getSnapshotSequence:decodeRows",
1355 ),
1356 ),
1357 Effect.map((stateRows) => ({
1358 snapshotSequence: computeSnapshotSequence(stateRows),
1359 })),
1360 );
1361
1100 const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () =>1362 const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () =>
1101 readProjectionCounts(undefined).pipe(1363 readProjectionCounts(undefined).pipe(
1102 Effect.mapError(1364 Effect.mapError(
...@@ -1376,15 +1638,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1376,15 +1638,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1376 }1638 }
1377 return message;1639 return message;
1378 }),1640 }),
1379 proposedPlans: proposedPlanRows.map((row) => ({1641 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
1380 id: row.planId,
1381 turnId: row.turnId,
1382 planMarkdown: row.planMarkdown,
1383 implementedAt: row.implementedAt,
1384 implementationThreadId: row.implementationThreadId,
1385 createdAt: row.createdAt,
1386 updatedAt: row.updatedAt,
1387 })),
1388 activities: activityRows.map((row) => {1642 activities: activityRows.map((row) => {
1389 const activity = {1643 const activity = {
1390 id: row.activityId,1644 id: row.activityId,
...@@ -1422,8 +1676,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1422,8 +1676,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1422 });1676 });
14231677
1424 return {1678 return {
1679 getCommandReadModel,
1425 getSnapshot,1680 getSnapshot,
1426 getShellSnapshot,1681 getShellSnapshot,
1682 getSnapshotSequence,
1427 getCounts,1683 getCounts,
1428 getActiveProjectByWorkspaceRoot,1684 getActiveProjectByWorkspaceRoot,
1429 getProjectShellById,1685 getProjectShellById,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+32-24
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45} from "./ProviderCommandReactor.ts";45} from "./ProviderCommandReactor.ts";
46import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";46import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
47import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";47import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
48import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
48import * as NodeServices from "@effect/platform-node/NodeServices";49import * as NodeServices from "@effect/platform-node/NodeServices";
49import { ServerSettingsService } from "../../serverSettings.ts";50import { ServerSettingsService } from "../../serverSettings.ts";
50import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";51import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
...@@ -79,7 +80,7 @@ async function waitFor(...@@ -79,7 +80,7 @@ async function waitFor(
7980
80describe("ProviderCommandReactor", () => {81describe("ProviderCommandReactor", () => {
81 let runtime: ManagedRuntime.ManagedRuntime<82 let runtime: ManagedRuntime.ManagedRuntime<
82 OrchestrationEngineService | ProviderCommandReactor,83 OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery,
83 unknown84 unknown
84 > | null = null;85 > | null = null;
85 let scope: Scope.Closeable | null = null;86 let scope: Scope.Closeable | null = null;
...@@ -318,8 +319,13 @@ describe("ProviderCommandReactor", () => {...@@ -318,8 +319,13 @@ describe("ProviderCommandReactor", () => {
318 Layer.provide(RepositoryIdentityResolverLive),319 Layer.provide(RepositoryIdentityResolverLive),
319 Layer.provide(SqlitePersistenceMemory),320 Layer.provide(SqlitePersistenceMemory),
320 );321 );
322 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
323 Layer.provide(RepositoryIdentityResolverLive),
324 Layer.provide(SqlitePersistenceMemory),
325 );
321 const layer = ProviderCommandReactorLive.pipe(326 const layer = ProviderCommandReactorLive.pipe(
322 Layer.provideMerge(orchestrationLayer),327 Layer.provideMerge(orchestrationLayer),
328 Layer.provideMerge(projectionSnapshotLayer),
323 Layer.provideMerge(Layer.succeed(ProviderService, service)),329 Layer.provideMerge(Layer.succeed(ProviderService, service)),
324 Layer.provideMerge(330 Layer.provideMerge(
325 Layer.mock(GitWorkflowService)({331 Layer.mock(GitWorkflowService)({
...@@ -348,6 +354,7 @@ describe("ProviderCommandReactor", () => {...@@ -348,6 +354,7 @@ describe("ProviderCommandReactor", () => {
348 runtime = ManagedRuntime.make(layer);354 runtime = ManagedRuntime.make(layer);
349355
350 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));356 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
357 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
351 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));358 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
352 scope = await Effect.runPromise(Scope.make("sequential"));359 scope = await Effect.runPromise(Scope.make("sequential"));
353 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));360 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
...@@ -382,6 +389,7 @@ describe("ProviderCommandReactor", () => {...@@ -382,6 +389,7 @@ describe("ProviderCommandReactor", () => {
382389
383 return {390 return {
384 engine,391 engine,
392 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
385 startSession,393 startSession,
386 sendTurn,394 sendTurn,
387 interruptTurn,395 interruptTurn,
...@@ -431,7 +439,7 @@ describe("ProviderCommandReactor", () => {...@@ -431,7 +439,7 @@ describe("ProviderCommandReactor", () => {
431 runtimeMode: "approval-required",439 runtimeMode: "approval-required",
432 });440 });
433441
434 const readModel = await Effect.runPromise(harness.engine.getReadModel());442 const readModel = await harness.readModel();
435 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));443 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
436 expect(thread?.session?.threadId).toBe("thread-1");444 expect(thread?.session?.threadId).toBe("thread-1");
437 expect(thread?.session?.runtimeMode).toBe("approval-required");445 expect(thread?.session?.runtimeMode).toBe("approval-required");
...@@ -476,13 +484,13 @@ describe("ProviderCommandReactor", () => {...@@ -476,13 +484,13 @@ describe("ProviderCommandReactor", () => {
476 });484 });
477485
478 await waitFor(async () => {486 await waitFor(async () => {
479 const readModel = await Effect.runPromise(harness.engine.getReadModel());487 const readModel = await harness.readModel();
480 return (488 return (
481 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===489 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===
482 "Generated title"490 "Generated title"
483 );491 );
484 });492 });
485 const readModel = await Effect.runPromise(harness.engine.getReadModel());493 const readModel = await harness.readModel();
486 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));494 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
487 expect(thread?.title).toBe("Generated title");495 expect(thread?.title).toBe("Generated title");
488 });496 });
...@@ -522,7 +530,7 @@ describe("ProviderCommandReactor", () => {...@@ -522,7 +530,7 @@ describe("ProviderCommandReactor", () => {
522 await waitFor(() => harness.sendTurn.mock.calls.length === 1);530 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
523 expect(harness.generateThreadTitle).not.toHaveBeenCalled();531 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
524532
525 const readModel = await Effect.runPromise(harness.engine.getReadModel());533 const readModel = await harness.readModel();
526 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));534 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
527 expect(thread?.title).toBe("Keep this custom title");535 expect(thread?.title).toBe("Keep this custom title");
528 });536 });
...@@ -566,14 +574,14 @@ describe("ProviderCommandReactor", () => {...@@ -566,14 +574,14 @@ describe("ProviderCommandReactor", () => {
566574
567 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);575 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
568 await waitFor(async () => {576 await waitFor(async () => {
569 const readModel = await Effect.runPromise(harness.engine.getReadModel());577 const readModel = await harness.readModel();
570 return (578 return (
571 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===579 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===
572 "Reconnect spinner resume bug"580 "Reconnect spinner resume bug"
573 );581 );
574 });582 });
575583
576 const readModel = await Effect.runPromise(harness.engine.getReadModel());584 const readModel = await harness.readModel();
577 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));585 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
578 expect(thread?.title).toBe("Reconnect spinner resume bug");586 expect(thread?.title).toBe("Reconnect spinner resume bug");
579 });587 });
...@@ -902,7 +910,7 @@ describe("ProviderCommandReactor", () => {...@@ -902,7 +910,7 @@ describe("ProviderCommandReactor", () => {
902 },910 },
903 });911 });
904912
905 const readModel = await Effect.runPromise(harness.engine.getReadModel());913 const readModel = await harness.readModel();
906 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));914 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
907 expect(thread?.session?.providerName).toBe("claudeAgent");915 expect(thread?.session?.providerName).toBe("claudeAgent");
908 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent"));916 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent"));
...@@ -1014,7 +1022,7 @@ describe("ProviderCommandReactor", () => {...@@ -1014,7 +1022,7 @@ describe("ProviderCommandReactor", () => {
1014 resumeCursor: { opaque: "resume-1" },1022 resumeCursor: { opaque: "resume-1" },
1015 });1023 });
10161024
1017 const readModel = await Effect.runPromise(harness.engine.getReadModel());1025 const readModel = await harness.readModel();
1018 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1026 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1019 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));1027 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
1020 });1028 });
...@@ -1205,7 +1213,7 @@ describe("ProviderCommandReactor", () => {...@@ -1205,7 +1213,7 @@ describe("ProviderCommandReactor", () => {
1205 );1213 );
12061214
1207 await waitFor(async () => {1215 await waitFor(async () => {
1208 const readModel = await Effect.runPromise(harness.engine.getReadModel());1216 const readModel = await harness.readModel();
1209 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1217 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1210 return thread?.runtimeMode === "approval-required";1218 return thread?.runtimeMode === "approval-required";
1211 });1219 });
...@@ -1239,7 +1247,7 @@ describe("ProviderCommandReactor", () => {...@@ -1239,7 +1247,7 @@ describe("ProviderCommandReactor", () => {
1239 threadId: ThreadId.make("thread-1"),1247 threadId: ThreadId.make("thread-1"),
1240 });1248 });
12411249
1242 const readModel = await Effect.runPromise(harness.engine.getReadModel());1250 const readModel = await harness.readModel();
1243 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1251 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1244 expect(thread?.session?.threadId).toBe("thread-1");1252 expect(thread?.session?.threadId).toBe("thread-1");
1245 expect(thread?.session?.runtimeMode).toBe("approval-required");1253 expect(thread?.session?.runtimeMode).toBe("approval-required");
...@@ -1342,7 +1350,7 @@ describe("ProviderCommandReactor", () => {...@@ -1342,7 +1350,7 @@ describe("ProviderCommandReactor", () => {
1342 );1350 );
13431351
1344 await waitFor(async () => {1352 await waitFor(async () => {
1345 const readModel = await Effect.runPromise(harness.engine.getReadModel());1353 const readModel = await harness.readModel();
1346 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1354 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1347 return thread?.runtimeMode === "approval-required";1355 return thread?.runtimeMode === "approval-required";
1348 });1356 });
...@@ -1352,7 +1360,7 @@ describe("ProviderCommandReactor", () => {...@@ -1352,7 +1360,7 @@ describe("ProviderCommandReactor", () => {
1352 expect(harness.stopSession.mock.calls.length).toBe(0);1360 expect(harness.stopSession.mock.calls.length).toBe(0);
1353 expect(harness.sendTurn.mock.calls.length).toBe(1);1361 expect(harness.sendTurn.mock.calls.length).toBe(1);
13541362
1355 const readModel = await Effect.runPromise(harness.engine.getReadModel());1363 const readModel = await harness.readModel();
1356 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1364 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1357 expect(thread?.session?.threadId).toBe("thread-1");1365 expect(thread?.session?.threadId).toBe("thread-1");
1358 expect(thread?.session?.runtimeMode).toBe("full-access");1366 expect(thread?.session?.runtimeMode).toBe("full-access");
...@@ -1404,7 +1412,7 @@ describe("ProviderCommandReactor", () => {...@@ -1404,7 +1412,7 @@ describe("ProviderCommandReactor", () => {
1404 );1412 );
14051413
1406 await waitFor(async () => {1414 await waitFor(async () => {
1407 const readModel = await Effect.runPromise(harness.engine.getReadModel());1415 const readModel = await harness.readModel();
1408 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1416 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1409 return (1417 return (
1410 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??1418 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
...@@ -1416,7 +1424,7 @@ describe("ProviderCommandReactor", () => {...@@ -1416,7 +1424,7 @@ describe("ProviderCommandReactor", () => {
1416 expect(harness.sendTurn.mock.calls.length).toBe(1);1424 expect(harness.sendTurn.mock.calls.length).toBe(1);
1417 expect(harness.stopSession.mock.calls.length).toBe(0);1425 expect(harness.stopSession.mock.calls.length).toBe(0);
14181426
1419 const readModel = await Effect.runPromise(harness.engine.getReadModel());1427 const readModel = await harness.readModel();
1420 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1428 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1421 expect(thread?.session?.threadId).toBe("thread-1");1429 expect(thread?.session?.threadId).toBe("thread-1");
1422 expect(thread?.session?.providerName).toBe("codex");1430 expect(thread?.session?.providerName).toBe("codex");
...@@ -1475,7 +1483,7 @@ describe("ProviderCommandReactor", () => {...@@ -1475,7 +1483,7 @@ describe("ProviderCommandReactor", () => {
1475 );1483 );
14761484
1477 await waitFor(async () => {1485 await waitFor(async () => {
1478 const readModel = await Effect.runPromise(harness.engine.getReadModel());1486 const readModel = await harness.readModel();
1479 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1487 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1480 return (1488 return (
1481 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??1489 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
...@@ -1485,7 +1493,7 @@ describe("ProviderCommandReactor", () => {...@@ -1485,7 +1493,7 @@ describe("ProviderCommandReactor", () => {
14851493
1486 expect(harness.startSession.mock.calls.length).toBe(0);1494 expect(harness.startSession.mock.calls.length).toBe(0);
1487 expect(harness.sendTurn.mock.calls.length).toBe(0);1495 expect(harness.sendTurn.mock.calls.length).toBe(0);
1488 const readModel = await Effect.runPromise(harness.engine.getReadModel());1496 const readModel = await harness.readModel();
1489 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1497 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1490 expect(1498 expect(
1491 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),1499 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
...@@ -1639,7 +1647,7 @@ describe("ProviderCommandReactor", () => {...@@ -1639,7 +1647,7 @@ describe("ProviderCommandReactor", () => {
1639 );1647 );
16401648
1641 await waitFor(async () => {1649 await waitFor(async () => {
1642 const readModel = await Effect.runPromise(harness.engine.getReadModel());1650 const readModel = await harness.readModel();
1643 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1651 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1644 return (1652 return (
1645 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??1653 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
...@@ -1649,7 +1657,7 @@ describe("ProviderCommandReactor", () => {...@@ -1649,7 +1657,7 @@ describe("ProviderCommandReactor", () => {
16491657
1650 expect(harness.startSession.mock.calls.length).toBe(0);1658 expect(harness.startSession.mock.calls.length).toBe(0);
1651 expect(harness.sendTurn.mock.calls.length).toBe(0);1659 expect(harness.sendTurn.mock.calls.length).toBe(0);
1652 const readModel = await Effect.runPromise(harness.engine.getReadModel());1660 const readModel = await harness.readModel();
1653 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1661 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1654 expect(1662 expect(
1655 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),1663 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
...@@ -1810,7 +1818,7 @@ describe("ProviderCommandReactor", () => {...@@ -1810,7 +1818,7 @@ describe("ProviderCommandReactor", () => {
1810 );1818 );
18111819
1812 await waitFor(async () => {1820 await waitFor(async () => {
1813 const readModel = await Effect.runPromise(harness.engine.getReadModel());1821 const readModel = await harness.readModel();
1814 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1822 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1815 if (!thread) return false;1823 if (!thread) return false;
1816 return thread.activities.some(1824 return thread.activities.some(
...@@ -1818,7 +1826,7 @@ describe("ProviderCommandReactor", () => {...@@ -1818,7 +1826,7 @@ describe("ProviderCommandReactor", () => {
1818 );1826 );
1819 });1827 });
18201828
1821 const readModel = await Effect.runPromise(harness.engine.getReadModel());1829 const readModel = await harness.readModel();
1822 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1830 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1823 expect(thread).toBeDefined();1831 expect(thread).toBeDefined();
18241832
...@@ -1919,7 +1927,7 @@ describe("ProviderCommandReactor", () => {...@@ -1919,7 +1927,7 @@ describe("ProviderCommandReactor", () => {
1919 );1927 );
19201928
1921 await waitFor(async () => {1929 await waitFor(async () => {
1922 const readModel = await Effect.runPromise(harness.engine.getReadModel());1930 const readModel = await harness.readModel();
1923 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1931 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1924 if (!thread) return false;1932 if (!thread) return false;
1925 return thread.activities.some(1933 return thread.activities.some(
...@@ -1927,7 +1935,7 @@ describe("ProviderCommandReactor", () => {...@@ -1927,7 +1935,7 @@ describe("ProviderCommandReactor", () => {
1927 );1935 );
1928 });1936 });
19291937
1930 const readModel = await Effect.runPromise(harness.engine.getReadModel());1938 const readModel = await harness.readModel();
1931 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1939 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1932 expect(thread).toBeDefined();1940 expect(thread).toBeDefined();
19331941
...@@ -1983,7 +1991,7 @@ describe("ProviderCommandReactor", () => {...@@ -1983,7 +1991,7 @@ describe("ProviderCommandReactor", () => {
1983 );1991 );
19841992
1985 await waitFor(() => harness.stopSession.mock.calls.length === 1);1993 await waitFor(() => harness.stopSession.mock.calls.length === 1);
1986 const readModel = await Effect.runPromise(harness.engine.getReadModel());1994 const readModel = await harness.readModel();
1987 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1995 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1988 expect(thread?.session).not.toBeNull();1996 expect(thread?.session).not.toBeNull();
1989 expect(thread?.session?.status).toBe("stopped");1997 expect(thread?.session?.status).toBe("stopped");
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+27-20
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type ModelSelection,5 type ModelSelection,
6 type OrchestrationEvent,6 type OrchestrationEvent,
7 ProviderDriverKind,7 ProviderDriverKind,
8 type ProjectId,
8 type OrchestrationSession,9 type OrchestrationSession,
9 ThreadId,10 ThreadId,
10 type ProviderSession,11 type ProviderSession,
...@@ -22,6 +23,7 @@ import type { ProviderServiceError } from "../../provider/Errors.ts";...@@ -22,6 +23,7 @@ import type { ProviderServiceError } from "../../provider/Errors.ts";
22import { TextGeneration } from "../../textGeneration/TextGeneration.ts";23import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
23import { ProviderService } from "../../provider/Services/ProviderService.ts";24import { ProviderService } from "../../provider/Services/ProviderService.ts";
24import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";25import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
26import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
25import {27import {
26 ProviderCommandReactor,28 ProviderCommandReactor,
27 type ProviderCommandReactorShape,29 type ProviderCommandReactorShape,
...@@ -167,6 +169,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string {...@@ -167,6 +169,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
167169
168const make = Effect.gen(function* () {170const make = Effect.gen(function* () {
169 const orchestrationEngine = yield* OrchestrationEngineService;171 const orchestrationEngine = yield* OrchestrationEngineService;
172 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
170 const providerService = yield* ProviderService;173 const providerService = yield* ProviderService;
171 const gitWorkflow = yield* GitWorkflowService;174 const gitWorkflow = yield* GitWorkflowService;
172 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;175 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
...@@ -267,9 +270,16 @@ const make = Effect.gen(function* () {...@@ -267,9 +270,16 @@ const make = Effect.gen(function* () {
267 });270 });
268 });271 });
269272
273 const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) {
274 return yield* projectionSnapshotQuery
275 .getProjectShellById(projectId)
276 .pipe(Effect.map(Option.getOrUndefined));
277 });
278
270 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {279 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
271 const readModel = yield* orchestrationEngine.getReadModel();280 return yield* projectionSnapshotQuery
272 return readModel.threads.find((entry) => entry.id === threadId);281 .getThreadDetailById(threadId)
282 .pipe(Effect.map(Option.getOrUndefined));
273 });283 });
274284
275 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (285 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (
...@@ -279,8 +289,7 @@ const make = Effect.gen(function* () {...@@ -279,8 +289,7 @@ const make = Effect.gen(function* () {
279 readonly modelSelection?: ModelSelection;289 readonly modelSelection?: ModelSelection;
280 },290 },
281 ) {291 ) {
282 const readModel = yield* orchestrationEngine.getReadModel();292 const thread = yield* resolveThread(threadId);
283 const thread = readModel.threads.find((entry) => entry.id === threadId);
284 if (!thread) {293 if (!thread) {
285 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));294 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
286 }295 }
...@@ -375,9 +384,10 @@ const make = Effect.gen(function* () {...@@ -375,9 +384,10 @@ const make = Effect.gen(function* () {
375 });384 });
376 }385 }
377 }386 }
387 const project = yield* resolveProject(thread.projectId);
378 const effectiveCwd = resolveThreadWorkspaceCwd({388 const effectiveCwd = resolveThreadWorkspaceCwd({
379 thread,389 thread,
380 projects: readModel.projects,390 projects: project ? [project] : [],
381 });391 });
382392
383 const startProviderSession = (input?: {393 const startProviderSession = (input?: {
...@@ -682,10 +692,11 @@ const make = Effect.gen(function* () {...@@ -682,10 +692,11 @@ const make = Effect.gen(function* () {
682 const isFirstUserMessageTurn =692 const isFirstUserMessageTurn =
683 thread.messages.filter((entry) => entry.role === "user").length === 1;693 thread.messages.filter((entry) => entry.role === "user").length === 1;
684 if (isFirstUserMessageTurn) {694 if (isFirstUserMessageTurn) {
695 const project = yield* resolveProject(thread.projectId);
685 const generationCwd =696 const generationCwd =
686 resolveThreadWorkspaceCwd({697 resolveThreadWorkspaceCwd({
687 thread,698 thread,
688 projects: (yield* orchestrationEngine.getReadModel()).projects,699 projects: project ? [project] : [],
689 }) ?? process.cwd();700 }) ?? process.cwd();
690 const generationInput = {701 const generationInput = {
691 messageText: message.text,702 messageText: message.text,
...@@ -819,20 +830,16 @@ const make = Effect.gen(function* () {...@@ -819,20 +830,16 @@ const make = Effect.gen(function* () {
819 })830 })
820 .pipe(831 .pipe(
821 Effect.catchCause((cause) =>832 Effect.catchCause((cause) =>
822 Effect.gen(function* () {833 appendProviderFailureActivity({
823 yield* appendProviderFailureActivity({834 threadId: event.payload.threadId,
824 threadId: event.payload.threadId,835 kind: "provider.approval.respond.failed",
825 kind: "provider.approval.respond.failed",836 summary: "Provider approval response failed",
826 summary: "Provider approval response failed",837 detail: isUnknownPendingApprovalRequestError(cause)
827 detail: isUnknownPendingApprovalRequestError(cause)838 ? stalePendingRequestDetail("approval", event.payload.requestId)
828 ? stalePendingRequestDetail("approval", event.payload.requestId)839 : Cause.pretty(cause),
829 : Cause.pretty(cause),840 turnId: null,
830 turnId: null,841 createdAt: event.payload.createdAt,
831 createdAt: event.payload.createdAt,842 requestId: event.payload.requestId,
832 requestId: event.payload.requestId,
833 });
834
835 if (!isUnknownPendingApprovalRequestError(cause)) return;
836 }),843 }),
837 ),844 ),
838 );845 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+89-84
...@@ -36,11 +36,9 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";...@@ -36,11 +36,9 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
36import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";36import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
37import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";37import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
38import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";38import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
39import {39import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
40 OrchestrationEngineService,
41 type OrchestrationEngineShape,
42} from "../Services/OrchestrationEngine.ts";
43import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";40import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
41import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
44import { ServerConfig } from "../../config.ts";42import { ServerConfig } from "../../config.ts";
45import { ServerSettingsService } from "../../serverSettings.ts";43import { ServerSettingsService } from "../../serverSettings.ts";
46import * as NodeServices from "@effect/platform-node/NodeServices";44import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -154,16 +152,23 @@ function createProviderServiceHarness() {...@@ -154,16 +152,23 @@ function createProviderServiceHarness() {
154 };152 };
155}153}
156154
155type ProviderRuntimeTestReadModel = OrchestrationReadModel;
156type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
157type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
158type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
159type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
160type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
161
157async function waitForThread(162async function waitForThread(
158 engine: OrchestrationEngineShape,163 readModel: () => Promise<ProviderRuntimeTestReadModel>,
159 predicate: (thread: ProviderRuntimeTestThread) => boolean,164 predicate: (thread: ProviderRuntimeTestThread) => boolean,
160 timeoutMs = 2000,165 timeoutMs = 2000,
161 threadId: ThreadId = asThreadId("thread-1"),166 threadId: ThreadId = asThreadId("thread-1"),
162) {167) {
163 const deadline = Date.now() + timeoutMs;168 const deadline = Date.now() + timeoutMs;
164 const poll = async (): Promise<ProviderRuntimeTestThread> => {169 const poll = async (): Promise<ProviderRuntimeTestThread> => {
165 const readModel = await Effect.runPromise(engine.getReadModel());170 const snapshot = await readModel();
166 const thread = readModel.threads.find((entry) => entry.id === threadId);171 const thread = snapshot.threads.find((entry) => entry.id === threadId);
167 if (thread && predicate(thread)) {172 if (thread && predicate(thread)) {
168 return thread;173 return thread;
169 }174 }
...@@ -176,16 +181,9 @@ async function waitForThread(...@@ -176,16 +181,9 @@ async function waitForThread(
176 return poll();181 return poll();
177}182}
178183
179type ProviderRuntimeTestReadModel = OrchestrationReadModel;
180type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
181type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
182type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
183type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
184type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
185
186describe("ProviderRuntimeIngestion", () => {184describe("ProviderRuntimeIngestion", () => {
187 let runtime: ManagedRuntime.ManagedRuntime<185 let runtime: ManagedRuntime.ManagedRuntime<
188 OrchestrationEngineService | ProviderRuntimeIngestionService,186 OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery,
189 unknown187 unknown
190 > | null = null;188 > | null = null;
191 let scope: Scope.Closeable | null = null;189 let scope: Scope.Closeable | null = null;
...@@ -223,8 +221,13 @@ describe("ProviderRuntimeIngestion", () => {...@@ -223,8 +221,13 @@ describe("ProviderRuntimeIngestion", () => {
223 Layer.provide(RepositoryIdentityResolverLive),221 Layer.provide(RepositoryIdentityResolverLive),
224 Layer.provide(SqlitePersistenceMemory),222 Layer.provide(SqlitePersistenceMemory),
225 );223 );
224 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
225 Layer.provide(RepositoryIdentityResolverLive),
226 Layer.provide(SqlitePersistenceMemory),
227 );
226 const layer = ProviderRuntimeIngestionLive.pipe(228 const layer = ProviderRuntimeIngestionLive.pipe(
227 Layer.provideMerge(orchestrationLayer),229 Layer.provideMerge(orchestrationLayer),
230 Layer.provideMerge(projectionSnapshotLayer),
228 Layer.provideMerge(SqlitePersistenceMemory),231 Layer.provideMerge(SqlitePersistenceMemory),
229 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),232 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
230 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),233 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
...@@ -233,6 +236,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -233,6 +236,7 @@ describe("ProviderRuntimeIngestion", () => {
233 );236 );
234 runtime = ManagedRuntime.make(layer);237 runtime = ManagedRuntime.make(layer);
235 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));238 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
239 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
236 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));240 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));
237 scope = await Effect.runPromise(Scope.make("sequential"));241 scope = await Effect.runPromise(Scope.make("sequential"));
238 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));242 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));
...@@ -299,6 +303,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -299,6 +303,7 @@ describe("ProviderRuntimeIngestion", () => {
299303
300 return {304 return {
301 engine,305 engine,
306 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
302 emit: provider.emit,307 emit: provider.emit,
303 setProviderSession: provider.setSession,308 setProviderSession: provider.setSession,
304 drain,309 drain,
...@@ -319,7 +324,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -319,7 +324,7 @@ describe("ProviderRuntimeIngestion", () => {
319 });324 });
320325
321 await waitForThread(326 await waitForThread(
322 harness.engine,327 harness.readModel,
323 (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1",328 (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1",
324 );329 );
325330
...@@ -337,7 +342,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -337,7 +342,7 @@ describe("ProviderRuntimeIngestion", () => {
337 });342 });
338343
339 const thread = await waitForThread(344 const thread = await waitForThread(
340 harness.engine,345 harness.readModel,
341 (entry) =>346 (entry) =>
342 entry.session?.status === "error" &&347 entry.session?.status === "error" &&
343 entry.session?.activeTurnId === null &&348 entry.session?.activeTurnId === null &&
...@@ -364,7 +369,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -364,7 +369,7 @@ describe("ProviderRuntimeIngestion", () => {
364 });369 });
365370
366 let thread = await waitForThread(371 let thread = await waitForThread(
367 harness.engine,372 harness.readModel,
368 (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null,373 (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null,
369 );374 );
370 expect(thread.session?.status).toBe("running");375 expect(thread.session?.status).toBe("running");
...@@ -383,7 +388,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -383,7 +388,7 @@ describe("ProviderRuntimeIngestion", () => {
383 });388 });
384389
385 thread = await waitForThread(390 thread = await waitForThread(
386 harness.engine,391 harness.readModel,
387 (entry) =>392 (entry) =>
388 entry.session?.status === "error" &&393 entry.session?.status === "error" &&
389 entry.session?.activeTurnId === null &&394 entry.session?.activeTurnId === null &&
...@@ -404,7 +409,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -404,7 +409,7 @@ describe("ProviderRuntimeIngestion", () => {
404 });409 });
405410
406 thread = await waitForThread(411 thread = await waitForThread(
407 harness.engine,412 harness.readModel,
408 (entry) =>413 (entry) =>
409 entry.session?.status === "stopped" &&414 entry.session?.status === "stopped" &&
410 entry.session?.activeTurnId === null &&415 entry.session?.activeTurnId === null &&
...@@ -425,7 +430,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -425,7 +430,7 @@ describe("ProviderRuntimeIngestion", () => {
425 });430 });
426431
427 thread = await waitForThread(432 thread = await waitForThread(
428 harness.engine,433 harness.readModel,
429 (entry) =>434 (entry) =>
430 entry.session?.status === "ready" &&435 entry.session?.status === "ready" &&
431 entry.session?.activeTurnId === null &&436 entry.session?.activeTurnId === null &&
...@@ -449,7 +454,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -449,7 +454,7 @@ describe("ProviderRuntimeIngestion", () => {
449 });454 });
450455
451 await waitForThread(456 await waitForThread(
452 harness.engine,457 harness.readModel,
453 (thread) =>458 (thread) =>
454 thread.session?.status === "running" &&459 thread.session?.status === "running" &&
455 thread.session?.activeTurnId === "turn-midturn-lifecycle",460 thread.session?.activeTurnId === "turn-midturn-lifecycle",
...@@ -471,7 +476,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -471,7 +476,7 @@ describe("ProviderRuntimeIngestion", () => {
471 });476 });
472477
473 await harness.drain();478 await harness.drain();
474 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());479 const midReadModel = await harness.readModel();
475 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));480 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
476 expect(midThread?.session?.status).toBe("running");481 expect(midThread?.session?.status).toBe("running");
477 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");482 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");
...@@ -487,7 +492,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -487,7 +492,7 @@ describe("ProviderRuntimeIngestion", () => {
487 });492 });
488493
489 await waitForThread(494 await waitForThread(
490 harness.engine,495 harness.readModel,
491 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,496 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
492 );497 );
493 });498 });
...@@ -524,7 +529,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -524,7 +529,7 @@ describe("ProviderRuntimeIngestion", () => {
524 });529 });
525530
526 await waitForThread(531 await waitForThread(
527 harness.engine,532 harness.readModel,
528 (thread) =>533 (thread) =>
529 thread.session?.status === "running" &&534 thread.session?.status === "running" &&
530 thread.session?.activeTurnId === "turn-claude-placeholder",535 thread.session?.activeTurnId === "turn-claude-placeholder",
...@@ -541,7 +546,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -541,7 +546,7 @@ describe("ProviderRuntimeIngestion", () => {
541 });546 });
542547
543 await waitForThread(548 await waitForThread(
544 harness.engine,549 harness.readModel,
545 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,550 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
546 );551 );
547 });552 });
...@@ -560,7 +565,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -560,7 +565,7 @@ describe("ProviderRuntimeIngestion", () => {
560 });565 });
561566
562 await waitForThread(567 await waitForThread(
563 harness.engine,568 harness.readModel,
564 (thread) =>569 (thread) =>
565 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary",570 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary",
566 );571 );
...@@ -576,7 +581,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -576,7 +581,7 @@ describe("ProviderRuntimeIngestion", () => {
576 });581 });
577582
578 await harness.drain();583 await harness.drain();
579 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());584 const midReadModel = await harness.readModel();
580 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));585 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
581 expect(midThread?.session?.status).toBe("running");586 expect(midThread?.session?.status).toBe("running");
582 expect(midThread?.session?.activeTurnId).toBe("turn-primary");587 expect(midThread?.session?.activeTurnId).toBe("turn-primary");
...@@ -592,7 +597,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -592,7 +597,7 @@ describe("ProviderRuntimeIngestion", () => {
592 });597 });
593598
594 await waitForThread(599 await waitForThread(
595 harness.engine,600 harness.readModel,
596 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,601 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
597 );602 );
598 });603 });
...@@ -611,7 +616,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -611,7 +616,7 @@ describe("ProviderRuntimeIngestion", () => {
611 });616 });
612617
613 await waitForThread(618 await waitForThread(
614 harness.engine,619 harness.readModel,
615 (thread) =>620 (thread) =>
616 thread.session?.status === "running" &&621 thread.session?.status === "running" &&
617 thread.session?.activeTurnId === "turn-guarded-main",622 thread.session?.activeTurnId === "turn-guarded-main",
...@@ -628,7 +633,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -628,7 +633,7 @@ describe("ProviderRuntimeIngestion", () => {
628 });633 });
629634
630 await harness.drain();635 await harness.drain();
631 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());636 const midReadModel = await harness.readModel();
632 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));637 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
633 expect(midThread?.session?.status).toBe("running");638 expect(midThread?.session?.status).toBe("running");
634 expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main");639 expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main");
...@@ -644,7 +649,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -644,7 +649,7 @@ describe("ProviderRuntimeIngestion", () => {
644 });649 });
645650
646 await waitForThread(651 await waitForThread(
647 harness.engine,652 harness.readModel,
648 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,653 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
649 );654 );
650 });655 });
...@@ -693,7 +698,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -693,7 +698,7 @@ describe("ProviderRuntimeIngestion", () => {
693 },698 },
694 });699 });
695700
696 const thread = await waitForThread(harness.engine, (entry) =>701 const thread = await waitForThread(harness.readModel, (entry) =>
697 entry.messages.some(702 entry.messages.some(
698 (message: ProviderRuntimeTestMessage) =>703 (message: ProviderRuntimeTestMessage) =>
699 message.id === "assistant:item-1" && !message.streaming,704 message.id === "assistant:item-1" && !message.streaming,
...@@ -725,7 +730,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -725,7 +730,7 @@ describe("ProviderRuntimeIngestion", () => {
725 },730 },
726 });731 });
727732
728 const thread = await waitForThread(harness.engine, (entry) =>733 const thread = await waitForThread(harness.readModel, (entry) =>
729 entry.messages.some(734 entry.messages.some(
730 (message: ProviderRuntimeTestMessage) =>735 (message: ProviderRuntimeTestMessage) =>
731 message.id === "assistant:item-no-delta" && !message.streaming,736 message.id === "assistant:item-no-delta" && !message.streaming,
...@@ -764,7 +769,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -764,7 +769,7 @@ describe("ProviderRuntimeIngestion", () => {
764 },769 },
765 });770 });
766771
767 const thread = await waitForThread(harness.engine, (entry) =>772 const thread = await waitForThread(harness.readModel, (entry) =>
768 entry.activities.some(773 entry.activities.some(
769 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data",774 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data",
770 ),775 ),
...@@ -819,7 +824,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -819,7 +824,7 @@ describe("ProviderRuntimeIngestion", () => {
819 },824 },
820 });825 });
821826
822 const thread = await waitForThread(harness.engine, (entry) =>827 const thread = await waitForThread(harness.readModel, (entry) =>
823 entry.activities.some(828 entry.activities.some(
824 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed",829 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed",
825 ),830 ),
...@@ -861,7 +866,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -861,7 +866,7 @@ describe("ProviderRuntimeIngestion", () => {
861 },866 },
862 });867 });
863868
864 const thread = await waitForThread(harness.engine, (entry) =>869 const thread = await waitForThread(harness.readModel, (entry) =>
865 entry.activities.some(870 entry.activities.some(
866 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed",871 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed",
867 ),872 ),
...@@ -894,7 +899,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -894,7 +899,7 @@ describe("ProviderRuntimeIngestion", () => {
894 },899 },
895 });900 });
896901
897 const thread = await waitForThread(harness.engine, (entry) =>902 const thread = await waitForThread(harness.readModel, (entry) =>
898 entry.proposedPlans.some(903 entry.proposedPlans.some(
899 (proposedPlan: ProviderRuntimeTestProposedPlan) =>904 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
900 proposedPlan.id === "plan:thread-1:turn:turn-plan-final",905 proposedPlan.id === "plan:thread-1:turn:turn-plan-final",
...@@ -1009,7 +1014,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1009,7 +1014,7 @@ describe("ProviderRuntimeIngestion", () => {
1009 });1014 });
10101015
1011 const sourceThreadWithPlan = await waitForThread(1016 const sourceThreadWithPlan = await waitForThread(
1012 harness.engine,1017 harness.readModel,
1013 (thread) =>1018 (thread) =>
1014 thread.proposedPlans.some(1019 thread.proposedPlans.some(
1015 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1020 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
...@@ -1050,7 +1055,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1050,7 +1055,7 @@ describe("ProviderRuntimeIngestion", () => {
1050 );1055 );
10511056
1052 const sourceThreadBeforeStart = await waitForThread(1057 const sourceThreadBeforeStart = await waitForThread(
1053 harness.engine,1058 harness.readModel,
1054 (thread) =>1059 (thread) =>
1055 thread.proposedPlans.some(1060 thread.proposedPlans.some(
1056 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1061 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
...@@ -1076,7 +1081,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1076,7 +1081,7 @@ describe("ProviderRuntimeIngestion", () => {
1076 });1081 });
10771082
1078 const sourceThreadAfterStart = await waitForThread(1083 const sourceThreadAfterStart = await waitForThread(
1079 harness.engine,1084 harness.readModel,
1080 (thread) =>1085 (thread) =>
1081 thread.proposedPlans.some(1086 thread.proposedPlans.some(
1082 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1087 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
...@@ -1158,7 +1163,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1158,7 +1163,7 @@ describe("ProviderRuntimeIngestion", () => {
1158 });1163 });
11591164
1160 await waitForThread(1165 await waitForThread(
1161 harness.engine,1166 harness.readModel,
1162 (thread) =>1167 (thread) =>
1163 thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId,1168 thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId,
1164 2_000,1169 2_000,
...@@ -1178,7 +1183,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1178,7 +1183,7 @@ describe("ProviderRuntimeIngestion", () => {
1178 });1183 });
11791184
1180 const sourceThreadWithPlan = await waitForThread(1185 const sourceThreadWithPlan = await waitForThread(
1181 harness.engine,1186 harness.readModel,
1182 (thread) =>1187 (thread) =>
1183 thread.proposedPlans.some(1188 thread.proposedPlans.some(
1184 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1189 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
...@@ -1229,7 +1234,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1229,7 +1234,7 @@ describe("ProviderRuntimeIngestion", () => {
12291234
1230 await harness.drain();1235 await harness.drain();
12311236
1232 const readModel = await Effect.runPromise(harness.engine.getReadModel());1237 const readModel = await harness.readModel();
1233 const sourceThreadAfterRejectedStart = readModel.threads.find(1238 const sourceThreadAfterRejectedStart = readModel.threads.find(
1234 (entry) => entry.id === sourceThreadId,1239 (entry) => entry.id === sourceThreadId,
1235 );1240 );
...@@ -1340,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1340,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => {
1340 });1345 });
13411346
1342 const sourceThreadWithPlan = await waitForThread(1347 const sourceThreadWithPlan = await waitForThread(
1343 harness.engine,1348 harness.readModel,
1344 (thread) =>1349 (thread) =>
1345 thread.proposedPlans.some(1350 thread.proposedPlans.some(
1346 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1351 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
...@@ -1401,7 +1406,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1401,7 +1406,7 @@ describe("ProviderRuntimeIngestion", () => {
14011406
1402 await harness.drain();1407 await harness.drain();
14031408
1404 const readModel = await Effect.runPromise(harness.engine.getReadModel());1409 const readModel = await harness.readModel();
1405 const sourceThreadAfterUnrelatedStart = readModel.threads.find(1410 const sourceThreadAfterUnrelatedStart = readModel.threads.find(
1406 (entry) => entry.id === sourceThreadId,1411 (entry) => entry.id === sourceThreadId,
1407 );1412 );
...@@ -1427,7 +1432,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1427,7 +1432,7 @@ describe("ProviderRuntimeIngestion", () => {
1427 });1432 });
14281433
1429 await waitForThread(1434 await waitForThread(
1430 harness.engine,1435 harness.readModel,
1431 (thread) =>1436 (thread) =>
1432 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer",1437 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer",
1433 );1438 );
...@@ -1466,7 +1471,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1466,7 +1471,7 @@ describe("ProviderRuntimeIngestion", () => {
1466 },1471 },
1467 });1472 });
14681473
1469 const thread = await waitForThread(harness.engine, (entry) =>1474 const thread = await waitForThread(harness.readModel, (entry) =>
1470 entry.proposedPlans.some(1475 entry.proposedPlans.some(
1471 (proposedPlan: ProviderRuntimeTestProposedPlan) =>1476 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
1472 proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer",1477 proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer",
...@@ -1492,7 +1497,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1492,7 +1497,7 @@ describe("ProviderRuntimeIngestion", () => {
1492 turnId: asTurnId("turn-buffered"),1497 turnId: asTurnId("turn-buffered"),
1493 });1498 });
1494 await waitForThread(1499 await waitForThread(
1495 harness.engine,1500 harness.readModel,
1496 (thread) =>1501 (thread) =>
1497 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered",1502 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered",
1498 );1503 );
...@@ -1512,7 +1517,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1512,7 +1517,7 @@ describe("ProviderRuntimeIngestion", () => {
1512 });1517 });
15131518
1514 await harness.drain();1519 await harness.drain();
1515 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());1520 const midReadModel = await harness.readModel();
1516 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));1521 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1517 expect(1522 expect(
1518 midThread?.messages.some(1523 midThread?.messages.some(
...@@ -1534,7 +1539,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1534,7 +1539,7 @@ describe("ProviderRuntimeIngestion", () => {
1534 },1539 },
1535 });1540 });
15361541
1537 const thread = await waitForThread(harness.engine, (entry) =>1542 const thread = await waitForThread(harness.readModel, (entry) =>
1538 entry.messages.some(1543 entry.messages.some(
1539 (message: ProviderRuntimeTestMessage) =>1544 (message: ProviderRuntimeTestMessage) =>
1540 message.id === "assistant:item-buffered" && !message.streaming,1545 message.id === "assistant:item-buffered" && !message.streaming,
...@@ -1560,7 +1565,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1560,7 +1565,7 @@ describe("ProviderRuntimeIngestion", () => {
1560 turnId: asTurnId("turn-buffered-request-flush"),1565 turnId: asTurnId("turn-buffered-request-flush"),
1561 });1566 });
1562 await waitForThread(1567 await waitForThread(
1563 harness.engine,1568 harness.readModel,
1564 (thread) =>1569 (thread) =>
1565 thread.session?.status === "running" &&1570 thread.session?.status === "running" &&
1566 thread.session?.activeTurnId === "turn-buffered-request-flush",1571 thread.session?.activeTurnId === "turn-buffered-request-flush",
...@@ -1593,7 +1598,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1593,7 +1598,7 @@ describe("ProviderRuntimeIngestion", () => {
1593 },1598 },
1594 });1599 });
15951600
1596 const thread = await waitForThread(harness.engine, (entry) =>1601 const thread = await waitForThread(harness.readModel, (entry) =>
1597 entry.messages.some(1602 entry.messages.some(
1598 (message: ProviderRuntimeTestMessage) =>1603 (message: ProviderRuntimeTestMessage) =>
1599 message.id === "assistant:item-buffered-request-flush" &&1604 message.id === "assistant:item-buffered-request-flush" &&
...@@ -1620,7 +1625,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1620,7 +1625,7 @@ describe("ProviderRuntimeIngestion", () => {
1620 turnId: asTurnId("turn-buffered-user-input-flush"),1625 turnId: asTurnId("turn-buffered-user-input-flush"),
1621 });1626 });
1622 await waitForThread(1627 await waitForThread(
1623 harness.engine,1628 harness.readModel,
1624 (thread) =>1629 (thread) =>
1625 thread.session?.status === "running" &&1630 thread.session?.status === "running" &&
1626 thread.session?.activeTurnId === "turn-buffered-user-input-flush",1631 thread.session?.activeTurnId === "turn-buffered-user-input-flush",
...@@ -1659,7 +1664,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1659,7 +1664,7 @@ describe("ProviderRuntimeIngestion", () => {
1659 },1664 },
1660 });1665 });
16611666
1662 const thread = await waitForThread(harness.engine, (entry) =>1667 const thread = await waitForThread(harness.readModel, (entry) =>
1663 entry.messages.some(1668 entry.messages.some(
1664 (message: ProviderRuntimeTestMessage) =>1669 (message: ProviderRuntimeTestMessage) =>
1665 message.id === "assistant:item-buffered-user-input-flush" &&1670 message.id === "assistant:item-buffered-user-input-flush" &&
...@@ -1688,7 +1693,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1688,7 +1693,7 @@ describe("ProviderRuntimeIngestion", () => {
1688 turnId: asTurnId("turn-buffered-whitespace-request"),1693 turnId: asTurnId("turn-buffered-whitespace-request"),
1689 });1694 });
1690 await waitForThread(1695 await waitForThread(
1691 harness.engine,1696 harness.readModel,
1692 (thread) =>1697 (thread) =>
1693 thread.session?.status === "running" &&1698 thread.session?.status === "running" &&
1694 thread.session?.activeTurnId === "turn-buffered-whitespace-request",1699 thread.session?.activeTurnId === "turn-buffered-whitespace-request",
...@@ -1721,7 +1726,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1721,7 +1726,7 @@ describe("ProviderRuntimeIngestion", () => {
1721 },1726 },
1722 });1727 });
17231728
1724 const thread = await waitForThread(harness.engine, (entry) =>1729 const thread = await waitForThread(harness.readModel, (entry) =>
1725 entry.activities.some(1730 entry.activities.some(
1726 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",1731 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",
1727 ),1732 ),
...@@ -1750,7 +1755,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1750,7 +1755,7 @@ describe("ProviderRuntimeIngestion", () => {
1750 turnId: asTurnId("turn-buffered-request-append"),1755 turnId: asTurnId("turn-buffered-request-append"),
1751 });1756 });
1752 await waitForThread(1757 await waitForThread(
1753 harness.engine,1758 harness.readModel,
1754 (thread) =>1759 (thread) =>
1755 thread.session?.status === "running" &&1760 thread.session?.status === "running" &&
1756 thread.session?.activeTurnId === "turn-buffered-request-append",1761 thread.session?.activeTurnId === "turn-buffered-request-append",
...@@ -1783,7 +1788,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1783,7 +1788,7 @@ describe("ProviderRuntimeIngestion", () => {
1783 },1788 },
1784 });1789 });
17851790
1786 await waitForThread(harness.engine, (entry) =>1791 await waitForThread(harness.readModel, (entry) =>
1787 entry.messages.some(1792 entry.messages.some(
1788 (message: ProviderRuntimeTestMessage) =>1793 (message: ProviderRuntimeTestMessage) =>
1789 message.id === "assistant:item-buffered-request-append" &&1794 message.id === "assistant:item-buffered-request-append" &&
...@@ -1819,7 +1824,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1819,7 +1824,7 @@ describe("ProviderRuntimeIngestion", () => {
1819 },1824 },
1820 });1825 });
18211826
1822 const thread = await waitForThread(harness.engine, (entry) =>1827 const thread = await waitForThread(harness.readModel, (entry) =>
1823 entry.messages.some(1828 entry.messages.some(
1824 (message: ProviderRuntimeTestMessage) =>1829 (message: ProviderRuntimeTestMessage) =>
1825 message.id === "assistant:item-buffered-request-append:segment:1" &&1830 message.id === "assistant:item-buffered-request-append:segment:1" &&
...@@ -1882,7 +1887,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1882,7 +1887,7 @@ describe("ProviderRuntimeIngestion", () => {
1882 turnId: asTurnId("turn-streaming-request-segment"),1887 turnId: asTurnId("turn-streaming-request-segment"),
1883 });1888 });
1884 await waitForThread(1889 await waitForThread(
1885 harness.engine,1890 harness.readModel,
1886 (thread) =>1891 (thread) =>
1887 thread.session?.status === "running" &&1892 thread.session?.status === "running" &&
1888 thread.session?.activeTurnId === "turn-streaming-request-segment",1893 thread.session?.activeTurnId === "turn-streaming-request-segment",
...@@ -1915,7 +1920,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1915,7 +1920,7 @@ describe("ProviderRuntimeIngestion", () => {
1915 },1920 },
1916 });1921 });
19171922
1918 await waitForThread(harness.engine, (entry) =>1923 await waitForThread(harness.readModel, (entry) =>
1919 entry.messages.some(1924 entry.messages.some(
1920 (message: ProviderRuntimeTestMessage) =>1925 (message: ProviderRuntimeTestMessage) =>
1921 message.id === "assistant:item-streaming-request-segment" &&1926 message.id === "assistant:item-streaming-request-segment" &&
...@@ -1951,7 +1956,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1951,7 +1956,7 @@ describe("ProviderRuntimeIngestion", () => {
1951 },1956 },
1952 });1957 });
19531958
1954 const thread = await waitForThread(harness.engine, (entry) =>1959 const thread = await waitForThread(harness.readModel, (entry) =>
1955 entry.messages.some(1960 entry.messages.some(
1956 (message: ProviderRuntimeTestMessage) =>1961 (message: ProviderRuntimeTestMessage) =>
1957 message.id === "assistant:item-streaming-request-segment:segment:1" &&1962 message.id === "assistant:item-streaming-request-segment:segment:1" &&
...@@ -2004,7 +2009,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2004,7 +2009,7 @@ describe("ProviderRuntimeIngestion", () => {
2004 turnId: asTurnId("turn-streaming-mode"),2009 turnId: asTurnId("turn-streaming-mode"),
2005 });2010 });
2006 await waitForThread(2011 await waitForThread(
2007 harness.engine,2012 harness.readModel,
2008 (thread) =>2013 (thread) =>
2009 thread.session?.status === "running" &&2014 thread.session?.status === "running" &&
2010 thread.session?.activeTurnId === "turn-streaming-mode",2015 thread.session?.activeTurnId === "turn-streaming-mode",
...@@ -2024,7 +2029,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2024,7 +2029,7 @@ describe("ProviderRuntimeIngestion", () => {
2024 },2029 },
2025 });2030 });
20262031
2027 const liveThread = await waitForThread(harness.engine, (entry) =>2032 const liveThread = await waitForThread(harness.readModel, (entry) =>
2028 entry.messages.some(2033 entry.messages.some(
2029 (message: ProviderRuntimeTestMessage) =>2034 (message: ProviderRuntimeTestMessage) =>
2030 message.id === "assistant:item-streaming-mode" &&2035 message.id === "assistant:item-streaming-mode" &&
...@@ -2052,7 +2057,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2052,7 +2057,7 @@ describe("ProviderRuntimeIngestion", () => {
2052 },2057 },
2053 });2058 });
20542059
2055 const finalThread = await waitForThread(harness.engine, (entry) =>2060 const finalThread = await waitForThread(harness.readModel, (entry) =>
2056 entry.messages.some(2061 entry.messages.some(
2057 (message: ProviderRuntimeTestMessage) =>2062 (message: ProviderRuntimeTestMessage) =>
2058 message.id === "assistant:item-streaming-mode" && !message.streaming,2063 message.id === "assistant:item-streaming-mode" && !message.streaming,
...@@ -2079,7 +2084,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2079,7 +2084,7 @@ describe("ProviderRuntimeIngestion", () => {
2079 turnId: asTurnId("turn-buffer-spill"),2084 turnId: asTurnId("turn-buffer-spill"),
2080 });2085 });
2081 await waitForThread(2086 await waitForThread(
2082 harness.engine,2087 harness.readModel,
2083 (thread) =>2088 (thread) =>
2084 thread.session?.status === "running" &&2089 thread.session?.status === "running" &&
2085 thread.session?.activeTurnId === "turn-buffer-spill",2090 thread.session?.activeTurnId === "turn-buffer-spill",
...@@ -2112,7 +2117,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2112,7 +2117,7 @@ describe("ProviderRuntimeIngestion", () => {
2112 },2117 },
2113 });2118 });
21142119
2115 const thread = await waitForThread(harness.engine, (entry) =>2120 const thread = await waitForThread(harness.readModel, (entry) =>
2116 entry.messages.some(2121 entry.messages.some(
2117 (message: ProviderRuntimeTestMessage) =>2122 (message: ProviderRuntimeTestMessage) =>
2118 message.id === "assistant:item-buffer-spill" && !message.streaming,2123 message.id === "assistant:item-buffer-spill" && !message.streaming,
...@@ -2140,7 +2145,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2140,7 +2145,7 @@ describe("ProviderRuntimeIngestion", () => {
2140 });2145 });
21412146
2142 await waitForThread(2147 await waitForThread(
2143 harness.engine,2148 harness.readModel,
2144 (thread) =>2149 (thread) =>
2145 thread.session?.status === "running" &&2150 thread.session?.status === "running" &&
2146 thread.session?.activeTurnId === "turn-complete-dedup",2151 thread.session?.activeTurnId === "turn-complete-dedup",
...@@ -2185,7 +2190,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2185,7 +2190,7 @@ describe("ProviderRuntimeIngestion", () => {
2185 });2190 });
21862191
2187 await waitForThread(2192 await waitForThread(
2188 harness.engine,2193 harness.readModel,
2189 (thread) =>2194 (thread) =>
2190 thread.session?.status === "ready" &&2195 thread.session?.status === "ready" &&
2191 thread.session?.activeTurnId === null &&2196 thread.session?.activeTurnId === null &&
...@@ -2243,7 +2248,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2243,7 +2248,7 @@ describe("ProviderRuntimeIngestion", () => {
2243 });2248 });
22442249
2245 await waitForThread(2250 await waitForThread(
2246 harness.engine,2251 harness.readModel,
2247 (entry) =>2252 (entry) =>
2248 entry.activities.some(2253 entry.activities.some(
2249 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",2254 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",
...@@ -2253,7 +2258,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2253,7 +2258,7 @@ describe("ProviderRuntimeIngestion", () => {
2253 ),2258 ),
2254 );2259 );
22552260
2256 const readModel = await Effect.runPromise(harness.engine.getReadModel());2261 const readModel = await harness.readModel();
2257 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));2262 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
2258 expect(thread).toBeDefined();2263 expect(thread).toBeDefined();
22592264
...@@ -2295,7 +2300,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2295,7 +2300,7 @@ describe("ProviderRuntimeIngestion", () => {
2295 });2300 });
22962301
2297 const thread = await waitForThread(2302 const thread = await waitForThread(
2298 harness.engine,2303 harness.readModel,
2299 (entry) =>2304 (entry) =>
2300 entry.session?.status === "error" &&2305 entry.session?.status === "error" &&
2301 entry.session?.activeTurnId === "turn-3" &&2306 entry.session?.activeTurnId === "turn-3" &&
...@@ -2321,7 +2326,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2321,7 +2326,7 @@ describe("ProviderRuntimeIngestion", () => {
2321 },2326 },
2322 });2327 });
23232328
2324 const thread = await waitForThread(harness.engine, (entry) =>2329 const thread = await waitForThread(harness.readModel, (entry) =>
2325 entry.activities.some((activity) => activity.id === "evt-runtime-error-activity"),2330 entry.activities.some((activity) => activity.id === "evt-runtime-error-activity"),
2326 );2331 );
2327 const activity = thread.activities.find(2332 const activity = thread.activities.find(
...@@ -2366,7 +2371,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2366,7 +2371,7 @@ describe("ProviderRuntimeIngestion", () => {
2366 });2371 });
23672372
2368 const thread = await waitForThread(2373 const thread = await waitForThread(
2369 harness.engine,2374 harness.readModel,
2370 (entry) =>2375 (entry) =>
2371 entry.session?.status === "running" &&2376 entry.session?.status === "running" &&
2372 entry.session?.activeTurnId === "turn-warning" &&2377 entry.session?.activeTurnId === "turn-warning" &&
...@@ -2415,7 +2420,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2415,7 +2420,7 @@ describe("ProviderRuntimeIngestion", () => {
2415 });2420 });
24162421
2417 const thread = await waitForThread(2422 const thread = await waitForThread(
2418 harness.engine,2423 harness.readModel,
2419 (entry) =>2424 (entry) =>
2420 entry.session?.status === "ready" &&2425 entry.session?.status === "ready" &&
2421 entry.session?.activeTurnId === null &&2426 entry.session?.activeTurnId === null &&
...@@ -2508,7 +2513,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2508,7 +2513,7 @@ describe("ProviderRuntimeIngestion", () => {
2508 });2513 });
25092514
2510 const thread = await waitForThread(2515 const thread = await waitForThread(
2511 harness.engine,2516 harness.readModel,
2512 (entry) =>2517 (entry) =>
2513 entry.title === "Renamed by provider" &&2518 entry.title === "Renamed by provider" &&
2514 entry.activities.some(2519 entry.activities.some(
...@@ -2595,7 +2600,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2595,7 +2600,7 @@ describe("ProviderRuntimeIngestion", () => {
2595 },2600 },
2596 });2601 });
25972602
2598 const thread = await waitForThread(harness.engine, (entry) =>2603 const thread = await waitForThread(harness.readModel, (entry) =>
2599 entry.activities.some(2604 entry.activities.some(
2600 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",2605 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
2601 ),2606 ),
...@@ -2647,7 +2652,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2647,7 +2652,7 @@ describe("ProviderRuntimeIngestion", () => {
2647 },2652 },
2648 });2653 });
26492654
2650 const thread = await waitForThread(harness.engine, (entry) =>2655 const thread = await waitForThread(harness.readModel, (entry) =>
2651 entry.activities.some(2656 entry.activities.some(
2652 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",2657 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
2653 ),2658 ),
...@@ -2697,7 +2702,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2697,7 +2702,7 @@ describe("ProviderRuntimeIngestion", () => {
2697 },2702 },
2698 });2703 });
26992704
2700 const thread = await waitForThread(harness.engine, (entry) =>2705 const thread = await waitForThread(harness.readModel, (entry) =>
2701 entry.activities.some(2706 entry.activities.some(
2702 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",2707 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
2703 ),2708 ),
...@@ -2732,7 +2737,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2732,7 +2737,7 @@ describe("ProviderRuntimeIngestion", () => {
2732 },2737 },
2733 });2738 });
27342739
2735 const thread = await waitForThread(harness.engine, (entry) =>2740 const thread = await waitForThread(harness.readModel, (entry) =>
2736 entry.activities.some(2741 entry.activities.some(
2737 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",2742 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",
2738 ),2743 ),
...@@ -2802,7 +2807,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2802,7 +2807,7 @@ describe("ProviderRuntimeIngestion", () => {
2802 });2807 });
28032808
2804 const thread = await waitForThread(2809 const thread = await waitForThread(
2805 harness.engine,2810 harness.readModel,
2806 (entry) =>2811 (entry) =>
2807 entry.activities.some(2812 entry.activities.some(
2808 (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed",2813 (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed",
...@@ -2893,7 +2898,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2893,7 +2898,7 @@ describe("ProviderRuntimeIngestion", () => {
2893 });2898 });
28942899
2895 const thread = await waitForThread(2900 const thread = await waitForThread(
2896 harness.engine,2901 harness.readModel,
2897 (entry) =>2902 (entry) =>
2898 entry.activities.some(2903 entry.activities.some(
2899 (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested",2904 (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested",
...@@ -2952,7 +2957,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2952,7 +2957,7 @@ describe("ProviderRuntimeIngestion", () => {
2952 });2957 });
29532958
2954 const thread = await waitForThread(2959 const thread = await waitForThread(
2955 harness.engine,2960 harness.readModel,
2956 (entry) =>2961 (entry) =>
2957 entry.session?.status === "error" &&2962 entry.session?.status === "error" &&
2958 entry.session?.activeTurnId === "turn-after-failure" &&2963 entry.session?.activeTurnId === "turn-after-failure" &&
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+132-42
...@@ -4,12 +4,16 @@ import {...@@ -4,12 +4,16 @@ import {
4 CommandId,4 CommandId,
5 MessageId,5 MessageId,
6 type OrchestrationEvent,6 type OrchestrationEvent,
7 type OrchestrationMessage,
7 type OrchestrationProposedPlanId,8 type OrchestrationProposedPlanId,
8 CheckpointRef,9 CheckpointRef,
9 isToolLifecycleItemType,10 isToolLifecycleItemType,
10 ThreadId,11 ThreadId,
11 type ThreadTokenUsageSnapshot,12 type ThreadTokenUsageSnapshot,
12 TurnId,13 TurnId,
14 type OrchestrationCheckpointSummary,
15 type OrchestrationProposedPlan,
16 type OrchestrationThread,
13 type OrchestrationThreadActivity,17 type OrchestrationThreadActivity,
14 type ProviderRuntimeEvent,18 type ProviderRuntimeEvent,
15} from "@t3tools/contracts";19} from "@t3tools/contracts";
...@@ -19,9 +23,9 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";...@@ -19,9 +23,9 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
19import { ProviderService } from "../../provider/Services/ProviderService.ts";23import { ProviderService } from "../../provider/Services/ProviderService.ts";
20import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";24import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
21import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";25import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
22import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
23import { isGitRepository } from "../../git/Utils.ts";26import { isGitRepository } from "../../git/Utils.ts";
24import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";27import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
28import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
25import {29import {
26 ProviderRuntimeIngestionService,30 ProviderRuntimeIngestionService,
27 type ProviderRuntimeIngestionShape,31 type ProviderRuntimeIngestionShape,
...@@ -77,6 +81,82 @@ function sameId(left: string | null | undefined, right: string | null | undefine...@@ -77,6 +81,82 @@ function sameId(left: string | null | undefined, right: string | null | undefine
77 return left === right;81 return left === right;
78}82}
7983
84function hasAssistantMessageForTurn(
85 messages: ReadonlyArray<OrchestrationMessage>,
86 turnId: TurnId,
87 options?: { readonly streamingOnly?: boolean },
88): boolean {
89 for (let index = 0; index < messages.length; index += 1) {
90 const message = messages[index];
91 if (!message) {
92 continue;
93 }
94 if (message.role !== "assistant" || message.turnId !== turnId) {
95 continue;
96 }
97 if (options?.streamingOnly === true && !message.streaming) {
98 continue;
99 }
100 return true;
101 }
102 return false;
103}
104
105function findMessageById(
106 messages: ReadonlyArray<OrchestrationMessage>,
107 messageId: MessageId,
108): OrchestrationMessage | undefined {
109 for (let index = 0; index < messages.length; index += 1) {
110 const message = messages[index];
111 if (message?.id === messageId) {
112 return message;
113 }
114 }
115 return undefined;
116}
117
118function findProposedPlanById(
119 proposedPlans: ReadonlyArray<
120 Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId">
121 >,
122 planId: string,
123):
124 | Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId">
125 | undefined {
126 for (let index = 0; index < proposedPlans.length; index += 1) {
127 const proposedPlan = proposedPlans[index];
128 if (proposedPlan?.id === planId) {
129 return proposedPlan;
130 }
131 }
132 return undefined;
133}
134
135function hasCheckpointForTurn(
136 checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
137 turnId: TurnId,
138): boolean {
139 for (let index = 0; index < checkpoints.length; index += 1) {
140 if (checkpoints[index]?.turnId === turnId) {
141 return true;
142 }
143 }
144 return false;
145}
146
147function maxCheckpointTurnCount(
148 checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
149): number {
150 let maxTurnCount = 0;
151 for (let index = 0; index < checkpoints.length; index += 1) {
152 const checkpoint = checkpoints[index];
153 if (checkpoint && checkpoint.checkpointTurnCount > maxTurnCount) {
154 maxTurnCount = checkpoint.checkpointTurnCount;
155 }
156 }
157 return maxTurnCount;
158}
159
80function truncateDetail(value: string, limit = 180): string {160function truncateDetail(value: string, limit = 180): string {
81 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;161 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
82}162}
...@@ -522,6 +602,7 @@ function runtimeEventToActivities(...@@ -522,6 +602,7 @@ function runtimeEventToActivities(
522602
523const make = Effect.gen(function* () {603const make = Effect.gen(function* () {
524 const orchestrationEngine = yield* OrchestrationEngineService;604 const orchestrationEngine = yield* OrchestrationEngineService;
605 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
525 const providerService = yield* ProviderService;606 const providerService = yield* ProviderService;
526 const projectionTurnRepository = yield* ProjectionTurnRepository;607 const projectionTurnRepository = yield* ProjectionTurnRepository;
527 const serverSettingsService = yield* ServerSettingsService;608 const serverSettingsService = yield* ServerSettingsService;
...@@ -553,20 +634,16 @@ const make = Effect.gen(function* () {...@@ -553,20 +634,16 @@ const make = Effect.gen(function* () {
553 lookup: () => Effect.succeed({ text: "", createdAt: "" }),634 lookup: () => Effect.succeed({ text: "", createdAt: "" }),
554 });635 });
555636
556 const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) {637 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
557 const readModel = yield* orchestrationEngine.getReadModel();638 return yield* projectionSnapshotQuery
558 const thread = readModel.threads.find((entry) => entry.id === threadId);639 .getThreadDetailById(threadId)
559 if (!thread) {640 .pipe(Effect.map(Option.getOrUndefined));
560 return false;641 });
561 }642
562 const workspaceCwd = resolveThreadWorkspaceCwd({643 const resolveThreadShell = Effect.fn("resolveThreadShell")(function* (threadId: ThreadId) {
563 thread,644 return yield* projectionSnapshotQuery
564 projects: readModel.projects,645 .getThreadShellById(threadId)
565 });646 .pipe(Effect.map(Option.getOrUndefined));
566 if (!workspaceCwd) {
567 return false;
568 }
569 return isGitRepository(workspaceCwd);
570 });647 });
571648
572 const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>649 const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
...@@ -917,7 +994,7 @@ const make = Effect.gen(function* () {...@@ -917,7 +994,7 @@ const make = Effect.gen(function* () {
917 return;994 return;
918 }995 }
919996
920 const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);997 const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId);
921 yield* orchestrationEngine.dispatch({998 yield* orchestrationEngine.dispatch({
922 type: "thread.proposed-plan.upsert",999 type: "thread.proposed-plan.upsert",
923 commandId: providerCommandId(input.event, "proposed-plan-upsert"),1000 commandId: providerCommandId(input.event, "proposed-plan-upsert"),
...@@ -1070,8 +1147,7 @@ const make = Effect.gen(function* () {...@@ -1070,8 +1147,7 @@ const make = Effect.gen(function* () {
1070 implementationThreadId: ThreadId,1147 implementationThreadId: ThreadId,
1071 implementedAt: string,1148 implementedAt: string,
1072 ) {1149 ) {
1073 const readModel = yield* orchestrationEngine.getReadModel();1150 const sourceThread = yield* resolveThreadDetail(sourceThreadId);
1074 const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId);
1075 const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId);1151 const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId);
1076 if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) {1152 if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) {
1077 return;1153 return;
...@@ -1096,10 +1172,19 @@ const make = Effect.gen(function* () {...@@ -1096,10 +1172,19 @@ const make = Effect.gen(function* () {
10961172
1097 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>1173 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
1098 Effect.gen(function* () {1174 Effect.gen(function* () {
1099 const readModel = yield* orchestrationEngine.getReadModel();1175 const thread = yield* resolveThreadShell(event.threadId);
1100 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
1101 if (!thread) return;1176 if (!thread) return;
11021177
1178 let loadedThreadDetail: OrchestrationThread | null | undefined;
1179 const getLoadedThreadDetail = () =>
1180 Effect.gen(function* () {
1181 if (loadedThreadDetail !== undefined) {
1182 return loadedThreadDetail;
1183 }
1184 loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null;
1185 return loadedThreadDetail;
1186 });
1187
1103 const now = event.createdAt;1188 const now = event.createdAt;
1104 const eventTurnId = toTurnId(event.turnId);1189 const eventTurnId = toTurnId(event.turnId);
1105 const activeTurnId = thread.session?.activeTurnId ?? null;1190 const activeTurnId = thread.session?.activeTurnId ?? null;
...@@ -1277,6 +1362,7 @@ const make = Effect.gen(function* () {...@@ -1277,6 +1362,7 @@ const make = Effect.gen(function* () {
1277 ? toTurnId(event.turnId)1362 ? toTurnId(event.turnId)
1278 : undefined;1363 : undefined;
1279 if (pauseForUserTurnId) {1364 if (pauseForUserTurnId) {
1365 const detailedThread = yield* getLoadedThreadDetail();
1280 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1366 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1281 serverSettingsService.getSettings,1367 serverSettingsService.getSettings,
1282 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),1368 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
...@@ -1307,10 +1393,11 @@ const make = Effect.gen(function* () {...@@ -1307,10 +1393,11 @@ const make = Effect.gen(function* () {
1307 event.type === "request.opened"1393 event.type === "request.opened"
1308 ? "assistant-delta-finalize-on-request-opened"1394 ? "assistant-delta-finalize-on-request-opened"
1309 : "assistant-delta-finalize-on-user-input-requested",1395 : "assistant-delta-finalize-on-user-input-requested",
1310 hasProjectedMessage: thread.messages.some(1396 hasProjectedMessage:
1311 (entry) =>1397 detailedThread !== null &&
1312 entry.role === "assistant" && entry.turnId === pauseForUserTurnId && entry.streaming,1398 hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, {
1313 ),1399 streamingOnly: true,
1400 }),
1314 flushedMessageIds,1401 flushedMessageIds,
1315 });1402 });
1316 }1403 }
...@@ -1339,21 +1426,19 @@ const make = Effect.gen(function* () {...@@ -1339,21 +1426,19 @@ const make = Effect.gen(function* () {
1339 : undefined;1426 : undefined;
13401427
1341 if (assistantCompletion) {1428 if (assistantCompletion) {
1429 const detailedThread = yield* getLoadedThreadDetail();
1430 const messages = detailedThread?.messages ?? [];
1342 const turnId = toTurnId(event.turnId);1431 const turnId = toTurnId(event.turnId);
1343 const activeAssistantMessageId = turnId1432 const activeAssistantMessageId = turnId
1344 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)1433 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)
1345 : Option.none<MessageId>();1434 : Option.none<MessageId>();
1346 const hasAssistantMessagesForTurn =1435 const hasAssistantMessagesForTurn =
1347 turnId !== undefined1436 turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false;
1348 ? thread.messages.some((entry) => entry.role === "assistant" && entry.turnId === turnId)
1349 : false;
1350 const assistantMessageId = Option.getOrElse(1437 const assistantMessageId = Option.getOrElse(
1351 activeAssistantMessageId,1438 activeAssistantMessageId,
1352 () => assistantCompletion.messageId,1439 () => assistantCompletion.messageId,
1353 );1440 );
1354 const existingAssistantMessage = thread.messages.find(1441 const existingAssistantMessage = findMessageById(messages, assistantMessageId);
1355 (entry) => entry.id === assistantMessageId,
1356 );
1357 const shouldApplyFallbackCompletionText =1442 const shouldApplyFallbackCompletionText =
1358 !existingAssistantMessage || existingAssistantMessage.text.length === 0;1443 !existingAssistantMessage || existingAssistantMessage.text.length === 0;
13591444
...@@ -1393,10 +1478,11 @@ const make = Effect.gen(function* () {...@@ -1393,10 +1478,11 @@ const make = Effect.gen(function* () {
1393 }1478 }
13941479
1395 if (proposedPlanCompletion) {1480 if (proposedPlanCompletion) {
1481 const detailedThread = yield* getLoadedThreadDetail();
1396 yield* finalizeBufferedProposedPlan({1482 yield* finalizeBufferedProposedPlan({
1397 event,1483 event,
1398 threadId: thread.id,1484 threadId: thread.id,
1399 threadProposedPlans: thread.proposedPlans,1485 threadProposedPlans: detailedThread?.proposedPlans ?? [],
1400 planId: proposedPlanCompletion.planId,1486 planId: proposedPlanCompletion.planId,
1401 ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),1487 ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),
1402 fallbackMarkdown: proposedPlanCompletion.planMarkdown,1488 fallbackMarkdown: proposedPlanCompletion.planMarkdown,
...@@ -1405,6 +1491,9 @@ const make = Effect.gen(function* () {...@@ -1405,6 +1491,9 @@ const make = Effect.gen(function* () {
1405 }1491 }
14061492
1407 if (event.type === "turn.completed") {1493 if (event.type === "turn.completed") {
1494 const detailedThread = yield* getLoadedThreadDetail();
1495 const messages = detailedThread?.messages ?? [];
1496 const proposedPlans = detailedThread?.proposedPlans ?? [];
1408 const turnId = toTurnId(event.turnId);1497 const turnId = toTurnId(event.turnId);
1409 if (turnId) {1498 if (turnId) {
1410 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);1499 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
...@@ -1419,9 +1508,7 @@ const make = Effect.gen(function* () {...@@ -1419,9 +1508,7 @@ const make = Effect.gen(function* () {
1419 createdAt: now,1508 createdAt: now,
1420 commandTag: "assistant-complete-finalize",1509 commandTag: "assistant-complete-finalize",
1421 finalDeltaCommandTag: "assistant-delta-finalize-fallback",1510 finalDeltaCommandTag: "assistant-delta-finalize-fallback",
1422 hasProjectedMessage: thread.messages.some(1511 hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined,
1423 (entry) => entry.id === assistantMessageId,
1424 ),
1425 }),1512 }),
1426 { concurrency: 1 },1513 { concurrency: 1 },
1427 ).pipe(Effect.asVoid);1514 ).pipe(Effect.asVoid);
...@@ -1431,7 +1518,7 @@ const make = Effect.gen(function* () {...@@ -1431,7 +1518,7 @@ const make = Effect.gen(function* () {
1431 yield* finalizeBufferedProposedPlan({1518 yield* finalizeBufferedProposedPlan({
1432 event,1519 event,
1433 threadId: thread.id,1520 threadId: thread.id,
1434 threadProposedPlans: thread.proposedPlans,1521 threadProposedPlans: proposedPlans,
1435 planId: proposedPlanIdForTurn(thread.id, turnId),1522 planId: proposedPlanIdForTurn(thread.id, turnId),
1436 turnId,1523 turnId,
1437 updatedAt: now,1524 updatedAt: now,
...@@ -1483,21 +1570,24 @@ const make = Effect.gen(function* () {...@@ -1483,21 +1570,24 @@ const make = Effect.gen(function* () {
14831570
1484 if (event.type === "turn.diff.updated") {1571 if (event.type === "turn.diff.updated") {
1485 const turnId = toTurnId(event.turnId);1572 const turnId = toTurnId(event.turnId);
1486 if (turnId && (yield* isGitRepoForThread(thread.id))) {1573 const checkpointContext = turnId
1574 ? yield* projectionSnapshotQuery
1575 .getThreadCheckpointContext(thread.id)
1576 .pipe(Effect.map(Option.getOrUndefined))
1577 : undefined;
1578 const workspaceCwd =
1579 checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;
1580 if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) {
1487 // Skip if a checkpoint already exists for this turn. A real1581 // Skip if a checkpoint already exists for this turn. A real
1488 // (non-placeholder) capture from CheckpointReactor should not1582 // (non-placeholder) capture from CheckpointReactor should not
1489 // be clobbered, and dispatching a duplicate placeholder for the1583 // be clobbered, and dispatching a duplicate placeholder for the
1490 // same turnId would produce an unstable checkpointTurnCount.1584 // same turnId would produce an unstable checkpointTurnCount.
1491 if (thread.checkpoints.some((c) => c.turnId === turnId)) {1585 if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) {
1492 // Already tracked; no-op.1586 // Already tracked; no-op.
1493 } else {1587 } else {
1494 const assistantMessageId = MessageId.make(1588 const assistantMessageId = MessageId.make(
1495 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,1589 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1496 );1590 );
1497 const maxTurnCount = thread.checkpoints.reduce(
1498 (max, c) => Math.max(max, c.checkpointTurnCount),
1499 0,
1500 );
1501 yield* orchestrationEngine.dispatch({1591 yield* orchestrationEngine.dispatch({
1502 type: "thread.turn.diff.complete",1592 type: "thread.turn.diff.complete",
1503 commandId: providerCommandId(event, "thread-turn-diff-complete"),1593 commandId: providerCommandId(event, "thread-turn-diff-complete"),
...@@ -1508,7 +1598,7 @@ const make = Effect.gen(function* () {...@@ -1508,7 +1598,7 @@ const make = Effect.gen(function* () {
1508 status: "missing",1598 status: "missing",
1509 files: [],1599 files: [],
1510 assistantMessageId,1600 assistantMessageId,
1511 checkpointTurnCount: maxTurnCount + 1,1601 checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
1512 createdAt: now,1602 createdAt: now,
1513 });1603 });
1514 }1604 }
apps/server/src/orchestration/Services/OrchestrationEngine.ts+2-13
...@@ -10,11 +10,7 @@...@@ -10,11 +10,7 @@
10 *10 *
11 * @module OrchestrationEngineService11 * @module OrchestrationEngineService
12 */12 */
13import type {13import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts";
14 OrchestrationCommand,
15 OrchestrationEvent,
16 OrchestrationReadModel,
17} from "@t3tools/contracts";
18import { Context } from "effect";14import { Context } from "effect";
19import type { Effect, Stream } from "effect";15import type { Effect, Stream } from "effect";
2016
...@@ -25,13 +21,6 @@ import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";...@@ -25,13 +21,6 @@ import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
25 * OrchestrationEngineShape - Service API for orchestration command and event flow.21 * OrchestrationEngineShape - Service API for orchestration command and event flow.
26 */22 */
27export interface OrchestrationEngineShape {23export interface OrchestrationEngineShape {
28 /**
29 * Read the current in-memory orchestration read model.
30 *
31 * @returns Effect containing the latest read model.
32 */
33 readonly getReadModel: () => Effect.Effect<OrchestrationReadModel, never, never>;
34
35 /**24 /**
36 * Replay persisted orchestration events from an exclusive sequence cursor.25 * Replay persisted orchestration events from an exclusive sequence cursor.
37 *26 *
...@@ -70,7 +59,7 @@ export interface OrchestrationEngineShape {...@@ -70,7 +59,7 @@ export interface OrchestrationEngineShape {
70 * ```ts59 * ```ts
71 * const program = Effect.gen(function* () {60 * const program = Effect.gen(function* () {
72 * const engine = yield* OrchestrationEngineService61 * const engine = yield* OrchestrationEngineService
73 * return yield* engine.getReadModel()62 * return yield* engine.dispatch(command)
74 * })63 * })
75 * ```64 * ```
76 */65 */
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+22
...@@ -28,6 +28,10 @@ export interface ProjectionSnapshotCounts {...@@ -28,6 +28,10 @@ export interface ProjectionSnapshotCounts {
28 readonly threadCount: number;28 readonly threadCount: number;
29}29}
3030
31export interface ProjectionSnapshotSequence {
32 readonly snapshotSequence: number;
33}
34
31export interface ProjectionThreadCheckpointContext {35export interface ProjectionThreadCheckpointContext {
32 readonly threadId: ThreadId;36 readonly threadId: ThreadId;
33 readonly projectId: ProjectId;37 readonly projectId: ProjectId;
...@@ -40,6 +44,15 @@ export interface ProjectionThreadCheckpointContext {...@@ -40,6 +44,15 @@ export interface ProjectionThreadCheckpointContext {
40 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.44 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
41 */45 */
42export interface ProjectionSnapshotQueryShape {46export interface ProjectionSnapshotQueryShape {
47 /**
48 * Read the lightweight command snapshot used to bootstrap the in-memory
49 * orchestration engine without hydrating message/activity/checkpoint bodies.
50 */
51 readonly getCommandReadModel: () => Effect.Effect<
52 OrchestrationReadModel,
53 ProjectionRepositoryError
54 >;
55
43 /**56 /**
44 * Read the latest orchestration projection snapshot.57 * Read the latest orchestration projection snapshot.
45 *58 *
...@@ -59,6 +72,15 @@ export interface ProjectionSnapshotQueryShape {...@@ -59,6 +72,15 @@ export interface ProjectionSnapshotQueryShape {
59 ProjectionRepositoryError72 ProjectionRepositoryError
60 >;73 >;
6174
75 /**
76 * Read the latest projection snapshot sequence without hydrating read-model
77 * entities.
78 */
79 readonly getSnapshotSequence: () => Effect.Effect<
80 ProjectionSnapshotSequence,
81 ProjectionRepositoryError
82 >;
83
62 /**84 /**
63 * Read aggregate projection counts without hydrating the full read model.85 * Read aggregate projection counts without hydrating the full read model.
64 */86 */
apps/server/src/persistence/Migrations.ts+2
...@@ -41,6 +41,7 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro...@@ -41,6 +41,7 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro
41import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";41import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4445
45/**46/**
46 * Migration loader with all migrations defined inline.47 * Migration loader with all migrations defined inline.
...@@ -81,6 +82,7 @@ export const migrationEntries = [...@@ -81,6 +82,7 @@ export const migrationEntries = [
81 [26, "CanonicalizeModelSelectionOptions", Migration0026],82 [26, "CanonicalizeModelSelectionOptions", Migration0026],
82 [27, "ProviderSessionRuntimeInstanceId", Migration0027],83 [27, "ProviderSessionRuntimeInstanceId", Migration0027],
83 [28, "ProjectionThreadSessionInstanceId", Migration0028],84 [28, "ProjectionThreadSessionInstanceId", Migration0028],
85 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
84] as const;86] as const;
8587
86export const makeMigrationLoader = (throughId?: number) =>88export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts created+73
...@@ -0,0 +1,73 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {
11 it.effect("creates indexes matching thread detail ordering queries", () =>
12 Effect.gen(function* () {
13 const sql = yield* SqlClient.SqlClient;
14
15 yield* runMigrations({ toMigrationInclusive: 28 });
16 yield* runMigrations({ toMigrationInclusive: 29 });
17
18 const activityIndexes = yield* sql<{
19 readonly seq: number;
20 readonly name: string;
21 readonly unique: number;
22 readonly origin: string;
23 readonly partial: number;
24 }>`
25 PRAGMA index_list(projection_thread_activities)
26 `;
27 assert.ok(
28 activityIndexes.some(
29 (index) => index.name === "idx_projection_thread_activities_thread_sequence_created_id",
30 ),
31 );
32
33 const activityIndexColumns = yield* sql<{
34 readonly seqno: number;
35 readonly cid: number;
36 readonly name: string;
37 }>`
38 PRAGMA index_info('idx_projection_thread_activities_thread_sequence_created_id')
39 `;
40 assert.deepStrictEqual(
41 activityIndexColumns.map((column) => column.name),
42 ["thread_id", "sequence", "created_at", "activity_id"],
43 );
44
45 const messageIndexes = yield* sql<{
46 readonly seq: number;
47 readonly name: string;
48 readonly unique: number;
49 readonly origin: string;
50 readonly partial: number;
51 }>`
52 PRAGMA index_list(projection_thread_messages)
53 `;
54 assert.ok(
55 messageIndexes.some(
56 (index) => index.name === "idx_projection_thread_messages_thread_created_id",
57 ),
58 );
59
60 const messageIndexColumns = yield* sql<{
61 readonly seqno: number;
62 readonly cid: number;
63 readonly name: string;
64 }>`
65 PRAGMA index_info('idx_projection_thread_messages_thread_created_id')
66 `;
67 assert.deepStrictEqual(
68 messageIndexColumns.map((column) => column.name),
69 ["thread_id", "created_at", "message_id"],
70 );
71 }),
72 );
73});
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence_created_id
9 ON projection_thread_activities(thread_id, sequence, created_at, activity_id)
10 `;
11
12 yield* sql`
13 CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created_id
14 ON projection_thread_messages(thread_id, created_at, message_id)
15 `;
16});
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+44-54
...@@ -1,53 +1,52 @@...@@ -1,53 +1,52 @@
1import { Effect, Layer, Stream } from "effect";1import { ProjectId, type OrchestrationProject } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";
2import { describe, expect, it, vi } from "vitest";3import { describe, expect, it, vi } from "vitest";
3import type { OrchestrationReadModel } from "@t3tools/contracts";
44
5import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";5import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
6import { TerminalManager } from "../../terminal/Services/Manager.ts";6import { TerminalManager } from "../../terminal/Services/Manager.ts";
7import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";7import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
8import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";8import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
99
10const emptySnapshot = (10const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({
11 scripts: OrchestrationReadModel["projects"][number]["scripts"],11 id: ProjectId.make("project-1"),
12): OrchestrationReadModel =>12 title: "Project",
13 ({13 workspaceRoot: "/repo/project",
14 snapshotSequence: 1,14 defaultModelSelection: null,
15 updatedAt: "2026-01-01T00:00:00.000Z",15 scripts,
16 projects: [16 createdAt: "2026-01-01T00:00:00.000Z",
17 {17 updatedAt: "2026-01-01T00:00:00.000Z",
18 id: "project-1",18 deletedAt: null,
19 title: "Project",19});
20 workspaceRoot: "/repo/project",20
21 defaultModelSelection: null,21const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
22 scripts,22 Layer.succeed(ProjectionSnapshotQuery, {
23 createdAt: "2026-01-01T00:00:00.000Z",23 getCommandReadModel: () => Effect.die("unused"),
24 updatedAt: "2026-01-01T00:00:00.000Z",24 getSnapshot: () => Effect.die("unused"),
25 deletedAt: null,25 getShellSnapshot: () => Effect.die("unused"),
26 },26 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
27 ],27 getCounts: () => Effect.die("unused"),
28 threads: [],28 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
29 providerSessions: [],29 Effect.succeed(
30 providerStatuses: [],30 workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(),
31 pendingApprovals: [],31 ),
32 latestTurnByThreadId: {},32 getProjectShellById: (projectId) =>
33 }) as unknown as OrchestrationReadModel;33 Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
34 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
35 getThreadCheckpointContext: () => Effect.die("unused"),
36 getThreadShellById: () => Effect.die("unused"),
37 getThreadDetailById: () => Effect.die("unused"),
38 });
3439
35describe("ProjectSetupScriptRunner", () => {40describe("ProjectSetupScriptRunner", () => {
36 it("returns no-script when no setup script exists", async () => {41 it("returns no-script when no setup script exists", async () => {
37 const open = vi.fn();42 const open = vi.fn();
38 const write = vi.fn();43 const write = vi.fn();
44 const project = makeProject([]);
39 const runner = await Effect.runPromise(45 const runner = await Effect.runPromise(
40 Effect.service(ProjectSetupScriptRunner).pipe(46 Effect.service(ProjectSetupScriptRunner).pipe(
41 Effect.provide(47 Effect.provide(
42 ProjectSetupScriptRunnerLive.pipe(48 ProjectSetupScriptRunnerLive.pipe(
43 Layer.provideMerge(49 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
44 Layer.succeed(OrchestrationEngineService, {
45 getReadModel: () => Effect.succeed(emptySnapshot([])),
46 readEvents: () => Stream.empty,
47 dispatch: () => Effect.die(new Error("unused")),
48 streamDomainEvents: Stream.empty,
49 }),
50 ),
51 Layer.provideMerge(50 Layer.provideMerge(
52 Layer.succeed(TerminalManager, {51 Layer.succeed(TerminalManager, {
53 open,52 open,
...@@ -93,29 +92,20 @@ describe("ProjectSetupScriptRunner", () => {...@@ -93,29 +92,20 @@ describe("ProjectSetupScriptRunner", () => {
93 }),92 }),
94 );93 );
95 const write = vi.fn(() => Effect.void);94 const write = vi.fn(() => Effect.void);
95 const project = makeProject([
96 {
97 id: "setup",
98 name: "Setup",
99 command: "bun install",
100 icon: "configure",
101 runOnWorktreeCreate: true,
102 },
103 ]);
96 const runner = await Effect.runPromise(104 const runner = await Effect.runPromise(
97 Effect.service(ProjectSetupScriptRunner).pipe(105 Effect.service(ProjectSetupScriptRunner).pipe(
98 Effect.provide(106 Effect.provide(
99 ProjectSetupScriptRunnerLive.pipe(107 ProjectSetupScriptRunnerLive.pipe(
100 Layer.provideMerge(108 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
101 Layer.succeed(OrchestrationEngineService, {
102 getReadModel: () =>
103 Effect.succeed(
104 emptySnapshot([
105 {
106 id: "setup",
107 name: "Setup",
108 command: "bun install",
109 icon: "configure",
110 runOnWorktreeCreate: true,
111 },
112 ]),
113 ),
114 readEvents: () => Stream.empty,
115 dispatch: () => Effect.die(new Error("unused")),
116 streamDomainEvents: Stream.empty,
117 }),
118 ),
119 Layer.provideMerge(109 Layer.provideMerge(
120 Layer.succeed(TerminalManager, {110 Layer.succeed(TerminalManager, {
121 open,111 open,
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts+10-6
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { ProjectId } from "@t3tools/contracts";
1import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";2import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
2import { Effect, Layer } from "effect";3import { Effect, Layer, Option } from "effect";
34
4import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";5import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
5import { TerminalManager } from "../../terminal/Services/Manager.ts";6import { TerminalManager } from "../../terminal/Services/Manager.ts";
6import {7import {
7 type ProjectSetupScriptRunnerShape,8 type ProjectSetupScriptRunnerShape,
...@@ -9,18 +10,21 @@ import {...@@ -9,18 +10,21 @@ import {
9} from "../Services/ProjectSetupScriptRunner.ts";10} from "../Services/ProjectSetupScriptRunner.ts";
1011
11const makeProjectSetupScriptRunner = Effect.gen(function* () {12const makeProjectSetupScriptRunner = Effect.gen(function* () {
12 const orchestrationEngine = yield* OrchestrationEngineService;13 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
13 const terminalManager = yield* TerminalManager;14 const terminalManager = yield* TerminalManager;
1415
15 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>16 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
16 Effect.gen(function* () {17 Effect.gen(function* () {
17 const readModel = yield* orchestrationEngine.getReadModel();
18 const project =18 const project =
19 (input.projectId19 (input.projectId
20 ? readModel.projects.find((entry) => entry.id === input.projectId)20 ? yield* projectionSnapshotQuery
21 .getProjectShellById(ProjectId.make(input.projectId))
22 .pipe(Effect.map(Option.getOrUndefined))
21 : null) ??23 : null) ??
22 (input.projectCwd24 (input.projectCwd
23 ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd)25 ? yield* projectionSnapshotQuery
26 .getActiveProjectByWorkspaceRoot(input.projectCwd)
27 .pipe(Effect.map(Option.getOrUndefined))
24 : null) ??28 : null) ??
25 null;29 null;
2630
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+26-12
...@@ -9,10 +9,7 @@ import {...@@ -9,10 +9,7 @@ import {
9import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";9import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
10import { afterEach, describe, expect, it, vi } from "vitest";10import { afterEach, describe, expect, it, vi } from "vitest";
1111
12import {12import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
13 OrchestrationEngineService,
14 type OrchestrationEngineShape,
15} from "../../orchestration/Services/OrchestrationEngine.ts";
16import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";13import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";14import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
18import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";15import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
...@@ -92,6 +89,10 @@ function makeReadModel(...@@ -92,6 +89,10 @@ function makeReadModel(
92 createdAt: now,89 createdAt: now,
93 updatedAt: now,90 updatedAt: now,
94 archivedAt: null,91 archivedAt: null,
92 latestUserMessageAt: null,
93 hasPendingApprovals: false,
94 hasPendingUserInput: false,
95 hasActionableProposedPlan: false,
95 latestTurn: null,96 latestTurn: null,
96 messages: [],97 messages: [],
97 session: thread.session,98 session: thread.session,
...@@ -163,13 +164,6 @@ describe("ProviderSessionReaper", () => {...@@ -163,13 +164,6 @@ describe("ProviderSessionReaper", () => {
163 streamEvents: Stream.empty,164 streamEvents: Stream.empty,
164 };165 };
165166
166 const orchestrationEngine: OrchestrationEngineShape = {
167 getReadModel: () => Effect.succeed(input.readModel),
168 readEvents: () => Stream.empty,
169 dispatch: () => unsupported(),
170 streamDomainEvents: Stream.empty,
171 };
172
173 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(167 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
174 Layer.provide(SqlitePersistenceMemory),168 Layer.provide(SqlitePersistenceMemory),
175 );169 );
...@@ -183,7 +177,27 @@ describe("ProviderSessionReaper", () => {...@@ -183,7 +177,27 @@ describe("ProviderSessionReaper", () => {
183 Layer.provideMerge(providerSessionDirectoryLayer),177 Layer.provideMerge(providerSessionDirectoryLayer),
184 Layer.provideMerge(runtimeRepositoryLayer),178 Layer.provideMerge(runtimeRepositoryLayer),
185 Layer.provideMerge(Layer.succeed(ProviderService, providerService)),179 Layer.provideMerge(Layer.succeed(ProviderService, providerService)),
186 Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)),180 Layer.provideMerge(
181 Layer.succeed(ProjectionSnapshotQuery, {
182 getCommandReadModel: () => Effect.die("unused"),
183 getSnapshot: () => Effect.die("unused"),
184 getShellSnapshot: () => Effect.die("unused"),
185 getSnapshotSequence: () =>
186 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),
187 getCounts: () => Effect.die("unused"),
188 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
189 getProjectShellById: () => Effect.die("unused"),
190 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
191 getThreadCheckpointContext: () => Effect.die("unused"),
192 getThreadShellById: (threadId) =>
193 Effect.succeed(
194 input.readModel.threads.find((thread) => thread.id === threadId)
195 ? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!)
196 : Option.none(),
197 ),
198 getThreadDetailById: () => Effect.die("unused"),
199 }),
200 ),
187 Layer.provideMerge(NodeServices.layer),201 Layer.provideMerge(NodeServices.layer),
188 );202 );
189203
apps/server/src/provider/Layers/ProviderSessionReaper.ts+6-6
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { Duration, Effect, Layer, Schedule } from "effect";1import { Duration, Effect, Layer, Option, Schedule } from "effect";
22
3import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";3import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
4import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";4import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
5import {5import {
6 ProviderSessionReaper,6 ProviderSessionReaper,
...@@ -20,7 +20,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =...@@ -20,7 +20,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
20 Effect.gen(function* () {20 Effect.gen(function* () {
21 const providerService = yield* ProviderService;21 const providerService = yield* ProviderService;
22 const directory = yield* ProviderSessionDirectory;22 const directory = yield* ProviderSessionDirectory;
23 const orchestrationEngine = yield* OrchestrationEngineService;23 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2424
25 const inactivityThresholdMs = Math.max(25 const inactivityThresholdMs = Math.max(
26 1,26 1,
...@@ -29,8 +29,6 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =...@@ -29,8 +29,6 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
29 const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);29 const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);
3030
31 const sweep = Effect.gen(function* () {31 const sweep = Effect.gen(function* () {
32 const readModel = yield* orchestrationEngine.getReadModel();
33 const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const));
34 const bindings = yield* directory.listBindings();32 const bindings = yield* directory.listBindings();
35 const now = Date.now();33 const now = Date.now();
36 let reapedCount = 0;34 let reapedCount = 0;
...@@ -55,7 +53,9 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =...@@ -55,7 +53,9 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
55 continue;53 continue;
56 }54 }
5755
58 const thread = threadsById.get(binding.threadId);56 const thread = yield* projectionSnapshotQuery
57 .getThreadShellById(binding.threadId)
58 .pipe(Effect.map(Option.getOrUndefined));
59 if (thread?.session?.activeTurnId != null) {59 if (thread?.session?.activeTurnId != null) {
60 yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", {60 yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", {
61 threadId: binding.threadId,61 threadId: binding.threadId,
apps/server/src/server.test.ts+2-1
...@@ -559,7 +559,6 @@ const buildAppUnderTest = (options?: {...@@ -559,7 +559,6 @@ const buildAppUnderTest = (options?: {
559 ),559 ),
560 Layer.provide(560 Layer.provide(
561 Layer.mock(OrchestrationEngineService)({561 Layer.mock(OrchestrationEngineService)({
562 getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
563 readEvents: () => Stream.empty,562 readEvents: () => Stream.empty,
564 dispatch: () => Effect.succeed({ sequence: 0 }),563 dispatch: () => Effect.succeed({ sequence: 0 }),
565 streamDomainEvents: Stream.empty,564 streamDomainEvents: Stream.empty,
...@@ -568,6 +567,7 @@ const buildAppUnderTest = (options?: {...@@ -568,6 +567,7 @@ const buildAppUnderTest = (options?: {
568 ),567 ),
569 Layer.provide(568 Layer.provide(
570 Layer.mock(ProjectionSnapshotQuery)({569 Layer.mock(ProjectionSnapshotQuery)({
570 getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
571 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),571 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
572 getShellSnapshot: () =>572 getShellSnapshot: () =>
573 Effect.succeed({573 Effect.succeed({
...@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: {...@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: {
576 threads: [],576 threads: [],
577 updatedAt: new Date(0).toISOString(),577 updatedAt: new Date(0).toISOString(),
578 }),578 }),
579 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
579 getProjectShellById: () => Effect.succeed(Option.none()),580 getProjectShellById: () => Effect.succeed(Option.none()),
580 getThreadShellById: () => Effect.succeed(Option.none()),581 getThreadShellById: () => Effect.succeed(Option.none()),
581 getThreadDetailById: () => Effect.succeed(Option.none()),582 getThreadDetailById: () => Effect.succeed(Option.none()),
apps/server/src/serverRuntimeStartup.test.ts+6-2
...@@ -77,8 +77,10 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -77,8 +77,10 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
7777
78 yield* launchStartupHeartbeat.pipe(78 yield* launchStartupHeartbeat.pipe(
79 Effect.provideService(ProjectionSnapshotQuery, {79 Effect.provideService(ProjectionSnapshotQuery, {
80 getCommandReadModel: () => Effect.die("unused"),
80 getSnapshot: () => Effect.die("unused"),81 getSnapshot: () => Effect.die("unused"),
81 getShellSnapshot: () => Effect.die("unused"),82 getShellSnapshot: () => Effect.die("unused"),
83 getSnapshotSequence: () => Effect.die("unused"),
82 getCounts: () =>84 getCounts: () =>
83 Deferred.await(releaseCounts).pipe(85 Deferred.await(releaseCounts).pipe(
84 Effect.as({86 Effect.as({
...@@ -129,8 +131,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -129,8 +131,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
129 autoBootstrapProjectFromCwd: true,131 autoBootstrapProjectFromCwd: true,
130 } as never),132 } as never),
131 Effect.provideService(ProjectionSnapshotQuery, {133 Effect.provideService(ProjectionSnapshotQuery, {
134 getCommandReadModel: () => Effect.die("unused"),
132 getSnapshot: () => Effect.die("unused"),135 getSnapshot: () => Effect.die("unused"),
133 getShellSnapshot: () => Effect.die("unused"),136 getShellSnapshot: () => Effect.die("unused"),
137 getSnapshotSequence: () => Effect.die("unused"),
134 getCounts: () => Effect.die("unused"),138 getCounts: () => Effect.die("unused"),
135 getActiveProjectByWorkspaceRoot: () =>139 getActiveProjectByWorkspaceRoot: () =>
136 Effect.succeed(140 Effect.succeed(
...@@ -152,7 +156,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -152,7 +156,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
152 getThreadDetailById: () => Effect.die("unused"),156 getThreadDetailById: () => Effect.die("unused"),
153 }),157 }),
154 Effect.provideService(OrchestrationEngineService, {158 Effect.provideService(OrchestrationEngineService, {
155 getReadModel: () => Effect.die("unused"),
156 readEvents: () => Stream.empty,159 readEvents: () => Stream.empty,
157 dispatch: (command) =>160 dispatch: (command) =>
158 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(161 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
...@@ -180,8 +183,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -180,8 +183,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
180 autoBootstrapProjectFromCwd: true,183 autoBootstrapProjectFromCwd: true,
181 } as never),184 } as never),
182 Effect.provideService(ProjectionSnapshotQuery, {185 Effect.provideService(ProjectionSnapshotQuery, {
186 getCommandReadModel: () => Effect.die("unused"),
183 getSnapshot: () => Effect.die("unused"),187 getSnapshot: () => Effect.die("unused"),
184 getShellSnapshot: () => Effect.die("unused"),188 getShellSnapshot: () => Effect.die("unused"),
189 getSnapshotSequence: () => Effect.die("unused"),
185 getCounts: () => Effect.die("unused"),190 getCounts: () => Effect.die("unused"),
186 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),191 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
187 getProjectShellById: () => Effect.die("unused"),192 getProjectShellById: () => Effect.die("unused"),
...@@ -191,7 +196,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -191,7 +196,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
191 getThreadDetailById: () => Effect.die("unused"),196 getThreadDetailById: () => Effect.die("unused"),
192 }),197 }),
193 Effect.provideService(OrchestrationEngineService, {198 Effect.provideService(OrchestrationEngineService, {
194 getReadModel: () => Effect.die("unused"),
195 readEvents: () => Stream.empty,199 readEvents: () => Stream.empty,
196 dispatch: (command) =>200 dispatch: (command) =>
197 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(201 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
apps/server/src/ws.ts+18-7
...@@ -236,9 +236,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -236,9 +236,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
236 return Effect.gen(function* () {236 return Effect.gen(function* () {
237 const workspaceRoot =237 const workspaceRoot =
238 event.payload.workspaceRoot ??238 event.payload.workspaceRoot ??
239 (yield* orchestrationEngine.getReadModel()).projects.find(239 Option.match(
240 (project) => project.id === event.payload.projectId,240 yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId),
241 )?.workspaceRoot ??241 {
242 onNone: () => null,
243 onSome: (project) => project.workspaceRoot,
244 },
245 ) ??
242 null;246 null;
243 if (workspaceRoot === null) {247 if (workspaceRoot === null) {
244 return event;248 return event;
...@@ -252,7 +256,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -252,7 +256,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
252 repositoryIdentity,256 repositoryIdentity,
253 },257 },
254 } satisfies OrchestrationEvent;258 } satisfies OrchestrationEvent;
255 });259 }).pipe(Effect.catch(() => Effect.succeed(event)));
256 default:260 default:
257 return Effect.succeed(event);261 return Effect.succeed(event);
258 }262 }
...@@ -723,9 +727,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -723,9 +727,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
723 }),727 }),
724 ),728 ),
725 ),729 ),
726 orchestrationEngine730 projectionSnapshotQuery.getSnapshotSequence().pipe(
727 .getReadModel()731 Effect.map(({ snapshotSequence }) => snapshotSequence),
728 .pipe(Effect.map((readModel) => readModel.snapshotSequence)),732 Effect.mapError(
733 (cause) =>
734 new OrchestrationGetSnapshotError({
735 message: "Failed to load orchestration snapshot sequence",
736 cause,
737 }),
738 ),
739 ),
729 ]);740 ]);
730741
731 if (Option.isNone(threadDetail)) {742 if (Option.isNone(threadDetail)) {
apps/web/src/environments/runtime/connection.test.ts+33-4
...@@ -22,14 +22,14 @@ function createTestClient() {...@@ -22,14 +22,14 @@ function createTestClient() {
22 environmentId: EnvironmentId.make("env-1"),22 environmentId: EnvironmentId.make("env-1"),
23 },23 },
24 })),24 })),
25 subscribeConfig: (listener: (event: any) => void) => {25 subscribeConfig: vi.fn((listener: (event: any) => void) => {
26 configListeners.add(listener);26 configListeners.add(listener);
27 return () => configListeners.delete(listener);27 return () => configListeners.delete(listener);
28 },28 }),
29 subscribeLifecycle: (listener: (event: any) => void) => {29 subscribeLifecycle: vi.fn((listener: (event: any) => void) => {
30 lifecycleListeners.add(listener);30 lifecycleListeners.add(listener);
31 return () => lifecycleListeners.delete(listener);31 return () => lifecycleListeners.delete(listener);
32 },32 }),
33 subscribeAuthAccess: () => () => undefined,33 subscribeAuthAccess: () => () => undefined,
34 refreshProviders: vi.fn(async () => undefined),34 refreshProviders: vi.fn(async () => undefined),
35 upsertKeybinding: vi.fn(async () => undefined),35 upsertKeybinding: vi.fn(async () => undefined),
...@@ -237,4 +237,33 @@ describe("createEnvironmentConnection", () => {...@@ -237,4 +237,33 @@ describe("createEnvironmentConnection", () => {
237237
238 await connection.dispose();238 await connection.dispose();
239 });239 });
240
241 it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => {
242 const environmentId = EnvironmentId.make("env-1");
243 const { client } = createTestClient();
244
245 const connection = createEnvironmentConnection({
246 kind: "primary",
247 knownEnvironment: {
248 id: "env-1",
249 label: "Local env",
250 source: "manual",
251 target: {
252 httpBaseUrl: "http://example.test",
253 wsBaseUrl: "ws://example.test",
254 },
255 environmentId,
256 },
257 client,
258 applyShellEvent: vi.fn(),
259 syncShellSnapshot: vi.fn(),
260 applyTerminalEvent: vi.fn(),
261 });
262
263 expect(client.server.subscribeLifecycle).not.toHaveBeenCalled();
264 expect(client.server.subscribeConfig).not.toHaveBeenCalled();
265 expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce();
266
267 await connection.dispose();
268 });
240});269});
apps/web/src/environments/runtime/connection.ts+31-22
...@@ -83,6 +83,8 @@ export function createEnvironmentConnection(...@@ -83,6 +83,8 @@ export function createEnvironmentConnection(
8383
84 let disposed = false;84 let disposed = false;
85 const bootstrapGate = createBootstrapGate();85 const bootstrapGate = createBootstrapGate();
86 const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined;
87 const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined;
8688
87 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {89 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {
88 if (environmentId !== nextEnvironmentId) {90 if (environmentId !== nextEnvironmentId) {
...@@ -92,28 +94,35 @@ export function createEnvironmentConnection(...@@ -92,28 +94,35 @@ export function createEnvironmentConnection(
92 }94 }
93 };95 };
9496
95 const unsubLifecycle = input.client.server.subscribeLifecycle(97 const unsubLifecycle = shouldObserveLifecycle
96 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {98 ? input.client.server.subscribeLifecycle(
97 if (event.type !== "welcome") {99 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {
98 return;100 if (event.type !== "welcome") {
99 }101 return;
100 observeEnvironmentIdentity(102 }
101 event.payload.environment.environmentId,103 observeEnvironmentIdentity(
102 "server lifecycle welcome",104 event.payload.environment.environmentId,
103 );105 "server lifecycle welcome",
104 input.onWelcome?.(event.payload);106 );
105 },107 input.onWelcome?.(event.payload);
106 );108 },
107109 )
108 const unsubConfig = input.client.server.subscribeConfig(110 : () => undefined;
109 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => {111
110 if (event.type !== "snapshot") {112 const unsubConfig = shouldObserveConfig
111 return;113 ? input.client.server.subscribeConfig(
112 }114 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => {
113 observeEnvironmentIdentity(event.config.environment.environmentId, "server config snapshot");115 if (event.type !== "snapshot") {
114 input.onConfigSnapshot?.(event.config);116 return;
115 },117 }
116 );118 observeEnvironmentIdentity(
119 event.config.environment.environmentId,
120 "server config snapshot",
121 );
122 input.onConfigSnapshot?.(event.config);
123 },
124 )
125 : () => undefined;
117126
118 const unsubShell = input.client.orchestration.subscribeShell(127 const unsubShell = input.client.orchestration.subscribeShell(
119 (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => {128 (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => {
apps/web/src/environments/runtime/service.savedEnvironments.test.ts created+326
...@@ -0,0 +1,326 @@
1import { QueryClient } from "@tanstack/react-query";
2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5const mockCreateEnvironmentConnection = vi.fn();
6const mockCreateWsRpcClient = vi.fn();
7const mockFetchRemoteSessionState = vi.fn();
8const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
9const mockListSavedEnvironmentRecords = vi.fn();
10const mockSavedEnvironmentRegistrySubscribe = vi.fn();
11const mockReadSavedEnvironmentBearerToken = vi.fn();
12const mockGetSavedEnvironmentRecord = vi.fn();
13
14function MockWsTransport() {
15 return undefined;
16}
17
18vi.mock("../primary", () => ({
19 getPrimaryKnownEnvironment: vi.fn(() => ({
20 id: "env-1",
21 label: "Primary environment",
22 source: "window-origin",
23 target: {
24 httpBaseUrl: "http://127.0.0.1:3000/",
25 wsBaseUrl: "ws://127.0.0.1:3000/",
26 },
27 environmentId: EnvironmentId.make("env-1"),
28 })),
29}));
30
31vi.mock("../remote/api", () => ({
32 bootstrapRemoteBearerSession: vi.fn(),
33 fetchRemoteEnvironmentDescriptor: vi.fn(),
34 fetchRemoteSessionState: mockFetchRemoteSessionState,
35 resolveRemoteWebSocketConnectionUrl: vi.fn(() => "ws://remote.example.test"),
36}));
37
38vi.mock("./catalog", () => ({
39 getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
40 hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),
41 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
42 persistSavedEnvironmentRecord: vi.fn(),
43 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
44 removeSavedEnvironmentBearerToken: vi.fn(),
45 useSavedEnvironmentRegistryStore: {
46 subscribe: mockSavedEnvironmentRegistrySubscribe,
47 getState: () => ({
48 upsert: vi.fn(),
49 remove: vi.fn(),
50 markConnected: vi.fn(),
51 rename: vi.fn(),
52 }),
53 },
54 useSavedEnvironmentRuntimeStore: {
55 getState: () => ({
56 ensure: vi.fn(),
57 patch: vi.fn(),
58 clear: vi.fn(),
59 }),
60 },
61 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
62 writeSavedEnvironmentBearerToken: vi.fn(),
63}));
64
65vi.mock("./connection", () => ({
66 createEnvironmentConnection: mockCreateEnvironmentConnection,
67}));
68
69vi.mock("../../rpc/wsRpcClient", () => ({
70 createWsRpcClient: mockCreateWsRpcClient,
71}));
72
73vi.mock("../../rpc/wsTransport", () => ({
74 WsTransport: MockWsTransport,
75}));
76
77vi.mock("~/composerDraftStore", () => ({
78 markPromotedDraftThreadByRef: vi.fn(),
79 markPromotedDraftThreadsByRef: vi.fn(),
80 useComposerDraftStore: {
81 getState: () => ({
82 getDraftThreadByRef: vi.fn(() => null),
83 clearDraftThread: vi.fn(),
84 }),
85 },
86}));
87
88vi.mock("~/localApi", () => ({
89 ensureLocalApi: vi.fn(() => ({
90 persistence: {
91 setSavedEnvironmentRegistry: vi.fn(async () => undefined),
92 },
93 })),
94}));
95
96vi.mock("~/lib/terminalStateCleanup", () => ({
97 collectActiveTerminalThreadIds: vi.fn(() => []),
98}));
99
100vi.mock("~/orchestrationEventEffects", () => ({
101 deriveOrchestrationBatchEffects: vi.fn(() => ({
102 promotedThreadRefs: [],
103 invalidatedProviderState: false,
104 })),
105}));
106
107vi.mock("~/lib/projectReactQuery", () => ({
108 projectQueryKeys: {
109 all: ["projects"],
110 },
111}));
112
113vi.mock("~/lib/providerReactQuery", () => ({
114 providerQueryKeys: {
115 all: ["providers"],
116 },
117}));
118
119vi.mock("~/store", () => ({
120 useStore: {
121 getState: () => ({
122 syncServerShellSnapshot: vi.fn(),
123 syncServerThreadDetail: vi.fn(),
124 removeServerThreadDetail: vi.fn(),
125 applyServerShellEvent: vi.fn(),
126 }),
127 },
128 selectProjectsAcrossEnvironments: vi.fn(() => []),
129 selectSidebarThreadSummaryByRef: vi.fn(() => null),
130 selectThreadByRef: vi.fn(() => null),
131 selectThreadsAcrossEnvironments: vi.fn(() => []),
132}));
133
134vi.mock("~/terminalStateStore", () => ({
135 useTerminalStateStore: {
136 getState: () => ({
137 applyTerminalEvent: vi.fn(),
138 removeTerminalState: vi.fn(),
139 clearTerminalSelection: vi.fn(),
140 }),
141 },
142}));
143
144vi.mock("~/uiStateStore", () => ({
145 useUiStateStore: {
146 getState: () => ({
147 clearThreadUi: vi.fn(),
148 syncPromotedDraftThreadRefs: vi.fn(),
149 }),
150 },
151}));
152
153const savedRecord = {
154 environmentId: EnvironmentId.make("env-saved"),
155 label: "Remote environment",
156 httpBaseUrl: "https://remote.example.test/",
157 wsBaseUrl: "wss://remote.example.test/",
158};
159
160const configSnapshot = {
161 environment: {
162 environmentId: savedRecord.environmentId,
163 label: "Remote environment",
164 },
165};
166
167function createClient() {
168 return {
169 dispose: vi.fn(async () => undefined),
170 reconnect: vi.fn(async () => undefined),
171 server: {
172 getConfig: vi.fn(async () => configSnapshot),
173 subscribeConfig: vi.fn(() => () => undefined),
174 subscribeLifecycle: vi.fn(() => () => undefined),
175 subscribeAuthAccess: vi.fn(() => () => undefined),
176 refreshProviders: vi.fn(async () => undefined),
177 upsertKeybinding: vi.fn(async () => undefined),
178 getSettings: vi.fn(async () => undefined),
179 updateSettings: vi.fn(async () => undefined),
180 },
181 orchestration: {
182 subscribeShell: vi.fn(() => () => undefined),
183 subscribeThread: vi.fn(() => () => undefined),
184 dispatchCommand: vi.fn(async () => undefined),
185 getTurnDiff: vi.fn(async () => undefined),
186 getFullThreadDiff: vi.fn(async () => undefined),
187 },
188 terminal: {
189 open: vi.fn(async () => undefined),
190 write: vi.fn(async () => undefined),
191 resize: vi.fn(async () => undefined),
192 clear: vi.fn(async () => undefined),
193 restart: vi.fn(async () => undefined),
194 close: vi.fn(async () => undefined),
195 onEvent: vi.fn(() => () => undefined),
196 },
197 projects: {
198 searchEntries: vi.fn(async () => []),
199 writeFile: vi.fn(async () => undefined),
200 },
201 shell: {
202 openInEditor: vi.fn(async () => undefined),
203 },
204 git: {
205 pull: vi.fn(async () => undefined),
206 refreshStatus: vi.fn(async () => undefined),
207 onStatus: vi.fn(() => () => undefined),
208 runStackedAction: vi.fn(async () => ({})),
209 listBranches: vi.fn(async () => []),
210 createWorktree: vi.fn(async () => undefined),
211 removeWorktree: vi.fn(async () => undefined),
212 createBranch: vi.fn(async () => undefined),
213 checkout: vi.fn(async () => undefined),
214 init: vi.fn(async () => undefined),
215 resolvePullRequest: vi.fn(async () => undefined),
216 preparePullRequestThread: vi.fn(async () => undefined),
217 },
218 };
219}
220
221describe("saved environment startup", () => {
222 beforeEach(() => {
223 vi.useFakeTimers();
224 vi.resetModules();
225 vi.clearAllMocks();
226
227 mockFetchRemoteSessionState.mockResolvedValue({
228 authenticated: true,
229 role: "owner",
230 });
231 mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) =>
232 environmentId === savedRecord.environmentId ? savedRecord : null,
233 );
234 mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]);
235 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
236 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
237 mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");
238 mockCreateWsRpcClient.mockImplementation(() => createClient());
239 mockCreateEnvironmentConnection.mockImplementation((input) => {
240 if (input.kind === "saved") {
241 queueMicrotask(() => {
242 input.onConfigSnapshot?.(configSnapshot);
243 });
244 }
245
246 return {
247 kind: input.kind,
248 environmentId: input.knownEnvironment.environmentId,
249 knownEnvironment: input.knownEnvironment,
250 client: input.client,
251 ensureBootstrapped: vi.fn(async () => undefined),
252 reconnect: vi.fn(async () => undefined),
253 dispose: vi.fn(async () => undefined),
254 };
255 });
256 });
257
258 afterEach(async () => {
259 const { resetEnvironmentServiceForTests } = await import("./service");
260 await resetEnvironmentServiceForTests();
261 vi.useRealTimers();
262 });
263
264 it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => {
265 const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } =
266 await import("./service");
267
268 const stop = startEnvironmentConnectionService(new QueryClient());
269 await vi.runAllTimersAsync();
270
271 const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find(
272 ([input]) => input.kind === "saved",
273 );
274 expect(savedConnectionCall).toBeDefined();
275
276 const savedClient = savedConnectionCall?.[0]?.client;
277 expect(savedClient.server.getConfig).not.toHaveBeenCalled();
278 expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1);
279
280 stop();
281 await resetEnvironmentServiceForTests();
282 });
283
284 it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => {
285 let finishHydration!: () => void;
286 let finishTokenRead!: (token: string) => void;
287
288 mockWaitForSavedEnvironmentRegistryHydration.mockImplementation(
289 () =>
290 new Promise<void>((resolve) => {
291 finishHydration = () => resolve();
292 }),
293 );
294 mockReadSavedEnvironmentBearerToken.mockImplementation(
295 () =>
296 new Promise<string>((resolve) => {
297 finishTokenRead = resolve;
298 }),
299 );
300
301 const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } =
302 await import("./service");
303
304 const stop = startEnvironmentConnectionService(new QueryClient());
305 const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0];
306 expect(registryListener).toBeTypeOf("function");
307
308 registryListener?.();
309 finishHydration();
310 await vi.waitFor(() => {
311 expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1);
312 });
313
314 finishTokenRead("saved-bearer-token");
315 await vi.runAllTimersAsync();
316
317 const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter(
318 ([input]) => input.kind === "saved",
319 );
320 expect(savedConnectionCalls).toHaveLength(1);
321 expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1);
322
323 stop();
324 await resetEnvironmentServiceForTests();
325 });
326});
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+3-1
...@@ -369,7 +369,9 @@ describe("retainThreadDetailSubscription", () => {...@@ -369,7 +369,9 @@ describe("retainThreadDetailSubscription", () => {
369 listEnvironmentConnections().some((connection) => connection.environmentId === environmentId),369 listEnvironmentConnections().some((connection) => connection.environmentId === environmentId),
370 ).toBe(false);370 ).toBe(false);
371371
372 await reconnectSavedEnvironment(environmentId);372 const reconnectPromise = reconnectSavedEnvironment(environmentId);
373 await vi.advanceTimersByTimeAsync(200);
374 await reconnectPromise;
373 await vi.waitFor(() => {375 await vi.waitFor(() => {
374 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3);376 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3);
375 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);377 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);
apps/web/src/environments/runtime/service.ts+73-4
...@@ -139,9 +139,70 @@ let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;...@@ -139,9 +139,70 @@ let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY;
139const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;139const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
140const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;140const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
141const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;141const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
142const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150;
142const NOOP = () => undefined;143const NOOP = () => undefined;
143const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;144const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
144145
146function createDeferredPromise<T>() {
147 let resolve: ((value: T) => void) | null = null;
148 const promise = new Promise<T>((nextResolve) => {
149 resolve = nextResolve;
150 });
151
152 return {
153 promise,
154 resolve: (value: T) => {
155 resolve?.(value);
156 resolve = null;
157 },
158 };
159}
160
161async function waitForConfigSnapshot(
162 promise: Promise<ServerConfig>,
163 timeoutMs: number,
164): Promise<ServerConfig | null> {
165 return await new Promise<ServerConfig | null>((resolve) => {
166 const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs);
167 promise.then(
168 (config) => {
169 clearTimeout(timeoutId);
170 resolve(config);
171 },
172 () => {
173 clearTimeout(timeoutId);
174 resolve(null);
175 },
176 );
177 });
178}
179
180function createSavedEnvironmentSyncScheduler() {
181 let activeSync: Promise<void> | null = null;
182 let queued = false;
183
184 const run = async (): Promise<void> => {
185 do {
186 queued = false;
187 await syncSavedEnvironmentConnections(listSavedEnvironmentRecords());
188 } while (queued);
189 };
190
191 return () => {
192 if (activeSync) {
193 queued = true;
194 return activeSync;
195 }
196
197 activeSync = run()
198 .catch(() => undefined)
199 .finally(() => {
200 activeSync = null;
201 });
202
203 return activeSync;
204 };
205}
145function compareAppliedProjectionVersion(206function compareAppliedProjectionVersion(
146 left: { readonly sequence: number; readonly updatedAt: string | null },207 left: { readonly sequence: number; readonly updatedAt: string | null },
147 right: { readonly sequence: number; readonly updatedAt: string | null },208 right: { readonly sequence: number; readonly updatedAt: string | null },
...@@ -233,7 +294,6 @@ function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: numb...@@ -233,7 +294,6 @@ function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: numb
233 updatedAt: currentVersion?.updatedAt ?? null,294 updatedAt: currentVersion?.updatedAt ?? null,
234 });295 });
235}296}
236
237function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {297function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
238 return scopedThreadKey(scopeThreadRef(environmentId, threadId));298 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
239}299}
...@@ -1284,6 +1344,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1284,6 +1344,7 @@ async function ensureSavedEnvironmentConnection(
1284 const client =1344 const client =
1285 options?.client ??1345 options?.client ??
1286 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);1346 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);
1347 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
1287 const knownEnvironment = createKnownEnvironment({1348 const knownEnvironment = createKnownEnvironment({
1288 id: activeRecord.environmentId,1349 id: activeRecord.environmentId,
1289 label: activeRecord.label,1350 label: activeRecord.label,
...@@ -1308,6 +1369,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1308,6 +1369,7 @@ async function ensureSavedEnvironmentConnection(
1308 );1369 );
1309 },1370 },
1310 onConfigSnapshot: (config) => {1371 onConfigSnapshot: (config) => {
1372 initialConfigSnapshot.resolve(config);
1311 useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, {1373 useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, {
1312 descriptor: config.environment,1374 descriptor: config.environment,
1313 serverConfig: config,1375 serverConfig: config,
...@@ -1323,12 +1385,18 @@ async function ensureSavedEnvironmentConnection(...@@ -1323,12 +1385,18 @@ async function ensureSavedEnvironmentConnection(
13231385
1324 try {1386 try {
1325 try {1387 try {
1388 const initialServerConfig =
1389 options?.serverConfig ??
1390 (await waitForConfigSnapshot(
1391 initialConfigSnapshot.promise,
1392 INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS,
1393 ));
1326 await refreshSavedEnvironmentMetadata(1394 await refreshSavedEnvironmentMetadata(
1327 activeRecord.environmentId,1395 activeRecord.environmentId,
1328 activeBearerToken,1396 activeBearerToken,
1329 client,1397 client,
1330 roleHint,1398 roleHint,
1331 options?.serverConfig ?? null,1399 initialServerConfig,
1332 );1400 );
1333 } catch (error) {1401 } catch (error) {
1334 const isAuthError = activeRecord.desktopSsh1402 const isAuthError = activeRecord.desktopSsh
...@@ -1699,6 +1767,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1699,6 +1767,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
1699 trailing: true,1767 trailing: true,
1700 },1768 },
1701 );1769 );
1770 const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler();
17021771
1703 maybeCreatePrimaryEnvironmentConnection();1772 maybeCreatePrimaryEnvironmentConnection();
17041773
...@@ -1706,11 +1775,11 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -1706,11 +1775,11 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
1706 if (!hasSavedEnvironmentRegistryHydrated()) {1775 if (!hasSavedEnvironmentRegistryHydrated()) {
1707 return;1776 return;
1708 }1777 }
1709 void syncSavedEnvironmentConnections(listSavedEnvironmentRecords());1778 void requestSavedEnvironmentSync();
1710 });1779 });
17111780
1712 void waitForSavedEnvironmentRegistryHydration()1781 void waitForSavedEnvironmentRegistryHydration()
1713 .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords()))1782 .then(() => requestSavedEnvironmentSync())
1714 .catch(() => undefined);1783 .catch(() => undefined);
17151784
1716 const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects();1785 const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects();