| 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,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => { |
| 82 | Layer.succeed(ProjectionSnapshotQuery, { | 82 | Layer.succeed(ProjectionSnapshotQuery, { |
| 83 | getSnapshot: () => | 83 | getSnapshot: () => |
| 84 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | 84 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 85 | getShellSnapshot: () => | ||
| 86 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | ||
| 85 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | 87 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 86 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 88 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 89 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 87 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 90 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 88 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), | 91 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 92 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 93 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 89 | }), | 94 | }), |
| 90 | ), | 95 | ), |
| 91 | ); | 96 | ); |
| ... | @@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { | ... | @@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { |
| 136 | Layer.succeed(ProjectionSnapshotQuery, { | 141 | Layer.succeed(ProjectionSnapshotQuery, { |
| 137 | getSnapshot: () => | 142 | getSnapshot: () => |
| 138 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), | 143 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 144 | getShellSnapshot: () => | ||
| 145 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | ||
| 139 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | 146 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 140 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 147 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 148 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 141 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 149 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 142 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 150 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 151 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 152 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 143 | }), | 153 | }), |
| 144 | ), | 154 | ), |
| 145 | ); | 155 | ); |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+10| ... | @@ -144,10 +144,20 @@ describe("OrchestrationEngine", () => { | ... | @@ -144,10 +144,20 @@ describe("OrchestrationEngine", () => { |
| 144 | Layer.provide( | 144 | Layer.provide( |
| 145 | Layer.succeed(ProjectionSnapshotQuery, { | 145 | Layer.succeed(ProjectionSnapshotQuery, { |
| 146 | getSnapshot: () => Effect.succeed(projectionSnapshot), | 146 | getSnapshot: () => Effect.succeed(projectionSnapshot), |
| 147 | getShellSnapshot: () => | ||
| 148 | Effect.succeed({ | ||
| 149 | snapshotSequence: projectionSnapshot.snapshotSequence, | ||
| 150 | projects: [], | ||
| 151 | threads: [], | ||
| 152 | updatedAt: projectionSnapshot.updatedAt, | ||
| 153 | }), | ||
| 147 | getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), | 154 | getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), |
| 148 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 155 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 156 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 149 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 157 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 150 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 158 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 159 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 160 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 151 | }), | 161 | }), |
| 152 | ), | 162 | ), |
| 153 | Layer.provide( | 163 | Layer.provide( |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+125-1| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | ApprovalRequestId, | 2 | ApprovalRequestId, |
| 3 | type ChatAttachment, | 3 | type ChatAttachment, |
| 4 | type OrchestrationEvent, | 4 | type OrchestrationEvent, |
| 5 | ThreadId, | ||
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; | 7 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; |
| 7 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -89,6 +90,77 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { | ... | @@ -89,6 +90,77 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| 89 | return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; | 90 | return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; |
| 90 | } | 91 | } |
| 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 | function retainProjectionMessagesAfterRevert( | 164 | function retainProjectionMessagesAfterRevert( |
| 93 | messages: ReadonlyArray<ProjectionThreadMessage>, | 165 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| 94 | turns: ReadonlyArray<ProjectionTurn>, | 166 | turns: ReadonlyArray<ProjectionTurn>, |
| ... | @@ -432,6 +504,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -432,6 +504,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 432 | } | 504 | } |
| 433 | }); | 505 | }); |
| 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 | const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( | 549 | const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( |
| 436 | "applyThreadsProjection", | 550 | "applyThreadsProjection", |
| 437 | )(function* (event, attachmentSideEffects) { | 551 | )(function* (event, attachmentSideEffects) { |
| ... | @@ -450,6 +564,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -450,6 +564,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 450 | createdAt: event.payload.createdAt, | 564 | createdAt: event.payload.createdAt, |
| 451 | updatedAt: event.payload.updatedAt, | 565 | updatedAt: event.payload.updatedAt, |
| 452 | archivedAt: null, | 566 | archivedAt: null, |
| 567 | latestUserMessageAt: null, | ||
| 568 | pendingApprovalCount: 0, | ||
| 569 | pendingUserInputCount: 0, | ||
| 570 | hasActionableProposedPlan: 0, | ||
| 453 | deletedAt: null, | 571 | deletedAt: null, |
| 454 | }); | 572 | }); |
| 455 | return; | 573 | return; |
| ... | @@ -554,7 +672,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -554,7 +672,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 554 | 672 | ||
| 555 | case "thread.message-sent": | 673 | case "thread.message-sent": |
| 556 | case "thread.proposed-plan-upserted": | 674 | case "thread.proposed-plan-upserted": |
| 557 | case "thread.activity-appended": { | 675 | case "thread.activity-appended": |
| 676 | case "thread.approval-response-requested": | ||
| 677 | case "thread.user-input-response-requested": { | ||
| 558 | const existingRow = yield* projectionThreadRepository.getById({ | 678 | const existingRow = yield* projectionThreadRepository.getById({ |
| 559 | threadId: event.payload.threadId, | 679 | threadId: event.payload.threadId, |
| 560 | }); | 680 | }); |
| ... | @@ -565,6 +685,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -565,6 +685,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 565 | ...existingRow.value, | 685 | ...existingRow.value, |
| 566 | updatedAt: event.occurredAt, | 686 | updatedAt: event.occurredAt, |
| 567 | }); | 687 | }); |
| 688 | yield* refreshThreadShellSummary(event.payload.threadId); | ||
| 568 | return; | 689 | return; |
| 569 | } | 690 | } |
| 570 | 691 | ||
| ... | @@ -580,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -580,6 +701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 580 | latestTurnId: event.payload.session.activeTurnId, | 701 | latestTurnId: event.payload.session.activeTurnId, |
| 581 | updatedAt: event.occurredAt, | 702 | updatedAt: event.occurredAt, |
| 582 | }); | 703 | }); |
| 704 | yield* refreshThreadShellSummary(event.payload.threadId); | ||
| 583 | return; | 705 | return; |
| 584 | } | 706 | } |
| 585 | 707 | ||
| ... | @@ -595,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -595,6 +717,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 595 | latestTurnId: event.payload.turnId, | 717 | latestTurnId: event.payload.turnId, |
| 596 | updatedAt: event.occurredAt, | 718 | updatedAt: event.occurredAt, |
| 597 | }); | 719 | }); |
| 720 | yield* refreshThreadShellSummary(event.payload.threadId); | ||
| 598 | return; | 721 | return; |
| 599 | } | 722 | } |
| 600 | 723 | ||
| ... | @@ -610,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -610,6 +733,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 610 | latestTurnId: null, | 733 | latestTurnId: null, |
| 611 | updatedAt: event.occurredAt, | 734 | updatedAt: event.occurredAt, |
| 612 | }); | 735 | }); |
| 736 | yield* refreshThreadShellSummary(event.payload.threadId); | ||
| 613 | return; | 737 | return; |
| 614 | } | 738 | } |
| 615 | 739 |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+392| ... | @@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 62 | project_id, | 62 | project_id, |
| 63 | title, | 63 | title, |
| 64 | model_selection_json, | 64 | model_selection_json, |
| 65 | runtime_mode, | ||
| 66 | interaction_mode, | ||
| 65 | branch, | 67 | branch, |
| 66 | worktree_path, | 68 | worktree_path, |
| 67 | latest_turn_id, | 69 | latest_turn_id, |
| 70 | latest_user_message_at, | ||
| 71 | pending_approval_count, | ||
| 72 | pending_user_input_count, | ||
| 73 | has_actionable_proposed_plan, | ||
| 68 | created_at, | 74 | created_at, |
| 69 | updated_at, | 75 | updated_at, |
| 70 | deleted_at | 76 | deleted_at |
| ... | @@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 74 | 'project-1', | 80 | 'project-1', |
| 75 | 'Thread 1', | 81 | 'Thread 1', |
| 76 | '{"provider":"codex","model":"gpt-5-codex"}', | 82 | '{"provider":"codex","model":"gpt-5-codex"}', |
| 83 | 'full-access', | ||
| 84 | 'default', | ||
| 77 | NULL, | 85 | NULL, |
| 78 | NULL, | 86 | NULL, |
| 79 | 'turn-1', | 87 | 'turn-1', |
| 88 | '2026-02-24T00:00:04.000Z', | ||
| 89 | 1, | ||
| 90 | 0, | ||
| 91 | 0, | ||
| 80 | '2026-02-24T00:00:02.000Z', | 92 | '2026-02-24T00:00:02.000Z', |
| 81 | '2026-02-24T00:00:03.000Z', | 93 | '2026-02-24T00:00:03.000Z', |
| 82 | NULL | 94 | NULL |
| ... | @@ -341,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -341,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 341 | }, | 353 | }, |
| 342 | }, | 354 | }, |
| 343 | ]); | 355 | ]); |
| 356 | |||
| 357 | const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); | ||
| 358 | assert.equal(shellSnapshot.snapshotSequence, 5); | ||
| 359 | assert.deepEqual(shellSnapshot.projects, [ | ||
| 360 | { | ||
| 361 | id: asProjectId("project-1"), | ||
| 362 | title: "Project 1", | ||
| 363 | workspaceRoot: "/tmp/project-1", | ||
| 364 | repositoryIdentity: null, | ||
| 365 | defaultModelSelection: { | ||
| 366 | provider: "codex", | ||
| 367 | model: "gpt-5-codex", | ||
| 368 | }, | ||
| 369 | scripts: [ | ||
| 370 | { | ||
| 371 | id: "script-1", | ||
| 372 | name: "Build", | ||
| 373 | command: "bun run build", | ||
| 374 | icon: "build", | ||
| 375 | runOnWorktreeCreate: false, | ||
| 376 | }, | ||
| 377 | ], | ||
| 378 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 379 | updatedAt: "2026-02-24T00:00:01.000Z", | ||
| 380 | }, | ||
| 381 | ]); | ||
| 382 | assert.deepEqual(shellSnapshot.threads, [ | ||
| 383 | { | ||
| 384 | id: ThreadId.make("thread-1"), | ||
| 385 | projectId: asProjectId("project-1"), | ||
| 386 | title: "Thread 1", | ||
| 387 | modelSelection: { | ||
| 388 | provider: "codex", | ||
| 389 | model: "gpt-5-codex", | ||
| 390 | }, | ||
| 391 | interactionMode: "default", | ||
| 392 | runtimeMode: "full-access", | ||
| 393 | branch: null, | ||
| 394 | worktreePath: null, | ||
| 395 | latestTurn: { | ||
| 396 | turnId: asTurnId("turn-1"), | ||
| 397 | state: "completed", | ||
| 398 | requestedAt: "2026-02-24T00:00:08.000Z", | ||
| 399 | startedAt: "2026-02-24T00:00:08.000Z", | ||
| 400 | completedAt: "2026-02-24T00:00:08.000Z", | ||
| 401 | assistantMessageId: asMessageId("message-1"), | ||
| 402 | sourceProposedPlan: { | ||
| 403 | threadId: ThreadId.make("thread-1"), | ||
| 404 | planId: "plan-1", | ||
| 405 | }, | ||
| 406 | }, | ||
| 407 | createdAt: "2026-02-24T00:00:02.000Z", | ||
| 408 | updatedAt: "2026-02-24T00:00:03.000Z", | ||
| 409 | archivedAt: null, | ||
| 410 | session: { | ||
| 411 | threadId: ThreadId.make("thread-1"), | ||
| 412 | status: "running", | ||
| 413 | providerName: "codex", | ||
| 414 | runtimeMode: "approval-required", | ||
| 415 | activeTurnId: asTurnId("turn-1"), | ||
| 416 | lastError: null, | ||
| 417 | updatedAt: "2026-02-24T00:00:07.000Z", | ||
| 418 | }, | ||
| 419 | latestUserMessageAt: "2026-02-24T00:00:04.000Z", | ||
| 420 | hasPendingApprovals: true, | ||
| 421 | hasPendingUserInput: false, | ||
| 422 | hasActionableProposedPlan: false, | ||
| 423 | }, | ||
| 424 | ]); | ||
| 425 | |||
| 426 | const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | ||
| 427 | assert.equal(threadDetail._tag, "Some"); | ||
| 428 | if (threadDetail._tag === "Some") { | ||
| 429 | assert.deepEqual(threadDetail.value, snapshot.threads[0]); | ||
| 430 | } | ||
| 344 | }), | 431 | }), |
| 345 | ); | 432 | ); |
| 346 | 433 | ||
| ... | @@ -629,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -629,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 629 | } | 716 | } |
| 630 | }), | 717 | }), |
| 631 | ); | 718 | ); |
| 719 | |||
| 720 | it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => | ||
| 721 | Effect.gen(function* () { | ||
| 722 | const snapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 723 | const sql = yield* SqlClient.SqlClient; | ||
| 724 | |||
| 725 | yield* sql`DELETE FROM projection_projects`; | ||
| 726 | yield* sql`DELETE FROM projection_threads`; | ||
| 727 | yield* sql`DELETE FROM projection_thread_activities`; | ||
| 728 | yield* sql`DELETE FROM projection_state`; | ||
| 729 | |||
| 730 | yield* sql` | ||
| 731 | INSERT INTO projection_projects ( | ||
| 732 | project_id, | ||
| 733 | title, | ||
| 734 | workspace_root, | ||
| 735 | default_model_selection_json, | ||
| 736 | scripts_json, | ||
| 737 | created_at, | ||
| 738 | updated_at, | ||
| 739 | deleted_at | ||
| 740 | ) | ||
| 741 | VALUES ( | ||
| 742 | 'project-1', | ||
| 743 | 'Project 1', | ||
| 744 | '/tmp/project-1', | ||
| 745 | '{"provider":"codex","model":"gpt-5-codex"}', | ||
| 746 | '[]', | ||
| 747 | '2026-04-01T00:00:00.000Z', | ||
| 748 | '2026-04-01T00:00:01.000Z', | ||
| 749 | NULL | ||
| 750 | ) | ||
| 751 | `; | ||
| 752 | |||
| 753 | yield* sql` | ||
| 754 | INSERT INTO projection_threads ( | ||
| 755 | thread_id, | ||
| 756 | project_id, | ||
| 757 | title, | ||
| 758 | model_selection_json, | ||
| 759 | runtime_mode, | ||
| 760 | interaction_mode, | ||
| 761 | branch, | ||
| 762 | worktree_path, | ||
| 763 | latest_turn_id, | ||
| 764 | latest_user_message_at, | ||
| 765 | pending_approval_count, | ||
| 766 | pending_user_input_count, | ||
| 767 | has_actionable_proposed_plan, | ||
| 768 | created_at, | ||
| 769 | updated_at, | ||
| 770 | deleted_at | ||
| 771 | ) | ||
| 772 | VALUES ( | ||
| 773 | 'thread-1', | ||
| 774 | 'project-1', | ||
| 775 | 'Thread 1', | ||
| 776 | '{"provider":"codex","model":"gpt-5-codex"}', | ||
| 777 | 'full-access', | ||
| 778 | 'default', | ||
| 779 | NULL, | ||
| 780 | NULL, | ||
| 781 | NULL, | ||
| 782 | NULL, | ||
| 783 | 0, | ||
| 784 | 0, | ||
| 785 | 0, | ||
| 786 | '2026-04-01T00:00:02.000Z', | ||
| 787 | '2026-04-01T00:00:03.000Z', | ||
| 788 | NULL | ||
| 789 | ) | ||
| 790 | `; | ||
| 791 | |||
| 792 | yield* sql` | ||
| 793 | INSERT INTO projection_thread_activities ( | ||
| 794 | activity_id, | ||
| 795 | thread_id, | ||
| 796 | turn_id, | ||
| 797 | tone, | ||
| 798 | kind, | ||
| 799 | summary, | ||
| 800 | payload_json, | ||
| 801 | sequence, | ||
| 802 | created_at | ||
| 803 | ) | ||
| 804 | VALUES | ||
| 805 | ( | ||
| 806 | 'activity-unsequenced', | ||
| 807 | 'thread-1', | ||
| 808 | NULL, | ||
| 809 | 'info', | ||
| 810 | 'runtime.note', | ||
| 811 | 'unsequenced first', | ||
| 812 | '{"source":"unsequenced"}', | ||
| 813 | NULL, | ||
| 814 | '2026-04-01T00:00:06.000Z' | ||
| 815 | ), | ||
| 816 | ( | ||
| 817 | 'activity-sequence-2', | ||
| 818 | 'thread-1', | ||
| 819 | NULL, | ||
| 820 | 'info', | ||
| 821 | 'runtime.note', | ||
| 822 | 'sequence two', | ||
| 823 | '{"source":"sequence-2"}', | ||
| 824 | 2, | ||
| 825 | '2026-04-01T00:00:04.000Z' | ||
| 826 | ), | ||
| 827 | ( | ||
| 828 | 'activity-sequence-1', | ||
| 829 | 'thread-1', | ||
| 830 | NULL, | ||
| 831 | 'info', | ||
| 832 | 'runtime.note', | ||
| 833 | 'sequence one', | ||
| 834 | '{"source":"sequence-1"}', | ||
| 835 | 1, | ||
| 836 | '2026-04-01T00:00:05.000Z' | ||
| 837 | ) | ||
| 838 | `; | ||
| 839 | |||
| 840 | const snapshot = yield* snapshotQuery.getSnapshot(); | ||
| 841 | const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | ||
| 842 | |||
| 843 | assert.equal(threadDetail._tag, "Some"); | ||
| 844 | if (threadDetail._tag === "Some") { | ||
| 845 | assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); | ||
| 846 | } | ||
| 847 | |||
| 848 | assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ | ||
| 849 | { | ||
| 850 | id: asEventId("activity-unsequenced"), | ||
| 851 | tone: "info", | ||
| 852 | kind: "runtime.note", | ||
| 853 | summary: "unsequenced first", | ||
| 854 | payload: { source: "unsequenced" }, | ||
| 855 | turnId: null, | ||
| 856 | createdAt: "2026-04-01T00:00:06.000Z", | ||
| 857 | }, | ||
| 858 | { | ||
| 859 | id: asEventId("activity-sequence-1"), | ||
| 860 | tone: "info", | ||
| 861 | kind: "runtime.note", | ||
| 862 | summary: "sequence one", | ||
| 863 | payload: { source: "sequence-1" }, | ||
| 864 | turnId: null, | ||
| 865 | sequence: 1, | ||
| 866 | createdAt: "2026-04-01T00:00:05.000Z", | ||
| 867 | }, | ||
| 868 | { | ||
| 869 | id: asEventId("activity-sequence-2"), | ||
| 870 | tone: "info", | ||
| 871 | kind: "runtime.note", | ||
| 872 | summary: "sequence two", | ||
| 873 | payload: { source: "sequence-2" }, | ||
| 874 | turnId: null, | ||
| 875 | sequence: 2, | ||
| 876 | createdAt: "2026-04-01T00:00:04.000Z", | ||
| 877 | }, | ||
| 878 | ]); | ||
| 879 | }), | ||
| 880 | ); | ||
| 881 | |||
| 882 | it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => | ||
| 883 | Effect.gen(function* () { | ||
| 884 | const snapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 885 | const sql = yield* SqlClient.SqlClient; | ||
| 886 | |||
| 887 | yield* sql`DELETE FROM projection_projects`; | ||
| 888 | yield* sql`DELETE FROM projection_threads`; | ||
| 889 | yield* sql`DELETE FROM projection_turns`; | ||
| 890 | |||
| 891 | yield* sql` | ||
| 892 | INSERT INTO projection_projects ( | ||
| 893 | project_id, | ||
| 894 | title, | ||
| 895 | workspace_root, | ||
| 896 | default_model_selection_json, | ||
| 897 | scripts_json, | ||
| 898 | created_at, | ||
| 899 | updated_at, | ||
| 900 | deleted_at | ||
| 901 | ) | ||
| 902 | VALUES ( | ||
| 903 | 'project-1', | ||
| 904 | 'Project 1', | ||
| 905 | '/tmp/project-1', | ||
| 906 | '{"provider":"codex","model":"gpt-5-codex"}', | ||
| 907 | '[]', | ||
| 908 | '2026-04-02T00:00:00.000Z', | ||
| 909 | '2026-04-02T00:00:01.000Z', | ||
| 910 | NULL | ||
| 911 | ) | ||
| 912 | `; | ||
| 913 | |||
| 914 | yield* sql` | ||
| 915 | INSERT INTO projection_threads ( | ||
| 916 | thread_id, | ||
| 917 | project_id, | ||
| 918 | title, | ||
| 919 | model_selection_json, | ||
| 920 | runtime_mode, | ||
| 921 | interaction_mode, | ||
| 922 | branch, | ||
| 923 | worktree_path, | ||
| 924 | latest_turn_id, | ||
| 925 | latest_user_message_at, | ||
| 926 | pending_approval_count, | ||
| 927 | pending_user_input_count, | ||
| 928 | has_actionable_proposed_plan, | ||
| 929 | created_at, | ||
| 930 | updated_at, | ||
| 931 | archived_at, | ||
| 932 | deleted_at | ||
| 933 | ) | ||
| 934 | VALUES ( | ||
| 935 | 'thread-1', | ||
| 936 | 'project-1', | ||
| 937 | 'Thread 1', | ||
| 938 | '{"provider":"codex","model":"gpt-5-codex"}', | ||
| 939 | 'full-access', | ||
| 940 | 'default', | ||
| 941 | NULL, | ||
| 942 | NULL, | ||
| 943 | 'turn-running', | ||
| 944 | '2026-04-02T00:00:04.000Z', | ||
| 945 | 0, | ||
| 946 | 0, | ||
| 947 | 0, | ||
| 948 | '2026-04-02T00:00:02.000Z', | ||
| 949 | '2026-04-02T00:00:03.000Z', | ||
| 950 | NULL, | ||
| 951 | NULL | ||
| 952 | ) | ||
| 953 | `; | ||
| 954 | |||
| 955 | yield* sql` | ||
| 956 | INSERT INTO projection_turns ( | ||
| 957 | thread_id, | ||
| 958 | turn_id, | ||
| 959 | pending_message_id, | ||
| 960 | source_proposed_plan_thread_id, | ||
| 961 | source_proposed_plan_id, | ||
| 962 | assistant_message_id, | ||
| 963 | state, | ||
| 964 | requested_at, | ||
| 965 | started_at, | ||
| 966 | completed_at, | ||
| 967 | checkpoint_turn_count, | ||
| 968 | checkpoint_ref, | ||
| 969 | checkpoint_status, | ||
| 970 | checkpoint_files_json | ||
| 971 | ) | ||
| 972 | VALUES | ||
| 973 | ( | ||
| 974 | 'thread-1', | ||
| 975 | 'turn-completed', | ||
| 976 | 'message-user-1', | ||
| 977 | NULL, | ||
| 978 | NULL, | ||
| 979 | 'message-assistant-1', | ||
| 980 | 'completed', | ||
| 981 | '2026-04-02T00:00:05.000Z', | ||
| 982 | '2026-04-02T00:00:06.000Z', | ||
| 983 | '2026-04-02T00:00:20.000Z', | ||
| 984 | 5, | ||
| 985 | 'checkpoint-5', | ||
| 986 | 'ready', | ||
| 987 | '[]' | ||
| 988 | ), | ||
| 989 | ( | ||
| 990 | 'thread-1', | ||
| 991 | 'turn-running', | ||
| 992 | 'message-user-2', | ||
| 993 | NULL, | ||
| 994 | NULL, | ||
| 995 | NULL, | ||
| 996 | 'running', | ||
| 997 | '2026-04-02T00:00:30.000Z', | ||
| 998 | '2026-04-02T00:00:30.000Z', | ||
| 999 | NULL, | ||
| 1000 | NULL, | ||
| 1001 | NULL, | ||
| 1002 | NULL, | ||
| 1003 | '[]' | ||
| 1004 | ) | ||
| 1005 | `; | ||
| 1006 | |||
| 1007 | const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); | ||
| 1008 | assert.equal(threadShell._tag, "Some"); | ||
| 1009 | if (threadShell._tag === "Some") { | ||
| 1010 | assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); | ||
| 1011 | assert.equal(threadShell.value.latestTurn?.state, "running"); | ||
| 1012 | assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); | ||
| 1013 | } | ||
| 1014 | |||
| 1015 | const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | ||
| 1016 | assert.equal(threadDetail._tag, "Some"); | ||
| 1017 | if (threadDetail._tag === "Some") { | ||
| 1018 | assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); | ||
| 1019 | assert.equal(threadDetail.value.latestTurn?.state, "running"); | ||
| 1020 | assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); | ||
| 1021 | } | ||
| 1022 | }), | ||
| 1023 | ); | ||
| 632 | }); | 1024 | }); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+594-1| ... | @@ -6,16 +6,19 @@ import { | ... | @@ -6,16 +6,19 @@ import { |
| 6 | OrchestrationCheckpointFile, | 6 | OrchestrationCheckpointFile, |
| 7 | OrchestrationProposedPlanId, | 7 | OrchestrationProposedPlanId, |
| 8 | OrchestrationReadModel, | 8 | OrchestrationReadModel, |
| 9 | OrchestrationShellSnapshot, | ||
| 10 | OrchestrationThread, | ||
| 9 | ProjectScript, | 11 | ProjectScript, |
| 10 | TurnId, | 12 | TurnId, |
| 11 | type OrchestrationCheckpointSummary, | 13 | type OrchestrationCheckpointSummary, |
| 12 | type OrchestrationLatestTurn, | 14 | type OrchestrationLatestTurn, |
| 13 | type OrchestrationMessage, | 15 | type OrchestrationMessage, |
| 16 | type OrchestrationProjectShell, | ||
| 14 | type OrchestrationProposedPlan, | 17 | type OrchestrationProposedPlan, |
| 15 | type OrchestrationProject, | 18 | type OrchestrationProject, |
| 16 | type OrchestrationSession, | 19 | type OrchestrationSession, |
| 17 | type OrchestrationThread, | ||
| 18 | type OrchestrationThreadActivity, | 20 | type OrchestrationThreadActivity, |
| 21 | type OrchestrationThreadShell, | ||
| 19 | ModelSelection, | 22 | ModelSelection, |
| 20 | ProjectId, | 23 | ProjectId, |
| 21 | ThreadId, | 24 | ThreadId, |
| ... | @@ -48,6 +51,8 @@ import { | ... | @@ -48,6 +51,8 @@ import { |
| 48 | } from "../Services/ProjectionSnapshotQuery.ts"; | 51 | } from "../Services/ProjectionSnapshotQuery.ts"; |
| 49 | 52 | ||
| 50 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); | 53 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); |
| 54 | const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); | ||
| 55 | const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); | ||
| 51 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 56 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 52 | Struct.assign({ | 57 | Struct.assign({ |
| 53 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | 58 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| ... | @@ -155,6 +160,64 @@ function computeSnapshotSequence( | ... | @@ -155,6 +160,64 @@ function computeSnapshotSequence( |
| 155 | return Number.isFinite(minSequence) ? minSequence : 0; | 160 | return Number.isFinite(minSequence) ? minSequence : 0; |
| 156 | } | 161 | } |
| 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 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | 221 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 159 | return (cause: unknown): ProjectionRepositoryError => | 222 | return (cause: unknown): ProjectionRepositoryError => |
| 160 | Schema.isSchemaError(cause) | 223 | Schema.isSchemaError(cause) |
| ... | @@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 204 | created_at AS "createdAt", | 267 | created_at AS "createdAt", |
| 205 | updated_at AS "updatedAt", | 268 | updated_at AS "updatedAt", |
| 206 | archived_at AS "archivedAt", | 269 | archived_at AS "archivedAt", |
| 270 | latest_user_message_at AS "latestUserMessageAt", | ||
| 271 | pending_approval_count AS "pendingApprovalCount", | ||
| 272 | pending_user_input_count AS "pendingUserInputCount", | ||
| 273 | has_actionable_proposed_plan AS "hasActionableProposedPlan", | ||
| 207 | deleted_at AS "deletedAt" | 274 | deleted_at AS "deletedAt" |
| 208 | FROM projection_threads | 275 | FROM projection_threads |
| 209 | ORDER BY created_at ASC, thread_id ASC | 276 | ORDER BY created_at ASC, thread_id ASC |
| ... | @@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 381 | `, | 448 | `, |
| 382 | }); | 449 | }); |
| 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 | const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ | 472 | const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ |
| 385 | Request: ProjectIdLookupInput, | 473 | Request: ProjectIdLookupInput, |
| 386 | Result: ProjectionThreadIdLookupRowSchema, | 474 | Result: ProjectionThreadIdLookupRowSchema, |
| ... | @@ -415,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -415,6 +503,146 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 415 | `, | 503 | `, |
| 416 | }); | 504 | }); |
| 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 | const listCheckpointRowsByThread = SqlSchema.findAll({ | 646 | const listCheckpointRowsByThread = SqlSchema.findAll({ |
| 419 | Request: ThreadIdLookupInput, | 647 | Request: ThreadIdLookupInput, |
| 420 | Result: ProjectionCheckpointDbRowSchema, | 648 | Result: ProjectionCheckpointDbRowSchema, |
| ... | @@ -724,6 +952,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -724,6 +952,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 724 | }), | 952 | }), |
| 725 | ); | 953 | ); |
| 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 | const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => | 1094 | const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => |
| 728 | readProjectionCounts(undefined).pipe( | 1095 | readProjectionCounts(undefined).pipe( |
| 729 | Effect.mapError( | 1096 | Effect.mapError( |
| ... | @@ -770,6 +1137,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -770,6 +1137,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 770 | ), | 1137 | ), |
| 771 | ); | 1138 | ); |
| 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 | const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = | 1161 | const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = |
| 774 | (projectId) => | 1162 | (projectId) => |
| 775 | getFirstActiveThreadIdByProject({ projectId }).pipe( | 1163 | getFirstActiveThreadIdByProject({ projectId }).pipe( |
| ... | @@ -826,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -826,12 +1214,217 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 826 | }); | 1214 | }); |
| 827 | }); | 1215 | }); |
| 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 | return { | 1418 | return { |
| 830 | getSnapshot, | 1419 | getSnapshot, |
| 1420 | getShellSnapshot, | ||
| 831 | getCounts, | 1421 | getCounts, |
| 832 | getActiveProjectByWorkspaceRoot, | 1422 | getActiveProjectByWorkspaceRoot, |
| 1423 | getProjectShellById, | ||
| 833 | getFirstActiveThreadIdByProjectId, | 1424 | getFirstActiveThreadIdByProjectId, |
| 834 | getThreadCheckpointContext, | 1425 | getThreadCheckpointContext, |
| 1426 | getThreadShellById, | ||
| 1427 | getThreadDetailById, | ||
| 835 | } satisfies ProjectionSnapshotQueryShape; | 1428 | } satisfies ProjectionSnapshotQueryShape; |
| 836 | }); | 1429 | }); |
| 837 | 1430 |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+36| ... | @@ -9,7 +9,11 @@ | ... | @@ -9,7 +9,11 @@ |
| 9 | import type { | 9 | import type { |
| 10 | OrchestrationCheckpointSummary, | 10 | OrchestrationCheckpointSummary, |
| 11 | OrchestrationProject, | 11 | OrchestrationProject, |
| 12 | OrchestrationProjectShell, | ||
| 12 | OrchestrationReadModel, | 13 | OrchestrationReadModel, |
| 14 | OrchestrationShellSnapshot, | ||
| 15 | OrchestrationThread, | ||
| 16 | OrchestrationThreadShell, | ||
| 13 | ProjectId, | 17 | ProjectId, |
| 14 | ThreadId, | 18 | ThreadId, |
| 15 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| ... | @@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape { |
| 44 | */ | 48 | */ |
| 45 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>; | 49 | readonly getSnapshot: () => Effect.Effect<OrchestrationReadModel, ProjectionRepositoryError>; |
| 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 | * Read aggregate projection counts without hydrating the full read model. | 63 | * Read aggregate projection counts without hydrating the full read model. |
| 49 | */ | 64 | */ |
| ... | @@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape { |
| 56 | workspaceRoot: string, | 71 | workspaceRoot: string, |
| 57 | ) => Effect.Effect<Option.Option<OrchestrationProject>, ProjectionRepositoryError>; | 72 | ) => Effect.Effect<Option.Option<OrchestrationProject>, ProjectionRepositoryError>; |
| 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 | * Read the earliest active thread for a project. | 82 | * Read the earliest active thread for a project. |
| 61 | */ | 83 | */ |
| ... | @@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape { | ... | @@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape { |
| 69 | readonly getThreadCheckpointContext: ( | 91 | readonly getThreadCheckpointContext: ( |
| 70 | threadId: ThreadId, | 92 | threadId: ThreadId, |
| 71 | ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>; | 93 | ) => Effect.Effect<Option.Option<ProjectionThreadCheckpointContext>, ProjectionRepositoryError>; |
| 94 | |||
| 95 | /** | ||
| 96 | * Read a single active thread shell row by id. | ||
| 97 | */ | ||
| 98 | readonly getThreadShellById: ( | ||
| 99 | threadId: ThreadId, | ||
| 100 | ) => Effect.Effect<Option.Option<OrchestrationThreadShell>, ProjectionRepositoryError>; | ||
| 101 | |||
| 102 | /** | ||
| 103 | * Read a single active thread detail snapshot by id. | ||
| 104 | */ | ||
| 105 | readonly getThreadDetailById: ( | ||
| 106 | threadId: ThreadId, | ||
| 107 | ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>; | ||
| 72 | } | 108 | } |
| 73 | 109 | ||
| 74 | /** | 110 | /** |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+4| ... | @@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { | ... | @@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { |
| 88 | createdAt: "2026-03-24T00:00:00.000Z", | 88 | createdAt: "2026-03-24T00:00:00.000Z", |
| 89 | updatedAt: "2026-03-24T00:00:00.000Z", | 89 | updatedAt: "2026-03-24T00:00:00.000Z", |
| 90 | archivedAt: null, | 90 | archivedAt: null, |
| 91 | latestUserMessageAt: null, | ||
| 92 | pendingApprovalCount: 0, | ||
| 93 | pendingUserInputCount: 0, | ||
| 94 | hasActionableProposedPlan: 0, | ||
| 91 | deletedAt: null, | 95 | deletedAt: null, |
| 92 | }); | 96 | }); |
| 93 | 97 |
apps/server/src/persistence/Layers/ProjectionThreads.ts+20| ... | @@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 40 | created_at, | 40 | created_at, |
| 41 | updated_at, | 41 | updated_at, |
| 42 | archived_at, | 42 | archived_at, |
| 43 | latest_user_message_at, | ||
| 44 | pending_approval_count, | ||
| 45 | pending_user_input_count, | ||
| 46 | has_actionable_proposed_plan, | ||
| 43 | deleted_at | 47 | deleted_at |
| 44 | ) | 48 | ) |
| 45 | VALUES ( | 49 | VALUES ( |
| ... | @@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 55 | ${row.createdAt}, | 59 | ${row.createdAt}, |
| 56 | ${row.updatedAt}, | 60 | ${row.updatedAt}, |
| 57 | ${row.archivedAt}, | 61 | ${row.archivedAt}, |
| 62 | ${row.latestUserMessageAt}, | ||
| 63 | ${row.pendingApprovalCount}, | ||
| 64 | ${row.pendingUserInputCount}, | ||
| 65 | ${row.hasActionableProposedPlan}, | ||
| 58 | ${row.deletedAt} | 66 | ${row.deletedAt} |
| 59 | ) | 67 | ) |
| 60 | ON CONFLICT (thread_id) | 68 | ON CONFLICT (thread_id) |
| ... | @@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 70 | created_at = excluded.created_at, | 78 | created_at = excluded.created_at, |
| 71 | updated_at = excluded.updated_at, | 79 | updated_at = excluded.updated_at, |
| 72 | archived_at = excluded.archived_at, | 80 | archived_at = excluded.archived_at, |
| 81 | latest_user_message_at = excluded.latest_user_message_at, | ||
| 82 | pending_approval_count = excluded.pending_approval_count, | ||
| 83 | pending_user_input_count = excluded.pending_user_input_count, | ||
| 84 | has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, | ||
| 73 | deleted_at = excluded.deleted_at | 85 | deleted_at = excluded.deleted_at |
| 74 | `, | 86 | `, |
| 75 | }); | 87 | }); |
| ... | @@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 92 | created_at AS "createdAt", | 104 | created_at AS "createdAt", |
| 93 | updated_at AS "updatedAt", | 105 | updated_at AS "updatedAt", |
| 94 | archived_at AS "archivedAt", | 106 | archived_at AS "archivedAt", |
| 107 | latest_user_message_at AS "latestUserMessageAt", | ||
| 108 | pending_approval_count AS "pendingApprovalCount", | ||
| 109 | pending_user_input_count AS "pendingUserInputCount", | ||
| 110 | has_actionable_proposed_plan AS "hasActionableProposedPlan", | ||
| 95 | deleted_at AS "deletedAt" | 111 | deleted_at AS "deletedAt" |
| 96 | FROM projection_threads | 112 | FROM projection_threads |
| 97 | WHERE thread_id = ${threadId} | 113 | WHERE thread_id = ${threadId} |
| ... | @@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 116 | created_at AS "createdAt", | 132 | created_at AS "createdAt", |
| 117 | updated_at AS "updatedAt", | 133 | updated_at AS "updatedAt", |
| 118 | archived_at AS "archivedAt", | 134 | archived_at AS "archivedAt", |
| 135 | latest_user_message_at AS "latestUserMessageAt", | ||
| 136 | pending_approval_count AS "pendingApprovalCount", | ||
| 137 | pending_user_input_count AS "pendingUserInputCount", | ||
| 138 | has_actionable_proposed_plan AS "hasActionableProposedPlan", | ||
| 119 | deleted_at AS "deletedAt" | 139 | deleted_at AS "deletedAt" |
| 120 | FROM projection_threads | 140 | FROM projection_threads |
| 121 | WHERE project_id = ${projectId} | 141 | WHERE project_id = ${projectId} |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; | ... | @@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; |
| 35 | import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; | 35 | import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; |
| 36 | import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; | 36 | import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; |
| 37 | import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; | 37 | import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; |
| 38 | import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; | ||
| 38 | 39 | ||
| 39 | /** | 40 | /** |
| 40 | * Migration loader with all migrations defined inline. | 41 | * Migration loader with all migrations defined inline. |
| ... | @@ -69,6 +70,7 @@ export const migrationEntries = [ | ... | @@ -69,6 +70,7 @@ export const migrationEntries = [ |
| 69 | [20, "AuthAccessManagement", Migration0020], | 70 | [20, "AuthAccessManagement", Migration0020], |
| 70 | [21, "AuthSessionClientMetadata", Migration0021], | 71 | [21, "AuthSessionClientMetadata", Migration0021], |
| 71 | [22, "AuthSessionLastConnectedAt", Migration0022], | 72 | [22, "AuthSessionLastConnectedAt", Migration0022], |
| 73 | [23, "ProjectionThreadShellSummary", Migration0023], | ||
| 72 | ] as const; | 74 | ] as const; |
| 73 | 75 | ||
| 74 | export const makeMigrationLoader = (throughId?: number) => | 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,6 +9,7 @@ |
| 9 | import { | 9 | import { |
| 10 | IsoDateTime, | 10 | IsoDateTime, |
| 11 | ModelSelection, | 11 | ModelSelection, |
| 12 | NonNegativeInt, | ||
| 12 | ProjectId, | 13 | ProjectId, |
| 13 | ProviderInteractionMode, | 14 | ProviderInteractionMode, |
| 14 | RuntimeMode, | 15 | RuntimeMode, |
| ... | @@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({ | ... | @@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({ |
| 33 | createdAt: IsoDateTime, | 34 | createdAt: IsoDateTime, |
| 34 | updatedAt: IsoDateTime, | 35 | updatedAt: IsoDateTime, |
| 35 | archivedAt: Schema.NullOr(IsoDateTime), | 36 | archivedAt: Schema.NullOr(IsoDateTime), |
| 37 | latestUserMessageAt: Schema.NullOr(IsoDateTime), | ||
| 38 | pendingApprovalCount: NonNegativeInt, | ||
| 39 | pendingUserInputCount: NonNegativeInt, | ||
| 40 | hasActionableProposedPlan: NonNegativeInt, | ||
| 36 | deletedAt: Schema.NullOr(IsoDateTime), | 41 | deletedAt: Schema.NullOr(IsoDateTime), |
| 37 | }); | 42 | }); |
| 38 | export type ProjectionThread = typeof ProjectionThread.Type; | 43 | export type ProjectionThread = typeof ProjectionThread.Type; |
apps/server/src/server.test.ts+15-234| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | FileSystem, | 31 | FileSystem, |
| 32 | Layer, | 32 | Layer, |
| 33 | ManagedRuntime, | 33 | ManagedRuntime, |
| 34 | Option, | ||
| 34 | Path, | 35 | Path, |
| 35 | Stream, | 36 | Stream, |
| 36 | } from "effect"; | 37 | } from "effect"; |
| ... | @@ -68,7 +69,6 @@ import { | ... | @@ -68,7 +69,6 @@ import { |
| 68 | ProjectionSnapshotQuery, | 69 | ProjectionSnapshotQuery, |
| 69 | type ProjectionSnapshotQueryShape, | 70 | type ProjectionSnapshotQueryShape, |
| 70 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 71 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 71 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | ||
| 72 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | 72 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; |
| 73 | import { | 73 | import { |
| 74 | ProviderRegistry, | 74 | ProviderRegistry, |
| ... | @@ -410,6 +410,20 @@ const buildAppUnderTest = (options?: { | ... | @@ -410,6 +410,20 @@ const buildAppUnderTest = (options?: { |
| 410 | Layer.provide( | 410 | Layer.provide( |
| 411 | Layer.mock(ProjectionSnapshotQuery)({ | 411 | Layer.mock(ProjectionSnapshotQuery)({ |
| 412 | getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), | 412 | getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), |
| 413 | getShellSnapshot: () => | ||
| 414 | Effect.succeed({ | ||
| 415 | snapshotSequence: 0, | ||
| 416 | projects: [], | ||
| 417 | threads: [], | ||
| 418 | updatedAt: new Date(0).toISOString(), | ||
| 419 | }), | ||
| 420 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 421 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 422 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 423 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), | ||
| 424 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | ||
| 425 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | ||
| 426 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | ||
| 413 | ...options?.layers?.projectionSnapshotQuery, | 427 | ...options?.layers?.projectionSnapshotQuery, |
| 414 | }), | 428 | }), |
| 415 | ), | 429 | ), |
| ... | @@ -2748,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2748,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2748 | }); | 2762 | }); |
| 2749 | 2763 | ||
| 2750 | const wsUrl = yield* getWsServerUrl("/ws"); | 2764 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 2751 | const snapshotResult = yield* Effect.scoped( | ||
| 2752 | withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | ||
| 2753 | ); | ||
| 2754 | assert.equal(snapshotResult.snapshotSequence, 1); | ||
| 2755 | |||
| 2756 | const dispatchResult = yield* Effect.scoped( | 2765 | const dispatchResult = yield* Effect.scoped( |
| 2757 | withWsRpcClient(wsUrl, (client) => | 2766 | withWsRpcClient(wsUrl, (client) => |
| 2758 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | 2767 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ |
| ... | @@ -3306,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3306,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3306 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3315 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 3307 | ); | 3316 | ); |
| 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 | it.effect("routes websocket rpc terminal methods", () => | 3318 | it.effect("routes websocket rpc terminal methods", () => |
| 3538 | Effect.gen(function* () { | 3319 | Effect.gen(function* () { |
| 3539 | const snapshot = { | 3320 | const snapshot = { |
apps/server/src/serverRuntimeStartup.test.ts+4| ... | @@ -70,6 +70,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa | ... | @@ -70,6 +70,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa |
| 70 | yield* launchStartupHeartbeat.pipe( | 70 | yield* launchStartupHeartbeat.pipe( |
| 71 | Effect.provideService(ProjectionSnapshotQuery, { | 71 | Effect.provideService(ProjectionSnapshotQuery, { |
| 72 | getSnapshot: () => Effect.die("unused"), | 72 | getSnapshot: () => Effect.die("unused"), |
| 73 | getShellSnapshot: () => Effect.die("unused"), | ||
| 73 | getCounts: () => | 74 | getCounts: () => |
| 74 | Deferred.await(releaseCounts).pipe( | 75 | Deferred.await(releaseCounts).pipe( |
| 75 | Effect.as({ | 76 | Effect.as({ |
| ... | @@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa | ... | @@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa |
| 78 | }), | 79 | }), |
| 79 | ), | 80 | ), |
| 80 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), | 81 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 82 | getProjectShellById: () => Effect.succeed(Option.none()), | ||
| 81 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), | 83 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 82 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), | 84 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 85 | getThreadShellById: () => Effect.succeed(Option.none()), | ||
| 86 | getThreadDetailById: () => Effect.succeed(Option.none()), | ||
| 83 | }), | 87 | }), |
| 84 | Effect.provideService(AnalyticsService, { | 88 | Effect.provideService(AnalyticsService, { |
| 85 | record: () => Effect.void, | 89 | record: () => Effect.void, |
apps/server/src/ws.ts+146-66| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; | 1 | import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; |
| 2 | import { | 2 | import { |
| 3 | type AuthAccessStreamEvent, | 3 | type AuthAccessStreamEvent, |
| 4 | AuthSessionId, | 4 | AuthSessionId, |
| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | type GitManagerServiceError, | 9 | type GitManagerServiceError, |
| 10 | OrchestrationDispatchCommandError, | 10 | OrchestrationDispatchCommandError, |
| 11 | type OrchestrationEvent, | 11 | type OrchestrationEvent, |
| 12 | type OrchestrationShellStreamEvent, | ||
| 12 | OrchestrationGetFullThreadDiffError, | 13 | OrchestrationGetFullThreadDiffError, |
| 13 | OrchestrationGetSnapshotError, | 14 | OrchestrationGetSnapshotError, |
| 14 | OrchestrationGetTurnDiffError, | 15 | OrchestrationGetTurnDiffError, |
| ... | @@ -62,6 +63,28 @@ import { | ... | @@ -62,6 +63,28 @@ import { |
| 62 | } from "./auth/Services/SessionCredentialService"; | 63 | } from "./auth/Services/SessionCredentialService"; |
| 63 | import { respondToAuthError } from "./auth/http"; | 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 | function toAuthAccessStreamEvent( | 88 | function toAuthAccessStreamEvent( |
| 66 | change: BootstrapCredentialChange | SessionCredentialChange, | 89 | change: BootstrapCredentialChange | SessionCredentialChange, |
| 67 | revision: number, | 90 | revision: number, |
| ... | @@ -222,6 +245,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -222,6 +245,57 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 222 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => | 245 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => |
| 223 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | 246 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); |
| 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 | const dispatchBootstrapTurnStart = ( | 299 | const dispatchBootstrapTurnStart = ( |
| 226 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, | 300 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, |
| 227 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => | 301 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => |
| ... | @@ -467,20 +541,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -467,20 +541,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 467 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); | 541 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); |
| 468 | 542 | ||
| 469 | return WsRpcGroup.of({ | 543 | return WsRpcGroup.of({ |
| 470 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => | ||
| 471 | observeRpcEffect( | ||
| 472 | ORCHESTRATION_WS_METHODS.getSnapshot, | ||
| 473 | projectionSnapshotQuery.getSnapshot().pipe( | ||
| 474 | Effect.mapError( | ||
| 475 | (cause) => | ||
| 476 | new OrchestrationGetSnapshotError({ | ||
| 477 | message: "Failed to load orchestration snapshot", | ||
| 478 | cause, | ||
| 479 | }), | ||
| 480 | ), | ||
| 481 | ), | ||
| 482 | { "rpc.aggregate": "orchestration" }, | ||
| 483 | ), | ||
| 484 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => | 544 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => |
| 485 | observeRpcEffect( | 545 | observeRpcEffect( |
| 486 | ORCHESTRATION_WS_METHODS.dispatchCommand, | 546 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| ... | @@ -561,65 +621,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -561,65 +621,85 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 561 | ), | 621 | ), |
| 562 | { "rpc.aggregate": "orchestration" }, | 622 | { "rpc.aggregate": "orchestration" }, |
| 563 | ), | 623 | ), |
| 564 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | 624 | [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => |
| 565 | observeRpcStreamEffect( | 625 | observeRpcStreamEffect( |
| 566 | WS_METHODS.subscribeOrchestrationDomainEvents, | 626 | ORCHESTRATION_WS_METHODS.subscribeShell, |
| 567 | Effect.gen(function* () { | 627 | Effect.gen(function* () { |
| 568 | const snapshot = yield* orchestrationEngine.getReadModel(); | 628 | const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( |
| 569 | const fromSequenceExclusive = snapshot.snapshotSequence; | 629 | Effect.mapError( |
| 570 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | 630 | (cause) => |
| 571 | orchestrationEngine.readEvents(fromSequenceExclusive), | 631 | new OrchestrationGetSnapshotError({ |
| 572 | ).pipe( | 632 | message: "Failed to load orchestration shell snapshot", |
| 573 | Effect.map((events) => Array.from(events)), | 633 | cause, |
| 574 | Effect.flatMap(enrichOrchestrationEvents), | 634 | }), |
| 575 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | 635 | ), |
| 576 | ); | 636 | ); |
| 577 | const replayStream = Stream.fromIterable(replayEvents); | 637 | |
| 578 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( | 638 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( |
| 579 | Stream.mapEffect(enrichProjectEvent), | 639 | Stream.mapEffect(toShellStreamEvent), |
| 640 | Stream.flatMap((event) => | ||
| 641 | Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, | ||
| 642 | ), | ||
| 580 | ); | 643 | ); |
| 581 | const source = Stream.merge(replayStream, liveStream); | ||
| 582 | type SequenceState = { | ||
| 583 | readonly nextSequence: number; | ||
| 584 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | ||
| 585 | }; | ||
| 586 | const state = yield* Ref.make<SequenceState>({ | ||
| 587 | nextSequence: fromSequenceExclusive + 1, | ||
| 588 | pendingBySequence: new Map<number, OrchestrationEvent>(), | ||
| 589 | }); | ||
| 590 | 644 | ||
| 591 | return source.pipe( | 645 | return Stream.concat( |
| 592 | Stream.mapEffect((event) => | 646 | Stream.make({ |
| 593 | Ref.modify( | 647 | kind: "snapshot" as const, |
| 594 | state, | 648 | snapshot, |
| 595 | ({ | 649 | }), |
| 596 | nextSequence, | 650 | liveStream, |
| 597 | pendingBySequence, | 651 | ); |
| 598 | }): [Array<OrchestrationEvent>, SequenceState] => { | 652 | }), |
| 599 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | 653 | { "rpc.aggregate": "orchestration" }, |
| 600 | return [[], { nextSequence, pendingBySequence }]; | 654 | ), |
| 601 | } | 655 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => |
| 602 | 656 | observeRpcStreamEffect( | |
| 603 | const updatedPending = new Map(pendingBySequence); | 657 | ORCHESTRATION_WS_METHODS.subscribeThread, |
| 604 | updatedPending.set(event.sequence, event); | 658 | Effect.gen(function* () { |
| 605 | 659 | const [threadDetail, snapshotSequence] = yield* Effect.all([ | |
| 606 | const emit: Array<OrchestrationEvent> = []; | 660 | projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( |
| 607 | let expected = nextSequence; | 661 | Effect.mapError( |
| 608 | for (;;) { | 662 | (cause) => |
| 609 | const expectedEvent = updatedPending.get(expected); | 663 | new OrchestrationGetSnapshotError({ |
| 610 | if (!expectedEvent) { | 664 | message: `Failed to load thread ${input.threadId}`, |
| 611 | break; | 665 | cause, |
| 612 | } | 666 | }), |
| 613 | emit.push(expectedEvent); | ||
| 614 | updatedPending.delete(expected); | ||
| 615 | expected += 1; | ||
| 616 | } | ||
| 617 | |||
| 618 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | ||
| 619 | }, | ||
| 620 | ), | 667 | ), |
| 621 | ), | 668 | ), |
| 622 | Stream.flatMap((events) => Stream.fromIterable(events)), | 669 | orchestrationEngine |
| 670 | .getReadModel() | ||
| 671 | .pipe(Effect.map((readModel) => readModel.snapshotSequence)), | ||
| 672 | ]); | ||
| 673 | |||
| 674 | if (Option.isNone(threadDetail)) { | ||
| 675 | return yield* new OrchestrationGetSnapshotError({ | ||
| 676 | message: `Thread ${input.threadId} was not found`, | ||
| 677 | cause: input.threadId, | ||
| 678 | }); | ||
| 679 | } | ||
| 680 | |||
| 681 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( | ||
| 682 | Stream.filter( | ||
| 683 | (event) => | ||
| 684 | event.aggregateKind === "thread" && | ||
| 685 | event.aggregateId === input.threadId && | ||
| 686 | isThreadDetailEvent(event), | ||
| 687 | ), | ||
| 688 | Stream.map((event) => ({ | ||
| 689 | kind: "event" as const, | ||
| 690 | event, | ||
| 691 | })), | ||
| 692 | ); | ||
| 693 | |||
| 694 | return Stream.concat( | ||
| 695 | Stream.make({ | ||
| 696 | kind: "snapshot" as const, | ||
| 697 | snapshot: { | ||
| 698 | snapshotSequence, | ||
| 699 | thread: threadDetail.value, | ||
| 700 | }, | ||
| 701 | }), | ||
| 702 | liveStream, | ||
| 623 | ); | 703 | ); |
| 624 | }), | 704 | }), |
| 625 | { "rpc.aggregate": "orchestration" }, | 705 | { "rpc.aggregate": "orchestration" }, |
apps/web/src/authBootstrap.test.ts+3-9| ... | @@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => { |
| 424 | expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); | 424 | expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); |
| 425 | }); | 425 | }); |
| 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 | const fetchMock = vi | 428 | const fetchMock = vi |
| 429 | .fn<typeof fetch>() | 429 | .fn<typeof fetch>() |
| 430 | .mockResolvedValueOnce( | 430 | .mockResolvedValueOnce( |
| ... | @@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => { | ... | @@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => { |
| 459 | status: "authenticated", | 459 | status: "authenticated", |
| 460 | }); | 460 | }); |
| 461 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | 461 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ |
| 462 | status: "requires-auth", | 462 | status: "authenticated", |
| 463 | auth: { | ||
| 464 | policy: "loopback-browser", | ||
| 465 | bootstrapMethods: ["one-time-token"], | ||
| 466 | sessionMethods: ["browser-session-cookie"], | ||
| 467 | sessionCookieName: "t3_session", | ||
| 468 | }, | ||
| 469 | }); | 463 | }); |
| 470 | expect(fetchMock).toHaveBeenCalledTimes(2); | 464 | expect(fetchMock).toHaveBeenCalledTimes(1); |
| 471 | }); | 465 | }); |
| 472 | 466 | ||
| 473 | it("creates a pairing credential from the authenticated auth endpoint", async () => { | 467 | it("creates a pairing credential from the authenticated auth endpoint", async () => { |
apps/web/src/components/ChatView.browser.tsx+109-65| ... | @@ -6,7 +6,6 @@ import { | ... | @@ -6,7 +6,6 @@ import { |
| 6 | ORCHESTRATION_WS_METHODS, | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | EnvironmentId, | 7 | EnvironmentId, |
| 8 | type MessageId, | 8 | type MessageId, |
| 9 | type OrchestrationEvent, | ||
| 10 | type OrchestrationReadModel, | 9 | type OrchestrationReadModel, |
| 11 | type ProjectId, | 10 | type ProjectId, |
| 12 | type ServerConfig, | 11 | type ServerConfig, |
| ... | @@ -112,6 +111,7 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { | ... | @@ -112,6 +111,7 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { |
| 112 | textTolerancePx: 56, | 111 | textTolerancePx: 56, |
| 113 | attachmentTolerancePx: 56, | 112 | attachmentTolerancePx: 56, |
| 114 | }; | 113 | }; |
| 114 | |||
| 115 | interface MountedChatView { | 115 | interface MountedChatView { |
| 116 | [Symbol.asyncDispose]: () => Promise<void>; | 116 | [Symbol.asyncDispose]: () => Promise<void>; |
| 117 | cleanup: () => Promise<void>; | 117 | cleanup: () => Promise<void>; |
| ... | @@ -385,74 +385,94 @@ function addThreadToSnapshot( | ... | @@ -385,74 +385,94 @@ function addThreadToSnapshot( |
| 385 | }; | 385 | }; |
| 386 | } | 386 | } |
| 387 | 387 | ||
| 388 | function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | 388 | function toShellThread(thread: OrchestrationReadModel["threads"][number]) { |
| 389 | return { | 389 | return { |
| 390 | sequence, | 390 | id: thread.id, |
| 391 | eventId: EventId.make(`event-thread-created-${sequence}`), | 391 | projectId: thread.projectId, |
| 392 | aggregateKind: "thread", | 392 | title: thread.title, |
| 393 | aggregateId: threadId, | 393 | modelSelection: thread.modelSelection, |
| 394 | occurredAt: NOW_ISO, | 394 | runtimeMode: thread.runtimeMode, |
| 395 | commandId: null, | 395 | interactionMode: thread.interactionMode, |
| 396 | causationEventId: null, | 396 | branch: thread.branch, |
| 397 | correlationId: null, | 397 | worktreePath: thread.worktreePath, |
| 398 | metadata: {}, | 398 | latestTurn: thread.latestTurn, |
| 399 | type: "thread.created", | 399 | createdAt: thread.createdAt, |
| 400 | payload: { | 400 | updatedAt: thread.updatedAt, |
| 401 | threadId, | 401 | archivedAt: thread.archivedAt, |
| 402 | projectId: PROJECT_ID, | 402 | session: thread.session, |
| 403 | title: "New thread", | 403 | latestUserMessageAt: |
| 404 | modelSelection: { | 404 | thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, |
| 405 | provider: "codex", | 405 | hasPendingApprovals: false, |
| 406 | model: "gpt-5", | 406 | hasPendingUserInput: false, |
| 407 | }, | 407 | hasActionableProposedPlan: false, |
| 408 | runtimeMode: "full-access", | ||
| 409 | interactionMode: "default", | ||
| 410 | branch: "main", | ||
| 411 | worktreePath: null, | ||
| 412 | createdAt: NOW_ISO, | ||
| 413 | updatedAt: NOW_ISO, | ||
| 414 | }, | ||
| 415 | }; | 408 | }; |
| 416 | } | 409 | } |
| 417 | 410 | ||
| 418 | function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { | 411 | function toShellSnapshot(snapshot: OrchestrationReadModel) { |
| 419 | return { | 412 | return { |
| 420 | sequence, | 413 | snapshotSequence: snapshot.snapshotSequence, |
| 421 | eventId: EventId.make(`event-thread-session-set-${sequence}`), | 414 | projects: snapshot.projects.map((project) => ({ |
| 422 | aggregateKind: "thread", | 415 | id: project.id, |
| 423 | aggregateId: threadId, | 416 | title: project.title, |
| 424 | occurredAt: NOW_ISO, | 417 | workspaceRoot: project.workspaceRoot, |
| 425 | commandId: null, | 418 | repositoryIdentity: project.repositoryIdentity ?? null, |
| 426 | causationEventId: null, | 419 | defaultModelSelection: project.defaultModelSelection, |
| 427 | correlationId: null, | 420 | scripts: project.scripts, |
| 428 | metadata: {}, | 421 | createdAt: project.createdAt, |
| 429 | type: "thread.session-set", | 422 | updatedAt: project.updatedAt, |
| 430 | payload: { | 423 | })), |
| 431 | threadId, | 424 | threads: snapshot.threads.map(toShellThread), |
| 432 | session: { | 425 | updatedAt: snapshot.updatedAt, |
| 433 | threadId, | ||
| 434 | status: "running", | ||
| 435 | providerName: "codex", | ||
| 436 | runtimeMode: "full-access", | ||
| 437 | activeTurnId: `turn-${threadId}` as TurnId, | ||
| 438 | lastError: null, | ||
| 439 | updatedAt: NOW_ISO, | ||
| 440 | }, | ||
| 441 | }, | ||
| 442 | }; | 426 | }; |
| 443 | } | 427 | } |
| 444 | 428 | ||
| 445 | function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { | 429 | function updateThreadSessionInSnapshot( |
| 446 | rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); | 430 | snapshot: OrchestrationReadModel, |
| 431 | threadId: ThreadId, | ||
| 432 | session: OrchestrationReadModel["threads"][number]["session"], | ||
| 433 | ): OrchestrationReadModel { | ||
| 434 | return { | ||
| 435 | ...snapshot, | ||
| 436 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 437 | threads: snapshot.threads.map((thread) => | ||
| 438 | thread.id === threadId | ||
| 439 | ? { | ||
| 440 | ...thread, | ||
| 441 | session, | ||
| 442 | updatedAt: NOW_ISO, | ||
| 443 | } | ||
| 444 | : thread, | ||
| 445 | ), | ||
| 446 | }; | ||
| 447 | } | ||
| 448 | |||
| 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 | async function waitForWsClient(): Promise<void> { | 471 | async function waitForWsClient(): Promise<void> { |
| 450 | await vi.waitFor( | 472 | await vi.waitFor( |
| 451 | () => { | 473 | () => { |
| 452 | expect( | 474 | expect( |
| 453 | wsRequests.some( | 475 | wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), |
| 454 | (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, | ||
| 455 | ), | ||
| 456 | ).toBe(true); | 476 | ).toBe(true); |
| 457 | expect( | 477 | expect( |
| 458 | wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), | 478 | wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), |
| ... | @@ -512,15 +532,21 @@ async function waitForAppBootstrap(): Promise<void> { | ... | @@ -512,15 +532,21 @@ async function waitForAppBootstrap(): Promise<void> { |
| 512 | async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | 532 | async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| 513 | await waitForWsClient(); | 533 | await waitForWsClient(); |
| 514 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); | 534 | fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); |
| 515 | sendOrchestrationDomainEvent( | 535 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); |
| 516 | createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), | 536 | sendShellThreadUpsert(threadId, { session: null }); |
| 517 | ); | ||
| 518 | } | 537 | } |
| 519 | 538 | ||
| 520 | async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | 539 | async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| 521 | sendOrchestrationDomainEvent( | 540 | fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { |
| 522 | createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), | 541 | threadId, |
| 523 | ); | 542 | status: "running", |
| 543 | providerName: "codex", | ||
| 544 | runtimeMode: "full-access", | ||
| 545 | activeTurnId: `turn-${threadId}` as TurnId, | ||
| 546 | lastError: null, | ||
| 547 | updatedAt: NOW_ISO, | ||
| 548 | }); | ||
| 549 | sendShellThreadUpsert(threadId); | ||
| 524 | } | 550 | } |
| 525 | 551 | ||
| 526 | async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { | 552 | async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise<void> { |
| ... | @@ -871,9 +897,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -871,9 +897,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 871 | return customResult; | 897 | return customResult; |
| 872 | } | 898 | } |
| 873 | const tag = body._tag; | 899 | const tag = body._tag; |
| 874 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | ||
| 875 | return fixture.snapshot; | ||
| 876 | } | ||
| 877 | if (tag === WS_METHODS.serverGetConfig) { | 900 | if (tag === WS_METHODS.serverGetConfig) { |
| 878 | return fixture.serverConfig; | 901 | return fixture.serverConfig; |
| 879 | } | 902 | } |
| ... | @@ -1469,6 +1492,28 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1469,6 +1492,28 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1469 | }, | 1492 | }, |
| 1470 | ]; | 1493 | ]; |
| 1471 | } | 1494 | } |
| 1495 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { | ||
| 1496 | return [ | ||
| 1497 | { | ||
| 1498 | kind: "snapshot", | ||
| 1499 | snapshot: toShellSnapshot(fixture.snapshot), | ||
| 1500 | }, | ||
| 1501 | ]; | ||
| 1502 | } | ||
| 1503 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { | ||
| 1504 | const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); | ||
| 1505 | return thread | ||
| 1506 | ? [ | ||
| 1507 | { | ||
| 1508 | kind: "snapshot", | ||
| 1509 | snapshot: { | ||
| 1510 | snapshotSequence: fixture.snapshot.snapshotSequence, | ||
| 1511 | thread, | ||
| 1512 | }, | ||
| 1513 | }, | ||
| 1514 | ] | ||
| 1515 | : []; | ||
| 1516 | } | ||
| 1472 | return []; | 1517 | return []; |
| 1473 | }, | 1518 | }, |
| 1474 | }); | 1519 | }); |
| ... | @@ -1510,7 +1555,6 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1510,7 +1555,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1510 | customWsRpcResolver = null; | 1555 | customWsRpcResolver = null; |
| 1511 | document.body.innerHTML = ""; | 1556 | document.body.innerHTML = ""; |
| 1512 | }); | 1557 | }); |
| 1513 | |||
| 1514 | it("re-expands the bootstrap project using its scoped key", async () => { | 1558 | it("re-expands the bootstrap project using its scoped key", async () => { |
| 1515 | useUiStateStore.setState({ | 1559 | useUiStateStore.setState({ |
| 1516 | projectExpandedById: { | 1560 | projectExpandedById: { |
apps/web/src/components/ChatView.logic.test.ts+136| ... | @@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 530 | ).toBe(true); | 530 | ).toBe(true); |
| 531 | }); | 531 | }); |
| 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 | it("clears local dispatch when the session changes without an observed running phase", () => { | 669 | it("clears local dispatch when the session changes without an observed running phase", () => { |
| 534 | const localDispatch = createLocalDispatchSnapshot({ | 670 | const localDispatch = createLocalDispatchSnapshot({ |
| 535 | id: ThreadId.make("thread-1"), | 671 | id: ThreadId.make("thread-1"), |
apps/web/src/components/ChatView.logic.ts+23-9| ... | @@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 322 | if (!input.localDispatch) { | 322 | if (!input.localDispatch) { |
| 323 | return false; | 323 | return false; |
| 324 | } | 324 | } |
| 325 | if ( | 325 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { |
| 326 | input.phase === "running" || | ||
| 327 | input.hasPendingApproval || | ||
| 328 | input.hasPendingUserInput || | ||
| 329 | Boolean(input.threadError) | ||
| 330 | ) { | ||
| 331 | return true; | 326 | return true; |
| 332 | } | 327 | } |
| 333 | 328 | ||
| 334 | const latestTurn = input.latestTurn ?? null; | 329 | const latestTurn = input.latestTurn ?? null; |
| 335 | const session = input.session ?? null; | 330 | const session = input.session ?? null; |
| 336 | 331 | const latestTurnChanged = | |
| 337 | return ( | ||
| 338 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || | 332 | input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || |
| 339 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || | 333 | input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || |
| 340 | input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || | 334 | input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || |
| 341 | input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) || | 335 | input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); |
| 336 | |||
| 337 | if (input.phase === "running") { | ||
| 338 | if (!latestTurnChanged) { | ||
| 339 | return false; | ||
| 340 | } | ||
| 341 | if (latestTurn?.startedAt === null || latestTurn === null) { | ||
| 342 | return false; | ||
| 343 | } | ||
| 344 | if ( | ||
| 345 | session?.activeTurnId !== undefined && | ||
| 346 | session.activeTurnId !== null && | ||
| 347 | latestTurn?.turnId !== session.activeTurnId | ||
| 348 | ) { | ||
| 349 | return false; | ||
| 350 | } | ||
| 351 | return true; | ||
| 352 | } | ||
| 353 | |||
| 354 | return ( | ||
| 355 | latestTurnChanged || | ||
| 342 | input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || | 356 | input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || |
| 343 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) | 357 | input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) |
| 344 | ); | 358 | ); |
apps/web/src/components/ChatView.tsx+8| ... | @@ -169,6 +169,7 @@ import { | ... | @@ -169,6 +169,7 @@ import { |
| 169 | useServerKeybindings, | 169 | useServerKeybindings, |
| 170 | } from "~/rpc/serverState"; | 170 | } from "~/rpc/serverState"; |
| 171 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 171 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 172 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | ||
| 172 | 173 | ||
| 173 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 174 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 174 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 175 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| ... | @@ -825,6 +826,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -825,6 +826,13 @@ export default function ChatView(props: ChatViewProps) { |
| 825 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | 826 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), |
| 826 | ); | 827 | ); |
| 827 | 828 | ||
| 829 | useEffect(() => { | ||
| 830 | if (routeKind !== "server") { | ||
| 831 | return; | ||
| 832 | } | ||
| 833 | return retainThreadDetailSubscription(environmentId, threadId); | ||
| 834 | }, [environmentId, routeKind, threadId]); | ||
| 835 | |||
| 828 | // Compute the list of environments this logical project spans, used to | 836 | // Compute the list of environments this logical project spans, used to |
| 829 | // drive the environment picker in BranchToolbar. | 837 | // drive the environment picker in BranchToolbar. |
| 830 | const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); | 838 | const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); |
apps/web/src/components/KeybindingsToast.browser.tsx+59-3| ... | @@ -169,6 +169,43 @@ function createMinimalSnapshot(): OrchestrationReadModel { | ... | @@ -169,6 +169,43 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 169 | }; | 169 | }; |
| 170 | } | 170 | } |
| 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 | function buildFixture(): TestFixture { | 209 | function buildFixture(): TestFixture { |
| 173 | return { | 210 | return { |
| 174 | snapshot: createMinimalSnapshot(), | 211 | snapshot: createMinimalSnapshot(), |
| ... | @@ -190,9 +227,6 @@ function buildFixture(): TestFixture { | ... | @@ -190,9 +227,6 @@ function buildFixture(): TestFixture { |
| 190 | } | 227 | } |
| 191 | 228 | ||
| 192 | function resolveWsRpc(tag: string): unknown { | 229 | function resolveWsRpc(tag: string): unknown { |
| 193 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | ||
| 194 | return fixture.snapshot; | ||
| 195 | } | ||
| 196 | if (tag === WS_METHODS.serverGetConfig) { | 230 | if (tag === WS_METHODS.serverGetConfig) { |
| 197 | return fixture.serverConfig; | 231 | return fixture.serverConfig; |
| 198 | } | 232 | } |
| ... | @@ -429,6 +463,28 @@ describe("Keybindings update toast", () => { | ... | @@ -429,6 +463,28 @@ describe("Keybindings update toast", () => { |
| 429 | }, | 463 | }, |
| 430 | ]; | 464 | ]; |
| 431 | } | 465 | } |
| 466 | if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { | ||
| 467 | return [ | ||
| 468 | { | ||
| 469 | kind: "snapshot", | ||
| 470 | snapshot: toShellSnapshot(fixture.snapshot), | ||
| 471 | }, | ||
| 472 | ]; | ||
| 473 | } | ||
| 474 | if ( | ||
| 475 | request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && | ||
| 476 | request.threadId === THREAD_ID | ||
| 477 | ) { | ||
| 478 | return [ | ||
| 479 | { | ||
| 480 | kind: "snapshot", | ||
| 481 | snapshot: { | ||
| 482 | snapshotSequence: fixture.snapshot.snapshotSequence, | ||
| 483 | thread: fixture.snapshot.threads[0], | ||
| 484 | }, | ||
| 485 | }, | ||
| 486 | ]; | ||
| 487 | } | ||
| 432 | return []; | 488 | return []; |
| 433 | }, | 489 | }, |
| 434 | }); | 490 | }); |
apps/web/src/environmentApi.ts+4-7| ... | @@ -32,16 +32,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ... | @@ -32,16 +32,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 32 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | 32 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, |
| 33 | }, | 33 | }, |
| 34 | orchestration: { | 34 | orchestration: { |
| 35 | getSnapshot: rpcClient.orchestration.getSnapshot, | ||
| 36 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | 35 | dispatchCommand: rpcClient.orchestration.dispatchCommand, |
| 37 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | 36 | getTurnDiff: rpcClient.orchestration.getTurnDiff, |
| 38 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, | 37 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, |
| 39 | replayEvents: (fromSequenceExclusive) => | 38 | subscribeShell: (callback, options) => |
| 40 | rpcClient.orchestration | 39 | rpcClient.orchestration.subscribeShell(callback, options), |
| 41 | .replayEvents({ fromSequenceExclusive }) | 40 | subscribeThread: (input, callback, options) => |
| 42 | .then((events) => [...events]), | 41 | rpcClient.orchestration.subscribeThread(input, callback, options), |
| 43 | onDomainEvent: (callback, options) => | ||
| 44 | rpcClient.orchestration.onDomainEvent(callback, options), | ||
| 45 | }, | 42 | }, |
| 46 | }; | 43 | }; |
| 47 | } | 44 | } |
apps/web/src/environments/primary/auth.ts+19-5| ... | @@ -57,6 +57,7 @@ type ServerAuthGateState = | ... | @@ -57,6 +57,7 @@ type ServerAuthGateState = |
| 57 | }; | 57 | }; |
| 58 | 58 | ||
| 59 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; | 59 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; |
| 60 | let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; | ||
| 60 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; | 61 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; |
| 61 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; | 62 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; |
| 62 | 63 | ||
| ... | @@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo | ... | @@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise<vo |
| 224 | throw new Error("Enter a pairing token to continue."); | 225 | throw new Error("Enter a pairing token to continue."); |
| 225 | } | 226 | } |
| 226 | 227 | ||
| 228 | resolvedAuthenticatedGateState = null; | ||
| 227 | await exchangeBootstrapCredential(trimmedCredential); | 229 | await exchangeBootstrapCredential(trimmedCredential); |
| 228 | bootstrapPromise = null; | 230 | bootstrapPromise = null; |
| 229 | stripPairingTokenFromUrl(); | 231 | stripPairingTokenFromUrl(); |
| ... | @@ -341,19 +343,31 @@ export async function revokeOtherServerClientSessions(): Promise<number> { | ... | @@ -341,19 +343,31 @@ export async function revokeOtherServerClientSessions(): Promise<number> { |
| 341 | } | 343 | } |
| 342 | 344 | ||
| 343 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { | 345 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { |
| 346 | if (resolvedAuthenticatedGateState?.status === "authenticated") { | ||
| 347 | return resolvedAuthenticatedGateState; | ||
| 348 | } | ||
| 349 | |||
| 344 | if (bootstrapPromise) { | 350 | if (bootstrapPromise) { |
| 345 | return bootstrapPromise; | 351 | return bootstrapPromise; |
| 346 | } | 352 | } |
| 347 | 353 | ||
| 348 | const nextPromise = bootstrapServerAuth(); | 354 | const nextPromise = bootstrapServerAuth(); |
| 349 | bootstrapPromise = nextPromise; | 355 | bootstrapPromise = nextPromise; |
| 350 | return nextPromise.finally(() => { | 356 | return nextPromise |
| 351 | if (bootstrapPromise === nextPromise) { | 357 | .then((result) => { |
| 352 | bootstrapPromise = null; | 358 | if (result.status === "authenticated") { |
| 353 | } | 359 | resolvedAuthenticatedGateState = result; |
| 354 | }); | 360 | } |
| 361 | return result; | ||
| 362 | }) | ||
| 363 | .finally(() => { | ||
| 364 | if (bootstrapPromise === nextPromise) { | ||
| 365 | bootstrapPromise = null; | ||
| 366 | } | ||
| 367 | }); | ||
| 355 | } | 368 | } |
| 356 | 369 | ||
| 357 | export function __resetServerAuthBootstrapForTests() { | 370 | export function __resetServerAuthBootstrapForTests() { |
| 358 | bootstrapPromise = null; | 371 | bootstrapPromise = null; |
| 372 | resolvedAuthenticatedGateState = null; | ||
| 359 | } | 373 | } |
apps/web/src/environments/runtime/connection.test.ts+67-165| ... | @@ -4,29 +4,18 @@ import { describe, expect, it, vi } from "vitest"; | ... | @@ -4,29 +4,18 @@ import { describe, expect, it, vi } from "vitest"; |
| 4 | import { createEnvironmentConnection } from "./connection"; | 4 | import { createEnvironmentConnection } from "./connection"; |
| 5 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | 5 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 6 | 6 | ||
| 7 | function createTestClient(options?: { | 7 | function createTestClient() { |
| 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; | ||
| 9 | readonly replayEvents?: () => Promise<ReadonlyArray<any>>; | ||
| 10 | }) { | ||
| 11 | const lifecycleListeners = new Set<(event: any) => void>(); | 8 | const lifecycleListeners = new Set<(event: any) => void>(); |
| 12 | const configListeners = new Set<(event: any) => void>(); | 9 | const configListeners = new Set<(event: any) => void>(); |
| 13 | const terminalListeners = new Set<(event: any) => void>(); | 10 | const terminalListeners = new Set<(event: any) => void>(); |
| 14 | let domainResubscribe: (() => void) | undefined; | 11 | const shellListeners = new Set<(event: any) => void>(); |
| 15 | 12 | let shellResubscribe: (() => void) | undefined; | |
| 16 | const getSnapshot = vi.fn( | ||
| 17 | options?.getSnapshot ?? | ||
| 18 | (async () => | ||
| 19 | ({ | ||
| 20 | snapshotSequence: 1, | ||
| 21 | projects: [], | ||
| 22 | threads: [], | ||
| 23 | }) as any), | ||
| 24 | ); | ||
| 25 | const replayEvents = vi.fn(options?.replayEvents ?? (async () => [])); | ||
| 26 | 13 | ||
| 27 | const client = { | 14 | const client = { |
| 28 | dispose: vi.fn(async () => undefined), | 15 | dispose: vi.fn(async () => undefined), |
| 29 | reconnect: vi.fn(async () => undefined), | 16 | reconnect: vi.fn(async () => { |
| 17 | shellResubscribe?.(); | ||
| 18 | }), | ||
| 30 | server: { | 19 | server: { |
| 31 | getConfig: vi.fn(async () => ({ | 20 | getConfig: vi.fn(async () => ({ |
| 32 | environment: { | 21 | environment: { |
| ... | @@ -48,19 +37,33 @@ function createTestClient(options?: { | ... | @@ -48,19 +37,33 @@ function createTestClient(options?: { |
| 48 | updateSettings: vi.fn(async () => undefined), | 37 | updateSettings: vi.fn(async () => undefined), |
| 49 | }, | 38 | }, |
| 50 | orchestration: { | 39 | orchestration: { |
| 51 | getSnapshot, | ||
| 52 | dispatchCommand: vi.fn(async () => undefined), | 40 | dispatchCommand: vi.fn(async () => undefined), |
| 53 | getTurnDiff: vi.fn(async () => undefined), | 41 | getTurnDiff: vi.fn(async () => undefined), |
| 54 | getFullThreadDiff: vi.fn(async () => undefined), | 42 | getFullThreadDiff: vi.fn(async () => undefined), |
| 55 | replayEvents, | 43 | subscribeShell: vi.fn( |
| 56 | onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => { | 44 | (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { |
| 57 | domainResubscribe = options?.onResubscribe; | 45 | shellListeners.add(listener); |
| 58 | return () => { | 46 | shellResubscribe = options?.onResubscribe; |
| 59 | if (domainResubscribe === options?.onResubscribe) { | 47 | queueMicrotask(() => { |
| 60 | domainResubscribe = undefined; | 48 | listener({ |
| 61 | } | 49 | kind: "snapshot", |
| 62 | }; | 50 | snapshot: { |
| 63 | }), | 51 | snapshotSequence: 1, |
| 52 | projects: [], | ||
| 53 | threads: [], | ||
| 54 | updatedAt: "2026-04-12T00:00:00.000Z", | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | return () => { | ||
| 59 | shellListeners.delete(listener); | ||
| 60 | if (shellResubscribe === options?.onResubscribe) { | ||
| 61 | shellResubscribe = undefined; | ||
| 62 | } | ||
| 63 | }; | ||
| 64 | }, | ||
| 65 | ), | ||
| 66 | subscribeThread: vi.fn(() => () => undefined), | ||
| 64 | }, | 67 | }, |
| 65 | terminal: { | 68 | terminal: { |
| 66 | open: vi.fn(async () => undefined), | 69 | open: vi.fn(async () => undefined), |
| ... | @@ -99,8 +102,6 @@ function createTestClient(options?: { | ... | @@ -99,8 +102,6 @@ function createTestClient(options?: { |
| 99 | 102 | ||
| 100 | return { | 103 | return { |
| 101 | client, | 104 | client, |
| 102 | getSnapshot, | ||
| 103 | replayEvents, | ||
| 104 | emitWelcome: (environmentId: EnvironmentId) => { | 105 | emitWelcome: (environmentId: EnvironmentId) => { |
| 105 | for (const listener of lifecycleListeners) { | 106 | for (const listener of lifecycleListeners) { |
| 106 | listener({ | 107 | listener({ |
| ... | @@ -125,17 +126,27 @@ function createTestClient(options?: { | ... | @@ -125,17 +126,27 @@ function createTestClient(options?: { |
| 125 | }); | 126 | }); |
| 126 | } | 127 | } |
| 127 | }, | 128 | }, |
| 128 | triggerDomainResubscribe: () => { | 129 | emitShellSnapshot: (snapshotSequence: number) => { |
| 129 | domainResubscribe?.(); | 130 | for (const listener of shellListeners) { |
| 131 | listener({ | ||
| 132 | kind: "snapshot", | ||
| 133 | snapshot: { | ||
| 134 | snapshotSequence, | ||
| 135 | projects: [], | ||
| 136 | threads: [], | ||
| 137 | updatedAt: "2026-04-12T00:00:00.000Z", | ||
| 138 | }, | ||
| 139 | }); | ||
| 140 | } | ||
| 130 | }, | 141 | }, |
| 131 | }; | 142 | }; |
| 132 | } | 143 | } |
| 133 | 144 | ||
| 134 | describe("createEnvironmentConnection", () => { | 145 | describe("createEnvironmentConnection", () => { |
| 135 | it("bootstraps a snapshot immediately for a new connection", async () => { | 146 | it("bootstraps from the shell subscription snapshot", async () => { |
| 136 | const environmentId = EnvironmentId.make("env-1"); | 147 | const environmentId = EnvironmentId.make("env-1"); |
| 137 | const { client, getSnapshot } = createTestClient(); | 148 | const { client } = createTestClient(); |
| 138 | const syncSnapshot = vi.fn(); | 149 | const syncShellSnapshot = vi.fn(); |
| 139 | 150 | ||
| 140 | const connection = createEnvironmentConnection({ | 151 | const connection = createEnvironmentConnection({ |
| 141 | kind: "saved", | 152 | kind: "saved", |
| ... | @@ -150,16 +161,14 @@ describe("createEnvironmentConnection", () => { | ... | @@ -150,16 +161,14 @@ describe("createEnvironmentConnection", () => { |
| 150 | environmentId, | 161 | environmentId, |
| 151 | }, | 162 | }, |
| 152 | client, | 163 | client, |
| 153 | applyEventBatch: vi.fn(), | 164 | applyShellEvent: vi.fn(), |
| 154 | syncSnapshot, | 165 | syncShellSnapshot, |
| 155 | applyTerminalEvent: vi.fn(), | 166 | applyTerminalEvent: vi.fn(), |
| 156 | }); | 167 | }); |
| 157 | 168 | ||
| 158 | await Promise.resolve(); | 169 | await connection.ensureBootstrapped(); |
| 159 | await Promise.resolve(); | ||
| 160 | 170 | ||
| 161 | expect(getSnapshot).toHaveBeenCalledTimes(1); | 171 | expect(syncShellSnapshot).toHaveBeenCalledWith( |
| 162 | expect(syncSnapshot).toHaveBeenCalledWith( | ||
| 163 | expect.objectContaining({ snapshotSequence: 1 }), | 172 | expect.objectContaining({ snapshotSequence: 1 }), |
| 164 | environmentId, | 173 | environmentId, |
| 165 | ); | 174 | ); |
| ... | @@ -184,8 +193,8 @@ describe("createEnvironmentConnection", () => { | ... | @@ -184,8 +193,8 @@ describe("createEnvironmentConnection", () => { |
| 184 | environmentId, | 193 | environmentId, |
| 185 | }, | 194 | }, |
| 186 | client, | 195 | client, |
| 187 | applyEventBatch: vi.fn(), | 196 | applyShellEvent: vi.fn(), |
| 188 | syncSnapshot: vi.fn(), | 197 | syncShellSnapshot: vi.fn(), |
| 189 | applyTerminalEvent: vi.fn(), | 198 | applyTerminalEvent: vi.fn(), |
| 190 | }); | 199 | }); |
| 191 | 200 | ||
| ... | @@ -196,58 +205,10 @@ describe("createEnvironmentConnection", () => { | ... | @@ -196,58 +205,10 @@ describe("createEnvironmentConnection", () => { |
| 196 | await connection.dispose(); | 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 | const environmentId = EnvironmentId.make("env-1"); | 209 | const environmentId = EnvironmentId.make("env-1"); |
| 201 | const snapshotError = new Error("snapshot failed"); | 210 | const { client, emitShellSnapshot } = createTestClient(); |
| 202 | const { client } = createTestClient({ | 211 | const syncShellSnapshot = vi.fn(); |
| 203 | getSnapshot: async () => { | ||
| 204 | throw snapshotError; | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | |||
| 208 | const connection = createEnvironmentConnection({ | ||
| 209 | kind: "saved", | ||
| 210 | knownEnvironment: { | ||
| 211 | id: "env-1", | ||
| 212 | label: "Remote env", | ||
| 213 | source: "manual", | ||
| 214 | target: { | ||
| 215 | httpBaseUrl: "http://example.test", | ||
| 216 | wsBaseUrl: "ws://example.test", | ||
| 217 | }, | ||
| 218 | environmentId, | ||
| 219 | }, | ||
| 220 | client, | ||
| 221 | applyEventBatch: vi.fn(), | ||
| 222 | syncSnapshot: vi.fn(), | ||
| 223 | applyTerminalEvent: vi.fn(), | ||
| 224 | }); | ||
| 225 | |||
| 226 | await expect(connection.ensureBootstrapped()).rejects.toThrow("snapshot failed"); | ||
| 227 | |||
| 228 | await connection.dispose(); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("retries replay recovery after transport disconnects during resubscribe", async () => { | ||
| 232 | const environmentId = EnvironmentId.make("env-1"); | ||
| 233 | let replayAttempts = 0; | ||
| 234 | const applyEventBatch = vi.fn(); | ||
| 235 | const { client, replayEvents, triggerDomainResubscribe } = createTestClient({ | ||
| 236 | replayEvents: async () => { | ||
| 237 | replayAttempts += 1; | ||
| 238 | if (replayAttempts === 1) { | ||
| 239 | throw new Error("SocketCloseError: 1006"); | ||
| 240 | } | ||
| 241 | |||
| 242 | return [ | ||
| 243 | { | ||
| 244 | sequence: 2, | ||
| 245 | type: "thread.created", | ||
| 246 | payload: {}, | ||
| 247 | }, | ||
| 248 | ]; | ||
| 249 | }, | ||
| 250 | }); | ||
| 251 | 212 | ||
| 252 | const connection = createEnvironmentConnection({ | 213 | const connection = createEnvironmentConnection({ |
| 253 | kind: "saved", | 214 | kind: "saved", |
| ... | @@ -262,85 +223,26 @@ describe("createEnvironmentConnection", () => { | ... | @@ -262,85 +223,26 @@ describe("createEnvironmentConnection", () => { |
| 262 | environmentId, | 223 | environmentId, |
| 263 | }, | 224 | }, |
| 264 | client, | 225 | client, |
| 265 | applyEventBatch, | 226 | applyShellEvent: vi.fn(), |
| 266 | syncSnapshot: vi.fn(), | 227 | syncShellSnapshot, |
| 267 | applyTerminalEvent: vi.fn(), | 228 | applyTerminalEvent: vi.fn(), |
| 268 | }); | 229 | }); |
| 269 | 230 | ||
| 270 | await Promise.resolve(); | 231 | await connection.ensureBootstrapped(); |
| 271 | await Promise.resolve(); | ||
| 272 | |||
| 273 | triggerDomainResubscribe(); | ||
| 274 | |||
| 275 | await vi.waitFor(() => { | ||
| 276 | expect(replayEvents).toHaveBeenCalledTimes(2); | ||
| 277 | expect(applyEventBatch).toHaveBeenCalledWith( | ||
| 278 | [ | ||
| 279 | expect.objectContaining({ | ||
| 280 | sequence: 2, | ||
| 281 | }), | ||
| 282 | ], | ||
| 283 | environmentId, | ||
| 284 | ); | ||
| 285 | }); | ||
| 286 | |||
| 287 | await connection.dispose(); | ||
| 288 | }); | ||
| 289 | it("swallows replay recovery failures triggered by resubscribe", async () => { | ||
| 290 | const environmentId = EnvironmentId.make("env-1"); | ||
| 291 | const snapshotError = new Error("snapshot failed"); | ||
| 292 | let snapshotCalls = 0; | ||
| 293 | const { client, triggerDomainResubscribe } = createTestClient({ | ||
| 294 | getSnapshot: async () => { | ||
| 295 | snapshotCalls += 1; | ||
| 296 | if (snapshotCalls === 1) { | ||
| 297 | return { | ||
| 298 | snapshotSequence: 1, | ||
| 299 | projects: [], | ||
| 300 | threads: [], | ||
| 301 | } as any; | ||
| 302 | } | ||
| 303 | |||
| 304 | throw snapshotError; | ||
| 305 | }, | ||
| 306 | replayEvents: async () => { | ||
| 307 | throw new Error("SocketCloseError: 1006"); | ||
| 308 | }, | ||
| 309 | }); | ||
| 310 | |||
| 311 | const connection = createEnvironmentConnection({ | ||
| 312 | kind: "saved", | ||
| 313 | knownEnvironment: { | ||
| 314 | id: "env-1", | ||
| 315 | label: "Remote env", | ||
| 316 | source: "manual", | ||
| 317 | target: { | ||
| 318 | httpBaseUrl: "http://example.test", | ||
| 319 | wsBaseUrl: "ws://example.test", | ||
| 320 | }, | ||
| 321 | environmentId, | ||
| 322 | }, | ||
| 323 | client, | ||
| 324 | applyEventBatch: vi.fn(), | ||
| 325 | syncSnapshot: vi.fn(), | ||
| 326 | applyTerminalEvent: vi.fn(), | ||
| 327 | }); | ||
| 328 | 232 | ||
| 233 | const reconnectPromise = connection.reconnect(); | ||
| 329 | await Promise.resolve(); | 234 | await Promise.resolve(); |
| 330 | await Promise.resolve(); | 235 | expect(syncShellSnapshot).toHaveBeenCalledTimes(1); |
| 331 | |||
| 332 | const onUnhandledRejection = vi.fn(); | ||
| 333 | process.on("unhandledRejection", onUnhandledRejection); | ||
| 334 | 236 | ||
| 335 | try { | 237 | emitShellSnapshot(2); |
| 336 | triggerDomainResubscribe(); | 238 | await reconnectPromise; |
| 337 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 338 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 339 | } finally { | ||
| 340 | process.off("unhandledRejection", onUnhandledRejection); | ||
| 341 | } | ||
| 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 | await connection.dispose(); | 247 | await connection.dispose(); |
| 346 | }); | 248 | }); |
apps/web/src/environments/runtime/connection.ts+49-210| ... | @@ -1,29 +1,15 @@ | ... | @@ -1,29 +1,15 @@ |
| 1 | import type { | 1 | import type { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | OrchestrationEvent, | 3 | OrchestrationShellSnapshot, |
| 4 | OrchestrationReadModel, | 4 | OrchestrationShellStreamEvent, |
| 5 | ServerConfig, | 5 | ServerConfig, |
| 6 | ServerLifecycleWelcomePayload, | 6 | ServerLifecycleWelcomePayload, |
| 7 | TerminalEvent, | 7 | TerminalEvent, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import type { KnownEnvironment } from "@t3tools/client-runtime"; | 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 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | 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 | export interface EnvironmentConnection { | 13 | export interface EnvironmentConnection { |
| 28 | readonly kind: "primary" | "saved"; | 14 | readonly kind: "primary" | "saved"; |
| 29 | readonly environmentId: EnvironmentId; | 15 | readonly environmentId: EnvironmentId; |
| ... | @@ -35,11 +21,14 @@ export interface EnvironmentConnection { | ... | @@ -35,11 +21,14 @@ export interface EnvironmentConnection { |
| 35 | } | 21 | } |
| 36 | 22 | ||
| 37 | interface OrchestrationHandlers { | 23 | interface OrchestrationHandlers { |
| 38 | readonly applyEventBatch: ( | 24 | readonly applyShellEvent: ( |
| 39 | events: ReadonlyArray<OrchestrationEvent>, | 25 | event: OrchestrationShellStreamEvent, |
| 26 | environmentId: EnvironmentId, | ||
| 27 | ) => void; | ||
| 28 | readonly syncShellSnapshot: ( | ||
| 29 | snapshot: OrchestrationShellSnapshot, | ||
| 40 | environmentId: EnvironmentId, | 30 | environmentId: EnvironmentId, |
| 41 | ) => void; | 31 | ) => void; |
| 42 | readonly syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => void; | ||
| 43 | readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; | 32 | readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; |
| 44 | } | 33 | } |
| 45 | 34 | ||
| ... | @@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers { | ... | @@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers { |
| 52 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; | 41 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; |
| 53 | } | 42 | } |
| 54 | 43 | ||
| 55 | function createSnapshotBootstrapController(input: { | 44 | function createBootstrapGate() { |
| 56 | readonly isBootstrapped: () => boolean; | 45 | let resolve: (() => void) | null = null; |
| 57 | readonly runSnapshotRecovery: ( | 46 | let reject: ((error: unknown) => void) | null = null; |
| 58 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | 47 | let promise = new Promise<void>((nextResolve, nextReject) => { |
| 59 | ) => Promise<void>; | 48 | resolve = nextResolve; |
| 60 | }) { | 49 | reject = nextReject; |
| 61 | let inFlight: Promise<void> | null = null; | 50 | }); |
| 62 | 51 | ||
| 63 | return { | 52 | return { |
| 64 | ensureSnapshotRecovery( | 53 | wait: () => promise, |
| 65 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | 54 | resolve: () => { |
| 66 | ): Promise<void> { | 55 | resolve?.(); |
| 67 | if (input.isBootstrapped()) { | 56 | resolve = null; |
| 68 | return Promise.resolve(); | 57 | reject = null; |
| 69 | } | 58 | }, |
| 70 | 59 | reject: (error: unknown) => { | |
| 71 | if (inFlight !== null) { | 60 | reject?.(error); |
| 72 | return inFlight; | 61 | resolve = null; |
| 73 | } | 62 | reject = null; |
| 74 | 63 | }, | |
| 75 | const nextInFlight = input.runSnapshotRecovery(reason).finally(() => { | 64 | reset: () => { |
| 76 | if (inFlight === nextInFlight) { | 65 | promise = new Promise<void>((nextResolve, nextReject) => { |
| 77 | inFlight = null; | 66 | resolve = nextResolve; |
| 78 | } | 67 | reject = nextReject; |
| 79 | }); | 68 | }); |
| 80 | inFlight = nextInFlight; | ||
| 81 | return inFlight; | ||
| 82 | }, | 69 | }, |
| 83 | }; | 70 | }; |
| 84 | } | 71 | } |
| ... | @@ -86,10 +73,6 @@ function createSnapshotBootstrapController(input: { | ... | @@ -86,10 +73,6 @@ function createSnapshotBootstrapController(input: { |
| 86 | export function createEnvironmentConnection( | 73 | export function createEnvironmentConnection( |
| 87 | input: EnvironmentConnectionInput, | 74 | input: EnvironmentConnectionInput, |
| 88 | ): EnvironmentConnection { | 75 | ): EnvironmentConnection { |
| 89 | const recovery = createOrchestrationRecoveryCoordinator(); | ||
| 90 | let replayRetryTracker: ReplayRetryTracker | null = null; | ||
| 91 | const pendingDomainEvents: OrchestrationEvent[] = []; | ||
| 92 | let flushPendingDomainEventsScheduled = false; | ||
| 93 | const environmentId = input.knownEnvironment.environmentId; | 76 | const environmentId = input.knownEnvironment.environmentId; |
| 94 | 77 | ||
| 95 | if (!environmentId) { | 78 | if (!environmentId) { |
| ... | @@ -99,6 +82,7 @@ export function createEnvironmentConnection( | ... | @@ -99,6 +82,7 @@ export function createEnvironmentConnection( |
| 99 | } | 82 | } |
| 100 | 83 | ||
| 101 | let disposed = false; | 84 | let disposed = false; |
| 85 | const bootstrapGate = createBootstrapGate(); | ||
| 102 | 86 | ||
| 103 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { | 87 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { |
| 104 | if (environmentId !== nextEnvironmentId) { | 88 | if (environmentId !== nextEnvironmentId) { |
| ... | @@ -108,148 +92,6 @@ export function createEnvironmentConnection( | ... | @@ -108,148 +92,6 @@ export function createEnvironmentConnection( |
| 108 | } | 92 | } |
| 109 | }; | 93 | }; |
| 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 | const unsubLifecycle = input.client.server.subscribeLifecycle( | 95 | const unsubLifecycle = input.client.server.subscribeLifecycle( |
| 254 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => { | 96 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => { |
| 255 | if (event.type !== "welcome") { | 97 | if (event.type !== "welcome") { |
| ... | @@ -273,26 +115,21 @@ export function createEnvironmentConnection( | ... | @@ -273,26 +115,21 @@ export function createEnvironmentConnection( |
| 273 | }, | 115 | }, |
| 274 | ); | 116 | ); |
| 275 | 117 | ||
| 276 | const unsubDomainEvent = input.client.orchestration.onDomainEvent( | 118 | const unsubShell = input.client.orchestration.subscribeShell( |
| 277 | (event: Parameters<Parameters<WsRpcClient["orchestration"]["onDomainEvent"]>[0]>[0]) => { | 119 | (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => { |
| 278 | const action = recovery.classifyDomainEvent(event.sequence); | 120 | if (item.kind === "snapshot") { |
| 279 | if (action === "apply") { | 121 | input.syncShellSnapshot(item.snapshot, environmentId); |
| 280 | pendingDomainEvents.push(event); | 122 | bootstrapGate.resolve(); |
| 281 | schedulePendingDomainEventFlush(); | ||
| 282 | return; | 123 | return; |
| 283 | } | 124 | } |
| 284 | if (action === "recover") { | 125 | input.applyShellEvent(item, environmentId); |
| 285 | flushPendingDomainEvents(); | ||
| 286 | scheduleReplayRecovery("sequence-gap"); | ||
| 287 | } | ||
| 288 | }, | 126 | }, |
| 289 | { | 127 | { |
| 290 | onResubscribe: () => { | 128 | onResubscribe: () => { |
| 291 | if (disposed) { | 129 | if (disposed) { |
| 292 | return; | 130 | return; |
| 293 | } | 131 | } |
| 294 | flushPendingDomainEvents(); | 132 | bootstrapGate.reset(); |
| 295 | scheduleReplayRecovery("resubscribe"); | ||
| 296 | }, | 133 | }, |
| 297 | }, | 134 | }, |
| 298 | ); | 135 | ); |
| ... | @@ -303,13 +140,9 @@ export function createEnvironmentConnection( | ... | @@ -303,13 +140,9 @@ export function createEnvironmentConnection( |
| 303 | }, | 140 | }, |
| 304 | ); | 141 | ); |
| 305 | 142 | ||
| 306 | void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined); | ||
| 307 | |||
| 308 | const cleanup = () => { | 143 | const cleanup = () => { |
| 309 | disposed = true; | 144 | disposed = true; |
| 310 | flushPendingDomainEventsScheduled = false; | 145 | unsubShell(); |
| 311 | pendingDomainEvents.length = 0; | ||
| 312 | unsubDomainEvent(); | ||
| 313 | unsubTerminalEvent(); | 146 | unsubTerminalEvent(); |
| 314 | unsubLifecycle(); | 147 | unsubLifecycle(); |
| 315 | unsubConfig(); | 148 | unsubConfig(); |
| ... | @@ -320,11 +153,17 @@ export function createEnvironmentConnection( | ... | @@ -320,11 +153,17 @@ export function createEnvironmentConnection( |
| 320 | environmentId, | 153 | environmentId, |
| 321 | knownEnvironment: input.knownEnvironment, | 154 | knownEnvironment: input.knownEnvironment, |
| 322 | client: input.client, | 155 | client: input.client, |
| 323 | ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"), | 156 | ensureBootstrapped: () => bootstrapGate.wait(), |
| 324 | reconnect: async () => { | 157 | reconnect: async () => { |
| 325 | await input.client.reconnect(); | 158 | bootstrapGate.reset(); |
| 326 | await input.refreshMetadata?.(); | 159 | try { |
| 327 | await snapshotBootstrap.ensureSnapshotRecovery("bootstrap"); | 160 | await input.client.reconnect(); |
| 161 | await input.refreshMetadata?.(); | ||
| 162 | await bootstrapGate.wait(); | ||
| 163 | } catch (error) { | ||
| 164 | bootstrapGate.reject(error); | ||
| 165 | throw error; | ||
| 166 | } | ||
| 328 | }, | 167 | }, |
| 329 | dispose: async () => { | 168 | dispose: async () => { |
| 330 | cleanup(); | 169 | cleanup(); |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 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,7 +2,8 @@ import { |
| 2 | type AuthSessionRole, | 2 | type AuthSessionRole, |
| 3 | type EnvironmentId, | 3 | type EnvironmentId, |
| 4 | type OrchestrationEvent, | 4 | type OrchestrationEvent, |
| 5 | type OrchestrationReadModel, | 5 | type OrchestrationShellSnapshot, |
| 6 | type OrchestrationShellStreamEvent, | ||
| 6 | type ServerConfig, | 7 | type ServerConfig, |
| 7 | type TerminalEvent, | 8 | type TerminalEvent, |
| 8 | ThreadId, | 9 | ThreadId, |
| ... | @@ -68,12 +69,206 @@ type EnvironmentServiceState = { | ... | @@ -68,12 +69,206 @@ type EnvironmentServiceState = { |
| 68 | stop: () => void; | 69 | stop: () => void; |
| 69 | }; | 70 | }; |
| 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 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | 82 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 72 | const environmentConnectionListeners = new Set<() => void>(); | 83 | const environmentConnectionListeners = new Set<() => void>(); |
| 84 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | ||
| 73 | 85 | ||
| 74 | let activeService: EnvironmentServiceState | null = null; | 86 | let activeService: EnvironmentServiceState | null = null; |
| 75 | let needsProviderInvalidation = false; | 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 | function emitEnvironmentConnectionRegistryChange() { | 272 | function emitEnvironmentConnectionRegistryChange() { |
| 78 | for (const listener of environmentConnectionListeners) { | 273 | for (const listener of environmentConnectionListeners) { |
| 79 | listener(); | 274 | listener(); |
| ... | @@ -169,17 +364,18 @@ function coalesceOrchestrationUiEvents( | ... | @@ -169,17 +364,18 @@ function coalesceOrchestrationUiEvents( |
| 169 | return coalesced; | 364 | return coalesced; |
| 170 | } | 365 | } |
| 171 | 366 | ||
| 172 | function reconcileSnapshotDerivedState() { | 367 | function syncProjectUiFromStore() { |
| 173 | const storeState = useStore.getState(); | 368 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); |
| 174 | const threads = selectThreadsAcrossEnvironments(storeState); | ||
| 175 | const projects = selectProjectsAcrossEnvironments(storeState); | ||
| 176 | |||
| 177 | useUiStateStore.getState().syncProjects( | 369 | useUiStateStore.getState().syncProjects( |
| 178 | projects.map((project) => ({ | 370 | projects.map((project) => ({ |
| 179 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | 371 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), |
| 180 | cwd: project.cwd, | 372 | cwd: project.cwd, |
| 181 | })), | 373 | })), |
| 182 | ); | 374 | ); |
| 375 | } | ||
| 376 | |||
| 377 | function syncThreadUiFromStore() { | ||
| 378 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | ||
| 183 | useUiStateStore.getState().syncThreads( | 379 | useUiStateStore.getState().syncThreads( |
| 184 | threads.map((thread) => ({ | 380 | threads.map((thread) => ({ |
| 185 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 381 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| ... | @@ -189,7 +385,13 @@ function reconcileSnapshotDerivedState() { | ... | @@ -189,7 +385,13 @@ function reconcileSnapshotDerivedState() { |
| 189 | markPromotedDraftThreadsByRef( | 385 | markPromotedDraftThreadsByRef( |
| 190 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), | 386 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), |
| 191 | ); | 387 | ); |
| 388 | } | ||
| 389 | |||
| 390 | function reconcileSnapshotDerivedState() { | ||
| 391 | syncProjectUiFromStore(); | ||
| 392 | syncThreadUiFromStore(); | ||
| 192 | 393 | ||
| 394 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | ||
| 193 | const activeThreadKeys = collectActiveTerminalThreadIds({ | 395 | const activeThreadKeys = collectActiveTerminalThreadIds({ |
| 194 | snapshotThreads: threads.map((thread) => ({ | 396 | snapshotThreads: threads.map((thread) => ({ |
| 195 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 397 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| ... | @@ -273,11 +475,60 @@ function applyRecoveredEventBatch( | ... | @@ -273,11 +475,60 @@ function applyRecoveredEventBatch( |
| 273 | } | 475 | } |
| 274 | } | 476 | } |
| 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 | function createEnvironmentConnectionHandlers() { | 523 | function createEnvironmentConnectionHandlers() { |
| 277 | return { | 524 | return { |
| 278 | applyEventBatch: applyRecoveredEventBatch, | 525 | applyShellEvent, |
| 279 | syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => { | 526 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { |
| 280 | useStore.getState().syncServerReadModel(snapshot, environmentId); | 527 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); |
| 528 | reconcileThreadDetailSubscriptionsForEnvironment( | ||
| 529 | environmentId, | ||
| 530 | snapshot.threads.map((thread) => thread.id), | ||
| 531 | ); | ||
| 281 | reconcileSnapshotDerivedState(); | 532 | reconcileSnapshotDerivedState(); |
| 282 | }, | 533 | }, |
| 283 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { | 534 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { |
| ... | @@ -386,6 +637,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> | ... | @@ -386,6 +637,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 386 | return false; | 637 | return false; |
| 387 | } | 638 | } |
| 388 | 639 | ||
| 640 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | ||
| 389 | environmentConnections.delete(environmentId); | 641 | environmentConnections.delete(environmentId); |
| 390 | emitEnvironmentConnectionRegistryChange(); | 642 | emitEnvironmentConnectionRegistryChange(); |
| 391 | await connection.dispose(); | 643 | await connection.dispose(); |
| ... | @@ -714,6 +966,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -714,6 +966,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 714 | 966 | ||
| 715 | export async function resetEnvironmentServiceForTests(): Promise<void> { | 967 | export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 716 | stopActiveService(); | 968 | stopActiveService(); |
| 969 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | ||
| 970 | disposeThreadDetailSubscriptionByKey(key); | ||
| 971 | } | ||
| 717 | await Promise.all( | 972 | await Promise.all( |
| 718 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), | 973 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), |
| 719 | ); | 974 | ); |
apps/web/src/lib/gitStatusState.test.ts+2-3| ... | @@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | ... | @@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { |
| 123 | subscribeAuthAccess: vi.fn(() => () => undefined), | 123 | subscribeAuthAccess: vi.fn(() => () => undefined), |
| 124 | }, | 124 | }, |
| 125 | orchestration: { | 125 | orchestration: { |
| 126 | getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any), | ||
| 127 | dispatchCommand: vi.fn(async () => undefined), | 126 | dispatchCommand: vi.fn(async () => undefined), |
| 128 | getTurnDiff: vi.fn(async () => undefined), | 127 | getTurnDiff: vi.fn(async () => undefined), |
| 129 | getFullThreadDiff: vi.fn(async () => undefined), | 128 | getFullThreadDiff: vi.fn(async () => undefined), |
| 130 | replayEvents: vi.fn(async () => []), | 129 | subscribeShell: vi.fn(() => () => undefined), |
| 131 | onDomainEvent: vi.fn(() => () => undefined), | 130 | subscribeThread: vi.fn(() => () => undefined), |
| 132 | }, | 131 | }, |
| 133 | } as unknown as WsRpcClient; | 132 | } as unknown as WsRpcClient; |
| 134 | 133 |
apps/web/src/localApi.test.ts+20-30| ... | @@ -3,10 +3,9 @@ import { | ... | @@ -3,10 +3,9 @@ import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type DesktopBridge, | 4 | type DesktopBridge, |
| 5 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | EventId, | ||
| 7 | type GitStatusResult, | 6 | type GitStatusResult, |
| 8 | ProjectId, | 7 | ProjectId, |
| 9 | type OrchestrationEvent, | 8 | type OrchestrationShellStreamItem, |
| 10 | type ServerConfig, | 9 | type ServerConfig, |
| 11 | type ServerProvider, | 10 | type ServerProvider, |
| 12 | type TerminalEvent, | 11 | type TerminalEvent, |
| ... | @@ -32,7 +31,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -32,7 +31,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 32 | } | 31 | } |
| 33 | 32 | ||
| 34 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | 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 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | 35 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); |
| 37 | 36 | ||
| 38 | const rpcClientMock = { | 37 | const rpcClientMock = { |
| ... | @@ -82,14 +81,13 @@ const rpcClientMock = { | ... | @@ -82,14 +81,13 @@ const rpcClientMock = { |
| 82 | subscribeAuthAccess: vi.fn(), | 81 | subscribeAuthAccess: vi.fn(), |
| 83 | }, | 82 | }, |
| 84 | orchestration: { | 83 | orchestration: { |
| 85 | getSnapshot: vi.fn(), | ||
| 86 | dispatchCommand: vi.fn(), | 84 | dispatchCommand: vi.fn(), |
| 87 | getTurnDiff: vi.fn(), | 85 | getTurnDiff: vi.fn(), |
| 88 | getFullThreadDiff: vi.fn(), | 86 | getFullThreadDiff: vi.fn(), |
| 89 | replayEvents: vi.fn(), | 87 | subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => |
| 90 | onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => | 88 | registerListener(shellStreamListeners, listener), |
| 91 | registerListener(orchestrationEventListeners, listener), | ||
| 92 | ), | 89 | ), |
| 90 | subscribeThread: vi.fn(() => () => undefined), | ||
| 93 | }, | 91 | }, |
| 94 | }; | 92 | }; |
| 95 | 93 | ||
| ... | @@ -269,7 +267,7 @@ beforeEach(() => { | ... | @@ -269,7 +267,7 @@ beforeEach(() => { |
| 269 | vi.clearAllMocks(); | 267 | vi.clearAllMocks(); |
| 270 | showContextMenuFallbackMock.mockReset(); | 268 | showContextMenuFallbackMock.mockReset(); |
| 271 | terminalEventListeners.clear(); | 269 | terminalEventListeners.clear(); |
| 272 | orchestrationEventListeners.clear(); | 270 | shellStreamListeners.clear(); |
| 273 | gitStatusListeners.clear(); | 271 | gitStatusListeners.clear(); |
| 274 | const testWindow = getWindowForTest(); | 272 | const testWindow = getWindowForTest(); |
| 275 | Reflect.deleteProperty(testWindow, "desktopBridge"); | 273 | Reflect.deleteProperty(testWindow, "desktopBridge"); |
| ... | @@ -296,15 +294,15 @@ describe("wsApi", () => { | ... | @@ -296,15 +294,15 @@ describe("wsApi", () => { |
| 296 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | 294 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); |
| 297 | }); | 295 | }); |
| 298 | 296 | ||
| 299 | it("forwards terminal and orchestration stream events", async () => { | 297 | it("forwards terminal and shell stream events", async () => { |
| 300 | const { createEnvironmentApi } = await import("./environmentApi"); | 298 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 301 | 299 | ||
| 302 | const api = createEnvironmentApi(rpcClientMock as never); | 300 | const api = createEnvironmentApi(rpcClientMock as never); |
| 303 | const onTerminalEvent = vi.fn(); | 301 | const onTerminalEvent = vi.fn(); |
| 304 | const onDomainEvent = vi.fn(); | 302 | const onShellEvent = vi.fn(); |
| 305 | 303 | ||
| 306 | api.terminal.onEvent(onTerminalEvent); | 304 | api.terminal.onEvent(onTerminalEvent); |
| 307 | api.orchestration.onDomainEvent(onDomainEvent); | 305 | api.orchestration.subscribeShell(onShellEvent); |
| 308 | 306 | ||
| 309 | const terminalEvent = { | 307 | const terminalEvent = { |
| 310 | threadId: "thread-1", | 308 | threadId: "thread-1", |
| ... | @@ -315,19 +313,11 @@ describe("wsApi", () => { | ... | @@ -315,19 +313,11 @@ describe("wsApi", () => { |
| 315 | } as const; | 313 | } as const; |
| 316 | emitEvent(terminalEventListeners, terminalEvent); | 314 | emitEvent(terminalEventListeners, terminalEvent); |
| 317 | 315 | ||
| 318 | const orchestrationEvent = { | 316 | const shellEvent = { |
| 317 | kind: "project-upserted" as const, | ||
| 319 | sequence: 1, | 318 | sequence: 1, |
| 320 | eventId: EventId.make("event-1"), | 319 | project: { |
| 321 | aggregateKind: "project", | 320 | id: ProjectId.make("project-1"), |
| 322 | aggregateId: ProjectId.make("project-1"), | ||
| 323 | occurredAt: "2026-02-24T00:00:00.000Z", | ||
| 324 | commandId: null, | ||
| 325 | causationEventId: null, | ||
| 326 | correlationId: null, | ||
| 327 | metadata: {}, | ||
| 328 | type: "project.created", | ||
| 329 | payload: { | ||
| 330 | projectId: ProjectId.make("project-1"), | ||
| 331 | title: "Project", | 321 | title: "Project", |
| 332 | workspaceRoot: "/tmp/workspace", | 322 | workspaceRoot: "/tmp/workspace", |
| 333 | defaultModelSelection: { | 323 | defaultModelSelection: { |
| ... | @@ -338,11 +328,11 @@ describe("wsApi", () => { | ... | @@ -338,11 +328,11 @@ describe("wsApi", () => { |
| 338 | createdAt: "2026-02-24T00:00:00.000Z", | 328 | createdAt: "2026-02-24T00:00:00.000Z", |
| 339 | updatedAt: "2026-02-24T00:00:00.000Z", | 329 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 340 | }, | 330 | }, |
| 341 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; | 331 | } satisfies OrchestrationShellStreamItem; |
| 342 | emitEvent(orchestrationEventListeners, orchestrationEvent); | 332 | emitEvent(shellStreamListeners, shellEvent); |
| 343 | 333 | ||
| 344 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | 334 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| 345 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | 335 | expect(onShellEvent).toHaveBeenCalledWith(shellEvent); |
| 346 | }); | 336 | }); |
| 347 | 337 | ||
| 348 | it("forwards git status stream events", async () => { | 338 | it("forwards git status stream events", async () => { |
| ... | @@ -371,16 +361,16 @@ describe("wsApi", () => { | ... | @@ -371,16 +361,16 @@ describe("wsApi", () => { |
| 371 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | 361 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 372 | }); | 362 | }); |
| 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 | const { createEnvironmentApi } = await import("./environmentApi"); | 365 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 376 | 366 | ||
| 377 | const api = createEnvironmentApi(rpcClientMock as never); | 367 | const api = createEnvironmentApi(rpcClientMock as never); |
| 378 | const onDomainEvent = vi.fn(); | 368 | const onShellEvent = vi.fn(); |
| 379 | const onResubscribe = vi.fn(); | 369 | const onResubscribe = vi.fn(); |
| 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 | onResubscribe, | 374 | onResubscribe, |
| 385 | }); | 375 | }); |
| 386 | }); | 376 | }); |
apps/web/src/routes/_chat.tsx+2-10| ... | @@ -2,10 +2,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | ... | @@ -2,10 +2,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | 2 | import { useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; | 4 | import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 5 | import { | ||
| 6 | ensurePrimaryEnvironmentReady, | ||
| 7 | resolveInitialServerAuthGateState, | ||
| 8 | } from "../environments/primary"; | ||
| 9 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 5 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 10 | import { | 6 | import { |
| 11 | startNewLocalThreadFromContext, | 7 | startNewLocalThreadFromContext, |
| ... | @@ -111,12 +107,8 @@ function ChatRouteLayout() { | ... | @@ -111,12 +107,8 @@ function ChatRouteLayout() { |
| 111 | } | 107 | } |
| 112 | 108 | ||
| 113 | export const Route = createFileRoute("/_chat")({ | 109 | export const Route = createFileRoute("/_chat")({ |
| 114 | beforeLoad: async () => { | 110 | beforeLoad: async ({ context }) => { |
| 115 | const [, authGateState] = await Promise.all([ | 111 | if (context.authGateState.status !== "authenticated") { |
| 116 | ensurePrimaryEnvironmentReady(), | ||
| 117 | resolveInitialServerAuthGateState(), | ||
| 118 | ]); | ||
| 119 | if (authGateState.status !== "authenticated") { | ||
| 120 | throw redirect({ to: "/pair", replace: true }); | 112 | throw redirect({ to: "/pair", replace: true }); |
| 121 | } | 113 | } |
| 122 | }, | 114 | }, |
apps/web/src/routes/pair.tsx+2-9| ... | @@ -1,17 +1,10 @@ | ... | @@ -1,17 +1,10 @@ |
| 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; | 3 | import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; |
| 4 | import { | ||
| 5 | ensurePrimaryEnvironmentReady, | ||
| 6 | resolveInitialServerAuthGateState, | ||
| 7 | } from "../environments/primary"; | ||
| 8 | 4 | ||
| 9 | export const Route = createFileRoute("/pair")({ | 5 | export const Route = createFileRoute("/pair")({ |
| 10 | beforeLoad: async () => { | 6 | beforeLoad: async ({ context }) => { |
| 11 | const [, authGateState] = await Promise.all([ | 7 | const { authGateState } = context; |
| 12 | ensurePrimaryEnvironmentReady(), | ||
| 13 | resolveInitialServerAuthGateState(), | ||
| 14 | ]); | ||
| 15 | if (authGateState.status === "authenticated") { | 8 | if (authGateState.status === "authenticated") { |
| 16 | throw redirect({ to: "/", replace: true }); | 9 | throw redirect({ to: "/", replace: true }); |
| 17 | } | 10 | } |
apps/web/src/routes/settings.tsx+2-10| ... | @@ -2,10 +2,6 @@ import { RotateCcwIcon } from "lucide-react"; | ... | @@ -2,10 +2,6 @@ import { RotateCcwIcon } from "lucide-react"; |
| 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { | ||
| 6 | ensurePrimaryEnvironmentReady, | ||
| 7 | resolveInitialServerAuthGateState, | ||
| 8 | } from "../environments/primary"; | ||
| 9 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; | 5 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 10 | import { Button } from "../components/ui/button"; | 6 | import { Button } from "../components/ui/button"; |
| 11 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| ... | @@ -87,12 +83,8 @@ function SettingsRouteLayout() { | ... | @@ -87,12 +83,8 @@ function SettingsRouteLayout() { |
| 87 | } | 83 | } |
| 88 | 84 | ||
| 89 | export const Route = createFileRoute("/settings")({ | 85 | export const Route = createFileRoute("/settings")({ |
| 90 | beforeLoad: async ({ location }) => { | 86 | beforeLoad: async ({ context, location }) => { |
| 91 | const [, authGateState] = await Promise.all([ | 87 | if (context.authGateState.status !== "authenticated") { |
| 92 | ensurePrimaryEnvironmentReady(), | ||
| 93 | resolveInitialServerAuthGateState(), | ||
| 94 | ]); | ||
| 95 | if (authGateState.status !== "authenticated") { | ||
| 96 | throw redirect({ to: "/pair", replace: true }); | 88 | throw redirect({ to: "/pair", replace: true }); |
| 97 | } | 89 | } |
| 98 | 90 |
apps/web/src/rpc/wsRpcClient.ts+19-11| ... | @@ -39,6 +39,15 @@ type RpcStreamMethod<TTag extends RpcTag> = | ... | @@ -39,6 +39,15 @@ type RpcStreamMethod<TTag extends RpcTag> = |
| 39 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | 39 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void |
| 40 | : never; | 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 | interface GitRunStackedActionOptions { | 51 | interface GitRunStackedActionOptions { |
| 43 | readonly onProgress?: (event: GitActionProgressEvent) => void; | 52 | readonly onProgress?: (event: GitActionProgressEvent) => void; |
| 44 | } | 53 | } |
| ... | @@ -101,12 +110,11 @@ export interface WsRpcClient { | ... | @@ -101,12 +110,11 @@ export interface WsRpcClient { |
| 101 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | 110 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; |
| 102 | }; | 111 | }; |
| 103 | readonly orchestration: { | 112 | readonly orchestration: { |
| 104 | readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>; | ||
| 105 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | 113 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; |
| 106 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | 114 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; |
| 107 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | 115 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; |
| 108 | readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>; | 116 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; |
| 109 | readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>; | 117 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; |
| 110 | }; | 118 | }; |
| 111 | } | 119 | } |
| 112 | 120 | ||
| ... | @@ -219,21 +227,21 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -219,21 +227,21 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 219 | ), | 227 | ), |
| 220 | }, | 228 | }, |
| 221 | orchestration: { | 229 | orchestration: { |
| 222 | getSnapshot: () => | ||
| 223 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | ||
| 224 | dispatchCommand: (input) => | 230 | dispatchCommand: (input) => |
| 225 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | 231 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), |
| 226 | getTurnDiff: (input) => | 232 | getTurnDiff: (input) => |
| 227 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | 233 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), |
| 228 | getFullThreadDiff: (input) => | 234 | getFullThreadDiff: (input) => |
| 229 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | 235 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), |
| 230 | replayEvents: (input) => | 236 | subscribeShell: (listener, options) => |
| 231 | transport | 237 | transport.subscribe( |
| 232 | .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) | 238 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), |
| 233 | .then((events) => [...events]), | 239 | listener, |
| 234 | onDomainEvent: (listener, options) => | 240 | options, |
| 241 | ), | ||
| 242 | subscribeThread: (input, listener, options) => | ||
| 235 | transport.subscribe( | 243 | transport.subscribe( |
| 236 | (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), | 244 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), |
| 237 | listener, | 245 | listener, |
| 238 | options, | 246 | options, |
| 239 | ), | 247 | ), |
apps/web/src/session-logic.test.ts+13| ... | @@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1227 | ).toBe("2026-02-27T21:10:00.000Z"); | 1227 | ).toBe("2026-02-27T21:10:00.000Z"); |
| 1228 | }); | 1228 | }); |
| 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 | it("falls back to sendStartedAt once the latest turn is settled", () => { | 1243 | it("falls back to sendStartedAt once the latest turn is settled", () => { |
| 1231 | expect( | 1244 | expect( |
| 1232 | deriveActiveWorkStartedAt( | 1245 | deriveActiveWorkStartedAt( |
apps/web/src/session-logic.ts+8| ... | @@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt( | ... | @@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt( |
| 145 | session: SessionActivityState | null, | 145 | session: SessionActivityState | null, |
| 146 | sendStartedAt: string | null, | 146 | sendStartedAt: string | null, |
| 147 | ): string | null { | 147 | ): string | null { |
| 148 | const runningTurnId = | ||
| 149 | session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; | ||
| 150 | if (runningTurnId !== null) { | ||
| 151 | if (latestTurn?.turnId === runningTurnId) { | ||
| 152 | return latestTurn.startedAt ?? sendStartedAt; | ||
| 153 | } | ||
| 154 | return sendStartedAt; | ||
| 155 | } | ||
| 148 | if (!isLatestTurnSettled(latestTurn, session)) { | 156 | if (!isLatestTurnSettled(latestTurn, session)) { |
| 149 | return latestTurn?.startedAt ?? sendStartedAt; | 157 | return latestTurn?.startedAt ?? sendStartedAt; |
| 150 | } | 158 | } |
apps/web/src/store.test.ts+79| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | EventId, | 6 | EventId, |
| 7 | MessageId, | 7 | MessageId, |
| 8 | type OrchestrationShellSnapshot, | ||
| 8 | ProjectId, | 9 | ProjectId, |
| 9 | ThreadId, | 10 | ThreadId, |
| 10 | TurnId, | 11 | TurnId, |
| ... | @@ -23,6 +24,7 @@ import { | ... | @@ -23,6 +24,7 @@ import { |
| 23 | setThreadBranch, | 24 | setThreadBranch, |
| 24 | selectThreadsAcrossEnvironments, | 25 | selectThreadsAcrossEnvironments, |
| 25 | syncServerReadModel, | 26 | syncServerReadModel, |
| 27 | syncServerShellSnapshot, | ||
| 26 | type AppState, | 28 | type AppState, |
| 27 | type EnvironmentState, | 29 | type EnvironmentState, |
| 28 | } from "./store"; | 30 | } from "./store"; |
| ... | @@ -496,6 +498,83 @@ describe("store read model sync", () => { | ... | @@ -496,6 +498,83 @@ describe("store read model sync", () => { |
| 496 | expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); | 498 | expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); |
| 497 | }); | 499 | }); |
| 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 | it("preserves claude model slugs without an active session", () => { | 578 | it("preserves claude model slugs without an active session", () => { |
| 500 | const initialState = makeState(makeThread()); | 579 | const initialState = makeState(makeThread()); |
| 501 | const readModel = makeReadModel( | 580 | const readModel = makeReadModel( |
apps/web/src/store.ts+335-1| ... | @@ -6,9 +6,12 @@ import type { | ... | @@ -6,9 +6,12 @@ import type { |
| 6 | OrchestrationMessage, | 6 | OrchestrationMessage, |
| 7 | OrchestrationProposedPlan, | 7 | OrchestrationProposedPlan, |
| 8 | OrchestrationReadModel, | 8 | OrchestrationReadModel, |
| 9 | OrchestrationShellSnapshot, | ||
| 10 | OrchestrationShellStreamEvent, | ||
| 9 | OrchestrationSession, | 11 | OrchestrationSession, |
| 10 | OrchestrationSessionStatus, | 12 | OrchestrationSessionStatus, |
| 11 | OrchestrationThread, | 13 | OrchestrationThread, |
| 14 | OrchestrationThreadShell, | ||
| 12 | OrchestrationThreadActivity, | 15 | OrchestrationThreadActivity, |
| 13 | ProjectId, | 16 | ProjectId, |
| 14 | ProviderKind, | 17 | ProviderKind, |
| ... | @@ -175,7 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif | ... | @@ -175,7 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif |
| 175 | } | 178 | } |
| 176 | 179 | ||
| 177 | function mapProject( | 180 | function mapProject( |
| 178 | project: OrchestrationReadModel["projects"][number], | 181 | project: |
| 182 | | OrchestrationReadModel["projects"][number] | ||
| 183 | | OrchestrationShellSnapshot["projects"][number], | ||
| 179 | environmentId: EnvironmentId, | 184 | environmentId: EnvironmentId, |
| 180 | ): Project { | 185 | ): Project { |
| 181 | return { | 186 | return { |
| ... | @@ -219,6 +224,62 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T | ... | @@ -219,6 +224,62 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T |
| 219 | }; | 224 | }; |
| 220 | } | 225 | } |
| 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 | function toThreadShell(thread: Thread): ThreadShell { | 283 | function toThreadShell(thread: Thread): ThreadShell { |
| 223 | return { | 284 | return { |
| 224 | id: thread.id, | 285 | id: thread.id, |
| ... | @@ -562,6 +623,120 @@ function writeThreadState( | ... | @@ -562,6 +623,120 @@ function writeThreadState( |
| 562 | return nextState; | 623 | return nextState; |
| 563 | } | 624 | } |
| 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 | function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { | 740 | function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { |
| 566 | const shell = state.threadShellById[threadId]; | 741 | const shell = state.threadShellById[threadId]; |
| 567 | if (!shell) { | 742 | if (!shell) { |
| ... | @@ -960,6 +1135,46 @@ function syncEnvironmentReadModel( | ... | @@ -960,6 +1135,46 @@ function syncEnvironmentReadModel( |
| 960 | }; | 1135 | }; |
| 961 | } | 1136 | } |
| 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 | export function syncServerReadModel( | 1178 | export function syncServerReadModel( |
| 964 | state: AppState, | 1179 | state: AppState, |
| 965 | readModel: OrchestrationReadModel, | 1180 | readModel: OrchestrationReadModel, |
| ... | @@ -976,6 +1191,36 @@ export function syncServerReadModel( | ... | @@ -976,6 +1191,36 @@ export function syncServerReadModel( |
| 976 | ); | 1191 | ); |
| 977 | } | 1192 | } |
| 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 | function applyEnvironmentOrchestrationEvent( | 1224 | function applyEnvironmentOrchestrationEvent( |
| 980 | state: EnvironmentState, | 1225 | state: EnvironmentState, |
| 981 | event: OrchestrationEvent, | 1226 | event: OrchestrationEvent, |
| ... | @@ -1466,6 +1711,67 @@ function applyEnvironmentOrchestrationEvent( | ... | @@ -1466,6 +1711,67 @@ function applyEnvironmentOrchestrationEvent( |
| 1466 | return state; | 1711 | return state; |
| 1467 | } | 1712 | } |
| 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 | export function applyOrchestrationEvents( | 1775 | export function applyOrchestrationEvents( |
| 1470 | state: AppState, | 1776 | state: AppState, |
| 1471 | events: ReadonlyArray<OrchestrationEvent>, | 1777 | events: ReadonlyArray<OrchestrationEvent>, |
| ... | @@ -1649,6 +1955,22 @@ export function applyOrchestrationEvent( | ... | @@ -1649,6 +1955,22 @@ export function applyOrchestrationEvent( |
| 1649 | ); | 1955 | ); |
| 1650 | } | 1956 | } |
| 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 | export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { | 1974 | export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { |
| 1653 | if (state.activeEnvironmentId === environmentId) { | 1975 | if (state.activeEnvironmentId === environmentId) { |
| 1654 | return state; | 1976 | return state; |
| ... | @@ -1686,11 +2008,17 @@ export function setThreadBranch( | ... | @@ -1686,11 +2008,17 @@ export function setThreadBranch( |
| 1686 | interface AppStore extends AppState { | 2008 | interface AppStore extends AppState { |
| 1687 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; | 2009 | setActiveEnvironmentId: (environmentId: EnvironmentId) => void; |
| 1688 | syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; | 2010 | syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; |
| 2011 | syncServerShellSnapshot: ( | ||
| 2012 | snapshot: OrchestrationShellSnapshot, | ||
| 2013 | environmentId: EnvironmentId, | ||
| 2014 | ) => void; | ||
| 2015 | syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; | ||
| 1689 | applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; | 2016 | applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; |
| 1690 | applyOrchestrationEvents: ( | 2017 | applyOrchestrationEvents: ( |
| 1691 | events: ReadonlyArray<OrchestrationEvent>, | 2018 | events: ReadonlyArray<OrchestrationEvent>, |
| 1692 | environmentId: EnvironmentId, | 2019 | environmentId: EnvironmentId, |
| 1693 | ) => void; | 2020 | ) => void; |
| 2021 | applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; | ||
| 1694 | setError: (threadId: ThreadId, error: string | null) => void; | 2022 | setError: (threadId: ThreadId, error: string | null) => void; |
| 1695 | setThreadBranch: ( | 2023 | setThreadBranch: ( |
| 1696 | threadRef: ScopedThreadRef, | 2024 | threadRef: ScopedThreadRef, |
| ... | @@ -1705,10 +2033,16 @@ export const useStore = create<AppStore>((set) => ({ | ... | @@ -1705,10 +2033,16 @@ export const useStore = create<AppStore>((set) => ({ |
| 1705 | set((state) => setActiveEnvironmentId(state, environmentId)), | 2033 | set((state) => setActiveEnvironmentId(state, environmentId)), |
| 1706 | syncServerReadModel: (readModel, environmentId) => | 2034 | syncServerReadModel: (readModel, environmentId) => |
| 1707 | set((state) => syncServerReadModel(state, readModel, environmentId)), | 2035 | set((state) => syncServerReadModel(state, readModel, environmentId)), |
| 2036 | syncServerShellSnapshot: (snapshot, environmentId) => | ||
| 2037 | set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), | ||
| 2038 | syncServerThreadDetail: (thread, environmentId) => | ||
| 2039 | set((state) => syncServerThreadDetail(state, thread, environmentId)), | ||
| 1708 | applyOrchestrationEvent: (event, environmentId) => | 2040 | applyOrchestrationEvent: (event, environmentId) => |
| 1709 | set((state) => applyOrchestrationEvent(state, event, environmentId)), | 2041 | set((state) => applyOrchestrationEvent(state, event, environmentId)), |
| 1710 | applyOrchestrationEvents: (events, environmentId) => | 2042 | applyOrchestrationEvents: (events, environmentId) => |
| 1711 | set((state) => applyOrchestrationEvents(state, events, environmentId)), | 2043 | set((state) => applyOrchestrationEvents(state, events, environmentId)), |
| 2044 | applyShellEvent: (event, environmentId) => | ||
| 2045 | set((state) => applyShellEvent(state, event, environmentId)), | ||
| 1712 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), | 2046 | setError: (threadId, error) => set((state) => setError(state, threadId, error)), |
| 1713 | setThreadBranch: (threadRef, branch, worktreePath) => | 2047 | setThreadBranch: (threadRef, branch, worktreePath) => |
| 1714 | set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), | 2048 | set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), |
apps/web/test/wsRpcHarness.ts+3-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; | 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 | import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 3 | import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 4 | 4 | ||
| 5 | type RpcServerInstance = RpcServer.RpcServer<any>; | 5 | type RpcServerInstance = RpcServer.RpcServer<any>; |
| ... | @@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions { | ... | @@ -23,9 +23,10 @@ interface BrowserWsRpcHarnessOptions { |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | const STREAM_METHODS = new Set<string>([ | 25 | const STREAM_METHODS = new Set<string>([ |
| 26 | ORCHESTRATION_WS_METHODS.subscribeShell, | ||
| 27 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 26 | WS_METHODS.gitRunStackedAction, | 28 | WS_METHODS.gitRunStackedAction, |
| 27 | WS_METHODS.subscribeGitStatus, | 29 | WS_METHODS.subscribeGitStatus, |
| 28 | WS_METHODS.subscribeOrchestrationDomainEvents, | ||
| 29 | WS_METHODS.subscribeTerminalEvents, | 30 | WS_METHODS.subscribeTerminalEvents, |
| 30 | WS_METHODS.subscribeServerConfig, | 31 | WS_METHODS.subscribeServerConfig, |
| 31 | WS_METHODS.subscribeServerLifecycle, | 32 | WS_METHODS.subscribeServerLifecycle, |
packages/contracts/src/ipc.ts+12-6| ... | @@ -46,8 +46,9 @@ import type { | ... | @@ -46,8 +46,9 @@ import type { |
| 46 | OrchestrationGetFullThreadDiffResult, | 46 | OrchestrationGetFullThreadDiffResult, |
| 47 | OrchestrationGetTurnDiffInput, | 47 | OrchestrationGetTurnDiffInput, |
| 48 | OrchestrationGetTurnDiffResult, | 48 | OrchestrationGetTurnDiffResult, |
| 49 | OrchestrationEvent, | 49 | OrchestrationShellStreamItem, |
| 50 | OrchestrationReadModel, | 50 | OrchestrationSubscribeThreadInput, |
| 51 | OrchestrationThreadStreamItem, | ||
| 51 | } from "./orchestration"; | 52 | } from "./orchestration"; |
| 52 | import type { EnvironmentId } from "./baseSchemas"; | 53 | import type { EnvironmentId } from "./baseSchemas"; |
| 53 | import { EditorId } from "./editor"; | 54 | import { EditorId } from "./editor"; |
| ... | @@ -249,15 +250,20 @@ export interface EnvironmentApi { | ... | @@ -249,15 +250,20 @@ export interface EnvironmentApi { |
| 249 | ) => () => void; | 250 | ) => () => void; |
| 250 | }; | 251 | }; |
| 251 | orchestration: { | 252 | orchestration: { |
| 252 | getSnapshot: () => Promise<OrchestrationReadModel>; | ||
| 253 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; | 253 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
| 254 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; | 254 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; |
| 255 | getFullThreadDiff: ( | 255 | getFullThreadDiff: ( |
| 256 | input: OrchestrationGetFullThreadDiffInput, | 256 | input: OrchestrationGetFullThreadDiffInput, |
| 257 | ) => Promise<OrchestrationGetFullThreadDiffResult>; | 257 | ) => Promise<OrchestrationGetFullThreadDiffResult>; |
| 258 | replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>; | 258 | subscribeShell: ( |
| 259 | onDomainEvent: ( | 259 | callback: (event: OrchestrationShellStreamItem) => void, |
| 260 | callback: (event: OrchestrationEvent) => void, | 260 | options?: { |
| 261 | onResubscribe?: () => void; | ||
| 262 | }, | ||
| 263 | ) => () => void; | ||
| 264 | subscribeThread: ( | ||
| 265 | input: OrchestrationSubscribeThreadInput, | ||
| 266 | callback: (event: OrchestrationThreadStreamItem) => void, | ||
| 261 | options?: { | 267 | options?: { |
| 262 | onResubscribe?: () => void; | 268 | onResubscribe?: () => void; |
| 263 | }, | 269 | }, |
packages/contracts/src/orchestration.ts+109-10| ... | @@ -17,11 +17,12 @@ import { | ... | @@ -17,11 +17,12 @@ import { |
| 17 | } from "./baseSchemas"; | 17 | } from "./baseSchemas"; |
| 18 | 18 | ||
| 19 | export const ORCHESTRATION_WS_METHODS = { | 19 | export const ORCHESTRATION_WS_METHODS = { |
| 20 | getSnapshot: "orchestration.getSnapshot", | ||
| 21 | dispatchCommand: "orchestration.dispatchCommand", | 20 | dispatchCommand: "orchestration.dispatchCommand", |
| 22 | getTurnDiff: "orchestration.getTurnDiff", | 21 | getTurnDiff: "orchestration.getTurnDiff", |
| 23 | getFullThreadDiff: "orchestration.getFullThreadDiff", | 22 | getFullThreadDiff: "orchestration.getFullThreadDiff", |
| 24 | replayEvents: "orchestration.replayEvents", | 23 | replayEvents: "orchestration.replayEvents", |
| 24 | subscribeShell: "orchestration.subscribeShell", | ||
| 25 | subscribeThread: "orchestration.subscribeThread", | ||
| 25 | } as const; | 26 | } as const; |
| 26 | 27 | ||
| 27 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); | 28 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); |
| ... | @@ -308,6 +309,93 @@ export const OrchestrationReadModel = Schema.Struct({ | ... | @@ -308,6 +309,93 @@ export const OrchestrationReadModel = Schema.Struct({ |
| 308 | }); | 309 | }); |
| 309 | export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; | 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 | export const ProjectCreateCommand = Schema.Struct({ | 399 | export const ProjectCreateCommand = Schema.Struct({ |
| 312 | type: Schema.Literal("project.create"), | 400 | type: Schema.Literal("project.create"), |
| 313 | commandId: CommandId, | 401 | commandId: CommandId, |
| ... | @@ -955,6 +1043,18 @@ export const OrchestrationEvent = Schema.Union([ | ... | @@ -955,6 +1043,18 @@ export const OrchestrationEvent = Schema.Union([ |
| 955 | ]); | 1043 | ]); |
| 956 | export type OrchestrationEvent = typeof OrchestrationEvent.Type; | 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 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); | 1058 | export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); |
| 959 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; | 1059 | export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; |
| 960 | 1060 | ||
| ... | @@ -1019,11 +1119,6 @@ export const DispatchResult = Schema.Struct({ | ... | @@ -1019,11 +1119,6 @@ export const DispatchResult = Schema.Struct({ |
| 1019 | }); | 1119 | }); |
| 1020 | export type DispatchResult = typeof DispatchResult.Type; | 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 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( | 1122 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( |
| 1028 | Struct.assign({ threadId: ThreadId }), | 1123 | Struct.assign({ threadId: ThreadId }), |
| 1029 | { unsafePreserveChecks: true }, | 1124 | { unsafePreserveChecks: true }, |
| ... | @@ -1051,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); | ... | @@ -1051,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); |
| 1051 | export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; | 1146 | export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; |
| 1052 | 1147 | ||
| 1053 | export const OrchestrationRpcSchemas = { | 1148 | export const OrchestrationRpcSchemas = { |
| 1054 | getSnapshot: { | ||
| 1055 | input: OrchestrationGetSnapshotInput, | ||
| 1056 | output: OrchestrationGetSnapshotResult, | ||
| 1057 | }, | ||
| 1058 | dispatchCommand: { | 1149 | dispatchCommand: { |
| 1059 | input: ClientOrchestrationCommand, | 1150 | input: ClientOrchestrationCommand, |
| 1060 | output: DispatchResult, | 1151 | output: DispatchResult, |
| ... | @@ -1071,6 +1162,14 @@ export const OrchestrationRpcSchemas = { | ... | @@ -1071,6 +1162,14 @@ export const OrchestrationRpcSchemas = { |
| 1071 | input: OrchestrationReplayEventsInput, | 1162 | input: OrchestrationReplayEventsInput, |
| 1072 | output: OrchestrationReplayEventsResult, | 1163 | output: OrchestrationReplayEventsResult, |
| 1073 | }, | 1164 | }, |
| 1165 | subscribeThread: { | ||
| 1166 | input: OrchestrationSubscribeThreadInput, | ||
| 1167 | output: OrchestrationThreadStreamItem, | ||
| 1168 | }, | ||
| 1169 | subscribeShell: { | ||
| 1170 | input: Schema.Struct({}), | ||
| 1171 | output: OrchestrationShellStreamItem, | ||
| 1172 | }, | ||
| 1074 | } as const; | 1173 | } as const; |
| 1075 | 1174 | ||
| 1076 | export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()( | 1175 | export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<OrchestrationGetSnapshotError>()( |
packages/contracts/src/rpc.ts+14-15| ... | @@ -32,13 +32,11 @@ import { | ... | @@ -32,13 +32,11 @@ import { |
| 32 | import { KeybindingsConfigError } from "./keybindings"; | 32 | import { KeybindingsConfigError } from "./keybindings"; |
| 33 | import { | 33 | import { |
| 34 | ClientOrchestrationCommand, | 34 | ClientOrchestrationCommand, |
| 35 | OrchestrationEvent, | ||
| 36 | ORCHESTRATION_WS_METHODS, | 35 | ORCHESTRATION_WS_METHODS, |
| 37 | OrchestrationDispatchCommandError, | 36 | OrchestrationDispatchCommandError, |
| 38 | OrchestrationGetFullThreadDiffError, | 37 | OrchestrationGetFullThreadDiffError, |
| 39 | OrchestrationGetFullThreadDiffInput, | 38 | OrchestrationGetFullThreadDiffInput, |
| 40 | OrchestrationGetSnapshotError, | 39 | OrchestrationGetSnapshotError, |
| 41 | OrchestrationGetSnapshotInput, | ||
| 42 | OrchestrationGetTurnDiffError, | 40 | OrchestrationGetTurnDiffError, |
| 43 | OrchestrationGetTurnDiffInput, | 41 | OrchestrationGetTurnDiffInput, |
| 44 | OrchestrationReplayEventsError, | 42 | OrchestrationReplayEventsError, |
| ... | @@ -115,7 +113,6 @@ export const WS_METHODS = { | ... | @@ -115,7 +113,6 @@ export const WS_METHODS = { |
| 115 | 113 | ||
| 116 | // Streaming subscriptions | 114 | // Streaming subscriptions |
| 117 | subscribeGitStatus: "subscribeGitStatus", | 115 | subscribeGitStatus: "subscribeGitStatus", |
| 118 | subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", | ||
| 119 | subscribeTerminalEvents: "subscribeTerminalEvents", | 116 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 120 | subscribeServerConfig: "subscribeServerConfig", | 117 | subscribeServerConfig: "subscribeServerConfig", |
| 121 | subscribeServerLifecycle: "subscribeServerLifecycle", | 118 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| ... | @@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { | ... | @@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { |
| 272 | error: TerminalError, | 269 | error: TerminalError, |
| 273 | }); | 270 | }); |
| 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 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( | 272 | export const WsOrchestrationDispatchCommandRpc = Rpc.make( |
| 282 | ORCHESTRATION_WS_METHODS.dispatchCommand, | 273 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 283 | { | 274 | { |
| ... | @@ -308,11 +299,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. | ... | @@ -308,11 +299,19 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. |
| 308 | error: OrchestrationReplayEventsError, | 299 | error: OrchestrationReplayEventsError, |
| 309 | }); | 300 | }); |
| 310 | 301 | ||
| 311 | export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( | 302 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { |
| 312 | WS_METHODS.subscribeOrchestrationDomainEvents, | 303 | payload: OrchestrationRpcSchemas.subscribeShell.input, |
| 304 | success: OrchestrationRpcSchemas.subscribeShell.output, | ||
| 305 | error: OrchestrationGetSnapshotError, | ||
| 306 | stream: true, | ||
| 307 | }); | ||
| 308 | |||
| 309 | export const WsOrchestrationSubscribeThreadRpc = Rpc.make( | ||
| 310 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 313 | { | 311 | { |
| 314 | payload: Schema.Struct({}), | 312 | payload: OrchestrationRpcSchemas.subscribeThread.input, |
| 315 | success: OrchestrationEvent, | 313 | success: OrchestrationRpcSchemas.subscribeThread.output, |
| 314 | error: OrchestrationGetSnapshotError, | ||
| 316 | stream: true, | 315 | stream: true, |
| 317 | }, | 316 | }, |
| 318 | ); | 317 | ); |
| ... | @@ -369,14 +368,14 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -369,14 +368,14 @@ export const WsRpcGroup = RpcGroup.make( |
| 369 | WsTerminalClearRpc, | 368 | WsTerminalClearRpc, |
| 370 | WsTerminalRestartRpc, | 369 | WsTerminalRestartRpc, |
| 371 | WsTerminalCloseRpc, | 370 | WsTerminalCloseRpc, |
| 372 | WsSubscribeOrchestrationDomainEventsRpc, | ||
| 373 | WsSubscribeTerminalEventsRpc, | 371 | WsSubscribeTerminalEventsRpc, |
| 374 | WsSubscribeServerConfigRpc, | 372 | WsSubscribeServerConfigRpc, |
| 375 | WsSubscribeServerLifecycleRpc, | 373 | WsSubscribeServerLifecycleRpc, |
| 376 | WsSubscribeAuthAccessRpc, | 374 | WsSubscribeAuthAccessRpc, |
| 377 | WsOrchestrationGetSnapshotRpc, | ||
| 378 | WsOrchestrationDispatchCommandRpc, | 375 | WsOrchestrationDispatchCommandRpc, |
| 379 | WsOrchestrationGetTurnDiffRpc, | 376 | WsOrchestrationGetTurnDiffRpc, |
| 380 | WsOrchestrationGetFullThreadDiffRpc, | 377 | WsOrchestrationGetFullThreadDiffRpc, |
| 381 | WsOrchestrationReplayEventsRpc, | 378 | WsOrchestrationReplayEventsRpc, |
| 379 | WsOrchestrationSubscribeShellRpc, | ||
| 380 | WsOrchestrationSubscribeThreadRpc, | ||
| 382 | ); | 381 | ); |