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";
66import { NodeServices } from "@effect/platform-node";
77import {
88 ApprovalRequestId,
9 CheckpointRef,
910 type OrchestrationEvent,
1011 type OrchestrationThread,
11 type ThreadId,
1212} from "@t3tools/contracts";
1313import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
1414
1515import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
16import { CheckpointDiffBlobRepositoryLive } from "../src/persistence/Layers/CheckpointDiffBlobs.ts";
16import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
1717import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
1818import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
1919import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
2020import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
2121import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
2222import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
23import { CheckpointDiffBlobRepository } from "../src/persistence/Services/CheckpointDiffBlobs.ts";
2423import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
2524import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
2625import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
......@@ -64,9 +63,9 @@ function initializeGitWorkspace(cwd: string) {
6463 runGit(cwd, ["commit", "-m", "Initial"]);
6564}
6665
67export function checkpointRefForTurn(threadId: string, turnCount: number): string {
66export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef {
6867 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}`);
7069}
7170
7271export function gitRefExists(cwd: string, ref: string): boolean {
......@@ -127,7 +126,7 @@ export interface OrchestrationIntegrationHarness {
127126 readonly engine: OrchestrationEngineShape;
128127 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
129128 readonly providerService: ProviderService["Service"];
130 readonly diffBlobRepository: CheckpointDiffBlobRepository["Service"];
129 readonly checkpointStore: CheckpointStore["Service"];
131130 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
132131 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
133132 readonly waitForThread: (
......@@ -155,23 +154,6 @@ export interface OrchestrationIntegrationHarness {
155154 },
156155 never
157156 >;
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 >;
175157 readonly dispose: Effect.Effect<void, never>;
176158}
177159
......@@ -214,7 +196,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
214196 const runtimeServicesLayer = Layer.mergeAll(
215197 orchestrationLayer,
216198 OrchestrationProjectionSnapshotQueryLive,
217 CheckpointDiffBlobRepositoryLive,
218199 ProjectionCheckpointRepositoryLive,
219200 ProjectionPendingApprovalRepositoryLive,
220201 checkpointStoreLayer,
......@@ -252,8 +233,8 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
252233 const providerService = yield* Effect.promise(() =>
253234 runtime.runPromise(Effect.service(ProviderService)),
254235 );
255 const diffBlobRepository = yield* Effect.promise(() =>
256 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
236 const checkpointStore = yield* Effect.promise(() =>
237 runtime.runPromise(Effect.service(CheckpointStore)),
257238 );
258239 const checkpointRepository = yield* Effect.promise(() =>
259240 runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
......@@ -335,38 +316,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
335316 never
336317 >;
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
370319 let disposed = false;
371320 const dispose = Effect.gen(function* () {
372321 if (disposed) {
......@@ -390,13 +339,12 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
390339 engine,
391340 snapshotQuery,
392341 providerService,
393 diffBlobRepository,
342 checkpointStore,
394343 checkpointRepository,
395344 pendingApprovalRepository,
396345 waitForThread,
397346 waitForDomainEvent,
398347 waitForPendingApproval,
399 waitForDiffBlob,
400348 dispose,
401349 } satisfies OrchestrationIntegrationHarness;
402350});
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 +
202202 ),
203203);
204204
205it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =>
205it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
206206 withHarness((harness) =>
207207 Effect.gen(function* () {
208208 yield* seedProjectAndThread(harness);
......@@ -327,12 +327,21 @@ it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =
327327 [1, 2],
328328 );
329329
330 const diffBlob = yield* harness.waitForDiffBlob({
331 threadId: THREAD_ID,
332 fromTurnCount: 1,
333 toTurnCount: 2,
330 const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({
331 cwd: harness.workspaceDir,
332 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1),
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,
334343 });
335 assert.equal(diffBlob.diff.includes("README.md"), true);
344 assert.equal(fullDiff.includes("README.md"), true);
336345
337346 assert.equal(
338347 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
511520 threadId: FIXTURE_THREAD_ID,
512521 turnId: FIXTURE_TURN_ID,
513522 },
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 },
514548 {
515549 type: "turn.completed",
516550 ...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
549583 threadId: FIXTURE_THREAD_ID,
550584 turnId: FIXTURE_TURN_ID,
551585 },
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 },
552611 {
553612 type: "turn.completed",
554613 ...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
571630
572631 yield* harness.waitForThread(
573632 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"),
575637 8000,
576638 );
577639
......@@ -590,6 +652,29 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
590652 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,
591653 );
592654 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 );
593678 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");
594679 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false);
595680 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
apps/server/src/index.ts+1
......@@ -118,6 +118,7 @@ async function main() {
118118 port,
119119 host: mode === "desktop" ? "127.0.0.1" : undefined,
120120 cwd,
121 autoBootstrapProjectFromCwd: mode === "web",
121122 stateDir,
122123 staticDir,
123124 devUrl,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1-77
......@@ -24,11 +24,9 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
2424import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2525import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2626import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
27import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts";
2827import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2928import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
3029import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
31import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
3230import {
3331 OrchestrationEngineService,
3432 type OrchestrationEngineShape,
......@@ -145,26 +143,6 @@ async function waitForEvent(
145143 return poll();
146144}
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
168146function runGit(cwd: string, args: ReadonlyArray<string>) {
169147 return execFileSync("git", args, {
170148 cwd,
......@@ -214,7 +192,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {
214192
215193describe("CheckpointReactor", () => {
216194 let runtime: ManagedRuntime.ManagedRuntime<
217 OrchestrationEngineService | CheckpointReactor | CheckpointDiffBlobRepository | CheckpointStore,
195 OrchestrationEngineService | CheckpointReactor | CheckpointStore,
218196 unknown
219197 > | null = null;
220198 let scope: Scope.Closeable | null = null;
......@@ -250,23 +228,16 @@ describe("CheckpointReactor", () => {
250228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
251229 Layer.provide(SqlitePersistenceMemory),
252230 );
253 const diffBlobLayer = CheckpointDiffBlobRepositoryLive.pipe(
254 Layer.provide(SqlitePersistenceMemory),
255 );
256231 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
257232 const layer = CheckpointReactorLive.pipe(
258233 Layer.provideMerge(orchestrationLayer),
259234 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
260 Layer.provideMerge(diffBlobLayer),
261235 Layer.provideMerge(checkpointStoreLayer),
262236 );
263237
264238 runtime = ManagedRuntime.make(layer);
265239 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
266240 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
267 const diffBlobRepository = await runtime.runPromise(
268 Effect.service(CheckpointDiffBlobRepository),
269 );
270241 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
271242 scope = await Effect.runPromise(Scope.make("sequential"));
272243 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
......@@ -325,7 +296,6 @@ describe("CheckpointReactor", () => {
325296 engine,
326297 provider,
327298 cwd,
328 diffBlobRepository,
329299 };
330300 }
331301
......@@ -561,50 +531,4 @@ describe("CheckpointReactor", () => {
561531 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
562532 });
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 });
610534});
apps/server/src/orchestration/Layers/CheckpointReactor.ts-73
......@@ -13,7 +13,6 @@ import { Effect, Layer, Option, Queue, Stream } from "effect";
1313
1414import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
1515import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
16import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
1716import { ProviderService } from "../../provider/Services/ProviderService.ts";
1817import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
1918import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
......@@ -67,7 +66,6 @@ const make = Effect.gen(function* () {
6766 const orchestrationEngine = yield* OrchestrationEngineService;
6867 const providerService = yield* ProviderService;
6968 const checkpointStore = yield* CheckpointStore;
70 const checkpointDiffBlobRepository = yield* CheckpointDiffBlobRepository;
7169
7270 const appendRevertFailureActivity = (input: {
7371 readonly threadId: ThreadId;
......@@ -160,62 +158,6 @@ const make = Effect.gen(function* () {
160158 return findSessionWithCwd(matchedSession);
161159 });
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
219161 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
220162 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
221163 ) {
......@@ -565,20 +507,6 @@ const make = Effect.gen(function* () {
565507 return;
566508 }
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
582510 if (event.type === "thread.checkpoint-revert-requested") {
583511 yield* handleRevertRequested(event).pipe(
584512 Effect.catch((error) =>
......@@ -714,7 +642,6 @@ const make = Effect.gen(function* () {
714642 if (
715643 event.type !== "thread.turn-start-requested" &&
716644 event.type !== "thread.message-sent" &&
717 event.type !== "thread.turn-diff-completed" &&
718645 event.type !== "thread.checkpoint-revert-requested"
719646 ) {
720647 return Effect.void;
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+131
......@@ -269,6 +269,137 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
269269 }
270270 }),
271271 );
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 );
272403});
273404
274405const engineLayer = it.layer(
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+189-33
......@@ -12,7 +12,11 @@ import { ProjectionPendingApprovalRepository } from "../../persistence/Services/
1212import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
1313import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
1414import { 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";
1620import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
1721import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts";
1822import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts";
......@@ -62,6 +66,91 @@ function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number {
6266 return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1;
6367}
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
65154const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
66155 const sql = yield* SqlClient.SqlClient;
67156 const eventStore = yield* OrchestrationEventStore;
......@@ -260,44 +349,111 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
260349
261350 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>
262351 Effect.gen(function* () {
263 if (event.type !== "thread.message-sent") {
264 return;
352 switch (event.type) {
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;
265408 }
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 });
284409 });
285410
286411 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>
287412 Effect.gen(function* () {
288 if (event.type !== "thread.activity-appended") {
289 return;
413 switch (event.type) {
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;
290456 }
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 });
301457 });
302458
303459 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>
apps/server/src/orchestration/projector.test.ts+209
......@@ -282,4 +282,213 @@ describe("orchestration projector", () => {
282282 expect(message?.streaming).toBe(false);
283283 expect(message?.updatedAt).toBe(completeAt);
284284 });
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 });
285494});
apps/server/src/orchestration/projector.ts+74
......@@ -47,6 +47,68 @@ function decodeForEvent<A>(
4747 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;
4848}
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
50112export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
51113 return {
52114 snapshotSequence: 0,
......@@ -347,6 +409,16 @@ export function projectEvent(
347409 const checkpoints = thread.checkpoints
348410 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)
349411 .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
351423 const latestTurnId =
352424 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;
......@@ -355,6 +427,8 @@ export function projectEvent(
355427 ...nextBase,
356428 threads: updateThread(nextBase.threads, payload.threadId, {
357429 checkpoints,
430 messages,
431 activities,
358432 latestTurnId,
359433 updatedAt: event.occurredAt,
360434 }),
apps/server/src/persistence/Errors.ts-2
......@@ -205,8 +205,6 @@ export type OrchestrationCommandReceiptRepositoryError =
205205 | PersistenceSqlError
206206 | PersistenceDecodeError;
207207
208export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError;
209
210208export type ProviderSessionRuntimeRepositoryError =
211209 | PersistenceSqlError
212210 | 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", () => {
273273 options: {
274274 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
275275 cwd?: string;
276 autoBootstrapProjectFromCwd?: boolean;
276277 devUrl?: string;
277278 authToken?: string;
278279 stateDir?: string;
......@@ -290,6 +291,9 @@ describe("WebSocket Server", () => {
290291 cwd: options.cwd ?? "/test/project",
291292 stateDir,
292293 persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,
294 ...(options.autoBootstrapProjectFromCwd !== undefined
295 ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
296 : {}),
293297 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
294298 ...(options.authToken ? { authToken: options.authToken } : {}),
295299 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
......@@ -333,6 +337,37 @@ describe("WebSocket Server", () => {
333337 });
334338 });
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
336371 it("logs outbound websocket push events in dev mode", async () => {
337372 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
338373 // Keep test output clean while verifying websocket logs.
......@@ -670,6 +705,86 @@ describe("WebSocket Server", () => {
670705 expect(response.error!.message).toContain("Invalid request format");
671706 });
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
673788 it("keeps orchestration domain push behavior for provider runtime events", async () => {
674789 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
675790 const sessionId = "sess-test";
......@@ -1011,21 +1126,23 @@ describe("WebSocket Server", () => {
10111126 expect(pullResponse.error?.message).not.toContain("Unknown method");
10121127 });
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
10151144 const gitManager = {
1016 status: vi.fn().mockResolvedValue({
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 }),
1145 status: vi.fn().mockResolvedValue(statusResult),
10291146 runStackedAction: vi.fn(),
10301147 };
10311148
......@@ -1041,9 +1158,9 @@ describe("WebSocket Server", () => {
10411158 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
10421159 cwd: "/test",
10431160 });
1044 expect(response.result).toBeUndefined();
1045 expect(response.error?.message).toContain("Unknown method");
1046 expect(gitManager.status).not.toHaveBeenCalled();
1161 expect(response.error).toBeUndefined();
1162 expect(response.result).toEqual(statusResult);
1163 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
10471164 });
10481165
10491166 it("returns errors from git.runStackedAction", async () => {
apps/server/src/wsServer.ts+172-51
......@@ -6,6 +6,7 @@ import path from "node:path";
66import type { Duplex } from "node:stream";
77
88import {
9 CheckpointRef,
910 EDITORS,
1011 ORCHESTRATION_WS_CHANNELS,
1112 ORCHESTRATION_WS_METHODS,
......@@ -17,7 +18,7 @@ import {
1718 WsResponse,
1819} from "@t3tools/contracts";
1920import { 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";
2122import { WebSocketServer, type WebSocket } from "ws";
2223
2324import { createLogger } from "./logger";
......@@ -52,7 +53,6 @@ import {
5253import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5354import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
5455import assert from "node:assert";
55import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs";
5656import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
5757import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
5858import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
......@@ -62,10 +62,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
6262import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
6363import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
6464import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65import {
66 CheckpointDiffBlobRepository,
67 type CheckpointDiffBlobRepositoryShape,
68} from "./persistence/Services/CheckpointDiffBlobs";
65import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore";
6966import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
7067import * as SqlClient from "effect/unstable/sql/SqlClient";
7168import * as SqlError from "effect/unstable/sql/SqlError";
......@@ -91,6 +88,7 @@ export interface ServerOptions {
9188 port: number;
9289 host?: string | undefined;
9390 cwd: string;
91 autoBootstrapProjectFromCwd?: boolean | undefined;
9492 stateDir?: string | undefined;
9593 persistenceLayer?:
9694 | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>
......@@ -171,11 +169,18 @@ function stripRequestTag<T extends { _tag: string }>(
171169 return rest;
172170}
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
174179type EffectRuntime = ManagedRuntime.ManagedRuntime<
175180 | OrchestrationEngineService
176181 | ProjectionSnapshotQuery
177182 | ProviderService
178 | CheckpointDiffBlobRepository
183 | CheckpointStore
179184 | OrchestrationReactor,
180185 unknown
181186>;
......@@ -185,6 +190,7 @@ export function createServer(options: ServerOptions) {
185190 port,
186191 host,
187192 cwd,
193 autoBootstrapProjectFromCwd = false,
188194 stateDir,
189195 persistenceLayer: customPersistenceLayer,
190196 staticDir,
......@@ -201,7 +207,7 @@ export function createServer(options: ServerOptions) {
201207 let orchestrationEngine: OrchestrationEngineShape | null = null;
202208 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
203209 let providerService: ProviderServiceShape | null = null;
204 let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null;
210 let checkpointStore: CheckpointStoreShape | null = null;
205211 const clients = new Set<WebSocket>();
206212 const logger = createLogger("ws");
207213 const logWebSocketEvents =
......@@ -235,11 +241,40 @@ export function createServer(options: ServerOptions) {
235241 return projectionSnapshotQuery;
236242 };
237243
238 const getCheckpointDiffBlobRepository = () => {
239 assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started");
240 return checkpointDiffBlobRepository;
244 const getCheckpointStore = () => {
245 assert(checkpointStore, "Checkpoint store is not started");
246 return checkpointStore;
241247 };
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
243278 const attachSubscriptionss = Effect.gen(function* () {
244279 const orchestrationEngine = yield* OrchestrationEngineService;
245280 const orchestrationReactor = yield* OrchestrationReactor;
......@@ -439,7 +474,114 @@ export function createServer(options: ServerOptions) {
439474 const orchestrationEngine = getOrchestrationEngine();
440475 const projectionReadModelQuery = getProjectionSnapshotQuery();
441476 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
444586 switch (request.body._tag) {
445587 case ORCHESTRATION_WS_METHODS.getSnapshot: {
......@@ -456,19 +598,16 @@ export function createServer(options: ServerOptions) {
456598
457599 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
458600 const body = stripRequestTag(request.body);
459 const blob = await effectRuntime.runPromise(diffBlobRepository.get(body));
460 if (Option.isNone(blob)) {
461 throw new Error(
462 `Turn diff not found for thread '${body.threadId}' from ${body.fromTurnCount} to ${body.toTurnCount}.`,
463 );
464 }
601 return computeTurnDiff(body);
602 }
465603
466 return {
604 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
605 const body = stripRequestTag(request.body);
606 return computeTurnDiff({
467607 threadId: body.threadId,
468 fromTurnCount: body.fromTurnCount,
608 fromTurnCount: 0,
469609 toTurnCount: body.toTurnCount,
470 diff: blob.value?.diff,
471 };
610 });
472611 }
473612
474613 case ORCHESTRATION_WS_METHODS.replayEvents:
......@@ -526,28 +665,8 @@ export function createServer(options: ServerOptions) {
526665 return undefined;
527666 }
528667
529 // TODO: Bring back
530 // case WS_METHODS.gitStatus: {
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 // }
668 case WS_METHODS.gitStatus:
669 return gitManager.status(stripRequestTag(request.body));
551670
552671 case WS_METHODS.gitPull:
553672 return pullGitBranch(stripRequestTag(request.body));
......@@ -606,8 +725,10 @@ export function createServer(options: ServerOptions) {
606725 keybindings: keybindingsConfig,
607726 };
608727
609 default:
610 throw new Error(`Unknown method: ${request.body._tag}`);
728 default: {
729 const _exhaustiveCheck: never = request.body;
730 throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`);
731 }
611732 }
612733 }
613734
......@@ -651,7 +772,6 @@ export function createServer(options: ServerOptions) {
651772 const runtimeServicesLayer = Layer.mergeAll(
652773 orchestrationLayer,
653774 OrchestrationProjectionSnapshotQueryLive,
654 CheckpointDiffBlobRepositoryLive,
655775 checkpointStoreLayer,
656776 providerLayer,
657777 );
......@@ -679,17 +799,17 @@ export function createServer(options: ServerOptions) {
679799 const [
680800 nextOrchestrationEngine,
681801 nextProjectionSnapshotQuery,
682 nextCheckpointDiffBlobRepository,
802 nextCheckpointStore,
683803 nextProviderService,
684804 ] = await Promise.all([
685805 runtime.runPromise(Effect.service(OrchestrationEngineService)),
686806 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
687 runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
807 runtime.runPromise(Effect.service(CheckpointStore)),
688808 runtime.runPromise(Effect.service(ProviderService)),
689809 ]);
690810 orchestrationEngine = nextOrchestrationEngine;
691811 projectionSnapshotQuery = nextProjectionSnapshotQuery;
692 checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository;
812 checkpointStore = nextCheckpointStore;
693813 providerService = nextProviderService;
694814 await runtime.runPromise(attachSubscriptionss);
695815 } catch (error) {
......@@ -715,7 +835,7 @@ export function createServer(options: ServerOptions) {
715835 effectRuntime = null;
716836 orchestrationEngine = null;
717837 projectionSnapshotQuery = null;
718 checkpointDiffBlobRepository = null;
838 checkpointStore = null;
719839 providerService = null;
720840
721841 if (!runtime) {
......@@ -731,6 +851,7 @@ export function createServer(options: ServerOptions) {
731851
732852 async function start() {
733853 effectRuntime = await createEffectRuntime();
854 await bootstrapProjectForCwd();
734855
735856 return new Promise<void>((resolve, reject) => {
736857 const onError = (error: Error) => {
apps/web/src/components/Sidebar.tsx+36-23
......@@ -132,28 +132,39 @@ export default function Sidebar() {
132132 branch?: string | null;
133133 worktreePath?: string | null;
134134 },
135 ) => {
136 if (!api) return;
135 ): Promise<void> => {
136 if (!api) return Promise.resolve();
137137 const threadId = newThreadId();
138138 const createdAt = new Date().toISOString();
139139 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
140 void api.orchestration.dispatchCommand({
141 type: "thread.create",
142 commandId: newCommandId(),
143 threadId,
144 projectId: asProjectId(projectId),
145 title: "New thread",
146 model,
147 branch: options?.branch ?? null,
148 worktreePath: options?.worktreePath ?? null,
149 createdAt,
150 });
151 void navigate({
152 to: "/$threadId",
153 params: { threadId },
154 });
140 return (async () => {
141 await api.orchestration.dispatchCommand({
142 type: "thread.create",
143 commandId: newCommandId(),
144 threadId,
145 projectId: asProjectId(projectId),
146 title: "New thread",
147 model,
148 branch: options?.branch ?? null,
149 worktreePath: options?.worktreePath ?? null,
150 createdAt,
151 });
152
153 // Ensure route guards can see the new thread before navigating.
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 })();
155166 },
156 [api, navigate, state.projects],
167 [api, dispatch, navigate, state.projects],
157168 );
158169
159170 const focusMostRecentThreadForProject = useCallback(
......@@ -201,7 +212,7 @@ export default function Sidebar() {
201212 defaultModel: DEFAULT_MODEL,
202213 createdAt,
203214 });
204 handleNewThread(projectId);
215 await handleNewThread(projectId);
205216 } finally {
206217 setIsAddingProject(false);
207218 setNewCwd("");
......@@ -399,7 +410,7 @@ export default function Sidebar() {
399410 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
400411 if (!projectId) return;
401412 event.preventDefault();
402 handleNewThread(projectId);
413 void handleNewThread(projectId);
403414 return;
404415 }
405416
......@@ -407,7 +418,7 @@ export default function Sidebar() {
407418 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
408419 if (!projectId) return;
409420 event.preventDefault();
410 handleNewThread(projectId, {
421 void handleNewThread(projectId, {
411422 branch: activeThread?.branch ?? null,
412423 worktreePath: activeThread?.worktreePath ?? null,
413424 });
......@@ -446,7 +457,7 @@ export default function Sidebar() {
446457 return;
447458 }
448459 const firstProject = state.projects[0];
449 if (firstProject) handleNewThread(firstProject.id);
460 if (firstProject) void handleNewThread(firstProject.id);
450461 }}
451462 >
452463 <span className="text-foreground">+</span>
......@@ -561,7 +572,9 @@ export default function Sidebar() {
561572 <button
562573 type="button"
563574 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 }}
565578 >
566579 <span>+</span> New thread
567580 </button>
apps/web/src/lib/providerReactQuery.test.ts+30
......@@ -28,9 +28,11 @@ describe("providerQueryKeys.checkpointDiff", () => {
2828describe("checkpointDiffQueryOptions", () => {
2929 it("forwards checkpoint range to the provider API", async () => {
3030 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
31 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
3132 const api = {
3233 orchestration: {
3334 getTurnDiff,
35 getFullThreadDiff,
3436 },
3537 } as unknown as NativeApi;
3638
......@@ -49,6 +51,34 @@ describe("checkpointDiffQueryOptions", () => {
4951 fromTurnCount: 3,
5052 toTurnCount: 4,
5153 });
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();
5282 });
5383
5484 it("retries checkpoint-not-ready errors longer than generic failures", () => {
apps/web/src/lib/providerReactQuery.ts+6
......@@ -64,6 +64,12 @@ export function checkpointDiffQueryOptions(
6464 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
6565 throw new Error("Checkpoint diff range is invalid.");
6666 }
67 if (fromTurnCount === 0) {
68 return api.orchestration.getFullThreadDiff({
69 threadId: asThreadId(input.threadId),
70 toTurnCount,
71 });
72 }
6773 return api.orchestration.getTurnDiff({
6874 threadId: asThreadId(input.threadId),
6975 fromTurnCount,
apps/web/src/routes/__root.tsx+8-63
......@@ -3,18 +3,16 @@ import {
33 createRootRouteWithContext,
44 type ErrorComponentProps,
55} from "@tanstack/react-router";
6import { useEffect, useRef } from "react";
7import { QueryClient } from "@tanstack/react-query";
6import { useEffect } from "react";
7import { QueryClient, useQueryClient } from "@tanstack/react-query";
88
99import { APP_DISPLAY_NAME } from "../branding";
1010import { Button } from "../components/ui/button";
1111import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
12import { isElectron } from "../env";
1312import { useNativeApi } from "../hooks/useNativeApi";
14import { DEFAULT_MODEL } from "../model-logic";
1513import { useStore } from "../store";
16import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
1714import { onServerWelcome } from "../wsNativeApi";
15import { providerQueryKeys } from "../lib/providerReactQuery";
1816
1917export const Route = createRootRouteWithContext<{
2018 queryClient: QueryClient;
......@@ -42,7 +40,6 @@ function RootRouteView() {
4240 <ToastProvider>
4341 <AnchoredToastProvider>
4442 <EventRouter />
45 <AutoProjectBootstrap />
4643 <DesktopProjectBootstrap />
4744 <Outlet />
4845 </AnchoredToastProvider>
......@@ -124,6 +121,7 @@ function errorDetails(error: unknown): string {
124121function EventRouter() {
125122 const api = useNativeApi();
126123 const { dispatch } = useStore();
124 const queryClient = useQueryClient();
127125
128126 useEffect(() => {
129127 if (!api) return;
......@@ -166,6 +164,9 @@ function EventRouter() {
166164 return;
167165 }
168166 latestSequence = event.sequence;
167 if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") {
168 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
169 }
169170 void syncSnapshot();
170171 });
171172 const unsubWelcome = onServerWelcome(() => {
......@@ -176,63 +177,7 @@ function EventRouter() {
176177 unsubDomainEvent();
177178 unsubWelcome();
178179 };
179 }, [api, dispatch]);
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]);
180 }, [api, dispatch, queryClient]);
236181
237182 return null;
238183}
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 {
128128 orchestration: {
129129 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
130130 dispatchCommand: (command) =>
131 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),
131 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
132132 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
133 getFullThreadDiff: (input) =>
134 transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
133135 replayEvents: (fromSequenceExclusive) =>
134136 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
135137 onDomainEvent: (callback) =>
packages/contracts/src/ipc.ts+5
......@@ -28,6 +28,8 @@ import type {
2828import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
2929import type {
3030 ClientOrchestrationCommand,
31 OrchestrationGetFullThreadDiffInput,
32 OrchestrationGetFullThreadDiffResult,
3133 OrchestrationGetTurnDiffInput,
3234 OrchestrationGetTurnDiffResult,
3335 OrchestrationEvent,
......@@ -95,6 +97,9 @@ export interface NativeApi {
9597 getSnapshot: () => Promise<OrchestrationReadModel>;
9698 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
9799 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
100 getFullThreadDiff: (
101 input: OrchestrationGetFullThreadDiffInput,
102 ) => Promise<OrchestrationGetFullThreadDiffResult>;
98103 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
99104 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
100105 };
packages/contracts/src/orchestration.ts+14
......@@ -4,6 +4,7 @@ export const ORCHESTRATION_WS_METHODS = {
44 getSnapshot: "orchestration.getSnapshot",
55 dispatchCommand: "orchestration.dispatchCommand",
66 getTurnDiff: "orchestration.getTurnDiff",
7 getFullThreadDiff: "orchestration.getFullThreadDiff",
78 replayEvents: "orchestration.replayEvents",
89} as const;
910
......@@ -837,6 +838,15 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput
837838export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
838839export 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
840850export const OrchestrationReplayEventsInput = Schema.Struct({
841851 fromSequenceExclusive: NonNegativeInt,
842852});
......@@ -858,6 +868,10 @@ export const OrchestrationRpcSchemas = {
858868 input: OrchestrationGetTurnDiffInput,
859869 output: OrchestrationGetTurnDiffResult,
860870 },
871 getFullThreadDiff: {
872 input: OrchestrationGetFullThreadDiffInput,
873 output: OrchestrationGetFullThreadDiffResult,
874 },
861875 replayEvents: {
862876 input: OrchestrationReplayEventsInput,
863877 output: OrchestrationReplayEventsResult,
packages/contracts/src/ws.ts+2
......@@ -2,6 +2,7 @@ import { Schema } from "effect";
22
33import {
44 ClientOrchestrationCommand,
5 OrchestrationGetFullThreadDiffInput,
56 ORCHESTRATION_WS_METHODS,
67 OrchestrationGetSnapshotInput,
78 OrchestrationGetTurnDiffInput,
......@@ -80,6 +81,7 @@ export const WebSocketRequestBody = Schema.TaggedUnion({
8081 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
8182 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
8283 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,
84 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields,
8385 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,
8486
8587 // Project Search