| author | |
| committer | |
| log | f7fa62aaf90d0bd191acf99b65889f0d20d6ce97 |
| tree | ac01190a67cc28ac9665b156febcffbef167d453 |
| parent | 57d7746a5d844281caae451702475d7bcf967b9d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 82 | Layer.succeed(ProjectionSnapshotQuery, { |
| 83 | 83 | getSnapshot: () => |
| 84 | 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 | 87 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 86 | 88 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 89 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 87 | 90 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 88 | 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 | 141 | Layer.succeed(ProjectionSnapshotQuery, { |
| 137 | 142 | getSnapshot: () => |
| 138 | 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 | 146 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 140 | 147 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 148 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 141 | 149 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 142 | 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 | 144 | Layer.provide( |
| 145 | 145 | Layer.succeed(ProjectionSnapshotQuery, { |
| 146 | 146 | getSnapshot: () => Effect.succeed(projectionSnapshot), |
| 147 | getShellSnapshot: () => | |
| 148 | Effect.succeed({ | |
| 149 | snapshotSequence: projectionSnapshot.snapshotSequence, | |
| 150 | projects: [], | |
| 151 | threads: [], | |
| 152 | updatedAt: projectionSnapshot.updatedAt, | |
| 153 | }), | |
| 147 | 154 | getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), |
| 148 | 155 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 156 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 149 | 157 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 150 | 158 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 159 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 160 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 151 | 161 | }), |
| 152 | 162 | ), |
| 153 | 163 | Layer.provide( |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+125-1| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | ApprovalRequestId, |
| 3 | 3 | type ChatAttachment, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | ThreadId, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; |
| 7 | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | ... | @@ -89,6 +90,77 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| 89 | 90 | return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; |
| 90 | 91 | } |
| 91 | 92 | |
| 93 | function 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 | ||
| 137 | function 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 | ||
| 92 | 164 | function retainProjectionMessagesAfterRevert( |
| 93 | 165 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| 94 | 166 | turns: ReadonlyArray<ProjectionTurn>, |
| ... | ... | @@ -432,6 +504,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 432 | 504 | } |
| 433 | 505 | }); |
| 434 | 506 | |
| 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 | 549 | const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( |
| 436 | 550 | "applyThreadsProjection", |
| 437 | 551 | )(function* (event, attachmentSideEffects) { |
| ... | ... | @@ -450,6 +564,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 450 | 564 | createdAt: event.payload.createdAt, |
| 451 | 565 | updatedAt: event.payload.updatedAt, |
| 452 | 566 | archivedAt: null, |
| 567 | latestUserMessageAt: null, | |
| 568 | pendingApprovalCount: 0, | |
| 569 | pendingUserInputCount: 0, | |
| 570 | hasActionableProposedPlan: 0, | |
| 453 | 571 | deletedAt: null, |
| 454 | 572 | }); |
| 455 | 573 | return; |
| ... | ... | @@ -554,7 +672,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 554 | 672 | |
| 555 | 673 | case "thread.message-sent": |
| 556 | 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 | 678 | const existingRow = yield* projectionThreadRepository.getById({ |
| 559 | 679 | threadId: event.payload.threadId, |
| 560 | 680 | }); |
| ... | ... | @@ -565,6 +685,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 565 | 685 | ...existingRow.value, |
| 566 | 686 | updatedAt: event.occurredAt, |
| 567 | 687 | }); |
| 688 | yield* refreshThreadShellSummary(event.payload.threadId); | |
| 568 | 689 | return; |
| 569 | 690 | } |
| 570 | 691 | |
| ... | ... | @@ -580,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 580 | 701 | latestTurnId: event.payload.session.activeTurnId, |
| 581 | 702 | updatedAt: event.occurredAt, |
| 582 | 703 | }); |
| 704 | yield* refreshThreadShellSummary(event.payload.threadId); | |
| 583 | 705 | return; |
| 584 | 706 | } |
| 585 | 707 | |
| ... | ... | @@ -595,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 595 | 717 | latestTurnId: event.payload.turnId, |
| 596 | 718 | updatedAt: event.occurredAt, |
| 597 | 719 | }); |
| 720 | yield* refreshThreadShellSummary(event.payload.threadId); | |
| 598 | 721 | return; |
| 599 | 722 | } |
| 600 | 723 | |
| ... | ... | @@ -610,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 610 | 733 | latestTurnId: null, |
| 611 | 734 | updatedAt: event.occurredAt, |
| 612 | 735 | }); |
| 736 | yield* refreshThreadShellSummary(event.payload.threadId); | |
| 613 | 737 | return; |
| 614 | 738 | } |
| 615 | 739 |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+392| ... | ... | @@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 62 | 62 | project_id, |
| 63 | 63 | title, |
| 64 | 64 | model_selection_json, |
| 65 | runtime_mode, | |
| 66 | interaction_mode, | |
| 65 | 67 | branch, |
| 66 | 68 | worktree_path, |
| 67 | 69 | latest_turn_id, |
| 70 | latest_user_message_at, | |
| 71 | pending_approval_count, | |
| 72 | pending_user_input_count, | |
| 73 | has_actionable_proposed_plan, | |
| 68 | 74 | created_at, |
| 69 | 75 | updated_at, |
| 70 | 76 | deleted_at |
| ... | ... | @@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 74 | 80 | 'project-1', |
| 75 | 81 | 'Thread 1', |
| 76 | 82 | '{"provider":"codex","model":"gpt-5-codex"}', |
| 83 | 'full-access', | |
| 84 | 'default', | |
| 77 | 85 | NULL, |
| 78 | 86 | NULL, |
| 79 | 87 | 'turn-1', |
| 88 | '2026-02-24T00:00:04.000Z', | |
| 89 | 1, | |
| 90 | 0, | |
| 91 | 0, | |
| 80 | 92 | '2026-02-24T00:00:02.000Z', |
| 81 | 93 | '2026-02-24T00:00:03.000Z', |
| 82 | 94 | NULL |
| ... | ... | @@ -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 | ); |
| 346 | 433 | |
| ... | ... | @@ -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 | 6 | OrchestrationCheckpointFile, |
| 7 | 7 | OrchestrationProposedPlanId, |
| 8 | 8 | OrchestrationReadModel, |
| 9 | OrchestrationShellSnapshot, | |
| 10 | OrchestrationThread, | |
| 9 | 11 | ProjectScript, |
| 10 | 12 | TurnId, |
| 11 | 13 | type OrchestrationCheckpointSummary, |
| 12 | 14 | type OrchestrationLatestTurn, |
| 13 | 15 | type OrchestrationMessage, |
| 16 | type OrchestrationProjectShell, | |
| 14 | 17 | type OrchestrationProposedPlan, |
| 15 | 18 | type OrchestrationProject, |
| 16 | 19 | type OrchestrationSession, |
| 17 | type OrchestrationThread, | |
| 18 | 20 | type OrchestrationThreadActivity, |
| 21 | type OrchestrationThreadShell, | |
| 19 | 22 | ModelSelection, |
| 20 | 23 | ProjectId, |
| 21 | 24 | ThreadId, |
| ... | ... | @@ -48,6 +51,8 @@ import { |
| 48 | 51 | } from "../Services/ProjectionSnapshotQuery.ts"; |
| 49 | 52 | |
| 50 | 53 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); |
| 54 | const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); | |
| 55 | const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); | |
| 51 | 56 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 52 | 57 | Struct.assign({ |
| 53 | 58 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| ... | ... | @@ -155,6 +160,64 @@ function computeSnapshotSequence( |
| 155 | 160 | return Number.isFinite(minSequence) ? minSequence : 0; |
| 156 | 161 | } |
| 157 | 162 | |
| 163 | function 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 | ||
| 191 | function 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 | ||
| 205 | function 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 | ||
| 158 | 221 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 159 | 222 | return (cause: unknown): ProjectionRepositoryError => |
| 160 | 223 | Schema.isSchemaError(cause) |
| ... | ... | @@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 204 | 267 | created_at AS "createdAt", |
| 205 | 268 | updated_at AS "updatedAt", |
| 206 | 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 | 274 | deleted_at AS "deletedAt" |
| 208 | 275 | FROM projection_threads |
| 209 | 276 | ORDER BY created_at ASC, thread_id ASC |
| ... | ... | @@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 381 | 448 | `, |
| 382 | 449 | }); |
| 383 | 450 | |
| 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 | 472 | const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ |
| 385 | 473 | Request: ProjectIdLookupInput, |
| 386 | 474 | Result: ProjectionThreadIdLookupRowSchema, |
| ... | ... | @@ -415,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 415 | 503 | `, |
| 416 | 504 | }); |
| 417 | 505 | |
| 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 | 646 | const listCheckpointRowsByThread = SqlSchema.findAll({ |
| 419 | 647 | Request: ThreadIdLookupInput, |
| 420 | 648 | Result: ProjectionCheckpointDbRowSchema, |
| ... | ... | @@ -724,6 +952,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 724 | 952 | }), |
| 725 | 953 | ); |
| 726 | 954 | |
| 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 | 1094 | const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => |
| 728 | 1095 | readProjectionCounts(undefined).pipe( |
| 729 | 1096 | Effect.mapError( |
| ... | ... | @@ -770,6 +1137,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 770 | 1137 | ), |
| 771 | 1138 | ); |
| 772 | 1139 | |
| 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 | 1161 | const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = |
| 774 | 1162 | (projectId) => |
| 775 | 1163 | getFirstActiveThreadIdByProject({ projectId }).pipe( |
| ... | ... | @@ -826,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 826 | 1214 | }); |
| 827 | 1215 | }); |
| 828 | 1216 | |
| 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 | 1418 | return { |
| 830 | 1419 | getSnapshot, |
| 1420 | getShellSnapshot, | |
| 831 | 1421 | getCounts, |
| 832 | 1422 | getActiveProjectByWorkspaceRoot, |
| 1423 | getProjectShellById, | |
| 833 | 1424 | getFirstActiveThreadIdByProjectId, |
| 834 | 1425 | getThreadCheckpointContext, |
| 1426 | getThreadShellById, | |
| 1427 | getThreadDetailById, | |
| 835 | 1428 | } satisfies ProjectionSnapshotQueryShape; |
| 836 | 1429 | }); |
| 837 | 1430 |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+36| ... | ... | @@ -9,7 +9,11 @@ |
| 9 | 9 | import type { |
| 10 | 10 | OrchestrationCheckpointSummary, |
| 11 | 11 | OrchestrationProject, |
| 12 | OrchestrationProjectShell, | |
| 12 | 13 | OrchestrationReadModel, |
| 14 | OrchestrationShellSnapshot, | |
| 15 | OrchestrationThread, | |
| 16 | OrchestrationThreadShell, | |
| 13 | 17 | ProjectId, |
| 14 | 18 | ThreadId, |
| 15 | 19 | } from "@t3tools/contracts"; |
| ... | ... | @@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape { |
| 44 | 48 | */ |
| 45 | 49 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>; |
| 46 | 50 | |
| 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 | 63 | * Read aggregate projection counts without hydrating the full read model. |
| 49 | 64 | */ |
| ... | ... | @@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape { |
| 56 | 71 | workspaceRoot: string, |
| 57 | 72 | ) => Effect.Effect<Option.Option<OrchestrationProject>, ProjectionRepositoryError>; |
| 58 | 73 | |
| 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 | 82 | * Read the earliest active thread for a project. |
| 61 | 83 | */ |
| ... | ... | @@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape { |
| 69 | 91 | readonly getThreadCheckpointContext: ( |
| 70 | 92 | threadId: ThreadId, |
| 71 | 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 | } |
| 73 | 109 | |
| 74 | 110 | /** |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+4| ... | ... | @@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 88 | 88 | createdAt: "2026-03-24T00:00:00.000Z", |
| 89 | 89 | updatedAt: "2026-03-24T00:00:00.000Z", |
| 90 | 90 | archivedAt: null, |
| 91 | latestUserMessageAt: null, | |
| 92 | pendingApprovalCount: 0, | |
| 93 | pendingUserInputCount: 0, | |
| 94 | hasActionableProposedPlan: 0, | |
| 91 | 95 | deletedAt: null, |
| 92 | 96 | }); |
| 93 | 97 |
apps/server/src/persistence/Layers/ProjectionThreads.ts+20| ... | ... | @@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 40 | 40 | created_at, |
| 41 | 41 | updated_at, |
| 42 | 42 | archived_at, |
| 43 | latest_user_message_at, | |
| 44 | pending_approval_count, | |
| 45 | pending_user_input_count, | |
| 46 | has_actionable_proposed_plan, | |
| 43 | 47 | deleted_at |
| 44 | 48 | ) |
| 45 | 49 | VALUES ( |
| ... | ... | @@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 55 | 59 | ${row.createdAt}, |
| 56 | 60 | ${row.updatedAt}, |
| 57 | 61 | ${row.archivedAt}, |
| 62 | ${row.latestUserMessageAt}, | |
| 63 | ${row.pendingApprovalCount}, | |
| 64 | ${row.pendingUserInputCount}, | |
| 65 | ${row.hasActionableProposedPlan}, | |
| 58 | 66 | ${row.deletedAt} |
| 59 | 67 | ) |
| 60 | 68 | ON CONFLICT (thread_id) |
| ... | ... | @@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 70 | 78 | created_at = excluded.created_at, |
| 71 | 79 | updated_at = excluded.updated_at, |
| 72 | 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 | 85 | deleted_at = excluded.deleted_at |
| 74 | 86 | `, |
| 75 | 87 | }); |
| ... | ... | @@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 92 | 104 | created_at AS "createdAt", |
| 93 | 105 | updated_at AS "updatedAt", |
| 94 | 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 | 111 | deleted_at AS "deletedAt" |
| 96 | 112 | FROM projection_threads |
| 97 | 113 | WHERE thread_id = ${threadId} |
| ... | ... | @@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 116 | 132 | created_at AS "createdAt", |
| 117 | 133 | updated_at AS "updatedAt", |
| 118 | 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 | 139 | deleted_at AS "deletedAt" |
| 120 | 140 | FROM projection_threads |
| 121 | 141 | WHERE project_id = ${projectId} |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; |
| 35 | 35 | import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; |
| 36 | 36 | import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; |
| 37 | 37 | import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; |
| 38 | import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; | |
| 38 | 39 | |
| 39 | 40 | /** |
| 40 | 41 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -69,6 +70,7 @@ export const migrationEntries = [ |
| 69 | 70 | [20, "AuthAccessManagement", Migration0020], |
| 70 | 71 | [21, "AuthSessionClientMetadata", Migration0021], |
| 71 | 72 | [22, "AuthSessionLastConnectedAt", Migration0022], |
| 73 | [23, "ProjectionThreadShellSummary", Migration0023], | |
| 72 | 74 | ] as const; |
| 73 | 75 | |
| 74 | 76 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | ||
| 4 | export 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 | 9 | import { |
| 10 | 10 | IsoDateTime, |
| 11 | 11 | ModelSelection, |
| 12 | NonNegativeInt, | |
| 12 | 13 | ProjectId, |
| 13 | 14 | ProviderInteractionMode, |
| 14 | 15 | RuntimeMode, |
| ... | ... | @@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({ |
| 33 | 34 | createdAt: IsoDateTime, |
| 34 | 35 | updatedAt: IsoDateTime, |
| 35 | 36 | archivedAt: Schema.NullOr(IsoDateTime), |
| 37 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | |
| 38 | pendingApprovalCount: NonNegativeInt, | |
| 39 | pendingUserInputCount: NonNegativeInt, | |
| 40 | hasActionableProposedPlan: NonNegativeInt, | |
| 36 | 41 | deletedAt: Schema.NullOr(IsoDateTime), |
| 37 | 42 | }); |
| 38 | 43 | export type ProjectionThread = typeof ProjectionThread.Type; |
apps/server/src/server.test.ts+15-234| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | FileSystem, |
| 32 | 32 | Layer, |
| 33 | 33 | ManagedRuntime, |
| 34 | Option, | |
| 34 | 35 | Path, |
| 35 | 36 | Stream, |
| 36 | 37 | } from "effect"; |
| ... | ... | @@ -68,7 +69,6 @@ import { |
| 68 | 69 | ProjectionSnapshotQuery, |
| 69 | 70 | type ProjectionSnapshotQueryShape, |
| 70 | 71 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 71 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | |
| 72 | 72 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 73 | 73 | import { |
| 74 | 74 | ProviderRegistry, |
| ... | ... | @@ -410,6 +410,20 @@ const buildAppUnderTest = (options?: { |
| 410 | 410 | Layer.provide( |
| 411 | 411 | Layer.mock(ProjectionSnapshotQuery)({ |
| 412 | 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 | 427 | ...options?.layers?.projectionSnapshotQuery, |
| 414 | 428 | }), |
| 415 | 429 | ), |
| ... | ... | @@ -2748,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2748 | 2762 | }); |
| 2749 | 2763 | |
| 2750 | 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 | 2765 | const dispatchResult = yield* Effect.scoped( |
| 2757 | 2766 | withWsRpcClient(wsUrl, (client) => |
| 2758 | 2767 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ |
| ... | ... | @@ -3306,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3306 | 3315 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3307 | 3316 | ); |
| 3308 | 3317 | |
| 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 | 3318 | it.effect("routes websocket rpc terminal methods", () => |
| 3538 | 3319 | Effect.gen(function* () { |
| 3539 | 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 | 70 | yield* launchStartupHeartbeat.pipe( |
| 71 | 71 | Effect.provideService(ProjectionSnapshotQuery, { |
| 72 | 72 | getSnapshot: () => Effect.die("unused"), |
| 73 | getShellSnapshot: () => Effect.die("unused"), | |
| 73 | 74 | getCounts: () => |
| 74 | 75 | Deferred.await(releaseCounts).pipe( |
| 75 | 76 | Effect.as({ |
| ... | ... | @@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa |
| 78 | 79 | }), |
| 79 | 80 | ), |
| 80 | 81 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 82 | getProjectShellById: () => Effect.succeed(Option.none()), | |
| 81 | 83 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 82 | 84 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 85 | getThreadShellById: () => Effect.succeed(Option.none()), | |
| 86 | getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 83 | 87 | }), |
| 84 | 88 | Effect.provideService(AnalyticsService, { |
| 85 | 89 | record: () => Effect.void, |
apps/server/src/ws.ts+146-66| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; | |
| 1 | import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; | |
| 2 | 2 | import { |
| 3 | 3 | type AuthAccessStreamEvent, |
| 4 | 4 | AuthSessionId, |
| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | type GitManagerServiceError, |
| 10 | 10 | OrchestrationDispatchCommandError, |
| 11 | 11 | type OrchestrationEvent, |
| 12 | type OrchestrationShellStreamEvent, | |
| 12 | 13 | OrchestrationGetFullThreadDiffError, |
| 13 | 14 | OrchestrationGetSnapshotError, |
| 14 | 15 | OrchestrationGetTurnDiffError, |
| ... | ... | @@ -62,6 +63,28 @@ import { |
| 62 | 63 | } from "./auth/Services/SessionCredentialService"; |
| 63 | 64 | import { respondToAuthError } from "./auth/http"; |
| 64 | 65 | |
| 66 | function 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 | ||
| 65 | 88 | function toAuthAccessStreamEvent( |
| 66 | 89 | change: BootstrapCredentialChange | SessionCredentialChange, |
| 67 | 90 | revision: number, |
| ... | ... | @@ -222,6 +245,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 222 | 245 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => |
| 223 | 246 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); |
| 224 | 247 | |
| 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 | 299 | const dispatchBootstrapTurnStart = ( |
| 226 | 300 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, |
| 227 | 301 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => |
| ... | ... | @@ -467,20 +541,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 467 | 541 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); |
| 468 | 542 | |
| 469 | 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 | 544 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => |
| 485 | 545 | observeRpcEffect( |
| 486 | 546 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| ... | ... | @@ -561,65 +621,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 561 | 621 | ), |
| 562 | 622 | { "rpc.aggregate": "orchestration" }, |
| 563 | 623 | ), |
| 564 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | |
| 624 | [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => | |
| 565 | 625 | observeRpcStreamEffect( |
| 566 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 626 | ORCHESTRATION_WS_METHODS.subscribeShell, | |
| 567 | 627 | Effect.gen(function* () { |
| 568 | const snapshot = yield* orchestrationEngine.getReadModel(); | |
| 569 | const fromSequenceExclusive = snapshot.snapshotSequence; | |
| 570 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | |
| 571 | orchestrationEngine.readEvents(fromSequenceExclusive), | |
| 572 | ).pipe( | |
| 573 | Effect.map((events) => Array.from(events)), | |
| 574 | Effect.flatMap(enrichOrchestrationEvents), | |
| 575 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | |
| 628 | const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( | |
| 629 | Effect.mapError( | |
| 630 | (cause) => | |
| 631 | new OrchestrationGetSnapshotError({ | |
| 632 | message: "Failed to load orchestration shell snapshot", | |
| 633 | cause, | |
| 634 | }), | |
| 635 | ), | |
| 576 | 636 | ); |
| 577 | const replayStream = Stream.fromIterable(replayEvents); | |
| 637 | ||
| 578 | 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 | }); | |
| 590 | 644 | |
| 591 | return source.pipe( | |
| 592 | Stream.mapEffect((event) => | |
| 593 | Ref.modify( | |
| 594 | state, | |
| 595 | ({ | |
| 596 | nextSequence, | |
| 597 | pendingBySequence, | |
| 598 | }): [Array<OrchestrationEvent>, SequenceState] => { | |
| 599 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | |
| 600 | return [[], { nextSequence, pendingBySequence }]; | |
| 601 | } | |
| 602 | ||
| 603 | const updatedPending = new Map(pendingBySequence); | |
| 604 | updatedPending.set(event.sequence, event); | |
| 605 | ||
| 606 | const emit: Array<OrchestrationEvent> = []; | |
| 607 | let expected = nextSequence; | |
| 608 | for (;;) { | |
| 609 | const expectedEvent = updatedPending.get(expected); | |
| 610 | if (!expectedEvent) { | |
| 611 | break; | |
| 612 | } | |
| 613 | emit.push(expectedEvent); | |
| 614 | updatedPending.delete(expected); | |
| 615 | expected += 1; | |
| 616 | } | |
| 617 | ||
| 618 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | |
| 619 | }, | |
| 645 | return Stream.concat( | |
| 646 | Stream.make({ | |
| 647 | kind: "snapshot" as const, | |
| 648 | snapshot, | |
| 649 | }), | |
| 650 | liveStream, | |
| 651 | ); | |
| 652 | }), | |
| 653 | { "rpc.aggregate": "orchestration" }, | |
| 654 | ), | |
| 655 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => | |
| 656 | observeRpcStreamEffect( | |
| 657 | ORCHESTRATION_WS_METHODS.subscribeThread, | |
| 658 | Effect.gen(function* () { | |
| 659 | const [threadDetail, snapshotSequence] = yield* Effect.all([ | |
| 660 | projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( | |
| 661 | Effect.mapError( | |
| 662 | (cause) => | |
| 663 | new OrchestrationGetSnapshotError({ | |
| 664 | message: `Failed to load thread ${input.threadId}`, | |
| 665 | cause, | |
| 666 | }), | |
| 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 | 705 | { "rpc.aggregate": "orchestration" }, |
apps/web/src/authBootstrap.test.ts+3-9| ... | ... | @@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 424 | 424 | expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); |
| 425 | 425 | }); |
| 426 | 426 | |
| 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 | 428 | const fetchMock = vi |
| 429 | 429 | .fn<typeof fetch>() |
| 430 | 430 | .mockResolvedValueOnce( |
| ... | ... | @@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => { |
| 459 | 459 | status: "authenticated", |
| 460 | 460 | }); |
| 461 | 461 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 462 | status: "requires-auth", | |
| 463 | auth: { | |
| 464 | policy: "loopback-browser", | |
| 465 | bootstrapMethods: ["one-time-token"], | |
| 466 | sessionMethods: ["browser-session-cookie"], | |
| 467 | sessionCookieName: "t3_session", | |
| 468 | }, | |
| 462 | status: "authenticated", | |
| 469 | 463 | }); |
| 470 | expect(fetchMock).toHaveBeenCalledTimes(2); | |
| 464 | expect(fetchMock).toHaveBeenCalledTimes(1); | |
| 471 | 465 | }); |
| 472 | 466 | |
| 473 | 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 | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | 7 | EnvironmentId, |
| 8 | 8 | type MessageId, |
| 9 | type OrchestrationEvent, | |
| 10 | 9 | type OrchestrationReadModel, |
| 11 | 10 | type ProjectId, |
| 12 | 11 | type ServerConfig, |
| ... | ... | @@ -112,6 +111,7 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { |
| 112 | 111 | textTolerancePx: 56, |
| 113 | 112 | attachmentTolerancePx: 56, |
| 114 | 113 | }; |
| 114 | ||
| 115 | 115 | interface MountedChatView { |
| 116 | 116 | [Symbol.asyncDispose]: () => Promise<void>; |
| 117 | 117 | cleanup: () => Promise<void>; |
| ... | ... | @@ -385,74 +385,94 @@ function addThreadToSnapshot( |
| 385 | 385 | }; |
| 386 | 386 | } |
| 387 | 387 | |
| 388 | function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | |
| 388 | function toShellThread(thread: OrchestrationReadModel["threads"][number]) { | |
| 389 | 389 | return { |
| 390 | sequence, | |
| 391 | eventId: EventId.make(`event-thread-created-${sequence}`), | |
| 392 | aggregateKind: "thread", | |
| 393 | aggregateId: threadId, | |
| 394 | occurredAt: NOW_ISO, | |
| 395 | commandId: null, | |
| 396 | causationEventId: null, | |
| 397 | correlationId: null, | |
| 398 | metadata: {}, | |
| 399 | type: "thread.created", | |
| 400 | payload: { | |
| 401 | threadId, | |
| 402 | projectId: PROJECT_ID, | |
| 403 | title: "New thread", | |
| 404 | modelSelection: { | |
| 405 | provider: "codex", | |
| 406 | model: "gpt-5", | |
| 407 | }, | |
| 408 | runtimeMode: "full-access", | |
| 409 | interactionMode: "default", | |
| 410 | branch: "main", | |
| 411 | worktreePath: null, | |
| 412 | createdAt: NOW_ISO, | |
| 413 | updatedAt: NOW_ISO, | |
| 414 | }, | |
| 390 | id: thread.id, | |
| 391 | projectId: thread.projectId, | |
| 392 | title: thread.title, | |
| 393 | modelSelection: thread.modelSelection, | |
| 394 | runtimeMode: thread.runtimeMode, | |
| 395 | interactionMode: thread.interactionMode, | |
| 396 | branch: thread.branch, | |
| 397 | worktreePath: thread.worktreePath, | |
| 398 | latestTurn: thread.latestTurn, | |
| 399 | createdAt: thread.createdAt, | |
| 400 | updatedAt: thread.updatedAt, | |
| 401 | archivedAt: thread.archivedAt, | |
| 402 | session: thread.session, | |
| 403 | latestUserMessageAt: | |
| 404 | thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, | |
| 405 | hasPendingApprovals: false, | |
| 406 | hasPendingUserInput: false, | |
| 407 | hasActionableProposedPlan: false, | |
| 415 | 408 | }; |
| 416 | 409 | } |
| 417 | 410 | |
| 418 | function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | |
| 411 | function toShellSnapshot(snapshot: OrchestrationReadModel) { | |
| 419 | 412 | return { |
| 420 | sequence, | |
| 421 | eventId: EventId.make(`event-thread-session-set-${sequence}`), | |
| 422 | aggregateKind: "thread", | |
| 423 | aggregateId: threadId, | |
| 424 | occurredAt: NOW_ISO, | |
| 425 | commandId: null, | |
| 426 | causationEventId: null, | |
| 427 | correlationId: null, | |
| 428 | metadata: {}, | |
| 429 | type: "thread.session-set", | |
| 430 | payload: { | |
| 431 | threadId, | |
| 432 | session: { | |
| 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 | }, | |
| 413 | snapshotSequence: snapshot.snapshotSequence, | |
| 414 | projects: snapshot.projects.map((project) => ({ | |
| 415 | id: project.id, | |
| 416 | title: project.title, | |
| 417 | workspaceRoot: project.workspaceRoot, | |
| 418 | repositoryIdentity: project.repositoryIdentity ?? null, | |
| 419 | defaultModelSelection: project.defaultModelSelection, | |
| 420 | scripts: project.scripts, | |
| 421 | createdAt: project.createdAt, | |
| 422 | updatedAt: project.updatedAt, | |
| 423 | })), | |
| 424 | threads: snapshot.threads.map(toShellThread), | |
| 425 | updatedAt: snapshot.updatedAt, | |
| 442 | 426 | }; |
| 443 | 427 | } |
| 444 | 428 | |
| 445 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { | |
| 446 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); | |
| 429 | function updateThreadSessionInSnapshot( | |
| 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 | ||
| 449 | function 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 | } |
| 448 | 470 | |
| 449 | 471 | async function waitForWsClient(): Promise<void> { |
| 450 | 472 | await vi.waitFor( |
| 451 | 473 | () => { |
| 452 | 474 | expect( |
| 453 | wsRequests.some( | |
| 454 | (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 455 | ), | |
| 475 | wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), | |
| 456 | 476 | ).toBe(true); |
| 457 | 477 | expect( |
| 458 | 478 | wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), |
| ... | ... | @@ -512,15 +532,21 @@ async function waitForAppBootstrap(): Promise<void> { |
| 512 | 532 | async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| 513 | 533 | await waitForWsClient(); |
| 514 | 534 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); |
| 515 | sendOrchestrationDomainEvent( | |
| 516 | createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), | |
| 517 | ); | |
| 535 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); | |
| 536 | sendShellThreadUpsert(threadId, { session: null }); | |
| 518 | 537 | } |
| 519 | 538 | |
| 520 | 539 | async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| 521 | sendOrchestrationDomainEvent( | |
| 522 | createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), | |
| 523 | ); | |
| 540 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { | |
| 541 | threadId, | |
| 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 | } |
| 525 | 551 | |
| 526 | 552 | async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| ... | ... | @@ -871,9 +897,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 871 | 897 | return customResult; |
| 872 | 898 | } |
| 873 | 899 | const tag = body._tag; |
| 874 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | |
| 875 | return fixture.snapshot; | |
| 876 | } | |
| 877 | 900 | if (tag === WS_METHODS.serverGetConfig) { |
| 878 | 901 | return fixture.serverConfig; |
| 879 | 902 | } |
| ... | ... | @@ -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 | 1517 | return []; |
| 1473 | 1518 | }, |
| 1474 | 1519 | }); |
| ... | ... | @@ -1510,7 +1555,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1510 | 1555 | customWsRpcResolver = null; |
| 1511 | 1556 | document.body.innerHTML = ""; |
| 1512 | 1557 | }); |
| 1513 | ||
| 1514 | 1558 | it("re-expands the bootstrap project using its scoped key", async () => { |
| 1515 | 1559 | useUiStateStore.setState({ |
| 1516 | 1560 | projectExpandedById: { |
apps/web/src/components/ChatView.logic.test.ts+136| ... | ... | @@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 530 | 530 | ).toBe(true); |
| 531 | 531 | }); |
| 532 | 532 | |
| 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 | 669 | it("clears local dispatch when the session changes without an observed running phase", () => { |
| 534 | 670 | const localDispatch = createLocalDispatchSnapshot({ |
| 535 | 671 | id: ThreadId.make("thread-1"), |
apps/web/src/components/ChatView.logic.ts+23-9| ... | ... | @@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 322 | 322 | if (!input.localDispatch) { |
| 323 | 323 | return false; |
| 324 | 324 | } |
| 325 | if ( | |
| 326 | input.phase === "running" || | |
| 327 | input.hasPendingApproval || | |
| 328 | input.hasPendingUserInput || | |
| 329 | Boolean(input.threadError) | |
| 330 | ) { | |
| 325 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { | |
| 331 | 326 | return true; |
| 332 | 327 | } |
| 333 | 328 | |
| 334 | 329 | const latestTurn = input.latestTurn ?? null; |
| 335 | 330 | const session = input.session ?? null; |
| 336 | ||
| 337 | return ( | |
| 331 | const latestTurnChanged = | |
| 338 | 332 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || |
| 339 | 333 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || |
| 340 | 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 | 356 | input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || |
| 343 | 357 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) |
| 344 | 358 | ); |
apps/web/src/components/ChatView.tsx+8| ... | ... | @@ -169,6 +169,7 @@ import { |
| 169 | 169 | useServerKeybindings, |
| 170 | 170 | } from "~/rpc/serverState"; |
| 171 | 171 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 172 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | |
| 172 | 173 | |
| 173 | 174 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 174 | 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 | 826 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), |
| 826 | 827 | ); |
| 827 | 828 | |
| 829 | useEffect(() => { | |
| 830 | if (routeKind !== "server") { | |
| 831 | return; | |
| 832 | } | |
| 833 | return retainThreadDetailSubscription(environmentId, threadId); | |
| 834 | }, [environmentId, routeKind, threadId]); | |
| 835 | ||
| 828 | 836 | // Compute the list of environments this logical project spans, used to |
| 829 | 837 | // drive the environment picker in BranchToolbar. |
| 830 | 838 | const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); |
apps/web/src/components/KeybindingsToast.browser.tsx+59-3| ... | ... | @@ -169,6 +169,43 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 169 | 169 | }; |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | function 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 | ||
| 172 | 209 | function buildFixture(): TestFixture { |
| 173 | 210 | return { |
| 174 | 211 | snapshot: createMinimalSnapshot(), |
| ... | ... | @@ -190,9 +227,6 @@ function buildFixture(): TestFixture { |
| 190 | 227 | } |
| 191 | 228 | |
| 192 | 229 | function resolveWsRpc(tag: string): unknown { |
| 193 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | |
| 194 | return fixture.snapshot; | |
| 195 | } | |
| 196 | 230 | if (tag === WS_METHODS.serverGetConfig) { |
| 197 | 231 | return fixture.serverConfig; |
| 198 | 232 | } |
| ... | ... | @@ -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 | 488 | return []; |
| 433 | 489 | }, |
| 434 | 490 | }); |
apps/web/src/environmentApi.ts+4-7| ... | ... | @@ -32,16 +32,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 32 | 32 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, |
| 33 | 33 | }, |
| 34 | 34 | orchestration: { |
| 35 | getSnapshot: rpcClient.orchestration.getSnapshot, | |
| 36 | 35 | dispatchCommand: rpcClient.orchestration.dispatchCommand, |
| 37 | 36 | getTurnDiff: rpcClient.orchestration.getTurnDiff, |
| 38 | 37 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, |
| 39 | replayEvents: (fromSequenceExclusive) => | |
| 40 | rpcClient.orchestration | |
| 41 | .replayEvents({ fromSequenceExclusive }) | |
| 42 | .then((events) => [...events]), | |
| 43 | onDomainEvent: (callback, options) => | |
| 44 | rpcClient.orchestration.onDomainEvent(callback, options), | |
| 38 | subscribeShell: (callback, options) => | |
| 39 | rpcClient.orchestration.subscribeShell(callback, options), | |
| 40 | subscribeThread: (input, callback, options) => | |
| 41 | rpcClient.orchestration.subscribeThread(input, callback, options), | |
| 45 | 42 | }, |
| 46 | 43 | }; |
| 47 | 44 | } |
apps/web/src/environments/primary/auth.ts+19-5| ... | ... | @@ -57,6 +57,7 @@ type ServerAuthGateState = |
| 57 | 57 | }; |
| 58 | 58 | |
| 59 | 59 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; |
| 60 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; | |
| 60 | 61 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; |
| 61 | 62 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; |
| 62 | 63 | |
| ... | ... | @@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 224 | 225 | throw new Error("Enter a pairing token to continue."); |
| 225 | 226 | } |
| 226 | 227 | |
| 228 | resolvedAuthenticatedGateState = null; | |
| 227 | 229 | await exchangeBootstrapCredential(trimmedCredential); |
| 228 | 230 | bootstrapPromise = null; |
| 229 | 231 | stripPairingTokenFromUrl(); |
| ... | ... | @@ -341,19 +343,31 @@ export async function revokeOtherServerClientSessions(): Promise<number> { |
| 341 | 343 | } |
| 342 | 344 | |
| 343 | 345 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
| 346 | if (resolvedAuthenticatedGateState?.status === "authenticated") { | |
| 347 | return resolvedAuthenticatedGateState; | |
| 348 | } | |
| 349 | ||
| 344 | 350 | if (bootstrapPromise) { |
| 345 | 351 | return bootstrapPromise; |
| 346 | 352 | } |
| 347 | 353 | |
| 348 | 354 | const nextPromise = bootstrapServerAuth(); |
| 349 | 355 | bootstrapPromise = nextPromise; |
| 350 | return nextPromise.finally(() => { | |
| 351 | if (bootstrapPromise === nextPromise) { | |
| 352 | bootstrapPromise = null; | |
| 353 | } | |
| 354 | }); | |
| 356 | return nextPromise | |
| 357 | .then((result) => { | |
| 358 | if (result.status === "authenticated") { | |
| 359 | resolvedAuthenticatedGateState = result; | |
| 360 | } | |
| 361 | return result; | |
| 362 | }) | |
| 363 | .finally(() => { | |
| 364 | if (bootstrapPromise === nextPromise) { | |
| 365 | bootstrapPromise = null; | |
| 366 | } | |
| 367 | }); | |
| 355 | 368 | } |
| 356 | 369 | |
| 357 | 370 | export function __resetServerAuthBootstrapForTests() { |
| 358 | 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 | 4 | import { createEnvironmentConnection } from "./connection"; |
| 5 | 5 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 6 | 6 | |
| 7 | function createTestClient(options?: { | |
| 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; | |
| 9 | readonly replayEvents?: () => Promise<ReadonlyArray<any>>; | |
| 10 | }) { | |
| 7 | function createTestClient() { | |
| 11 | 8 | const lifecycleListeners = new Set<(event: any) => void>(); |
| 12 | 9 | const configListeners = new Set<(event: any) => void>(); |
| 13 | 10 | const terminalListeners = new Set<(event: any) => void>(); |
| 14 | let domainResubscribe: (() => void) | undefined; | |
| 15 | ||
| 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 () => [])); | |
| 11 | const shellListeners = new Set<(event: any) => void>(); | |
| 12 | let shellResubscribe: (() => void) | undefined; | |
| 26 | 13 | |
| 27 | 14 | const client = { |
| 28 | 15 | dispose: vi.fn(async () => undefined), |
| 29 | reconnect: vi.fn(async () => undefined), | |
| 16 | reconnect: vi.fn(async () => { | |
| 17 | shellResubscribe?.(); | |
| 18 | }), | |
| 30 | 19 | server: { |
| 31 | 20 | getConfig: vi.fn(async () => ({ |
| 32 | 21 | environment: { |
| ... | ... | @@ -48,19 +37,33 @@ function createTestClient(options?: { |
| 48 | 37 | updateSettings: vi.fn(async () => undefined), |
| 49 | 38 | }, |
| 50 | 39 | orchestration: { |
| 51 | getSnapshot, | |
| 52 | 40 | dispatchCommand: vi.fn(async () => undefined), |
| 53 | 41 | getTurnDiff: vi.fn(async () => undefined), |
| 54 | 42 | getFullThreadDiff: vi.fn(async () => undefined), |
| 55 | replayEvents, | |
| 56 | onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => { | |
| 57 | domainResubscribe = options?.onResubscribe; | |
| 58 | return () => { | |
| 59 | if (domainResubscribe === options?.onResubscribe) { | |
| 60 | domainResubscribe = undefined; | |
| 61 | } | |
| 62 | }; | |
| 63 | }), | |
| 43 | subscribeShell: vi.fn( | |
| 44 | (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { | |
| 45 | shellListeners.add(listener); | |
| 46 | shellResubscribe = options?.onResubscribe; | |
| 47 | queueMicrotask(() => { | |
| 48 | listener({ | |
| 49 | kind: "snapshot", | |
| 50 | snapshot: { | |
| 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 | 68 | terminal: { |
| 66 | 69 | open: vi.fn(async () => undefined), |
| ... | ... | @@ -99,8 +102,6 @@ function createTestClient(options?: { |
| 99 | 102 | |
| 100 | 103 | return { |
| 101 | 104 | client, |
| 102 | getSnapshot, | |
| 103 | replayEvents, | |
| 104 | 105 | emitWelcome: (environmentId: EnvironmentId) => { |
| 105 | 106 | for (const listener of lifecycleListeners) { |
| 106 | 107 | listener({ |
| ... | ... | @@ -125,17 +126,27 @@ function createTestClient(options?: { |
| 125 | 126 | }); |
| 126 | 127 | } |
| 127 | 128 | }, |
| 128 | triggerDomainResubscribe: () => { | |
| 129 | domainResubscribe?.(); | |
| 129 | emitShellSnapshot: (snapshotSequence: number) => { | |
| 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 | } |
| 133 | 144 | |
| 134 | 145 | describe("createEnvironmentConnection", () => { |
| 135 | it("bootstraps a snapshot immediately for a new connection", async () => { | |
| 146 | it("bootstraps from the shell subscription snapshot", async () => { | |
| 136 | 147 | const environmentId = EnvironmentId.make("env-1"); |
| 137 | const { client, getSnapshot } = createTestClient(); | |
| 138 | const syncSnapshot = vi.fn(); | |
| 148 | const { client } = createTestClient(); | |
| 149 | const syncShellSnapshot = vi.fn(); | |
| 139 | 150 | |
| 140 | 151 | const connection = createEnvironmentConnection({ |
| 141 | 152 | kind: "saved", |
| ... | ... | @@ -150,16 +161,14 @@ describe("createEnvironmentConnection", () => { |
| 150 | 161 | environmentId, |
| 151 | 162 | }, |
| 152 | 163 | client, |
| 153 | applyEventBatch: vi.fn(), | |
| 154 | syncSnapshot, | |
| 164 | applyShellEvent: vi.fn(), | |
| 165 | syncShellSnapshot, | |
| 155 | 166 | applyTerminalEvent: vi.fn(), |
| 156 | 167 | }); |
| 157 | 168 | |
| 158 | await Promise.resolve(); | |
| 159 | await Promise.resolve(); | |
| 169 | await connection.ensureBootstrapped(); | |
| 160 | 170 | |
| 161 | expect(getSnapshot).toHaveBeenCalledTimes(1); | |
| 162 | expect(syncSnapshot).toHaveBeenCalledWith( | |
| 171 | expect(syncShellSnapshot).toHaveBeenCalledWith( | |
| 163 | 172 | expect.objectContaining({ snapshotSequence: 1 }), |
| 164 | 173 | environmentId, |
| 165 | 174 | ); |
| ... | ... | @@ -184,8 +193,8 @@ describe("createEnvironmentConnection", () => { |
| 184 | 193 | environmentId, |
| 185 | 194 | }, |
| 186 | 195 | client, |
| 187 | applyEventBatch: vi.fn(), | |
| 188 | syncSnapshot: vi.fn(), | |
| 196 | applyShellEvent: vi.fn(), | |
| 197 | syncShellSnapshot: vi.fn(), | |
| 189 | 198 | applyTerminalEvent: vi.fn(), |
| 190 | 199 | }); |
| 191 | 200 | |
| ... | ... | @@ -196,58 +205,10 @@ describe("createEnvironmentConnection", () => { |
| 196 | 205 | await connection.dispose(); |
| 197 | 206 | }); |
| 198 | 207 | |
| 199 | it("rejects ensureBootstrapped when snapshot recovery fails", async () => { | |
| 208 | it("waits for a fresh shell snapshot after reconnect", async () => { | |
| 200 | 209 | const environmentId = EnvironmentId.make("env-1"); |
| 201 | const snapshotError = new Error("snapshot failed"); | |
| 202 | const { client } = createTestClient({ | |
| 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 | }); | |
| 210 | const { client, emitShellSnapshot } = createTestClient(); | |
| 211 | const syncShellSnapshot = vi.fn(); | |
| 251 | 212 | |
| 252 | 213 | const connection = createEnvironmentConnection({ |
| 253 | 214 | kind: "saved", |
| ... | ... | @@ -262,85 +223,26 @@ describe("createEnvironmentConnection", () => { |
| 262 | 223 | environmentId, |
| 263 | 224 | }, |
| 264 | 225 | client, |
| 265 | applyEventBatch, | |
| 266 | syncSnapshot: vi.fn(), | |
| 226 | applyShellEvent: vi.fn(), | |
| 227 | syncShellSnapshot, | |
| 267 | 228 | applyTerminalEvent: vi.fn(), |
| 268 | 229 | }); |
| 269 | 230 | |
| 270 | await Promise.resolve(); | |
| 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 | }); | |
| 231 | await connection.ensureBootstrapped(); | |
| 328 | 232 | |
| 233 | const reconnectPromise = connection.reconnect(); | |
| 329 | 234 | await Promise.resolve(); |
| 330 | await Promise.resolve(); | |
| 331 | ||
| 332 | const onUnhandledRejection = vi.fn(); | |
| 333 | process.on("unhandledRejection", onUnhandledRejection); | |
| 235 | expect(syncShellSnapshot).toHaveBeenCalledTimes(1); | |
| 334 | 236 | |
| 335 | try { | |
| 336 | triggerDomainResubscribe(); | |
| 337 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 338 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 339 | } finally { | |
| 340 | process.off("unhandledRejection", onUnhandledRejection); | |
| 341 | } | |
| 237 | emitShellSnapshot(2); | |
| 238 | await reconnectPromise; | |
| 342 | 239 | |
| 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 | ); | |
| 344 | 246 | |
| 345 | 247 | await connection.dispose(); |
| 346 | 248 | }); |
apps/web/src/environments/runtime/connection.ts+49-210| ... | ... | @@ -1,29 +1,15 @@ |
| 1 | 1 | import type { |
| 2 | 2 | EnvironmentId, |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 3 | OrchestrationShellSnapshot, | |
| 4 | OrchestrationShellStreamEvent, | |
| 5 | 5 | ServerConfig, |
| 6 | 6 | ServerLifecycleWelcomePayload, |
| 7 | 7 | TerminalEvent, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import type { KnownEnvironment } from "@t3tools/client-runtime"; |
| 10 | 10 | |
| 11 | import { | |
| 12 | deriveReplayRetryDecision, | |
| 13 | type OrchestrationRecoveryReason, | |
| 14 | } from "../../orchestrationRecovery"; | |
| 15 | import { | |
| 16 | createOrchestrationRecoveryCoordinator, | |
| 17 | type ReplayRetryTracker, | |
| 18 | } from "../../orchestrationRecovery"; | |
| 19 | import { isTransportConnectionErrorMessage } from "~/rpc/transportError"; | |
| 20 | 11 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 21 | 12 | |
| 22 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | |
| 23 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | |
| 24 | const RECOVERY_TRANSPORT_RETRY_DELAY_MS = 250; | |
| 25 | const MAX_RECOVERY_TRANSPORT_RETRIES = 20; | |
| 26 | ||
| 27 | 13 | export interface EnvironmentConnection { |
| 28 | 14 | readonly kind: "primary" | "saved"; |
| 29 | 15 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -35,11 +21,14 @@ export interface EnvironmentConnection { |
| 35 | 21 | } |
| 36 | 22 | |
| 37 | 23 | interface OrchestrationHandlers { |
| 38 | readonly applyEventBatch: ( | |
| 39 | events: ReadonlyArray<OrchestrationEvent>, | |
| 24 | readonly applyShellEvent: ( | |
| 25 | event: OrchestrationShellStreamEvent, | |
| 26 | environmentId: EnvironmentId, | |
| 27 | ) => void; | |
| 28 | readonly syncShellSnapshot: ( | |
| 29 | snapshot: OrchestrationShellSnapshot, | |
| 40 | 30 | environmentId: EnvironmentId, |
| 41 | 31 | ) => void; |
| 42 | readonly syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => void; | |
| 43 | 32 | readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; |
| 44 | 33 | } |
| 45 | 34 | |
| ... | ... | @@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers { |
| 52 | 41 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; |
| 53 | 42 | } |
| 54 | 43 | |
| 55 | function createSnapshotBootstrapController(input: { | |
| 56 | readonly isBootstrapped: () => boolean; | |
| 57 | readonly runSnapshotRecovery: ( | |
| 58 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | |
| 59 | ) => Promise<void>; | |
| 60 | }) { | |
| 61 | let inFlight: Promise<void> | null = null; | |
| 44 | function createBootstrapGate() { | |
| 45 | let resolve: (() => void) | null = null; | |
| 46 | let reject: ((error: unknown) => void) | null = null; | |
| 47 | let promise = new Promise<void>((nextResolve, nextReject) => { | |
| 48 | resolve = nextResolve; | |
| 49 | reject = nextReject; | |
| 50 | }); | |
| 62 | 51 | |
| 63 | 52 | return { |
| 64 | ensureSnapshotRecovery( | |
| 65 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | |
| 66 | ): Promise<void> { | |
| 67 | if (input.isBootstrapped()) { | |
| 68 | return Promise.resolve(); | |
| 69 | } | |
| 70 | ||
| 71 | if (inFlight !== null) { | |
| 72 | return inFlight; | |
| 73 | } | |
| 74 | ||
| 75 | const nextInFlight = input.runSnapshotRecovery(reason).finally(() => { | |
| 76 | if (inFlight === nextInFlight) { | |
| 77 | inFlight = null; | |
| 78 | } | |
| 53 | wait: () => promise, | |
| 54 | resolve: () => { | |
| 55 | resolve?.(); | |
| 56 | resolve = null; | |
| 57 | reject = null; | |
| 58 | }, | |
| 59 | reject: (error: unknown) => { | |
| 60 | reject?.(error); | |
| 61 | resolve = null; | |
| 62 | reject = null; | |
| 63 | }, | |
| 64 | reset: () => { | |
| 65 | promise = new Promise<void>((nextResolve, nextReject) => { | |
| 66 | resolve = nextResolve; | |
| 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 | 73 | export function createEnvironmentConnection( |
| 87 | 74 | input: EnvironmentConnectionInput, |
| 88 | 75 | ): EnvironmentConnection { |
| 89 | const recovery = createOrchestrationRecoveryCoordinator(); | |
| 90 | let replayRetryTracker: ReplayRetryTracker | null = null; | |
| 91 | const pendingDomainEvents: OrchestrationEvent[] = []; | |
| 92 | let flushPendingDomainEventsScheduled = false; | |
| 93 | 76 | const environmentId = input.knownEnvironment.environmentId; |
| 94 | 77 | |
| 95 | 78 | if (!environmentId) { |
| ... | ... | @@ -99,6 +82,7 @@ export function createEnvironmentConnection( |
| 99 | 82 | } |
| 100 | 83 | |
| 101 | 84 | let disposed = false; |
| 85 | const bootstrapGate = createBootstrapGate(); | |
| 102 | 86 | |
| 103 | 87 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { |
| 104 | 88 | if (environmentId !== nextEnvironmentId) { |
| ... | ... | @@ -108,148 +92,6 @@ export function createEnvironmentConnection( |
| 108 | 92 | } |
| 109 | 93 | }; |
| 110 | 94 | |
| 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 | 95 | const unsubLifecycle = input.client.server.subscribeLifecycle( |
| 254 | 96 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => { |
| 255 | 97 | if (event.type !== "welcome") { |
| ... | ... | @@ -273,26 +115,21 @@ export function createEnvironmentConnection( |
| 273 | 115 | }, |
| 274 | 116 | ); |
| 275 | 117 | |
| 276 | const unsubDomainEvent = input.client.orchestration.onDomainEvent( | |
| 277 | (event: Parameters<Parameters<WsRpcClient["orchestration"]["onDomainEvent"]>[0]>[0]) => { | |
| 278 | const action = recovery.classifyDomainEvent(event.sequence); | |
| 279 | if (action === "apply") { | |
| 280 | pendingDomainEvents.push(event); | |
| 281 | schedulePendingDomainEventFlush(); | |
| 118 | const unsubShell = input.client.orchestration.subscribeShell( | |
| 119 | (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => { | |
| 120 | if (item.kind === "snapshot") { | |
| 121 | input.syncShellSnapshot(item.snapshot, environmentId); | |
| 122 | bootstrapGate.resolve(); | |
| 282 | 123 | return; |
| 283 | 124 | } |
| 284 | if (action === "recover") { | |
| 285 | flushPendingDomainEvents(); | |
| 286 | scheduleReplayRecovery("sequence-gap"); | |
| 287 | } | |
| 125 | input.applyShellEvent(item, environmentId); | |
| 288 | 126 | }, |
| 289 | 127 | { |
| 290 | 128 | onResubscribe: () => { |
| 291 | 129 | if (disposed) { |
| 292 | 130 | return; |
| 293 | 131 | } |
| 294 | flushPendingDomainEvents(); | |
| 295 | scheduleReplayRecovery("resubscribe"); | |
| 132 | bootstrapGate.reset(); | |
| 296 | 133 | }, |
| 297 | 134 | }, |
| 298 | 135 | ); |
| ... | ... | @@ -303,13 +140,9 @@ export function createEnvironmentConnection( |
| 303 | 140 | }, |
| 304 | 141 | ); |
| 305 | 142 | |
| 306 | void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined); | |
| 307 | ||
| 308 | 143 | const cleanup = () => { |
| 309 | 144 | disposed = true; |
| 310 | flushPendingDomainEventsScheduled = false; | |
| 311 | pendingDomainEvents.length = 0; | |
| 312 | unsubDomainEvent(); | |
| 145 | unsubShell(); | |
| 313 | 146 | unsubTerminalEvent(); |
| 314 | 147 | unsubLifecycle(); |
| 315 | 148 | unsubConfig(); |
| ... | ... | @@ -320,11 +153,17 @@ export function createEnvironmentConnection( |
| 320 | 153 | environmentId, |
| 321 | 154 | knownEnvironment: input.knownEnvironment, |
| 322 | 155 | client: input.client, |
| 323 | ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"), | |
| 156 | ensureBootstrapped: () => bootstrapGate.wait(), | |
| 324 | 157 | reconnect: async () => { |
| 325 | await input.client.reconnect(); | |
| 326 | await input.refreshMetadata?.(); | |
| 327 | await snapshotBootstrap.ensureSnapshotRecovery("bootstrap"); | |
| 158 | bootstrapGate.reset(); | |
| 159 | try { | |
| 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 | 168 | dispose: async () => { |
| 330 | 169 | cleanup(); |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 1 | import { QueryClient } from "@tanstack/react-query"; | |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 4 | ||
| 5 | const mockSubscribeThread = vi.fn(); | |
| 6 | const mockThreadUnsubscribe = vi.fn(); | |
| 7 | const mockCreateEnvironmentConnection = vi.fn(); | |
| 8 | const mockCreateWsRpcClient = vi.fn(); | |
| 9 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | |
| 10 | const mockListSavedEnvironmentRecords = vi.fn(); | |
| 11 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | |
| 12 | ||
| 13 | function MockWsTransport() { | |
| 14 | return undefined; | |
| 15 | } | |
| 16 | ||
| 17 | vi.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 | ||
| 30 | vi.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 | ||
| 56 | vi.mock("./connection", () => ({ | |
| 57 | createEnvironmentConnection: mockCreateEnvironmentConnection, | |
| 58 | })); | |
| 59 | ||
| 60 | vi.mock("../../rpc/wsRpcClient", () => ({ | |
| 61 | createWsRpcClient: mockCreateWsRpcClient, | |
| 62 | })); | |
| 63 | ||
| 64 | vi.mock("../../rpc/wsTransport", () => ({ | |
| 65 | WsTransport: MockWsTransport, | |
| 66 | })); | |
| 67 | ||
| 68 | describe("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 | 2 | type AuthSessionRole, |
| 3 | 3 | type EnvironmentId, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | type OrchestrationReadModel, | |
| 5 | type OrchestrationShellSnapshot, | |
| 6 | type OrchestrationShellStreamEvent, | |
| 6 | 7 | type ServerConfig, |
| 7 | 8 | type TerminalEvent, |
| 8 | 9 | ThreadId, |
| ... | ... | @@ -68,12 +69,206 @@ type EnvironmentServiceState = { |
| 68 | 69 | stop: () => void; |
| 69 | 70 | }; |
| 70 | 71 | |
| 72 | type 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 | ||
| 71 | 82 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 72 | 83 | const environmentConnectionListeners = new Set<() => void>(); |
| 84 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | |
| 73 | 85 | |
| 74 | 86 | let activeService: EnvironmentServiceState | null = null; |
| 75 | 87 | let needsProviderInvalidation = false; |
| 76 | 88 | |
| 89 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000; | |
| 90 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 8; | |
| 91 | const NOOP = () => undefined; | |
| 92 | ||
| 93 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { | |
| 94 | return scopedThreadKey(scopeThreadRef(environmentId, threadId)); | |
| 95 | } | |
| 96 | ||
| 97 | function 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 | ||
| 107 | function 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 | ||
| 134 | function 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 | ||
| 147 | function 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 | ||
| 162 | function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { | |
| 163 | for (const [key, entry] of threadDetailSubscriptions) { | |
| 164 | if (entry.environmentId === environmentId) { | |
| 165 | disposeThreadDetailSubscriptionByKey(key); | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | function 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 | ||
| 182 | function 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 | ||
| 197 | function 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 | ||
| 214 | export 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 | ||
| 77 | 272 | function emitEnvironmentConnectionRegistryChange() { |
| 78 | 273 | for (const listener of environmentConnectionListeners) { |
| 79 | 274 | listener(); |
| ... | ... | @@ -169,17 +364,18 @@ function coalesceOrchestrationUiEvents( |
| 169 | 364 | return coalesced; |
| 170 | 365 | } |
| 171 | 366 | |
| 172 | function reconcileSnapshotDerivedState() { | |
| 173 | const storeState = useStore.getState(); | |
| 174 | const threads = selectThreadsAcrossEnvironments(storeState); | |
| 175 | const projects = selectProjectsAcrossEnvironments(storeState); | |
| 176 | ||
| 367 | function syncProjectUiFromStore() { | |
| 368 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); | |
| 177 | 369 | useUiStateStore.getState().syncProjects( |
| 178 | 370 | projects.map((project) => ({ |
| 179 | 371 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), |
| 180 | 372 | cwd: project.cwd, |
| 181 | 373 | })), |
| 182 | 374 | ); |
| 375 | } | |
| 376 | ||
| 377 | function syncThreadUiFromStore() { | |
| 378 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | |
| 183 | 379 | useUiStateStore.getState().syncThreads( |
| 184 | 380 | threads.map((thread) => ({ |
| 185 | 381 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| ... | ... | @@ -189,7 +385,13 @@ function reconcileSnapshotDerivedState() { |
| 189 | 385 | markPromotedDraftThreadsByRef( |
| 190 | 386 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), |
| 191 | 387 | ); |
| 388 | } | |
| 389 | ||
| 390 | function reconcileSnapshotDerivedState() { | |
| 391 | syncProjectUiFromStore(); | |
| 392 | syncThreadUiFromStore(); | |
| 192 | 393 | |
| 394 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | |
| 193 | 395 | const activeThreadKeys = collectActiveTerminalThreadIds({ |
| 194 | 396 | snapshotThreads: threads.map((thread) => ({ |
| 195 | 397 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| ... | ... | @@ -273,11 +475,60 @@ function applyRecoveredEventBatch( |
| 273 | 475 | } |
| 274 | 476 | } |
| 275 | 477 | |
| 478 | export function applyEnvironmentThreadDetailEvent( | |
| 479 | event: OrchestrationEvent, | |
| 480 | environmentId: EnvironmentId, | |
| 481 | ) { | |
| 482 | applyRecoveredEventBatch([event], environmentId); | |
| 483 | } | |
| 484 | ||
| 485 | function 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 | ||
| 276 | 523 | function createEnvironmentConnectionHandlers() { |
| 277 | 524 | return { |
| 278 | applyEventBatch: applyRecoveredEventBatch, | |
| 279 | syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => { | |
| 280 | useStore.getState().syncServerReadModel(snapshot, environmentId); | |
| 525 | applyShellEvent, | |
| 526 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { | |
| 527 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); | |
| 528 | reconcileThreadDetailSubscriptionsForEnvironment( | |
| 529 | environmentId, | |
| 530 | snapshot.threads.map((thread) => thread.id), | |
| 531 | ); | |
| 281 | 532 | reconcileSnapshotDerivedState(); |
| 282 | 533 | }, |
| 283 | 534 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { |
| ... | ... | @@ -386,6 +637,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 386 | 637 | return false; |
| 387 | 638 | } |
| 388 | 639 | |
| 640 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | |
| 389 | 641 | environmentConnections.delete(environmentId); |
| 390 | 642 | emitEnvironmentConnectionRegistryChange(); |
| 391 | 643 | await connection.dispose(); |
| ... | ... | @@ -714,6 +966,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 714 | 966 | |
| 715 | 967 | export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 716 | 968 | stopActiveService(); |
| 969 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | |
| 970 | disposeThreadDetailSubscriptionByKey(key); | |
| 971 | } | |
| 717 | 972 | await Promise.all( |
| 718 | 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 | 123 | subscribeAuthAccess: vi.fn(() => () => undefined), |
| 124 | 124 | }, |
| 125 | 125 | orchestration: { |
| 126 | getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any), | |
| 127 | 126 | dispatchCommand: vi.fn(async () => undefined), |
| 128 | 127 | getTurnDiff: vi.fn(async () => undefined), |
| 129 | 128 | getFullThreadDiff: vi.fn(async () => undefined), |
| 130 | replayEvents: vi.fn(async () => []), | |
| 131 | onDomainEvent: vi.fn(() => () => undefined), | |
| 129 | subscribeShell: vi.fn(() => () => undefined), | |
| 130 | subscribeThread: vi.fn(() => () => undefined), | |
| 132 | 131 | }, |
| 133 | 132 | } as unknown as WsRpcClient; |
| 134 | 133 |
apps/web/src/localApi.test.ts+20-30| ... | ... | @@ -3,10 +3,9 @@ import { |
| 3 | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | 4 | type DesktopBridge, |
| 5 | 5 | EnvironmentId, |
| 6 | EventId, | |
| 7 | 6 | type GitStatusResult, |
| 8 | 7 | ProjectId, |
| 9 | type OrchestrationEvent, | |
| 8 | type OrchestrationShellStreamItem, | |
| 10 | 9 | type ServerConfig, |
| 11 | 10 | type ServerProvider, |
| 12 | 11 | type TerminalEvent, |
| ... | ... | @@ -32,7 +31,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 32 | 31 | } |
| 33 | 32 | |
| 34 | 33 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); |
| 35 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); | |
| 34 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); | |
| 36 | 35 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); |
| 37 | 36 | |
| 38 | 37 | const rpcClientMock = { |
| ... | ... | @@ -82,14 +81,13 @@ const rpcClientMock = { |
| 82 | 81 | subscribeAuthAccess: vi.fn(), |
| 83 | 82 | }, |
| 84 | 83 | orchestration: { |
| 85 | getSnapshot: vi.fn(), | |
| 86 | 84 | dispatchCommand: vi.fn(), |
| 87 | 85 | getTurnDiff: vi.fn(), |
| 88 | 86 | getFullThreadDiff: vi.fn(), |
| 89 | replayEvents: vi.fn(), | |
| 90 | onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => | |
| 91 | registerListener(orchestrationEventListeners, listener), | |
| 87 | subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => | |
| 88 | registerListener(shellStreamListeners, listener), | |
| 92 | 89 | ), |
| 90 | subscribeThread: vi.fn(() => () => undefined), | |
| 93 | 91 | }, |
| 94 | 92 | }; |
| 95 | 93 | |
| ... | ... | @@ -269,7 +267,7 @@ beforeEach(() => { |
| 269 | 267 | vi.clearAllMocks(); |
| 270 | 268 | showContextMenuFallbackMock.mockReset(); |
| 271 | 269 | terminalEventListeners.clear(); |
| 272 | orchestrationEventListeners.clear(); | |
| 270 | shellStreamListeners.clear(); | |
| 273 | 271 | gitStatusListeners.clear(); |
| 274 | 272 | const testWindow = getWindowForTest(); |
| 275 | 273 | Reflect.deleteProperty(testWindow, "desktopBridge"); |
| ... | ... | @@ -296,15 +294,15 @@ describe("wsApi", () => { |
| 296 | 294 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); |
| 297 | 295 | }); |
| 298 | 296 | |
| 299 | it("forwards terminal and orchestration stream events", async () => { | |
| 297 | it("forwards terminal and shell stream events", async () => { | |
| 300 | 298 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 301 | 299 | |
| 302 | 300 | const api = createEnvironmentApi(rpcClientMock as never); |
| 303 | 301 | const onTerminalEvent = vi.fn(); |
| 304 | const onDomainEvent = vi.fn(); | |
| 302 | const onShellEvent = vi.fn(); | |
| 305 | 303 | |
| 306 | 304 | api.terminal.onEvent(onTerminalEvent); |
| 307 | api.orchestration.onDomainEvent(onDomainEvent); | |
| 305 | api.orchestration.subscribeShell(onShellEvent); | |
| 308 | 306 | |
| 309 | 307 | const terminalEvent = { |
| 310 | 308 | threadId: "thread-1", |
| ... | ... | @@ -315,19 +313,11 @@ describe("wsApi", () => { |
| 315 | 313 | } as const; |
| 316 | 314 | emitEvent(terminalEventListeners, terminalEvent); |
| 317 | 315 | |
| 318 | const orchestrationEvent = { | |
| 316 | const shellEvent = { | |
| 317 | kind: "project-upserted" as const, | |
| 319 | 318 | sequence: 1, |
| 320 | eventId: EventId.make("event-1"), | |
| 321 | aggregateKind: "project", | |
| 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"), | |
| 319 | project: { | |
| 320 | id: ProjectId.make("project-1"), | |
| 331 | 321 | title: "Project", |
| 332 | 322 | workspaceRoot: "/tmp/workspace", |
| 333 | 323 | defaultModelSelection: { |
| ... | ... | @@ -338,11 +328,11 @@ describe("wsApi", () => { |
| 338 | 328 | createdAt: "2026-02-24T00:00:00.000Z", |
| 339 | 329 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 340 | 330 | }, |
| 341 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; | |
| 342 | emitEvent(orchestrationEventListeners, orchestrationEvent); | |
| 331 | } satisfies OrchestrationShellStreamItem; | |
| 332 | emitEvent(shellStreamListeners, shellEvent); | |
| 343 | 333 | |
| 344 | 334 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| 345 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | |
| 335 | expect(onShellEvent).toHaveBeenCalledWith(shellEvent); | |
| 346 | 336 | }); |
| 347 | 337 | |
| 348 | 338 | it("forwards git status stream events", async () => { |
| ... | ... | @@ -371,16 +361,16 @@ describe("wsApi", () => { |
| 371 | 361 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 372 | 362 | }); |
| 373 | 363 | |
| 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 | 365 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 376 | 366 | |
| 377 | 367 | const api = createEnvironmentApi(rpcClientMock as never); |
| 378 | const onDomainEvent = vi.fn(); | |
| 368 | const onShellEvent = vi.fn(); | |
| 379 | 369 | const onResubscribe = vi.fn(); |
| 380 | 370 | |
| 381 | api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe }); | |
| 371 | api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); | |
| 382 | 372 | |
| 383 | expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, { | |
| 373 | expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { | |
| 384 | 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 | 2 | import { useEffect } from "react"; |
| 3 | 3 | |
| 4 | 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 5 | import { | |
| 6 | ensurePrimaryEnvironmentReady, | |
| 7 | resolveInitialServerAuthGateState, | |
| 8 | } from "../environments/primary"; | |
| 9 | 5 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 10 | 6 | import { |
| 11 | 7 | startNewLocalThreadFromContext, |
| ... | ... | @@ -111,12 +107,8 @@ function ChatRouteLayout() { |
| 111 | 107 | } |
| 112 | 108 | |
| 113 | 109 | export const Route = createFileRoute("/_chat")({ |
| 114 | beforeLoad: async () => { | |
| 115 | const [, authGateState] = await Promise.all([ | |
| 116 | ensurePrimaryEnvironmentReady(), | |
| 117 | resolveInitialServerAuthGateState(), | |
| 118 | ]); | |
| 119 | if (authGateState.status !== "authenticated") { | |
| 110 | beforeLoad: async ({ context }) => { | |
| 111 | if (context.authGateState.status !== "authenticated") { | |
| 120 | 112 | throw redirect({ to: "/pair", replace: true }); |
| 121 | 113 | } |
| 122 | 114 | }, |
apps/web/src/routes/pair.tsx+2-9| ... | ... | @@ -1,17 +1,10 @@ |
| 1 | 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | 3 | import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; |
| 4 | import { | |
| 5 | ensurePrimaryEnvironmentReady, | |
| 6 | resolveInitialServerAuthGateState, | |
| 7 | } from "../environments/primary"; | |
| 8 | 4 | |
| 9 | 5 | export const Route = createFileRoute("/pair")({ |
| 10 | beforeLoad: async () => { | |
| 11 | const [, authGateState] = await Promise.all([ | |
| 12 | ensurePrimaryEnvironmentReady(), | |
| 13 | resolveInitialServerAuthGateState(), | |
| 14 | ]); | |
| 6 | beforeLoad: async ({ context }) => { | |
| 7 | const { authGateState } = context; | |
| 15 | 8 | if (authGateState.status === "authenticated") { |
| 16 | 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 | 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | import { | |
| 6 | ensurePrimaryEnvironmentReady, | |
| 7 | resolveInitialServerAuthGateState, | |
| 8 | } from "../environments/primary"; | |
| 9 | 5 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 10 | 6 | import { Button } from "../components/ui/button"; |
| 11 | 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| ... | ... | @@ -87,12 +83,8 @@ function SettingsRouteLayout() { |
| 87 | 83 | } |
| 88 | 84 | |
| 89 | 85 | export const Route = createFileRoute("/settings")({ |
| 90 | beforeLoad: async ({ location }) => { | |
| 91 | const [, authGateState] = await Promise.all([ | |
| 92 | ensurePrimaryEnvironmentReady(), | |
| 93 | resolveInitialServerAuthGateState(), | |
| 94 | ]); | |
| 95 | if (authGateState.status !== "authenticated") { | |
| 86 | beforeLoad: async ({ context, location }) => { | |
| 87 | if (context.authGateState.status !== "authenticated") { | |
| 96 | 88 | throw redirect({ to: "/pair", replace: true }); |
| 97 | 89 | } |
| 98 | 90 |
apps/web/src/rpc/wsRpcClient.ts+19-11| ... | ... | @@ -39,6 +39,15 @@ type RpcStreamMethod<TTag extends RpcTag> = |
| 39 | 39 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void |
| 40 | 40 | : never; |
| 41 | 41 | |
| 42 | type 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 | ||
| 42 | 51 | interface GitRunStackedActionOptions { |
| 43 | 52 | readonly onProgress?: (event: GitActionProgressEvent) => void; |
| 44 | 53 | } |
| ... | ... | @@ -101,12 +110,11 @@ export interface WsRpcClient { |
| 101 | 110 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; |
| 102 | 111 | }; |
| 103 | 112 | readonly orchestration: { |
| 104 | readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>; | |
| 105 | 113 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; |
| 106 | 114 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; |
| 107 | 115 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; |
| 108 | readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>; | |
| 109 | readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>; | |
| 116 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; | |
| 117 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; | |
| 110 | 118 | }; |
| 111 | 119 | } |
| 112 | 120 | |
| ... | ... | @@ -219,21 +227,21 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 219 | 227 | ), |
| 220 | 228 | }, |
| 221 | 229 | orchestration: { |
| 222 | getSnapshot: () => | |
| 223 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | |
| 224 | 230 | dispatchCommand: (input) => |
| 225 | 231 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), |
| 226 | 232 | getTurnDiff: (input) => |
| 227 | 233 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), |
| 228 | 234 | getFullThreadDiff: (input) => |
| 229 | 235 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), |
| 230 | replayEvents: (input) => | |
| 231 | transport | |
| 232 | .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) | |
| 233 | .then((events) => [...events]), | |
| 234 | onDomainEvent: (listener, options) => | |
| 236 | subscribeShell: (listener, options) => | |
| 237 | transport.subscribe( | |
| 238 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), | |
| 239 | listener, | |
| 240 | options, | |
| 241 | ), | |
| 242 | subscribeThread: (input, listener, options) => | |
| 235 | 243 | transport.subscribe( |
| 236 | (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), | |
| 244 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), | |
| 237 | 245 | listener, |
| 238 | 246 | options, |
| 239 | 247 | ), |
apps/web/src/session-logic.test.ts+13| ... | ... | @@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1227 | 1227 | ).toBe("2026-02-27T21:10:00.000Z"); |
| 1228 | 1228 | }); |
| 1229 | 1229 | |
| 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 | 1243 | it("falls back to sendStartedAt once the latest turn is settled", () => { |
| 1231 | 1244 | expect( |
| 1232 | 1245 | deriveActiveWorkStartedAt( |
apps/web/src/session-logic.ts+8| ... | ... | @@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt( |
| 145 | 145 | session: SessionActivityState | null, |
| 146 | 146 | sendStartedAt: string | null, |
| 147 | 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 | 156 | if (!isLatestTurnSettled(latestTurn, session)) { |
| 149 | 157 | return latestTurn?.startedAt ?? sendStartedAt; |
| 150 | 158 | } |
apps/web/src/store.test.ts+79| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | EnvironmentId, |
| 6 | 6 | EventId, |
| 7 | 7 | MessageId, |
| 8 | type OrchestrationShellSnapshot, | |
| 8 | 9 | ProjectId, |
| 9 | 10 | ThreadId, |
| 10 | 11 | TurnId, |
| ... | ... | @@ -23,6 +24,7 @@ import { |
| 23 | 24 | setThreadBranch, |
| 24 | 25 | selectThreadsAcrossEnvironments, |
| 25 | 26 | syncServerReadModel, |
| 27 | syncServerShellSnapshot, | |
| 26 | 28 | type AppState, |
| 27 | 29 | type EnvironmentState, |
| 28 | 30 | } from "./store"; |
| ... | ... | @@ -496,6 +498,83 @@ describe("store read model sync", () => { |
| 496 | 498 | expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); |
| 497 | 499 | }); |
| 498 | 500 | |
| 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 | 578 | it("preserves claude model slugs without an active session", () => { |
| 500 | 579 | const initialState = makeState(makeThread()); |
| 501 | 580 | const readModel = makeReadModel( |
apps/web/src/store.ts+335-1| ... | ... | @@ -6,9 +6,12 @@ import type { |
| 6 | 6 | OrchestrationMessage, |
| 7 | 7 | OrchestrationProposedPlan, |
| 8 | 8 | OrchestrationReadModel, |
| 9 | OrchestrationShellSnapshot, | |
| 10 | OrchestrationShellStreamEvent, | |
| 9 | 11 | OrchestrationSession, |
| 10 | 12 | OrchestrationSessionStatus, |
| 11 | 13 | OrchestrationThread, |
| 14 | OrchestrationThreadShell, | |
| 12 | 15 | OrchestrationThreadActivity, |
| 13 | 16 | ProjectId, |
| 14 | 17 | ProviderKind, |
| ... | ... | @@ -175,7 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif |
| 175 | 178 | } |
| 176 | 179 | |
| 177 | 180 | function mapProject( |
| 178 | project: OrchestrationReadModel["projects"][number], | |
| 181 | project: | |
| 182 | | OrchestrationReadModel["projects"][number] | |
| 183 | | OrchestrationShellSnapshot["projects"][number], | |
| 179 | 184 | environmentId: EnvironmentId, |
| 180 | 185 | ): Project { |
| 181 | 186 | return { |
| ... | ... | @@ -219,6 +224,62 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T |
| 219 | 224 | }; |
| 220 | 225 | } |
| 221 | 226 | |
| 227 | function 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 | ||
| 222 | 283 | function toThreadShell(thread: Thread): ThreadShell { |
| 223 | 284 | return { |
| 224 | 285 | id: thread.id, |
| ... | ... | @@ -562,6 +623,120 @@ function writeThreadState( |
| 562 | 623 | return nextState; |
| 563 | 624 | } |
| 564 | 625 | |
| 626 | function 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 | ||
| 729 | function 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 | ||
| 565 | 740 | function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { |
| 566 | 741 | const shell = state.threadShellById[threadId]; |
| 567 | 742 | if (!shell) { |
| ... | ... | @@ -960,6 +1135,46 @@ function syncEnvironmentReadModel( |
| 960 | 1135 | }; |
| 961 | 1136 | } |
| 962 | 1137 | |
| 1138 | function 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 | ||
| 963 | 1178 | export function syncServerReadModel( |
| 964 | 1179 | state: AppState, |
| 965 | 1180 | readModel: OrchestrationReadModel, |
| ... | ... | @@ -976,6 +1191,36 @@ export function syncServerReadModel( |
| 976 | 1191 | ); |
| 977 | 1192 | } |
| 978 | 1193 | |
| 1194 | export 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 | ||
| 1210 | export 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 | ||
| 979 | 1224 | function applyEnvironmentOrchestrationEvent( |
| 980 | 1225 | state: EnvironmentState, |
| 981 | 1226 | event: OrchestrationEvent, |
| ... | ... | @@ -1466,6 +1711,67 @@ function applyEnvironmentOrchestrationEvent( |
| 1466 | 1711 | return state; |
| 1467 | 1712 | } |
| 1468 | 1713 | |
| 1714 | function 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 | ||
| 1469 | 1775 | export function applyOrchestrationEvents( |
| 1470 | 1776 | state: AppState, |
| 1471 | 1777 | events: ReadonlyArray<OrchestrationEvent>, |
| ... | ... | @@ -1649,6 +1955,22 @@ export function applyOrchestrationEvent( |
| 1649 | 1955 | ); |
| 1650 | 1956 | } |
| 1651 | 1957 | |
| 1958 | export 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 | ||
| 1652 | 1974 | export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { |
| 1653 | 1975 | if (state.activeEnvironmentId === environmentId) { |
| 1654 | 1976 | return state; |
| ... | ... | @@ -1686,11 +2008,17 @@ export function setThreadBranch( |
| 1686 | 2008 | interface AppStore extends AppState { |
| 1687 | 2009 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; |
| 1688 | 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 | 2016 | applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; |
| 1690 | 2017 | applyOrchestrationEvents: ( |
| 1691 | 2018 | events: ReadonlyArray<OrchestrationEvent>, |
| 1692 | 2019 | environmentId: EnvironmentId, |
| 1693 | 2020 | ) => void; |
| 2021 | applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; | |
| 1694 | 2022 | setError: (threadId: ThreadId, error: string | null) => void; |
| 1695 | 2023 | setThreadBranch: ( |
| 1696 | 2024 | threadRef: ScopedThreadRef, |
| ... | ... | @@ -1705,10 +2033,16 @@ export const useStore = create<AppStore>((set) => ({ |
| 1705 | 2033 | set((state) => setActiveEnvironmentId(state, environmentId)), |
| 1706 | 2034 | syncServerReadModel: (readModel, environmentId) => |
| 1707 | 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 | 2040 | applyOrchestrationEvent: (event, environmentId) => |
| 1709 | 2041 | set((state) => applyOrchestrationEvent(state, event, environmentId)), |
| 1710 | 2042 | applyOrchestrationEvents: (events, environmentId) => |
| 1711 | 2043 | set((state) => applyOrchestrationEvents(state, events, environmentId)), |
| 2044 | applyShellEvent: (event, environmentId) => | |
| 2045 | set((state) => applyShellEvent(state, event, environmentId)), | |
| 1712 | 2046 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), |
| 1713 | 2047 | setThreadBranch: (threadRef, branch, worktreePath) => |
| 1714 | 2048 | set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), |
apps/web/test/wsRpcHarness.ts+3-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; |
| 2 | import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; | |
| 2 | import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; | |
| 3 | 3 | import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 4 | 4 | |
| 5 | 5 | type RpcServerInstance = RpcServer.RpcServer<any>; |
| ... | ... | @@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions { |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | const STREAM_METHODS = new Set<string>([ |
| 26 | ORCHESTRATION_WS_METHODS.subscribeShell, | |
| 27 | ORCHESTRATION_WS_METHODS.subscribeThread, | |
| 26 | 28 | WS_METHODS.gitRunStackedAction, |
| 27 | 29 | WS_METHODS.subscribeGitStatus, |
| 28 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 29 | 30 | WS_METHODS.subscribeTerminalEvents, |
| 30 | 31 | WS_METHODS.subscribeServerConfig, |
| 31 | 32 | WS_METHODS.subscribeServerLifecycle, |
packages/contracts/src/ipc.ts+12-6| ... | ... | @@ -46,8 +46,9 @@ import type { |
| 46 | 46 | OrchestrationGetFullThreadDiffResult, |
| 47 | 47 | OrchestrationGetTurnDiffInput, |
| 48 | 48 | OrchestrationGetTurnDiffResult, |
| 49 | OrchestrationEvent, | |
| 50 | OrchestrationReadModel, | |
| 49 | OrchestrationShellStreamItem, | |
| 50 | OrchestrationSubscribeThreadInput, | |
| 51 | OrchestrationThreadStreamItem, | |
| 51 | 52 | } from "./orchestration"; |
| 52 | 53 | import type { EnvironmentId } from "./baseSchemas"; |
| 53 | 54 | import { EditorId } from "./editor"; |
| ... | ... | @@ -249,15 +250,20 @@ export interface EnvironmentApi { |
| 249 | 250 | ) => () => void; |
| 250 | 251 | }; |
| 251 | 252 | orchestration: { |
| 252 | getSnapshot: () => Promise<OrchestrationReadModel>; | |
| 253 | 253 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
| 254 | 254 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; |
| 255 | 255 | getFullThreadDiff: ( |
| 256 | 256 | input: OrchestrationGetFullThreadDiffInput, |
| 257 | 257 | ) => Promise<OrchestrationGetFullThreadDiffResult>; |
| 258 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; | |
| 259 | onDomainEvent: ( | |
| 260 | callback: (event: OrchestrationEvent) => void, | |
| 258 | subscribeShell: ( | |
| 259 | callback: (event: OrchestrationShellStreamItem) => void, | |
| 260 | options?: { | |
| 261 | onResubscribe?: () => void; | |
| 262 | }, | |
| 263 | ) => () => void; | |
| 264 | subscribeThread: ( | |
| 265 | input: OrchestrationSubscribeThreadInput, | |
| 266 | callback: (event: OrchestrationThreadStreamItem) => void, | |
| 261 | 267 | options?: { |
| 262 | 268 | onResubscribe?: () => void; |
| 263 | 269 | }, |
packages/contracts/src/orchestration.ts+109-10| ... | ... | @@ -17,11 +17,12 @@ import { |
| 17 | 17 | } from "./baseSchemas"; |
| 18 | 18 | |
| 19 | 19 | export const ORCHESTRATION_WS_METHODS = { |
| 20 | getSnapshot: "orchestration.getSnapshot", | |
| 21 | 20 | dispatchCommand: "orchestration.dispatchCommand", |
| 22 | 21 | getTurnDiff: "orchestration.getTurnDiff", |
| 23 | 22 | getFullThreadDiff: "orchestration.getFullThreadDiff", |
| 24 | 23 | replayEvents: "orchestration.replayEvents", |
| 24 | subscribeShell: "orchestration.subscribeShell", | |
| 25 | subscribeThread: "orchestration.subscribeThread", | |
| 25 | 26 | } as const; |
| 26 | 27 | |
| 27 | 28 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); |
| ... | ... | @@ -308,6 +309,93 @@ export const OrchestrationReadModel = Schema.Struct({ |
| 308 | 309 | }); |
| 309 | 310 | export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; |
| 310 | 311 | |
| 312 | export 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 | }); | |
| 322 | export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; | |
| 323 | ||
| 324 | export 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 | }); | |
| 345 | export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; | |
| 346 | ||
| 347 | export const OrchestrationShellSnapshot = Schema.Struct({ | |
| 348 | snapshotSequence: NonNegativeInt, | |
| 349 | projects: Schema.Array(OrchestrationProjectShell), | |
| 350 | threads: Schema.Array(OrchestrationThreadShell), | |
| 351 | updatedAt: IsoDateTime, | |
| 352 | }); | |
| 353 | export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; | |
| 354 | ||
| 355 | export 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 | ]); | |
| 377 | export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; | |
| 378 | ||
| 379 | export const OrchestrationShellStreamItem = Schema.Union([ | |
| 380 | Schema.Struct({ | |
| 381 | kind: Schema.Literal("snapshot"), | |
| 382 | snapshot: OrchestrationShellSnapshot, | |
| 383 | }), | |
| 384 | OrchestrationShellStreamEvent, | |
| 385 | ]); | |
| 386 | export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type; | |
| 387 | ||
| 388 | export const OrchestrationSubscribeThreadInput = Schema.Struct({ | |
| 389 | threadId: ThreadId, | |
| 390 | }); | |
| 391 | export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; | |
| 392 | ||
| 393 | export const OrchestrationThreadDetailSnapshot = Schema.Struct({ | |
| 394 | snapshotSequence: NonNegativeInt, | |
| 395 | thread: OrchestrationThread, | |
| 396 | }); | |
| 397 | export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; | |
| 398 | ||
| 311 | 399 | export const ProjectCreateCommand = Schema.Struct({ |
| 312 | 400 | type: Schema.Literal("project.create"), |
| 313 | 401 | commandId: CommandId, |
| ... | ... | @@ -955,6 +1043,18 @@ export const OrchestrationEvent = Schema.Union([ |
| 955 | 1043 | ]); |
| 956 | 1044 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; |
| 957 | 1045 | |
| 1046 | export 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 | ]); | |
| 1056 | export type OrchestrationThreadStreamItem = typeof OrchestrationThreadStreamItem.Type; | |
| 1057 | ||
| 958 | 1058 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); |
| 959 | 1059 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; |
| 960 | 1060 | |
| ... | ... | @@ -1019,11 +1119,6 @@ export const DispatchResult = Schema.Struct({ |
| 1019 | 1119 | }); |
| 1020 | 1120 | export type DispatchResult = typeof DispatchResult.Type; |
| 1021 | 1121 | |
| 1022 | export const OrchestrationGetSnapshotInput = Schema.Struct({}); | |
| 1023 | export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; | |
| 1024 | const OrchestrationGetSnapshotResult = OrchestrationReadModel; | |
| 1025 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; | |
| 1026 | ||
| 1027 | 1122 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( |
| 1028 | 1123 | Struct.assign({ threadId: ThreadId }), |
| 1029 | 1124 | { unsafePreserveChecks: true }, |
| ... | ... | @@ -1051,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); |
| 1051 | 1146 | export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; |
| 1052 | 1147 | |
| 1053 | 1148 | export const OrchestrationRpcSchemas = { |
| 1054 | getSnapshot: { | |
| 1055 | input: OrchestrationGetSnapshotInput, | |
| 1056 | output: OrchestrationGetSnapshotResult, | |
| 1057 | }, | |
| 1058 | 1149 | dispatchCommand: { |
| 1059 | 1150 | input: ClientOrchestrationCommand, |
| 1060 | 1151 | output: DispatchResult, |
| ... | ... | @@ -1071,6 +1162,14 @@ export const OrchestrationRpcSchemas = { |
| 1071 | 1162 | input: OrchestrationReplayEventsInput, |
| 1072 | 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 | 1173 | } as const; |
| 1075 | 1174 | |
| 1076 | 1175 | export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()( |
packages/contracts/src/rpc.ts+14-15| ... | ... | @@ -32,13 +32,11 @@ import { |
| 32 | 32 | import { KeybindingsConfigError } from "./keybindings"; |
| 33 | 33 | import { |
| 34 | 34 | ClientOrchestrationCommand, |
| 35 | OrchestrationEvent, | |
| 36 | 35 | ORCHESTRATION_WS_METHODS, |
| 37 | 36 | OrchestrationDispatchCommandError, |
| 38 | 37 | OrchestrationGetFullThreadDiffError, |
| 39 | 38 | OrchestrationGetFullThreadDiffInput, |
| 40 | 39 | OrchestrationGetSnapshotError, |
| 41 | OrchestrationGetSnapshotInput, | |
| 42 | 40 | OrchestrationGetTurnDiffError, |
| 43 | 41 | OrchestrationGetTurnDiffInput, |
| 44 | 42 | OrchestrationReplayEventsError, |
| ... | ... | @@ -115,7 +113,6 @@ export const WS_METHODS = { |
| 115 | 113 | |
| 116 | 114 | // Streaming subscriptions |
| 117 | 115 | subscribeGitStatus: "subscribeGitStatus", |
| 118 | subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", | |
| 119 | 116 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 120 | 117 | subscribeServerConfig: "subscribeServerConfig", |
| 121 | 118 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| ... | ... | @@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 272 | 269 | error: TerminalError, |
| 273 | 270 | }); |
| 274 | 271 | |
| 275 | export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, { | |
| 276 | payload: OrchestrationGetSnapshotInput, | |
| 277 | success: OrchestrationRpcSchemas.getSnapshot.output, | |
| 278 | error: OrchestrationGetSnapshotError, | |
| 279 | }); | |
| 280 | ||
| 281 | 272 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 282 | 273 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 283 | 274 | { |
| ... | ... | @@ -308,11 +299,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. |
| 308 | 299 | error: OrchestrationReplayEventsError, |
| 309 | 300 | }); |
| 310 | 301 | |
| 311 | export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( | |
| 312 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 302 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { | |
| 303 | payload: OrchestrationRpcSchemas.subscribeShell.input, | |
| 304 | success: OrchestrationRpcSchemas.subscribeShell.output, | |
| 305 | error: OrchestrationGetSnapshotError, | |
| 306 | stream: true, | |
| 307 | }); | |
| 308 | ||
| 309 | export const WsOrchestrationSubscribeThreadRpc = Rpc.make( | |
| 310 | ORCHESTRATION_WS_METHODS.subscribeThread, | |
| 313 | 311 | { |
| 314 | payload: Schema.Struct({}), | |
| 315 | success: OrchestrationEvent, | |
| 312 | payload: OrchestrationRpcSchemas.subscribeThread.input, | |
| 313 | success: OrchestrationRpcSchemas.subscribeThread.output, | |
| 314 | error: OrchestrationGetSnapshotError, | |
| 316 | 315 | stream: true, |
| 317 | 316 | }, |
| 318 | 317 | ); |
| ... | ... | @@ -369,14 +368,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 369 | 368 | WsTerminalClearRpc, |
| 370 | 369 | WsTerminalRestartRpc, |
| 371 | 370 | WsTerminalCloseRpc, |
| 372 | WsSubscribeOrchestrationDomainEventsRpc, | |
| 373 | 371 | WsSubscribeTerminalEventsRpc, |
| 374 | 372 | WsSubscribeServerConfigRpc, |
| 375 | 373 | WsSubscribeServerLifecycleRpc, |
| 376 | 374 | WsSubscribeAuthAccessRpc, |
| 377 | WsOrchestrationGetSnapshotRpc, | |
| 378 | 375 | WsOrchestrationDispatchCommandRpc, |
| 379 | 376 | WsOrchestrationGetTurnDiffRpc, |
| 380 | 377 | WsOrchestrationGetFullThreadDiffRpc, |
| 381 | 378 | WsOrchestrationReplayEventsRpc, |
| 379 | WsOrchestrationSubscribeShellRpc, | |
| 380 | WsOrchestrationSubscribeThreadRpc, | |
| 382 | 381 | ); |