From 9b6dbb8deab8f8cd3986dbf2c7487e0cd95f4221 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 19:19:59 -0700 Subject: [PATCH 1/9] Add shell snapshot queries and summaries - project and thread shell projections now include runtime status fields - add snapshot/query paths for shell and thread detail reads - keep shell counts in sync from projection updates --- .../Layers/CheckpointDiffQuery.test.ts | 14 + .../Layers/OrchestrationEngine.test.ts | 10 + .../Layers/ProjectionPipeline.ts | 120 +++- .../Layers/ProjectionSnapshotQuery.test.ts | 87 +++ .../Layers/ProjectionSnapshotQuery.ts | 582 +++++++++++++++++- .../Services/ProjectionSnapshotQuery.ts | 36 ++ .../Layers/ProjectionRepositories.test.ts | 4 + .../persistence/Layers/ProjectionThreads.ts | 20 + apps/server/src/persistence/Migrations.ts | 2 + .../023_ProjectionThreadShellSummary.ts | 26 + .../persistence/Services/ProjectionThreads.ts | 5 + apps/server/src/server.test.ts | 15 + apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 156 ++++- apps/web/src/components/ChatView.tsx | 18 + apps/web/src/environmentApi.ts | 4 + .../environments/runtime/connection.test.ts | 235 +++---- .../src/environments/runtime/connection.ts | 259 ++------ apps/web/src/environments/runtime/service.ts | 70 ++- apps/web/src/rpc/wsRpcClient.ts | 23 + apps/web/src/store.test.ts | 79 +++ apps/web/src/store.ts | 350 +++++++++++ packages/contracts/src/ipc.ts | 16 + packages/contracts/src/orchestration.ts | 109 ++++ packages/contracts/src/rpc.ts | 19 + 25 files changed, 1876 insertions(+), 387 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 21cbe1509aa..b99dd848f4b 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -82,10 +82,17 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die( + "CheckpointDiffQuery should not request the orchestration shell snapshot", + ), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); @@ -136,10 +143,17 @@ describe("CheckpointDiffQueryLive", () => { Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), + getShellSnapshot: () => + Effect.die( + "CheckpointDiffQuery should not request the orchestration shell snapshot", + ), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index eaf66f3971d..b61664f1619 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -144,10 +144,20 @@ describe("OrchestrationEngine", () => { Layer.provide( Layer.succeed(ProjectionSnapshotQuery, { getSnapshot: () => Effect.succeed(projectionSnapshot), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bdc6fac6d1c..226a06ca19a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, + ThreadId, } from "@t3tools/contracts"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -89,6 +90,71 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; } +function derivePendingUserInputCountFromActivities( + activities: ReadonlyArray, +): number { + const openRequestIds = new Set(); + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + + for (const activity of ordered) { + const requestId = extractActivityRequestId(activity.payload); + if (requestId === null) { + continue; + } + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + + if (activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + continue; + } + + if (activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + detail !== null && + (detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request")) + ) { + openRequestIds.delete(requestId); + } + } + + return openRequestIds.size; +} + +function deriveHasActionableProposedPlan(input: { + readonly latestTurnId: string | null; + readonly proposedPlans: ReadonlyArray; +}): boolean { + const sorted = [...input.proposedPlans].toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + ); + + const latestForTurn = + input.latestTurnId === null + ? null + : (sorted.filter((plan) => plan.turnId === input.latestTurnId).at(-1) ?? null); + if (latestForTurn !== null) { + return latestForTurn.implementedAt === null; + } + + const latestPlan = sorted.at(-1) ?? null; + return latestPlan !== null && latestPlan.implementedAt === null; +} + function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -432,6 +498,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( + threadId: ThreadId, + ) { + const existingRow = yield* projectionThreadRepository.getById({ + threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + + const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ + projectionThreadMessageRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionPendingApprovalRepository.listByThreadId({ threadId }), + ]); + + const latestUserMessageAt = + messages + .filter((message) => message.role === "user") + .map((message) => message.createdAt) + .toSorted() + .at(-1) ?? null; + + const pendingApprovalCount = pendingApprovals.filter( + (approval) => approval.status === "pending", + ).length; + const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); + const hasActionableProposedPlan = deriveHasActionableProposedPlan({ + latestTurnId: existingRow.value.latestTurnId, + proposedPlans, + }); + + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestUserMessageAt, + pendingApprovalCount, + pendingUserInputCount, + hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + }); + }); + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", )(function* (event, attachmentSideEffects) { @@ -450,6 +558,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); return; @@ -554,7 +666,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": - case "thread.activity-appended": { + case "thread.activity-appended": + case "thread.approval-response-requested": + case "thread.user-input-response-requested": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -565,6 +679,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -580,6 +695,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.session.activeTurnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -595,6 +711,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } @@ -610,6 +727,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: null, updatedAt: event.occurredAt, }); + yield* refreshThreadShellSummary(event.payload.threadId); return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 0eb312cbfdc..5658e99355f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -62,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { project_id, title, model_selection_json, + runtime_mode, + interaction_mode, branch, worktree_path, latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at @@ -74,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'project-1', 'Thread 1', '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', NULL, NULL, 'turn-1', + '2026-02-24T00:00:04.000Z', + 1, + 0, + 0, '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -341,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, }, ]); + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellSnapshot.snapshotSequence, 5); + assert.deepEqual(shellSnapshot.projects, [ + { + id: asProjectId("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + scripts: [ + { + id: "script-1", + name: "Build", + command: "bun run build", + icon: "build", + runOnWorktreeCreate: false, + }, + ], + createdAt: "2026-02-24T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:01.000Z", + }, + ]); + assert.deepEqual(shellSnapshot.threads, [ + { + id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: { + turnId: asTurnId("turn-1"), + state: "completed", + requestedAt: "2026-02-24T00:00:08.000Z", + startedAt: "2026-02-24T00:00:08.000Z", + completedAt: "2026-02-24T00:00:08.000Z", + assistantMessageId: asMessageId("message-1"), + sourceProposedPlan: { + threadId: ThreadId.make("thread-1"), + planId: "plan-1", + }, + }, + createdAt: "2026-02-24T00:00:02.000Z", + updatedAt: "2026-02-24T00:00:03.000Z", + archivedAt: null, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: "2026-02-24T00:00:07.000Z", + }, + latestUserMessageAt: "2026-02-24T00:00:04.000Z", + hasPendingApprovals: true, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ]); + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value, snapshot.threads[0]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b0f883f9402..2cdacb67c26 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -6,16 +6,19 @@ import { OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, ProjectScript, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationProjectShell, type OrchestrationProposedPlan, type OrchestrationProject, type OrchestrationSession, - type OrchestrationThread, type OrchestrationThreadActivity, + type OrchestrationThreadShell, ModelSelection, ProjectId, ThreadId, @@ -48,6 +51,8 @@ import { } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); +const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); +const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -155,6 +160,64 @@ function computeSnapshotSequence( return Number.isFinite(minSequence) ? minSequence : 0; } +function mapLatestTurn( + row: Schema.Schema.Type, +): OrchestrationLatestTurn { + return { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }; +} + +function mapSessionRow( + row: Schema.Schema.Type, +): OrchestrationSession { + return { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }; +} + +function mapProjectShellRow( + row: Schema.Schema.Type, + repositoryIdentity: OrchestrationProject["repositoryIdentity"], +): OrchestrationProjectShell { + return { + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -204,6 +267,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC @@ -381,6 +448,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveProjectRowById = SqlSchema.findOneOption({ + Request: ProjectIdLookupInput, + Result: ProjectionProjectLookupRowSchema, + execute: ({ projectId }) => + sql` + SELECT + project_id AS "projectId", + title, + workspace_root AS "workspaceRoot", + default_model_selection_json AS "defaultModelSelection", + scripts_json AS "scripts", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM projection_projects + WHERE project_id = ${projectId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + const getFirstActiveThreadIdByProject = SqlSchema.findOneOption({ Request: ProjectIdLookupInput, Result: ProjectionThreadIdLookupRowSchema, @@ -415,6 +503,143 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getActiveThreadRowById = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + LIMIT 1 + `, + }); + + const listThreadMessageRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadProposedPlanRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const listThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, activity_id ASC + `, + }); + + const getThreadSessionRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadSessionDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + status, + provider_name AS "providerName", + runtime_mode AS "runtimeMode", + active_turn_id AS "activeTurnId", + last_error AS "lastError", + updated_at AS "updatedAt" + FROM projection_thread_sessions + WHERE thread_id = ${threadId} + LIMIT 1 + `, + }); + + const getLatestTurnRowByThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionLatestTurnDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + assistant_message_id AS "assistantMessageId", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + ORDER BY + COALESCE(checkpoint_turn_count, -1) DESC, + COALESCE(completed_at, started_at, requested_at) DESC, + row_id DESC + LIMIT 1 + `, + }); + const listCheckpointRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionCheckpointDbRowSchema, @@ -724,6 +949,145 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows + .filter((row) => row.deletedAt === null) + .map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); + }), + ); + const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () => readProjectionCounts(undefined).pipe( Effect.mapError( @@ -770,6 +1134,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"] = (projectId) => + getActiveProjectRowById({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectShellById:query", + "ProjectionSnapshotQuery.getProjectShellById:decodeRow", + ), + ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver + .resolve(option.value.workspaceRoot) + .pipe( + Effect.map((repositoryIdentity) => + Option.some(mapProjectShellRow(option.value, repositoryIdentity)), + ), + ), + ), + ); + const getFirstActiveThreadIdByProjectId: ProjectionSnapshotQueryShape["getFirstActiveThreadIdByProjectId"] = (projectId) => getFirstActiveThreadIdByProject({ projectId }).pipe( @@ -826,12 +1211,207 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); }); + const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => + Effect.gen(function* () { + const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getThread:query", + "ProjectionSnapshotQuery.getThreadShellById:getThread:decodeRow", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadShellById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:getSession:query", + "ProjectionSnapshotQuery.getThreadShellById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + return Option.some({ + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + latestUserMessageAt: threadRow.value.latestUserMessageAt, + hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, + hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, + hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + } satisfies OrchestrationThreadShell); + }); + + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + Effect.gen(function* () { + const [ + threadRow, + messageRows, + proposedPlanRows, + activityRows, + checkpointRows, + latestTurnRow, + sessionRow, + ] = yield* Effect.all([ + getActiveThreadRowById({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", + "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + ), + ), + ), + listThreadMessageRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", + ), + ), + ), + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + ), + ), + ), + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + ), + ), + ), + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", + "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + ), + ), + ), + ]); + + if (Option.isNone(threadRow)) { + return Option.none(); + } + + const thread = { + id: threadRow.value.threadId, + projectId: threadRow.value.projectId, + title: threadRow.value.title, + modelSelection: threadRow.value.modelSelection, + runtimeMode: threadRow.value.runtimeMode, + interactionMode: threadRow.value.interactionMode, + branch: threadRow.value.branch, + worktreePath: threadRow.value.worktreePath, + latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, + createdAt: threadRow.value.createdAt, + updatedAt: threadRow.value.updatedAt, + archivedAt: threadRow.value.archivedAt, + deletedAt: null, + messages: messageRows.map((row) => ({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + proposedPlans: proposedPlanRows.map((row) => ({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + activities: activityRows.map((row) => ({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })), + checkpoints: checkpointRows.map((row) => ({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + })), + session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + }; + + return Option.some( + yield* decodeThread(thread).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getThreadDetailById:decodeThread"), + ), + ), + ); + }); + return { getSnapshot, + getShellSnapshot, getCounts, getActiveProjectByWorkspaceRoot, + getProjectShellById, getFirstActiveThreadIdByProjectId, getThreadCheckpointContext, + getThreadShellById, + getThreadDetailById, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 36bbf5028b4..be81dcbb374 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -9,7 +9,11 @@ import type { OrchestrationCheckpointSummary, OrchestrationProject, + OrchestrationProjectShell, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationThread, + OrchestrationThreadShell, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -44,6 +48,17 @@ export interface ProjectionSnapshotQueryShape { */ readonly getSnapshot: () => Effect.Effect; + /** + * Read the latest orchestration shell snapshot. + * + * Returns only projects and thread shell summaries so clients can bootstrap + * lightweight navigation state without hydrating every thread body. + */ + readonly getShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + /** * Read aggregate projection counts without hydrating the full read model. */ @@ -56,6 +71,13 @@ export interface ProjectionSnapshotQueryShape { workspaceRoot: string, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read a single active project shell row by id. + */ + readonly getProjectShellById: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Read the earliest active thread for a project. */ @@ -69,6 +91,20 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadCheckpointContext: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread shell row by id. + */ + readonly getThreadShellById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Read a single active thread detail snapshot by id. + */ + readonly getThreadDetailById: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 08bc2481226..d42be699458 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -88,6 +88,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, deletedAt: null, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fdcab..57fb88c371c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -40,6 +40,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, deleted_at ) VALUES ( @@ -55,6 +59,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.latestUserMessageAt}, + ${row.pendingApprovalCount}, + ${row.pendingUserInputCount}, + ${row.hasActionableProposedPlan}, ${row.deletedAt} ) ON CONFLICT (thread_id) @@ -70,6 +78,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + latest_user_message_at = excluded.latest_user_message_at, + pending_approval_count = excluded.pending_approval_count, + pending_user_input_count = excluded.pending_user_input_count, + has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, deleted_at = excluded.deleted_at `, }); @@ -92,6 +104,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -116,6 +132,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" FROM projection_threads WHERE project_id = ${projectId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8c9fe4d9fd1..6c2885a3725 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,6 +35,7 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; +import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; /** * Migration loader with all migrations defined inline. @@ -69,6 +70,7 @@ export const migrationEntries = [ [20, "AuthAccessManagement", Migration0020], [21, "AuthSessionClientMetadata", Migration0021], [22, "AuthSessionLastConnectedAt", Migration0022], + [23, "ProjectionThreadShellSummary", Migration0023], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts new file mode 100644 index 00000000000..759f87e8ad7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_at TEXT + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0 + `.pipe(Effect.catch(() => Effect.void)); +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 31ec00a6e5e..7afdab2d58b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -9,6 +9,7 @@ import { IsoDateTime, ModelSelection, + NonNegativeInt, ProjectId, ProviderInteractionMode, RuntimeMode, @@ -33,6 +34,10 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + pendingApprovalCount: NonNegativeInt, + pendingUserInputCount: NonNegativeInt, + hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fbbab1c84a2..e94612c1a4a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -31,6 +31,7 @@ import { FileSystem, Layer, ManagedRuntime, + Option, Path, Stream, } from "effect"; @@ -410,6 +411,20 @@ const buildAppUnderTest = (options?: { Layer.provide( Layer.mock(ProjectionSnapshotQuery)({ getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: new Date(0).toISOString(), + }), + getProjectShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), + getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), ), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index c3159cc9d8c..7e8c0e14bad 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -70,6 +70,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa yield* launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery, { getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( Effect.as({ @@ -78,8 +79,11 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa }), ), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeed(Option.none()), + getThreadDetailById: () => Effect.succeed(Option.none()), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3ef4a864697..0667cf256c8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { type AuthAccessStreamEvent, AuthSessionId, @@ -9,6 +9,7 @@ import { type GitManagerServiceError, OrchestrationDispatchCommandError, type OrchestrationEvent, + type OrchestrationShellStreamEvent, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -222,6 +223,76 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const enrichOrchestrationEvents = (events: ReadonlyArray) => Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const toShellStreamEvent = ( + event: OrchestrationEvent, + ): Effect.Effect, never, never> => { + switch (event.type) { + case "project.created": + case "project.meta-updated": + return projectionSnapshotQuery.getProjectShellById(event.payload.projectId).pipe( + Effect.map((project) => + Option.map(project, (nextProject) => ({ + kind: "project-upserted" as const, + sequence: event.sequence, + project: nextProject, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + case "project.deleted": + return Effect.succeed( + Option.some({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: event.payload.projectId, + }), + ); + case "thread.deleted": + return Effect.succeed( + Option.some({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: event.payload.threadId, + }), + ); + default: + if (event.aggregateKind !== "thread") { + return Effect.succeed(Option.none()); + } + return projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + } + }; + + const isThreadDetailEvent = ( + event: OrchestrationEvent, + ): event is Extract< + OrchestrationEvent, + { + type: + | "thread.message-sent" + | "thread.proposed-plan-upserted" + | "thread.activity-appended" + | "thread.turn-diff-completed" + | "thread.reverted"; + } + > => + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted"; + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -561,6 +632,89 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeShell, + Effect.gen(function* () { + const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration shell snapshot", + cause, + }), + ), + ); + + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(toShellStreamEvent), + Stream.flatMap((event) => + Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty, + ), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot, + }), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => + observeRpcStreamEffect( + ORCHESTRATION_WS_METHODS.subscribeThread, + Effect.gen(function* () { + const [threadDetail, snapshotSequence] = yield* Effect.all([ + projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread ${input.threadId}`, + cause, + }), + ), + ), + orchestrationEngine + .getReadModel() + .pipe(Effect.map((readModel) => readModel.snapshotSequence)), + ]); + + if (Option.isNone(threadDetail)) { + return yield* new OrchestrationGetSnapshotError({ + message: `Thread ${input.threadId} was not found`, + cause: input.threadId, + }); + } + + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.aggregateKind === "thread" && + event.aggregateId === input.threadId && + isThreadDetailEvent(event), + ), + Stream.map((event) => ({ + kind: "event" as const, + event, + })), + ); + + return Stream.concat( + Stream.make({ + kind: "snapshot" as const, + snapshot: { + snapshotSequence, + thread: threadDetail.value, + }, + }), + liveStream, + ); + }), + { "rpc.aggregate": "orchestration" }, + ), [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeOrchestrationDomainEvents, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875ccc..bd8c554f97a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,7 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { applyEnvironmentThreadDetailEvent } from "../environments/runtime/service"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -843,6 +844,23 @@ export default function ChatView(props: ChatViewProps) { useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + useEffect(() => { + if (routeKind !== "server") { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + return api.orchestration.subscribeThread({ threadId }, (item) => { + if (item.kind === "snapshot") { + useStore.getState().syncServerThreadDetail(item.snapshot.thread, environmentId); + return; + } + applyEnvironmentThreadDetailEvent(item.event, environmentId); + }); + }, [environmentId, routeKind, threadId]); + // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 5f23a53a756..82da9eae81c 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -36,6 +36,10 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + subscribeShell: (callback, options) => + rpcClient.orchestration.subscribeShell(callback, options), + subscribeThread: (input, callback, options) => + rpcClient.orchestration.subscribeThread(input, callback, options), replayEvents: (fromSequenceExclusive) => rpcClient.orchestration .replayEvents({ fromSequenceExclusive }) diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 4dc65015497..5f8576cbf35 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -4,29 +4,18 @@ import { describe, expect, it, vi } from "vitest"; import { createEnvironmentConnection } from "./connection"; import type { WsRpcClient } from "~/rpc/wsRpcClient"; -function createTestClient(options?: { - readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; - readonly replayEvents?: () => Promise>; -}) { +function createTestClient() { const lifecycleListeners = new Set<(event: any) => void>(); const configListeners = new Set<(event: any) => void>(); const terminalListeners = new Set<(event: any) => void>(); - let domainResubscribe: (() => void) | undefined; - - const getSnapshot = vi.fn( - options?.getSnapshot ?? - (async () => - ({ - snapshotSequence: 1, - projects: [], - threads: [], - }) as any), - ); - const replayEvents = vi.fn(options?.replayEvents ?? (async () => [])); + const shellListeners = new Set<(event: any) => void>(); + let shellResubscribe: (() => void) | undefined; const client = { dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), + reconnect: vi.fn(async () => { + shellResubscribe?.(); + }), server: { getConfig: vi.fn(async () => ({ environment: { @@ -48,19 +37,36 @@ function createTestClient(options?: { updateSettings: vi.fn(async () => undefined), }, orchestration: { - getSnapshot, + getSnapshot: vi.fn(async () => undefined), dispatchCommand: vi.fn(async () => undefined), getTurnDiff: vi.fn(async () => undefined), getFullThreadDiff: vi.fn(async () => undefined), - replayEvents, - onDomainEvent: vi.fn((_: (event: any) => void, options?: { onResubscribe?: () => void }) => { - domainResubscribe = options?.onResubscribe; - return () => { - if (domainResubscribe === options?.onResubscribe) { - domainResubscribe = undefined; - } - }; - }), + replayEvents: vi.fn(async () => []), + subscribeShell: vi.fn( + (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { + shellListeners.add(listener); + shellResubscribe = options?.onResubscribe; + queueMicrotask(() => { + listener({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-04-12T00:00:00.000Z", + }, + }); + }); + return () => { + shellListeners.delete(listener); + if (shellResubscribe === options?.onResubscribe) { + shellResubscribe = undefined; + } + }; + }, + ), + subscribeThread: vi.fn(() => () => undefined), + onDomainEvent: vi.fn(() => () => undefined), }, terminal: { open: vi.fn(async () => undefined), @@ -99,8 +105,6 @@ function createTestClient(options?: { return { client, - getSnapshot, - replayEvents, emitWelcome: (environmentId: EnvironmentId) => { for (const listener of lifecycleListeners) { listener({ @@ -125,17 +129,27 @@ function createTestClient(options?: { }); } }, - triggerDomainResubscribe: () => { - domainResubscribe?.(); + emitShellSnapshot: (snapshotSequence: number) => { + for (const listener of shellListeners) { + listener({ + kind: "snapshot", + snapshot: { + snapshotSequence, + projects: [], + threads: [], + updatedAt: "2026-04-12T00:00:00.000Z", + }, + }); + } }, }; } describe("createEnvironmentConnection", () => { - it("bootstraps a snapshot immediately for a new connection", async () => { + it("bootstraps from the shell subscription snapshot", async () => { const environmentId = EnvironmentId.make("env-1"); - const { client, getSnapshot } = createTestClient(); - const syncSnapshot = vi.fn(); + const { client } = createTestClient(); + const syncShellSnapshot = vi.fn(); const connection = createEnvironmentConnection({ kind: "saved", @@ -150,16 +164,14 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncSnapshot, + applyShellEvent: vi.fn(), + syncShellSnapshot, applyTerminalEvent: vi.fn(), }); - await Promise.resolve(); - await Promise.resolve(); + await connection.ensureBootstrapped(); - expect(getSnapshot).toHaveBeenCalledTimes(1); - expect(syncSnapshot).toHaveBeenCalledWith( + expect(syncShellSnapshot).toHaveBeenCalledWith( expect.objectContaining({ snapshotSequence: 1 }), environmentId, ); @@ -184,8 +196,8 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch: vi.fn(), - syncSnapshot: vi.fn(), + applyShellEvent: vi.fn(), + syncShellSnapshot: vi.fn(), applyTerminalEvent: vi.fn(), }); @@ -196,58 +208,10 @@ describe("createEnvironmentConnection", () => { await connection.dispose(); }); - it("rejects ensureBootstrapped when snapshot recovery fails", async () => { + it("waits for a fresh shell snapshot after reconnect", async () => { const environmentId = EnvironmentId.make("env-1"); - const snapshotError = new Error("snapshot failed"); - const { client } = createTestClient({ - getSnapshot: async () => { - throw snapshotError; - }, - }); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyEventBatch: vi.fn(), - syncSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); - - await expect(connection.ensureBootstrapped()).rejects.toThrow("snapshot failed"); - - await connection.dispose(); - }); - - it("retries replay recovery after transport disconnects during resubscribe", async () => { - const environmentId = EnvironmentId.make("env-1"); - let replayAttempts = 0; - const applyEventBatch = vi.fn(); - const { client, replayEvents, triggerDomainResubscribe } = createTestClient({ - replayEvents: async () => { - replayAttempts += 1; - if (replayAttempts === 1) { - throw new Error("SocketCloseError: 1006"); - } - - return [ - { - sequence: 2, - type: "thread.created", - payload: {}, - }, - ]; - }, - }); + const { client, emitShellSnapshot } = createTestClient(); + const syncShellSnapshot = vi.fn(); const connection = createEnvironmentConnection({ kind: "saved", @@ -262,85 +226,26 @@ describe("createEnvironmentConnection", () => { environmentId, }, client, - applyEventBatch, - syncSnapshot: vi.fn(), + applyShellEvent: vi.fn(), + syncShellSnapshot, applyTerminalEvent: vi.fn(), }); - await Promise.resolve(); - await Promise.resolve(); - - triggerDomainResubscribe(); - - await vi.waitFor(() => { - expect(replayEvents).toHaveBeenCalledTimes(2); - expect(applyEventBatch).toHaveBeenCalledWith( - [ - expect.objectContaining({ - sequence: 2, - }), - ], - environmentId, - ); - }); - - await connection.dispose(); - }); - it("swallows replay recovery failures triggered by resubscribe", async () => { - const environmentId = EnvironmentId.make("env-1"); - const snapshotError = new Error("snapshot failed"); - let snapshotCalls = 0; - const { client, triggerDomainResubscribe } = createTestClient({ - getSnapshot: async () => { - snapshotCalls += 1; - if (snapshotCalls === 1) { - return { - snapshotSequence: 1, - projects: [], - threads: [], - } as any; - } - - throw snapshotError; - }, - replayEvents: async () => { - throw new Error("SocketCloseError: 1006"); - }, - }); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyEventBatch: vi.fn(), - syncSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); + await connection.ensureBootstrapped(); + const reconnectPromise = connection.reconnect(); await Promise.resolve(); - await Promise.resolve(); - - const onUnhandledRejection = vi.fn(); - process.on("unhandledRejection", onUnhandledRejection); + expect(syncShellSnapshot).toHaveBeenCalledTimes(1); - try { - triggerDomainResubscribe(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } + emitShellSnapshot(2); + await reconnectPromise; - expect(onUnhandledRejection).not.toHaveBeenCalled(); + expect(client.reconnect).toHaveBeenCalledTimes(1); + expect(syncShellSnapshot).toHaveBeenCalledTimes(2); + expect(syncShellSnapshot).toHaveBeenLastCalledWith( + expect.objectContaining({ snapshotSequence: 2 }), + environmentId, + ); await connection.dispose(); }); diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts index ef79d3fce6c..9f3465dfefb 100644 --- a/apps/web/src/environments/runtime/connection.ts +++ b/apps/web/src/environments/runtime/connection.ts @@ -1,29 +1,15 @@ import type { EnvironmentId, - OrchestrationEvent, - OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationShellStreamEvent, ServerConfig, ServerLifecycleWelcomePayload, TerminalEvent, } from "@t3tools/contracts"; import type { KnownEnvironment } from "@t3tools/client-runtime"; -import { - deriveReplayRetryDecision, - type OrchestrationRecoveryReason, -} from "../../orchestrationRecovery"; -import { - createOrchestrationRecoveryCoordinator, - type ReplayRetryTracker, -} from "../../orchestrationRecovery"; -import { isTransportConnectionErrorMessage } from "~/rpc/transportError"; import type { WsRpcClient } from "~/rpc/wsRpcClient"; -const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; -const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; -const RECOVERY_TRANSPORT_RETRY_DELAY_MS = 250; -const MAX_RECOVERY_TRANSPORT_RETRIES = 20; - export interface EnvironmentConnection { readonly kind: "primary" | "saved"; readonly environmentId: EnvironmentId; @@ -35,11 +21,14 @@ export interface EnvironmentConnection { } interface OrchestrationHandlers { - readonly applyEventBatch: ( - events: ReadonlyArray, + readonly applyShellEvent: ( + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, + ) => void; + readonly syncShellSnapshot: ( + snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId, ) => void; - readonly syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => void; readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; } @@ -52,33 +41,31 @@ interface EnvironmentConnectionInput extends OrchestrationHandlers { readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; } -function createSnapshotBootstrapController(input: { - readonly isBootstrapped: () => boolean; - readonly runSnapshotRecovery: ( - reason: Extract, - ) => Promise; -}) { - let inFlight: Promise | null = null; +function createBootstrapGate() { + let resolve: (() => void) | null = null; + let reject: ((error: unknown) => void) | null = null; + let promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); return { - ensureSnapshotRecovery( - reason: Extract, - ): Promise { - if (input.isBootstrapped()) { - return Promise.resolve(); - } - - if (inFlight !== null) { - return inFlight; - } - - const nextInFlight = input.runSnapshotRecovery(reason).finally(() => { - if (inFlight === nextInFlight) { - inFlight = null; - } + wait: () => promise, + resolve: () => { + resolve?.(); + resolve = null; + reject = null; + }, + reject: (error: unknown) => { + reject?.(error); + resolve = null; + reject = null; + }, + reset: () => { + promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; }); - inFlight = nextInFlight; - return inFlight; }, }; } @@ -86,10 +73,6 @@ function createSnapshotBootstrapController(input: { export function createEnvironmentConnection( input: EnvironmentConnectionInput, ): EnvironmentConnection { - const recovery = createOrchestrationRecoveryCoordinator(); - let replayRetryTracker: ReplayRetryTracker | null = null; - const pendingDomainEvents: OrchestrationEvent[] = []; - let flushPendingDomainEventsScheduled = false; const environmentId = input.knownEnvironment.environmentId; if (!environmentId) { @@ -99,6 +82,7 @@ export function createEnvironmentConnection( } let disposed = false; + const bootstrapGate = createBootstrapGate(); const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { if (environmentId !== nextEnvironmentId) { @@ -108,148 +92,6 @@ export function createEnvironmentConnection( } }; - const flushPendingDomainEvents = () => { - flushPendingDomainEventsScheduled = false; - if (disposed || pendingDomainEvents.length === 0) { - return; - } - - const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - const nextEvents = recovery.markEventBatchApplied(events); - if (nextEvents.length === 0) { - return; - } - input.applyEventBatch(nextEvents, environmentId); - }; - - const schedulePendingDomainEventFlush = () => { - if (flushPendingDomainEventsScheduled) { - return; - } - - flushPendingDomainEventsScheduled = true; - queueMicrotask(flushPendingDomainEvents); - }; - - const retryTransportRecoveryOperation = async (operation: () => Promise): Promise => { - for (let attempt = 0; ; attempt += 1) { - try { - return await operation(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - disposed || - !isTransportConnectionErrorMessage(message) || - attempt >= MAX_RECOVERY_TRANSPORT_RETRIES - 1 - ) { - throw error; - } - - await new Promise((resolve) => { - setTimeout(resolve, RECOVERY_TRANSPORT_RETRY_DELAY_MS); - }); - - if (disposed) { - throw error; - } - } - } - }; - const scheduleReplayRecovery = (reason: "sequence-gap" | "resubscribe") => { - void runReplayRecovery(reason).catch(() => undefined); - }; - - const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { - if (!recovery.beginReplayRecovery(reason)) { - return; - } - - const fromSequenceExclusive = recovery.getState().latestSequence; - try { - const events = await retryTransportRecoveryOperation(() => - input.client.orchestration.replayEvents({ fromSequenceExclusive }), - ); - if (!disposed) { - const nextEvents = recovery.markEventBatchApplied(events); - if (nextEvents.length > 0) { - input.applyEventBatch(nextEvents, environmentId); - } - } - } catch { - replayRetryTracker = null; - recovery.failReplayRecovery(); - if (disposed) { - return; - } - await snapshotBootstrap.ensureSnapshotRecovery("replay-failed"); - return; - } - - if (disposed) { - return; - } - - const replayCompletion = recovery.completeReplayRecovery(); - const retryDecision = deriveReplayRetryDecision({ - previousTracker: replayRetryTracker, - completion: replayCompletion, - recoveryState: recovery.getState(), - baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, - maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, - }); - replayRetryTracker = retryDecision.tracker; - - if (retryDecision.shouldRetry) { - if (retryDecision.delayMs > 0) { - await new Promise((resolve) => { - setTimeout(resolve, retryDecision.delayMs); - }); - if (disposed) { - return; - } - } - scheduleReplayRecovery(reason); - } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { - console.warn( - "[orchestration-recovery]", - "Stopping replay recovery after no-progress retries.", - { - environmentId, - state: recovery.getState(), - }, - ); - } - }; - - const runSnapshotRecovery = async ( - reason: Extract, - ): Promise => { - const started = recovery.beginSnapshotRecovery(reason); - if (!started) { - return; - } - - try { - const snapshot = await retryTransportRecoveryOperation(() => - input.client.orchestration.getSnapshot(), - ); - if (!disposed) { - input.syncSnapshot(snapshot, environmentId); - if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { - scheduleReplayRecovery("sequence-gap"); - } - } - } catch (error) { - recovery.failSnapshotRecovery(); - throw error; - } - }; - - const snapshotBootstrap = createSnapshotBootstrapController({ - isBootstrapped: () => recovery.getState().bootstrapped, - runSnapshotRecovery, - }); - const unsubLifecycle = input.client.server.subscribeLifecycle( (event: Parameters[0]>[0]) => { if (event.type !== "welcome") { @@ -273,26 +115,21 @@ export function createEnvironmentConnection( }, ); - const unsubDomainEvent = input.client.orchestration.onDomainEvent( - (event: Parameters[0]>[0]) => { - const action = recovery.classifyDomainEvent(event.sequence); - if (action === "apply") { - pendingDomainEvents.push(event); - schedulePendingDomainEventFlush(); + const unsubShell = input.client.orchestration.subscribeShell( + (item: Parameters[0]>[0]) => { + if (item.kind === "snapshot") { + input.syncShellSnapshot(item.snapshot, environmentId); + bootstrapGate.resolve(); return; } - if (action === "recover") { - flushPendingDomainEvents(); - scheduleReplayRecovery("sequence-gap"); - } + input.applyShellEvent(item, environmentId); }, { onResubscribe: () => { if (disposed) { return; } - flushPendingDomainEvents(); - scheduleReplayRecovery("resubscribe"); + bootstrapGate.reset(); }, }, ); @@ -303,13 +140,9 @@ export function createEnvironmentConnection( }, ); - void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined); - const cleanup = () => { disposed = true; - flushPendingDomainEventsScheduled = false; - pendingDomainEvents.length = 0; - unsubDomainEvent(); + unsubShell(); unsubTerminalEvent(); unsubLifecycle(); unsubConfig(); @@ -320,11 +153,17 @@ export function createEnvironmentConnection( environmentId, knownEnvironment: input.knownEnvironment, client: input.client, - ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"), + ensureBootstrapped: () => bootstrapGate.wait(), reconnect: async () => { - await input.client.reconnect(); - await input.refreshMetadata?.(); - await snapshotBootstrap.ensureSnapshotRecovery("bootstrap"); + bootstrapGate.reset(); + try { + await input.client.reconnect(); + await input.refreshMetadata?.(); + await bootstrapGate.wait(); + } catch (error) { + bootstrapGate.reject(error); + throw error; + } }, dispose: async () => { cleanup(); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 5c8d2dacabc..112d89608fd 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -2,7 +2,8 @@ import { type AuthSessionRole, type EnvironmentId, type OrchestrationEvent, - type OrchestrationReadModel, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamEvent, type ServerConfig, type TerminalEvent, ThreadId, @@ -169,17 +170,18 @@ function coalesceOrchestrationUiEvents( return coalesced; } -function reconcileSnapshotDerivedState() { - const storeState = useStore.getState(); - const threads = selectThreadsAcrossEnvironments(storeState); - const projects = selectProjectsAcrossEnvironments(storeState); - +function syncProjectUiFromStore() { + const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), cwd: project.cwd, })), ); +} + +function syncThreadUiFromStore() { + const threads = selectThreadsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncThreads( threads.map((thread) => ({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -189,7 +191,13 @@ function reconcileSnapshotDerivedState() { markPromotedDraftThreadsByRef( threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); +} +function reconcileSnapshotDerivedState() { + syncProjectUiFromStore(); + syncThreadUiFromStore(); + + const threads = selectThreadsAcrossEnvironments(useStore.getState()); const activeThreadKeys = collectActiveTerminalThreadIds({ snapshotThreads: threads.map((thread) => ({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -273,11 +281,55 @@ function applyRecoveredEventBatch( } } +export function applyEnvironmentThreadDetailEvent( + event: OrchestrationEvent, + environmentId: EnvironmentId, +) { + applyRecoveredEventBatch([event], environmentId); +} + +function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { + const threadId = + event.kind === "thread-upserted" + ? event.thread.id + : event.kind === "thread-removed" + ? event.threadId + : null; + const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; + const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; + + useStore.getState().applyShellEvent(event, environmentId); + + switch (event.kind) { + case "project-upserted": + case "project-removed": + syncProjectUiFromStore(); + return; + case "thread-upserted": + syncThreadUiFromStore(); + if (!previousThread && threadRef) { + markPromotedDraftThreadByRef(threadRef); + } + if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { + useTerminalStateStore.getState().removeTerminalState(threadRef); + } + return; + case "thread-removed": + if (threadRef) { + useComposerDraftStore.getState().clearDraftThread(threadRef); + useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); + useTerminalStateStore.getState().removeTerminalState(threadRef); + } + syncThreadUiFromStore(); + return; + } +} + function createEnvironmentConnectionHandlers() { return { - applyEventBatch: applyRecoveredEventBatch, - syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => { - useStore.getState().syncServerReadModel(snapshot, environmentId); + applyShellEvent, + syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { + useStore.getState().syncServerShellSnapshot(snapshot, environmentId); reconcileSnapshotDerivedState(); }, applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index b714889b7aa..23350123a35 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -39,6 +39,15 @@ type RpcStreamMethod = ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void : never; +type RpcInputStreamMethod = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? ( + input: RpcInput, + listener: (event: TEvent) => void, + options?: StreamSubscriptionOptions, + ) => () => void + : never; + interface GitRunStackedActionOptions { readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -106,6 +115,8 @@ export interface WsRpcClient { readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; readonly replayEvents: RpcUnaryMethod; + readonly subscribeShell: RpcStreamMethod; + readonly subscribeThread: RpcInputStreamMethod; readonly onDomainEvent: RpcStreamMethod; }; } @@ -231,6 +242,18 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) .then((events) => [...events]), + subscribeShell: (listener, options) => + transport.subscribe( + (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), + listener, + options, + ), + subscribeThread: (input, listener, options) => + transport.subscribe( + (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), + listener, + options, + ), onDomainEvent: (listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 951830dbbc2..0673f48c2f0 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, EventId, MessageId, + type OrchestrationShellSnapshot, ProjectId, ThreadId, TurnId, @@ -23,6 +24,7 @@ import { setThreadBranch, selectThreadsAcrossEnvironments, syncServerReadModel, + syncServerShellSnapshot, type AppState, type EnvironmentState, } from "./store"; @@ -496,6 +498,83 @@ describe("store read model sync", () => { expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); }); + it("updates shell state without discarding hydrated thread detail", () => { + const initialState = makeState( + makeThread({ + title: "Initial thread", + messages: [ + { + id: MessageId.make("message-1"), + role: "assistant", + text: "hydrated body", + createdAt: "2026-02-13T00:00:01.000Z", + completedAt: "2026-02-13T00:00:01.000Z", + streaming: false, + }, + ], + }), + ); + const shellSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 2, + projects: [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + repositoryIdentity: null, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + scripts: [], + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + }, + ], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Renamed thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/renamed", + worktreePath: null, + latestTurn: null, + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:02.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ], + updatedAt: "2026-02-13T00:00:02.000Z", + }; + + const next = syncServerShellSnapshot(initialState, shellSnapshot, localEnvironmentId); + const thread = selectThreadByRef( + next, + scopeThreadRef(localEnvironmentId, ThreadId.make("thread-1")), + ); + + expect(thread?.title).toBe("Renamed thread"); + expect(thread?.branch).toBe("feature/renamed"); + expect(thread?.messages).toEqual([ + expect.objectContaining({ + id: MessageId.make("message-1"), + text: "hydrated body", + }), + ]); + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); + }); + it("preserves claude model slugs without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 58a0697c7d0..207107b0c87 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -6,9 +6,12 @@ import type { OrchestrationMessage, OrchestrationProposedPlan, OrchestrationReadModel, + OrchestrationShellSnapshot, + OrchestrationShellStreamEvent, OrchestrationSession, OrchestrationSessionStatus, OrchestrationThread, + OrchestrationThreadShell, OrchestrationThreadActivity, ProjectId, ProviderKind, @@ -193,6 +196,25 @@ function mapProject( }; } +function mapProjectShell( + project: OrchestrationShellSnapshot["projects"][number], + environmentId: EnvironmentId, +): Project { + return { + id: project.id, + environmentId, + name: project.title, + cwd: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + scripts: mapProjectScripts(project.scripts), + }; +} + function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { return { id: thread.id, @@ -219,6 +241,61 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T }; } +function mapThreadShell( + thread: OrchestrationThreadShell, + environmentId: EnvironmentId, +): { + shell: ThreadShell; + session: ThreadSession | null; + turnState: ThreadTurnState; + summary: SidebarThreadSummary; +} { + const shell: ThreadShell = { + id: thread.id, + environmentId, + codexThreadId: null, + projectId: thread.projectId, + title: thread.title, + modelSelection: normalizeModelSelection(thread.modelSelection), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: sanitizeThreadErrorMessage(thread.session?.lastError), + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }; + const session = thread.session ? mapSession(thread.session) : null; + const turnState: ThreadTurnState = { + latestTurn: thread.latestTurn, + }; + const summary: SidebarThreadSummary = { + id: thread.id, + environmentId, + projectId: thread.projectId, + title: thread.title, + interactionMode: thread.interactionMode, + session, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestUserMessageAt: thread.latestUserMessageAt, + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasActionableProposedPlan: thread.hasActionableProposedPlan, + }; + return { + shell, + session, + turnState, + summary, + }; +} + function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, @@ -562,6 +639,120 @@ function writeThreadState( return nextState; } +function writeThreadShellState( + state: EnvironmentState, + nextThread: { + shell: ThreadShell; + session: ThreadSession | null; + turnState: ThreadTurnState; + summary: SidebarThreadSummary; + }, +): EnvironmentState { + let nextState = state; + const previousShell = state.threadShellById[nextThread.shell.id]; + const previousProjectId = previousShell?.projectId; + const nextProjectId = nextThread.shell.projectId; + + if (!state.threadIds.includes(nextThread.shell.id)) { + nextState = { + ...nextState, + threadIds: [...nextState.threadIds, nextThread.shell.id], + }; + } + + if (previousProjectId !== nextProjectId) { + let threadIdsByProjectId = nextState.threadIdsByProjectId; + if (previousProjectId) { + const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; + const nextIds = removeId(previousIds, nextThread.shell.id); + if (nextIds.length === 0) { + const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; + threadIdsByProjectId = rest as Record; + } else if (!arraysEqual(previousIds, nextIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [previousProjectId]: nextIds, + }; + } + } + + const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = appendId(projectThreadIds, nextThread.shell.id); + if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [nextProjectId]: nextProjectThreadIds, + }; + } + if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { + nextState = { + ...nextState, + threadIdsByProjectId, + }; + } + } + + if (!threadShellsEqual(previousShell, nextThread.shell)) { + nextState = { + ...nextState, + threadShellById: { + ...nextState.threadShellById, + [nextThread.shell.id]: nextThread.shell, + }, + }; + } + + if ((state.threadSessionById[nextThread.shell.id] ?? null) !== nextThread.session) { + nextState = { + ...nextState, + threadSessionById: { + ...nextState.threadSessionById, + [nextThread.shell.id]: nextThread.session, + }, + }; + } + + if ( + !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) + ) { + nextState = { + ...nextState, + threadTurnStateById: { + ...nextState.threadTurnStateById, + [nextThread.shell.id]: nextThread.turnState, + }, + }; + } + + if ( + !sidebarThreadSummariesEqual( + state.sidebarThreadSummaryById[nextThread.shell.id], + nextThread.summary, + ) + ) { + nextState = { + ...nextState, + sidebarThreadSummaryById: { + ...nextState.sidebarThreadSummaryById, + [nextThread.shell.id]: nextThread.summary, + }, + }; + } + + return nextState; +} + +function retainThreadScopedRecord( + record: Record, + nextThreadIds: ReadonlySet, +): Record { + return Object.fromEntries( + Object.entries(record).flatMap(([threadId, value]) => + nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], + ), + ) as Record; +} + function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { @@ -960,6 +1151,46 @@ function syncEnvironmentReadModel( }; } +function syncEnvironmentShellSnapshot( + state: EnvironmentState, + snapshot: OrchestrationShellSnapshot, + environmentId: EnvironmentId, +): EnvironmentState { + const nextProjects = snapshot.projects.map((project) => mapProjectShell(project, environmentId)); + const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); + let nextState: EnvironmentState = { + ...state, + ...buildProjectState(nextProjects), + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + sidebarThreadSummaryById: {}, + messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), + messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), + activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), + activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), + proposedPlanIdsByThreadId: retainThreadScopedRecord( + state.proposedPlanIdsByThreadId, + nextThreadIds, + ), + proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), + turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), + turnDiffSummaryByThreadId: retainThreadScopedRecord( + state.turnDiffSummaryByThreadId, + nextThreadIds, + ), + bootstrapComplete: true, + }; + + for (const thread of snapshot.threads) { + nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); + } + + return nextState; +} + export function syncServerReadModel( state: AppState, readModel: OrchestrationReadModel, @@ -976,6 +1207,36 @@ export function syncServerReadModel( ); } +export function syncServerShellSnapshot( + state: AppState, + snapshot: OrchestrationShellSnapshot, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + syncEnvironmentShellSnapshot( + getStoredEnvironmentState(state, environmentId), + snapshot, + environmentId, + ), + ); +} + +export function syncServerThreadDetail( + state: AppState, + thread: OrchestrationThread, + environmentId: EnvironmentId, +): AppState { + const environmentState = getStoredEnvironmentState(state, environmentId); + const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); + return commitEnvironmentState( + state, + environmentId, + writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), + ); +} + function applyEnvironmentOrchestrationEvent( state: EnvironmentState, event: OrchestrationEvent, @@ -1466,6 +1727,67 @@ function applyEnvironmentOrchestrationEvent( return state; } +function applyEnvironmentShellEvent( + state: EnvironmentState, + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, +): EnvironmentState { + switch (event.kind) { + case "project-upserted": { + const nextProject = mapProjectShell(event.project, environmentId); + const existingProjectId = + state.projectIds.find( + (projectId) => + projectId === event.project.id || + state.projectById[projectId]?.cwd === event.project.workspaceRoot, + ) ?? null; + let projectById = state.projectById; + let projectIds = state.projectIds; + + if (existingProjectId !== null && existingProjectId !== nextProject.id) { + const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; + projectById = { + ...restProjectById, + [nextProject.id]: nextProject, + }; + projectIds = state.projectIds.map((projectId) => + projectId === existingProjectId ? nextProject.id : projectId, + ); + } else { + projectById = { + ...state.projectById, + [nextProject.id]: nextProject, + }; + projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + } + + return { + ...state, + projectById, + projectIds, + }; + } + case "project-removed": { + if (!state.projectById[event.projectId]) { + return state; + } + const { [event.projectId]: _removedProject, ...projectById } = state.projectById; + return { + ...state, + projectById, + projectIds: removeId(state.projectIds, event.projectId), + }; + } + case "thread-upserted": + return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); + case "thread-removed": + return removeThreadState(state, event.threadId); + } +} + export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, @@ -1649,6 +1971,22 @@ export function applyOrchestrationEvent( ); } +export function applyShellEvent( + state: AppState, + event: OrchestrationShellStreamEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentShellEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} + export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { if (state.activeEnvironmentId === environmentId) { return state; @@ -1686,11 +2024,17 @@ export function setThreadBranch( interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + syncServerShellSnapshot: ( + snapshot: OrchestrationShellSnapshot, + environmentId: EnvironmentId, + ) => void; + syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; applyOrchestrationEvents: ( events: ReadonlyArray, environmentId: EnvironmentId, ) => void; + applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadRef: ScopedThreadRef, @@ -1705,10 +2049,16 @@ export const useStore = create((set) => ({ set((state) => setActiveEnvironmentId(state, environmentId)), syncServerReadModel: (readModel, environmentId) => set((state) => syncServerReadModel(state, readModel, environmentId)), + syncServerShellSnapshot: (snapshot, environmentId) => + set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), + syncServerThreadDetail: (thread, environmentId) => + set((state) => syncServerThreadDetail(state, thread, environmentId)), applyOrchestrationEvent: (event, environmentId) => set((state) => applyOrchestrationEvent(state, event, environmentId)), applyOrchestrationEvents: (events, environmentId) => set((state) => applyOrchestrationEvents(state, events, environmentId)), + applyShellEvent: (event, environmentId) => + set((state) => applyShellEvent(state, event, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadRef, branch, worktreePath) => set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 31e86938053..27a73f375cf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -48,6 +48,9 @@ import type { OrchestrationGetTurnDiffResult, OrchestrationEvent, OrchestrationReadModel, + OrchestrationShellStreamItem, + OrchestrationSubscribeThreadInput, + OrchestrationThreadStreamItem, } from "./orchestration"; import type { EnvironmentId } from "./baseSchemas"; import { EditorId } from "./editor"; @@ -256,6 +259,19 @@ export interface EnvironmentApi { input: OrchestrationGetFullThreadDiffInput, ) => Promise; replayEvents: (fromSequenceExclusive: number) => Promise; + subscribeShell: ( + callback: (event: OrchestrationShellStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + subscribeThread: ( + input: OrchestrationSubscribeThreadInput, + callback: (event: OrchestrationThreadStreamItem) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; onDomainEvent: ( callback: (event: OrchestrationEvent) => void, options?: { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c80367d1587..977f12197ca 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,8 @@ export const ORCHESTRATION_WS_METHODS = { getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", + subscribeShell: "orchestration.subscribeShell", + subscribeThread: "orchestration.subscribeThread", } as const; export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); @@ -308,6 +310,93 @@ export const OrchestrationReadModel = Schema.Struct({ }); export type OrchestrationReadModel = typeof OrchestrationReadModel.Type; +export const OrchestrationProjectShell = Schema.Struct({ + id: ProjectId, + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), + defaultModelSelection: Schema.NullOr(ModelSelection), + scripts: Schema.Array(ProjectScript), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; + +export const OrchestrationThreadShell = Schema.Struct({ + id: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + session: Schema.NullOr(OrchestrationSession), + latestUserMessageAt: Schema.NullOr(IsoDateTime), + hasPendingApprovals: Schema.Boolean, + hasPendingUserInput: Schema.Boolean, + hasActionableProposedPlan: Schema.Boolean, +}); +export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; + +export const OrchestrationShellSnapshot = Schema.Struct({ + snapshotSequence: NonNegativeInt, + projects: Schema.Array(OrchestrationProjectShell), + threads: Schema.Array(OrchestrationThreadShell), + updatedAt: IsoDateTime, +}); +export type OrchestrationShellSnapshot = typeof OrchestrationShellSnapshot.Type; + +export const OrchestrationShellStreamEvent = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("project-upserted"), + sequence: NonNegativeInt, + project: OrchestrationProjectShell, + }), + Schema.Struct({ + kind: Schema.Literal("project-removed"), + sequence: NonNegativeInt, + projectId: ProjectId, + }), + Schema.Struct({ + kind: Schema.Literal("thread-upserted"), + sequence: NonNegativeInt, + thread: OrchestrationThreadShell, + }), + Schema.Struct({ + kind: Schema.Literal("thread-removed"), + sequence: NonNegativeInt, + threadId: ThreadId, + }), +]); +export type OrchestrationShellStreamEvent = typeof OrchestrationShellStreamEvent.Type; + +export const OrchestrationShellStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("snapshot"), + snapshot: OrchestrationShellSnapshot, + }), + OrchestrationShellStreamEvent, +]); +export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type; + +export const OrchestrationSubscribeThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; + +export const OrchestrationThreadDetailSnapshot = Schema.Struct({ + snapshotSequence: NonNegativeInt, + thread: OrchestrationThread, +}); +export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; + export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, @@ -955,6 +1044,18 @@ export const OrchestrationEvent = Schema.Union([ ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type; +export const OrchestrationThreadStreamItem = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("snapshot"), + snapshot: OrchestrationThreadDetailSnapshot, + }), + Schema.Struct({ + kind: Schema.Literal("event"), + event: OrchestrationEvent, + }), +]); +export type OrchestrationThreadStreamItem = typeof OrchestrationThreadStreamItem.Type; + export const OrchestrationCommandReceiptStatus = Schema.Literals(["accepted", "rejected"]); export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandReceiptStatus.Type; @@ -1071,6 +1172,14 @@ export const OrchestrationRpcSchemas = { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, }, + subscribeThread: { + input: OrchestrationSubscribeThreadInput, + output: OrchestrationThreadStreamItem, + }, + subscribeShell: { + input: Schema.Struct({}), + output: OrchestrationShellStreamItem, + }, } as const; export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass()( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f47b427bcd6..57f79c33818 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -308,6 +308,23 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. error: OrchestrationReplayEventsError, }); +export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { + payload: OrchestrationRpcSchemas.subscribeShell.input, + success: OrchestrationRpcSchemas.subscribeShell.output, + error: OrchestrationGetSnapshotError, + stream: true, +}); + +export const WsOrchestrationSubscribeThreadRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.subscribeThread, + { + payload: OrchestrationRpcSchemas.subscribeThread.input, + success: OrchestrationRpcSchemas.subscribeThread.output, + error: OrchestrationGetSnapshotError, + stream: true, + }, +); + export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( WS_METHODS.subscribeOrchestrationDomainEvents, { @@ -379,4 +396,6 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationReplayEventsRpc, + WsOrchestrationSubscribeShellRpc, + WsOrchestrationSubscribeThreadRpc, ); From 5d58fee8123360fa971afef1a980b212b73d5b9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 02:32:04 +0000 Subject: [PATCH 2/9] fix: add pendingSourceProposedPlan to mapThreadShell and deduplicate mapProject - mapThreadShell now sets pendingSourceProposedPlan from thread.latestTurn?.sourceProposedPlan, matching the behavior of the full mapThread function. - Remove redundant mapProjectShell function; mapProject now accepts both OrchestrationProject and OrchestrationProjectShell via a union type. --- apps/web/src/store.ts | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 207107b0c87..092593b06d2 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -178,26 +178,9 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif } function mapProject( - project: OrchestrationReadModel["projects"][number], - environmentId: EnvironmentId, -): Project { - return { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), - }; -} - -function mapProjectShell( - project: OrchestrationShellSnapshot["projects"][number], + project: + | OrchestrationReadModel["projects"][number] + | OrchestrationShellSnapshot["projects"][number], environmentId: EnvironmentId, ): Project { return { @@ -269,6 +252,7 @@ function mapThreadShell( const session = thread.session ? mapSession(thread.session) : null; const turnState: ThreadTurnState = { latestTurn: thread.latestTurn, + pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, }; const summary: SidebarThreadSummary = { id: thread.id, @@ -1156,7 +1140,7 @@ function syncEnvironmentShellSnapshot( snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId, ): EnvironmentState { - const nextProjects = snapshot.projects.map((project) => mapProjectShell(project, environmentId)); + const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); let nextState: EnvironmentState = { ...state, @@ -1734,7 +1718,7 @@ function applyEnvironmentShellEvent( ): EnvironmentState { switch (event.kind) { case "project-upserted": { - const nextProject = mapProjectShell(event.project, environmentId); + const nextProject = mapProject(event.project, environmentId); const existingProjectId = state.projectIds.find( (projectId) => From 44e2b020fe80e7a80dbcafa373763410f84fc518 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 19:34:00 -0700 Subject: [PATCH 3/9] Cache auth gate and retain thread subscriptions - Memoize authenticated server auth bootstrap results - Keep thread detail subscriptions warm across route churn - Move auth gate checks onto route context --- .../Layers/CheckpointDiffQuery.test.ts | 8 +- apps/web/src/authBootstrap.test.ts | 12 +- apps/web/src/components/ChatView.tsx | 14 +- apps/web/src/environments/primary/auth.ts | 24 ++- .../service.threadSubscriptions.test.ts | 151 ++++++++++++++++ apps/web/src/environments/runtime/service.ts | 164 ++++++++++++++++++ apps/web/src/routes/_chat.tsx | 12 +- apps/web/src/routes/pair.tsx | 11 +- apps/web/src/routes/settings.tsx | 12 +- 9 files changed, 347 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/environments/runtime/service.threadSubscriptions.test.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index b99dd848f4b..e6c53dd06b0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -83,9 +83,7 @@ describe("CheckpointDiffQueryLive", () => { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => - Effect.die( - "CheckpointDiffQuery should not request the orchestration shell snapshot", - ), + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), @@ -144,9 +142,7 @@ describe("CheckpointDiffQueryLive", () => { getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => - Effect.die( - "CheckpointDiffQuery should not request the orchestration shell snapshot", - ), + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index f86d7c5a792..b06c4248fca 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -424,7 +424,7 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); }); - it("revalidates the server session state after a previous authenticated result", async () => { + it("memoizes the authenticated gate state after the first successful read", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -459,15 +459,9 @@ describe("resolveInitialServerAuthGateState", () => { status: "authenticated", }); await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie"], - sessionCookieName: "t3_session", - }, + status: "authenticated", }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it("creates a pairing credential from the authenticated auth endpoint", async () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bd8c554f97a..ce55738764e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,7 +168,7 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { applyEnvironmentThreadDetailEvent } from "../environments/runtime/service"; +import { retainThreadDetailSubscription } from "../environments/runtime/service"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -848,17 +848,7 @@ export default function ChatView(props: ChatViewProps) { if (routeKind !== "server") { return; } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } - return api.orchestration.subscribeThread({ threadId }, (item) => { - if (item.kind === "snapshot") { - useStore.getState().syncServerThreadDetail(item.snapshot.thread, environmentId); - return; - } - applyEnvironmentThreadDetailEvent(item.event, environmentId); - }); + return retainThreadDetailSubscription(environmentId, threadId); }, [environmentId, routeKind, threadId]); // Compute the list of environments this logical project spans, used to diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index ae2d6bd64dc..42eaa821cf9 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -57,6 +57,7 @@ type ServerAuthGateState = }; let bootstrapPromise: Promise | null = null; +let resolvedAuthenticatedGateState: ServerAuthGateState | null = null; const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; const AUTH_SESSION_ESTABLISH_STEP_MS = 100; @@ -224,6 +225,7 @@ export async function submitServerAuthCredential(credential: string): Promise { } export async function resolveInitialServerAuthGateState(): Promise { + if (resolvedAuthenticatedGateState?.status === "authenticated") { + return resolvedAuthenticatedGateState; + } + if (bootstrapPromise) { return bootstrapPromise; } const nextPromise = bootstrapServerAuth(); bootstrapPromise = nextPromise; - return nextPromise.finally(() => { - if (bootstrapPromise === nextPromise) { - bootstrapPromise = null; - } - }); + return nextPromise + .then((result) => { + if (result.status === "authenticated") { + resolvedAuthenticatedGateState = result; + } + return result; + }) + .finally(() => { + if (bootstrapPromise === nextPromise) { + bootstrapPromise = null; + } + }); } export function __resetServerAuthBootstrapForTests() { bootstrapPromise = null; + resolvedAuthenticatedGateState = null; } diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts new file mode 100644 index 00000000000..a5ebc36ca53 --- /dev/null +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -0,0 +1,151 @@ +import { QueryClient } from "@tanstack/react-query"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSubscribeThread = vi.fn(); +const mockThreadUnsubscribe = vi.fn(); +const mockCreateEnvironmentConnection = vi.fn(); +const mockCreateWsRpcClient = vi.fn(); +const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); +const mockListSavedEnvironmentRecords = vi.fn(); +const mockSavedEnvironmentRegistrySubscribe = vi.fn(); + +function MockWsTransport() { + return undefined; +} + +vi.mock("../primary", () => ({ + getPrimaryKnownEnvironment: vi.fn(() => ({ + id: "env-1", + label: "Primary environment", + source: "window-origin", + target: { + httpBaseUrl: "http://127.0.0.1:3000/", + wsBaseUrl: "ws://127.0.0.1:3000/", + }, + environmentId: EnvironmentId.make("env-1"), + })), +})); + +vi.mock("./catalog", () => ({ + getSavedEnvironmentRecord: vi.fn(), + hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), + listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, + persistSavedEnvironmentRecord: vi.fn(), + readSavedEnvironmentBearerToken: vi.fn(), + removeSavedEnvironmentBearerToken: vi.fn(), + useSavedEnvironmentRegistryStore: { + subscribe: mockSavedEnvironmentRegistrySubscribe, + getState: () => ({ + upsert: vi.fn(), + remove: vi.fn(), + markConnected: vi.fn(), + }), + }, + useSavedEnvironmentRuntimeStore: { + getState: () => ({ + ensure: vi.fn(), + patch: vi.fn(), + clear: vi.fn(), + }), + }, + waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, + writeSavedEnvironmentBearerToken: vi.fn(), +})); + +vi.mock("./connection", () => ({ + createEnvironmentConnection: mockCreateEnvironmentConnection, +})); + +vi.mock("../../rpc/wsRpcClient", () => ({ + createWsRpcClient: mockCreateWsRpcClient, +})); + +vi.mock("../../rpc/wsTransport", () => ({ + WsTransport: MockWsTransport, +})); + +describe("retainThreadDetailSubscription", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + + mockThreadUnsubscribe.mockImplementation(() => undefined); + mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); + mockCreateWsRpcClient.mockReturnValue({ + orchestration: { + subscribeThread: mockSubscribeThread, + }, + }); + mockCreateEnvironmentConnection.mockImplementation((input) => ({ + kind: input.kind, + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + })); + mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); + mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); + mockListSavedEnvironmentRecords.mockReturnValue([]); + }); + + afterEach(async () => { + const { resetEnvironmentServiceForTests } = await import("./service"); + await resetEnvironmentServiceForTests(); + vi.useRealTimers(); + }); + + it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + const threadId = ThreadId.make("thread-1"); + + const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); + + releaseFirst(); + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); + + releaseSecond(); + await vi.advanceTimersByTimeAsync(2 * 60 * 1000 - 1); + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); + + stop(); + await resetEnvironmentServiceForTests(); + }); + + it("disposes cached thread detail subscriptions when the environment service resets", async () => { + const { + retainThreadDetailSubscription, + startEnvironmentConnectionService, + resetEnvironmentServiceForTests, + } = await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + const environmentId = EnvironmentId.make("env-1"); + const threadId = ThreadId.make("thread-2"); + + const release = retainThreadDetailSubscription(environmentId, threadId); + release(); + + await resetEnvironmentServiceForTests(); + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); + + stop(); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 112d89608fd..00a684997c8 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -69,12 +69,167 @@ type EnvironmentServiceState = { stop: () => void; }; +type ThreadDetailSubscriptionEntry = { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly unsubscribe: () => void; + refCount: number; + lastAccessedAt: number; + evictionTimeoutId: ReturnType | null; +}; + const environmentConnections = new Map(); const environmentConnectionListeners = new Set<() => void>(); +const threadDetailSubscriptions = new Map(); let activeService: EnvironmentServiceState | null = null; let needsProviderInvalidation = false; +const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000; +const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 8; + +function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); +} + +function clearThreadDetailSubscriptionEviction( + entry: ThreadDetailSubscriptionEntry, +): ThreadDetailSubscriptionEntry { + if (entry.evictionTimeoutId !== null) { + clearTimeout(entry.evictionTimeoutId); + entry.evictionTimeoutId = null; + } + return entry; +} + +function disposeThreadDetailSubscriptionByKey(key: string): boolean { + const entry = threadDetailSubscriptions.get(key); + if (!entry) { + return false; + } + + clearThreadDetailSubscriptionEviction(entry); + threadDetailSubscriptions.delete(key); + entry.unsubscribe(); + return true; +} + +function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { + for (const [key, entry] of threadDetailSubscriptions) { + if (entry.environmentId === environmentId) { + disposeThreadDetailSubscriptionByKey(key); + } + } +} + +function reconcileThreadDetailSubscriptionsForEnvironment( + environmentId: EnvironmentId, + threadIds: ReadonlyArray, +): void { + const activeThreadIds = new Set(threadIds); + for (const [key, entry] of threadDetailSubscriptions) { + if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { + disposeThreadDetailSubscriptionByKey(key); + } + } +} + +function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { + clearThreadDetailSubscriptionEviction(entry); + entry.evictionTimeoutId = setTimeout(() => { + const currentEntry = threadDetailSubscriptions.get( + getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), + ); + if (!currentEntry || currentEntry.refCount > 0) { + return; + } + disposeThreadDetailSubscriptionByKey( + getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), + ); + }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); +} + +function evictIdleThreadDetailSubscriptionsToCapacity(): void { + if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { + return; + } + + const idleEntries = [...threadDetailSubscriptions.entries()] + .filter(([, entry]) => entry.refCount === 0) + .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); + + for (const [key] of idleEntries) { + if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { + return; + } + disposeThreadDetailSubscriptionByKey(key); + } +} + +export function retainThreadDetailSubscription( + environmentId: EnvironmentId, + threadId: ThreadId, +): () => void { + const key = getThreadDetailSubscriptionKey(environmentId, threadId); + const existing = threadDetailSubscriptions.get(key); + if (existing) { + clearThreadDetailSubscriptionEviction(existing); + existing.refCount += 1; + existing.lastAccessedAt = Date.now(); + let released = false; + return () => { + if (released) { + return; + } + released = true; + existing.refCount = Math.max(0, existing.refCount - 1); + existing.lastAccessedAt = Date.now(); + if (existing.refCount === 0) { + scheduleThreadDetailSubscriptionEviction(existing); + evictIdleThreadDetailSubscriptionsToCapacity(); + } + }; + } + + const connection = readEnvironmentConnection(environmentId); + if (!connection) { + return () => undefined; + } + + const unsubscribe = connection.client.orchestration.subscribeThread({ threadId }, (item) => { + if (item.kind === "snapshot") { + useStore.getState().syncServerThreadDetail(item.snapshot.thread, environmentId); + return; + } + applyEnvironmentThreadDetailEvent(item.event, environmentId); + }); + + const entry: ThreadDetailSubscriptionEntry = { + environmentId, + threadId, + unsubscribe, + refCount: 1, + lastAccessedAt: Date.now(), + evictionTimeoutId: null, + }; + threadDetailSubscriptions.set(key, entry); + evictIdleThreadDetailSubscriptionsToCapacity(); + + let released = false; + return () => { + if (released) { + return; + } + released = true; + entry.refCount = Math.max(0, entry.refCount - 1); + entry.lastAccessedAt = Date.now(); + if (entry.refCount === 0) { + scheduleThreadDetailSubscriptionEviction(entry); + evictIdleThreadDetailSubscriptionsToCapacity(); + } + }; +} + function emitEnvironmentConnectionRegistryChange() { for (const listener of environmentConnectionListeners) { listener(); @@ -316,6 +471,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En return; case "thread-removed": if (threadRef) { + disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); useComposerDraftStore.getState().clearDraftThread(threadRef); useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); useTerminalStateStore.getState().removeTerminalState(threadRef); @@ -330,6 +486,10 @@ function createEnvironmentConnectionHandlers() { applyShellEvent, syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { useStore.getState().syncServerShellSnapshot(snapshot, environmentId); + reconcileThreadDetailSubscriptionsForEnvironment( + environmentId, + snapshot.threads.map((thread) => thread.id), + ); reconcileSnapshotDerivedState(); }, applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { @@ -438,6 +598,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise return false; } + disposeThreadDetailSubscriptionsForEnvironment(environmentId); environmentConnections.delete(environmentId); emitEnvironmentConnectionRegistryChange(); await connection.dispose(); @@ -766,6 +927,9 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); + for (const key of Array.from(threadDetailSubscriptions.keys())) { + disposeThreadDetailSubscriptionByKey(key); + } await Promise.all( [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), ); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index b72e504fae9..fb8191f4480 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,10 +2,6 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; import { useCommandPaletteStore } from "../commandPaletteStore"; -import { - ensurePrimaryEnvironmentReady, - resolveInitialServerAuthGateState, -} from "../environments/primary"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewLocalThreadFromContext, @@ -111,12 +107,8 @@ function ChatRouteLayout() { } export const Route = createFileRoute("/_chat")({ - beforeLoad: async () => { - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); - if (authGateState.status !== "authenticated") { + beforeLoad: async ({ context }) => { + if (context.authGateState.status !== "authenticated") { throw redirect({ to: "/pair", replace: true }); } }, diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 053816bd773..6925dac69cc 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,17 +1,10 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; -import { - ensurePrimaryEnvironmentReady, - resolveInitialServerAuthGateState, -} from "../environments/primary"; export const Route = createFileRoute("/pair")({ - beforeLoad: async () => { - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); + beforeLoad: async ({ context }) => { + const { authGateState } = context; if (authGateState.status === "authenticated") { throw redirect({ to: "/", replace: true }); } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 543b182c374..107eb47093a 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -2,10 +2,6 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { - ensurePrimaryEnvironmentReady, - resolveInitialServerAuthGateState, -} from "../environments/primary"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; @@ -87,12 +83,8 @@ function SettingsRouteLayout() { } export const Route = createFileRoute("/settings")({ - beforeLoad: async ({ location }) => { - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); - if (authGateState.status !== "authenticated") { + beforeLoad: async ({ context, location }) => { + if (context.authGateState.status !== "authenticated") { throw redirect({ to: "/pair", replace: true }); } From a1eace81a430ba2d691858b29b92f10ac06ae401 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 19:44:15 -0700 Subject: [PATCH 4/9] Sort thread activities by sequence in snapshots - Keep thread detail ordering aligned with shell snapshot queries - Prefer sequenced activities before unsequenced ones - Add regression coverage for snapshot ordering --- .../Layers/ProjectionSnapshotQuery.test.ts | 162 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 6 +- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5658e99355f..09a1b6a4ced 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -716,4 +716,166 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } }), ); + + it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-01T00:00:00.000Z', + '2026-04-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-01T00:00:02.000Z', + '2026-04-01T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-unsequenced', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'unsequenced first', + '{"source":"unsequenced"}', + NULL, + '2026-04-01T00:00:06.000Z' + ), + ( + 'activity-sequence-2', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence two', + '{"source":"sequence-2"}', + 2, + '2026-04-01T00:00:04.000Z' + ), + ( + 'activity-sequence-1', + 'thread-1', + NULL, + 'info', + 'runtime.note', + 'sequence one', + '{"source":"sequence-1"}', + 1, + '2026-04-01T00:00:05.000Z' + ) + `; + + const snapshot = yield* snapshotQuery.getSnapshot(); + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); + } + + assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ + { + id: asEventId("activity-unsequenced"), + tone: "info", + kind: "runtime.note", + summary: "unsequenced first", + payload: { source: "unsequenced" }, + turnId: null, + createdAt: "2026-04-01T00:00:06.000Z", + }, + { + id: asEventId("activity-sequence-1"), + tone: "info", + kind: "runtime.note", + summary: "sequence one", + payload: { source: "sequence-1" }, + turnId: null, + sequence: 1, + createdAt: "2026-04-01T00:00:05.000Z", + }, + { + id: asEventId("activity-sequence-2"), + tone: "info", + kind: "runtime.note", + summary: "sequence two", + payload: { source: "sequence-2" }, + turnId: null, + sequence: 2, + createdAt: "2026-04-01T00:00:04.000Z", + }, + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2cdacb67c26..fb1b70d327b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -591,7 +591,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt" FROM projection_thread_activities WHERE thread_id = ${threadId} - ORDER BY created_at ASC, activity_id ASC + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC `, }); From 930ae14350b9745ac6916c71b5b972f8627d046e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 22:08:26 -0700 Subject: [PATCH 5/9] Sync shell snapshots and thread subscriptions - Switch web tests and runtime thread detail flow to shell snapshot subscriptions - Improve reconnect handling for thread detail subscriptions - Loosen long-message parity checks for full-app layout --- apps/web/src/components/ChatView.browser.tsx | 203 ++++++++++++------ .../components/KeybindingsToast.browser.tsx | 58 +++++ apps/web/src/environments/runtime/service.ts | 69 ++++-- apps/web/test/wsRpcHarness.ts | 4 +- 4 files changed, 253 insertions(+), 81 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a65f5755f9a..3e7adfa65bc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -6,7 +6,6 @@ import { ORCHESTRATION_WS_METHODS, EnvironmentId, type MessageId, - type OrchestrationEvent, type OrchestrationReadModel, type ProjectId, type ServerConfig, @@ -124,6 +123,18 @@ const ATTACHMENT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; +const FULL_APP_LONG_MESSAGE_ESTIMATE_TOLERANCE_RATIO = 0.25; + +function resolveFullAppLongMessageTolerancePx( + viewportTolerancePx: number, + estimatedHeightPx: number, +): number { + return Math.max( + viewportTolerancePx, + Math.ceil(estimatedHeightPx * FULL_APP_LONG_MESSAGE_ESTIMATE_TOLERANCE_RATIO), + ); +} + interface UserRowMeasurement { measuredRowHeightPx: number; timelineWidthMeasuredPx: number; @@ -404,74 +415,93 @@ function addThreadToSnapshot( }; } -function createThreadCreatedEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellThread(thread: OrchestrationReadModel["threads"][number]) { return { - sequence, - eventId: EventId.make(`event-thread-created-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.created", - payload: { - threadId, - projectId: PROJECT_ID, - title: "New thread", - modelSelection: { - provider: "codex", - model: "gpt-5", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, }; } -function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { +function toShellSnapshot(snapshot: OrchestrationReadModel) { return { - sequence, - eventId: EventId.make(`event-thread-session-set-${sequence}`), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: NOW_ISO, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.session-set", - payload: { - threadId, - session: { - threadId, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: `turn-${threadId}` as TurnId, - lastError: null, - updatedAt: NOW_ISO, - }, - }, + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map(toShellThread), + updatedAt: snapshot.updatedAt, }; } -function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { - rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); +function updateThreadSessionInSnapshot( + snapshot: OrchestrationReadModel, + threadId: ThreadId, + session: OrchestrationReadModel["threads"][number]["session"], +): OrchestrationReadModel { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => + thread.id === threadId + ? { + ...thread, + session, + updatedAt: NOW_ISO, + } + : thread, + ), + }; +} + +function sendShellThreadUpsert( + threadId: ThreadId, + options?: { + readonly session?: OrchestrationReadModel["threads"][number]["session"]; + }, +): void { + const thread = fixture.snapshot.threads.find((entry) => entry.id === threadId); + if (!thread) { + throw new Error(`Expected thread ${threadId} in snapshot.`); + } + + const shellThread = + options?.session !== undefined + ? toShellThread({ ...thread, session: options.session }) + : toShellThread(thread); + rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { + kind: "thread-upserted", + sequence: fixture.snapshot.snapshotSequence, + thread: shellThread, + }); } async function waitForWsClient(): Promise { await vi.waitFor( () => { expect( - wsRequests.some( - (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, - ), + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), ).toBe(true); expect( wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), @@ -531,15 +561,21 @@ async function waitForAppBootstrap(): Promise { async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); - sendOrchestrationDomainEvent( - createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, null); + sendShellThreadUpsert(threadId, { session: null }); } async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { - sendOrchestrationDomainEvent( - createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), - ); + fixture.snapshot = updateThreadSessionInSnapshot(fixture.snapshot, threadId, { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }); + sendShellThreadUpsert(threadId); } async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { @@ -1405,6 +1441,10 @@ async function measureUserRow(options: { }): Promise { const { host, targetMessageId } = options; const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; + const targetMessageText = + fixture.snapshot.threads + .flatMap((thread) => thread.messages) + .find((message) => message.id === targetMessageId)?.text ?? null; const scrollContainer = await waitForElement( () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), @@ -1419,6 +1459,9 @@ async function measureUserRow(options: { await waitForLayout(); row = host.querySelector(rowSelector); expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + if (targetMessageText !== null) { + expect(row?.textContent?.includes(targetMessageText)).toBe(true); + } }, { timeout: 8_000, @@ -1445,7 +1488,7 @@ async function measureUserRow(options: { async () => { scrollContainer.scrollTop = 0; scrollContainer.dispatchEvent(new Event("scroll")); - await nextFrame(); + await waitForLayout(); const measuredRow = host.querySelector(rowSelector); expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; @@ -1591,6 +1634,28 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ]; } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), + }, + ]; + } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeThread) { + const thread = fixture.snapshot.threads.find((entry) => entry.id === request.threadId); + return thread + ? [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread, + }, + }, + ] + : []; + } return []; }, }); @@ -1657,8 +1722,16 @@ describe("ChatView timeline estimator parity (full app)", () => { { timelineWidthPx: timelineWidthMeasuredPx }, ); - expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, + expect( + Math.abs(measuredRowHeightPx - estimatedHeightPx), + JSON.stringify({ + viewport: viewport.name, + measuredRowHeightPx, + estimatedHeightPx, + timelineWidthMeasuredPx, + }), + ).toBeLessThanOrEqual( + resolveFullAppLongMessageTolerancePx(viewport.textTolerancePx, estimatedHeightPx), ); } finally { await mounted.cleanup(); @@ -1721,7 +1794,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(measurement.renderedInVirtualizedRegion).toBe(true); expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( - viewport.textTolerancePx, + resolveFullAppLongMessageTolerancePx(viewport.textTolerancePx, estimatedHeightPx), ); measurements.push({ ...measurement, viewport, estimatedHeightPx }); } diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 223f5d8ebdc..28916bb3f9f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -169,6 +169,42 @@ function createMinimalSnapshot(): OrchestrationReadModel { }; } +function toShellSnapshot(snapshot: OrchestrationReadModel) { + return { + snapshotSequence: snapshot.snapshotSequence, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })), + threads: snapshot.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: thread.archivedAt, + session: thread.session, + latestUserMessageAt: + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + })), + updatedAt: snapshot.updatedAt, + }; +} + function buildFixture(): TestFixture { return { snapshot: createMinimalSnapshot(), @@ -429,6 +465,28 @@ describe("Keybindings update toast", () => { }, ]; } + if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + return [ + { + kind: "snapshot", + snapshot: toShellSnapshot(fixture.snapshot), + }, + ]; + } + if ( + request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && + request.threadId === THREAD_ID + ) { + return [ + { + kind: "snapshot", + snapshot: { + snapshotSequence: fixture.snapshot.snapshotSequence, + thread: fixture.snapshot.threads[0], + }, + }, + ]; + } return []; }, }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 00a684997c8..0e72607d9af 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -72,7 +72,8 @@ type EnvironmentServiceState = { type ThreadDetailSubscriptionEntry = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; - readonly unsubscribe: () => void; + unsubscribe: () => void; + unsubscribeConnectionListener: (() => void) | null; refCount: number; lastAccessedAt: number; evictionTimeoutId: ReturnType | null; @@ -87,6 +88,7 @@ let needsProviderInvalidation = false; const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000; const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 8; +const NOOP = () => undefined; function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { return scopedThreadKey(scopeThreadRef(environmentId, threadId)); @@ -102,6 +104,46 @@ function clearThreadDetailSubscriptionEviction( return entry; } +function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { + if (entry.unsubscribeConnectionListener !== null) { + entry.unsubscribeConnectionListener(); + entry.unsubscribeConnectionListener = null; + } + if (entry.unsubscribe !== NOOP) { + return true; + } + + const connection = readEnvironmentConnection(entry.environmentId); + if (!connection) { + return false; + } + + entry.unsubscribe = connection.client.orchestration.subscribeThread( + { threadId: entry.threadId }, + (item) => { + if (item.kind === "snapshot") { + useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); + return; + } + applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); + }, + ); + return true; +} + +function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { + if (entry.unsubscribeConnectionListener !== null) { + return; + } + + entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { + if (attachThreadDetailSubscription(entry)) { + entry.lastAccessedAt = Date.now(); + } + }); + attachThreadDetailSubscription(entry); +} + function disposeThreadDetailSubscriptionByKey(key: string): boolean { const entry = threadDetailSubscriptions.get(key); if (!entry) { @@ -109,8 +151,11 @@ function disposeThreadDetailSubscriptionByKey(key: string): boolean { } clearThreadDetailSubscriptionEviction(entry); + entry.unsubscribeConnectionListener?.(); + entry.unsubscribeConnectionListener = null; threadDetailSubscriptions.delete(key); entry.unsubscribe(); + entry.unsubscribe = NOOP; return true; } @@ -176,6 +221,9 @@ export function retainThreadDetailSubscription( clearThreadDetailSubscriptionEviction(existing); existing.refCount += 1; existing.lastAccessedAt = Date.now(); + if (!attachThreadDetailSubscription(existing)) { + watchThreadDetailSubscriptionConnection(existing); + } let released = false; return () => { if (released) { @@ -191,28 +239,19 @@ export function retainThreadDetailSubscription( }; } - const connection = readEnvironmentConnection(environmentId); - if (!connection) { - return () => undefined; - } - - const unsubscribe = connection.client.orchestration.subscribeThread({ threadId }, (item) => { - if (item.kind === "snapshot") { - useStore.getState().syncServerThreadDetail(item.snapshot.thread, environmentId); - return; - } - applyEnvironmentThreadDetailEvent(item.event, environmentId); - }); - const entry: ThreadDetailSubscriptionEntry = { environmentId, threadId, - unsubscribe, + unsubscribe: NOOP, + unsubscribeConnectionListener: null, refCount: 1, lastAccessedAt: Date.now(), evictionTimeoutId: null, }; threadDetailSubscriptions.set(key, entry); + if (!attachThreadDetailSubscription(entry)) { + watchThreadDetailSubscriptionConnection(entry); + } evictIdleThreadDetailSubscriptionsToCapacity(); let released = false; diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index f5d86a50194..500134933b1 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -1,5 +1,5 @@ import { Effect, Exit, PubSub, Scope, Stream } from "effect"; -import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; +import { ORCHESTRATION_WS_METHODS, WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; type RpcServerInstance = RpcServer.RpcServer; @@ -23,6 +23,8 @@ interface BrowserWsRpcHarnessOptions { } const STREAM_METHODS = new Set([ + ORCHESTRATION_WS_METHODS.subscribeShell, + ORCHESTRATION_WS_METHODS.subscribeThread, WS_METHODS.gitRunStackedAction, WS_METHODS.subscribeGitStatus, WS_METHODS.subscribeOrchestrationDomainEvents, From 017e05c761bd407c7922172e0ce773bee57f6e97 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 22:29:23 -0700 Subject: [PATCH 6/9] Remove orchestration snapshot and domain-event RPCs - Drop snapshot fetch and replay/subscription plumbing from server and web clients - Update contracts and tests for the new granular orchestration sync flow --- apps/server/src/server.test.ts | 234 ------------------ apps/server/src/ws.ts | 116 ++------- apps/web/src/components/ChatView.browser.tsx | 3 - .../components/KeybindingsToast.browser.tsx | 3 - apps/web/src/environmentApi.ts | 7 - .../environments/runtime/connection.test.ts | 3 - apps/web/src/lib/gitStatusState.test.ts | 5 +- apps/web/src/localApi.test.ts | 50 ++-- apps/web/src/rpc/wsRpcClient.ts | 15 -- apps/web/test/wsRpcHarness.ts | 1 - packages/contracts/src/ipc.ts | 10 - packages/contracts/src/orchestration.ts | 10 - packages/contracts/src/rpc.ts | 20 -- 13 files changed, 42 insertions(+), 435 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e94612c1a4a..c1188495dcc 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -69,7 +69,6 @@ import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { PersistenceSqlError } from "./persistence/Errors.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { ProviderRegistry, @@ -2763,11 +2762,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); - const snapshotResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), - ); - assert.equal(snapshotResult.snapshotSequence, 1); - const dispatchResult = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ @@ -3321,234 +3315,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect( - "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", - () => - Effect.gen(function* () { - const now = new Date().toISOString(); - const threadId = ThreadId.make("thread-1"); - let replayCursor: number | null = null; - const makeEvent = (sequence: number): OrchestrationEvent => - ({ - sequence, - eventId: `event-${sequence}`, - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "thread.reverted", - payload: { - threadId, - turnCount: sequence, - }, - }) as OrchestrationEvent; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 1, - }), - readEvents: (fromSequenceExclusive) => { - replayCursor = fromSequenceExclusive; - return Stream.make(makeEvent(2), makeEvent(3)); - }, - streamDomainEvents: Stream.make(makeEvent(3), makeEvent(4)), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(3), - Stream.runCollect, - ), - ), - ); - - assert.equal(replayCursor, 1); - assert.deepEqual( - Array.from(events).map((event) => event.sequence), - [2, 3, 4], - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches replayed project events only once before streaming them to subscribers", () => - Effect.gen(function* () { - let resolveCalls = 0; - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - displayName: "t3tools/t3code", - provider: "github" as const, - owner: "t3tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - readEvents: () => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-06T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Replayed Project", - updatedAt: "2026-04-06T00:00:00.000Z", - }, - } satisfies Extract), - streamDomainEvents: Stream.empty, - }, - repositoryIdentityResolver: { - resolve: () => { - resolveCalls += 1; - return Effect.succeed(repositoryIdentity); - }, - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(resolveCalls, 1); - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("enriches subscribed project meta updates with repository identity metadata", () => - Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "upstream", - remoteUrl: "git@github.com:T3Tools/t3code.git", - }, - displayName: "T3Tools/t3code", - provider: "github", - owner: "T3Tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - getReadModel: () => - Effect.succeed({ - ...makeDefaultOrchestrationReadModel(), - snapshotSequence: 0, - }), - streamDomainEvents: Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.meta-updated", - payload: { - projectId: defaultProjectId, - title: "Renamed Project", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), - }, - repositoryIdentityResolver: { - resolve: () => Effect.succeed(repositoryIdentity), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( - Stream.take(1), - Stream.runCollect, - ), - ), - ); - - const event = Array.from(events)[0]; - assert.equal(event?.type, "project.meta-updated"); - assert.deepEqual( - event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("routes websocket rpc orchestration.getSnapshot errors", () => - Effect.gen(function* () { - yield* buildAppUnderTest({ - layers: { - projectionSnapshotQuery: { - getSnapshot: () => - Effect.fail( - new PersistenceSqlError({ - operation: "ProjectionSnapshotQuery.getSnapshot", - detail: "projection unavailable", - }), - ), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})).pipe( - Effect.result, - ), - ); - - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "OrchestrationGetSnapshotError"); - assertInclude(result.failure.message, "Failed to load orchestration snapshot"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc terminal methods", () => Effect.gen(function* () { const snapshot = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0667cf256c8..aa37b46b12d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -63,6 +63,26 @@ import { } from "./auth/Services/SessionCredentialService"; import { respondToAuthError } from "./auth/http"; +function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< + OrchestrationEvent, + { + type: + | "thread.message-sent" + | "thread.proposed-plan-upserted" + | "thread.activity-appended" + | "thread.turn-diff-completed" + | "thread.reverted"; + } +> { + return ( + event.type === "thread.message-sent" || + event.type === "thread.proposed-plan-upserted" || + event.type === "thread.activity-appended" || + event.type === "thread.turn-diff-completed" || + event.type === "thread.reverted" + ); +} + function toAuthAccessStreamEvent( change: BootstrapCredentialChange | SessionCredentialChange, revision: number, @@ -274,25 +294,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } }; - const isThreadDetailEvent = ( - event: OrchestrationEvent, - ): event is Extract< - OrchestrationEvent, - { - type: - | "thread.message-sent" - | "thread.proposed-plan-upserted" - | "thread.activity-appended" - | "thread.turn-diff-completed" - | "thread.reverted"; - } - > => - event.type === "thread.message-sent" || - event.type === "thread.proposed-plan-upserted" || - event.type === "thread.activity-appended" || - event.type === "thread.turn-diff-completed" || - event.type === "thread.reverted"; - const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -538,20 +539,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getSnapshot, - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, @@ -715,69 +702,6 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, - Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.catch(() => Effect.succeed([] as Array)), - ); - const replayStream = Stream.fromIterable(replayEvents); - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(enrichProjectEvent), - ); - const source = Stream.merge(replayStream, liveStream); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); - - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } - - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); - - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; - } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } - - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, - ), - ), - Stream.flatMap((events) => Stream.fromIterable(events)), - ); - }), - { "rpc.aggregate": "orchestration" }, - ), [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3e7adfa65bc..16d3c2de44a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -926,9 +926,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { return customResult; } const tag = body._tag; - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 28916bb3f9f..21e491ff62f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -226,9 +226,6 @@ function buildFixture(): TestFixture { } function resolveWsRpc(tag: string): unknown { - if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { - return fixture.snapshot; - } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 82da9eae81c..06fce4ea617 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -32,7 +32,6 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { preparePullRequestThread: rpcClient.git.preparePullRequestThread, }, orchestration: { - getSnapshot: rpcClient.orchestration.getSnapshot, dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, @@ -40,12 +39,6 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { rpcClient.orchestration.subscribeShell(callback, options), subscribeThread: (input, callback, options) => rpcClient.orchestration.subscribeThread(input, callback, options), - replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) - .then((events) => [...events]), - onDomainEvent: (callback, options) => - rpcClient.orchestration.onDomainEvent(callback, options), }, }; } diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 5f8576cbf35..57f23241456 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -37,11 +37,9 @@ function createTestClient() { updateSettings: vi.fn(async () => undefined), }, orchestration: { - getSnapshot: vi.fn(async () => undefined), dispatchCommand: vi.fn(async () => undefined), getTurnDiff: vi.fn(async () => undefined), getFullThreadDiff: vi.fn(async () => undefined), - replayEvents: vi.fn(async () => []), subscribeShell: vi.fn( (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { shellListeners.add(listener); @@ -66,7 +64,6 @@ function createTestClient() { }, ), subscribeThread: vi.fn(() => () => undefined), - onDomainEvent: vi.fn(() => () => undefined), }, terminal: { open: vi.fn(async () => undefined), diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index b5317d9ec11..2e17cd15521 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -123,12 +123,11 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { subscribeAuthAccess: vi.fn(() => () => undefined), }, orchestration: { - getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any), dispatchCommand: vi.fn(async () => undefined), getTurnDiff: vi.fn(async () => undefined), getFullThreadDiff: vi.fn(async () => undefined), - replayEvents: vi.fn(async () => []), - onDomainEvent: vi.fn(() => () => undefined), + subscribeShell: vi.fn(() => () => undefined), + subscribeThread: vi.fn(() => () => undefined), }, } as unknown as WsRpcClient; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 3883d77c8dc..0e169efb215 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -3,10 +3,9 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EnvironmentId, - EventId, type GitStatusResult, ProjectId, - type OrchestrationEvent, + type OrchestrationShellStreamItem, type ServerConfig, type ServerProvider, type TerminalEvent, @@ -32,7 +31,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even } const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); -const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const rpcClientMock = { @@ -82,14 +81,13 @@ const rpcClientMock = { subscribeAuthAccess: vi.fn(), }, orchestration: { - getSnapshot: vi.fn(), dispatchCommand: vi.fn(), getTurnDiff: vi.fn(), getFullThreadDiff: vi.fn(), - replayEvents: vi.fn(), - onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => - registerListener(orchestrationEventListeners, listener), + subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => + registerListener(shellStreamListeners, listener), ), + subscribeThread: vi.fn(() => () => undefined), }, }; @@ -269,7 +267,7 @@ beforeEach(() => { vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); - orchestrationEventListeners.clear(); + shellStreamListeners.clear(); gitStatusListeners.clear(); const testWindow = getWindowForTest(); Reflect.deleteProperty(testWindow, "desktopBridge"); @@ -296,15 +294,15 @@ describe("wsApi", () => { expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); }); - it("forwards terminal and orchestration stream events", async () => { + it("forwards terminal and shell stream events", async () => { const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); const onTerminalEvent = vi.fn(); - const onDomainEvent = vi.fn(); + const onShellEvent = vi.fn(); api.terminal.onEvent(onTerminalEvent); - api.orchestration.onDomainEvent(onDomainEvent); + api.orchestration.subscribeShell(onShellEvent); const terminalEvent = { threadId: "thread-1", @@ -315,19 +313,11 @@ describe("wsApi", () => { } as const; emitEvent(terminalEventListeners, terminalEvent); - const orchestrationEvent = { + const shellEvent = { + kind: "project-upserted" as const, sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: ProjectId.make("project-1"), - occurredAt: "2026-02-24T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: ProjectId.make("project-1"), + project: { + id: ProjectId.make("project-1"), title: "Project", workspaceRoot: "/tmp/workspace", defaultModelSelection: { @@ -338,11 +328,11 @@ describe("wsApi", () => { createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:00.000Z", }, - } satisfies Extract; - emitEvent(orchestrationEventListeners, orchestrationEvent); + } satisfies OrchestrationShellStreamItem; + emitEvent(shellStreamListeners, shellEvent); expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); - expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); + expect(onShellEvent).toHaveBeenCalledWith(shellEvent); }); it("forwards git status stream events", async () => { @@ -371,16 +361,16 @@ describe("wsApi", () => { expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); }); - it("forwards orchestration stream subscription options to the RPC client", async () => { + it("forwards shell stream subscription options to the RPC client", async () => { const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); - const onDomainEvent = vi.fn(); + const onShellEvent = vi.fn(); const onResubscribe = vi.fn(); - api.orchestration.onDomainEvent(onDomainEvent, { onResubscribe }); + api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); - expect(rpcClientMock.orchestration.onDomainEvent).toHaveBeenCalledWith(onDomainEvent, { + expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { onResubscribe, }); }); diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 23350123a35..42c2c64bd06 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -110,14 +110,11 @@ export interface WsRpcClient { readonly subscribeAuthAccess: RpcStreamMethod; }; readonly orchestration: { - readonly getSnapshot: RpcUnaryNoArgMethod; readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; - readonly replayEvents: RpcUnaryMethod; readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; - readonly onDomainEvent: RpcStreamMethod; }; } @@ -230,18 +227,12 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { ), }, orchestration: { - getSnapshot: () => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), dispatchCommand: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), getTurnDiff: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), getFullThreadDiff: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), - replayEvents: (input) => - transport - .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) - .then((events) => [...events]), subscribeShell: (listener, options) => transport.subscribe( (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), @@ -254,12 +245,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { listener, options, ), - onDomainEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), - listener, - options, - ), }, }; } diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 500134933b1..56711f4ad4b 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -27,7 +27,6 @@ const STREAM_METHODS = new Set([ ORCHESTRATION_WS_METHODS.subscribeThread, WS_METHODS.gitRunStackedAction, WS_METHODS.subscribeGitStatus, - WS_METHODS.subscribeOrchestrationDomainEvents, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, WS_METHODS.subscribeServerLifecycle, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 27a73f375cf..a657526d60b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,8 +46,6 @@ import type { OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, - OrchestrationEvent, - OrchestrationReadModel, OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, @@ -252,13 +250,11 @@ export interface EnvironmentApi { ) => () => void; }; orchestration: { - getSnapshot: () => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; getFullThreadDiff: ( input: OrchestrationGetFullThreadDiffInput, ) => Promise; - replayEvents: (fromSequenceExclusive: number) => Promise; subscribeShell: ( callback: (event: OrchestrationShellStreamItem) => void, options?: { @@ -272,11 +268,5 @@ export interface EnvironmentApi { onResubscribe?: () => void; }, ) => () => void; - onDomainEvent: ( - callback: (event: OrchestrationEvent) => void, - options?: { - onResubscribe?: () => void; - }, - ) => () => void; }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 977f12197ca..0c26a42256c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -17,7 +17,6 @@ import { } from "./baseSchemas"; export const ORCHESTRATION_WS_METHODS = { - getSnapshot: "orchestration.getSnapshot", dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", @@ -1120,11 +1119,6 @@ export const DispatchResult = Schema.Struct({ }); export type DispatchResult = typeof DispatchResult.Type; -export const OrchestrationGetSnapshotInput = Schema.Struct({}); -export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput.Type; -const OrchestrationGetSnapshotResult = OrchestrationReadModel; -export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; - export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( Struct.assign({ threadId: ThreadId }), { unsafePreserveChecks: true }, @@ -1152,10 +1146,6 @@ const OrchestrationReplayEventsResult = Schema.Array(OrchestrationEvent); export type OrchestrationReplayEventsResult = typeof OrchestrationReplayEventsResult.Type; export const OrchestrationRpcSchemas = { - getSnapshot: { - input: OrchestrationGetSnapshotInput, - output: OrchestrationGetSnapshotResult, - }, dispatchCommand: { input: ClientOrchestrationCommand, output: DispatchResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 57f79c33818..b3809d96587 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -32,13 +32,11 @@ import { import { KeybindingsConfigError } from "./keybindings"; import { ClientOrchestrationCommand, - OrchestrationEvent, ORCHESTRATION_WS_METHODS, OrchestrationDispatchCommandError, OrchestrationGetFullThreadDiffError, OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, - OrchestrationGetSnapshotInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, OrchestrationReplayEventsError, @@ -115,7 +113,6 @@ export const WS_METHODS = { // Streaming subscriptions subscribeGitStatus: "subscribeGitStatus", - subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -272,12 +269,6 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: TerminalError, }); -export const WsOrchestrationGetSnapshotRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getSnapshot, { - payload: OrchestrationGetSnapshotInput, - success: OrchestrationRpcSchemas.getSnapshot.output, - error: OrchestrationGetSnapshotError, -}); - export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -325,15 +316,6 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make( }, ); -export const WsSubscribeOrchestrationDomainEventsRpc = Rpc.make( - WS_METHODS.subscribeOrchestrationDomainEvents, - { - payload: Schema.Struct({}), - success: OrchestrationEvent, - stream: true, - }, -); - export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, @@ -386,12 +368,10 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalClearRpc, WsTerminalRestartRpc, WsTerminalCloseRpc, - WsSubscribeOrchestrationDomainEventsRpc, WsSubscribeTerminalEventsRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, - WsOrchestrationGetSnapshotRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, From 24190180b2f828c6a240b15746e791f2c5c92564 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 12 Apr 2026 23:36:16 -0700 Subject: [PATCH 7/9] Optimize projection snapshot lookups - Scan proposed plans from the end to find the latest turn entry - Build message and activity records without unnecessary spread allocations --- .../Layers/ProjectionPipeline.ts | 14 ++++-- .../Layers/ProjectionSnapshotQuery.ts | 50 +++++++++++-------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 226a06ca19a..d7e62c39bde 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -143,10 +143,16 @@ function deriveHasActionableProposedPlan(input: { left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), ); - const latestForTurn = - input.latestTurnId === null - ? null - : (sorted.filter((plan) => plan.turnId === input.latestTurnId).at(-1) ?? null); + let latestForTurn: ProjectionThreadProposedPlan | null = null; + if (input.latestTurnId !== null) { + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const plan = sorted[index]; + if (plan?.turnId === input.latestTurnId) { + latestForTurn = plan; + break; + } + } + } if (latestForTurn !== null) { return latestForTurn.implementedAt === null; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fb1b70d327b..16bb9cb0a8e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1356,16 +1356,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, deletedAt: null, - messages: messageRows.map((row) => ({ - id: row.messageId, - role: row.role, - text: row.text, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })), + messages: messageRows.map((row) => { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.attachments !== null) { + return Object.assign(message, { attachments: row.attachments }); + } + return message; + }), proposedPlans: proposedPlanRows.map((row) => ({ id: row.planId, turnId: row.turnId, @@ -1375,16 +1380,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, })), - activities: activityRows.map((row) => ({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), + activities: activityRows.map((row) => { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + if (row.sequence !== null) { + return Object.assign(activity, { sequence: row.sequence }); + } + return activity; + }), checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, From 3a5dfb886a0dedf48d119b8e90a080e36b0cee51 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 06:39:01 +0000 Subject: [PATCH 8/9] Fix thread detail stream missing session-set event and add repositoryIdentity to test shell snapshots - Add 'thread.session-set' to isThreadDetailEvent filter in ws.ts so subscribeThread live stream forwards session status changes to clients - Add missing repositoryIdentity field to toShellSnapshot test helpers in ChatView.browser.tsx and KeybindingsToast.browser.tsx --- apps/server/src/ws.ts | 6 ++++-- apps/web/src/components/ChatView.browser.tsx | 1 + apps/web/src/components/KeybindingsToast.browser.tsx | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index aa37b46b12d..c3672226ea0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,7 +71,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< | "thread.proposed-plan-upserted" | "thread.activity-appended" | "thread.turn-diff-completed" - | "thread.reverted"; + | "thread.reverted" + | "thread.session-set"; } > { return ( @@ -79,7 +80,8 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< event.type === "thread.proposed-plan-upserted" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || - event.type === "thread.reverted" + event.type === "thread.reverted" || + event.type === "thread.session-set" ); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f2338523b28..0b49f1d7da7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -415,6 +415,7 @@ function toShellSnapshot(snapshot: OrchestrationReadModel) { id: project.id, title: project.title, workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, defaultModelSelection: project.defaultModelSelection, scripts: project.scripts, createdAt: project.createdAt, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 21e491ff62f..92ba61aa5f4 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -176,6 +176,7 @@ function toShellSnapshot(snapshot: OrchestrationReadModel) { id: project.id, title: project.title, workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, defaultModelSelection: project.defaultModelSelection, scripts: project.scripts, createdAt: project.createdAt, From fa28310bd84d4cda1a86d80f2558a2aca56ab1cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 08:33:43 -0700 Subject: [PATCH 9/9] Handle running turn snapshots by latest turn id - Query latest thread turns via projection_threads.latest_turn_id - Keep local dispatch active until the running turn is acknowledged - Use session activeTurnId when deriving active work start time --- .../Layers/ProjectionSnapshotQuery.test.ts | 143 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 31 ++-- .../web/src/components/ChatView.logic.test.ts | 136 +++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 32 ++-- apps/web/src/session-logic.test.ts | 13 ++ apps/web/src/session-logic.ts | 8 + 6 files changed, 338 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 09a1b6a4ced..9f0d63545fc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -878,4 +878,147 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); }), ); + + it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-02T00:00:00.000Z', + '2026-04-02T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'turn-running', + '2026-04-02T00:00:04.000Z', + 0, + 0, + 0, + '2026-04-02T00:00:02.000Z', + '2026-04-02T00:00:03.000Z', + NULL, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-1', + 'turn-completed', + 'message-user-1', + NULL, + NULL, + 'message-assistant-1', + 'completed', + '2026-04-02T00:00:05.000Z', + '2026-04-02T00:00:06.000Z', + '2026-04-02T00:00:20.000Z', + 5, + 'checkpoint-5', + 'ready', + '[]' + ), + ( + 'thread-1', + 'turn-running', + 'message-user-2', + NULL, + NULL, + NULL, + 'running', + '2026-04-02T00:00:30.000Z', + '2026-04-02T00:00:30.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadShell.value.latestTurn?.state, "running"); + assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); + assert.equal(threadDetail._tag, "Some"); + if (threadDetail._tag === "Some") { + assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); + assert.equal(threadDetail.value.latestTurn?.state, "running"); + assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); + } + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 16bb9cb0a8e..07645571ba7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -624,22 +624,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { execute: ({ threadId }) => sql` SELECT - thread_id AS "threadId", - turn_id AS "turnId", - state, - requested_at AS "requestedAt", - started_at AS "startedAt", - completed_at AS "completedAt", - assistant_message_id AS "assistantMessageId", - source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", - source_proposed_plan_id AS "sourceProposedPlanId" - FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NOT NULL - ORDER BY - COALESCE(checkpoint_turn_count, -1) DESC, - COALESCE(completed_at, started_at, requested_at) DESC, - row_id DESC + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL LIMIT 1 `, }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1b96265a40e..37bf41b5b0b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -530,6 +530,142 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: previousLatestTurn, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + + it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: previousLatestTurn, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: undefined, + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + + it("clears local dispatch once the running latestTurn matches the active session turn", () => { + const localDispatch = createLocalDispatchSnapshot({ + id: ThreadId.make("thread-1"), + environmentId: localEnvironmentId, + codexThreadId: null, + projectId, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: previousSession, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-03-29T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-03-29T00:00:10.000Z", + latestTurn: previousLatestTurn, + branch: null, + worktreePath: null, + turnDiffSummaries: [], + activities: [], + }); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: { + ...previousLatestTurn, + turnId: TurnId.make("turn-2"), + state: "running", + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }, + session: { + ...previousSession, + status: "running", + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + updatedAt: "2026-03-29T00:01:01.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a753a71b390..510fef6d33f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -322,23 +322,37 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (!input.localDispatch) { return false; } - if ( - input.phase === "running" || - input.hasPendingApproval || - input.hasPendingUserInput || - Boolean(input.threadError) - ) { + if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; - - return ( + const latestTurnChanged = input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || - input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null) || + input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); + + if (input.phase === "running") { + if (!latestTurnChanged) { + return false; + } + if (latestTurn?.startedAt === null || latestTurn === null) { + return false; + } + if ( + session?.activeTurnId !== undefined && + session.activeTurnId !== null && + latestTurn?.turnId !== session.activeTurnId + ) { + return false; + } + return true; + } + + return ( + latestTurnChanged || input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index d3fd3043499..21ab1590d35 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1227,6 +1227,19 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:10:00.000Z"); }); + it("uses the new send start while the session is running a different turn", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "running", + activeTurnId: TurnId.make("turn-2"), + }, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + it("falls back to sendStartedAt once the latest turn is settled", () => { expect( deriveActiveWorkStartedAt( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 54e84c883a4..b03f1c541f2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -145,6 +145,14 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { + const runningTurnId = + session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; + if (runningTurnId !== null) { + if (latestTurn?.turnId === runningTurnId) { + return latestTurn.startedAt ?? sendStartedAt; + } + return sendStartedAt; + } if (!isLatestTurnSettled(latestTurn, session)) { return latestTurn?.startedAt ?? sendStartedAt; }