authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 08:50:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 08:50:50-07:00
logf7fa62aaf90d0bd191acf99b65889f0d20d6ce97
treeac01190a67cc28ac9665b156febcffbef167d453
parent57d7746a5d844281caae451702475d7bcf967b9d
signature Signed by PGP key B5690EEEBB952194

Add shell snapshot queries for orchestration state (#1973)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> Co-authored-by: codex <codex@users.noreply.github.com>

40 files changed, 2901 insertions(+), 891 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+10
...@@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => {...@@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => {
82 Layer.succeed(ProjectionSnapshotQuery, {82 Layer.succeed(ProjectionSnapshotQuery, {
83 getSnapshot: () =>83 getSnapshot: () =>
84 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),84 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
85 getShellSnapshot: () =>
86 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
85 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),87 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
86 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),88 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
89 getProjectShellById: () => Effect.succeed(Option.none()),
87 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),90 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
88 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),91 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
92 getThreadShellById: () => Effect.succeed(Option.none()),
93 getThreadDetailById: () => Effect.succeed(Option.none()),
89 }),94 }),
90 ),95 ),
91 );96 );
...@@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => {...@@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => {
136 Layer.succeed(ProjectionSnapshotQuery, {141 Layer.succeed(ProjectionSnapshotQuery, {
137 getSnapshot: () =>142 getSnapshot: () =>
138 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),143 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
144 getShellSnapshot: () =>
145 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
139 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),146 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
140 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),147 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
148 getProjectShellById: () => Effect.succeed(Option.none()),
141 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),149 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
142 getThreadCheckpointContext: () => Effect.succeed(Option.none()),150 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
151 getThreadShellById: () => Effect.succeed(Option.none()),
152 getThreadDetailById: () => Effect.succeed(Option.none()),
143 }),153 }),
144 ),154 ),
145 );155 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+10
...@@ -144,10 +144,20 @@ describe("OrchestrationEngine", () => {...@@ -144,10 +144,20 @@ describe("OrchestrationEngine", () => {
144 Layer.provide(144 Layer.provide(
145 Layer.succeed(ProjectionSnapshotQuery, {145 Layer.succeed(ProjectionSnapshotQuery, {
146 getSnapshot: () => Effect.succeed(projectionSnapshot),146 getSnapshot: () => Effect.succeed(projectionSnapshot),
147 getShellSnapshot: () =>
148 Effect.succeed({
149 snapshotSequence: projectionSnapshot.snapshotSequence,
150 projects: [],
151 threads: [],
152 updatedAt: projectionSnapshot.updatedAt,
153 }),
147 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),154 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
148 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),155 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
156 getProjectShellById: () => Effect.succeed(Option.none()),
149 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
150 getThreadCheckpointContext: () => Effect.succeed(Option.none()),158 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
159 getThreadShellById: () => Effect.succeed(Option.none()),
160 getThreadDetailById: () => Effect.succeed(Option.none()),
151 }),161 }),
152 ),162 ),
153 Layer.provide(163 Layer.provide(
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+125-1
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 ApprovalRequestId,2 ApprovalRequestId,
3 type ChatAttachment,3 type ChatAttachment,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5 ThreadId,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";7import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
7import * as SqlClient from "effect/unstable/sql/SqlClient";8import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -89,6 +90,77 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {...@@ -89,6 +90,77 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
89 return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;90 return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
90}91}
9192
93function derivePendingUserInputCountFromActivities(
94 activities: ReadonlyArray<ProjectionThreadActivity>,
95): number {
96 const openRequestIds = new Set<string>();
97 const ordered = [...activities].toSorted(
98 (left, right) =>
99 left.createdAt.localeCompare(right.createdAt) ||
100 left.activityId.localeCompare(right.activityId),
101 );
102
103 for (const activity of ordered) {
104 const requestId = extractActivityRequestId(activity.payload);
105 if (requestId === null) {
106 continue;
107 }
108 const payload =
109 typeof activity.payload === "object" && activity.payload !== null
110 ? (activity.payload as Record<string, unknown>)
111 : null;
112 const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
113
114 if (activity.kind === "user-input.requested") {
115 openRequestIds.add(requestId);
116 continue;
117 }
118
119 if (activity.kind === "user-input.resolved") {
120 openRequestIds.delete(requestId);
121 continue;
122 }
123
124 if (
125 activity.kind === "provider.user-input.respond.failed" &&
126 detail !== null &&
127 (detail.includes("stale pending user-input request") ||
128 detail.includes("unknown pending user-input request"))
129 ) {
130 openRequestIds.delete(requestId);
131 }
132 }
133
134 return openRequestIds.size;
135}
136
137function deriveHasActionableProposedPlan(input: {
138 readonly latestTurnId: string | null;
139 readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>;
140}): boolean {
141 const sorted = [...input.proposedPlans].toSorted(
142 (left, right) =>
143 left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId),
144 );
145
146 let latestForTurn: ProjectionThreadProposedPlan | null = null;
147 if (input.latestTurnId !== null) {
148 for (let index = sorted.length - 1; index >= 0; index -= 1) {
149 const plan = sorted[index];
150 if (plan?.turnId === input.latestTurnId) {
151 latestForTurn = plan;
152 break;
153 }
154 }
155 }
156 if (latestForTurn !== null) {
157 return latestForTurn.implementedAt === null;
158 }
159
160 const latestPlan = sorted.at(-1) ?? null;
161 return latestPlan !== null && latestPlan.implementedAt === null;
162}
163
92function retainProjectionMessagesAfterRevert(164function retainProjectionMessagesAfterRevert(
93 messages: ReadonlyArray<ProjectionThreadMessage>,165 messages: ReadonlyArray<ProjectionThreadMessage>,
94 turns: ReadonlyArray<ProjectionTurn>,166 turns: ReadonlyArray<ProjectionTurn>,
...@@ -432,6 +504,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -432,6 +504,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
432 }504 }
433 });505 });
434506
507 const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* (
508 threadId: ThreadId,
509 ) {
510 const existingRow = yield* projectionThreadRepository.getById({
511 threadId,
512 });
513 if (Option.isNone(existingRow)) {
514 return;
515 }
516
517 const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([
518 projectionThreadMessageRepository.listByThreadId({ threadId }),
519 projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
520 projectionThreadActivityRepository.listByThreadId({ threadId }),
521 projectionPendingApprovalRepository.listByThreadId({ threadId }),
522 ]);
523
524 const latestUserMessageAt =
525 messages
526 .filter((message) => message.role === "user")
527 .map((message) => message.createdAt)
528 .toSorted()
529 .at(-1) ?? null;
530
531 const pendingApprovalCount = pendingApprovals.filter(
532 (approval) => approval.status === "pending",
533 ).length;
534 const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities);
535 const hasActionableProposedPlan = deriveHasActionableProposedPlan({
536 latestTurnId: existingRow.value.latestTurnId,
537 proposedPlans,
538 });
539
540 yield* projectionThreadRepository.upsert({
541 ...existingRow.value,
542 latestUserMessageAt,
543 pendingApprovalCount,
544 pendingUserInputCount,
545 hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
546 });
547 });
548
435 const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(549 const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(
436 "applyThreadsProjection",550 "applyThreadsProjection",
437 )(function* (event, attachmentSideEffects) {551 )(function* (event, attachmentSideEffects) {
...@@ -450,6 +564,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -450,6 +564,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
450 createdAt: event.payload.createdAt,564 createdAt: event.payload.createdAt,
451 updatedAt: event.payload.updatedAt,565 updatedAt: event.payload.updatedAt,
452 archivedAt: null,566 archivedAt: null,
567 latestUserMessageAt: null,
568 pendingApprovalCount: 0,
569 pendingUserInputCount: 0,
570 hasActionableProposedPlan: 0,
453 deletedAt: null,571 deletedAt: null,
454 });572 });
455 return;573 return;
...@@ -554,7 +672,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -554,7 +672,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
554672
555 case "thread.message-sent":673 case "thread.message-sent":
556 case "thread.proposed-plan-upserted":674 case "thread.proposed-plan-upserted":
557 case "thread.activity-appended": {675 case "thread.activity-appended":
676 case "thread.approval-response-requested":
677 case "thread.user-input-response-requested": {
558 const existingRow = yield* projectionThreadRepository.getById({678 const existingRow = yield* projectionThreadRepository.getById({
559 threadId: event.payload.threadId,679 threadId: event.payload.threadId,
560 });680 });
...@@ -565,6 +685,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -565,6 +685,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
565 ...existingRow.value,685 ...existingRow.value,
566 updatedAt: event.occurredAt,686 updatedAt: event.occurredAt,
567 });687 });
688 yield* refreshThreadShellSummary(event.payload.threadId);
568 return;689 return;
569 }690 }
570691
...@@ -580,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -580,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
580 latestTurnId: event.payload.session.activeTurnId,701 latestTurnId: event.payload.session.activeTurnId,
581 updatedAt: event.occurredAt,702 updatedAt: event.occurredAt,
582 });703 });
704 yield* refreshThreadShellSummary(event.payload.threadId);
583 return;705 return;
584 }706 }
585707
...@@ -595,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -595,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
595 latestTurnId: event.payload.turnId,717 latestTurnId: event.payload.turnId,
596 updatedAt: event.occurredAt,718 updatedAt: event.occurredAt,
597 });719 });
720 yield* refreshThreadShellSummary(event.payload.threadId);
598 return;721 return;
599 }722 }
600723
...@@ -610,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -610,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
610 latestTurnId: null,733 latestTurnId: null,
611 updatedAt: event.occurredAt,734 updatedAt: event.occurredAt,
612 });735 });
736 yield* refreshThreadShellSummary(event.payload.threadId);
613 return;737 return;
614 }738 }
615739
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+392
...@@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
62 project_id,62 project_id,
63 title,63 title,
64 model_selection_json,64 model_selection_json,
65 runtime_mode,
66 interaction_mode,
65 branch,67 branch,
66 worktree_path,68 worktree_path,
67 latest_turn_id,69 latest_turn_id,
70 latest_user_message_at,
71 pending_approval_count,
72 pending_user_input_count,
73 has_actionable_proposed_plan,
68 created_at,74 created_at,
69 updated_at,75 updated_at,
70 deleted_at76 deleted_at
...@@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
74 'project-1',80 'project-1',
75 'Thread 1',81 'Thread 1',
76 '{"provider":"codex","model":"gpt-5-codex"}',82 '{"provider":"codex","model":"gpt-5-codex"}',
83 'full-access',
84 'default',
77 NULL,85 NULL,
78 NULL,86 NULL,
79 'turn-1',87 'turn-1',
88 '2026-02-24T00:00:04.000Z',
89 1,
90 0,
91 0,
80 '2026-02-24T00:00:02.000Z',92 '2026-02-24T00:00:02.000Z',
81 '2026-02-24T00:00:03.000Z',93 '2026-02-24T00:00:03.000Z',
82 NULL94 NULL
...@@ -341,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -341,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
341 },353 },
342 },354 },
343 ]);355 ]);
356
357 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
358 assert.equal(shellSnapshot.snapshotSequence, 5);
359 assert.deepEqual(shellSnapshot.projects, [
360 {
361 id: asProjectId("project-1"),
362 title: "Project 1",
363 workspaceRoot: "/tmp/project-1",
364 repositoryIdentity: null,
365 defaultModelSelection: {
366 provider: "codex",
367 model: "gpt-5-codex",
368 },
369 scripts: [
370 {
371 id: "script-1",
372 name: "Build",
373 command: "bun run build",
374 icon: "build",
375 runOnWorktreeCreate: false,
376 },
377 ],
378 createdAt: "2026-02-24T00:00:00.000Z",
379 updatedAt: "2026-02-24T00:00:01.000Z",
380 },
381 ]);
382 assert.deepEqual(shellSnapshot.threads, [
383 {
384 id: ThreadId.make("thread-1"),
385 projectId: asProjectId("project-1"),
386 title: "Thread 1",
387 modelSelection: {
388 provider: "codex",
389 model: "gpt-5-codex",
390 },
391 interactionMode: "default",
392 runtimeMode: "full-access",
393 branch: null,
394 worktreePath: null,
395 latestTurn: {
396 turnId: asTurnId("turn-1"),
397 state: "completed",
398 requestedAt: "2026-02-24T00:00:08.000Z",
399 startedAt: "2026-02-24T00:00:08.000Z",
400 completedAt: "2026-02-24T00:00:08.000Z",
401 assistantMessageId: asMessageId("message-1"),
402 sourceProposedPlan: {
403 threadId: ThreadId.make("thread-1"),
404 planId: "plan-1",
405 },
406 },
407 createdAt: "2026-02-24T00:00:02.000Z",
408 updatedAt: "2026-02-24T00:00:03.000Z",
409 archivedAt: null,
410 session: {
411 threadId: ThreadId.make("thread-1"),
412 status: "running",
413 providerName: "codex",
414 runtimeMode: "approval-required",
415 activeTurnId: asTurnId("turn-1"),
416 lastError: null,
417 updatedAt: "2026-02-24T00:00:07.000Z",
418 },
419 latestUserMessageAt: "2026-02-24T00:00:04.000Z",
420 hasPendingApprovals: true,
421 hasPendingUserInput: false,
422 hasActionableProposedPlan: false,
423 },
424 ]);
425
426 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
427 assert.equal(threadDetail._tag, "Some");
428 if (threadDetail._tag === "Some") {
429 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
430 }
344 }),431 }),
345 );432 );
346433
...@@ -629,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -629,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
629 }716 }
630 }),717 }),
631 );718 );
719
720 it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () =>
721 Effect.gen(function* () {
722 const snapshotQuery = yield* ProjectionSnapshotQuery;
723 const sql = yield* SqlClient.SqlClient;
724
725 yield* sql`DELETE FROM projection_projects`;
726 yield* sql`DELETE FROM projection_threads`;
727 yield* sql`DELETE FROM projection_thread_activities`;
728 yield* sql`DELETE FROM projection_state`;
729
730 yield* sql`
731 INSERT INTO projection_projects (
732 project_id,
733 title,
734 workspace_root,
735 default_model_selection_json,
736 scripts_json,
737 created_at,
738 updated_at,
739 deleted_at
740 )
741 VALUES (
742 'project-1',
743 'Project 1',
744 '/tmp/project-1',
745 '{"provider":"codex","model":"gpt-5-codex"}',
746 '[]',
747 '2026-04-01T00:00:00.000Z',
748 '2026-04-01T00:00:01.000Z',
749 NULL
750 )
751 `;
752
753 yield* sql`
754 INSERT INTO projection_threads (
755 thread_id,
756 project_id,
757 title,
758 model_selection_json,
759 runtime_mode,
760 interaction_mode,
761 branch,
762 worktree_path,
763 latest_turn_id,
764 latest_user_message_at,
765 pending_approval_count,
766 pending_user_input_count,
767 has_actionable_proposed_plan,
768 created_at,
769 updated_at,
770 deleted_at
771 )
772 VALUES (
773 'thread-1',
774 'project-1',
775 'Thread 1',
776 '{"provider":"codex","model":"gpt-5-codex"}',
777 'full-access',
778 'default',
779 NULL,
780 NULL,
781 NULL,
782 NULL,
783 0,
784 0,
785 0,
786 '2026-04-01T00:00:02.000Z',
787 '2026-04-01T00:00:03.000Z',
788 NULL
789 )
790 `;
791
792 yield* sql`
793 INSERT INTO projection_thread_activities (
794 activity_id,
795 thread_id,
796 turn_id,
797 tone,
798 kind,
799 summary,
800 payload_json,
801 sequence,
802 created_at
803 )
804 VALUES
805 (
806 'activity-unsequenced',
807 'thread-1',
808 NULL,
809 'info',
810 'runtime.note',
811 'unsequenced first',
812 '{"source":"unsequenced"}',
813 NULL,
814 '2026-04-01T00:00:06.000Z'
815 ),
816 (
817 'activity-sequence-2',
818 'thread-1',
819 NULL,
820 'info',
821 'runtime.note',
822 'sequence two',
823 '{"source":"sequence-2"}',
824 2,
825 '2026-04-01T00:00:04.000Z'
826 ),
827 (
828 'activity-sequence-1',
829 'thread-1',
830 NULL,
831 'info',
832 'runtime.note',
833 'sequence one',
834 '{"source":"sequence-1"}',
835 1,
836 '2026-04-01T00:00:05.000Z'
837 )
838 `;
839
840 const snapshot = yield* snapshotQuery.getSnapshot();
841 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
842
843 assert.equal(threadDetail._tag, "Some");
844 if (threadDetail._tag === "Some") {
845 assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []);
846 }
847
848 assert.deepEqual(snapshot.threads[0]?.activities ?? [], [
849 {
850 id: asEventId("activity-unsequenced"),
851 tone: "info",
852 kind: "runtime.note",
853 summary: "unsequenced first",
854 payload: { source: "unsequenced" },
855 turnId: null,
856 createdAt: "2026-04-01T00:00:06.000Z",
857 },
858 {
859 id: asEventId("activity-sequence-1"),
860 tone: "info",
861 kind: "runtime.note",
862 summary: "sequence one",
863 payload: { source: "sequence-1" },
864 turnId: null,
865 sequence: 1,
866 createdAt: "2026-04-01T00:00:05.000Z",
867 },
868 {
869 id: asEventId("activity-sequence-2"),
870 tone: "info",
871 kind: "runtime.note",
872 summary: "sequence two",
873 payload: { source: "sequence-2" },
874 turnId: null,
875 sequence: 2,
876 createdAt: "2026-04-01T00:00:04.000Z",
877 },
878 ]);
879 }),
880 );
881
882 it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () =>
883 Effect.gen(function* () {
884 const snapshotQuery = yield* ProjectionSnapshotQuery;
885 const sql = yield* SqlClient.SqlClient;
886
887 yield* sql`DELETE FROM projection_projects`;
888 yield* sql`DELETE FROM projection_threads`;
889 yield* sql`DELETE FROM projection_turns`;
890
891 yield* sql`
892 INSERT INTO projection_projects (
893 project_id,
894 title,
895 workspace_root,
896 default_model_selection_json,
897 scripts_json,
898 created_at,
899 updated_at,
900 deleted_at
901 )
902 VALUES (
903 'project-1',
904 'Project 1',
905 '/tmp/project-1',
906 '{"provider":"codex","model":"gpt-5-codex"}',
907 '[]',
908 '2026-04-02T00:00:00.000Z',
909 '2026-04-02T00:00:01.000Z',
910 NULL
911 )
912 `;
913
914 yield* sql`
915 INSERT INTO projection_threads (
916 thread_id,
917 project_id,
918 title,
919 model_selection_json,
920 runtime_mode,
921 interaction_mode,
922 branch,
923 worktree_path,
924 latest_turn_id,
925 latest_user_message_at,
926 pending_approval_count,
927 pending_user_input_count,
928 has_actionable_proposed_plan,
929 created_at,
930 updated_at,
931 archived_at,
932 deleted_at
933 )
934 VALUES (
935 'thread-1',
936 'project-1',
937 'Thread 1',
938 '{"provider":"codex","model":"gpt-5-codex"}',
939 'full-access',
940 'default',
941 NULL,
942 NULL,
943 'turn-running',
944 '2026-04-02T00:00:04.000Z',
945 0,
946 0,
947 0,
948 '2026-04-02T00:00:02.000Z',
949 '2026-04-02T00:00:03.000Z',
950 NULL,
951 NULL
952 )
953 `;
954
955 yield* sql`
956 INSERT INTO projection_turns (
957 thread_id,
958 turn_id,
959 pending_message_id,
960 source_proposed_plan_thread_id,
961 source_proposed_plan_id,
962 assistant_message_id,
963 state,
964 requested_at,
965 started_at,
966 completed_at,
967 checkpoint_turn_count,
968 checkpoint_ref,
969 checkpoint_status,
970 checkpoint_files_json
971 )
972 VALUES
973 (
974 'thread-1',
975 'turn-completed',
976 'message-user-1',
977 NULL,
978 NULL,
979 'message-assistant-1',
980 'completed',
981 '2026-04-02T00:00:05.000Z',
982 '2026-04-02T00:00:06.000Z',
983 '2026-04-02T00:00:20.000Z',
984 5,
985 'checkpoint-5',
986 'ready',
987 '[]'
988 ),
989 (
990 'thread-1',
991 'turn-running',
992 'message-user-2',
993 NULL,
994 NULL,
995 NULL,
996 'running',
997 '2026-04-02T00:00:30.000Z',
998 '2026-04-02T00:00:30.000Z',
999 NULL,
1000 NULL,
1001 NULL,
1002 NULL,
1003 '[]'
1004 )
1005 `;
1006
1007 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
1008 assert.equal(threadShell._tag, "Some");
1009 if (threadShell._tag === "Some") {
1010 assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running"));
1011 assert.equal(threadShell.value.latestTurn?.state, "running");
1012 assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1013 }
1014
1015 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
1016 assert.equal(threadDetail._tag, "Some");
1017 if (threadDetail._tag === "Some") {
1018 assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running"));
1019 assert.equal(threadDetail.value.latestTurn?.state, "running");
1020 assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1021 }
1022 }),
1023 );
632});1024});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+594-1
...@@ -6,16 +6,19 @@ import {...@@ -6,16 +6,19 @@ import {
6 OrchestrationCheckpointFile,6 OrchestrationCheckpointFile,
7 OrchestrationProposedPlanId,7 OrchestrationProposedPlanId,
8 OrchestrationReadModel,8 OrchestrationReadModel,
9 OrchestrationShellSnapshot,
10 OrchestrationThread,
9 ProjectScript,11 ProjectScript,
10 TurnId,12 TurnId,
11 type OrchestrationCheckpointSummary,13 type OrchestrationCheckpointSummary,
12 type OrchestrationLatestTurn,14 type OrchestrationLatestTurn,
13 type OrchestrationMessage,15 type OrchestrationMessage,
16 type OrchestrationProjectShell,
14 type OrchestrationProposedPlan,17 type OrchestrationProposedPlan,
15 type OrchestrationProject,18 type OrchestrationProject,
16 type OrchestrationSession,19 type OrchestrationSession,
17 type OrchestrationThread,
18 type OrchestrationThreadActivity,20 type OrchestrationThreadActivity,
21 type OrchestrationThreadShell,
19 ModelSelection,22 ModelSelection,
20 ProjectId,23 ProjectId,
21 ThreadId,24 ThreadId,
...@@ -48,6 +51,8 @@ import {...@@ -48,6 +51,8 @@ import {
48} from "../Services/ProjectionSnapshotQuery.ts";51} from "../Services/ProjectionSnapshotQuery.ts";
4952
50const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);53const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
54const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
55const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);
51const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(56const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
52 Struct.assign({57 Struct.assign({
53 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),58 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
...@@ -155,6 +160,64 @@ function computeSnapshotSequence(...@@ -155,6 +160,64 @@ function computeSnapshotSequence(
155 return Number.isFinite(minSequence) ? minSequence : 0;160 return Number.isFinite(minSequence) ? minSequence : 0;
156}161}
157162
163function mapLatestTurn(
164 row: Schema.Schema.Type<typeof ProjectionLatestTurnDbRowSchema>,
165): OrchestrationLatestTurn {
166 return {
167 turnId: row.turnId,
168 state:
169 row.state === "error"
170 ? "error"
171 : row.state === "interrupted"
172 ? "interrupted"
173 : row.state === "completed"
174 ? "completed"
175 : "running",
176 requestedAt: row.requestedAt,
177 startedAt: row.startedAt,
178 completedAt: row.completedAt,
179 assistantMessageId: row.assistantMessageId,
180 ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
181 ? {
182 sourceProposedPlan: {
183 threadId: row.sourceProposedPlanThreadId,
184 planId: row.sourceProposedPlanId,
185 },
186 }
187 : {}),
188 };
189}
190
191function mapSessionRow(
192 row: Schema.Schema.Type<typeof ProjectionThreadSessionDbRowSchema>,
193): OrchestrationSession {
194 return {
195 threadId: row.threadId,
196 status: row.status,
197 providerName: row.providerName,
198 runtimeMode: row.runtimeMode,
199 activeTurnId: row.activeTurnId,
200 lastError: row.lastError,
201 updatedAt: row.updatedAt,
202 };
203}
204
205function mapProjectShellRow(
206 row: Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>,
207 repositoryIdentity: OrchestrationProject["repositoryIdentity"],
208): OrchestrationProjectShell {
209 return {
210 id: row.projectId,
211 title: row.title,
212 workspaceRoot: row.workspaceRoot,
213 repositoryIdentity,
214 defaultModelSelection: row.defaultModelSelection,
215 scripts: row.scripts,
216 createdAt: row.createdAt,
217 updatedAt: row.updatedAt,
218 };
219}
220
158function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {221function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
159 return (cause: unknown): ProjectionRepositoryError =>222 return (cause: unknown): ProjectionRepositoryError =>
160 Schema.isSchemaError(cause)223 Schema.isSchemaError(cause)
...@@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
204 created_at AS "createdAt",267 created_at AS "createdAt",
205 updated_at AS "updatedAt",268 updated_at AS "updatedAt",
206 archived_at AS "archivedAt",269 archived_at AS "archivedAt",
270 latest_user_message_at AS "latestUserMessageAt",
271 pending_approval_count AS "pendingApprovalCount",
272 pending_user_input_count AS "pendingUserInputCount",
273 has_actionable_proposed_plan AS "hasActionableProposedPlan",
207 deleted_at AS "deletedAt"274 deleted_at AS "deletedAt"
208 FROM projection_threads275 FROM projection_threads
209 ORDER BY created_at ASC, thread_id ASC276 ORDER BY created_at ASC, thread_id ASC
...@@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
381 `,448 `,
382 });449 });
383450
451 const getActiveProjectRowById = SqlSchema.findOneOption({
452 Request: ProjectIdLookupInput,
453 Result: ProjectionProjectLookupRowSchema,
454 execute: ({ projectId }) =>
455 sql`
456 SELECT
457 project_id AS "projectId",
458 title,
459 workspace_root AS "workspaceRoot",
460 default_model_selection_json AS "defaultModelSelection",
461 scripts_json AS "scripts",
462 created_at AS "createdAt",
463 updated_at AS "updatedAt",
464 deleted_at AS "deletedAt"
465 FROM projection_projects
466 WHERE project_id = ${projectId}
467 AND deleted_at IS NULL
468 LIMIT 1
469 `,
470 });
471
384 const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({472 const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({
385 Request: ProjectIdLookupInput,473 Request: ProjectIdLookupInput,
386 Result: ProjectionThreadIdLookupRowSchema,474 Result: ProjectionThreadIdLookupRowSchema,
...@@ -415,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -415,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
415 `,503 `,
416 });504 });
417505
506 const getActiveThreadRowById = SqlSchema.findOneOption({
507 Request: ThreadIdLookupInput,
508 Result: ProjectionThreadDbRowSchema,
509 execute: ({ threadId }) =>
510 sql`
511 SELECT
512 thread_id AS "threadId",
513 project_id AS "projectId",
514 title,
515 model_selection_json AS "modelSelection",
516 runtime_mode AS "runtimeMode",
517 interaction_mode AS "interactionMode",
518 branch,
519 worktree_path AS "worktreePath",
520 latest_turn_id AS "latestTurnId",
521 created_at AS "createdAt",
522 updated_at AS "updatedAt",
523 archived_at AS "archivedAt",
524 latest_user_message_at AS "latestUserMessageAt",
525 pending_approval_count AS "pendingApprovalCount",
526 pending_user_input_count AS "pendingUserInputCount",
527 has_actionable_proposed_plan AS "hasActionableProposedPlan",
528 deleted_at AS "deletedAt"
529 FROM projection_threads
530 WHERE thread_id = ${threadId}
531 AND deleted_at IS NULL
532 LIMIT 1
533 `,
534 });
535
536 const listThreadMessageRowsByThread = SqlSchema.findAll({
537 Request: ThreadIdLookupInput,
538 Result: ProjectionThreadMessageDbRowSchema,
539 execute: ({ threadId }) =>
540 sql`
541 SELECT
542 message_id AS "messageId",
543 thread_id AS "threadId",
544 turn_id AS "turnId",
545 role,
546 text,
547 attachments_json AS "attachments",
548 is_streaming AS "isStreaming",
549 created_at AS "createdAt",
550 updated_at AS "updatedAt"
551 FROM projection_thread_messages
552 WHERE thread_id = ${threadId}
553 ORDER BY created_at ASC, message_id ASC
554 `,
555 });
556
557 const listThreadProposedPlanRowsByThread = SqlSchema.findAll({
558 Request: ThreadIdLookupInput,
559 Result: ProjectionThreadProposedPlanDbRowSchema,
560 execute: ({ threadId }) =>
561 sql`
562 SELECT
563 plan_id AS "planId",
564 thread_id AS "threadId",
565 turn_id AS "turnId",
566 plan_markdown AS "planMarkdown",
567 implemented_at AS "implementedAt",
568 implementation_thread_id AS "implementationThreadId",
569 created_at AS "createdAt",
570 updated_at AS "updatedAt"
571 FROM projection_thread_proposed_plans
572 WHERE thread_id = ${threadId}
573 ORDER BY created_at ASC, plan_id ASC
574 `,
575 });
576
577 const listThreadActivityRowsByThread = SqlSchema.findAll({
578 Request: ThreadIdLookupInput,
579 Result: ProjectionThreadActivityDbRowSchema,
580 execute: ({ threadId }) =>
581 sql`
582 SELECT
583 activity_id AS "activityId",
584 thread_id AS "threadId",
585 turn_id AS "turnId",
586 tone,
587 kind,
588 summary,
589 payload_json AS "payload",
590 sequence,
591 created_at AS "createdAt"
592 FROM projection_thread_activities
593 WHERE thread_id = ${threadId}
594 ORDER BY
595 CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
596 sequence ASC,
597 created_at ASC,
598 activity_id ASC
599 `,
600 });
601
602 const getThreadSessionRowByThread = SqlSchema.findOneOption({
603 Request: ThreadIdLookupInput,
604 Result: ProjectionThreadSessionDbRowSchema,
605 execute: ({ threadId }) =>
606 sql`
607 SELECT
608 thread_id AS "threadId",
609 status,
610 provider_name AS "providerName",
611 runtime_mode AS "runtimeMode",
612 active_turn_id AS "activeTurnId",
613 last_error AS "lastError",
614 updated_at AS "updatedAt"
615 FROM projection_thread_sessions
616 WHERE thread_id = ${threadId}
617 LIMIT 1
618 `,
619 });
620
621 const getLatestTurnRowByThread = SqlSchema.findOneOption({
622 Request: ThreadIdLookupInput,
623 Result: ProjectionLatestTurnDbRowSchema,
624 execute: ({ threadId }) =>
625 sql`
626 SELECT
627 turns.thread_id AS "threadId",
628 turns.turn_id AS "turnId",
629 turns.state,
630 turns.requested_at AS "requestedAt",
631 turns.started_at AS "startedAt",
632 turns.completed_at AS "completedAt",
633 turns.assistant_message_id AS "assistantMessageId",
634 turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
635 turns.source_proposed_plan_id AS "sourceProposedPlanId"
636 FROM projection_threads threads
637 JOIN projection_turns turns
638 ON turns.thread_id = threads.thread_id
639 AND turns.turn_id = threads.latest_turn_id
640 WHERE threads.thread_id = ${threadId}
641 AND threads.deleted_at IS NULL
642 LIMIT 1
643 `,
644 });
645
418 const listCheckpointRowsByThread = SqlSchema.findAll({646 const listCheckpointRowsByThread = SqlSchema.findAll({
419 Request: ThreadIdLookupInput,647 Request: ThreadIdLookupInput,
420 Result: ProjectionCheckpointDbRowSchema,648 Result: ProjectionCheckpointDbRowSchema,
...@@ -724,6 +952,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -724,6 +952,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
724 }),952 }),
725 );953 );
726954
955 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () =>
956 sql
957 .withTransaction(
958 Effect.all([
959 listProjectRows(undefined).pipe(
960 Effect.mapError(
961 toPersistenceSqlOrDecodeError(
962 "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query",
963 "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows",
964 ),
965 ),
966 ),
967 listThreadRows(undefined).pipe(
968 Effect.mapError(
969 toPersistenceSqlOrDecodeError(
970 "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query",
971 "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows",
972 ),
973 ),
974 ),
975 listThreadSessionRows(undefined).pipe(
976 Effect.mapError(
977 toPersistenceSqlOrDecodeError(
978 "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query",
979 "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows",
980 ),
981 ),
982 ),
983 listLatestTurnRows(undefined).pipe(
984 Effect.mapError(
985 toPersistenceSqlOrDecodeError(
986 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query",
987 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows",
988 ),
989 ),
990 ),
991 listProjectionStateRows(undefined).pipe(
992 Effect.mapError(
993 toPersistenceSqlOrDecodeError(
994 "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query",
995 "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows",
996 ),
997 ),
998 ),
999 ]),
1000 )
1001 .pipe(
1002 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
1003 Effect.gen(function* () {
1004 let updatedAt: string | null = null;
1005 for (const row of projectRows) {
1006 updatedAt = maxIso(updatedAt, row.updatedAt);
1007 }
1008 for (const row of threadRows) {
1009 updatedAt = maxIso(updatedAt, row.updatedAt);
1010 }
1011 for (const row of sessionRows) {
1012 updatedAt = maxIso(updatedAt, row.updatedAt);
1013 }
1014 for (const row of latestTurnRows) {
1015 updatedAt = maxIso(updatedAt, row.requestedAt);
1016 if (row.startedAt !== null) {
1017 updatedAt = maxIso(updatedAt, row.startedAt);
1018 }
1019 if (row.completedAt !== null) {
1020 updatedAt = maxIso(updatedAt, row.completedAt);
1021 }
1022 }
1023 for (const row of stateRows) {
1024 updatedAt = maxIso(updatedAt, row.updatedAt);
1025 }
1026
1027 const repositoryIdentities = new Map(
1028 yield* Effect.forEach(
1029 projectRows,
1030 (row) =>
1031 repositoryIdentityResolver
1032 .resolve(row.workspaceRoot)
1033 .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
1034 { concurrency: repositoryIdentityResolutionConcurrency },
1035 ),
1036 );
1037 const latestTurnByThread = new Map(
1038 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
1039 );
1040 const sessionByThread = new Map(
1041 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
1042 );
1043
1044 const snapshot = {
1045 snapshotSequence: computeSnapshotSequence(stateRows),
1046 projects: projectRows
1047 .filter((row) => row.deletedAt === null)
1048 .map((row) =>
1049 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
1050 ),
1051 threads: threadRows
1052 .filter((row) => row.deletedAt === null)
1053 .map(
1054 (row): OrchestrationThreadShell => ({
1055 id: row.threadId,
1056 projectId: row.projectId,
1057 title: row.title,
1058 modelSelection: row.modelSelection,
1059 runtimeMode: row.runtimeMode,
1060 interactionMode: row.interactionMode,
1061 branch: row.branch,
1062 worktreePath: row.worktreePath,
1063 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1064 createdAt: row.createdAt,
1065 updatedAt: row.updatedAt,
1066 archivedAt: row.archivedAt,
1067 session: sessionByThread.get(row.threadId) ?? null,
1068 latestUserMessageAt: row.latestUserMessageAt,
1069 hasPendingApprovals: row.pendingApprovalCount > 0,
1070 hasPendingUserInput: row.pendingUserInputCount > 0,
1071 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1072 }),
1073 ),
1074 updatedAt: updatedAt ?? new Date(0).toISOString(),
1075 };
1076
1077 return yield* decodeShellSnapshot(snapshot).pipe(
1078 Effect.mapError(
1079 toPersistenceDecodeError(
1080 "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot",
1081 ),
1082 ),
1083 );
1084 }),
1085 ),
1086 Effect.mapError((error) => {
1087 if (isPersistenceError(error)) {
1088 return error;
1089 }
1090 return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error);
1091 }),
1092 );
1093
727 const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () =>1094 const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () =>
728 readProjectionCounts(undefined).pipe(1095 readProjectionCounts(undefined).pipe(
729 Effect.mapError(1096 Effect.mapError(
...@@ -770,6 +1137,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -770,6 +1137,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
770 ),1137 ),
771 );1138 );
7721139
1140 const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) =>
1141 getActiveProjectRowById({ projectId }).pipe(
1142 Effect.mapError(
1143 toPersistenceSqlOrDecodeError(
1144 "ProjectionSnapshotQuery.getProjectShellById:query",
1145 "ProjectionSnapshotQuery.getProjectShellById:decodeRow",
1146 ),
1147 ),
1148 Effect.flatMap((option) =>
1149 Option.isNone(option)
1150 ? Effect.succeed(Option.none<OrchestrationProjectShell>())
1151 : repositoryIdentityResolver
1152 .resolve(option.value.workspaceRoot)
1153 .pipe(
1154 Effect.map((repositoryIdentity) =>
1155 Option.some(mapProjectShellRow(option.value, repositoryIdentity)),
1156 ),
1157 ),
1158 ),
1159 );
1160
773 const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] =1161 const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] =
774 (projectId) =>1162 (projectId) =>
775 getFirstActiveThreadIdByProject({ projectId }).pipe(1163 getFirstActiveThreadIdByProject({ projectId }).pipe(
...@@ -826,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -826,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
826 });1214 });
827 });1215 });
8281216
1217 const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) =>
1218 Effect.gen(function* () {
1219 const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([
1220 getActiveThreadRowById({ threadId }).pipe(
1221 Effect.mapError(
1222 toPersistenceSqlOrDecodeError(
1223 "ProjectionSnapshotQuery.getThreadShellById:getThread:query",
1224 "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow",
1225 ),
1226 ),
1227 ),
1228 getLatestTurnRowByThread({ threadId }).pipe(
1229 Effect.mapError(
1230 toPersistenceSqlOrDecodeError(
1231 "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query",
1232 "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow",
1233 ),
1234 ),
1235 ),
1236 getThreadSessionRowByThread({ threadId }).pipe(
1237 Effect.mapError(
1238 toPersistenceSqlOrDecodeError(
1239 "ProjectionSnapshotQuery.getThreadShellById:getSession:query",
1240 "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow",
1241 ),
1242 ),
1243 ),
1244 ]);
1245
1246 if (Option.isNone(threadRow)) {
1247 return Option.none<OrchestrationThreadShell>();
1248 }
1249
1250 return Option.some({
1251 id: threadRow.value.threadId,
1252 projectId: threadRow.value.projectId,
1253 title: threadRow.value.title,
1254 modelSelection: threadRow.value.modelSelection,
1255 runtimeMode: threadRow.value.runtimeMode,
1256 interactionMode: threadRow.value.interactionMode,
1257 branch: threadRow.value.branch,
1258 worktreePath: threadRow.value.worktreePath,
1259 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
1260 createdAt: threadRow.value.createdAt,
1261 updatedAt: threadRow.value.updatedAt,
1262 archivedAt: threadRow.value.archivedAt,
1263 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
1264 latestUserMessageAt: threadRow.value.latestUserMessageAt,
1265 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
1266 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
1267 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
1268 } satisfies OrchestrationThreadShell);
1269 });
1270
1271 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) =>
1272 Effect.gen(function* () {
1273 const [
1274 threadRow,
1275 messageRows,
1276 proposedPlanRows,
1277 activityRows,
1278 checkpointRows,
1279 latestTurnRow,
1280 sessionRow,
1281 ] = yield* Effect.all([
1282 getActiveThreadRowById({ threadId }).pipe(
1283 Effect.mapError(
1284 toPersistenceSqlOrDecodeError(
1285 "ProjectionSnapshotQuery.getThreadDetailById:getThread:query",
1286 "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow",
1287 ),
1288 ),
1289 ),
1290 listThreadMessageRowsByThread({ threadId }).pipe(
1291 Effect.mapError(
1292 toPersistenceSqlOrDecodeError(
1293 "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query",
1294 "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows",
1295 ),
1296 ),
1297 ),
1298 listThreadProposedPlanRowsByThread({ threadId }).pipe(
1299 Effect.mapError(
1300 toPersistenceSqlOrDecodeError(
1301 "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query",
1302 "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows",
1303 ),
1304 ),
1305 ),
1306 listThreadActivityRowsByThread({ threadId }).pipe(
1307 Effect.mapError(
1308 toPersistenceSqlOrDecodeError(
1309 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
1310 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
1311 ),
1312 ),
1313 ),
1314 listCheckpointRowsByThread({ threadId }).pipe(
1315 Effect.mapError(
1316 toPersistenceSqlOrDecodeError(
1317 "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query",
1318 "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows",
1319 ),
1320 ),
1321 ),
1322 getLatestTurnRowByThread({ threadId }).pipe(
1323 Effect.mapError(
1324 toPersistenceSqlOrDecodeError(
1325 "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query",
1326 "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow",
1327 ),
1328 ),
1329 ),
1330 getThreadSessionRowByThread({ threadId }).pipe(
1331 Effect.mapError(
1332 toPersistenceSqlOrDecodeError(
1333 "ProjectionSnapshotQuery.getThreadDetailById:getSession:query",
1334 "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow",
1335 ),
1336 ),
1337 ),
1338 ]);
1339
1340 if (Option.isNone(threadRow)) {
1341 return Option.none<OrchestrationThread>();
1342 }
1343
1344 const thread = {
1345 id: threadRow.value.threadId,
1346 projectId: threadRow.value.projectId,
1347 title: threadRow.value.title,
1348 modelSelection: threadRow.value.modelSelection,
1349 runtimeMode: threadRow.value.runtimeMode,
1350 interactionMode: threadRow.value.interactionMode,
1351 branch: threadRow.value.branch,
1352 worktreePath: threadRow.value.worktreePath,
1353 latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null,
1354 createdAt: threadRow.value.createdAt,
1355 updatedAt: threadRow.value.updatedAt,
1356 archivedAt: threadRow.value.archivedAt,
1357 deletedAt: null,
1358 messages: messageRows.map((row) => {
1359 const message = {
1360 id: row.messageId,
1361 role: row.role,
1362 text: row.text,
1363 turnId: row.turnId,
1364 streaming: row.isStreaming === 1,
1365 createdAt: row.createdAt,
1366 updatedAt: row.updatedAt,
1367 };
1368 if (row.attachments !== null) {
1369 return Object.assign(message, { attachments: row.attachments });
1370 }
1371 return message;
1372 }),
1373 proposedPlans: proposedPlanRows.map((row) => ({
1374 id: row.planId,
1375 turnId: row.turnId,
1376 planMarkdown: row.planMarkdown,
1377 implementedAt: row.implementedAt,
1378 implementationThreadId: row.implementationThreadId,
1379 createdAt: row.createdAt,
1380 updatedAt: row.updatedAt,
1381 })),
1382 activities: activityRows.map((row) => {
1383 const activity = {
1384 id: row.activityId,
1385 tone: row.tone,
1386 kind: row.kind,
1387 summary: row.summary,
1388 payload: row.payload,
1389 turnId: row.turnId,
1390 createdAt: row.createdAt,
1391 };
1392 if (row.sequence !== null) {
1393 return Object.assign(activity, { sequence: row.sequence });
1394 }
1395 return activity;
1396 }),
1397 checkpoints: checkpointRows.map((row) => ({
1398 turnId: row.turnId,
1399 checkpointTurnCount: row.checkpointTurnCount,
1400 checkpointRef: row.checkpointRef,
1401 status: row.status,
1402 files: row.files,
1403 assistantMessageId: row.assistantMessageId,
1404 completedAt: row.completedAt,
1405 })),
1406 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
1407 };
1408
1409 return Option.some(
1410 yield* decodeThread(thread).pipe(
1411 Effect.mapError(
1412 toPersistenceDecodeError("ProjectionSnapshotQuery.getThreadDetailById:decodeThread"),
1413 ),
1414 ),
1415 );
1416 });
1417
829 return {1418 return {
830 getSnapshot,1419 getSnapshot,
1420 getShellSnapshot,
831 getCounts,1421 getCounts,
832 getActiveProjectByWorkspaceRoot,1422 getActiveProjectByWorkspaceRoot,
1423 getProjectShellById,
833 getFirstActiveThreadIdByProjectId,1424 getFirstActiveThreadIdByProjectId,
834 getThreadCheckpointContext,1425 getThreadCheckpointContext,
1426 getThreadShellById,
1427 getThreadDetailById,
835 } satisfies ProjectionSnapshotQueryShape;1428 } satisfies ProjectionSnapshotQueryShape;
836});1429});
8371430
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+36
...@@ -9,7 +9,11 @@...@@ -9,7 +9,11 @@
9import type {9import type {
10 OrchestrationCheckpointSummary,10 OrchestrationCheckpointSummary,
11 OrchestrationProject,11 OrchestrationProject,
12 OrchestrationProjectShell,
12 OrchestrationReadModel,13 OrchestrationReadModel,
14 OrchestrationShellSnapshot,
15 OrchestrationThread,
16 OrchestrationThreadShell,
13 ProjectId,17 ProjectId,
14 ThreadId,18 ThreadId,
15} from "@t3tools/contracts";19} from "@t3tools/contracts";
...@@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape {...@@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape {
44 */48 */
45 readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>;49 readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>;
4650
51 /**
52 * Read the latest orchestration shell snapshot.
53 *
54 * Returns only projects and thread shell summaries so clients can bootstrap
55 * lightweight navigation state without hydrating every thread body.
56 */
57 readonly getShellSnapshot: () => Effect.Effect<
58 OrchestrationShellSnapshot,
59 ProjectionRepositoryError
60 >;
61
47 /**62 /**
48 * Read aggregate projection counts without hydrating the full read model.63 * Read aggregate projection counts without hydrating the full read model.
49 */64 */
...@@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape {...@@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape {
56 workspaceRoot: string,71 workspaceRoot: string,
57 ) => Effect.Effect<Option.Option<OrchestrationProject>, ProjectionRepositoryError>;72 ) => Effect.Effect<Option.Option<OrchestrationProject>, ProjectionRepositoryError>;
5873
74 /**
75 * Read a single active project shell row by id.
76 */
77 readonly getProjectShellById: (
78 projectId: ProjectId,
79 ) => Effect.Effect<Option.Option<OrchestrationProjectShell>, ProjectionRepositoryError>;
80
59 /**81 /**
60 * Read the earliest active thread for a project.82 * Read the earliest active thread for a project.
61 */83 */
...@@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape {...@@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape {
69 readonly getThreadCheckpointContext: (91 readonly getThreadCheckpointContext: (
70 threadId: ThreadId,92 threadId: ThreadId,
71 ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>;93 ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>;
94
95 /**
96 * Read a single active thread shell row by id.
97 */
98 readonly getThreadShellById: (
99 threadId: ThreadId,
100 ) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>;
101
102 /**
103 * Read a single active thread detail snapshot by id.
104 */
105 readonly getThreadDetailById: (
106 threadId: ThreadId,
107 ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;
72}108}
73109
74/**110/**
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+4
...@@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
88 createdAt: "2026-03-24T00:00:00.000Z",88 createdAt: "2026-03-24T00:00:00.000Z",
89 updatedAt: "2026-03-24T00:00:00.000Z",89 updatedAt: "2026-03-24T00:00:00.000Z",
90 archivedAt: null,90 archivedAt: null,
91 latestUserMessageAt: null,
92 pendingApprovalCount: 0,
93 pendingUserInputCount: 0,
94 hasActionableProposedPlan: 0,
91 deletedAt: null,95 deletedAt: null,
92 });96 });
9397
apps/server/src/persistence/Layers/ProjectionThreads.ts+20
...@@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
40 created_at,40 created_at,
41 updated_at,41 updated_at,
42 archived_at,42 archived_at,
43 latest_user_message_at,
44 pending_approval_count,
45 pending_user_input_count,
46 has_actionable_proposed_plan,
43 deleted_at47 deleted_at
44 )48 )
45 VALUES (49 VALUES (
...@@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
55 ${row.createdAt},59 ${row.createdAt},
56 ${row.updatedAt},60 ${row.updatedAt},
57 ${row.archivedAt},61 ${row.archivedAt},
62 ${row.latestUserMessageAt},
63 ${row.pendingApprovalCount},
64 ${row.pendingUserInputCount},
65 ${row.hasActionableProposedPlan},
58 ${row.deletedAt}66 ${row.deletedAt}
59 )67 )
60 ON CONFLICT (thread_id)68 ON CONFLICT (thread_id)
...@@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
70 created_at = excluded.created_at,78 created_at = excluded.created_at,
71 updated_at = excluded.updated_at,79 updated_at = excluded.updated_at,
72 archived_at = excluded.archived_at,80 archived_at = excluded.archived_at,
81 latest_user_message_at = excluded.latest_user_message_at,
82 pending_approval_count = excluded.pending_approval_count,
83 pending_user_input_count = excluded.pending_user_input_count,
84 has_actionable_proposed_plan = excluded.has_actionable_proposed_plan,
73 deleted_at = excluded.deleted_at85 deleted_at = excluded.deleted_at
74 `,86 `,
75 });87 });
...@@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
92 created_at AS "createdAt",104 created_at AS "createdAt",
93 updated_at AS "updatedAt",105 updated_at AS "updatedAt",
94 archived_at AS "archivedAt",106 archived_at AS "archivedAt",
107 latest_user_message_at AS "latestUserMessageAt",
108 pending_approval_count AS "pendingApprovalCount",
109 pending_user_input_count AS "pendingUserInputCount",
110 has_actionable_proposed_plan AS "hasActionableProposedPlan",
95 deleted_at AS "deletedAt"111 deleted_at AS "deletedAt"
96 FROM projection_threads112 FROM projection_threads
97 WHERE thread_id = ${threadId}113 WHERE thread_id = ${threadId}
...@@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
116 created_at AS "createdAt",132 created_at AS "createdAt",
117 updated_at AS "updatedAt",133 updated_at AS "updatedAt",
118 archived_at AS "archivedAt",134 archived_at AS "archivedAt",
135 latest_user_message_at AS "latestUserMessageAt",
136 pending_approval_count AS "pendingApprovalCount",
137 pending_user_input_count AS "pendingUserInputCount",
138 has_actionable_proposed_plan AS "hasActionableProposedPlan",
119 deleted_at AS "deletedAt"139 deleted_at AS "deletedAt"
120 FROM projection_threads140 FROM projection_threads
121 WHERE project_id = ${projectId}141 WHERE project_id = ${projectId}
apps/server/src/persistence/Migrations.ts+2
...@@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts";...@@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts";
35import Migration0020 from "./Migrations/020_AuthAccessManagement.ts";35import Migration0020 from "./Migrations/020_AuthAccessManagement.ts";
36import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";36import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
37import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";37import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
38import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
3839
39/**40/**
40 * Migration loader with all migrations defined inline.41 * Migration loader with all migrations defined inline.
...@@ -69,6 +70,7 @@ export const migrationEntries = [...@@ -69,6 +70,7 @@ export const migrationEntries = [
69 [20, "AuthAccessManagement", Migration0020],70 [20, "AuthAccessManagement", Migration0020],
70 [21, "AuthSessionClientMetadata", Migration0021],71 [21, "AuthSessionClientMetadata", Migration0021],
71 [22, "AuthSessionLastConnectedAt", Migration0022],72 [22, "AuthSessionLastConnectedAt", Migration0022],
73 [23, "ProjectionThreadShellSummary", Migration0023],
72] as const;74] as const;
7375
74export const makeMigrationLoader = (throughId?: number) =>76export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts created+26
...@@ -0,0 +1,26 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_threads
9 ADD COLUMN latest_user_message_at TEXT
10 `.pipe(Effect.catch(() => Effect.void));
11
12 yield* sql`
13 ALTER TABLE projection_threads
14 ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0
15 `.pipe(Effect.catch(() => Effect.void));
16
17 yield* sql`
18 ALTER TABLE projection_threads
19 ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0
20 `.pipe(Effect.catch(() => Effect.void));
21
22 yield* sql`
23 ALTER TABLE projection_threads
24 ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0
25 `.pipe(Effect.catch(() => Effect.void));
26});
apps/server/src/persistence/Services/ProjectionThreads.ts+5
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
9import {9import {
10 IsoDateTime,10 IsoDateTime,
11 ModelSelection,11 ModelSelection,
12 NonNegativeInt,
12 ProjectId,13 ProjectId,
13 ProviderInteractionMode,14 ProviderInteractionMode,
14 RuntimeMode,15 RuntimeMode,
...@@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({...@@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({
33 createdAt: IsoDateTime,34 createdAt: IsoDateTime,
34 updatedAt: IsoDateTime,35 updatedAt: IsoDateTime,
35 archivedAt: Schema.NullOr(IsoDateTime),36 archivedAt: Schema.NullOr(IsoDateTime),
37 latestUserMessageAt: Schema.NullOr(IsoDateTime),
38 pendingApprovalCount: NonNegativeInt,
39 pendingUserInputCount: NonNegativeInt,
40 hasActionableProposedPlan: NonNegativeInt,
36 deletedAt: Schema.NullOr(IsoDateTime),41 deletedAt: Schema.NullOr(IsoDateTime),
37});42});
38export type ProjectionThread = typeof ProjectionThread.Type;43export type ProjectionThread = typeof ProjectionThread.Type;
apps/server/src/server.test.ts+15-234
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 FileSystem,31 FileSystem,
32 Layer,32 Layer,
33 ManagedRuntime,33 ManagedRuntime,
34 Option,
34 Path,35 Path,
35 Stream,36 Stream,
36} from "effect";37} from "effect";
...@@ -68,7 +69,6 @@ import {...@@ -68,7 +69,6 @@ import {
68 ProjectionSnapshotQuery,69 ProjectionSnapshotQuery,
69 type ProjectionSnapshotQueryShape,70 type ProjectionSnapshotQueryShape,
70} from "./orchestration/Services/ProjectionSnapshotQuery.ts";71} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
71import { PersistenceSqlError } from "./persistence/Errors.ts";
72import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";72import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
73import {73import {
74 ProviderRegistry,74 ProviderRegistry,
...@@ -410,6 +410,20 @@ const buildAppUnderTest = (options?: {...@@ -410,6 +410,20 @@ const buildAppUnderTest = (options?: {
410 Layer.provide(410 Layer.provide(
411 Layer.mock(ProjectionSnapshotQuery)({411 Layer.mock(ProjectionSnapshotQuery)({
412 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),412 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
413 getShellSnapshot: () =>
414 Effect.succeed({
415 snapshotSequence: 0,
416 projects: [],
417 threads: [],
418 updatedAt: new Date(0).toISOString(),
419 }),
420 getProjectShellById: () => Effect.succeed(Option.none()),
421 getThreadShellById: () => Effect.succeed(Option.none()),
422 getThreadDetailById: () => Effect.succeed(Option.none()),
423 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
424 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
425 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
426 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
413 ...options?.layers?.projectionSnapshotQuery,427 ...options?.layers?.projectionSnapshotQuery,
414 }),428 }),
415 ),429 ),
...@@ -2748,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2748,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2748 });2762 });
27492763
2750 const wsUrl = yield* getWsServerUrl("/ws");2764 const wsUrl = yield* getWsServerUrl("/ws");
2751 const snapshotResult = yield* Effect.scoped(
2752 withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
2753 );
2754 assert.equal(snapshotResult.snapshotSequence, 1);
2755
2756 const dispatchResult = yield* Effect.scoped(2765 const dispatchResult = yield* Effect.scoped(
2757 withWsRpcClient(wsUrl, (client) =>2766 withWsRpcClient(wsUrl, (client) =>
2758 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({2767 client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
...@@ -3306,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3306,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3306 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3315 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3307 );3316 );
33083317
3309 it.effect(
3310 "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience",
3311 () =>
3312 Effect.gen(function* () {
3313 const now = new Date().toISOString();
3314 const threadId = ThreadId.make("thread-1");
3315 let replayCursor: number | null = null;
3316 const makeEvent = (sequence: number): OrchestrationEvent =>
3317 ({
3318 sequence,
3319 eventId: `event-${sequence}`,
3320 aggregateKind: "thread",
3321 aggregateId: threadId,
3322 occurredAt: now,
3323 commandId: null,
3324 causationEventId: null,
3325 correlationId: null,
3326 metadata: {},
3327 type: "thread.reverted",
3328 payload: {
3329 threadId,
3330 turnCount: sequence,
3331 },
3332 }) as OrchestrationEvent;
3333
3334 yield* buildAppUnderTest({
3335 layers: {
3336 orchestrationEngine: {
3337 getReadModel: () =>
3338 Effect.succeed({
3339 ...makeDefaultOrchestrationReadModel(),
3340 snapshotSequence: 1,
3341 }),
3342 readEvents: (fromSequenceExclusive) => {
3343 replayCursor = fromSequenceExclusive;
3344 return Stream.make(makeEvent(2), makeEvent(3));
3345 },
3346 streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)),
3347 },
3348 },
3349 });
3350
3351 const wsUrl = yield* getWsServerUrl("/ws");
3352 const events = yield* Effect.scoped(
3353 withWsRpcClient(wsUrl, (client) =>
3354 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
3355 Stream.take(3),
3356 Stream.runCollect,
3357 ),
3358 ),
3359 );
3360
3361 assert.equal(replayCursor, 1);
3362 assert.deepEqual(
3363 Array.from(events).map((event) => event.sequence),
3364 [2, 3, 4],
3365 );
3366 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3367 );
3368
3369 it.effect("enriches replayed project events only once before streaming them to subscribers", () =>
3370 Effect.gen(function* () {
3371 let resolveCalls = 0;
3372 const repositoryIdentity = {
3373 canonicalKey: "github.com/t3tools/t3code",
3374 locator: {
3375 source: "git-remote" as const,
3376 remoteName: "origin",
3377 remoteUrl: "git@github.com:t3tools/t3code.git",
3378 },
3379 displayName: "t3tools/t3code",
3380 provider: "github" as const,
3381 owner: "t3tools",
3382 name: "t3code",
3383 };
3384
3385 yield* buildAppUnderTest({
3386 layers: {
3387 orchestrationEngine: {
3388 getReadModel: () =>
3389 Effect.succeed({
3390 ...makeDefaultOrchestrationReadModel(),
3391 snapshotSequence: 0,
3392 }),
3393 readEvents: () =>
3394 Stream.make({
3395 sequence: 1,
3396 eventId: EventId.make("event-1"),
3397 aggregateKind: "project",
3398 aggregateId: defaultProjectId,
3399 occurredAt: "2026-04-06T00:00:00.000Z",
3400 commandId: null,
3401 causationEventId: null,
3402 correlationId: null,
3403 metadata: {},
3404 type: "project.meta-updated",
3405 payload: {
3406 projectId: defaultProjectId,
3407 title: "Replayed Project",
3408 updatedAt: "2026-04-06T00:00:00.000Z",
3409 },
3410 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
3411 streamDomainEvents: Stream.empty,
3412 },
3413 repositoryIdentityResolver: {
3414 resolve: () => {
3415 resolveCalls += 1;
3416 return Effect.succeed(repositoryIdentity);
3417 },
3418 },
3419 },
3420 });
3421
3422 const wsUrl = yield* getWsServerUrl("/ws");
3423 const events = yield* Effect.scoped(
3424 withWsRpcClient(wsUrl, (client) =>
3425 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
3426 Stream.take(1),
3427 Stream.runCollect,
3428 ),
3429 ),
3430 );
3431
3432 const event = Array.from(events)[0];
3433 assert.equal(resolveCalls, 1);
3434 assert.equal(event?.type, "project.meta-updated");
3435 assert.deepEqual(
3436 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
3437 repositoryIdentity,
3438 );
3439 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3440 );
3441
3442 it.effect("enriches subscribed project meta updates with repository identity metadata", () =>
3443 Effect.gen(function* () {
3444 const repositoryIdentity = {
3445 canonicalKey: "github.com/t3tools/t3code",
3446 locator: {
3447 source: "git-remote" as const,
3448 remoteName: "upstream",
3449 remoteUrl: "git@github.com:T3Tools/t3code.git",
3450 },
3451 displayName: "T3Tools/t3code",
3452 provider: "github",
3453 owner: "T3Tools",
3454 name: "t3code",
3455 };
3456
3457 yield* buildAppUnderTest({
3458 layers: {
3459 orchestrationEngine: {
3460 getReadModel: () =>
3461 Effect.succeed({
3462 ...makeDefaultOrchestrationReadModel(),
3463 snapshotSequence: 0,
3464 }),
3465 streamDomainEvents: Stream.make({
3466 sequence: 1,
3467 eventId: EventId.make("event-1"),
3468 aggregateKind: "project",
3469 aggregateId: defaultProjectId,
3470 occurredAt: "2026-04-05T00:00:00.000Z",
3471 commandId: null,
3472 causationEventId: null,
3473 correlationId: null,
3474 metadata: {},
3475 type: "project.meta-updated",
3476 payload: {
3477 projectId: defaultProjectId,
3478 title: "Renamed Project",
3479 updatedAt: "2026-04-05T00:00:00.000Z",
3480 },
3481 } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
3482 },
3483 repositoryIdentityResolver: {
3484 resolve: () => Effect.succeed(repositoryIdentity),
3485 },
3486 },
3487 });
3488
3489 const wsUrl = yield* getWsServerUrl("/ws");
3490 const events = yield* Effect.scoped(
3491 withWsRpcClient(wsUrl, (client) =>
3492 client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
3493 Stream.take(1),
3494 Stream.runCollect,
3495 ),
3496 ),
3497 );
3498
3499 const event = Array.from(events)[0];
3500 assert.equal(event?.type, "project.meta-updated");
3501 assert.deepEqual(
3502 event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
3503 repositoryIdentity,
3504 );
3505 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3506 );
3507
3508 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
3509 Effect.gen(function* () {
3510 yield* buildAppUnderTest({
3511 layers: {
3512 projectionSnapshotQuery: {
3513 getSnapshot: () =>
3514 Effect.fail(
3515 new PersistenceSqlError({
3516 operation: "ProjectionSnapshotQuery.getSnapshot",
3517 detail: "projection unavailable",
3518 }),
3519 ),
3520 },
3521 },
3522 });
3523
3524 const wsUrl = yield* getWsServerUrl("/ws");
3525 const result = yield* Effect.scoped(
3526 withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe(
3527 Effect.result,
3528 ),
3529 );
3530
3531 assertTrue(result._tag === "Failure");
3532 assertTrue(result.failure._tag === "OrchestrationGetSnapshotError");
3533 assertInclude(result.failure.message, "Failed to load orchestration snapshot");
3534 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3535 );
3536
3537 it.effect("routes websocket rpc terminal methods", () =>3318 it.effect("routes websocket rpc terminal methods", () =>
3538 Effect.gen(function* () {3319 Effect.gen(function* () {
3539 const snapshot = {3320 const snapshot = {
apps/server/src/serverRuntimeStartup.test.ts+4
...@@ -70,6 +70,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -70,6 +70,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
70 yield* launchStartupHeartbeat.pipe(70 yield* launchStartupHeartbeat.pipe(
71 Effect.provideService(ProjectionSnapshotQuery, {71 Effect.provideService(ProjectionSnapshotQuery, {
72 getSnapshot: () => Effect.die("unused"),72 getSnapshot: () => Effect.die("unused"),
73 getShellSnapshot: () => Effect.die("unused"),
73 getCounts: () =>74 getCounts: () =>
74 Deferred.await(releaseCounts).pipe(75 Deferred.await(releaseCounts).pipe(
75 Effect.as({76 Effect.as({
...@@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
78 }),79 }),
79 ),80 ),
80 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),81 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
82 getProjectShellById: () => Effect.succeed(Option.none()),
81 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),83 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
82 getThreadCheckpointContext: () => Effect.succeed(Option.none()),84 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
85 getThreadShellById: () => Effect.succeed(Option.none()),
86 getThreadDetailById: () => Effect.succeed(Option.none()),
83 }),87 }),
84 Effect.provideService(AnalyticsService, {88 Effect.provideService(AnalyticsService, {
85 record: () => Effect.void,89 record: () => Effect.void,
apps/server/src/ws.ts+146-66
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect";1import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
2import {2import {
3 type AuthAccessStreamEvent,3 type AuthAccessStreamEvent,
4 AuthSessionId,4 AuthSessionId,
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 type GitManagerServiceError,9 type GitManagerServiceError,
10 OrchestrationDispatchCommandError,10 OrchestrationDispatchCommandError,
11 type OrchestrationEvent,11 type OrchestrationEvent,
12 type OrchestrationShellStreamEvent,
12 OrchestrationGetFullThreadDiffError,13 OrchestrationGetFullThreadDiffError,
13 OrchestrationGetSnapshotError,14 OrchestrationGetSnapshotError,
14 OrchestrationGetTurnDiffError,15 OrchestrationGetTurnDiffError,
...@@ -62,6 +63,28 @@ import {...@@ -62,6 +63,28 @@ import {
62} from "./auth/Services/SessionCredentialService";63} from "./auth/Services/SessionCredentialService";
63import { respondToAuthError } from "./auth/http";64import { respondToAuthError } from "./auth/http";
6465
66function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
67 OrchestrationEvent,
68 {
69 type:
70 | "thread.message-sent"
71 | "thread.proposed-plan-upserted"
72 | "thread.activity-appended"
73 | "thread.turn-diff-completed"
74 | "thread.reverted"
75 | "thread.session-set";
76 }
77> {
78 return (
79 event.type === "thread.message-sent" ||
80 event.type === "thread.proposed-plan-upserted" ||
81 event.type === "thread.activity-appended" ||
82 event.type === "thread.turn-diff-completed" ||
83 event.type === "thread.reverted" ||
84 event.type === "thread.session-set"
85 );
86}
87
65function toAuthAccessStreamEvent(88function toAuthAccessStreamEvent(
66 change: BootstrapCredentialChange | SessionCredentialChange,89 change: BootstrapCredentialChange | SessionCredentialChange,
67 revision: number,90 revision: number,
...@@ -222,6 +245,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -222,6 +245,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
222 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>245 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
223 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });246 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
224247
248 const toShellStreamEvent = (
249 event: OrchestrationEvent,
250 ): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => {
251 switch (event.type) {
252 case "project.created":
253 case "project.meta-updated":
254 return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe(
255 Effect.map((project) =>
256 Option.map(project, (nextProject) => ({
257 kind: "project-upserted" as const,
258 sequence: event.sequence,
259 project: nextProject,
260 })),
261 ),
262 Effect.catch(() => Effect.succeed(Option.none())),
263 );
264 case "project.deleted":
265 return Effect.succeed(
266 Option.some({
267 kind: "project-removed" as const,
268 sequence: event.sequence,
269 projectId: event.payload.projectId,
270 }),
271 );
272 case "thread.deleted":
273 return Effect.succeed(
274 Option.some({
275 kind: "thread-removed" as const,
276 sequence: event.sequence,
277 threadId: event.payload.threadId,
278 }),
279 );
280 default:
281 if (event.aggregateKind !== "thread") {
282 return Effect.succeed(Option.none());
283 }
284 return projectionSnapshotQuery
285 .getThreadShellById(ThreadId.make(event.aggregateId))
286 .pipe(
287 Effect.map((thread) =>
288 Option.map(thread, (nextThread) => ({
289 kind: "thread-upserted" as const,
290 sequence: event.sequence,
291 thread: nextThread,
292 })),
293 ),
294 Effect.catch(() => Effect.succeed(Option.none())),
295 );
296 }
297 };
298
225 const dispatchBootstrapTurnStart = (299 const dispatchBootstrapTurnStart = (
226 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,300 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
227 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>301 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
...@@ -467,20 +541,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -467,20 +541,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
467 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);541 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
468542
469 return WsRpcGroup.of({543 return WsRpcGroup.of({
470 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
471 observeRpcEffect(
472 ORCHESTRATION_WS_METHODS.getSnapshot,
473 projectionSnapshotQuery.getSnapshot().pipe(
474 Effect.mapError(
475 (cause) =>
476 new OrchestrationGetSnapshotError({
477 message: "Failed to load orchestration snapshot",
478 cause,
479 }),
480 ),
481 ),
482 { "rpc.aggregate": "orchestration" },
483 ),
484 [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>544 [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
485 observeRpcEffect(545 observeRpcEffect(
486 ORCHESTRATION_WS_METHODS.dispatchCommand,546 ORCHESTRATION_WS_METHODS.dispatchCommand,
...@@ -561,65 +621,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -561,65 +621,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
561 ),621 ),
562 { "rpc.aggregate": "orchestration" },622 { "rpc.aggregate": "orchestration" },
563 ),623 ),
564 [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>624 [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) =>
565 observeRpcStreamEffect(625 observeRpcStreamEffect(
566 WS_METHODS.subscribeOrchestrationDomainEvents,626 ORCHESTRATION_WS_METHODS.subscribeShell,
567 Effect.gen(function* () {627 Effect.gen(function* () {
568 const snapshot = yield* orchestrationEngine.getReadModel();628 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
569 const fromSequenceExclusive = snapshot.snapshotSequence;629 Effect.mapError(
570 const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(630 (cause) =>
571 orchestrationEngine.readEvents(fromSequenceExclusive),631 new OrchestrationGetSnapshotError({
572 ).pipe(632 message: "Failed to load orchestration shell snapshot",
573 Effect.map((events) => Array.from(events)),633 cause,
574 Effect.flatMap(enrichOrchestrationEvents),634 }),
575 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),635 ),
576 );636 );
577 const replayStream = Stream.fromIterable(replayEvents);637
578 const liveStream = orchestrationEngine.streamDomainEvents.pipe(638 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
579 Stream.mapEffect(enrichProjectEvent),639 Stream.mapEffect(toShellStreamEvent),
640 Stream.flatMap((event) =>
641 Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty,
642 ),
580 );643 );
581 const source = Stream.merge(replayStream, liveStream);
582 type SequenceState = {
583 readonly nextSequence: number;
584 readonly pendingBySequence: Map<number, OrchestrationEvent>;
585 };
586 const state = yield* Ref.make<SequenceState>({
587 nextSequence: fromSequenceExclusive + 1,
588 pendingBySequence: new Map<number, OrchestrationEvent>(),
589 });
590644
591 return source.pipe(645 return Stream.concat(
592 Stream.mapEffect((event) =>646 Stream.make({
593 Ref.modify(647 kind: "snapshot" as const,
594 state,648 snapshot,
595 ({649 }),
596 nextSequence,650 liveStream,
597 pendingBySequence,651 );
598 }): [Array<OrchestrationEvent>, SequenceState] => {652 }),
599 if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {653 { "rpc.aggregate": "orchestration" },
600 return [[], { nextSequence, pendingBySequence }];654 ),
601 }655 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) =>
602656 observeRpcStreamEffect(
603 const updatedPending = new Map(pendingBySequence);657 ORCHESTRATION_WS_METHODS.subscribeThread,
604 updatedPending.set(event.sequence, event);658 Effect.gen(function* () {
605659 const [threadDetail, snapshotSequence] = yield* Effect.all([
606 const emit: Array<OrchestrationEvent> = [];660 projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(
607 let expected = nextSequence;661 Effect.mapError(
608 for (;;) {662 (cause) =>
609 const expectedEvent = updatedPending.get(expected);663 new OrchestrationGetSnapshotError({
610 if (!expectedEvent) {664 message: `Failed to load thread ${input.threadId}`,
611 break;665 cause,
612 }666 }),
613 emit.push(expectedEvent);
614 updatedPending.delete(expected);
615 expected += 1;
616 }
617
618 return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
619 },
620 ),667 ),
621 ),668 ),
622 Stream.flatMap((events) => Stream.fromIterable(events)),669 orchestrationEngine
670 .getReadModel()
671 .pipe(Effect.map((readModel) => readModel.snapshotSequence)),
672 ]);
673
674 if (Option.isNone(threadDetail)) {
675 return yield* new OrchestrationGetSnapshotError({
676 message: `Thread ${input.threadId} was not found`,
677 cause: input.threadId,
678 });
679 }
680
681 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
682 Stream.filter(
683 (event) =>
684 event.aggregateKind === "thread" &&
685 event.aggregateId === input.threadId &&
686 isThreadDetailEvent(event),
687 ),
688 Stream.map((event) => ({
689 kind: "event" as const,
690 event,
691 })),
692 );
693
694 return Stream.concat(
695 Stream.make({
696 kind: "snapshot" as const,
697 snapshot: {
698 snapshotSequence,
699 thread: threadDetail.value,
700 },
701 }),
702 liveStream,
623 );703 );
624 }),704 }),
625 { "rpc.aggregate": "orchestration" },705 { "rpc.aggregate": "orchestration" },
apps/web/src/authBootstrap.test.ts+3-9
...@@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => {
424 expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session");424 expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session");
425 });425 });
426426
427 it("revalidates the server session state after a previous authenticated result", async () => {427 it("memoizes the authenticated gate state after the first successful read", async () => {
428 const fetchMock = vi428 const fetchMock = vi
429 .fn<typeof fetch>()429 .fn<typeof fetch>()
430 .mockResolvedValueOnce(430 .mockResolvedValueOnce(
...@@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => {
459 status: "authenticated",459 status: "authenticated",
460 });460 });
461 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({461 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
462 status: "requires-auth",462 status: "authenticated",
463 auth: {
464 policy: "loopback-browser",
465 bootstrapMethods: ["one-time-token"],
466 sessionMethods: ["browser-session-cookie"],
467 sessionCookieName: "t3_session",
468 },
469 });463 });
470 expect(fetchMock).toHaveBeenCalledTimes(2);464 expect(fetchMock).toHaveBeenCalledTimes(1);
471 });465 });
472466
473 it("creates a pairing credential from the authenticated auth endpoint", async () => {467 it("creates a pairing credential from the authenticated auth endpoint", async () => {
apps/web/src/components/ChatView.browser.tsx+109-65
...@@ -6,7 +6,6 @@ import {...@@ -6,7 +6,6 @@ import {
6 ORCHESTRATION_WS_METHODS,6 ORCHESTRATION_WS_METHODS,
7 EnvironmentId,7 EnvironmentId,
8 type MessageId,8 type MessageId,
9 type OrchestrationEvent,
10 type OrchestrationReadModel,9 type OrchestrationReadModel,
11 type ProjectId,10 type ProjectId,
12 type ServerConfig,11 type ServerConfig,
...@@ -112,6 +111,7 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {...@@ -112,6 +111,7 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
112 textTolerancePx: 56,111 textTolerancePx: 56,
113 attachmentTolerancePx: 56,112 attachmentTolerancePx: 56,
114};113};
114
115interface MountedChatView {115interface MountedChatView {
116 [Symbol.asyncDispose]: () => Promise<void>;116 [Symbol.asyncDispose]: () => Promise<void>;
117 cleanup: () => Promise<void>;117 cleanup: () => Promise<void>;
...@@ -385,74 +385,94 @@ function addThreadToSnapshot(...@@ -385,74 +385,94 @@ function addThreadToSnapshot(
385 };385 };
386}386}
387387
388function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent {388function toShellThread(thread: OrchestrationReadModel["threads"][number]) {
389 return {389 return {
390 sequence,390 id: thread.id,
391 eventId: EventId.make(`event-thread-created-${sequence}`),391 projectId: thread.projectId,
392 aggregateKind: "thread",392 title: thread.title,
393 aggregateId: threadId,393 modelSelection: thread.modelSelection,
394 occurredAt: NOW_ISO,394 runtimeMode: thread.runtimeMode,
395 commandId: null,395 interactionMode: thread.interactionMode,
396 causationEventId: null,396 branch: thread.branch,
397 correlationId: null,397 worktreePath: thread.worktreePath,
398 metadata: {},398 latestTurn: thread.latestTurn,
399 type: "thread.created",399 createdAt: thread.createdAt,
400 payload: {400 updatedAt: thread.updatedAt,
401 threadId,401 archivedAt: thread.archivedAt,
402 projectId: PROJECT_ID,402 session: thread.session,
403 title: "New thread",403 latestUserMessageAt:
404 modelSelection: {404 thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null,
405 provider: "codex",405 hasPendingApprovals: false,
406 model: "gpt-5",406 hasPendingUserInput: false,
407 },407 hasActionableProposedPlan: false,
408 runtimeMode: "full-access",
409 interactionMode: "default",
410 branch: "main",
411 worktreePath: null,
412 createdAt: NOW_ISO,
413 updatedAt: NOW_ISO,
414 },
415 };408 };
416}409}
417410
418function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent {411function toShellSnapshot(snapshot: OrchestrationReadModel) {
419 return {412 return {
420 sequence,413 snapshotSequence: snapshot.snapshotSequence,
421 eventId: EventId.make(`event-thread-session-set-${sequence}`),414 projects: snapshot.projects.map((project) => ({
422 aggregateKind: "thread",415 id: project.id,
423 aggregateId: threadId,416 title: project.title,
424 occurredAt: NOW_ISO,417 workspaceRoot: project.workspaceRoot,
425 commandId: null,418 repositoryIdentity: project.repositoryIdentity ?? null,
426 causationEventId: null,419 defaultModelSelection: project.defaultModelSelection,
427 correlationId: null,420 scripts: project.scripts,
428 metadata: {},421 createdAt: project.createdAt,
429 type: "thread.session-set",422 updatedAt: project.updatedAt,
430 payload: {423 })),
431 threadId,424 threads: snapshot.threads.map(toShellThread),
432 session: {425 updatedAt: snapshot.updatedAt,
433 threadId,
434 status: "running",
435 providerName: "codex",
436 runtimeMode: "full-access",
437 activeTurnId: `turn-${threadId}` as TurnId,
438 lastError: null,
439 updatedAt: NOW_ISO,
440 },
441 },
442 };426 };
443}427}
444428
445function sendOrchestrationDomainEvent(event: OrchestrationEvent): void {429function updateThreadSessionInSnapshot(
446 rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event);430 snapshot: OrchestrationReadModel,
431 threadId: ThreadId,
432 session: OrchestrationReadModel["threads"][number]["session"],
433): OrchestrationReadModel {
434 return {
435 ...snapshot,
436 snapshotSequence: snapshot.snapshotSequence + 1,
437 threads: snapshot.threads.map((thread) =>
438 thread.id === threadId
439 ? {
440 ...thread,
441 session,
442 updatedAt: NOW_ISO,
443 }
444 : thread,
445 ),
446 };
447}
448
449function sendShellThreadUpsert(
450 threadId: ThreadId,
451 options?: {
452 readonly session?: OrchestrationReadModel["threads"][number]["session"];
453 },
454): void {
455 const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId);
456 if (!thread) {
457 throw new Error(`Expected thread ${threadId} in snapshot.`);
458 }
459
460 const shellThread =
461 options?.session !== undefined
462 ? toShellThread({ ...thread, session: options.session })
463 : toShellThread(thread);
464 rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, {
465 kind: "thread-upserted",
466 sequence: fixture.snapshot.snapshotSequence,
467 thread: shellThread,
468 });
447}469}
448470
449async function waitForWsClient(): Promise<void> {471async function waitForWsClient(): Promise<void> {
450 await vi.waitFor(472 await vi.waitFor(
451 () => {473 () => {
452 expect(474 expect(
453 wsRequests.some(475 wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell),
454 (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
455 ),
456 ).toBe(true);476 ).toBe(true);
457 expect(477 expect(
458 wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),478 wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),
...@@ -512,15 +532,21 @@ async function waitForAppBootstrap(): Promise<void> {...@@ -512,15 +532,21 @@ async function waitForAppBootstrap(): Promise<void> {
512async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {532async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
513 await waitForWsClient();533 await waitForWsClient();
514 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);534 fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId);
515 sendOrchestrationDomainEvent(535 fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null);
516 createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence),536 sendShellThreadUpsert(threadId, { session: null });
517 );
518}537}
519538
520async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> {539async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
521 sendOrchestrationDomainEvent(540 fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, {
522 createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1),541 threadId,
523 );542 status: "running",
543 providerName: "codex",
544 runtimeMode: "full-access",
545 activeTurnId: `turn-${threadId}` as TurnId,
546 lastError: null,
547 updatedAt: NOW_ISO,
548 });
549 sendShellThreadUpsert(threadId);
524}550}
525551
526async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {552async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> {
...@@ -871,9 +897,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -871,9 +897,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
871 return customResult;897 return customResult;
872 }898 }
873 const tag = body._tag;899 const tag = body._tag;
874 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
875 return fixture.snapshot;
876 }
877 if (tag === WS_METHODS.serverGetConfig) {900 if (tag === WS_METHODS.serverGetConfig) {
878 return fixture.serverConfig;901 return fixture.serverConfig;
879 }902 }
...@@ -1469,6 +1492,28 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1469,6 +1492,28 @@ describe("ChatView timeline estimator parity (full app)", () => {
1469 },1492 },
1470 ];1493 ];
1471 }1494 }
1495 if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) {
1496 return [
1497 {
1498 kind: "snapshot",
1499 snapshot: toShellSnapshot(fixture.snapshot),
1500 },
1501 ];
1502 }
1503 if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) {
1504 const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId);
1505 return thread
1506 ? [
1507 {
1508 kind: "snapshot",
1509 snapshot: {
1510 snapshotSequence: fixture.snapshot.snapshotSequence,
1511 thread,
1512 },
1513 },
1514 ]
1515 : [];
1516 }
1472 return [];1517 return [];
1473 },1518 },
1474 });1519 });
...@@ -1510,7 +1555,6 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1510,7 +1555,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
1510 customWsRpcResolver = null;1555 customWsRpcResolver = null;
1511 document.body.innerHTML = "";1556 document.body.innerHTML = "";
1512 });1557 });
1513
1514 it("re-expands the bootstrap project using its scoped key", async () => {1558 it("re-expands the bootstrap project using its scoped key", async () => {
1515 useUiStateStore.setState({1559 useUiStateStore.setState({
1516 projectExpandedById: {1560 projectExpandedById: {
apps/web/src/components/ChatView.logic.test.ts+136
...@@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
530 ).toBe(true);530 ).toBe(true);
531 });531 });
532532
533 it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => {
534 const localDispatch = createLocalDispatchSnapshot({
535 id: ThreadId.make("thread-1"),
536 environmentId: localEnvironmentId,
537 codexThreadId: null,
538 projectId,
539 title: "Thread",
540 modelSelection: { provider: "codex", model: "gpt-5.4" },
541 runtimeMode: "full-access",
542 interactionMode: "default",
543 session: previousSession,
544 messages: [],
545 proposedPlans: [],
546 error: null,
547 createdAt: "2026-03-29T00:00:00.000Z",
548 archivedAt: null,
549 updatedAt: "2026-03-29T00:00:10.000Z",
550 latestTurn: previousLatestTurn,
551 branch: null,
552 worktreePath: null,
553 turnDiffSummaries: [],
554 activities: [],
555 });
556
557 expect(
558 hasServerAcknowledgedLocalDispatch({
559 localDispatch,
560 phase: "running",
561 latestTurn: previousLatestTurn,
562 session: {
563 ...previousSession,
564 status: "running",
565 orchestrationStatus: "running",
566 activeTurnId: TurnId.make("turn-2"),
567 updatedAt: "2026-03-29T00:01:00.000Z",
568 },
569 hasPendingApproval: false,
570 hasPendingUserInput: false,
571 threadError: null,
572 }),
573 ).toBe(false);
574 });
575
576 it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => {
577 const localDispatch = createLocalDispatchSnapshot({
578 id: ThreadId.make("thread-1"),
579 environmentId: localEnvironmentId,
580 codexThreadId: null,
581 projectId,
582 title: "Thread",
583 modelSelection: { provider: "codex", model: "gpt-5.4" },
584 runtimeMode: "full-access",
585 interactionMode: "default",
586 session: previousSession,
587 messages: [],
588 proposedPlans: [],
589 error: null,
590 createdAt: "2026-03-29T00:00:00.000Z",
591 archivedAt: null,
592 updatedAt: "2026-03-29T00:00:10.000Z",
593 latestTurn: previousLatestTurn,
594 branch: null,
595 worktreePath: null,
596 turnDiffSummaries: [],
597 activities: [],
598 });
599
600 expect(
601 hasServerAcknowledgedLocalDispatch({
602 localDispatch,
603 phase: "running",
604 latestTurn: previousLatestTurn,
605 session: {
606 ...previousSession,
607 status: "running",
608 orchestrationStatus: "running",
609 activeTurnId: undefined,
610 updatedAt: "2026-03-29T00:01:00.000Z",
611 },
612 hasPendingApproval: false,
613 hasPendingUserInput: false,
614 threadError: null,
615 }),
616 ).toBe(false);
617 });
618
619 it("clears local dispatch once the running latestTurn matches the active session turn", () => {
620 const localDispatch = createLocalDispatchSnapshot({
621 id: ThreadId.make("thread-1"),
622 environmentId: localEnvironmentId,
623 codexThreadId: null,
624 projectId,
625 title: "Thread",
626 modelSelection: { provider: "codex", model: "gpt-5.4" },
627 runtimeMode: "full-access",
628 interactionMode: "default",
629 session: previousSession,
630 messages: [],
631 proposedPlans: [],
632 error: null,
633 createdAt: "2026-03-29T00:00:00.000Z",
634 archivedAt: null,
635 updatedAt: "2026-03-29T00:00:10.000Z",
636 latestTurn: previousLatestTurn,
637 branch: null,
638 worktreePath: null,
639 turnDiffSummaries: [],
640 activities: [],
641 });
642
643 expect(
644 hasServerAcknowledgedLocalDispatch({
645 localDispatch,
646 phase: "running",
647 latestTurn: {
648 ...previousLatestTurn,
649 turnId: TurnId.make("turn-2"),
650 state: "running",
651 requestedAt: "2026-03-29T00:01:00.000Z",
652 startedAt: "2026-03-29T00:01:01.000Z",
653 completedAt: null,
654 },
655 session: {
656 ...previousSession,
657 status: "running",
658 orchestrationStatus: "running",
659 activeTurnId: TurnId.make("turn-2"),
660 updatedAt: "2026-03-29T00:01:01.000Z",
661 },
662 hasPendingApproval: false,
663 hasPendingUserInput: false,
664 threadError: null,
665 }),
666 ).toBe(true);
667 });
668
533 it("clears local dispatch when the session changes without an observed running phase", () => {669 it("clears local dispatch when the session changes without an observed running phase", () => {
534 const localDispatch = createLocalDispatchSnapshot({670 const localDispatch = createLocalDispatchSnapshot({
535 id: ThreadId.make("thread-1"),671 id: ThreadId.make("thread-1"),
apps/web/src/components/ChatView.logic.ts+23-9
...@@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: {
322 if (!input.localDispatch) {322 if (!input.localDispatch) {
323 return false;323 return false;
324 }324 }
325 if (325 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
326 input.phase === "running" ||
327 input.hasPendingApproval ||
328 input.hasPendingUserInput ||
329 Boolean(input.threadError)
330 ) {
331 return true;326 return true;
332 }327 }
333328
334 const latestTurn = input.latestTurn ?? null;329 const latestTurn = input.latestTurn ?? null;
335 const session = input.session ?? null;330 const session = input.session ?? null;
336331 const latestTurnChanged =
337 return (
338 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||332 input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
339 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||333 input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
340 input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||334 input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
341 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) ||335 input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);
336
337 if (input.phase === "running") {
338 if (!latestTurnChanged) {
339 return false;
340 }
341 if (latestTurn?.startedAt === null || latestTurn === null) {
342 return false;
343 }
344 if (
345 session?.activeTurnId !== undefined &&
346 session.activeTurnId !== null &&
347 latestTurn?.turnId !== session.activeTurnId
348 ) {
349 return false;
350 }
351 return true;
352 }
353
354 return (
355 latestTurnChanged ||
342 input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||356 input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) ||
343 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)357 input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null)
344 );358 );
apps/web/src/components/ChatView.tsx+8
...@@ -169,6 +169,7 @@ import {...@@ -169,6 +169,7 @@ import {
169 useServerKeybindings,169 useServerKeybindings,
170} from "~/rpc/serverState";170} from "~/rpc/serverState";
171import { sanitizeThreadErrorMessage } from "~/rpc/transportError";171import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
172import { retainThreadDetailSubscription } from "../environments/runtime/service";
172173
173const IMAGE_ONLY_BOOTSTRAP_PROMPT =174const IMAGE_ONLY_BOOTSTRAP_PROMPT =
174 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";175 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
...@@ -825,6 +826,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -825,6 +826,13 @@ export default function ChatView(props: ChatViewProps) {
825 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),826 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
826 );827 );
827828
829 useEffect(() => {
830 if (routeKind !== "server") {
831 return;
832 }
833 return retainThreadDetailSubscription(environmentId, threadId);
834 }, [environmentId, routeKind, threadId]);
835
828 // Compute the list of environments this logical project spans, used to836 // Compute the list of environments this logical project spans, used to
829 // drive the environment picker in BranchToolbar.837 // drive the environment picker in BranchToolbar.
830 const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments));838 const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments));
apps/web/src/components/KeybindingsToast.browser.tsx+59-3
...@@ -169,6 +169,43 @@ function createMinimalSnapshot(): OrchestrationReadModel {...@@ -169,6 +169,43 @@ function createMinimalSnapshot(): OrchestrationReadModel {
169 };169 };
170}170}
171171
172function toShellSnapshot(snapshot: OrchestrationReadModel) {
173 return {
174 snapshotSequence: snapshot.snapshotSequence,
175 projects: snapshot.projects.map((project) => ({
176 id: project.id,
177 title: project.title,
178 workspaceRoot: project.workspaceRoot,
179 repositoryIdentity: project.repositoryIdentity ?? null,
180 defaultModelSelection: project.defaultModelSelection,
181 scripts: project.scripts,
182 createdAt: project.createdAt,
183 updatedAt: project.updatedAt,
184 })),
185 threads: snapshot.threads.map((thread) => ({
186 id: thread.id,
187 projectId: thread.projectId,
188 title: thread.title,
189 modelSelection: thread.modelSelection,
190 runtimeMode: thread.runtimeMode,
191 interactionMode: thread.interactionMode,
192 branch: thread.branch,
193 worktreePath: thread.worktreePath,
194 latestTurn: thread.latestTurn,
195 createdAt: thread.createdAt,
196 updatedAt: thread.updatedAt,
197 archivedAt: thread.archivedAt,
198 session: thread.session,
199 latestUserMessageAt:
200 thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null,
201 hasPendingApprovals: false,
202 hasPendingUserInput: false,
203 hasActionableProposedPlan: false,
204 })),
205 updatedAt: snapshot.updatedAt,
206 };
207}
208
172function buildFixture(): TestFixture {209function buildFixture(): TestFixture {
173 return {210 return {
174 snapshot: createMinimalSnapshot(),211 snapshot: createMinimalSnapshot(),
...@@ -190,9 +227,6 @@ function buildFixture(): TestFixture {...@@ -190,9 +227,6 @@ function buildFixture(): TestFixture {
190}227}
191228
192function resolveWsRpc(tag: string): unknown {229function resolveWsRpc(tag: string): unknown {
193 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
194 return fixture.snapshot;
195 }
196 if (tag === WS_METHODS.serverGetConfig) {230 if (tag === WS_METHODS.serverGetConfig) {
197 return fixture.serverConfig;231 return fixture.serverConfig;
198 }232 }
...@@ -429,6 +463,28 @@ describe("Keybindings update toast", () => {...@@ -429,6 +463,28 @@ describe("Keybindings update toast", () => {
429 },463 },
430 ];464 ];
431 }465 }
466 if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) {
467 return [
468 {
469 kind: "snapshot",
470 snapshot: toShellSnapshot(fixture.snapshot),
471 },
472 ];
473 }
474 if (
475 request._tag === ORCHESTRATION_WS_METHODS.subscribeThread &&
476 request.threadId === THREAD_ID
477 ) {
478 return [
479 {
480 kind: "snapshot",
481 snapshot: {
482 snapshotSequence: fixture.snapshot.snapshotSequence,
483 thread: fixture.snapshot.threads[0],
484 },
485 },
486 ];
487 }
432 return [];488 return [];
433 },489 },
434 });490 });
apps/web/src/environmentApi.ts+4-7
...@@ -32,16 +32,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -32,16 +32,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
32 preparePullRequestThread: rpcClient.git.preparePullRequestThread,32 preparePullRequestThread: rpcClient.git.preparePullRequestThread,
33 },33 },
34 orchestration: {34 orchestration: {
35 getSnapshot: rpcClient.orchestration.getSnapshot,
36 dispatchCommand: rpcClient.orchestration.dispatchCommand,35 dispatchCommand: rpcClient.orchestration.dispatchCommand,
37 getTurnDiff: rpcClient.orchestration.getTurnDiff,36 getTurnDiff: rpcClient.orchestration.getTurnDiff,
38 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,37 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
39 replayEvents: (fromSequenceExclusive) =>38 subscribeShell: (callback, options) =>
40 rpcClient.orchestration39 rpcClient.orchestration.subscribeShell(callback, options),
41 .replayEvents({ fromSequenceExclusive })40 subscribeThread: (input, callback, options) =>
42 .then((events) => [...events]),41 rpcClient.orchestration.subscribeThread(input, callback, options),
43 onDomainEvent: (callback, options) =>
44 rpcClient.orchestration.onDomainEvent(callback, options),
45 },42 },
46 };43 };
47}44}
apps/web/src/environments/primary/auth.ts+19-5
...@@ -57,6 +57,7 @@ type ServerAuthGateState =...@@ -57,6 +57,7 @@ type ServerAuthGateState =
57 };57 };
5858
59let bootstrapPromise: Promise<ServerAuthGateState> | null = null;59let bootstrapPromise: Promise<ServerAuthGateState> | null = null;
60let resolvedAuthenticatedGateState: ServerAuthGateState | null = null;
60const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;61const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;
61const AUTH_SESSION_ESTABLISH_STEP_MS = 100;62const AUTH_SESSION_ESTABLISH_STEP_MS = 100;
6263
...@@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo...@@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
224 throw new Error("Enter a pairing token to continue.");225 throw new Error("Enter a pairing token to continue.");
225 }226 }
226227
228 resolvedAuthenticatedGateState = null;
227 await exchangeBootstrapCredential(trimmedCredential);229 await exchangeBootstrapCredential(trimmedCredential);
228 bootstrapPromise = null;230 bootstrapPromise = null;
229 stripPairingTokenFromUrl();231 stripPairingTokenFromUrl();
...@@ -341,19 +343,31 @@ export async function revokeOtherServerClientSessions(): Promise<number> {...@@ -341,19 +343,31 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
341}343}
342344
343export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {345export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
346 if (resolvedAuthenticatedGateState?.status === "authenticated") {
347 return resolvedAuthenticatedGateState;
348 }
349
344 if (bootstrapPromise) {350 if (bootstrapPromise) {
345 return bootstrapPromise;351 return bootstrapPromise;
346 }352 }
347353
348 const nextPromise = bootstrapServerAuth();354 const nextPromise = bootstrapServerAuth();
349 bootstrapPromise = nextPromise;355 bootstrapPromise = nextPromise;
350 return nextPromise.finally(() => {356 return nextPromise
351 if (bootstrapPromise === nextPromise) {357 .then((result) => {
352 bootstrapPromise = null;358 if (result.status === "authenticated") {
353 }359 resolvedAuthenticatedGateState = result;
354 });360 }
361 return result;
362 })
363 .finally(() => {
364 if (bootstrapPromise === nextPromise) {
365 bootstrapPromise = null;
366 }
367 });
355}368}
356369
357export function __resetServerAuthBootstrapForTests() {370export function __resetServerAuthBootstrapForTests() {
358 bootstrapPromise = null;371 bootstrapPromise = null;
372 resolvedAuthenticatedGateState = null;
359}373}
apps/web/src/environments/runtime/connection.test.ts+67-165
...@@ -4,29 +4,18 @@ import { describe, expect, it, vi } from "vitest";...@@ -4,29 +4,18 @@ import { describe, expect, it, vi } from "vitest";
4import { createEnvironmentConnection } from "./connection";4import { createEnvironmentConnection } from "./connection";
5import type { WsRpcClient } from "~/rpc/wsRpcClient";5import type { WsRpcClient } from "~/rpc/wsRpcClient";
66
7function createTestClient(options?: {7function createTestClient() {
8 readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>;
9 readonly replayEvents?: () => Promise<ReadonlyArray<any>>;
10}) {
11 const lifecycleListeners = new Set<(event: any) => void>();8 const lifecycleListeners = new Set<(event: any) => void>();
12 const configListeners = new Set<(event: any) => void>();9 const configListeners = new Set<(event: any) => void>();
13 const terminalListeners = new Set<(event: any) => void>();10 const terminalListeners = new Set<(event: any) => void>();
14 let domainResubscribe: (() => void) | undefined;11 const shellListeners = new Set<(event: any) => void>();
1512 let shellResubscribe: (() => void) | undefined;
16 const getSnapshot = vi.fn(
17 options?.getSnapshot ??
18 (async () =>
19 ({
20 snapshotSequence: 1,
21 projects: [],
22 threads: [],
23 }) as any),
24 );
25 const replayEvents = vi.fn(options?.replayEvents ?? (async () => []));
2613
27 const client = {14 const client = {
28 dispose: vi.fn(async () => undefined),15 dispose: vi.fn(async () => undefined),
29 reconnect: vi.fn(async () => undefined),16 reconnect: vi.fn(async () => {
17 shellResubscribe?.();
18 }),
30 server: {19 server: {
31 getConfig: vi.fn(async () => ({20 getConfig: vi.fn(async () => ({
32 environment: {21 environment: {
...@@ -48,19 +37,33 @@ function createTestClient(options?: {...@@ -48,19 +37,33 @@ function createTestClient(options?: {
48 updateSettings: vi.fn(async () => undefined),37 updateSettings: vi.fn(async () => undefined),
49 },38 },
50 orchestration: {39 orchestration: {
51 getSnapshot,
52 dispatchCommand: vi.fn(async () => undefined),40 dispatchCommand: vi.fn(async () => undefined),
53 getTurnDiff: vi.fn(async () => undefined),41 getTurnDiff: vi.fn(async () => undefined),
54 getFullThreadDiff: vi.fn(async () => undefined),42 getFullThreadDiff: vi.fn(async () => undefined),
55 replayEvents,43 subscribeShell: vi.fn(
56 onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => {44 (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => {
57 domainResubscribe = options?.onResubscribe;45 shellListeners.add(listener);
58 return () => {46 shellResubscribe = options?.onResubscribe;
59 if (domainResubscribe === options?.onResubscribe) {47 queueMicrotask(() => {
60 domainResubscribe = undefined;48 listener({
61 }49 kind: "snapshot",
62 };50 snapshot: {
63 }),51 snapshotSequence: 1,
52 projects: [],
53 threads: [],
54 updatedAt: "2026-04-12T00:00:00.000Z",
55 },
56 });
57 });
58 return () => {
59 shellListeners.delete(listener);
60 if (shellResubscribe === options?.onResubscribe) {
61 shellResubscribe = undefined;
62 }
63 };
64 },
65 ),
66 subscribeThread: vi.fn(() => () => undefined),
64 },67 },
65 terminal: {68 terminal: {
66 open: vi.fn(async () => undefined),69 open: vi.fn(async () => undefined),
...@@ -99,8 +102,6 @@ function createTestClient(options?: {...@@ -99,8 +102,6 @@ function createTestClient(options?: {
99102
100 return {103 return {
101 client,104 client,
102 getSnapshot,
103 replayEvents,
104 emitWelcome: (environmentId: EnvironmentId) => {105 emitWelcome: (environmentId: EnvironmentId) => {
105 for (const listener of lifecycleListeners) {106 for (const listener of lifecycleListeners) {
106 listener({107 listener({
...@@ -125,17 +126,27 @@ function createTestClient(options?: {...@@ -125,17 +126,27 @@ function createTestClient(options?: {
125 });126 });
126 }127 }
127 },128 },
128 triggerDomainResubscribe: () => {129 emitShellSnapshot: (snapshotSequence: number) => {
129 domainResubscribe?.();130 for (const listener of shellListeners) {
131 listener({
132 kind: "snapshot",
133 snapshot: {
134 snapshotSequence,
135 projects: [],
136 threads: [],
137 updatedAt: "2026-04-12T00:00:00.000Z",
138 },
139 });
140 }
130 },141 },
131 };142 };
132}143}
133144
134describe("createEnvironmentConnection", () => {145describe("createEnvironmentConnection", () => {
135 it("bootstraps a snapshot immediately for a new connection", async () => {146 it("bootstraps from the shell subscription snapshot", async () => {
136 const environmentId = EnvironmentId.make("env-1");147 const environmentId = EnvironmentId.make("env-1");
137 const { client, getSnapshot } = createTestClient();148 const { client } = createTestClient();
138 const syncSnapshot = vi.fn();149 const syncShellSnapshot = vi.fn();
139150
140 const connection = createEnvironmentConnection({151 const connection = createEnvironmentConnection({
141 kind: "saved",152 kind: "saved",
...@@ -150,16 +161,14 @@ describe("createEnvironmentConnection", () => {...@@ -150,16 +161,14 @@ describe("createEnvironmentConnection", () => {
150 environmentId,161 environmentId,
151 },162 },
152 client,163 client,
153 applyEventBatch: vi.fn(),164 applyShellEvent: vi.fn(),
154 syncSnapshot,165 syncShellSnapshot,
155 applyTerminalEvent: vi.fn(),166 applyTerminalEvent: vi.fn(),
156 });167 });
157168
158 await Promise.resolve();169 await connection.ensureBootstrapped();
159 await Promise.resolve();
160170
161 expect(getSnapshot).toHaveBeenCalledTimes(1);171 expect(syncShellSnapshot).toHaveBeenCalledWith(
162 expect(syncSnapshot).toHaveBeenCalledWith(
163 expect.objectContaining({ snapshotSequence: 1 }),172 expect.objectContaining({ snapshotSequence: 1 }),
164 environmentId,173 environmentId,
165 );174 );
...@@ -184,8 +193,8 @@ describe("createEnvironmentConnection", () => {...@@ -184,8 +193,8 @@ describe("createEnvironmentConnection", () => {
184 environmentId,193 environmentId,
185 },194 },
186 client,195 client,
187 applyEventBatch: vi.fn(),196 applyShellEvent: vi.fn(),
188 syncSnapshot: vi.fn(),197 syncShellSnapshot: vi.fn(),
189 applyTerminalEvent: vi.fn(),198 applyTerminalEvent: vi.fn(),
190 });199 });
191200
...@@ -196,58 +205,10 @@ describe("createEnvironmentConnection", () => {...@@ -196,58 +205,10 @@ describe("createEnvironmentConnection", () => {
196 await connection.dispose();205 await connection.dispose();
197 });206 });
198207
199 it("rejects ensureBootstrapped when snapshot recovery fails", async () => {208 it("waits for a fresh shell snapshot after reconnect", async () => {
200 const environmentId = EnvironmentId.make("env-1");209 const environmentId = EnvironmentId.make("env-1");
201 const snapshotError = new Error("snapshot failed");210 const { client, emitShellSnapshot } = createTestClient();
202 const { client } = createTestClient({211 const syncShellSnapshot = vi.fn();
203 getSnapshot: async () => {
204 throw snapshotError;
205 },
206 });
207
208 const connection = createEnvironmentConnection({
209 kind: "saved",
210 knownEnvironment: {
211 id: "env-1",
212 label: "Remote env",
213 source: "manual",
214 target: {
215 httpBaseUrl: "http://example.test",
216 wsBaseUrl: "ws://example.test",
217 },
218 environmentId,
219 },
220 client,
221 applyEventBatch: vi.fn(),
222 syncSnapshot: vi.fn(),
223 applyTerminalEvent: vi.fn(),
224 });
225
226 await expect(connection.ensureBootstrapped()).rejects.toThrow("snapshot failed");
227
228 await connection.dispose();
229 });
230
231 it("retries replay recovery after transport disconnects during resubscribe", async () => {
232 const environmentId = EnvironmentId.make("env-1");
233 let replayAttempts = 0;
234 const applyEventBatch = vi.fn();
235 const { client, replayEvents, triggerDomainResubscribe } = createTestClient({
236 replayEvents: async () => {
237 replayAttempts += 1;
238 if (replayAttempts === 1) {
239 throw new Error("SocketCloseError: 1006");
240 }
241
242 return [
243 {
244 sequence: 2,
245 type: "thread.created",
246 payload: {},
247 },
248 ];
249 },
250 });
251212
252 const connection = createEnvironmentConnection({213 const connection = createEnvironmentConnection({
253 kind: "saved",214 kind: "saved",
...@@ -262,85 +223,26 @@ describe("createEnvironmentConnection", () => {...@@ -262,85 +223,26 @@ describe("createEnvironmentConnection", () => {
262 environmentId,223 environmentId,
263 },224 },
264 client,225 client,
265 applyEventBatch,226 applyShellEvent: vi.fn(),
266 syncSnapshot: vi.fn(),227 syncShellSnapshot,
267 applyTerminalEvent: vi.fn(),228 applyTerminalEvent: vi.fn(),
268 });229 });
269230
270 await Promise.resolve();231 await connection.ensureBootstrapped();
271 await Promise.resolve();
272
273 triggerDomainResubscribe();
274
275 await vi.waitFor(() => {
276 expect(replayEvents).toHaveBeenCalledTimes(2);
277 expect(applyEventBatch).toHaveBeenCalledWith(
278 [
279 expect.objectContaining({
280 sequence: 2,
281 }),
282 ],
283 environmentId,
284 );
285 });
286
287 await connection.dispose();
288 });
289 it("swallows replay recovery failures triggered by resubscribe", async () => {
290 const environmentId = EnvironmentId.make("env-1");
291 const snapshotError = new Error("snapshot failed");
292 let snapshotCalls = 0;
293 const { client, triggerDomainResubscribe } = createTestClient({
294 getSnapshot: async () => {
295 snapshotCalls += 1;
296 if (snapshotCalls === 1) {
297 return {
298 snapshotSequence: 1,
299 projects: [],
300 threads: [],
301 } as any;
302 }
303
304 throw snapshotError;
305 },
306 replayEvents: async () => {
307 throw new Error("SocketCloseError: 1006");
308 },
309 });
310
311 const connection = createEnvironmentConnection({
312 kind: "saved",
313 knownEnvironment: {
314 id: "env-1",
315 label: "Remote env",
316 source: "manual",
317 target: {
318 httpBaseUrl: "http://example.test",
319 wsBaseUrl: "ws://example.test",
320 },
321 environmentId,
322 },
323 client,
324 applyEventBatch: vi.fn(),
325 syncSnapshot: vi.fn(),
326 applyTerminalEvent: vi.fn(),
327 });
328232
233 const reconnectPromise = connection.reconnect();
329 await Promise.resolve();234 await Promise.resolve();
330 await Promise.resolve();235 expect(syncShellSnapshot).toHaveBeenCalledTimes(1);
331
332 const onUnhandledRejection = vi.fn();
333 process.on("unhandledRejection", onUnhandledRejection);
334236
335 try {237 emitShellSnapshot(2);
336 triggerDomainResubscribe();238 await reconnectPromise;
337 await new Promise((resolve) => setTimeout(resolve, 0));
338 await new Promise((resolve) => setTimeout(resolve, 0));
339 } finally {
340 process.off("unhandledRejection", onUnhandledRejection);
341 }
342239
343 expect(onUnhandledRejection).not.toHaveBeenCalled();240 expect(client.reconnect).toHaveBeenCalledTimes(1);
241 expect(syncShellSnapshot).toHaveBeenCalledTimes(2);
242 expect(syncShellSnapshot).toHaveBeenLastCalledWith(
243 expect.objectContaining({ snapshotSequence: 2 }),
244 environmentId,
245 );
344246
345 await connection.dispose();247 await connection.dispose();
346 });248 });
apps/web/src/environments/runtime/connection.ts+49-210
...@@ -1,29 +1,15 @@...@@ -1,29 +1,15 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 OrchestrationEvent,3 OrchestrationShellSnapshot,
4 OrchestrationReadModel,4 OrchestrationShellStreamEvent,
5 ServerConfig,5 ServerConfig,
6 ServerLifecycleWelcomePayload,6 ServerLifecycleWelcomePayload,
7 TerminalEvent,7 TerminalEvent,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { KnownEnvironment } from "@t3tools/client-runtime";9import type { KnownEnvironment } from "@t3tools/client-runtime";
1010
11import {
12 deriveReplayRetryDecision,
13 type OrchestrationRecoveryReason,
14} from "../../orchestrationRecovery";
15import {
16 createOrchestrationRecoveryCoordinator,
17 type ReplayRetryTracker,
18} from "../../orchestrationRecovery";
19import { isTransportConnectionErrorMessage } from "~/rpc/transportError";
20import type { WsRpcClient } from "~/rpc/wsRpcClient";11import type { WsRpcClient } from "~/rpc/wsRpcClient";
2112
22const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
23const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;
24const RECOVERY_TRANSPORT_RETRY_DELAY_MS = 250;
25const MAX_RECOVERY_TRANSPORT_RETRIES = 20;
26
27export interface EnvironmentConnection {13export interface EnvironmentConnection {
28 readonly kind: "primary" | "saved";14 readonly kind: "primary" | "saved";
29 readonly environmentId: EnvironmentId;15 readonly environmentId: EnvironmentId;
...@@ -35,11 +21,14 @@ export interface EnvironmentConnection {...@@ -35,11 +21,14 @@ export interface EnvironmentConnection {
35}21}
3622
37interface OrchestrationHandlers {23interface OrchestrationHandlers {
38 readonly applyEventBatch: (24 readonly applyShellEvent: (
39 events: ReadonlyArray<OrchestrationEvent>,25 event: OrchestrationShellStreamEvent,
26 environmentId: EnvironmentId,
27 ) => void;
28 readonly syncShellSnapshot: (
29 snapshot: OrchestrationShellSnapshot,
40 environmentId: EnvironmentId,30 environmentId: EnvironmentId,
41 ) => void;31 ) => void;
42 readonly syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => void;
43 readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void;32 readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void;
44}33}
4534
...@@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers {...@@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers {
52 readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void;41 readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void;
53}42}
5443
55function createSnapshotBootstrapController(input: {44function createBootstrapGate() {
56 readonly isBootstrapped: () => boolean;45 let resolve: (() => void) | null = null;
57 readonly runSnapshotRecovery: (46 let reject: ((error: unknown) => void) | null = null;
58 reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">,47 let promise = new Promise<void>((nextResolve, nextReject) => {
59 ) => Promise<void>;48 resolve = nextResolve;
60}) {49 reject = nextReject;
61 let inFlight: Promise<void> | null = null;50 });
6251
63 return {52 return {
64 ensureSnapshotRecovery(53 wait: () => promise,
65 reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">,54 resolve: () => {
66 ): Promise<void> {55 resolve?.();
67 if (input.isBootstrapped()) {56 resolve = null;
68 return Promise.resolve();57 reject = null;
69 }58 },
7059 reject: (error: unknown) => {
71 if (inFlight !== null) {60 reject?.(error);
72 return inFlight;61 resolve = null;
73 }62 reject = null;
7463 },
75 const nextInFlight = input.runSnapshotRecovery(reason).finally(() => {64 reset: () => {
76 if (inFlight === nextInFlight) {65 promise = new Promise<void>((nextResolve, nextReject) => {
77 inFlight = null;66 resolve = nextResolve;
78 }67 reject = nextReject;
79 });68 });
80 inFlight = nextInFlight;
81 return inFlight;
82 },69 },
83 };70 };
84}71}
...@@ -86,10 +73,6 @@ function createSnapshotBootstrapController(input: {...@@ -86,10 +73,6 @@ function createSnapshotBootstrapController(input: {
86export function createEnvironmentConnection(73export function createEnvironmentConnection(
87 input: EnvironmentConnectionInput,74 input: EnvironmentConnectionInput,
88): EnvironmentConnection {75): EnvironmentConnection {
89 const recovery = createOrchestrationRecoveryCoordinator();
90 let replayRetryTracker: ReplayRetryTracker | null = null;
91 const pendingDomainEvents: OrchestrationEvent[] = [];
92 let flushPendingDomainEventsScheduled = false;
93 const environmentId = input.knownEnvironment.environmentId;76 const environmentId = input.knownEnvironment.environmentId;
9477
95 if (!environmentId) {78 if (!environmentId) {
...@@ -99,6 +82,7 @@ export function createEnvironmentConnection(...@@ -99,6 +82,7 @@ export function createEnvironmentConnection(
99 }82 }
10083
101 let disposed = false;84 let disposed = false;
85 const bootstrapGate = createBootstrapGate();
10286
103 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {87 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {
104 if (environmentId !== nextEnvironmentId) {88 if (environmentId !== nextEnvironmentId) {
...@@ -108,148 +92,6 @@ export function createEnvironmentConnection(...@@ -108,148 +92,6 @@ export function createEnvironmentConnection(
108 }92 }
109 };93 };
11094
111 const flushPendingDomainEvents = () => {
112 flushPendingDomainEventsScheduled = false;
113 if (disposed || pendingDomainEvents.length === 0) {
114 return;
115 }
116
117 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
118 const nextEvents = recovery.markEventBatchApplied(events);
119 if (nextEvents.length === 0) {
120 return;
121 }
122 input.applyEventBatch(nextEvents, environmentId);
123 };
124
125 const schedulePendingDomainEventFlush = () => {
126 if (flushPendingDomainEventsScheduled) {
127 return;
128 }
129
130 flushPendingDomainEventsScheduled = true;
131 queueMicrotask(flushPendingDomainEvents);
132 };
133
134 const retryTransportRecoveryOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
135 for (let attempt = 0; ; attempt += 1) {
136 try {
137 return await operation();
138 } catch (error) {
139 const message = error instanceof Error ? error.message : String(error);
140 if (
141 disposed ||
142 !isTransportConnectionErrorMessage(message) ||
143 attempt >= MAX_RECOVERY_TRANSPORT_RETRIES - 1
144 ) {
145 throw error;
146 }
147
148 await new Promise<void>((resolve) => {
149 setTimeout(resolve, RECOVERY_TRANSPORT_RETRY_DELAY_MS);
150 });
151
152 if (disposed) {
153 throw error;
154 }
155 }
156 }
157 };
158 const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => {
159 void runReplayRecovery(reason).catch(() => undefined);
160 };
161
162 const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => {
163 if (!recovery.beginReplayRecovery(reason)) {
164 return;
165 }
166
167 const fromSequenceExclusive = recovery.getState().latestSequence;
168 try {
169 const events = await retryTransportRecoveryOperation(() =>
170 input.client.orchestration.replayEvents({ fromSequenceExclusive }),
171 );
172 if (!disposed) {
173 const nextEvents = recovery.markEventBatchApplied(events);
174 if (nextEvents.length > 0) {
175 input.applyEventBatch(nextEvents, environmentId);
176 }
177 }
178 } catch {
179 replayRetryTracker = null;
180 recovery.failReplayRecovery();
181 if (disposed) {
182 return;
183 }
184 await snapshotBootstrap.ensureSnapshotRecovery("replay-failed");
185 return;
186 }
187
188 if (disposed) {
189 return;
190 }
191
192 const replayCompletion = recovery.completeReplayRecovery();
193 const retryDecision = deriveReplayRetryDecision({
194 previousTracker: replayRetryTracker,
195 completion: replayCompletion,
196 recoveryState: recovery.getState(),
197 baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS,
198 maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES,
199 });
200 replayRetryTracker = retryDecision.tracker;
201
202 if (retryDecision.shouldRetry) {
203 if (retryDecision.delayMs > 0) {
204 await new Promise<void>((resolve) => {
205 setTimeout(resolve, retryDecision.delayMs);
206 });
207 if (disposed) {
208 return;
209 }
210 }
211 scheduleReplayRecovery(reason);
212 } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") {
213 console.warn(
214 "[orchestration-recovery]",
215 "Stopping replay recovery after no-progress retries.",
216 {
217 environmentId,
218 state: recovery.getState(),
219 },
220 );
221 }
222 };
223
224 const runSnapshotRecovery = async (
225 reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">,
226 ): Promise<void> => {
227 const started = recovery.beginSnapshotRecovery(reason);
228 if (!started) {
229 return;
230 }
231
232 try {
233 const snapshot = await retryTransportRecoveryOperation(() =>
234 input.client.orchestration.getSnapshot(),
235 );
236 if (!disposed) {
237 input.syncSnapshot(snapshot, environmentId);
238 if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
239 scheduleReplayRecovery("sequence-gap");
240 }
241 }
242 } catch (error) {
243 recovery.failSnapshotRecovery();
244 throw error;
245 }
246 };
247
248 const snapshotBootstrap = createSnapshotBootstrapController({
249 isBootstrapped: () => recovery.getState().bootstrapped,
250 runSnapshotRecovery,
251 });
252
253 const unsubLifecycle = input.client.server.subscribeLifecycle(95 const unsubLifecycle = input.client.server.subscribeLifecycle(
254 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {96 (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {
255 if (event.type !== "welcome") {97 if (event.type !== "welcome") {
...@@ -273,26 +115,21 @@ export function createEnvironmentConnection(...@@ -273,26 +115,21 @@ export function createEnvironmentConnection(
273 },115 },
274 );116 );
275117
276 const unsubDomainEvent = input.client.orchestration.onDomainEvent(118 const unsubShell = input.client.orchestration.subscribeShell(
277 (event: Parameters<Parameters<WsRpcClient["orchestration"]["onDomainEvent"]>[0]>[0]) => {119 (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => {
278 const action = recovery.classifyDomainEvent(event.sequence);120 if (item.kind === "snapshot") {
279 if (action === "apply") {121 input.syncShellSnapshot(item.snapshot, environmentId);
280 pendingDomainEvents.push(event);122 bootstrapGate.resolve();
281 schedulePendingDomainEventFlush();
282 return;123 return;
283 }124 }
284 if (action === "recover") {125 input.applyShellEvent(item, environmentId);
285 flushPendingDomainEvents();
286 scheduleReplayRecovery("sequence-gap");
287 }
288 },126 },
289 {127 {
290 onResubscribe: () => {128 onResubscribe: () => {
291 if (disposed) {129 if (disposed) {
292 return;130 return;
293 }131 }
294 flushPendingDomainEvents();132 bootstrapGate.reset();
295 scheduleReplayRecovery("resubscribe");
296 },133 },
297 },134 },
298 );135 );
...@@ -303,13 +140,9 @@ export function createEnvironmentConnection(...@@ -303,13 +140,9 @@ export function createEnvironmentConnection(
303 },140 },
304 );141 );
305142
306 void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined);
307
308 const cleanup = () => {143 const cleanup = () => {
309 disposed = true;144 disposed = true;
310 flushPendingDomainEventsScheduled = false;145 unsubShell();
311 pendingDomainEvents.length = 0;
312 unsubDomainEvent();
313 unsubTerminalEvent();146 unsubTerminalEvent();
314 unsubLifecycle();147 unsubLifecycle();
315 unsubConfig();148 unsubConfig();
...@@ -320,11 +153,17 @@ export function createEnvironmentConnection(...@@ -320,11 +153,17 @@ export function createEnvironmentConnection(
320 environmentId,153 environmentId,
321 knownEnvironment: input.knownEnvironment,154 knownEnvironment: input.knownEnvironment,
322 client: input.client,155 client: input.client,
323 ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"),156 ensureBootstrapped: () => bootstrapGate.wait(),
324 reconnect: async () => {157 reconnect: async () => {
325 await input.client.reconnect();158 bootstrapGate.reset();
326 await input.refreshMetadata?.();159 try {
327 await snapshotBootstrap.ensureSnapshotRecovery("bootstrap");160 await input.client.reconnect();
161 await input.refreshMetadata?.();
162 await bootstrapGate.wait();
163 } catch (error) {
164 bootstrapGate.reject(error);
165 throw error;
166 }
328 },167 },
329 dispose: async () => {168 dispose: async () => {
330 cleanup();169 cleanup();
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts created+151
...@@ -0,0 +1,151 @@
1import { QueryClient } from "@tanstack/react-query";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5const mockSubscribeThread = vi.fn();
6const mockThreadUnsubscribe = vi.fn();
7const mockCreateEnvironmentConnection = vi.fn();
8const mockCreateWsRpcClient = vi.fn();
9const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
10const mockListSavedEnvironmentRecords = vi.fn();
11const mockSavedEnvironmentRegistrySubscribe = vi.fn();
12
13function MockWsTransport() {
14 return undefined;
15}
16
17vi.mock("../primary", () => ({
18 getPrimaryKnownEnvironment: vi.fn(() => ({
19 id: "env-1",
20 label: "Primary environment",
21 source: "window-origin",
22 target: {
23 httpBaseUrl: "http://127.0.0.1:3000/",
24 wsBaseUrl: "ws://127.0.0.1:3000/",
25 },
26 environmentId: EnvironmentId.make("env-1"),
27 })),
28}));
29
30vi.mock("./catalog", () => ({
31 getSavedEnvironmentRecord: vi.fn(),
32 hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),
33 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
34 persistSavedEnvironmentRecord: vi.fn(),
35 readSavedEnvironmentBearerToken: vi.fn(),
36 removeSavedEnvironmentBearerToken: vi.fn(),
37 useSavedEnvironmentRegistryStore: {
38 subscribe: mockSavedEnvironmentRegistrySubscribe,
39 getState: () => ({
40 upsert: vi.fn(),
41 remove: vi.fn(),
42 markConnected: vi.fn(),
43 }),
44 },
45 useSavedEnvironmentRuntimeStore: {
46 getState: () => ({
47 ensure: vi.fn(),
48 patch: vi.fn(),
49 clear: vi.fn(),
50 }),
51 },
52 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
53 writeSavedEnvironmentBearerToken: vi.fn(),
54}));
55
56vi.mock("./connection", () => ({
57 createEnvironmentConnection: mockCreateEnvironmentConnection,
58}));
59
60vi.mock("../../rpc/wsRpcClient", () => ({
61 createWsRpcClient: mockCreateWsRpcClient,
62}));
63
64vi.mock("../../rpc/wsTransport", () => ({
65 WsTransport: MockWsTransport,
66}));
67
68describe("retainThreadDetailSubscription", () => {
69 beforeEach(() => {
70 vi.useFakeTimers();
71 vi.resetModules();
72 vi.clearAllMocks();
73
74 mockThreadUnsubscribe.mockImplementation(() => undefined);
75 mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe);
76 mockCreateWsRpcClient.mockReturnValue({
77 orchestration: {
78 subscribeThread: mockSubscribeThread,
79 },
80 });
81 mockCreateEnvironmentConnection.mockImplementation((input) => ({
82 kind: input.kind,
83 environmentId: input.knownEnvironment.environmentId,
84 knownEnvironment: input.knownEnvironment,
85 client: input.client,
86 ensureBootstrapped: vi.fn(async () => undefined),
87 reconnect: vi.fn(async () => undefined),
88 dispose: vi.fn(async () => undefined),
89 }));
90 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
91 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
92 mockListSavedEnvironmentRecords.mockReturnValue([]);
93 });
94
95 afterEach(async () => {
96 const { resetEnvironmentServiceForTests } = await import("./service");
97 await resetEnvironmentServiceForTests();
98 vi.useRealTimers();
99 });
100
101 it("keeps thread detail subscriptions warm across releases until idle eviction", async () => {
102 const {
103 retainThreadDetailSubscription,
104 startEnvironmentConnectionService,
105 resetEnvironmentServiceForTests,
106 } = await import("./service");
107
108 const stop = startEnvironmentConnectionService(new QueryClient());
109 const environmentId = EnvironmentId.make("env-1");
110 const threadId = ThreadId.make("thread-1");
111
112 const releaseFirst = retainThreadDetailSubscription(environmentId, threadId);
113 expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
114
115 releaseFirst();
116 expect(mockThreadUnsubscribe).not.toHaveBeenCalled();
117
118 const releaseSecond = retainThreadDetailSubscription(environmentId, threadId);
119 expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
120
121 releaseSecond();
122 await vi.advanceTimersByTimeAsync(2 * 60 * 1000 - 1);
123 expect(mockThreadUnsubscribe).not.toHaveBeenCalled();
124
125 await vi.advanceTimersByTimeAsync(1);
126 expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1);
127
128 stop();
129 await resetEnvironmentServiceForTests();
130 });
131
132 it("disposes cached thread detail subscriptions when the environment service resets", async () => {
133 const {
134 retainThreadDetailSubscription,
135 startEnvironmentConnectionService,
136 resetEnvironmentServiceForTests,
137 } = await import("./service");
138
139 const stop = startEnvironmentConnectionService(new QueryClient());
140 const environmentId = EnvironmentId.make("env-1");
141 const threadId = ThreadId.make("thread-2");
142
143 const release = retainThreadDetailSubscription(environmentId, threadId);
144 release();
145
146 await resetEnvironmentServiceForTests();
147 expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1);
148
149 stop();
150 });
151});
apps/web/src/environments/runtime/service.ts+264-9
...@@ -2,7 +2,8 @@ import {...@@ -2,7 +2,8 @@ import {
2 type AuthSessionRole,2 type AuthSessionRole,
3 type EnvironmentId,3 type EnvironmentId,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5 type OrchestrationReadModel,5 type OrchestrationShellSnapshot,
6 type OrchestrationShellStreamEvent,
6 type ServerConfig,7 type ServerConfig,
7 type TerminalEvent,8 type TerminalEvent,
8 ThreadId,9 ThreadId,
...@@ -68,12 +69,206 @@ type EnvironmentServiceState = {...@@ -68,12 +69,206 @@ type EnvironmentServiceState = {
68 stop: () => void;69 stop: () => void;
69};70};
7071
72type ThreadDetailSubscriptionEntry = {
73 readonly environmentId: EnvironmentId;
74 readonly threadId: ThreadId;
75 unsubscribe: () => void;
76 unsubscribeConnectionListener: (() => void) | null;
77 refCount: number;
78 lastAccessedAt: number;
79 evictionTimeoutId: ReturnType<typeof setTimeout> | null;
80};
81
71const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();82const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
72const environmentConnectionListeners = new Set<() => void>();83const environmentConnectionListeners = new Set<() => void>();
84const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>();
7385
74let activeService: EnvironmentServiceState | null = null;86let activeService: EnvironmentServiceState | null = null;
75let needsProviderInvalidation = false;87let needsProviderInvalidation = false;
7688
89const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000;
90const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 8;
91const NOOP = () => undefined;
92
93function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
94 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
95}
96
97function clearThreadDetailSubscriptionEviction(
98 entry: ThreadDetailSubscriptionEntry,
99): ThreadDetailSubscriptionEntry {
100 if (entry.evictionTimeoutId !== null) {
101 clearTimeout(entry.evictionTimeoutId);
102 entry.evictionTimeoutId = null;
103 }
104 return entry;
105}
106
107function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean {
108 if (entry.unsubscribeConnectionListener !== null) {
109 entry.unsubscribeConnectionListener();
110 entry.unsubscribeConnectionListener = null;
111 }
112 if (entry.unsubscribe !== NOOP) {
113 return true;
114 }
115
116 const connection = readEnvironmentConnection(entry.environmentId);
117 if (!connection) {
118 return false;
119 }
120
121 entry.unsubscribe = connection.client.orchestration.subscribeThread(
122 { threadId: entry.threadId },
123 (item) => {
124 if (item.kind === "snapshot") {
125 useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId);
126 return;
127 }
128 applyEnvironmentThreadDetailEvent(item.event, entry.environmentId);
129 },
130 );
131 return true;
132}
133
134function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void {
135 if (entry.unsubscribeConnectionListener !== null) {
136 return;
137 }
138
139 entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => {
140 if (attachThreadDetailSubscription(entry)) {
141 entry.lastAccessedAt = Date.now();
142 }
143 });
144 attachThreadDetailSubscription(entry);
145}
146
147function disposeThreadDetailSubscriptionByKey(key: string): boolean {
148 const entry = threadDetailSubscriptions.get(key);
149 if (!entry) {
150 return false;
151 }
152
153 clearThreadDetailSubscriptionEviction(entry);
154 entry.unsubscribeConnectionListener?.();
155 entry.unsubscribeConnectionListener = null;
156 threadDetailSubscriptions.delete(key);
157 entry.unsubscribe();
158 entry.unsubscribe = NOOP;
159 return true;
160}
161
162function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void {
163 for (const [key, entry] of threadDetailSubscriptions) {
164 if (entry.environmentId === environmentId) {
165 disposeThreadDetailSubscriptionByKey(key);
166 }
167 }
168}
169
170function reconcileThreadDetailSubscriptionsForEnvironment(
171 environmentId: EnvironmentId,
172 threadIds: ReadonlyArray<ThreadId>,
173): void {
174 const activeThreadIds = new Set(threadIds);
175 for (const [key, entry] of threadDetailSubscriptions) {
176 if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) {
177 disposeThreadDetailSubscriptionByKey(key);
178 }
179 }
180}
181
182function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void {
183 clearThreadDetailSubscriptionEviction(entry);
184 entry.evictionTimeoutId = setTimeout(() => {
185 const currentEntry = threadDetailSubscriptions.get(
186 getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId),
187 );
188 if (!currentEntry || currentEntry.refCount > 0) {
189 return;
190 }
191 disposeThreadDetailSubscriptionByKey(
192 getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId),
193 );
194 }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS);
195}
196
197function evictIdleThreadDetailSubscriptionsToCapacity(): void {
198 if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) {
199 return;
200 }
201
202 const idleEntries = [...threadDetailSubscriptions.entries()]
203 .filter(([, entry]) => entry.refCount === 0)
204 .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt);
205
206 for (const [key] of idleEntries) {
207 if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) {
208 return;
209 }
210 disposeThreadDetailSubscriptionByKey(key);
211 }
212}
213
214export function retainThreadDetailSubscription(
215 environmentId: EnvironmentId,
216 threadId: ThreadId,
217): () => void {
218 const key = getThreadDetailSubscriptionKey(environmentId, threadId);
219 const existing = threadDetailSubscriptions.get(key);
220 if (existing) {
221 clearThreadDetailSubscriptionEviction(existing);
222 existing.refCount += 1;
223 existing.lastAccessedAt = Date.now();
224 if (!attachThreadDetailSubscription(existing)) {
225 watchThreadDetailSubscriptionConnection(existing);
226 }
227 let released = false;
228 return () => {
229 if (released) {
230 return;
231 }
232 released = true;
233 existing.refCount = Math.max(0, existing.refCount - 1);
234 existing.lastAccessedAt = Date.now();
235 if (existing.refCount === 0) {
236 scheduleThreadDetailSubscriptionEviction(existing);
237 evictIdleThreadDetailSubscriptionsToCapacity();
238 }
239 };
240 }
241
242 const entry: ThreadDetailSubscriptionEntry = {
243 environmentId,
244 threadId,
245 unsubscribe: NOOP,
246 unsubscribeConnectionListener: null,
247 refCount: 1,
248 lastAccessedAt: Date.now(),
249 evictionTimeoutId: null,
250 };
251 threadDetailSubscriptions.set(key, entry);
252 if (!attachThreadDetailSubscription(entry)) {
253 watchThreadDetailSubscriptionConnection(entry);
254 }
255 evictIdleThreadDetailSubscriptionsToCapacity();
256
257 let released = false;
258 return () => {
259 if (released) {
260 return;
261 }
262 released = true;
263 entry.refCount = Math.max(0, entry.refCount - 1);
264 entry.lastAccessedAt = Date.now();
265 if (entry.refCount === 0) {
266 scheduleThreadDetailSubscriptionEviction(entry);
267 evictIdleThreadDetailSubscriptionsToCapacity();
268 }
269 };
270}
271
77function emitEnvironmentConnectionRegistryChange() {272function emitEnvironmentConnectionRegistryChange() {
78 for (const listener of environmentConnectionListeners) {273 for (const listener of environmentConnectionListeners) {
79 listener();274 listener();
...@@ -169,17 +364,18 @@ function coalesceOrchestrationUiEvents(...@@ -169,17 +364,18 @@ function coalesceOrchestrationUiEvents(
169 return coalesced;364 return coalesced;
170}365}
171366
172function reconcileSnapshotDerivedState() {367function syncProjectUiFromStore() {
173 const storeState = useStore.getState();368 const projects = selectProjectsAcrossEnvironments(useStore.getState());
174 const threads = selectThreadsAcrossEnvironments(storeState);
175 const projects = selectProjectsAcrossEnvironments(storeState);
176
177 useUiStateStore.getState().syncProjects(369 useUiStateStore.getState().syncProjects(
178 projects.map((project) => ({370 projects.map((project) => ({
179 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),371 key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
180 cwd: project.cwd,372 cwd: project.cwd,
181 })),373 })),
182 );374 );
375}
376
377function syncThreadUiFromStore() {
378 const threads = selectThreadsAcrossEnvironments(useStore.getState());
183 useUiStateStore.getState().syncThreads(379 useUiStateStore.getState().syncThreads(
184 threads.map((thread) => ({380 threads.map((thread) => ({
185 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),381 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
...@@ -189,7 +385,13 @@ function reconcileSnapshotDerivedState() {...@@ -189,7 +385,13 @@ function reconcileSnapshotDerivedState() {
189 markPromotedDraftThreadsByRef(385 markPromotedDraftThreadsByRef(
190 threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),386 threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)),
191 );387 );
388}
389
390function reconcileSnapshotDerivedState() {
391 syncProjectUiFromStore();
392 syncThreadUiFromStore();
192393
394 const threads = selectThreadsAcrossEnvironments(useStore.getState());
193 const activeThreadKeys = collectActiveTerminalThreadIds({395 const activeThreadKeys = collectActiveTerminalThreadIds({
194 snapshotThreads: threads.map((thread) => ({396 snapshotThreads: threads.map((thread) => ({
195 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),397 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
...@@ -273,11 +475,60 @@ function applyRecoveredEventBatch(...@@ -273,11 +475,60 @@ function applyRecoveredEventBatch(
273 }475 }
274}476}
275477
478export function applyEnvironmentThreadDetailEvent(
479 event: OrchestrationEvent,
480 environmentId: EnvironmentId,
481) {
482 applyRecoveredEventBatch([event], environmentId);
483}
484
485function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) {
486 const threadId =
487 event.kind === "thread-upserted"
488 ? event.thread.id
489 : event.kind === "thread-removed"
490 ? event.threadId
491 : null;
492 const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null;
493 const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined;
494
495 useStore.getState().applyShellEvent(event, environmentId);
496
497 switch (event.kind) {
498 case "project-upserted":
499 case "project-removed":
500 syncProjectUiFromStore();
501 return;
502 case "thread-upserted":
503 syncThreadUiFromStore();
504 if (!previousThread && threadRef) {
505 markPromotedDraftThreadByRef(threadRef);
506 }
507 if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) {
508 useTerminalStateStore.getState().removeTerminalState(threadRef);
509 }
510 return;
511 case "thread-removed":
512 if (threadRef) {
513 disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef));
514 useComposerDraftStore.getState().clearDraftThread(threadRef);
515 useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef));
516 useTerminalStateStore.getState().removeTerminalState(threadRef);
517 }
518 syncThreadUiFromStore();
519 return;
520 }
521}
522
276function createEnvironmentConnectionHandlers() {523function createEnvironmentConnectionHandlers() {
277 return {524 return {
278 applyEventBatch: applyRecoveredEventBatch,525 applyShellEvent,
279 syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => {526 syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => {
280 useStore.getState().syncServerReadModel(snapshot, environmentId);527 useStore.getState().syncServerShellSnapshot(snapshot, environmentId);
528 reconcileThreadDetailSubscriptionsForEnvironment(
529 environmentId,
530 snapshot.threads.map((thread) => thread.id),
531 );
281 reconcileSnapshotDerivedState();532 reconcileSnapshotDerivedState();
282 },533 },
283 applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => {534 applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => {
...@@ -386,6 +637,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>...@@ -386,6 +637,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
386 return false;637 return false;
387 }638 }
388639
640 disposeThreadDetailSubscriptionsForEnvironment(environmentId);
389 environmentConnections.delete(environmentId);641 environmentConnections.delete(environmentId);
390 emitEnvironmentConnectionRegistryChange();642 emitEnvironmentConnectionRegistryChange();
391 await connection.dispose();643 await connection.dispose();
...@@ -714,6 +966,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()...@@ -714,6 +966,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
714966
715export async function resetEnvironmentServiceForTests(): Promise<void> {967export async function resetEnvironmentServiceForTests(): Promise<void> {
716 stopActiveService();968 stopActiveService();
969 for (const key of Array.from(threadDetailSubscriptions.keys())) {
970 disposeThreadDetailSubscriptionByKey(key);
971 }
717 await Promise.all(972 await Promise.all(
718 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),973 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),
719 );974 );
apps/web/src/lib/gitStatusState.test.ts+2-3
...@@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) {...@@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
123 subscribeAuthAccess: vi.fn(() => () => undefined),123 subscribeAuthAccess: vi.fn(() => () => undefined),
124 },124 },
125 orchestration: {125 orchestration: {
126 getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any),
127 dispatchCommand: vi.fn(async () => undefined),126 dispatchCommand: vi.fn(async () => undefined),
128 getTurnDiff: vi.fn(async () => undefined),127 getTurnDiff: vi.fn(async () => undefined),
129 getFullThreadDiff: vi.fn(async () => undefined),128 getFullThreadDiff: vi.fn(async () => undefined),
130 replayEvents: vi.fn(async () => []),129 subscribeShell: vi.fn(() => () => undefined),
131 onDomainEvent: vi.fn(() => () => undefined),130 subscribeThread: vi.fn(() => () => undefined),
132 },131 },
133 } as unknown as WsRpcClient;132 } as unknown as WsRpcClient;
134133
apps/web/src/localApi.test.ts+20-30
...@@ -3,10 +3,9 @@ import {...@@ -3,10 +3,9 @@ import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,4 type DesktopBridge,
5 EnvironmentId,5 EnvironmentId,
6 EventId,
7 type GitStatusResult,6 type GitStatusResult,
8 ProjectId,7 ProjectId,
9 type OrchestrationEvent,8 type OrchestrationShellStreamItem,
10 type ServerConfig,9 type ServerConfig,
11 type ServerProvider,10 type ServerProvider,
12 type TerminalEvent,11 type TerminalEvent,
...@@ -32,7 +31,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even...@@ -32,7 +31,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
32}31}
3332
34const terminalEventListeners = new Set<(event: TerminalEvent) => void>();33const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
35const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();34const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>();
36const gitStatusListeners = new Set<(event: GitStatusResult) => void>();35const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
3736
38const rpcClientMock = {37const rpcClientMock = {
...@@ -82,14 +81,13 @@ const rpcClientMock = {...@@ -82,14 +81,13 @@ const rpcClientMock = {
82 subscribeAuthAccess: vi.fn(),81 subscribeAuthAccess: vi.fn(),
83 },82 },
84 orchestration: {83 orchestration: {
85 getSnapshot: vi.fn(),
86 dispatchCommand: vi.fn(),84 dispatchCommand: vi.fn(),
87 getTurnDiff: vi.fn(),85 getTurnDiff: vi.fn(),
88 getFullThreadDiff: vi.fn(),86 getFullThreadDiff: vi.fn(),
89 replayEvents: vi.fn(),87 subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) =>
90 onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) =>88 registerListener(shellStreamListeners, listener),
91 registerListener(orchestrationEventListeners, listener),
92 ),89 ),
90 subscribeThread: vi.fn(() => () => undefined),
93 },91 },
94};92};
9593
...@@ -269,7 +267,7 @@ beforeEach(() => {...@@ -269,7 +267,7 @@ beforeEach(() => {
269 vi.clearAllMocks();267 vi.clearAllMocks();
270 showContextMenuFallbackMock.mockReset();268 showContextMenuFallbackMock.mockReset();
271 terminalEventListeners.clear();269 terminalEventListeners.clear();
272 orchestrationEventListeners.clear();270 shellStreamListeners.clear();
273 gitStatusListeners.clear();271 gitStatusListeners.clear();
274 const testWindow = getWindowForTest();272 const testWindow = getWindowForTest();
275 Reflect.deleteProperty(testWindow, "desktopBridge");273 Reflect.deleteProperty(testWindow, "desktopBridge");
...@@ -296,15 +294,15 @@ describe("wsApi", () => {...@@ -296,15 +294,15 @@ describe("wsApi", () => {
296 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();294 expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled();
297 });295 });
298296
299 it("forwards terminal and orchestration stream events", async () => {297 it("forwards terminal and shell stream events", async () => {
300 const { createEnvironmentApi } = await import("./environmentApi");298 const { createEnvironmentApi } = await import("./environmentApi");
301299
302 const api = createEnvironmentApi(rpcClientMock as never);300 const api = createEnvironmentApi(rpcClientMock as never);
303 const onTerminalEvent = vi.fn();301 const onTerminalEvent = vi.fn();
304 const onDomainEvent = vi.fn();302 const onShellEvent = vi.fn();
305303
306 api.terminal.onEvent(onTerminalEvent);304 api.terminal.onEvent(onTerminalEvent);
307 api.orchestration.onDomainEvent(onDomainEvent);305 api.orchestration.subscribeShell(onShellEvent);
308306
309 const terminalEvent = {307 const terminalEvent = {
310 threadId: "thread-1",308 threadId: "thread-1",
...@@ -315,19 +313,11 @@ describe("wsApi", () => {...@@ -315,19 +313,11 @@ describe("wsApi", () => {
315 } as const;313 } as const;
316 emitEvent(terminalEventListeners, terminalEvent);314 emitEvent(terminalEventListeners, terminalEvent);
317315
318 const orchestrationEvent = {316 const shellEvent = {
317 kind: "project-upserted" as const,
319 sequence: 1,318 sequence: 1,
320 eventId: EventId.make("event-1"),319 project: {
321 aggregateKind: "project",320 id: ProjectId.make("project-1"),
322 aggregateId: ProjectId.make("project-1"),
323 occurredAt: "2026-02-24T00:00:00.000Z",
324 commandId: null,
325 causationEventId: null,
326 correlationId: null,
327 metadata: {},
328 type: "project.created",
329 payload: {
330 projectId: ProjectId.make("project-1"),
331 title: "Project",321 title: "Project",
332 workspaceRoot: "/tmp/workspace",322 workspaceRoot: "/tmp/workspace",
333 defaultModelSelection: {323 defaultModelSelection: {
...@@ -338,11 +328,11 @@ describe("wsApi", () => {...@@ -338,11 +328,11 @@ describe("wsApi", () => {
338 createdAt: "2026-02-24T00:00:00.000Z",328 createdAt: "2026-02-24T00:00:00.000Z",
339 updatedAt: "2026-02-24T00:00:00.000Z",329 updatedAt: "2026-02-24T00:00:00.000Z",
340 },330 },
341 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;331 } satisfies OrchestrationShellStreamItem;
342 emitEvent(orchestrationEventListeners, orchestrationEvent);332 emitEvent(shellStreamListeners, shellEvent);
343333
344 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);334 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
345 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);335 expect(onShellEvent).toHaveBeenCalledWith(shellEvent);
346 });336 });
347337
348 it("forwards git status stream events", async () => {338 it("forwards git status stream events", async () => {
...@@ -371,16 +361,16 @@ describe("wsApi", () => {...@@ -371,16 +361,16 @@ describe("wsApi", () => {
371 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });361 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
372 });362 });
373363
374 it("forwards orchestration stream subscription options to the RPC client", async () => {364 it("forwards shell stream subscription options to the RPC client", async () => {
375 const { createEnvironmentApi } = await import("./environmentApi");365 const { createEnvironmentApi } = await import("./environmentApi");
376366
377 const api = createEnvironmentApi(rpcClientMock as never);367 const api = createEnvironmentApi(rpcClientMock as never);
378 const onDomainEvent = vi.fn();368 const onShellEvent = vi.fn();
379 const onResubscribe = vi.fn();369 const onResubscribe = vi.fn();
380370
381 api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe });371 api.orchestration.subscribeShell(onShellEvent, { onResubscribe });
382372
383 expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, {373 expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, {
384 onResubscribe,374 onResubscribe,
385 });375 });
386 });376 });
apps/web/src/routes/_chat.tsx+2-10
...@@ -2,10 +2,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";...@@ -2,10 +2,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
33
4import { useCommandPaletteStore } from "../commandPaletteStore";4import { useCommandPaletteStore } from "../commandPaletteStore";
5import {
6 ensurePrimaryEnvironmentReady,
7 resolveInitialServerAuthGateState,
8} from "../environments/primary";
9import { useHandleNewThread } from "../hooks/useHandleNewThread";5import { useHandleNewThread } from "../hooks/useHandleNewThread";
10import {6import {
11 startNewLocalThreadFromContext,7 startNewLocalThreadFromContext,
...@@ -111,12 +107,8 @@ function ChatRouteLayout() {...@@ -111,12 +107,8 @@ function ChatRouteLayout() {
111}107}
112108
113export const Route = createFileRoute("/_chat")({109export const Route = createFileRoute("/_chat")({
114 beforeLoad: async () => {110 beforeLoad: async ({ context }) => {
115 const [, authGateState] = await Promise.all([111 if (context.authGateState.status !== "authenticated") {
116 ensurePrimaryEnvironmentReady(),
117 resolveInitialServerAuthGateState(),
118 ]);
119 if (authGateState.status !== "authenticated") {
120 throw redirect({ to: "/pair", replace: true });112 throw redirect({ to: "/pair", replace: true });
121 }113 }
122 },114 },
apps/web/src/routes/pair.tsx+2-9
...@@ -1,17 +1,10 @@...@@ -1,17 +1,10 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
22
3import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface";3import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface";
4import {
5 ensurePrimaryEnvironmentReady,
6 resolveInitialServerAuthGateState,
7} from "../environments/primary";
84
9export const Route = createFileRoute("/pair")({5export const Route = createFileRoute("/pair")({
10 beforeLoad: async () => {6 beforeLoad: async ({ context }) => {
11 const [, authGateState] = await Promise.all([7 const { authGateState } = context;
12 ensurePrimaryEnvironmentReady(),
13 resolveInitialServerAuthGateState(),
14 ]);
15 if (authGateState.status === "authenticated") {8 if (authGateState.status === "authenticated") {
16 throw redirect({ to: "/", replace: true });9 throw redirect({ to: "/", replace: true });
17 }10 }
apps/web/src/routes/settings.tsx+2-10
...@@ -2,10 +2,6 @@ import { RotateCcwIcon } from "lucide-react";...@@ -2,10 +2,6 @@ import { RotateCcwIcon } from "lucide-react";
2import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";2import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
3import { useEffect, useState } from "react";3import { useEffect, useState } from "react";
44
5import {
6 ensurePrimaryEnvironmentReady,
7 resolveInitialServerAuthGateState,
8} from "../environments/primary";
9import { useSettingsRestore } from "../components/settings/SettingsPanels";5import { useSettingsRestore } from "../components/settings/SettingsPanels";
10import { Button } from "../components/ui/button";6import { Button } from "../components/ui/button";
11import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
...@@ -87,12 +83,8 @@ function SettingsRouteLayout() {...@@ -87,12 +83,8 @@ function SettingsRouteLayout() {
87}83}
8884
89export const Route = createFileRoute("/settings")({85export const Route = createFileRoute("/settings")({
90 beforeLoad: async ({ location }) => {86 beforeLoad: async ({ context, location }) => {
91 const [, authGateState] = await Promise.all([87 if (context.authGateState.status !== "authenticated") {
92 ensurePrimaryEnvironmentReady(),
93 resolveInitialServerAuthGateState(),
94 ]);
95 if (authGateState.status !== "authenticated") {
96 throw redirect({ to: "/pair", replace: true });88 throw redirect({ to: "/pair", replace: true });
97 }89 }
9890
apps/web/src/rpc/wsRpcClient.ts+19-11
...@@ -39,6 +39,15 @@ type RpcStreamMethod<TTag extends RpcTag> =...@@ -39,6 +39,15 @@ type RpcStreamMethod<TTag extends RpcTag> =
39 ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void39 ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void
40 : never;40 : never;
4141
42type RpcInputStreamMethod<TTag extends RpcTag> =
43 RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any>
44 ? (
45 input: RpcInput<TTag>,
46 listener: (event: TEvent) => void,
47 options?: StreamSubscriptionOptions,
48 ) => () => void
49 : never;
50
42interface GitRunStackedActionOptions {51interface GitRunStackedActionOptions {
43 readonly onProgress?: (event: GitActionProgressEvent) => void;52 readonly onProgress?: (event: GitActionProgressEvent) => void;
44}53}
...@@ -101,12 +110,11 @@ export interface WsRpcClient {...@@ -101,12 +110,11 @@ export interface WsRpcClient {
101 readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>;110 readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>;
102 };111 };
103 readonly orchestration: {112 readonly orchestration: {
104 readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>;
105 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;113 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
106 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;114 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
107 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;115 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;
108 readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>;116 readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>;
109 readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>;117 readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>;
110 };118 };
111}119}
112120
...@@ -219,21 +227,21 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -219,21 +227,21 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
219 ),227 ),
220 },228 },
221 orchestration: {229 orchestration: {
222 getSnapshot: () =>
223 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})),
224 dispatchCommand: (input) =>230 dispatchCommand: (input) =>
225 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),231 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
226 getTurnDiff: (input) =>232 getTurnDiff: (input) =>
227 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),233 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
228 getFullThreadDiff: (input) =>234 getFullThreadDiff: (input) =>
229 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),235 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
230 replayEvents: (input) =>236 subscribeShell: (listener, options) =>
231 transport237 transport.subscribe(
232 .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input))238 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
233 .then((events) => [...events]),239 listener,
234 onDomainEvent: (listener, options) =>240 options,
241 ),
242 subscribeThread: (input, listener, options) =>
235 transport.subscribe(243 transport.subscribe(
236 (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}),244 (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input),
237 listener,245 listener,
238 options,246 options,
239 ),247 ),
apps/web/src/session-logic.test.ts+13
...@@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => {
1227 ).toBe("2026-02-27T21:10:00.000Z");1227 ).toBe("2026-02-27T21:10:00.000Z");
1228 });1228 });
12291229
1230 it("uses the new send start while the session is running a different turn", () => {
1231 expect(
1232 deriveActiveWorkStartedAt(
1233 latestTurn,
1234 {
1235 orchestrationStatus: "running",
1236 activeTurnId: TurnId.make("turn-2"),
1237 },
1238 "2026-02-27T21:11:00.000Z",
1239 ),
1240 ).toBe("2026-02-27T21:11:00.000Z");
1241 });
1242
1230 it("falls back to sendStartedAt once the latest turn is settled", () => {1243 it("falls back to sendStartedAt once the latest turn is settled", () => {
1231 expect(1244 expect(
1232 deriveActiveWorkStartedAt(1245 deriveActiveWorkStartedAt(
apps/web/src/session-logic.ts+8
...@@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt(...@@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt(
145 session: SessionActivityState | null,145 session: SessionActivityState | null,
146 sendStartedAt: string | null,146 sendStartedAt: string | null,
147): string | null {147): string | null {
148 const runningTurnId =
149 session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null;
150 if (runningTurnId !== null) {
151 if (latestTurn?.turnId === runningTurnId) {
152 return latestTurn.startedAt ?? sendStartedAt;
153 }
154 return sendStartedAt;
155 }
148 if (!isLatestTurnSettled(latestTurn, session)) {156 if (!isLatestTurnSettled(latestTurn, session)) {
149 return latestTurn?.startedAt ?? sendStartedAt;157 return latestTurn?.startedAt ?? sendStartedAt;
150 }158 }
apps/web/src/store.test.ts+79
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 EnvironmentId,5 EnvironmentId,
6 EventId,6 EventId,
7 MessageId,7 MessageId,
8 type OrchestrationShellSnapshot,
8 ProjectId,9 ProjectId,
9 ThreadId,10 ThreadId,
10 TurnId,11 TurnId,
...@@ -23,6 +24,7 @@ import {...@@ -23,6 +24,7 @@ import {
23 setThreadBranch,24 setThreadBranch,
24 selectThreadsAcrossEnvironments,25 selectThreadsAcrossEnvironments,
25 syncServerReadModel,26 syncServerReadModel,
27 syncServerShellSnapshot,
26 type AppState,28 type AppState,
27 type EnvironmentState,29 type EnvironmentState,
28} from "./store";30} from "./store";
...@@ -496,6 +498,83 @@ describe("store read model sync", () => {...@@ -496,6 +498,83 @@ describe("store read model sync", () => {
496 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);498 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
497 });499 });
498500
501 it("updates shell state without discarding hydrated thread detail", () => {
502 const initialState = makeState(
503 makeThread({
504 title: "Initial thread",
505 messages: [
506 {
507 id: MessageId.make("message-1"),
508 role: "assistant",
509 text: "hydrated body",
510 createdAt: "2026-02-13T00:00:01.000Z",
511 completedAt: "2026-02-13T00:00:01.000Z",
512 streaming: false,
513 },
514 ],
515 }),
516 );
517 const shellSnapshot: OrchestrationShellSnapshot = {
518 snapshotSequence: 2,
519 projects: [
520 {
521 id: ProjectId.make("project-1"),
522 title: "Project",
523 workspaceRoot: "/tmp/project",
524 repositoryIdentity: null,
525 defaultModelSelection: {
526 provider: "codex",
527 model: "gpt-5-codex",
528 },
529 scripts: [],
530 createdAt: "2026-02-13T00:00:00.000Z",
531 updatedAt: "2026-02-13T00:00:00.000Z",
532 },
533 ],
534 threads: [
535 {
536 id: ThreadId.make("thread-1"),
537 projectId: ProjectId.make("project-1"),
538 title: "Renamed thread",
539 modelSelection: {
540 provider: "codex",
541 model: "gpt-5-codex",
542 },
543 runtimeMode: "full-access",
544 interactionMode: "default",
545 branch: "feature/renamed",
546 worktreePath: null,
547 latestTurn: null,
548 createdAt: "2026-02-13T00:00:00.000Z",
549 updatedAt: "2026-02-13T00:00:02.000Z",
550 archivedAt: null,
551 session: null,
552 latestUserMessageAt: null,
553 hasPendingApprovals: false,
554 hasPendingUserInput: false,
555 hasActionableProposedPlan: false,
556 },
557 ],
558 updatedAt: "2026-02-13T00:00:02.000Z",
559 };
560
561 const next = syncServerShellSnapshot(initialState, shellSnapshot, localEnvironmentId);
562 const thread = selectThreadByRef(
563 next,
564 scopeThreadRef(localEnvironmentId, ThreadId.make("thread-1")),
565 );
566
567 expect(thread?.title).toBe("Renamed thread");
568 expect(thread?.branch).toBe("feature/renamed");
569 expect(thread?.messages).toEqual([
570 expect.objectContaining({
571 id: MessageId.make("message-1"),
572 text: "hydrated body",
573 }),
574 ]);
575 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
576 });
577
499 it("preserves claude model slugs without an active session", () => {578 it("preserves claude model slugs without an active session", () => {
500 const initialState = makeState(makeThread());579 const initialState = makeState(makeThread());
501 const readModel = makeReadModel(580 const readModel = makeReadModel(
apps/web/src/store.ts+335-1
...@@ -6,9 +6,12 @@ import type {...@@ -6,9 +6,12 @@ import type {
6 OrchestrationMessage,6 OrchestrationMessage,
7 OrchestrationProposedPlan,7 OrchestrationProposedPlan,
8 OrchestrationReadModel,8 OrchestrationReadModel,
9 OrchestrationShellSnapshot,
10 OrchestrationShellStreamEvent,
9 OrchestrationSession,11 OrchestrationSession,
10 OrchestrationSessionStatus,12 OrchestrationSessionStatus,
11 OrchestrationThread,13 OrchestrationThread,
14 OrchestrationThreadShell,
12 OrchestrationThreadActivity,15 OrchestrationThreadActivity,
13 ProjectId,16 ProjectId,
14 ProviderKind,17 ProviderKind,
...@@ -175,7 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif...@@ -175,7 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif
175}178}
176179
177function mapProject(180function mapProject(
178 project: OrchestrationReadModel["projects"][number],181 project:
182 | OrchestrationReadModel["projects"][number]
183 | OrchestrationShellSnapshot["projects"][number],
179 environmentId: EnvironmentId,184 environmentId: EnvironmentId,
180): Project {185): Project {
181 return {186 return {
...@@ -219,6 +224,62 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T...@@ -219,6 +224,62 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T
219 };224 };
220}225}
221226
227function mapThreadShell(
228 thread: OrchestrationThreadShell,
229 environmentId: EnvironmentId,
230): {
231 shell: ThreadShell;
232 session: ThreadSession | null;
233 turnState: ThreadTurnState;
234 summary: SidebarThreadSummary;
235} {
236 const shell: ThreadShell = {
237 id: thread.id,
238 environmentId,
239 codexThreadId: null,
240 projectId: thread.projectId,
241 title: thread.title,
242 modelSelection: normalizeModelSelection(thread.modelSelection),
243 runtimeMode: thread.runtimeMode,
244 interactionMode: thread.interactionMode,
245 error: sanitizeThreadErrorMessage(thread.session?.lastError),
246 createdAt: thread.createdAt,
247 archivedAt: thread.archivedAt,
248 updatedAt: thread.updatedAt,
249 branch: thread.branch,
250 worktreePath: thread.worktreePath,
251 };
252 const session = thread.session ? mapSession(thread.session) : null;
253 const turnState: ThreadTurnState = {
254 latestTurn: thread.latestTurn,
255 pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan,
256 };
257 const summary: SidebarThreadSummary = {
258 id: thread.id,
259 environmentId,
260 projectId: thread.projectId,
261 title: thread.title,
262 interactionMode: thread.interactionMode,
263 session,
264 createdAt: thread.createdAt,
265 archivedAt: thread.archivedAt,
266 updatedAt: thread.updatedAt,
267 latestTurn: thread.latestTurn,
268 branch: thread.branch,
269 worktreePath: thread.worktreePath,
270 latestUserMessageAt: thread.latestUserMessageAt,
271 hasPendingApprovals: thread.hasPendingApprovals,
272 hasPendingUserInput: thread.hasPendingUserInput,
273 hasActionableProposedPlan: thread.hasActionableProposedPlan,
274 };
275 return {
276 shell,
277 session,
278 turnState,
279 summary,
280 };
281}
282
222function toThreadShell(thread: Thread): ThreadShell {283function toThreadShell(thread: Thread): ThreadShell {
223 return {284 return {
224 id: thread.id,285 id: thread.id,
...@@ -562,6 +623,120 @@ function writeThreadState(...@@ -562,6 +623,120 @@ function writeThreadState(
562 return nextState;623 return nextState;
563}624}
564625
626function writeThreadShellState(
627 state: EnvironmentState,
628 nextThread: {
629 shell: ThreadShell;
630 session: ThreadSession | null;
631 turnState: ThreadTurnState;
632 summary: SidebarThreadSummary;
633 },
634): EnvironmentState {
635 let nextState = state;
636 const previousShell = state.threadShellById[nextThread.shell.id];
637 const previousProjectId = previousShell?.projectId;
638 const nextProjectId = nextThread.shell.projectId;
639
640 if (!state.threadIds.includes(nextThread.shell.id)) {
641 nextState = {
642 ...nextState,
643 threadIds: [...nextState.threadIds, nextThread.shell.id],
644 };
645 }
646
647 if (previousProjectId !== nextProjectId) {
648 let threadIdsByProjectId = nextState.threadIdsByProjectId;
649 if (previousProjectId) {
650 const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS;
651 const nextIds = removeId(previousIds, nextThread.shell.id);
652 if (nextIds.length === 0) {
653 const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId;
654 threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>;
655 } else if (!arraysEqual(previousIds, nextIds)) {
656 threadIdsByProjectId = {
657 ...threadIdsByProjectId,
658 [previousProjectId]: nextIds,
659 };
660 }
661 }
662
663 const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS;
664 const nextProjectThreadIds = appendId(projectThreadIds, nextThread.shell.id);
665 if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) {
666 threadIdsByProjectId = {
667 ...threadIdsByProjectId,
668 [nextProjectId]: nextProjectThreadIds,
669 };
670 }
671 if (threadIdsByProjectId !== nextState.threadIdsByProjectId) {
672 nextState = {
673 ...nextState,
674 threadIdsByProjectId,
675 };
676 }
677 }
678
679 if (!threadShellsEqual(previousShell, nextThread.shell)) {
680 nextState = {
681 ...nextState,
682 threadShellById: {
683 ...nextState.threadShellById,
684 [nextThread.shell.id]: nextThread.shell,
685 },
686 };
687 }
688
689 if ((state.threadSessionById[nextThread.shell.id] ?? null) !== nextThread.session) {
690 nextState = {
691 ...nextState,
692 threadSessionById: {
693 ...nextState.threadSessionById,
694 [nextThread.shell.id]: nextThread.session,
695 },
696 };
697 }
698
699 if (
700 !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState)
701 ) {
702 nextState = {
703 ...nextState,
704 threadTurnStateById: {
705 ...nextState.threadTurnStateById,
706 [nextThread.shell.id]: nextThread.turnState,
707 },
708 };
709 }
710
711 if (
712 !sidebarThreadSummariesEqual(
713 state.sidebarThreadSummaryById[nextThread.shell.id],
714 nextThread.summary,
715 )
716 ) {
717 nextState = {
718 ...nextState,
719 sidebarThreadSummaryById: {
720 ...nextState.sidebarThreadSummaryById,
721 [nextThread.shell.id]: nextThread.summary,
722 },
723 };
724 }
725
726 return nextState;
727}
728
729function retainThreadScopedRecord<T>(
730 record: Record<ThreadId, T>,
731 nextThreadIds: ReadonlySet<ThreadId>,
732): Record<ThreadId, T> {
733 return Object.fromEntries(
734 Object.entries(record).flatMap(([threadId, value]) =>
735 nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [],
736 ),
737 ) as Record<ThreadId, T>;
738}
739
565function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState {740function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState {
566 const shell = state.threadShellById[threadId];741 const shell = state.threadShellById[threadId];
567 if (!shell) {742 if (!shell) {
...@@ -960,6 +1135,46 @@ function syncEnvironmentReadModel(...@@ -960,6 +1135,46 @@ function syncEnvironmentReadModel(
960 };1135 };
961}1136}
9621137
1138function syncEnvironmentShellSnapshot(
1139 state: EnvironmentState,
1140 snapshot: OrchestrationShellSnapshot,
1141 environmentId: EnvironmentId,
1142): EnvironmentState {
1143 const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId));
1144 const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id));
1145 let nextState: EnvironmentState = {
1146 ...state,
1147 ...buildProjectState(nextProjects),
1148 threadIds: [],
1149 threadIdsByProjectId: {},
1150 threadShellById: {},
1151 threadSessionById: {},
1152 threadTurnStateById: {},
1153 sidebarThreadSummaryById: {},
1154 messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds),
1155 messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds),
1156 activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds),
1157 activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds),
1158 proposedPlanIdsByThreadId: retainThreadScopedRecord(
1159 state.proposedPlanIdsByThreadId,
1160 nextThreadIds,
1161 ),
1162 proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds),
1163 turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds),
1164 turnDiffSummaryByThreadId: retainThreadScopedRecord(
1165 state.turnDiffSummaryByThreadId,
1166 nextThreadIds,
1167 ),
1168 bootstrapComplete: true,
1169 };
1170
1171 for (const thread of snapshot.threads) {
1172 nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId));
1173 }
1174
1175 return nextState;
1176}
1177
963export function syncServerReadModel(1178export function syncServerReadModel(
964 state: AppState,1179 state: AppState,
965 readModel: OrchestrationReadModel,1180 readModel: OrchestrationReadModel,
...@@ -976,6 +1191,36 @@ export function syncServerReadModel(...@@ -976,6 +1191,36 @@ export function syncServerReadModel(
976 );1191 );
977}1192}
9781193
1194export function syncServerShellSnapshot(
1195 state: AppState,
1196 snapshot: OrchestrationShellSnapshot,
1197 environmentId: EnvironmentId,
1198): AppState {
1199 return commitEnvironmentState(
1200 state,
1201 environmentId,
1202 syncEnvironmentShellSnapshot(
1203 getStoredEnvironmentState(state, environmentId),
1204 snapshot,
1205 environmentId,
1206 ),
1207 );
1208}
1209
1210export function syncServerThreadDetail(
1211 state: AppState,
1212 thread: OrchestrationThread,
1213 environmentId: EnvironmentId,
1214): AppState {
1215 const environmentState = getStoredEnvironmentState(state, environmentId);
1216 const previousThread = getThreadFromEnvironmentState(environmentState, thread.id);
1217 return commitEnvironmentState(
1218 state,
1219 environmentId,
1220 writeThreadState(environmentState, mapThread(thread, environmentId), previousThread),
1221 );
1222}
1223
979function applyEnvironmentOrchestrationEvent(1224function applyEnvironmentOrchestrationEvent(
980 state: EnvironmentState,1225 state: EnvironmentState,
981 event: OrchestrationEvent,1226 event: OrchestrationEvent,
...@@ -1466,6 +1711,67 @@ function applyEnvironmentOrchestrationEvent(...@@ -1466,6 +1711,67 @@ function applyEnvironmentOrchestrationEvent(
1466 return state;1711 return state;
1467}1712}
14681713
1714function applyEnvironmentShellEvent(
1715 state: EnvironmentState,
1716 event: OrchestrationShellStreamEvent,
1717 environmentId: EnvironmentId,
1718): EnvironmentState {
1719 switch (event.kind) {
1720 case "project-upserted": {
1721 const nextProject = mapProject(event.project, environmentId);
1722 const existingProjectId =
1723 state.projectIds.find(
1724 (projectId) =>
1725 projectId === event.project.id ||
1726 state.projectById[projectId]?.cwd === event.project.workspaceRoot,
1727 ) ?? null;
1728 let projectById = state.projectById;
1729 let projectIds = state.projectIds;
1730
1731 if (existingProjectId !== null && existingProjectId !== nextProject.id) {
1732 const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById;
1733 projectById = {
1734 ...restProjectById,
1735 [nextProject.id]: nextProject,
1736 };
1737 projectIds = state.projectIds.map((projectId) =>
1738 projectId === existingProjectId ? nextProject.id : projectId,
1739 );
1740 } else {
1741 projectById = {
1742 ...state.projectById,
1743 [nextProject.id]: nextProject,
1744 };
1745 projectIds =
1746 existingProjectId === null && !state.projectIds.includes(nextProject.id)
1747 ? [...state.projectIds, nextProject.id]
1748 : state.projectIds;
1749 }
1750
1751 return {
1752 ...state,
1753 projectById,
1754 projectIds,
1755 };
1756 }
1757 case "project-removed": {
1758 if (!state.projectById[event.projectId]) {
1759 return state;
1760 }
1761 const { [event.projectId]: _removedProject, ...projectById } = state.projectById;
1762 return {
1763 ...state,
1764 projectById,
1765 projectIds: removeId(state.projectIds, event.projectId),
1766 };
1767 }
1768 case "thread-upserted":
1769 return writeThreadShellState(state, mapThreadShell(event.thread, environmentId));
1770 case "thread-removed":
1771 return removeThreadState(state, event.threadId);
1772 }
1773}
1774
1469export function applyOrchestrationEvents(1775export function applyOrchestrationEvents(
1470 state: AppState,1776 state: AppState,
1471 events: ReadonlyArray<OrchestrationEvent>,1777 events: ReadonlyArray<OrchestrationEvent>,
...@@ -1649,6 +1955,22 @@ export function applyOrchestrationEvent(...@@ -1649,6 +1955,22 @@ export function applyOrchestrationEvent(
1649 );1955 );
1650}1956}
16511957
1958export function applyShellEvent(
1959 state: AppState,
1960 event: OrchestrationShellStreamEvent,
1961 environmentId: EnvironmentId,
1962): AppState {
1963 return commitEnvironmentState(
1964 state,
1965 environmentId,
1966 applyEnvironmentShellEvent(
1967 getStoredEnvironmentState(state, environmentId),
1968 event,
1969 environmentId,
1970 ),
1971 );
1972}
1973
1652export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState {1974export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState {
1653 if (state.activeEnvironmentId === environmentId) {1975 if (state.activeEnvironmentId === environmentId) {
1654 return state;1976 return state;
...@@ -1686,11 +2008,17 @@ export function setThreadBranch(...@@ -1686,11 +2008,17 @@ export function setThreadBranch(
1686interface AppStore extends AppState {2008interface AppStore extends AppState {
1687 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;2009 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
1688 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;2010 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
2011 syncServerShellSnapshot: (
2012 snapshot: OrchestrationShellSnapshot,
2013 environmentId: EnvironmentId,
2014 ) => void;
2015 syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void;
1689 applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void;2016 applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void;
1690 applyOrchestrationEvents: (2017 applyOrchestrationEvents: (
1691 events: ReadonlyArray<OrchestrationEvent>,2018 events: ReadonlyArray<OrchestrationEvent>,
1692 environmentId: EnvironmentId,2019 environmentId: EnvironmentId,
1693 ) => void;2020 ) => void;
2021 applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void;
1694 setError: (threadId: ThreadId, error: string | null) => void;2022 setError: (threadId: ThreadId, error: string | null) => void;
1695 setThreadBranch: (2023 setThreadBranch: (
1696 threadRef: ScopedThreadRef,2024 threadRef: ScopedThreadRef,
...@@ -1705,10 +2033,16 @@ export const useStore = create<AppStore>((set) => ({...@@ -1705,10 +2033,16 @@ export const useStore = create<AppStore>((set) => ({
1705 set((state) => setActiveEnvironmentId(state, environmentId)),2033 set((state) => setActiveEnvironmentId(state, environmentId)),
1706 syncServerReadModel: (readModel, environmentId) =>2034 syncServerReadModel: (readModel, environmentId) =>
1707 set((state) => syncServerReadModel(state, readModel, environmentId)),2035 set((state) => syncServerReadModel(state, readModel, environmentId)),
2036 syncServerShellSnapshot: (snapshot, environmentId) =>
2037 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),
2038 syncServerThreadDetail: (thread, environmentId) =>
2039 set((state) => syncServerThreadDetail(state, thread, environmentId)),
1708 applyOrchestrationEvent: (event, environmentId) =>2040 applyOrchestrationEvent: (event, environmentId) =>
1709 set((state) => applyOrchestrationEvent(state, event, environmentId)),2041 set((state) => applyOrchestrationEvent(state, event, environmentId)),
1710 applyOrchestrationEvents: (events, environmentId) =>2042 applyOrchestrationEvents: (events, environmentId) =>
1711 set((state) => applyOrchestrationEvents(state, events, environmentId)),2043 set((state) => applyOrchestrationEvents(state, events, environmentId)),
2044 applyShellEvent: (event, environmentId) =>
2045 set((state) => applyShellEvent(state, event, environmentId)),
1712 setError: (threadId, error) => set((state) => setError(state, threadId, error)),2046 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
1713 setThreadBranch: (threadRef, branch, worktreePath) =>2047 setThreadBranch: (threadRef, branch, worktreePath) =>
1714 set((state) => setThreadBranch(state, threadRef, branch, worktreePath)),2048 set((state) => setThreadBranch(state, threadRef, branch, worktreePath)),
apps/web/test/wsRpcHarness.ts+3-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Effect, Exit, PubSub, Scope, Stream } from "effect";1import { Effect, Exit, PubSub, Scope, Stream } from "effect";
2import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts";2import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
3import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc";3import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc";
44
5type RpcServerInstance = RpcServer.RpcServer<any>;5type RpcServerInstance = RpcServer.RpcServer<any>;
...@@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions {...@@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions {
23}23}
2424
25const STREAM_METHODS = new Set<string>([25const STREAM_METHODS = new Set<string>([
26 ORCHESTRATION_WS_METHODS.subscribeShell,
27 ORCHESTRATION_WS_METHODS.subscribeThread,
26 WS_METHODS.gitRunStackedAction,28 WS_METHODS.gitRunStackedAction,
27 WS_METHODS.subscribeGitStatus,29 WS_METHODS.subscribeGitStatus,
28 WS_METHODS.subscribeOrchestrationDomainEvents,
29 WS_METHODS.subscribeTerminalEvents,30 WS_METHODS.subscribeTerminalEvents,
30 WS_METHODS.subscribeServerConfig,31 WS_METHODS.subscribeServerConfig,
31 WS_METHODS.subscribeServerLifecycle,32 WS_METHODS.subscribeServerLifecycle,
packages/contracts/src/ipc.ts+12-6
...@@ -46,8 +46,9 @@ import type {...@@ -46,8 +46,9 @@ import type {
46 OrchestrationGetFullThreadDiffResult,46 OrchestrationGetFullThreadDiffResult,
47 OrchestrationGetTurnDiffInput,47 OrchestrationGetTurnDiffInput,
48 OrchestrationGetTurnDiffResult,48 OrchestrationGetTurnDiffResult,
49 OrchestrationEvent,49 OrchestrationShellStreamItem,
50 OrchestrationReadModel,50 OrchestrationSubscribeThreadInput,
51 OrchestrationThreadStreamItem,
51} from "./orchestration";52} from "./orchestration";
52import type { EnvironmentId } from "./baseSchemas";53import type { EnvironmentId } from "./baseSchemas";
53import { EditorId } from "./editor";54import { EditorId } from "./editor";
...@@ -249,15 +250,20 @@ export interface EnvironmentApi {...@@ -249,15 +250,20 @@ export interface EnvironmentApi {
249 ) => () => void;250 ) => () => void;
250 };251 };
251 orchestration: {252 orchestration: {
252 getSnapshot: () => Promise<OrchestrationReadModel>;
253 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;253 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
254 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;254 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
255 getFullThreadDiff: (255 getFullThreadDiff: (
256 input: OrchestrationGetFullThreadDiffInput,256 input: OrchestrationGetFullThreadDiffInput,
257 ) => Promise<OrchestrationGetFullThreadDiffResult>;257 ) => Promise<OrchestrationGetFullThreadDiffResult>;
258 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;258 subscribeShell: (
259 onDomainEvent: (259 callback: (event: OrchestrationShellStreamItem) => void,
260 callback: (event: OrchestrationEvent) => void,260 options?: {
261 onResubscribe?: () => void;
262 },
263 ) => () => void;
264 subscribeThread: (
265 input: OrchestrationSubscribeThreadInput,
266 callback: (event: OrchestrationThreadStreamItem) => void,
261 options?: {267 options?: {
262 onResubscribe?: () => void;268 onResubscribe?: () => void;
263 },269 },
packages/contracts/src/orchestration.ts+109-10
...@@ -17,11 +17,12 @@ import {...@@ -17,11 +17,12 @@ import {
17} from "./baseSchemas";17} from "./baseSchemas";
1818
19export const ORCHESTRATION_WS_METHODS = {19export const ORCHESTRATION_WS_METHODS = {
20 getSnapshot: "orchestration.getSnapshot",
21 dispatchCommand: "orchestration.dispatchCommand",20 dispatchCommand: "orchestration.dispatchCommand",
22 getTurnDiff: "orchestration.getTurnDiff",21 getTurnDiff: "orchestration.getTurnDiff",
23 getFullThreadDiff: "orchestration.getFullThreadDiff",22 getFullThreadDiff: "orchestration.getFullThreadDiff",
24 replayEvents: "orchestration.replayEvents",23 replayEvents: "orchestration.replayEvents",
24 subscribeShell: "orchestration.subscribeShell",
25 subscribeThread: "orchestration.subscribeThread",
25} as const;26} as const;
2627
27export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);28export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);
...@@ -308,6 +309,93 @@ export const OrchestrationReadModel = Schema.Struct({...@@ -308,6 +309,93 @@ export const OrchestrationReadModel = Schema.Struct({
308});309});
309export type OrchestrationReadModel = typeof OrchestrationReadModel.Type;310export type OrchestrationReadModel = typeof OrchestrationReadModel.Type;
310311
312export const OrchestrationProjectShell = Schema.Struct({
313 id: ProjectId,
314 title: TrimmedNonEmptyString,
315 workspaceRoot: TrimmedNonEmptyString,
316 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
317 defaultModelSelection: Schema.NullOr(ModelSelection),
318 scripts: Schema.Array(ProjectScript),
319 createdAt: IsoDateTime,
320 updatedAt: IsoDateTime,
321});
322export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type;
323
324export const OrchestrationThreadShell = Schema.Struct({
325 id: ThreadId,
326 projectId: ProjectId,
327 title: TrimmedNonEmptyString,
328 modelSelection: ModelSelection,
329 runtimeMode: RuntimeMode,
330 interactionMode: ProviderInteractionMode.pipe(
331 Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)),
332 ),
333 branch: Schema.NullOr(TrimmedNonEmptyString),
334 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
335 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
336 createdAt: IsoDateTime,
337 updatedAt: IsoDateTime,
338 archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
339 session: Schema.NullOr(OrchestrationSession),
340 latestUserMessageAt: Schema.NullOr(IsoDateTime),
341 hasPendingApprovals: Schema.Boolean,
342 hasPendingUserInput: Schema.Boolean,
343 hasActionableProposedPlan: Schema.Boolean,
344});
345export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
346
347export const OrchestrationShellSnapshot = Schema.Struct({
348 snapshotSequence: NonNegativeInt,
349 projects: Schema.Array(OrchestrationProjectShell),
350 threads: Schema.Array(OrchestrationThreadShell),
351 updatedAt: IsoDateTime,
352});
353export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type;
354
355export const OrchestrationShellStreamEvent = Schema.Union([
356 Schema.Struct({
357 kind: Schema.Literal("project-upserted"),
358 sequence: NonNegativeInt,
359 project: OrchestrationProjectShell,
360 }),
361 Schema.Struct({
362 kind: Schema.Literal("project-removed"),
363 sequence: NonNegativeInt,
364 projectId: ProjectId,
365 }),
366 Schema.Struct({
367 kind: Schema.Literal("thread-upserted"),
368 sequence: NonNegativeInt,
369 thread: OrchestrationThreadShell,
370 }),
371 Schema.Struct({
372 kind: Schema.Literal("thread-removed"),
373 sequence: NonNegativeInt,
374 threadId: ThreadId,
375 }),
376]);
377export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type;
378
379export const OrchestrationShellStreamItem = Schema.Union([
380 Schema.Struct({
381 kind: Schema.Literal("snapshot"),
382 snapshot: OrchestrationShellSnapshot,
383 }),
384 OrchestrationShellStreamEvent,
385]);
386export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type;
387
388export const OrchestrationSubscribeThreadInput = Schema.Struct({
389 threadId: ThreadId,
390});
391export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
392
393export const OrchestrationThreadDetailSnapshot = Schema.Struct({
394 snapshotSequence: NonNegativeInt,
395 thread: OrchestrationThread,
396});
397export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type;
398
311export const ProjectCreateCommand = Schema.Struct({399export const ProjectCreateCommand = Schema.Struct({
312 type: Schema.Literal("project.create"),400 type: Schema.Literal("project.create"),
313 commandId: CommandId,401 commandId: CommandId,
...@@ -955,6 +1043,18 @@ export const OrchestrationEvent = Schema.Union([...@@ -955,6 +1043,18 @@ export const OrchestrationEvent = Schema.Union([
955]);1043]);
956export type OrchestrationEvent = typeof OrchestrationEvent.Type;1044export type OrchestrationEvent = typeof OrchestrationEvent.Type;
9571045
1046export const OrchestrationThreadStreamItem = Schema.Union([
1047 Schema.Struct({
1048 kind: Schema.Literal("snapshot"),
1049 snapshot: OrchestrationThreadDetailSnapshot,
1050 }),
1051 Schema.Struct({
1052 kind: Schema.Literal("event"),
1053 event: OrchestrationEvent,
1054 }),
1055]);
1056export type OrchestrationThreadStreamItem = typeof OrchestrationThreadStreamItem.Type;
1057
958export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);1058export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]);
959export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;1059export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type;
9601060
...@@ -1019,11 +1119,6 @@ export const DispatchResult = Schema.Struct({...@@ -1019,11 +1119,6 @@ export const DispatchResult = Schema.Struct({
1019});1119});
1020export type DispatchResult = typeof DispatchResult.Type;1120export type DispatchResult = typeof DispatchResult.Type;
10211121
1022export const OrchestrationGetSnapshotInput = Schema.Struct({});
1023export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type;
1024const OrchestrationGetSnapshotResult = OrchestrationReadModel;
1025export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;
1026
1027export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(1122export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
1028 Struct.assign({ threadId: ThreadId }),1123 Struct.assign({ threadId: ThreadId }),
1029 { unsafePreserveChecks: true },1124 { unsafePreserveChecks: true },
...@@ -1051,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent);...@@ -1051,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent);
1051export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type;1146export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type;
10521147
1053export const OrchestrationRpcSchemas = {1148export const OrchestrationRpcSchemas = {
1054 getSnapshot: {
1055 input: OrchestrationGetSnapshotInput,
1056 output: OrchestrationGetSnapshotResult,
1057 },
1058 dispatchCommand: {1149 dispatchCommand: {
1059 input: ClientOrchestrationCommand,1150 input: ClientOrchestrationCommand,
1060 output: DispatchResult,1151 output: DispatchResult,
...@@ -1071,6 +1162,14 @@ export const OrchestrationRpcSchemas = {...@@ -1071,6 +1162,14 @@ export const OrchestrationRpcSchemas = {
1071 input: OrchestrationReplayEventsInput,1162 input: OrchestrationReplayEventsInput,
1072 output: OrchestrationReplayEventsResult,1163 output: OrchestrationReplayEventsResult,
1073 },1164 },
1165 subscribeThread: {
1166 input: OrchestrationSubscribeThreadInput,
1167 output: OrchestrationThreadStreamItem,
1168 },
1169 subscribeShell: {
1170 input: Schema.Struct({}),
1171 output: OrchestrationShellStreamItem,
1172 },
1074} as const;1173} as const;
10751174
1076export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()(1175export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()(
packages/contracts/src/rpc.ts+14-15
...@@ -32,13 +32,11 @@ import {...@@ -32,13 +32,11 @@ import {
32import { KeybindingsConfigError } from "./keybindings";32import { KeybindingsConfigError } from "./keybindings";
33import {33import {
34 ClientOrchestrationCommand,34 ClientOrchestrationCommand,
35 OrchestrationEvent,
36 ORCHESTRATION_WS_METHODS,35 ORCHESTRATION_WS_METHODS,
37 OrchestrationDispatchCommandError,36 OrchestrationDispatchCommandError,
38 OrchestrationGetFullThreadDiffError,37 OrchestrationGetFullThreadDiffError,
39 OrchestrationGetFullThreadDiffInput,38 OrchestrationGetFullThreadDiffInput,
40 OrchestrationGetSnapshotError,39 OrchestrationGetSnapshotError,
41 OrchestrationGetSnapshotInput,
42 OrchestrationGetTurnDiffError,40 OrchestrationGetTurnDiffError,
43 OrchestrationGetTurnDiffInput,41 OrchestrationGetTurnDiffInput,
44 OrchestrationReplayEventsError,42 OrchestrationReplayEventsError,
...@@ -115,7 +113,6 @@ export const WS_METHODS = {...@@ -115,7 +113,6 @@ export const WS_METHODS = {
115113
116 // Streaming subscriptions114 // Streaming subscriptions
117 subscribeGitStatus: "subscribeGitStatus",115 subscribeGitStatus: "subscribeGitStatus",
118 subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",
119 subscribeTerminalEvents: "subscribeTerminalEvents",116 subscribeTerminalEvents: "subscribeTerminalEvents",
120 subscribeServerConfig: "subscribeServerConfig",117 subscribeServerConfig: "subscribeServerConfig",
121 subscribeServerLifecycle: "subscribeServerLifecycle",118 subscribeServerLifecycle: "subscribeServerLifecycle",
...@@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {...@@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
272 error: TerminalError,269 error: TerminalError,
273});270});
274271
275export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, {
276 payload: OrchestrationGetSnapshotInput,
277 success: OrchestrationRpcSchemas.getSnapshot.output,
278 error: OrchestrationGetSnapshotError,
279});
280
281export const WsOrchestrationDispatchCommandRpc = Rpc.make(272export const WsOrchestrationDispatchCommandRpc = Rpc.make(
282 ORCHESTRATION_WS_METHODS.dispatchCommand,273 ORCHESTRATION_WS_METHODS.dispatchCommand,
283 {274 {
...@@ -308,11 +299,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS....@@ -308,11 +299,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.
308 error: OrchestrationReplayEventsError,299 error: OrchestrationReplayEventsError,
309});300});
310301
311export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make(302export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {
312 WS_METHODS.subscribeOrchestrationDomainEvents,303 payload: OrchestrationRpcSchemas.subscribeShell.input,
304 success: OrchestrationRpcSchemas.subscribeShell.output,
305 error: OrchestrationGetSnapshotError,
306 stream: true,
307});
308
309export const WsOrchestrationSubscribeThreadRpc = Rpc.make(
310 ORCHESTRATION_WS_METHODS.subscribeThread,
313 {311 {
314 payload: Schema.Struct({}),312 payload: OrchestrationRpcSchemas.subscribeThread.input,
315 success: OrchestrationEvent,313 success: OrchestrationRpcSchemas.subscribeThread.output,
314 error: OrchestrationGetSnapshotError,
316 stream: true,315 stream: true,
317 },316 },
318);317);
...@@ -369,14 +368,14 @@ export const WsRpcGroup = RpcGroup.make(...@@ -369,14 +368,14 @@ export const WsRpcGroup = RpcGroup.make(
369 WsTerminalClearRpc,368 WsTerminalClearRpc,
370 WsTerminalRestartRpc,369 WsTerminalRestartRpc,
371 WsTerminalCloseRpc,370 WsTerminalCloseRpc,
372 WsSubscribeOrchestrationDomainEventsRpc,
373 WsSubscribeTerminalEventsRpc,371 WsSubscribeTerminalEventsRpc,
374 WsSubscribeServerConfigRpc,372 WsSubscribeServerConfigRpc,
375 WsSubscribeServerLifecycleRpc,373 WsSubscribeServerLifecycleRpc,
376 WsSubscribeAuthAccessRpc,374 WsSubscribeAuthAccessRpc,
377 WsOrchestrationGetSnapshotRpc,
378 WsOrchestrationDispatchCommandRpc,375 WsOrchestrationDispatchCommandRpc,
379 WsOrchestrationGetTurnDiffRpc,376 WsOrchestrationGetTurnDiffRpc,
380 WsOrchestrationGetFullThreadDiffRpc,377 WsOrchestrationGetFullThreadDiffRpc,
381 WsOrchestrationReplayEventsRpc,378 WsOrchestrationReplayEventsRpc,
379 WsOrchestrationSubscribeShellRpc,
380 WsOrchestrationSubscribeThreadRpc,
382);381);