authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 11:35:25-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log032c0e584943be48e197198ea1daaec8df9236bf
treeb43aeb4a7f3b0961269f9195276e00f792780dbe
parent319ac4590fa69a8ef53915d6833ae4ca59e14874

plumbing


23 files changed, 1168 insertions(+), 522 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+8-60
...@@ -6,21 +6,20 @@ import { execFileSync } from "node:child_process";...@@ -6,21 +6,20 @@ import { execFileSync } from "node:child_process";
6import { NodeServices } from "@effect/platform-node";6import { NodeServices } from "@effect/platform-node";
7import {7import {
8 ApprovalRequestId,8 ApprovalRequestId,
9 CheckpointRef,
9 type OrchestrationEvent,10 type OrchestrationEvent,
10 type OrchestrationThread,11 type OrchestrationThread,
11 type ThreadId,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";13import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
1414
15import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";15import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
16import { CheckpointDiffBlobRepositoryLive } from "../src/persistence/Layers/CheckpointDiffBlobs.ts";16import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
17import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";17import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
18import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";18import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
19import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";19import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
20import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";20import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
21import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";21import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
22import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";22import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
23import { CheckpointDiffBlobRepository } from "../src/persistence/Services/CheckpointDiffBlobs.ts";
24import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";23import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
25import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";24import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
26import { ProviderUnsupportedError } from "../src/provider/Errors.ts";25import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
...@@ -64,9 +63,9 @@ function initializeGitWorkspace(cwd: string) {...@@ -64,9 +63,9 @@ function initializeGitWorkspace(cwd: string) {
64 runGit(cwd, ["commit", "-m", "Initial"]);63 runGit(cwd, ["commit", "-m", "Initial"]);
65}64}
6665
67export function checkpointRefForTurn(threadId: string, turnCount: number): string {66export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef {
68 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");67 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
69 return `refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`;68 return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`);
70}69}
7170
72export function gitRefExists(cwd: string, ref: string): boolean {71export function gitRefExists(cwd: string, ref: string): boolean {
...@@ -127,7 +126,7 @@ export interface OrchestrationIntegrationHarness {...@@ -127,7 +126,7 @@ export interface OrchestrationIntegrationHarness {
127 readonly engine: OrchestrationEngineShape;126 readonly engine: OrchestrationEngineShape;
128 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];127 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
129 readonly providerService: ProviderService["Service"];128 readonly providerService: ProviderService["Service"];
130 readonly diffBlobRepository: CheckpointDiffBlobRepository["Service"];129 readonly checkpointStore: CheckpointStore["Service"];
131 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];130 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
132 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];131 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
133 readonly waitForThread: (132 readonly waitForThread: (
...@@ -155,23 +154,6 @@ export interface OrchestrationIntegrationHarness {...@@ -155,23 +154,6 @@ export interface OrchestrationIntegrationHarness {
155 },154 },
156 never155 never
157 >;156 >;
158 readonly waitForDiffBlob: (
159 input: {
160 readonly threadId: ThreadId;
161 readonly fromTurnCount: number;
162 readonly toTurnCount: number;
163 },
164 timeoutMs?: number,
165 ) => Effect.Effect<
166 {
167 readonly threadId: ThreadId;
168 readonly fromTurnCount: number;
169 readonly toTurnCount: number;
170 readonly diff: string;
171 readonly createdAt: string;
172 },
173 never
174 >;
175 readonly dispose: Effect.Effect<void, never>;157 readonly dispose: Effect.Effect<void, never>;
176}158}
177159
...@@ -214,7 +196,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {...@@ -214,7 +196,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
214 const runtimeServicesLayer = Layer.mergeAll(196 const runtimeServicesLayer = Layer.mergeAll(
215 orchestrationLayer,197 orchestrationLayer,
216 OrchestrationProjectionSnapshotQueryLive,198 OrchestrationProjectionSnapshotQueryLive,
217 CheckpointDiffBlobRepositoryLive,
218 ProjectionCheckpointRepositoryLive,199 ProjectionCheckpointRepositoryLive,
219 ProjectionPendingApprovalRepositoryLive,200 ProjectionPendingApprovalRepositoryLive,
220 checkpointStoreLayer,201 checkpointStoreLayer,
...@@ -252,8 +233,8 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {...@@ -252,8 +233,8 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
252 const providerService = yield* Effect.promise(() =>233 const providerService = yield* Effect.promise(() =>
253 runtime.runPromise(Effect.service(ProviderService)),234 runtime.runPromise(Effect.service(ProviderService)),
254 );235 );
255 const diffBlobRepository = yield* Effect.promise(() =>236 const checkpointStore = yield* Effect.promise(() =>
256 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),237 runtime.runPromise(Effect.service(CheckpointStore)),
257 );238 );
258 const checkpointRepository = yield* Effect.promise(() =>239 const checkpointRepository = yield* Effect.promise(() =>
259 runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),240 runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
...@@ -335,38 +316,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {...@@ -335,38 +316,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
335 never316 never
336 >;317 >;
337318
338 const waitForDiffBlob: OrchestrationIntegrationHarness["waitForDiffBlob"] = (input, timeoutMs) =>
339 waitFor(
340 diffBlobRepository.get(input).pipe(
341 Effect.map((blob) =>
342 Option.match(blob, {
343 onNone: () => null,
344 onSome: (value) => value,
345 }),
346 ),
347 ),
348 (
349 blob,
350 ): blob is {
351 readonly threadId: ThreadId;
352 readonly fromTurnCount: number;
353 readonly toTurnCount: number;
354 readonly diff: string;
355 readonly createdAt: string;
356 } => blob !== null,
357 `checkpoint diff blob ${input.threadId}:${input.fromTurnCount}->${input.toTurnCount}`,
358 timeoutMs,
359 ) as Effect.Effect<
360 {
361 readonly threadId: ThreadId;
362 readonly fromTurnCount: number;
363 readonly toTurnCount: number;
364 readonly diff: string;
365 readonly createdAt: string;
366 },
367 never
368 >;
369
370 let disposed = false;319 let disposed = false;
371 const dispose = Effect.gen(function* () {320 const dispose = Effect.gen(function* () {
372 if (disposed) {321 if (disposed) {
...@@ -390,13 +339,12 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {...@@ -390,13 +339,12 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
390 engine,339 engine,
391 snapshotQuery,340 snapshotQuery,
392 providerService,341 providerService,
393 diffBlobRepository,342 checkpointStore,
394 checkpointRepository,343 checkpointRepository,
395 pendingApprovalRepository,344 pendingApprovalRepository,
396 waitForThread,345 waitForThread,
397 waitForDomainEvent,346 waitForDomainEvent,
398 waitForPendingApproval,347 waitForPendingApproval,
399 waitForDiffBlob,
400 dispose,348 dispose,
401 } satisfies OrchestrationIntegrationHarness;349 } satisfies OrchestrationIntegrationHarness;
402});350});
apps/server/integration/orchestrationEngine.integration.test.ts+92-7
...@@ -202,7 +202,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +...@@ -202,7 +202,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
202 ),202 ),
203);203);
204204
205it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =>205it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
206 withHarness((harness) =>206 withHarness((harness) =>
207 Effect.gen(function* () {207 Effect.gen(function* () {
208 yield* seedProjectAndThread(harness);208 yield* seedProjectAndThread(harness);
...@@ -327,12 +327,21 @@ it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =...@@ -327,12 +327,21 @@ it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =
327 [1, 2],327 [1, 2],
328 );328 );
329329
330 const diffBlob = yield* harness.waitForDiffBlob({330 const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({
331 threadId: THREAD_ID,331 cwd: harness.workspaceDir,
332 fromTurnCount: 1,332 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1),
333 toTurnCount: 2,333 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
334 fallbackFromToHead: false,
335 });
336 assert.equal(incrementalDiff.includes("README.md"), true);
337
338 const fullDiff = yield* harness.checkpointStore.diffCheckpoints({
339 cwd: harness.workspaceDir,
340 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0),
341 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
342 fallbackFromToHead: false,
334 });343 });
335 assert.equal(diffBlob.diff.includes("README.md"), true);344 assert.equal(fullDiff.includes("README.md"), true);
336345
337 assert.equal(346 assert.equal(
338 gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"),347 gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"),
...@@ -511,6 +520,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -511,6 +520,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
511 threadId: FIXTURE_THREAD_ID,520 threadId: FIXTURE_THREAD_ID,
512 turnId: FIXTURE_TURN_ID,521 turnId: FIXTURE_TURN_ID,
513 },522 },
523 {
524 type: "tool.started",
525 ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"),
526 threadId: FIXTURE_THREAD_ID,
527 turnId: FIXTURE_TURN_ID,
528 toolKind: "command",
529 title: "Edit file",
530 detail: "README.md",
531 },
532 {
533 type: "tool.completed",
534 ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"),
535 threadId: FIXTURE_THREAD_ID,
536 turnId: FIXTURE_TURN_ID,
537 toolKind: "command",
538 title: "Edit file",
539 detail: "README.md",
540 },
541 {
542 type: "message.delta",
543 ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"),
544 threadId: FIXTURE_THREAD_ID,
545 turnId: FIXTURE_TURN_ID,
546 delta: "Updated README to v2.\n",
547 },
514 {548 {
515 type: "turn.completed",549 type: "turn.completed",
516 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),550 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),
...@@ -549,6 +583,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -549,6 +583,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
549 threadId: FIXTURE_THREAD_ID,583 threadId: FIXTURE_THREAD_ID,
550 turnId: FIXTURE_TURN_ID,584 turnId: FIXTURE_TURN_ID,
551 },585 },
586 {
587 type: "tool.started",
588 ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"),
589 threadId: FIXTURE_THREAD_ID,
590 turnId: FIXTURE_TURN_ID,
591 toolKind: "command",
592 title: "Edit file",
593 detail: "README.md",
594 },
595 {
596 type: "tool.completed",
597 ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"),
598 threadId: FIXTURE_THREAD_ID,
599 turnId: FIXTURE_TURN_ID,
600 toolKind: "command",
601 title: "Edit file",
602 detail: "README.md",
603 },
604 {
605 type: "message.delta",
606 ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"),
607 threadId: FIXTURE_THREAD_ID,
608 turnId: FIXTURE_TURN_ID,
609 delta: "Updated README to v3.\n",
610 },
552 {611 {
553 type: "turn.completed",612 type: "turn.completed",
554 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),613 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),
...@@ -571,7 +630,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -571,7 +630,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
571630
572 yield* harness.waitForThread(631 yield* harness.waitForThread(
573 THREAD_ID,632 THREAD_ID,
574 (entry) => entry.latestTurnId === "turn-2" && entry.checkpoints.length === 2,633 (entry) =>
634 entry.latestTurnId === "turn-2" &&
635 entry.checkpoints.length === 2 &&
636 entry.activities.some((activity) => activity.turnId === "turn-2"),
575 8000,637 8000,
576 );638 );
577639
...@@ -590,6 +652,29 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -590,6 +652,29 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
590 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,652 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,
591 );653 );
592 assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1);654 assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1);
655 assert.deepEqual(
656 revertedThread.messages.map((message) => ({ role: message.role, text: message.text })),
657 [
658 { role: "user", text: "First edit" },
659 { role: "assistant", text: "Updated README to v2.\n" },
660 ],
661 );
662 assert.equal(
663 revertedThread.activities.some((activity) => activity.turnId === "turn-2"),
664 false,
665 );
666 assert.equal(
667 revertedThread.activities.some(
668 (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started",
669 ),
670 true,
671 );
672 assert.equal(
673 revertedThread.activities.some(
674 (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed",
675 ),
676 true,
677 );
593 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");678 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");
594 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false);679 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false);
595 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);680 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
apps/server/src/index.ts+1
...@@ -118,6 +118,7 @@ async function main() {...@@ -118,6 +118,7 @@ async function main() {
118 port,118 port,
119 host: mode === "desktop" ? "127.0.0.1" : undefined,119 host: mode === "desktop" ? "127.0.0.1" : undefined,
120 cwd,120 cwd,
121 autoBootstrapProjectFromCwd: mode === "web",
121 stateDir,122 stateDir,
122 staticDir,123 staticDir,
123 devUrl,124 devUrl,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1-77
...@@ -24,11 +24,9 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts...@@ -24,11 +24,9 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
24import { CheckpointReactorLive } from "./CheckpointReactor.ts";24import { CheckpointReactorLive } from "./CheckpointReactor.ts";
25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
27import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts";
28import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";27import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
29import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";28import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
30import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";29import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
31import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
32import {30import {
33 OrchestrationEngineService,31 OrchestrationEngineService,
34 type OrchestrationEngineShape,32 type OrchestrationEngineShape,
...@@ -145,26 +143,6 @@ async function waitForEvent(...@@ -145,26 +143,6 @@ async function waitForEvent(
145 return poll();143 return poll();
146}144}
147145
148async function waitForDiffBlob(
149 repository: CheckpointDiffBlobRepository["Service"],
150 input: { threadId: ThreadId; fromTurnCount: number; toTurnCount: number },
151 timeoutMs = 2000,
152) {
153 const deadline = Date.now() + timeoutMs;
154 const poll = async () => {
155 const blob = await Effect.runPromise(repository.get(input));
156 if (blob._tag === "Some") {
157 return blob.value;
158 }
159 if (Date.now() >= deadline) {
160 throw new Error("Timed out waiting for checkpoint diff blob persistence.");
161 }
162 await new Promise((resolve) => setTimeout(resolve, 10));
163 return poll();
164 };
165 return poll();
166}
167
168function runGit(cwd: string, args: ReadonlyArray<string>) {146function runGit(cwd: string, args: ReadonlyArray<string>) {
169 return execFileSync("git", args, {147 return execFileSync("git", args, {
170 cwd,148 cwd,
...@@ -214,7 +192,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {...@@ -214,7 +192,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {
214192
215describe("CheckpointReactor", () => {193describe("CheckpointReactor", () => {
216 let runtime: ManagedRuntime.ManagedRuntime<194 let runtime: ManagedRuntime.ManagedRuntime<
217 OrchestrationEngineService | CheckpointReactor | CheckpointDiffBlobRepository | CheckpointStore,195 OrchestrationEngineService | CheckpointReactor | CheckpointStore,
218 unknown196 unknown
219 > | null = null;197 > | null = null;
220 let scope: Scope.Closeable | null = null;198 let scope: Scope.Closeable | null = null;
...@@ -250,23 +228,16 @@ describe("CheckpointReactor", () => {...@@ -250,23 +228,16 @@ describe("CheckpointReactor", () => {
250 Layer.provide(OrchestrationCommandReceiptRepositoryLive),228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
251 Layer.provide(SqlitePersistenceMemory),229 Layer.provide(SqlitePersistenceMemory),
252 );230 );
253 const diffBlobLayer = CheckpointDiffBlobRepositoryLive.pipe(
254 Layer.provide(SqlitePersistenceMemory),
255 );
256 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));231 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
257 const layer = CheckpointReactorLive.pipe(232 const layer = CheckpointReactorLive.pipe(
258 Layer.provideMerge(orchestrationLayer),233 Layer.provideMerge(orchestrationLayer),
259 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),234 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
260 Layer.provideMerge(diffBlobLayer),
261 Layer.provideMerge(checkpointStoreLayer),235 Layer.provideMerge(checkpointStoreLayer),
262 );236 );
263237
264 runtime = ManagedRuntime.make(layer);238 runtime = ManagedRuntime.make(layer);
265 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));239 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
266 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));240 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
267 const diffBlobRepository = await runtime.runPromise(
268 Effect.service(CheckpointDiffBlobRepository),
269 );
270 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));241 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
271 scope = await Effect.runPromise(Scope.make("sequential"));242 scope = await Effect.runPromise(Scope.make("sequential"));
272 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));243 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
...@@ -325,7 +296,6 @@ describe("CheckpointReactor", () => {...@@ -325,7 +296,6 @@ describe("CheckpointReactor", () => {
325 engine,296 engine,
326 provider,297 provider,
327 cwd,298 cwd,
328 diffBlobRepository,
329 };299 };
330 }300 }
331301
...@@ -561,50 +531,4 @@ describe("CheckpointReactor", () => {...@@ -561,50 +531,4 @@ describe("CheckpointReactor", () => {
561 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();531 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
562 });532 });
563533
564 it("persists full checkpoint diff blobs on thread.turn-diff-completed", async () => {
565 const harness = await createHarness();
566 const createdAt = new Date().toISOString();
567
568 await Effect.runPromise(
569 harness.engine.dispatch({
570 type: "thread.session.set",
571 commandId: asCommandId("cmd-session-set-diff"),
572 threadId: asThreadId("thread-1"),
573 session: {
574 threadId: asThreadId("thread-1"),
575 status: "ready",
576 providerName: "codex",
577 providerSessionId: asSessionId("sess-1"),
578 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
579 activeTurnId: null,
580 lastError: null,
581 updatedAt: createdAt,
582 },
583 createdAt,
584 }),
585 );
586
587 await Effect.runPromise(
588 harness.engine.dispatch({
589 type: "thread.turn.diff.complete",
590 commandId: asCommandId("cmd-diff-persist"),
591 threadId: asThreadId("thread-1"),
592 turnId: asTurnId("turn-1"),
593 completedAt: createdAt,
594 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
595 status: "ready",
596 files: [],
597 checkpointTurnCount: 1,
598 createdAt,
599 }),
600 );
601
602 const persistedBlob = await waitForDiffBlob(harness.diffBlobRepository, {
603 threadId: asThreadId("thread-1"),
604 fromTurnCount: 0,
605 toTurnCount: 1,
606 });
607 expect(persistedBlob.diff).toContain("diff --git");
608 expect(persistedBlob.diff).toContain("README.md");
609 });
610});534});
apps/server/src/orchestration/Layers/CheckpointReactor.ts-73
...@@ -13,7 +13,6 @@ import { Effect, Layer, Option, Queue, Stream } from "effect";...@@ -13,7 +13,6 @@ import { Effect, Layer, Option, Queue, Stream } from "effect";
1313
14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";15import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
16import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
17import { ProviderService } from "../../provider/Services/ProviderService.ts";16import { ProviderService } from "../../provider/Services/ProviderService.ts";
18import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";17import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
19import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";18import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
...@@ -67,7 +66,6 @@ const make = Effect.gen(function* () {...@@ -67,7 +66,6 @@ const make = Effect.gen(function* () {
67 const orchestrationEngine = yield* OrchestrationEngineService;66 const orchestrationEngine = yield* OrchestrationEngineService;
68 const providerService = yield* ProviderService;67 const providerService = yield* ProviderService;
69 const checkpointStore = yield* CheckpointStore;68 const checkpointStore = yield* CheckpointStore;
70 const checkpointDiffBlobRepository = yield* CheckpointDiffBlobRepository;
7169
72 const appendRevertFailureActivity = (input: {70 const appendRevertFailureActivity = (input: {
73 readonly threadId: ThreadId;71 readonly threadId: ThreadId;
...@@ -160,62 +158,6 @@ const make = Effect.gen(function* () {...@@ -160,62 +158,6 @@ const make = Effect.gen(function* () {
160 return findSessionWithCwd(matchedSession);158 return findSessionWithCwd(matchedSession);
161 });159 });
162160
163 const persistDiffBlobFromTurnCompletion = Effect.fnUntraced(function* (
164 event: Extract<OrchestrationEvent, { type: "thread.turn-diff-completed" }>,
165 ) {
166 const toTurnCount = event.payload.checkpointTurnCount;
167 const fromTurnCount = Math.max(0, toTurnCount - 1);
168
169 const readModel = yield* orchestrationEngine.getReadModel();
170 const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
171 if (!thread) {
172 return;
173 }
174
175 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
176 if (Option.isNone(sessionRuntime)) {
177 yield* Effect.logWarning(
178 "checkpoint diff blob skipped: no active provider session cwd for thread",
179 {
180 threadId: event.payload.threadId,
181 fromTurnCount,
182 toTurnCount,
183 },
184 );
185 return;
186 }
187
188 const fromCheckpointRef =
189 fromTurnCount === 0
190 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
191 : thread.checkpoints.find((checkpoint) => checkpoint.checkpointTurnCount === fromTurnCount)
192 ?.checkpointRef;
193
194 if (!fromCheckpointRef) {
195 yield* Effect.logWarning("checkpoint diff blob skipped: missing from checkpoint ref", {
196 threadId: event.payload.threadId,
197 fromTurnCount,
198 toTurnCount,
199 });
200 return;
201 }
202
203 const diff = yield* checkpointStore.diffCheckpoints({
204 cwd: sessionRuntime.value.cwd,
205 fromCheckpointRef,
206 toCheckpointRef: event.payload.checkpointRef,
207 fallbackFromToHead: false,
208 });
209
210 yield* checkpointDiffBlobRepository.upsert({
211 threadId: event.payload.threadId,
212 fromTurnCount,
213 toTurnCount,
214 diff,
215 createdAt: event.payload.completedAt,
216 });
217 });
218
219 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (161 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
220 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,162 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
221 ) {163 ) {
...@@ -565,20 +507,6 @@ const make = Effect.gen(function* () {...@@ -565,20 +507,6 @@ const make = Effect.gen(function* () {
565 return;507 return;
566 }508 }
567509
568 if (event.type === "thread.turn-diff-completed") {
569 yield* persistDiffBlobFromTurnCompletion(event).pipe(
570 Effect.catch((error) =>
571 Effect.logWarning("checkpoint diff blob persistence failed", {
572 threadId: event.payload.threadId,
573 turnId: event.payload.turnId,
574 checkpointTurnCount: event.payload.checkpointTurnCount,
575 detail: error.message,
576 }),
577 ),
578 );
579 return;
580 }
581
582 if (event.type === "thread.checkpoint-revert-requested") {510 if (event.type === "thread.checkpoint-revert-requested") {
583 yield* handleRevertRequested(event).pipe(511 yield* handleRevertRequested(event).pipe(
584 Effect.catch((error) =>512 Effect.catch((error) =>
...@@ -714,7 +642,6 @@ const make = Effect.gen(function* () {...@@ -714,7 +642,6 @@ const make = Effect.gen(function* () {
714 if (642 if (
715 event.type !== "thread.turn-start-requested" &&643 event.type !== "thread.turn-start-requested" &&
716 event.type !== "thread.message-sent" &&644 event.type !== "thread.message-sent" &&
717 event.type !== "thread.turn-diff-completed" &&
718 event.type !== "thread.checkpoint-revert-requested"645 event.type !== "thread.checkpoint-revert-requested"
719 ) {646 ) {
720 return Effect.void;647 return Effect.void;
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+131
...@@ -269,6 +269,137 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -269,6 +269,137 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
269 }269 }
270 }),270 }),
271 );271 );
272
273 it.effect("keeps accumulated assistant text when completion payload text is empty", () =>
274 Effect.gen(function* () {
275 const projectionPipeline = yield* OrchestrationProjectionPipeline;
276 const eventStore = yield* OrchestrationEventStore;
277 const sql = yield* SqlClient.SqlClient;
278 const now = new Date().toISOString();
279
280 yield* eventStore.append({
281 type: "project.created",
282 eventId: EventId.makeUnsafe("evt-empty-1"),
283 aggregateKind: "project",
284 aggregateId: ProjectId.makeUnsafe("project-empty"),
285 occurredAt: now,
286 commandId: CommandId.makeUnsafe("cmd-empty-1"),
287 causationEventId: null,
288 correlationId: CorrelationId.makeUnsafe("cmd-empty-1"),
289 metadata: {},
290 payload: {
291 projectId: ProjectId.makeUnsafe("project-empty"),
292 title: "Project Empty",
293 workspaceRoot: "/tmp/project-empty",
294 defaultModel: null,
295 scripts: [],
296 createdAt: now,
297 updatedAt: now,
298 },
299 });
300
301 yield* eventStore.append({
302 type: "thread.created",
303 eventId: EventId.makeUnsafe("evt-empty-2"),
304 aggregateKind: "thread",
305 aggregateId: ThreadId.makeUnsafe("thread-empty"),
306 occurredAt: now,
307 commandId: CommandId.makeUnsafe("cmd-empty-2"),
308 causationEventId: null,
309 correlationId: CorrelationId.makeUnsafe("cmd-empty-2"),
310 metadata: {},
311 payload: {
312 threadId: ThreadId.makeUnsafe("thread-empty"),
313 projectId: ProjectId.makeUnsafe("project-empty"),
314 title: "Thread Empty",
315 model: "gpt-5-codex",
316 branch: null,
317 worktreePath: null,
318 createdAt: now,
319 updatedAt: now,
320 },
321 });
322
323 yield* eventStore.append({
324 type: "thread.message-sent",
325 eventId: EventId.makeUnsafe("evt-empty-3"),
326 aggregateKind: "thread",
327 aggregateId: ThreadId.makeUnsafe("thread-empty"),
328 occurredAt: now,
329 commandId: CommandId.makeUnsafe("cmd-empty-3"),
330 causationEventId: null,
331 correlationId: CorrelationId.makeUnsafe("cmd-empty-3"),
332 metadata: {},
333 payload: {
334 threadId: ThreadId.makeUnsafe("thread-empty"),
335 messageId: MessageId.makeUnsafe("assistant-empty"),
336 role: "assistant",
337 text: "Hello",
338 turnId: null,
339 streaming: true,
340 createdAt: now,
341 updatedAt: now,
342 },
343 });
344
345 yield* eventStore.append({
346 type: "thread.message-sent",
347 eventId: EventId.makeUnsafe("evt-empty-4"),
348 aggregateKind: "thread",
349 aggregateId: ThreadId.makeUnsafe("thread-empty"),
350 occurredAt: now,
351 commandId: CommandId.makeUnsafe("cmd-empty-4"),
352 causationEventId: null,
353 correlationId: CorrelationId.makeUnsafe("cmd-empty-4"),
354 metadata: {},
355 payload: {
356 threadId: ThreadId.makeUnsafe("thread-empty"),
357 messageId: MessageId.makeUnsafe("assistant-empty"),
358 role: "assistant",
359 text: " world",
360 turnId: null,
361 streaming: true,
362 createdAt: now,
363 updatedAt: now,
364 },
365 });
366
367 yield* eventStore.append({
368 type: "thread.message-sent",
369 eventId: EventId.makeUnsafe("evt-empty-5"),
370 aggregateKind: "thread",
371 aggregateId: ThreadId.makeUnsafe("thread-empty"),
372 occurredAt: now,
373 commandId: CommandId.makeUnsafe("cmd-empty-5"),
374 causationEventId: null,
375 correlationId: CorrelationId.makeUnsafe("cmd-empty-5"),
376 metadata: {},
377 payload: {
378 threadId: ThreadId.makeUnsafe("thread-empty"),
379 messageId: MessageId.makeUnsafe("assistant-empty"),
380 role: "assistant",
381 text: "",
382 turnId: null,
383 streaming: false,
384 createdAt: now,
385 updatedAt: now,
386 },
387 });
388
389 yield* projectionPipeline.bootstrap;
390
391 const messageRows = yield* sql<{ readonly text: string; readonly isStreaming: unknown }>`
392 SELECT
393 text,
394 is_streaming AS "isStreaming"
395 FROM projection_thread_messages
396 WHERE message_id = 'assistant-empty'
397 `;
398 assert.equal(messageRows.length, 1);
399 assert.equal(messageRows[0]?.text, "Hello world");
400 assert.isFalse(Boolean(messageRows[0]?.isStreaming));
401 }),
402 );
272});403});
273404
274const engineLayer = it.layer(405const engineLayer = it.layer(
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+189-33
...@@ -12,7 +12,11 @@ import { ProjectionPendingApprovalRepository } from "../../persistence/Services/...@@ -12,7 +12,11 @@ import { ProjectionPendingApprovalRepository } from "../../persistence/Services/
12import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";12import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
13import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";13import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
14import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";14import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
15import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts";15import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
16import {
17 type ProjectionThreadMessage,
18 ProjectionThreadMessageRepository,
19} from "../../persistence/Services/ProjectionThreadMessages.ts";
16import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";20import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
17import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts";21import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts";
18import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts";22import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts";
...@@ -62,6 +66,91 @@ function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number {...@@ -62,6 +66,91 @@ function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number {
62 return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1;66 return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1;
63}67}
6468
69function retainProjectionMessagesAfterRevert(
70 messages: ReadonlyArray<ProjectionThreadMessage>,
71 turns: ReadonlyArray<ProjectionThreadTurn>,
72 turnCount: number,
73): ReadonlyArray<ProjectionThreadMessage> {
74 const retainedMessageIds = new Set<string>();
75 const retainedTurnIds = new Set<string>();
76 const keptTurns = turns.filter((turn) => turn.turnCount <= turnCount);
77 for (const turn of keptTurns) {
78 retainedTurnIds.add(turn.turnId);
79 if (turn.userMessageId !== null) {
80 retainedMessageIds.add(turn.userMessageId);
81 }
82 if (turn.assistantMessageId !== null) {
83 retainedMessageIds.add(turn.assistantMessageId);
84 }
85 }
86
87 for (const message of messages) {
88 if (message.role === "system") {
89 retainedMessageIds.add(message.messageId);
90 continue;
91 }
92 if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
93 retainedMessageIds.add(message.messageId);
94 }
95 }
96
97 const retainedUserCount = messages.filter(
98 (message) => message.role === "user" && retainedMessageIds.has(message.messageId),
99 ).length;
100 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
101 if (missingUserCount > 0) {
102 const fallbackUserMessages = messages
103 .filter((message) => message.role === "user" && !retainedMessageIds.has(message.messageId))
104 .toSorted(
105 (left, right) =>
106 left.createdAt.localeCompare(right.createdAt) ||
107 left.messageId.localeCompare(right.messageId),
108 )
109 .slice(0, missingUserCount);
110 for (const message of fallbackUserMessages) {
111 retainedMessageIds.add(message.messageId);
112 }
113 }
114
115 const retainedAssistantCount = messages.filter(
116 (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId),
117 ).length;
118 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
119 if (missingAssistantCount > 0) {
120 const fallbackAssistantMessages = messages
121 .filter(
122 (message) =>
123 message.role === "assistant" && !retainedMessageIds.has(message.messageId),
124 )
125 .toSorted(
126 (left, right) =>
127 left.createdAt.localeCompare(right.createdAt) ||
128 left.messageId.localeCompare(right.messageId),
129 )
130 .slice(0, missingAssistantCount);
131 for (const message of fallbackAssistantMessages) {
132 retainedMessageIds.add(message.messageId);
133 }
134 }
135
136 return messages.filter((message) => retainedMessageIds.has(message.messageId));
137}
138
139function retainProjectionActivitiesAfterRevert(
140 activities: ReadonlyArray<ProjectionThreadActivity>,
141 turns: ReadonlyArray<ProjectionThreadTurn>,
142 turnCount: number,
143): ReadonlyArray<ProjectionThreadActivity> {
144 const retainedTurnIds = new Set(
145 turns
146 .filter((turn) => turn.turnCount <= turnCount)
147 .map((turn) => turn.turnId),
148 );
149 return activities.filter(
150 (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
151 );
152}
153
65const makeOrchestrationProjectionPipeline = Effect.gen(function* () {154const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
66 const sql = yield* SqlClient.SqlClient;155 const sql = yield* SqlClient.SqlClient;
67 const eventStore = yield* OrchestrationEventStore;156 const eventStore = yield* OrchestrationEventStore;
...@@ -260,44 +349,111 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -260,44 +349,111 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
260349
261 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>350 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>
262 Effect.gen(function* () {351 Effect.gen(function* () {
263 if (event.type !== "thread.message-sent") {352 switch (event.type) {
264 return;353 case "thread.message-sent": {
354 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
355 threadId: event.payload.threadId,
356 });
357 const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId);
358 const nextText =
359 existingMessage && event.payload.streaming
360 ? `${existingMessage.text}${event.payload.text}`
361 : existingMessage && event.payload.text.length === 0
362 ? existingMessage.text
363 : event.payload.text;
364 yield* projectionThreadMessageRepository.upsert({
365 messageId: event.payload.messageId,
366 threadId: event.payload.threadId,
367 turnId: event.payload.turnId,
368 role: event.payload.role,
369 text: nextText,
370 isStreaming: event.payload.streaming,
371 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
372 updatedAt: event.payload.updatedAt,
373 });
374 return;
375 }
376
377 case "thread.reverted": {
378 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
379 threadId: event.payload.threadId,
380 });
381 if (existingRows.length === 0) {
382 return;
383 }
384
385 const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({
386 threadId: event.payload.threadId,
387 });
388 const keptRows = retainProjectionMessagesAfterRevert(
389 existingRows,
390 existingTurns,
391 event.payload.turnCount,
392 );
393 if (keptRows.length === existingRows.length) {
394 return;
395 }
396
397 yield* projectionThreadMessageRepository.deleteByThreadId({
398 threadId: event.payload.threadId,
399 });
400 yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {
401 concurrency: 1,
402 }).pipe(Effect.asVoid);
403 return;
404 }
405
406 default:
407 return;
265 }408 }
266 const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
267 threadId: event.payload.threadId,
268 });
269 const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId);
270 const nextText =
271 existingMessage && event.payload.streaming
272 ? `${existingMessage.text}${event.payload.text}`
273 : event.payload.text;
274 yield* projectionThreadMessageRepository.upsert({
275 messageId: event.payload.messageId,
276 threadId: event.payload.threadId,
277 turnId: event.payload.turnId,
278 role: event.payload.role,
279 text: nextText,
280 isStreaming: event.payload.streaming,
281 createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
282 updatedAt: event.payload.updatedAt,
283 });
284 });409 });
285410
286 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>411 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>
287 Effect.gen(function* () {412 Effect.gen(function* () {
288 if (event.type !== "thread.activity-appended") {413 switch (event.type) {
289 return;414 case "thread.activity-appended":
415 yield* projectionThreadActivityRepository.upsert({
416 activityId: event.payload.activity.id,
417 threadId: event.payload.threadId,
418 turnId: event.payload.activity.turnId,
419 tone: event.payload.activity.tone,
420 kind: event.payload.activity.kind,
421 summary: event.payload.activity.summary,
422 payload: event.payload.activity.payload,
423 createdAt: event.payload.activity.createdAt,
424 });
425 return;
426
427 case "thread.reverted": {
428 const existingRows = yield* projectionThreadActivityRepository.listByThreadId({
429 threadId: event.payload.threadId,
430 });
431 if (existingRows.length === 0) {
432 return;
433 }
434 const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({
435 threadId: event.payload.threadId,
436 });
437 const keptRows = retainProjectionActivitiesAfterRevert(
438 existingRows,
439 existingTurns,
440 event.payload.turnCount,
441 );
442 if (keptRows.length === existingRows.length) {
443 return;
444 }
445 yield* projectionThreadActivityRepository.deleteByThreadId({
446 threadId: event.payload.threadId,
447 });
448 yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
449 concurrency: 1,
450 }).pipe(Effect.asVoid);
451 return;
452 }
453
454 default:
455 return;
290 }456 }
291 yield* projectionThreadActivityRepository.upsert({
292 activityId: event.payload.activity.id,
293 threadId: event.payload.threadId,
294 turnId: event.payload.activity.turnId,
295 tone: event.payload.activity.tone,
296 kind: event.payload.activity.kind,
297 summary: event.payload.activity.summary,
298 payload: event.payload.activity.payload,
299 createdAt: event.payload.activity.createdAt,
300 });
301 });457 });
302458
303 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>459 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>
apps/server/src/orchestration/projector.test.ts+209
...@@ -282,4 +282,213 @@ describe("orchestration projector", () => {...@@ -282,4 +282,213 @@ describe("orchestration projector", () => {
282 expect(message?.streaming).toBe(false);282 expect(message?.streaming).toBe(false);
283 expect(message?.updatedAt).toBe(completeAt);283 expect(message?.updatedAt).toBe(completeAt);
284 });284 });
285
286 it("prunes reverted turn messages from in-memory thread snapshot", async () => {
287 const createdAt = "2026-02-23T10:00:00.000Z";
288 const model = createEmptyReadModel(createdAt);
289
290 const afterCreate = await Effect.runPromise(
291 projectEvent(
292 model,
293 makeEvent({
294 sequence: 1,
295 type: "thread.created",
296 aggregateType: "thread",
297 aggregateId: "thread-1",
298 occurredAt: createdAt,
299 commandId: "cmd-create",
300 payload: {
301 threadId: "thread-1",
302 projectId: "project-1",
303 title: "demo",
304 model: "gpt-5.3-codex",
305 branch: null,
306 worktreePath: null,
307 createdAt,
308 updatedAt: createdAt,
309 },
310 }),
311 ),
312 );
313
314 const events: ReadonlyArray<OrchestrationEvent> = [
315 makeEvent({
316 sequence: 2,
317 type: "thread.message-sent",
318 aggregateType: "thread",
319 aggregateId: "thread-1",
320 occurredAt: "2026-02-23T10:00:01.000Z",
321 commandId: "cmd-user-1",
322 payload: {
323 threadId: "thread-1",
324 messageId: "user-msg-1",
325 role: "user",
326 text: "First edit",
327 turnId: null,
328 streaming: false,
329 createdAt: "2026-02-23T10:00:01.000Z",
330 updatedAt: "2026-02-23T10:00:01.000Z",
331 },
332 }),
333 makeEvent({
334 sequence: 3,
335 type: "thread.message-sent",
336 aggregateType: "thread",
337 aggregateId: "thread-1",
338 occurredAt: "2026-02-23T10:00:02.000Z",
339 commandId: "cmd-assistant-1",
340 payload: {
341 threadId: "thread-1",
342 messageId: "assistant-msg-1",
343 role: "assistant",
344 text: "Updated README to v2.\n",
345 turnId: "turn-1",
346 streaming: false,
347 createdAt: "2026-02-23T10:00:02.000Z",
348 updatedAt: "2026-02-23T10:00:02.000Z",
349 },
350 }),
351 makeEvent({
352 sequence: 4,
353 type: "thread.turn-diff-completed",
354 aggregateType: "thread",
355 aggregateId: "thread-1",
356 occurredAt: "2026-02-23T10:00:02.500Z",
357 commandId: "cmd-turn-1-complete",
358 payload: {
359 threadId: "thread-1",
360 turnId: "turn-1",
361 checkpointTurnCount: 1,
362 checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",
363 status: "ready",
364 files: [],
365 assistantMessageId: "assistant-msg-1",
366 completedAt: "2026-02-23T10:00:02.500Z",
367 },
368 }),
369 makeEvent({
370 sequence: 5,
371 type: "thread.activity-appended",
372 aggregateType: "thread",
373 aggregateId: "thread-1",
374 occurredAt: "2026-02-23T10:00:02.750Z",
375 commandId: "cmd-activity-1",
376 payload: {
377 threadId: "thread-1",
378 activity: {
379 id: "activity-1",
380 tone: "tool",
381 kind: "tool.started",
382 summary: "Edit file started",
383 payload: { toolKind: "command" },
384 turnId: "turn-1",
385 createdAt: "2026-02-23T10:00:02.750Z",
386 },
387 },
388 }),
389 makeEvent({
390 sequence: 6,
391 type: "thread.message-sent",
392 aggregateType: "thread",
393 aggregateId: "thread-1",
394 occurredAt: "2026-02-23T10:00:03.000Z",
395 commandId: "cmd-user-2",
396 payload: {
397 threadId: "thread-1",
398 messageId: "user-msg-2",
399 role: "user",
400 text: "Second edit",
401 turnId: null,
402 streaming: false,
403 createdAt: "2026-02-23T10:00:03.000Z",
404 updatedAt: "2026-02-23T10:00:03.000Z",
405 },
406 }),
407 makeEvent({
408 sequence: 7,
409 type: "thread.message-sent",
410 aggregateType: "thread",
411 aggregateId: "thread-1",
412 occurredAt: "2026-02-23T10:00:04.000Z",
413 commandId: "cmd-assistant-2",
414 payload: {
415 threadId: "thread-1",
416 messageId: "assistant-msg-2",
417 role: "assistant",
418 text: "Updated README to v3.\n",
419 turnId: "turn-2",
420 streaming: false,
421 createdAt: "2026-02-23T10:00:04.000Z",
422 updatedAt: "2026-02-23T10:00:04.000Z",
423 },
424 }),
425 makeEvent({
426 sequence: 8,
427 type: "thread.turn-diff-completed",
428 aggregateType: "thread",
429 aggregateId: "thread-1",
430 occurredAt: "2026-02-23T10:00:04.500Z",
431 commandId: "cmd-turn-2-complete",
432 payload: {
433 threadId: "thread-1",
434 turnId: "turn-2",
435 checkpointTurnCount: 2,
436 checkpointRef: "refs/t3/checkpoints/thread-1/turn/2",
437 status: "ready",
438 files: [],
439 assistantMessageId: "assistant-msg-2",
440 completedAt: "2026-02-23T10:00:04.500Z",
441 },
442 }),
443 makeEvent({
444 sequence: 9,
445 type: "thread.activity-appended",
446 aggregateType: "thread",
447 aggregateId: "thread-1",
448 occurredAt: "2026-02-23T10:00:04.750Z",
449 commandId: "cmd-activity-2",
450 payload: {
451 threadId: "thread-1",
452 activity: {
453 id: "activity-2",
454 tone: "tool",
455 kind: "tool.completed",
456 summary: "Edit file complete",
457 payload: { toolKind: "command" },
458 turnId: "turn-2",
459 createdAt: "2026-02-23T10:00:04.750Z",
460 },
461 },
462 }),
463 makeEvent({
464 sequence: 10,
465 type: "thread.reverted",
466 aggregateType: "thread",
467 aggregateId: "thread-1",
468 occurredAt: "2026-02-23T10:00:05.000Z",
469 commandId: "cmd-revert",
470 payload: {
471 threadId: "thread-1",
472 turnCount: 1,
473 },
474 }),
475 ];
476
477 const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>(
478 (statePromise, event) =>
479 statePromise.then((state) => Effect.runPromise(projectEvent(state, event))),
480 Promise.resolve(afterCreate),
481 );
482
483 const thread = afterRevert.threads[0];
484 expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
485 { role: "user", text: "First edit" },
486 { role: "assistant", text: "Updated README to v2.\n" },
487 ]);
488 expect(thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId }))).toEqual([
489 { id: "activity-1", turnId: "turn-1" },
490 ]);
491 expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]);
492 expect(thread?.latestTurnId).toBe("turn-1");
493 });
285});494});
apps/server/src/orchestration/projector.ts+74
...@@ -47,6 +47,68 @@ function decodeForEvent<A>(...@@ -47,6 +47,68 @@ function decodeForEvent<A>(
47 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;47 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;
48}48}
4949
50function retainThreadMessagesAfterRevert(
51 messages: ReadonlyArray<OrchestrationMessage>,
52 retainedTurnIds: ReadonlySet<string>,
53 turnCount: number,
54): ReadonlyArray<OrchestrationMessage> {
55 const retainedMessageIds = new Set<string>();
56 for (const message of messages) {
57 if (message.role === "system") {
58 retainedMessageIds.add(message.id);
59 continue;
60 }
61 if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
62 retainedMessageIds.add(message.id);
63 }
64 }
65
66 const retainedUserCount = messages.filter(
67 (message) => message.role === "user" && retainedMessageIds.has(message.id),
68 ).length;
69 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
70 if (missingUserCount > 0) {
71 const fallbackUserMessages = messages
72 .filter((message) => message.role === "user" && !retainedMessageIds.has(message.id))
73 .toSorted(
74 (left, right) =>
75 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
76 )
77 .slice(0, missingUserCount);
78 for (const message of fallbackUserMessages) {
79 retainedMessageIds.add(message.id);
80 }
81 }
82
83 const retainedAssistantCount = messages.filter(
84 (message) => message.role === "assistant" && retainedMessageIds.has(message.id),
85 ).length;
86 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
87 if (missingAssistantCount > 0) {
88 const fallbackAssistantMessages = messages
89 .filter((message) => message.role === "assistant" && !retainedMessageIds.has(message.id))
90 .toSorted(
91 (left, right) =>
92 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
93 )
94 .slice(0, missingAssistantCount);
95 for (const message of fallbackAssistantMessages) {
96 retainedMessageIds.add(message.id);
97 }
98 }
99
100 return messages.filter((message) => retainedMessageIds.has(message.id));
101}
102
103function retainThreadActivitiesAfterRevert(
104 activities: ReadonlyArray<OrchestrationThread["activities"][number]>,
105 retainedTurnIds: ReadonlySet<string>,
106): ReadonlyArray<OrchestrationThread["activities"][number]> {
107 return activities.filter(
108 (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
109 );
110}
111
50export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {112export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
51 return {113 return {
52 snapshotSequence: 0,114 snapshotSequence: 0,
...@@ -347,6 +409,16 @@ export function projectEvent(...@@ -347,6 +409,16 @@ export function projectEvent(
347 const checkpoints = thread.checkpoints409 const checkpoints = thread.checkpoints
348 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)410 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)
349 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);411 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
412 const retainedTurnIds = new Set(checkpoints.map((checkpoint) => checkpoint.turnId));
413 const messages = retainThreadMessagesAfterRevert(
414 thread.messages,
415 retainedTurnIds,
416 payload.turnCount,
417 );
418 const activities = retainThreadActivitiesAfterRevert(
419 thread.activities,
420 retainedTurnIds,
421 );
350422
351 const latestTurnId =423 const latestTurnId =
352 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;424 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;
...@@ -355,6 +427,8 @@ export function projectEvent(...@@ -355,6 +427,8 @@ export function projectEvent(
355 ...nextBase,427 ...nextBase,
356 threads: updateThread(nextBase.threads, payload.threadId, {428 threads: updateThread(nextBase.threads, payload.threadId, {
357 checkpoints,429 checkpoints,
430 messages,
431 activities,
358 latestTurnId,432 latestTurnId,
359 updatedAt: event.occurredAt,433 updatedAt: event.occurredAt,
360 }),434 }),
apps/server/src/persistence/Errors.ts-2
...@@ -205,8 +205,6 @@ export type OrchestrationCommandReceiptRepositoryError =...@@ -205,8 +205,6 @@ export type OrchestrationCommandReceiptRepositoryError =
205 | PersistenceSqlError205 | PersistenceSqlError
206 | PersistenceDecodeError;206 | PersistenceDecodeError;
207207
208export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError;
209
210export type ProviderSessionRuntimeRepositoryError =208export type ProviderSessionRuntimeRepositoryError =
211 | PersistenceSqlError209 | PersistenceSqlError
212 | PersistenceDecodeError210 | PersistenceDecodeError
apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts deleted-79
...@@ -1,79 +0,0 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
4
5import { toPersistenceSqlError } from "../Errors.ts";
6
7import {
8 CheckpointDiffBlobRepository,
9 CheckpointDiffBlob,
10 GetCheckpointDiffBlobInput,
11 type CheckpointDiffBlobRepositoryShape,
12} from "../Services/CheckpointDiffBlobs.ts";
13
14const makeCheckpointDiffBlobRepository = Effect.gen(function* () {
15 const sql = yield* SqlClient.SqlClient;
16
17 const upsertDiffBlobRow = SqlSchema.void({
18 Request: CheckpointDiffBlob,
19 execute: (blob) =>
20 sql`
21 INSERT INTO checkpoint_diff_blobs (
22 thread_id,
23 from_turn_count,
24 to_turn_count,
25 diff,
26 created_at
27 )
28 VALUES (
29 ${blob.threadId},
30 ${blob.fromTurnCount},
31 ${blob.toTurnCount},
32 ${blob.diff},
33 ${blob.createdAt}
34 )
35 ON CONFLICT (thread_id, from_turn_count, to_turn_count)
36 DO UPDATE SET
37 diff = excluded.diff,
38 created_at = excluded.created_at
39 `,
40 });
41
42 const getDiffBlobRow = SqlSchema.findOneOption({
43 Request: GetCheckpointDiffBlobInput,
44 Result: CheckpointDiffBlob,
45 execute: (request) =>
46 sql`
47 SELECT
48 thread_id AS "threadId",
49 from_turn_count AS "fromTurnCount",
50 to_turn_count AS "toTurnCount",
51 diff,
52 created_at AS "createdAt"
53 FROM checkpoint_diff_blobs
54 WHERE thread_id = ${request.threadId}
55 AND from_turn_count = ${request.fromTurnCount}
56 AND to_turn_count = ${request.toTurnCount}
57 `,
58 });
59
60 const upsert: CheckpointDiffBlobRepositoryShape["upsert"] = (blob) =>
61 upsertDiffBlobRow(blob).pipe(
62 Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.upsert:query")),
63 );
64
65 const get: CheckpointDiffBlobRepositoryShape["get"] = (input) =>
66 getDiffBlobRow(input).pipe(
67 Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.get:query")),
68 );
69
70 return {
71 upsert,
72 get,
73 } satisfies CheckpointDiffBlobRepositoryShape;
74});
75
76export const CheckpointDiffBlobRepositoryLive = Layer.effect(
77 CheckpointDiffBlobRepository,
78 makeCheckpointDiffBlobRepository,
79);
apps/server/src/persistence/Services/CheckpointDiffBlobs.ts deleted-36
...@@ -1,36 +0,0 @@
1import { IsoDateTime, NonNegativeInt, ThreadId } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { CheckpointDiffBlobRepositoryError } from "../Errors.ts";
6
7export const CheckpointDiffBlob = Schema.Struct({
8 threadId: ThreadId,
9 fromTurnCount: NonNegativeInt,
10 toTurnCount: NonNegativeInt,
11 diff: Schema.String,
12 createdAt: IsoDateTime,
13});
14export type CheckpointDiffBlob = typeof CheckpointDiffBlob.Type;
15
16export const GetCheckpointDiffBlobInput = Schema.Struct({
17 threadId: ThreadId,
18 fromTurnCount: NonNegativeInt,
19 toTurnCount: NonNegativeInt,
20});
21export type GetCheckpointDiffBlobInput = typeof GetCheckpointDiffBlobInput.Type;
22
23export interface CheckpointDiffBlobRepositoryShape {
24 readonly upsert: (
25 blob: CheckpointDiffBlob,
26 ) => Effect.Effect<void, CheckpointDiffBlobRepositoryError>;
27
28 readonly get: (
29 input: GetCheckpointDiffBlobInput,
30 ) => Effect.Effect<Option.Option<CheckpointDiffBlob>, CheckpointDiffBlobRepositoryError>;
31}
32
33export class CheckpointDiffBlobRepository extends ServiceMap.Service<
34 CheckpointDiffBlobRepository,
35 CheckpointDiffBlobRepositoryShape
36>()("persistence/CheckpointDiffBlobRepository") {}
apps/server/src/wsServer.test.ts+134-17
...@@ -273,6 +273,7 @@ describe("WebSocket Server", () => {...@@ -273,6 +273,7 @@ describe("WebSocket Server", () => {
273 options: {273 options: {
274 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;274 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
275 cwd?: string;275 cwd?: string;
276 autoBootstrapProjectFromCwd?: boolean;
276 devUrl?: string;277 devUrl?: string;
277 authToken?: string;278 authToken?: string;
278 stateDir?: string;279 stateDir?: string;
...@@ -290,6 +291,9 @@ describe("WebSocket Server", () => {...@@ -290,6 +291,9 @@ describe("WebSocket Server", () => {
290 cwd: options.cwd ?? "/test/project",291 cwd: options.cwd ?? "/test/project",
291 stateDir,292 stateDir,
292 persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,293 persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,
294 ...(options.autoBootstrapProjectFromCwd !== undefined
295 ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
296 : {}),
293 ...(options.devUrl ? { devUrl: options.devUrl } : {}),297 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
294 ...(options.authToken ? { authToken: options.authToken } : {}),298 ...(options.authToken ? { authToken: options.authToken } : {}),
295 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),299 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
...@@ -333,6 +337,37 @@ describe("WebSocket Server", () => {...@@ -333,6 +337,37 @@ describe("WebSocket Server", () => {
333 });337 });
334 });338 });
335339
340 it("bootstraps the cwd project on startup when enabled", async () => {
341 server = createTestServer({
342 cwd: "/test/bootstrap-workspace",
343 autoBootstrapProjectFromCwd: true,
344 });
345 await server.start();
346 const addr = server.httpServer.address();
347 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
348 expect(port).toBeGreaterThan(0);
349
350 const ws = await connectWs(port);
351 connections.push(ws);
352 await waitForMessage(ws); // welcome
353
354 const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
355 expect(snapshotResponse.error).toBeUndefined();
356 const snapshot = snapshotResponse.result as {
357 projects: Array<{ workspaceRoot: string; title: string; defaultModel: string | null }>;
358 };
359
360 expect(snapshot.projects).toEqual(
361 expect.arrayContaining([
362 expect.objectContaining({
363 workspaceRoot: "/test/bootstrap-workspace",
364 title: "bootstrap-workspace",
365 defaultModel: "gpt-5-codex",
366 }),
367 ]),
368 );
369 });
370
336 it("logs outbound websocket push events in dev mode", async () => {371 it("logs outbound websocket push events in dev mode", async () => {
337 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {372 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
338 // Keep test output clean while verifying websocket logs.373 // Keep test output clean while verifying websocket logs.
...@@ -670,6 +705,86 @@ describe("WebSocket Server", () => {...@@ -670,6 +705,86 @@ describe("WebSocket Server", () => {
670 expect(response.error!.message).toContain("Invalid request format");705 expect(response.error!.message).toContain("Invalid request format");
671 });706 });
672707
708 it("returns error when requesting turn diff for unknown thread", async () => {
709 server = createTestServer({ cwd: "/test" });
710 await server.start();
711 const addr = server.httpServer.address();
712 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
713
714 const ws = await connectWs(port);
715 connections.push(ws);
716 await waitForMessage(ws);
717
718 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
719 threadId: "thread-missing",
720 fromTurnCount: 1,
721 toTurnCount: 2,
722 });
723 expect(response.result).toBeUndefined();
724 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
725 });
726
727 it("returns error when requesting full thread diff for unknown thread", async () => {
728 server = createTestServer({ cwd: "/test" });
729 await server.start();
730 const addr = server.httpServer.address();
731 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
732
733 const ws = await connectWs(port);
734 connections.push(ws);
735 await waitForMessage(ws);
736
737 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
738 threadId: "thread-missing",
739 toTurnCount: 2,
740 });
741 expect(response.result).toBeUndefined();
742 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
743 });
744
745 it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
746 server = createTestServer({ cwd: "/test" });
747 await server.start();
748 const addr = server.httpServer.address();
749 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
750
751 const ws = await connectWs(port);
752 connections.push(ws);
753 await waitForMessage(ws);
754
755 const createdAt = new Date().toISOString();
756 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
757 type: "project.create",
758 commandId: "cmd-diff-project-create",
759 projectId: "project-diff",
760 title: "Diff Project",
761 workspaceRoot: "/tmp/ws-diff-project",
762 defaultModel: "gpt-5-codex",
763 createdAt,
764 });
765 expect(createProjectResponse.error).toBeUndefined();
766 const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
767 type: "thread.create",
768 commandId: "cmd-diff-thread-create",
769 threadId: "thread-diff",
770 projectId: "project-diff",
771 title: "Diff Thread",
772 model: "gpt-5-codex",
773 branch: null,
774 worktreePath: null,
775 createdAt,
776 });
777 expect(createThreadResponse.error).toBeUndefined();
778
779 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
780 threadId: "thread-diff",
781 fromTurnCount: 0,
782 toTurnCount: 1,
783 });
784 expect(response.result).toBeUndefined();
785 expect(response.error?.message).toContain("exceeds current turn count");
786 });
787
673 it("keeps orchestration domain push behavior for provider runtime events", async () => {788 it("keeps orchestration domain push behavior for provider runtime events", async () => {
674 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());789 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
675 const sessionId = "sess-test";790 const sessionId = "sess-test";
...@@ -1011,21 +1126,23 @@ describe("WebSocket Server", () => {...@@ -1011,21 +1126,23 @@ describe("WebSocket Server", () => {
1011 expect(pullResponse.error?.message).not.toContain("Unknown method");1126 expect(pullResponse.error?.message).not.toContain("Unknown method");
1012 });1127 });
10131128
1014 it("returns error for git.status", async () => {1129 it("supports git.status over websocket", async () => {
1130 const statusResult = {
1131 branch: "feature/test",
1132 hasWorkingTreeChanges: true,
1133 workingTree: {
1134 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1135 insertions: 7,
1136 deletions: 2,
1137 },
1138 hasUpstream: false,
1139 aheadCount: 0,
1140 behindCount: 0,
1141 openPr: null,
1142 };
1143
1015 const gitManager = {1144 const gitManager = {
1016 status: vi.fn().mockResolvedValue({1145 status: vi.fn().mockResolvedValue(statusResult),
1017 branch: "feature/test",
1018 hasWorkingTreeChanges: true,
1019 workingTree: {
1020 files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1021 insertions: 7,
1022 deletions: 2,
1023 },
1024 hasUpstream: false,
1025 aheadCount: 0,
1026 behindCount: 0,
1027 openPr: null,
1028 }),
1029 runStackedAction: vi.fn(),1146 runStackedAction: vi.fn(),
1030 };1147 };
10311148
...@@ -1041,9 +1158,9 @@ describe("WebSocket Server", () => {...@@ -1041,9 +1158,9 @@ describe("WebSocket Server", () => {
1041 const response = await sendRequest(ws, WS_METHODS.gitStatus, {1158 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
1042 cwd: "/test",1159 cwd: "/test",
1043 });1160 });
1044 expect(response.result).toBeUndefined();1161 expect(response.error).toBeUndefined();
1045 expect(response.error?.message).toContain("Unknown method");1162 expect(response.result).toEqual(statusResult);
1046 expect(gitManager.status).not.toHaveBeenCalled();1163 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
1047 });1164 });
10481165
1049 it("returns errors from git.runStackedAction", async () => {1166 it("returns errors from git.runStackedAction", async () => {
apps/server/src/wsServer.ts+172-51
...@@ -6,6 +6,7 @@ import path from "node:path";...@@ -6,6 +6,7 @@ import path from "node:path";
6import type { Duplex } from "node:stream";6import type { Duplex } from "node:stream";
77
8import {8import {
9 CheckpointRef,
9 EDITORS,10 EDITORS,
10 ORCHESTRATION_WS_CHANNELS,11 ORCHESTRATION_WS_CHANNELS,
11 ORCHESTRATION_WS_METHODS,12 ORCHESTRATION_WS_METHODS,
...@@ -17,7 +18,7 @@ import {...@@ -17,7 +18,7 @@ import {
17 WsResponse,18 WsResponse,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import { NodeServices } from "@effect/platform-node";20import { NodeServices } from "@effect/platform-node";
20import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect";21import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect";
21import { WebSocketServer, type WebSocket } from "ws";22import { WebSocketServer, type WebSocket } from "ws";
2223
23import { createLogger } from "./logger";24import { createLogger } from "./logger";
...@@ -52,7 +53,6 @@ import {...@@ -52,7 +53,6 @@ import {
52import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";53import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
53import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";54import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
54import assert from "node:assert";55import assert from "node:assert";
55import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs";
56import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";56import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
57import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";57import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
58import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";58import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
...@@ -62,10 +62,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD...@@ -62,10 +62,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
62import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";62import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
63import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";63import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
64import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";64import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65import {65import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore";
66 CheckpointDiffBlobRepository,
67 type CheckpointDiffBlobRepositoryShape,
68} from "./persistence/Services/CheckpointDiffBlobs";
69import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";66import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
70import * as SqlClient from "effect/unstable/sql/SqlClient";67import * as SqlClient from "effect/unstable/sql/SqlClient";
71import * as SqlError from "effect/unstable/sql/SqlError";68import * as SqlError from "effect/unstable/sql/SqlError";
...@@ -91,6 +88,7 @@ export interface ServerOptions {...@@ -91,6 +88,7 @@ export interface ServerOptions {
91 port: number;88 port: number;
92 host?: string | undefined;89 host?: string | undefined;
93 cwd: string;90 cwd: string;
91 autoBootstrapProjectFromCwd?: boolean | undefined;
94 stateDir?: string | undefined;92 stateDir?: string | undefined;
95 persistenceLayer?:93 persistenceLayer?:
96 | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>94 | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>
...@@ -171,11 +169,18 @@ function stripRequestTag<T extends { _tag: string }>(...@@ -171,11 +169,18 @@ function stripRequestTag<T extends { _tag: string }>(
171 return rest;169 return rest;
172}170}
173171
172const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
173
174function checkpointRefForThreadTurn(threadId: string, turnCount: number): CheckpointRef {
175 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
176 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
177}
178
174type EffectRuntime = ManagedRuntime.ManagedRuntime<179type EffectRuntime = ManagedRuntime.ManagedRuntime<
175 | OrchestrationEngineService180 | OrchestrationEngineService
176 | ProjectionSnapshotQuery181 | ProjectionSnapshotQuery
177 | ProviderService182 | ProviderService
178 | CheckpointDiffBlobRepository183 | CheckpointStore
179 | OrchestrationReactor,184 | OrchestrationReactor,
180 unknown185 unknown
181>;186>;
...@@ -185,6 +190,7 @@ export function createServer(options: ServerOptions) {...@@ -185,6 +190,7 @@ export function createServer(options: ServerOptions) {
185 port,190 port,
186 host,191 host,
187 cwd,192 cwd,
193 autoBootstrapProjectFromCwd = false,
188 stateDir,194 stateDir,
189 persistenceLayer: customPersistenceLayer,195 persistenceLayer: customPersistenceLayer,
190 staticDir,196 staticDir,
...@@ -201,7 +207,7 @@ export function createServer(options: ServerOptions) {...@@ -201,7 +207,7 @@ export function createServer(options: ServerOptions) {
201 let orchestrationEngine: OrchestrationEngineShape | null = null;207 let orchestrationEngine: OrchestrationEngineShape | null = null;
202 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;208 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
203 let providerService: ProviderServiceShape | null = null;209 let providerService: ProviderServiceShape | null = null;
204 let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null;210 let checkpointStore: CheckpointStoreShape | null = null;
205 const clients = new Set<WebSocket>();211 const clients = new Set<WebSocket>();
206 const logger = createLogger("ws");212 const logger = createLogger("ws");
207 const logWebSocketEvents =213 const logWebSocketEvents =
...@@ -235,11 +241,40 @@ export function createServer(options: ServerOptions) {...@@ -235,11 +241,40 @@ export function createServer(options: ServerOptions) {
235 return projectionSnapshotQuery;241 return projectionSnapshotQuery;
236 };242 };
237243
238 const getCheckpointDiffBlobRepository = () => {244 const getCheckpointStore = () => {
239 assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started");245 assert(checkpointStore, "Checkpoint store is not started");
240 return checkpointDiffBlobRepository;246 return checkpointStore;
241 };247 };
242248
249 async function bootstrapProjectForCwd() {
250 if (!autoBootstrapProjectFromCwd) {
251 return;
252 }
253
254 const runtime = getEffectRuntime();
255 const snapshotQuery = getProjectionSnapshotQuery();
256 const engine = getOrchestrationEngine();
257 const snapshot = await runtime.runPromise(snapshotQuery.getSnapshot());
258 const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
259 if (existing) {
260 return;
261 }
262
263 const createdAt = new Date().toISOString();
264 const projectName = path.basename(cwd) || "project";
265 await runtime.runPromise(
266 engine.dispatchUnknownCommand({
267 type: "project.create",
268 commandId: crypto.randomUUID(),
269 projectId: crypto.randomUUID(),
270 title: projectName,
271 workspaceRoot: cwd,
272 defaultModel: "gpt-5-codex",
273 createdAt,
274 }),
275 );
276 }
277
243 const attachSubscriptionss = Effect.gen(function* () {278 const attachSubscriptionss = Effect.gen(function* () {
244 const orchestrationEngine = yield* OrchestrationEngineService;279 const orchestrationEngine = yield* OrchestrationEngineService;
245 const orchestrationReactor = yield* OrchestrationReactor;280 const orchestrationReactor = yield* OrchestrationReactor;
...@@ -439,7 +474,114 @@ export function createServer(options: ServerOptions) {...@@ -439,7 +474,114 @@ export function createServer(options: ServerOptions) {
439 const orchestrationEngine = getOrchestrationEngine();474 const orchestrationEngine = getOrchestrationEngine();
440 const projectionReadModelQuery = getProjectionSnapshotQuery();475 const projectionReadModelQuery = getProjectionSnapshotQuery();
441 const effectRuntime = getEffectRuntime();476 const effectRuntime = getEffectRuntime();
442 const diffBlobRepository = getCheckpointDiffBlobRepository();477 const liveCheckpointStore = getCheckpointStore();
478
479 const computeTurnDiff = async (input: {
480 readonly threadId: string;
481 readonly fromTurnCount: number;
482 readonly toTurnCount: number;
483 }) => {
484 if (input.fromTurnCount > input.toTurnCount) {
485 throw new Error(
486 `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`,
487 );
488 }
489
490 if (input.fromTurnCount === input.toTurnCount) {
491 return {
492 threadId: input.threadId,
493 fromTurnCount: input.fromTurnCount,
494 toTurnCount: input.toTurnCount,
495 diff: "",
496 };
497 }
498
499 const snapshot = await effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
500 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
501 if (!thread) {
502 throw new Error(`Thread '${input.threadId}' not found.`);
503 }
504
505 const maxTurnCount = thread.checkpoints.reduce(
506 (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount),
507 0,
508 );
509 if (input.toTurnCount > maxTurnCount) {
510 throw new Error(
511 `Turn diff range exceeds current turn count for thread '${input.threadId}': requested ${input.toTurnCount}, current ${maxTurnCount}.`,
512 );
513 }
514
515 const workspaceCwd =
516 thread.worktreePath?.trim() ||
517 snapshot.projects.find((project) => project.id === thread.projectId)?.workspaceRoot?.trim();
518 if (!workspaceCwd) {
519 throw new Error(
520 `Invariant violation: workspace path missing for thread '${input.threadId}' when computing turn diff.`,
521 );
522 }
523
524 const fromCheckpointRef =
525 input.fromTurnCount === 0
526 ? checkpointRefForThreadTurn(input.threadId, 0)
527 : thread.checkpoints.find(
528 (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount,
529 )?.checkpointRef;
530 if (!fromCheckpointRef) {
531 throw new Error(
532 `Checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
533 );
534 }
535
536 const toCheckpointRef = thread.checkpoints.find(
537 (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount,
538 )?.checkpointRef;
539 if (!toCheckpointRef) {
540 throw new Error(
541 `Checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
542 );
543 }
544
545 const [fromExists, toExists] = await Promise.all([
546 effectRuntime.runPromise(
547 liveCheckpointStore.hasCheckpointRef({
548 cwd: workspaceCwd,
549 checkpointRef: fromCheckpointRef,
550 }),
551 ),
552 effectRuntime.runPromise(
553 liveCheckpointStore.hasCheckpointRef({
554 cwd: workspaceCwd,
555 checkpointRef: toCheckpointRef,
556 }),
557 ),
558 ]);
559 if (!fromExists) {
560 throw new Error(
561 `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
562 );
563 }
564 if (!toExists) {
565 throw new Error(
566 `Filesystem checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
567 );
568 }
569
570 const diff = await effectRuntime.runPromise(
571 liveCheckpointStore.diffCheckpoints({
572 cwd: workspaceCwd,
573 fromCheckpointRef,
574 toCheckpointRef,
575 fallbackFromToHead: false,
576 }),
577 );
578 return {
579 threadId: input.threadId,
580 fromTurnCount: input.fromTurnCount,
581 toTurnCount: input.toTurnCount,
582 diff,
583 };
584 };
443585
444 switch (request.body._tag) {586 switch (request.body._tag) {
445 case ORCHESTRATION_WS_METHODS.getSnapshot: {587 case ORCHESTRATION_WS_METHODS.getSnapshot: {
...@@ -456,19 +598,16 @@ export function createServer(options: ServerOptions) {...@@ -456,19 +598,16 @@ export function createServer(options: ServerOptions) {
456598
457 case ORCHESTRATION_WS_METHODS.getTurnDiff: {599 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
458 const body = stripRequestTag(request.body);600 const body = stripRequestTag(request.body);
459 const blob = await effectRuntime.runPromise(diffBlobRepository.get(body));601 return computeTurnDiff(body);
460 if (Option.isNone(blob)) {602 }
461 throw new Error(
462 `Turn diff not found for thread '${body.threadId}' from ${body.fromTurnCount} to ${body.toTurnCount}.`,
463 );
464 }
465603
466 return {604 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
605 const body = stripRequestTag(request.body);
606 return computeTurnDiff({
467 threadId: body.threadId,607 threadId: body.threadId,
468 fromTurnCount: body.fromTurnCount,608 fromTurnCount: 0,
469 toTurnCount: body.toTurnCount,609 toTurnCount: body.toTurnCount,
470 diff: blob.value?.diff,610 });
471 };
472 }611 }
473612
474 case ORCHESTRATION_WS_METHODS.replayEvents:613 case ORCHESTRATION_WS_METHODS.replayEvents:
...@@ -526,28 +665,8 @@ export function createServer(options: ServerOptions) {...@@ -526,28 +665,8 @@ export function createServer(options: ServerOptions) {
526 return undefined;665 return undefined;
527 }666 }
528667
529 // TODO: Bring back668 case WS_METHODS.gitStatus:
530 // case WS_METHODS.gitStatus: {669 return gitManager.status(stripRequestTag(request.body));
531 // const params = request.params as { cwd: string };
532 // const status = await gitManager.status(request.params as never);
533 // const projects = await effectRuntime.runPromise(projectRepository.list());
534 // const project = projects.find((entry) => entry.cwd === params.cwd);
535 // if (project) {
536 // await effectRuntime.runPromise(
537 // orchestrationEngine.dispatch({
538 // type: "git.readModel.upsert",
539 // commandId: crypto.randomUUID(),
540 // projectId: project.id,
541 // branch: status.branch,
542 // hasWorkingTreeChanges: status.hasWorkingTreeChanges,
543 // aheadCount: status.aheadCount,
544 // behindCount: status.behindCount,
545 // createdAt: new Date().toISOString(),
546 // }),
547 // );
548 // }
549 // return status;
550 // }
551670
552 case WS_METHODS.gitPull:671 case WS_METHODS.gitPull:
553 return pullGitBranch(stripRequestTag(request.body));672 return pullGitBranch(stripRequestTag(request.body));
...@@ -606,8 +725,10 @@ export function createServer(options: ServerOptions) {...@@ -606,8 +725,10 @@ export function createServer(options: ServerOptions) {
606 keybindings: keybindingsConfig,725 keybindings: keybindingsConfig,
607 };726 };
608727
609 default:728 default: {
610 throw new Error(`Unknown method: ${request.body._tag}`);729 const _exhaustiveCheck: never = request.body;
730 throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`);
731 }
611 }732 }
612 }733 }
613734
...@@ -651,7 +772,6 @@ export function createServer(options: ServerOptions) {...@@ -651,7 +772,6 @@ export function createServer(options: ServerOptions) {
651 const runtimeServicesLayer = Layer.mergeAll(772 const runtimeServicesLayer = Layer.mergeAll(
652 orchestrationLayer,773 orchestrationLayer,
653 OrchestrationProjectionSnapshotQueryLive,774 OrchestrationProjectionSnapshotQueryLive,
654 CheckpointDiffBlobRepositoryLive,
655 checkpointStoreLayer,775 checkpointStoreLayer,
656 providerLayer,776 providerLayer,
657 );777 );
...@@ -679,17 +799,17 @@ export function createServer(options: ServerOptions) {...@@ -679,17 +799,17 @@ export function createServer(options: ServerOptions) {
679 const [799 const [
680 nextOrchestrationEngine,800 nextOrchestrationEngine,
681 nextProjectionSnapshotQuery,801 nextProjectionSnapshotQuery,
682 nextCheckpointDiffBlobRepository,802 nextCheckpointStore,
683 nextProviderService,803 nextProviderService,
684 ] = await Promise.all([804 ] = await Promise.all([
685 runtime.runPromise(Effect.service(OrchestrationEngineService)),805 runtime.runPromise(Effect.service(OrchestrationEngineService)),
686 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),806 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
687 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),807 runtime.runPromise(Effect.service(CheckpointStore)),
688 runtime.runPromise(Effect.service(ProviderService)),808 runtime.runPromise(Effect.service(ProviderService)),
689 ]);809 ]);
690 orchestrationEngine = nextOrchestrationEngine;810 orchestrationEngine = nextOrchestrationEngine;
691 projectionSnapshotQuery = nextProjectionSnapshotQuery;811 projectionSnapshotQuery = nextProjectionSnapshotQuery;
692 checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository;812 checkpointStore = nextCheckpointStore;
693 providerService = nextProviderService;813 providerService = nextProviderService;
694 await runtime.runPromise(attachSubscriptionss);814 await runtime.runPromise(attachSubscriptionss);
695 } catch (error) {815 } catch (error) {
...@@ -715,7 +835,7 @@ export function createServer(options: ServerOptions) {...@@ -715,7 +835,7 @@ export function createServer(options: ServerOptions) {
715 effectRuntime = null;835 effectRuntime = null;
716 orchestrationEngine = null;836 orchestrationEngine = null;
717 projectionSnapshotQuery = null;837 projectionSnapshotQuery = null;
718 checkpointDiffBlobRepository = null;838 checkpointStore = null;
719 providerService = null;839 providerService = null;
720840
721 if (!runtime) {841 if (!runtime) {
...@@ -731,6 +851,7 @@ export function createServer(options: ServerOptions) {...@@ -731,6 +851,7 @@ export function createServer(options: ServerOptions) {
731851
732 async function start() {852 async function start() {
733 effectRuntime = await createEffectRuntime();853 effectRuntime = await createEffectRuntime();
854 await bootstrapProjectForCwd();
734855
735 return new Promise<void>((resolve, reject) => {856 return new Promise<void>((resolve, reject) => {
736 const onError = (error: Error) => {857 const onError = (error: Error) => {
apps/web/src/components/Sidebar.tsx+36-23
...@@ -132,28 +132,39 @@ export default function Sidebar() {...@@ -132,28 +132,39 @@ export default function Sidebar() {
132 branch?: string | null;132 branch?: string | null;
133 worktreePath?: string | null;133 worktreePath?: string | null;
134 },134 },
135 ) => {135 ): Promise<void> => {
136 if (!api) return;136 if (!api) return Promise.resolve();
137 const threadId = newThreadId();137 const threadId = newThreadId();
138 const createdAt = new Date().toISOString();138 const createdAt = new Date().toISOString();
139 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;139 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
140 void api.orchestration.dispatchCommand({140 return (async () => {
141 type: "thread.create",141 await api.orchestration.dispatchCommand({
142 commandId: newCommandId(),142 type: "thread.create",
143 threadId,143 commandId: newCommandId(),
144 projectId: asProjectId(projectId),144 threadId,
145 title: "New thread",145 projectId: asProjectId(projectId),
146 model,146 title: "New thread",
147 branch: options?.branch ?? null,147 model,
148 worktreePath: options?.worktreePath ?? null,148 branch: options?.branch ?? null,
149 createdAt,149 worktreePath: options?.worktreePath ?? null,
150 });150 createdAt,
151 void navigate({151 });
152 to: "/$threadId",152
153 params: { threadId },153 // Ensure route guards can see the new thread before navigating.
154 });154 try {
155 const snapshot = await api.orchestration.getSnapshot();
156 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
157 } catch {
158 // Event stream can still hydrate the thread shortly after dispatch.
159 }
160
161 await navigate({
162 to: "/$threadId",
163 params: { threadId },
164 });
165 })();
155 },166 },
156 [api, navigate, state.projects],167 [api, dispatch, navigate, state.projects],
157 );168 );
158169
159 const focusMostRecentThreadForProject = useCallback(170 const focusMostRecentThreadForProject = useCallback(
...@@ -201,7 +212,7 @@ export default function Sidebar() {...@@ -201,7 +212,7 @@ export default function Sidebar() {
201 defaultModel: DEFAULT_MODEL,212 defaultModel: DEFAULT_MODEL,
202 createdAt,213 createdAt,
203 });214 });
204 handleNewThread(projectId);215 await handleNewThread(projectId);
205 } finally {216 } finally {
206 setIsAddingProject(false);217 setIsAddingProject(false);
207 setNewCwd("");218 setNewCwd("");
...@@ -399,7 +410,7 @@ export default function Sidebar() {...@@ -399,7 +410,7 @@ export default function Sidebar() {
399 const projectId = activeThread?.projectId ?? state.projects[0]?.id;410 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
400 if (!projectId) return;411 if (!projectId) return;
401 event.preventDefault();412 event.preventDefault();
402 handleNewThread(projectId);413 void handleNewThread(projectId);
403 return;414 return;
404 }415 }
405416
...@@ -407,7 +418,7 @@ export default function Sidebar() {...@@ -407,7 +418,7 @@ export default function Sidebar() {
407 const projectId = activeThread?.projectId ?? state.projects[0]?.id;418 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
408 if (!projectId) return;419 if (!projectId) return;
409 event.preventDefault();420 event.preventDefault();
410 handleNewThread(projectId, {421 void handleNewThread(projectId, {
411 branch: activeThread?.branch ?? null,422 branch: activeThread?.branch ?? null,
412 worktreePath: activeThread?.worktreePath ?? null,423 worktreePath: activeThread?.worktreePath ?? null,
413 });424 });
...@@ -446,7 +457,7 @@ export default function Sidebar() {...@@ -446,7 +457,7 @@ export default function Sidebar() {
446 return;457 return;
447 }458 }
448 const firstProject = state.projects[0];459 const firstProject = state.projects[0];
449 if (firstProject) handleNewThread(firstProject.id);460 if (firstProject) void handleNewThread(firstProject.id);
450 }}461 }}
451 >462 >
452 <span className="text-foreground">+</span>463 <span className="text-foreground">+</span>
...@@ -561,7 +572,9 @@ export default function Sidebar() {...@@ -561,7 +572,9 @@ export default function Sidebar() {
561 <button572 <button
562 type="button"573 type="button"
563 className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80"574 className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80"
564 onClick={() => handleNewThread(project.id)}575 onClick={() => {
576 void handleNewThread(project.id);
577 }}
565 >578 >
566 <span>+</span> New thread579 <span>+</span> New thread
567 </button>580 </button>
apps/web/src/lib/providerReactQuery.test.ts+30
...@@ -28,9 +28,11 @@ describe("providerQueryKeys.checkpointDiff", () => {...@@ -28,9 +28,11 @@ describe("providerQueryKeys.checkpointDiff", () => {
28describe("checkpointDiffQueryOptions", () => {28describe("checkpointDiffQueryOptions", () => {
29 it("forwards checkpoint range to the provider API", async () => {29 it("forwards checkpoint range to the provider API", async () => {
30 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });30 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
31 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
31 const api = {32 const api = {
32 orchestration: {33 orchestration: {
33 getTurnDiff,34 getTurnDiff,
35 getFullThreadDiff,
34 },36 },
35 } as unknown as NativeApi;37 } as unknown as NativeApi;
3638
...@@ -49,6 +51,34 @@ describe("checkpointDiffQueryOptions", () => {...@@ -49,6 +51,34 @@ describe("checkpointDiffQueryOptions", () => {
49 fromTurnCount: 3,51 fromTurnCount: 3,
50 toTurnCount: 4,52 toTurnCount: 4,
51 });53 });
54 expect(getFullThreadDiff).not.toHaveBeenCalled();
55 });
56
57 it("uses explicit full thread diff API when range starts from zero", async () => {
58 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
59 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
60 const api = {
61 orchestration: {
62 getTurnDiff,
63 getFullThreadDiff,
64 },
65 } as unknown as NativeApi;
66
67 const options = checkpointDiffQueryOptions(api, {
68 threadId: "thread-id",
69 fromTurnCount: 0,
70 toTurnCount: 2,
71 cacheScope: "thread:all",
72 });
73
74 const queryClient = new QueryClient();
75 await queryClient.fetchQuery(options);
76
77 expect(getFullThreadDiff).toHaveBeenCalledWith({
78 threadId: "thread-id",
79 toTurnCount: 2,
80 });
81 expect(getTurnDiff).not.toHaveBeenCalled();
52 });82 });
5383
54 it("retries checkpoint-not-ready errors longer than generic failures", () => {84 it("retries checkpoint-not-ready errors longer than generic failures", () => {
apps/web/src/lib/providerReactQuery.ts+6
...@@ -64,6 +64,12 @@ export function checkpointDiffQueryOptions(...@@ -64,6 +64,12 @@ export function checkpointDiffQueryOptions(
64 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {64 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
65 throw new Error("Checkpoint diff range is invalid.");65 throw new Error("Checkpoint diff range is invalid.");
66 }66 }
67 if (fromTurnCount === 0) {
68 return api.orchestration.getFullThreadDiff({
69 threadId: asThreadId(input.threadId),
70 toTurnCount,
71 });
72 }
67 return api.orchestration.getTurnDiff({73 return api.orchestration.getTurnDiff({
68 threadId: asThreadId(input.threadId),74 threadId: asThreadId(input.threadId),
69 fromTurnCount,75 fromTurnCount,
apps/web/src/routes/__root.tsx+8-63
...@@ -3,18 +3,16 @@ import {...@@ -3,18 +3,16 @@ import {
3 createRootRouteWithContext,3 createRootRouteWithContext,
4 type ErrorComponentProps,4 type ErrorComponentProps,
5} from "@tanstack/react-router";5} from "@tanstack/react-router";
6import { useEffect, useRef } from "react";6import { useEffect } from "react";
7import { QueryClient } from "@tanstack/react-query";7import { QueryClient, useQueryClient } from "@tanstack/react-query";
88
9import { APP_DISPLAY_NAME } from "../branding";9import { APP_DISPLAY_NAME } from "../branding";
10import { Button } from "../components/ui/button";10import { Button } from "../components/ui/button";
11import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";11import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
12import { isElectron } from "../env";
13import { useNativeApi } from "../hooks/useNativeApi";12import { useNativeApi } from "../hooks/useNativeApi";
14import { DEFAULT_MODEL } from "../model-logic";
15import { useStore } from "../store";13import { useStore } from "../store";
16import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
17import { onServerWelcome } from "../wsNativeApi";14import { onServerWelcome } from "../wsNativeApi";
15import { providerQueryKeys } from "../lib/providerReactQuery";
1816
19export const Route = createRootRouteWithContext<{17export const Route = createRootRouteWithContext<{
20 queryClient: QueryClient;18 queryClient: QueryClient;
...@@ -42,7 +40,6 @@ function RootRouteView() {...@@ -42,7 +40,6 @@ function RootRouteView() {
42 <ToastProvider>40 <ToastProvider>
43 <AnchoredToastProvider>41 <AnchoredToastProvider>
44 <EventRouter />42 <EventRouter />
45 <AutoProjectBootstrap />
46 <DesktopProjectBootstrap />43 <DesktopProjectBootstrap />
47 <Outlet />44 <Outlet />
48 </AnchoredToastProvider>45 </AnchoredToastProvider>
...@@ -124,6 +121,7 @@ function errorDetails(error: unknown): string {...@@ -124,6 +121,7 @@ function errorDetails(error: unknown): string {
124function EventRouter() {121function EventRouter() {
125 const api = useNativeApi();122 const api = useNativeApi();
126 const { dispatch } = useStore();123 const { dispatch } = useStore();
124 const queryClient = useQueryClient();
127125
128 useEffect(() => {126 useEffect(() => {
129 if (!api) return;127 if (!api) return;
...@@ -166,6 +164,9 @@ function EventRouter() {...@@ -166,6 +164,9 @@ function EventRouter() {
166 return;164 return;
167 }165 }
168 latestSequence = event.sequence;166 latestSequence = event.sequence;
167 if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") {
168 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
169 }
169 void syncSnapshot();170 void syncSnapshot();
170 });171 });
171 const unsubWelcome = onServerWelcome(() => {172 const unsubWelcome = onServerWelcome(() => {
...@@ -176,63 +177,7 @@ function EventRouter() {...@@ -176,63 +177,7 @@ function EventRouter() {
176 unsubDomainEvent();177 unsubDomainEvent();
177 unsubWelcome();178 unsubWelcome();
178 };179 };
179 }, [api, dispatch]);180 }, [api, dispatch, queryClient]);
180
181 return null;
182}
183
184function AutoProjectBootstrap() {
185 const api = useNativeApi();
186 const { state, dispatch } = useStore();
187 const bootstrappedRef = useRef(false);
188
189 useEffect(() => {
190 if (!api) return;
191 // Browser mode auto-adds the current cwd project via server welcome.
192 // Desktop skips this because projects are already loaded from the repository.
193 if (isElectron) return;
194
195 return onServerWelcome((payload) => {
196 if (bootstrappedRef.current) return;
197
198 // Don't create duplicate projects for the same cwd
199 const existing = state.projects.find((project) => project.cwd === payload.cwd);
200 if (existing) {
201 bootstrappedRef.current = true;
202 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
203 return;
204 }
205
206 bootstrappedRef.current = true;
207 const now = new Date().toISOString();
208 const projectId = newProjectId();
209 const threadId = newThreadId();
210 void api.orchestration
211 .dispatchCommand({
212 type: "project.create",
213 commandId: newCommandId(),
214 projectId,
215 title: payload.projectName,
216 workspaceRoot: payload.cwd,
217 defaultModel: DEFAULT_MODEL,
218 createdAt: now,
219 })
220 .then(() =>
221 api.orchestration.dispatchCommand({
222 type: "thread.create",
223 commandId: newCommandId(),
224 threadId,
225 projectId,
226 title: "New thread",
227 model: DEFAULT_MODEL,
228 branch: null,
229 worktreePath: null,
230 createdAt: now,
231 }),
232 )
233 .catch(() => undefined);
234 });
235 }, [api, state.projects, dispatch]);
236181
237 return null;182 return null;
238}183}
apps/web/src/wsNativeApi.test.ts created+53
...@@ -0,0 +1,53 @@
1import { CommandId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vitest";
3
4const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
5const subscribeMock = vi.fn<(...args: Array<unknown>) => () => void>(() => () => undefined);
6
7vi.mock("./wsTransport", () => {
8 return {
9 WsTransport: class MockWsTransport {
10 request = requestMock;
11 subscribe = subscribeMock;
12 },
13 };
14});
15
16describe("wsNativeApi", () => {
17 it("wraps orchestration dispatch commands in the command envelope", async () => {
18 requestMock.mockResolvedValue(undefined);
19 const { createWsNativeApi } = await import("./wsNativeApi");
20
21 const api = createWsNativeApi();
22 const command = {
23 type: "project.create",
24 commandId: CommandId.makeUnsafe("cmd-1"),
25 projectId: ProjectId.makeUnsafe("project-1"),
26 title: "Project",
27 workspaceRoot: "/tmp/project",
28 defaultModel: "gpt-5-codex",
29 createdAt: "2026-02-24T00:00:00.000Z",
30 } as const;
31 await api.orchestration.dispatchCommand(command);
32
33 expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {
34 command,
35 });
36 });
37
38 it("forwards full-thread diff requests to the orchestration websocket method", async () => {
39 requestMock.mockResolvedValue({ diff: "patch" });
40 const { createWsNativeApi } = await import("./wsNativeApi");
41
42 const api = createWsNativeApi();
43 await api.orchestration.getFullThreadDiff({
44 threadId: ThreadId.makeUnsafe("thread-1"),
45 toTurnCount: 1,
46 });
47
48 expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
49 threadId: "thread-1",
50 toTurnCount: 1,
51 });
52 });
53});
apps/web/src/wsNativeApi.ts+3-1
...@@ -128,8 +128,10 @@ export function createWsNativeApi(): NativeApi {...@@ -128,8 +128,10 @@ export function createWsNativeApi(): NativeApi {
128 orchestration: {128 orchestration: {
129 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),129 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
130 dispatchCommand: (command) =>130 dispatchCommand: (command) =>
131 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),131 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
132 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),132 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
133 getFullThreadDiff: (input) =>
134 transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
133 replayEvents: (fromSequenceExclusive) =>135 replayEvents: (fromSequenceExclusive) =>
134 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),136 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
135 onDomainEvent: (callback) =>137 onDomainEvent: (callback) =>
packages/contracts/src/ipc.ts+5
...@@ -28,6 +28,8 @@ import type {...@@ -28,6 +28,8 @@ import type {
28import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";28import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
29import type {29import type {
30 ClientOrchestrationCommand,30 ClientOrchestrationCommand,
31 OrchestrationGetFullThreadDiffInput,
32 OrchestrationGetFullThreadDiffResult,
31 OrchestrationGetTurnDiffInput,33 OrchestrationGetTurnDiffInput,
32 OrchestrationGetTurnDiffResult,34 OrchestrationGetTurnDiffResult,
33 OrchestrationEvent,35 OrchestrationEvent,
...@@ -95,6 +97,9 @@ export interface NativeApi {...@@ -95,6 +97,9 @@ export interface NativeApi {
95 getSnapshot: () => Promise<OrchestrationReadModel>;97 getSnapshot: () => Promise<OrchestrationReadModel>;
96 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;98 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
97 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;99 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
100 getFullThreadDiff: (
101 input: OrchestrationGetFullThreadDiffInput,
102 ) => Promise<OrchestrationGetFullThreadDiffResult>;
98 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;103 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
99 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;104 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
100 };105 };
packages/contracts/src/orchestration.ts+14
...@@ -4,6 +4,7 @@ export const ORCHESTRATION_WS_METHODS = {...@@ -4,6 +4,7 @@ export const ORCHESTRATION_WS_METHODS = {
4 getSnapshot: "orchestration.getSnapshot",4 getSnapshot: "orchestration.getSnapshot",
5 dispatchCommand: "orchestration.dispatchCommand",5 dispatchCommand: "orchestration.dispatchCommand",
6 getTurnDiff: "orchestration.getTurnDiff",6 getTurnDiff: "orchestration.getTurnDiff",
7 getFullThreadDiff: "orchestration.getFullThreadDiff",
7 replayEvents: "orchestration.replayEvents",8 replayEvents: "orchestration.replayEvents",
8} as const;9} as const;
910
...@@ -837,6 +838,15 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput...@@ -837,6 +838,15 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput
837export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;838export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
838export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type;839export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type;
839840
841export const OrchestrationGetFullThreadDiffInput = Schema.Struct({
842 threadId: ThreadId,
843 toTurnCount: NonNegativeInt,
844});
845export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;
846
847export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;
848export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type;
849
840export const OrchestrationReplayEventsInput = Schema.Struct({850export const OrchestrationReplayEventsInput = Schema.Struct({
841 fromSequenceExclusive: NonNegativeInt,851 fromSequenceExclusive: NonNegativeInt,
842});852});
...@@ -858,6 +868,10 @@ export const OrchestrationRpcSchemas = {...@@ -858,6 +868,10 @@ export const OrchestrationRpcSchemas = {
858 input: OrchestrationGetTurnDiffInput,868 input: OrchestrationGetTurnDiffInput,
859 output: OrchestrationGetTurnDiffResult,869 output: OrchestrationGetTurnDiffResult,
860 },870 },
871 getFullThreadDiff: {
872 input: OrchestrationGetFullThreadDiffInput,
873 output: OrchestrationGetFullThreadDiffResult,
874 },
861 replayEvents: {875 replayEvents: {
862 input: OrchestrationReplayEventsInput,876 input: OrchestrationReplayEventsInput,
863 output: OrchestrationReplayEventsResult,877 output: OrchestrationReplayEventsResult,
packages/contracts/src/ws.ts+2
...@@ -2,6 +2,7 @@ import { Schema } from "effect";...@@ -2,6 +2,7 @@ import { Schema } from "effect";
22
3import {3import {
4 ClientOrchestrationCommand,4 ClientOrchestrationCommand,
5 OrchestrationGetFullThreadDiffInput,
5 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
6 OrchestrationGetSnapshotInput,7 OrchestrationGetSnapshotInput,
7 OrchestrationGetTurnDiffInput,8 OrchestrationGetTurnDiffInput,
...@@ -80,6 +81,7 @@ export const WebSocketRequestBody = Schema.TaggedUnion({...@@ -80,6 +81,7 @@ export const WebSocketRequestBody = Schema.TaggedUnion({
80 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },81 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
81 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,82 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
82 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,83 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,
84 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields,
83 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,85 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,
8486
85 // Project Search87 // Project Search