authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 01:25:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 01:25:24-07:00
loga9ffb8279614df6ae2f1f4b7f09a0dc42edef797
tree5239663b66231d7bbfed7a9f26d46576b7823c31
parent73776d4e52087331245e82ebf3c053ae0991935d
signature Signed by PGP key B5690EEEBB952194

perf(server): bound snapshot activity payload memory (#9000)


5 files changed, 449 insertions(+), 94 deletions(-)

apps/server/src/orchestration/ActivityPayloadProjection.test.ts+22
...@@ -64,6 +64,28 @@ describe("projectActivityPayload", () => {...@@ -64,6 +64,28 @@ describe("projectActivityPayload", () => {
64 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);64 expect(JSON.stringify(projected.payload).length).toBeLessThan(500);
65 });65 });
6666
67 it("keeps preview normalization and fence-only fallback while scanning lines", () => {
68 const preview = projectActivityPayload(
69 activity({
70 itemType: "command_execution",
71 data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` },
72 }),
73 );
74 const fences = projectActivityPayload(
75 activity({
76 itemType: "command_execution",
77 data: { rawOutput: "```\r\n \t \n```\n" },
78 }),
79 );
80
81 expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({
82 content: "actual result",
83 });
84 expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({
85 content: "2 lines",
86 });
87 });
88
67 it("keeps bounded Claude and ACP command output summaries", () => {89 it("keeps bounded Claude and ACP command output summaries", () => {
68 const claude = projectActivityPayload(90 const claude = projectActivityPayload(
69 activity({91 activity({
apps/server/src/orchestration/ActivityPayloadProjection.ts+19-12
...@@ -144,22 +144,29 @@ function projectCommandValue(data: Record<string, unknown>): unknown {...@@ -144,22 +144,29 @@ function projectCommandValue(data: Record<string, unknown>): unknown {
144}144}
145145
146function summarizeToolTextOutput(value: string): string | null {146function summarizeToolTextOutput(value: string): string | null {
147 const lines: string[] = [];147 let meaningfulLineCount = 0;
148 for (const rawLine of value.split(/\r?\n/u)) {148 let offset = 0;
149 const line = rawLine.replace(/\s+/g, " ").trim();149
150 while (offset <= value.length) {
151 const newlineIndex = value.indexOf("\n", offset);
152 const lineEnd = newlineIndex === -1 ? value.length : newlineIndex;
153 const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim();
150 if (line.length > 0) {154 if (line.length > 0) {
151 lines.push(line);155 meaningfulLineCount += 1;
156 if (line !== "```") {
157 const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`;
158 // V8 can retain the full tool output behind a short sliced string.
159 // Join a tiny character array so the returned preview owns its bytes.
160 return Array.from(summary).join("");
161 }
162 }
163 if (newlineIndex === -1) {
164 break;
152 }165 }
166 offset = newlineIndex + 1;
153 }167 }
154168
155 const firstLine = lines.find((line) => line !== "```");169 return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null;
156 if (firstLine) {
157 return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`;
158 }
159 if (lines.length > 1) {
160 return `${lines.length.toLocaleString()} lines`;
161 }
162 return null;
163}170}
164171
165/**172/**
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+145-12
...@@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";...@@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
21import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";21import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
22import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";22import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
23import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";23import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";
24import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts";
2425
25const asProjectId = (value: string): ProjectId => ProjectId.make(value);26const asProjectId = (value: string): ProjectId => ProjectId.make(value);
26const asTurnId = (value: string): TurnId => TurnId.make(value);27const asTurnId = (value: string): TurnId => TurnId.make(value);
...@@ -2389,9 +2390,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2389,9 +2390,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2389 'thread-w',2390 'thread-w',
2390 'turn-5',2391 'turn-5',
2391 'tool',2392 'tool',
2392 'tool.completed',2393 CASE
2394 WHEN sequence = 2 THEN 'tool.updated'
2395 WHEN sequence IN (3, 70) THEN 'context-window.updated'
2396 ELSE 'tool.completed'
2397 END,
2393 'ran tool',2398 'ran tool',
2394 printf('{"sequence":%d}', sequence),2399 CASE
2400 WHEN sequence IN (2, 80) THEN json_object(
2401 'itemType', 'command_execution',
2402 'toolCallId', 'cross-batch-call',
2403 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END,
2404 'status', 'completed',
2405 'data', json_object(
2406 'toolCallId', 'cross-batch-call',
2407 'item', json_object(
2408 'command', 'vp test run',
2409 'aggregatedOutput', printf(
2410 'command output%s%s',
2411 char(10),
2412 replace(hex(zeroblob(8192)), '00', 'x')
2413 )
2414 ),
2415 'rawOutput', printf(
2416 'raw output%s%s',
2417 char(10),
2418 replace(hex(zeroblob(8192)), '00', 'y')
2419 ),
2420 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts'))
2421 )
2422 )
2423 WHEN sequence = 10 THEN json_object(
2424 'itemType', 'mcp_tool_call',
2425 'status', 'completed',
2426 'data', json_object(
2427 'item', json_object(
2428 'type', 'mcpToolCall',
2429 'id', 'mcp-item-10',
2430 'tool', 'fetch_pr',
2431 'server', 'github',
2432 'status', 'completed',
2433 'arguments', json_object('pr', 42),
2434 'result', json_object(
2435 'content', json_array(json_object(
2436 'type', 'text',
2437 'text', printf(
2438 'PR body line one%s%s',
2439 char(10),
2440 replace(hex(zeroblob(8192)), '00', 'z')
2441 )
2442 ))
2443 ),
2444 '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q'))
2445 )
2446 )
2447 )
2448 WHEN sequence = 11 THEN json_object(
2449 'itemType', 'command_execution',
2450 'status', 'completed',
2451 'data', json_object(
2452 'item', json_object(
2453 'status', 'failed',
2454 'command', 'vp test run',
2455 'aggregatedOutput', printf(
2456 'failed command%s%s',
2457 char(10),
2458 replace(hex(zeroblob(8192)), '00', 'w')
2459 )
2460 ),
2461 'rawOutput', json_object('stdout', 'failed output'),
2462 'files', json_array(json_object('path', 'apps/server/src/failed.ts'))
2463 )
2464 )
2465 WHEN sequence IN (3, 70) THEN json_object(
2466 'usedTokens', sequence * 100,
2467 'modelContextWindow', 100000
2468 )
2469 ELSE json_object('sequence', sequence)
2470 END,
2395 sequence,2471 sequence,
2396 '2026-03-01T00:04:00.000Z'2472 '2026-03-01T00:04:00.000Z'
2397 FROM activity_rows2473 FROM activity_rows
...@@ -2469,12 +2545,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2469,12 +2545,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2469 const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW);2545 const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW);
2470 assert.equal(detailWithPinnedRequests._tag, "Some");2546 assert.equal(detailWithPinnedRequests._tag, "Some");
2471 if (detailWithPinnedRequests._tag === "Some") {2547 if (detailWithPinnedRequests._tag === "Some") {
2472 const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id);2548 const ids = new Set(
2549 detailWithPinnedRequests.value.activities.map((activity) => activity.id),
2550 );
2473 assert.equal(detailWithPinnedRequests.value.activities.length, 503);2551 assert.equal(detailWithPinnedRequests.value.activities.length, 503);
2474 assert.equal(ids.includes(asEventId("approval-old")), true);2552 assert.equal(ids.has(asEventId("approval-old")), true);
2475 assert.equal(ids.includes(asEventId("user-input-old")), true);2553 assert.equal(ids.has(asEventId("user-input-old")), true);
2476 assert.equal(ids.includes(asEventId("user-input-closed")), false);2554 assert.equal(ids.has(asEventId("user-input-closed")), false);
2477 assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);2555 assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
2478 }2556 }
24792557
2480 const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {2558 const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, {
...@@ -2482,12 +2560,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =...@@ -2482,12 +2560,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) =
2482 });2560 });
2483 assert.equal(windowWithPinnedRequests._tag, "Some");2561 assert.equal(windowWithPinnedRequests._tag, "Some");
2484 if (windowWithPinnedRequests._tag === "Some") {2562 if (windowWithPinnedRequests._tag === "Some") {
2485 const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id);2563 const ids = new Set(
2564 windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id),
2565 );
2486 assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503);2566 assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503);
2487 assert.equal(ids.includes(asEventId("approval-old")), true);2567 assert.equal(ids.has(asEventId("approval-old")), true);
2488 assert.equal(ids.includes(asEventId("user-input-old")), true);2568 assert.equal(ids.has(asEventId("user-input-old")), true);
2489 assert.equal(ids.includes(asEventId("user-input-closed")), false);2569 assert.equal(ids.has(asEventId("user-input-closed")), false);
2490 assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true);2570 assert.equal(ids.has(asEventId("user-input-tied-z-request")), true);
2571 }
2572
2573 const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW);
2574 assert.equal(fullSnapshot._tag, "Some");
2575 if (
2576 detailWithPinnedRequests._tag === "Some" &&
2577 fullSnapshot._tag === "Some" &&
2578 windowWithPinnedRequests._tag === "Some"
2579 ) {
2580 const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value);
2581 const projectedRawBaseline = projectThreadDetailSnapshot({
2582 snapshotSequence: fullSnapshot.value.snapshotSequence,
2583 thread: detailWithPinnedRequests.value,
2584 });
2585 assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline);
2586
2587 const rawActivitiesById = new Map(
2588 detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]),
2589 );
2590 const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value);
2591 const projectedWindowBaseline = projectThreadDetailSnapshot({
2592 ...windowWithPinnedRequests.value,
2593 thread: {
2594 ...windowWithPinnedRequests.value.thread,
2595 activities: windowWithPinnedRequests.value.thread.activities.map(
2596 (activity) => rawActivitiesById.get(activity.id) ?? activity,
2597 ),
2598 },
2599 });
2600 assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline);
2601
2602 const projectedIds = new Set(
2603 projectedFullSnapshot.thread.activities.map((activity) => activity.id),
2604 );
2605 assert.equal(projectedIds.has(asEventId("activity-0002")), false);
2606 assert.equal(projectedIds.has(asEventId("activity-0003")), false);
2607 assert.equal(projectedIds.has(asEventId("activity-0070")), true);
2608
2609 const failedCommand = projectedFullSnapshot.thread.activities.find(
2610 (activity) => activity.id === asEventId("activity-0011"),
2611 );
2612 assert.deepStrictEqual(failedCommand?.payload, {
2613 itemType: "command_execution",
2614 status: "failed",
2615 data: {
2616 item: {
2617 command: "vp test run",
2618 aggregatedOutput: "failed command",
2619 },
2620 files: [{ path: "apps/server/src/failed.ts" }],
2621 rawOutput: { content: "failed output" },
2622 },
2623 });
2491 }2624 }
2492 }),2625 }),
2493 );2626 );
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+259-70
...@@ -57,6 +57,7 @@ import {...@@ -57,6 +57,7 @@ import {
57 decodeThreadDetailPageCursor,57 decodeThreadDetailPageCursor,
58 encodeThreadDetailPageCursor,58 encodeThreadDetailPageCursor,
59} from "../threadDetailCursor.ts";59} from "../threadDetailCursor.ts";
60import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
60import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";61import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
61import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";62import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
62import {63import {
...@@ -75,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);...@@ -75,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);
75// activity window. Applying the limit in SQL avoids decoding an unbounded76// activity window. Applying the limit in SQL avoids decoding an unbounded
76// payload_json set before the projector can enforce that invariant.77// payload_json set before the projector can enforce that invariant.
77const THREAD_DETAIL_ACTIVITY_LIMIT = 500;78const THREAD_DETAIL_ACTIVITY_LIMIT = 500;
79// Snapshot payloads are decoded and projected in small sequential batches so
80// one client read does not retain the raw payloads for the full activity window.
81const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
78const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(82const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
79 Struct.assign({83 Struct.assign({
80 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),84 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
...@@ -100,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(...@@ -100,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
100 sequence: Schema.NullOr(NonNegativeInt),104 sequence: Schema.NullOr(NonNegativeInt),
101 }),105 }),
102);106);
107const ProjectionThreadActivityIdRowSchema = Schema.Struct({
108 activityId: ProjectionThreadActivity.fields.activityId,
109});
103const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;110const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
104const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(111const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
105 Struct.assign({112 Struct.assign({
...@@ -146,6 +153,9 @@ const ThreadActivityKindsLookupInput = Schema.Struct({...@@ -146,6 +153,9 @@ const ThreadActivityKindsLookupInput = Schema.Struct({
146 threadId: ThreadId,153 threadId: ThreadId,
147 activityKinds: Schema.Array(Schema.String),154 activityKinds: Schema.Array(Schema.String),
148});155});
156const ThreadActivityIdsLookupInput = Schema.Struct({
157 activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId),
158});
149// Windowed reads order turns by the stable keyset (anchor, turn key), where159// Windowed reads order turns by the stable keyset (anchor, turn key), where
150// anchor is requested_at and turn key is160// anchor is requested_at and turn key is
151// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the161// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
...@@ -349,6 +359,21 @@ function mapProposedPlanRow(...@@ -349,6 +359,21 @@ function mapProposedPlanRow(
349 };359 };
350}360}
351361
362function mapThreadActivityRow(
363 row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
364): OrchestrationThreadActivity {
365 return {
366 id: row.activityId,
367 tone: row.tone,
368 kind: row.kind,
369 summary: row.summary,
370 payload: row.payload,
371 turnId: row.turnId,
372 createdAt: row.createdAt,
373 ...(row.sequence !== null ? { sequence: row.sequence } : {}),
374 };
375}
376
352function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {377function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
353 return (cause: unknown): ProjectionRepositoryError =>378 return (cause: unknown): ProjectionRepositoryError =>
354 Schema.isSchemaError(cause)379 Schema.isSchemaError(cause)
...@@ -1060,6 +1085,44 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1060,6 +1085,44 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1060 `,1085 `,
1061 });1086 });
10621087
1088 const listThreadActivityIdsByThread = SqlSchema.findAll({
1089 Request: ThreadIdLookupInput,
1090 Result: ProjectionThreadActivityIdRowSchema,
1091 execute: ({ threadId }) =>
1092 sql`
1093 SELECT activity_id AS "activityId"
1094 FROM projection_thread_activities
1095 WHERE thread_id = ${threadId}
1096 ORDER BY
1097 sequence DESC,
1098 created_at DESC,
1099 activity_id DESC
1100 LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1101 `,
1102 });
1103
1104 const listThreadActivityRowsByIds = SqlSchema.findAll({
1105 Request: ThreadActivityIdsLookupInput,
1106 Result: ProjectionThreadActivityDbRowSchema,
1107 execute: ({ activityIds }) =>
1108 sql`
1109 SELECT
1110 activity_id AS "activityId",
1111 thread_id AS "threadId",
1112 turn_id AS "turnId",
1113 tone,
1114 kind,
1115 summary,
1116 payload_json AS "payload",
1117 sequence,
1118 created_at AS "createdAt"
1119 FROM projection_thread_activities
1120 -- The selectors already scoped these globally unique ids to the
1121 -- thread inside this transaction. Keep this as a primary-key lookup.
1122 WHERE ${sql.in("activity_id", activityIds)}
1123 `,
1124 });
1125
1063 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({1126 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({
1064 Request: ThreadActivityKindsLookupInput,1127 Request: ThreadActivityKindsLookupInput,
1065 Result: ProjectionThreadActivityDbRowSchema,1128 Result: ProjectionThreadActivityDbRowSchema,
...@@ -1310,15 +1373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1310,15 +1373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1310 `,1373 `,
1311 });1374 });
13121375
1313 // Blocking request payloads must remain available even if they predate the1376 const pinnedThreadActivityIdsCte = (threadId: string) => sql`
1314 // recent activity window. Each CTE returns at most one unresolved row per1377pending_approval_requests AS (
1315 // request, so the merge below stays bounded by actionable work.
1316 const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1317 Request: ThreadIdLookupInput,
1318 Result: ProjectionThreadActivityDbRowSchema,
1319 execute: ({ threadId }) =>
1320 sql`
1321 WITH pending_approval_requests AS (
1322 SELECT request_id, thread_id1378 SELECT request_id, thread_id
1323 FROM projection_pending_approvals1379 FROM projection_pending_approvals
1324 WHERE thread_id = ${threadId}1380 WHERE thread_id = ${threadId}
...@@ -1382,6 +1438,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1382,6 +1438,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1382 WHERE request_order = 11438 WHERE request_order = 1
1383 AND kind = 'user-input.requested'1439 AND kind = 'user-input.requested'
1384 )1440 )
1441 `;
1442
1443 // Blocking request payloads must remain available even if they predate the
1444 // recent activity window. Each CTE returns at most one unresolved row per
1445 // request, so the merge below stays bounded by actionable work.
1446 const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1447 Request: ThreadIdLookupInput,
1448 Result: ProjectionThreadActivityDbRowSchema,
1449 execute: ({ threadId }) =>
1450 sql`
1451 WITH ${pinnedThreadActivityIdsCte(threadId)}
1385 SELECT1452 SELECT
1386 activity.activity_id AS "activityId",1453 activity.activity_id AS "activityId",
1387 activity.thread_id AS "threadId",1454 activity.thread_id AS "threadId",
...@@ -1399,6 +1466,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1399,6 +1466,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1399 `,1466 `,
1400 });1467 });
14011468
1469 const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({
1470 Request: ThreadIdLookupInput,
1471 Result: ProjectionThreadActivityIdRowSchema,
1472 execute: ({ threadId }) =>
1473 sql`
1474 WITH ${pinnedThreadActivityIdsCte(threadId)}
1475 SELECT activity_id AS "activityId"
1476 FROM pinned_activity_ids
1477 `,
1478 });
1479
1402 const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({1480 const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({
1403 Request: ThreadTurnRangeLookupInput,1481 Request: ThreadTurnRangeLookupInput,
1404 Result: ProjectionThreadActivityDbRowSchema,1482 Result: ProjectionThreadActivityDbRowSchema,
...@@ -1466,6 +1544,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1466,6 +1544,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1466 `,1544 `,
1467 });1545 });
14681546
1547 const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({
1548 Request: ThreadTurnRangeLookupInput,
1549 Result: ProjectionThreadActivityIdRowSchema,
1550 execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) =>
1551 sql`
1552 SELECT activity_id AS "activityId"
1553 FROM projection_thread_activities
1554 WHERE thread_id = ${threadId}
1555 AND (
1556 turn_id IN (
1557 SELECT turn_id FROM projection_turns
1558 WHERE thread_id = ${threadId}
1559 AND turn_id IS NOT NULL
1560 AND (
1561 requested_at > ${minAnchorAt}
1562 OR (
1563 requested_at = ${minAnchorAt}
1564 AND turn_id >= ${minTurnKey}
1565 )
1566 )
1567 AND (
1568 requested_at < ${beforeAnchorAt}
1569 OR (
1570 requested_at = ${beforeAnchorAt}
1571 AND turn_id < ${beforeTurnKey}
1572 )
1573 )
1574 )
1575 OR (
1576 turn_id IS NULL
1577 AND created_at >= ${minAnchorAt}
1578 AND created_at < ${beforeAnchorAt}
1579 )
1580 )
1581 ORDER BY
1582 sequence DESC,
1583 created_at DESC,
1584 activity_id DESC
1585 LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1586 `,
1587 });
1588
1469 const getFullThreadDiffContextRow = SqlSchema.findOneOption({1589 const getFullThreadDiffContextRow = SqlSchema.findOneOption({
1470 Request: FullThreadDiffContextLookupInput,1590 Request: FullThreadDiffContextLookupInput,
1471 Result: ProjectionFullThreadDiffContextRowSchema,1591 Result: ProjectionFullThreadDiffContextRowSchema,
...@@ -2571,49 +2691,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2571,49 +2691,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2571 readonly beforeTurnKey: string;2691 readonly beforeTurnKey: string;
2572 }2692 }
25732693
2574 const getThreadDetailByIdBounded = (2694 type ThreadDetailActivityRead =
2575 threadId: ThreadId,2695 | {
2576 bounds: ThreadDetailBounds | undefined,2696 readonly mode: "raw";
2577 query: ProjectionThreadDetailQuery | undefined = undefined,2697 readonly query?: ProjectionThreadDetailQuery;
2578 ) =>2698 }
2579 Effect.gen(function* () {2699 | {
2580 const activityRowsEffect = (2700 readonly mode: "client";
2581 query?.activityKinds === undefined2701 };
2582 ? bounds === undefined2702
2583 ? listThreadActivityRowsByThread({ threadId })2703 const listProjectedThreadActivities = Effect.fn(
2584 : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })2704 "ProjectionSnapshotQuery.listProjectedThreadActivities",
2585 : query.activityKinds.length === 02705 )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) {
2586 ? Effect.succeed([])2706 const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([
2587 : listThreadActivityRowsByThreadAndKinds({2707 (bounds === undefined
2588 threadId,2708 ? listThreadActivityIdsByThread({ threadId })
2589 activityKinds: query.activityKinds,2709 : listThreadActivityIdsByThreadWindow({ threadId, ...bounds })
2590 })
2591 ).pipe(2710 ).pipe(
2592 Effect.mapError(2711 Effect.mapError(
2593 toPersistenceSqlOrDecodeError(2712 toPersistenceSqlOrDecodeError(
2594 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",2713 "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query",
2595 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",2714 "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows",
2596 ),2715 ),
2597 ),2716 ),
2717 ),
2718 listPinnedThreadActivityIdsByThread({ threadId }).pipe(
2719 Effect.mapError(
2720 toPersistenceSqlOrDecodeError(
2721 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query",
2722 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows",
2723 ),
2724 ),
2725 ),
2726 ]);
2727 const activityIds = [
2728 ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)),
2729 ];
2730 const activities: OrchestrationThreadActivity[] = [];
2731
2732 for (
2733 let offset = 0;
2734 offset < activityIds.length;
2735 offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE
2736 ) {
2737 const batchIds = activityIds.slice(
2738 offset,
2739 offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE,
2598 );2740 );
2599 const pinnedActivityRowsEffect =2741 const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe(
2600 query?.activityKinds === undefined2742 Effect.mapError(
2601 ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(2743 toPersistenceSqlOrDecodeError(
2602 Effect.mapError(2744 "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query",
2603 toPersistenceSqlOrDecodeError(2745 "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows",
2604 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",2746 ),
2605 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",2747 ),
2748 );
2749 for (const row of batchRows) {
2750 activities.push(projectActivityPayload(mapThreadActivityRow(row)));
2751 }
2752 }
2753
2754 return activities.toSorted(
2755 (left, right) =>
2756 (left.sequence ?? -1) - (right.sequence ?? -1) ||
2757 left.createdAt.localeCompare(right.createdAt) ||
2758 left.id.localeCompare(right.id),
2759 );
2760 });
2761
2762 const getThreadDetailByIdBounded = (
2763 threadId: ThreadId,
2764 bounds: ThreadDetailBounds | undefined,
2765 activityRead: ThreadDetailActivityRead = { mode: "raw" },
2766 ) =>
2767 Effect.gen(function* () {
2768 const activitiesEffect =
2769 activityRead.mode === "client"
2770 ? listProjectedThreadActivities(threadId, bounds)
2771 : Effect.all([
2772 (activityRead.query?.activityKinds === undefined
2773 ? bounds === undefined
2774 ? listThreadActivityRowsByThread({ threadId })
2775 : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2776 : activityRead.query.activityKinds.length === 0
2777 ? Effect.succeed([])
2778 : listThreadActivityRowsByThreadAndKinds({
2779 threadId,
2780 activityKinds: activityRead.query.activityKinds,
2781 })
2782 ).pipe(
2783 Effect.mapError(
2784 toPersistenceSqlOrDecodeError(
2785 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2786 "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2787 ),
2606 ),2788 ),
2607 ),2789 ),
2608 )2790 activityRead.query?.activityKinds === undefined
2609 : Effect.succeed([]);2791 ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2792 Effect.mapError(
2793 toPersistenceSqlOrDecodeError(
2794 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2795 "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2796 ),
2797 ),
2798 )
2799 : Effect.succeed([]),
2800 ]).pipe(
2801 Effect.map(([activityRows, pinnedActivityRows]) =>
2802 [
2803 ...new Map(
2804 [...activityRows, ...pinnedActivityRows].map(
2805 (row) => [row.activityId, row] as const,
2806 ),
2807 ).values(),
2808 ]
2809 .toSorted(
2810 (left, right) =>
2811 (left.sequence ?? -1) - (right.sequence ?? -1) ||
2812 left.createdAt.localeCompare(right.createdAt) ||
2813 left.activityId.localeCompare(right.activityId),
2814 )
2815 .map(mapThreadActivityRow),
2816 ),
2817 );
26102818
2611 const [2819 const [
2612 threadRow,2820 threadRow,
2613 messageRows,2821 messageRows,
2614 proposedPlanRows,2822 proposedPlanRows,
2615 activityRows,2823 activities,
2616 pinnedActivityRows,
2617 checkpointRows,2824 checkpointRows,
2618 latestTurnRow,2825 latestTurnRow,
2619 sessionRow,2826 sessionRow,
...@@ -2645,8 +2852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2645,8 +2852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2645 ),2852 ),
2646 ),2853 ),
2647 ),2854 ),
2648 activityRowsEffect,2855 activitiesEffect,
2649 pinnedActivityRowsEffect,
2650 listCheckpointRowsByThread({ threadId }).pipe(2856 listCheckpointRowsByThread({ threadId }).pipe(
2651 Effect.mapError(2857 Effect.mapError(
2652 toPersistenceSqlOrDecodeError(2858 toPersistenceSqlOrDecodeError(
...@@ -2677,17 +2883,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2677,17 +2883,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2677 return Option.none<OrchestrationThread>();2883 return Option.none<OrchestrationThread>();
2678 }2884 }
26792885
2680 const selectedActivityRows = [
2681 ...new Map(
2682 [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const),
2683 ).values(),
2684 ].toSorted(
2685 (left, right) =>
2686 (left.sequence ?? -1) - (right.sequence ?? -1) ||
2687 left.createdAt.localeCompare(right.createdAt) ||
2688 left.activityId.localeCompare(right.activityId),
2689 );
2690
2691 const thread = {2886 const thread = {
2692 id: threadRow.value.threadId,2887 id: threadRow.value.threadId,
2693 projectId: threadRow.value.projectId,2888 projectId: threadRow.value.projectId,
...@@ -2729,21 +2924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2729,21 +2924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2729 return message;2924 return message;
2730 }),2925 }),
2731 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),2926 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
2732 activities: selectedActivityRows.map((row) => {2927 activities,
2733 const activity = {
2734 id: row.activityId,
2735 tone: row.tone,
2736 kind: row.kind,
2737 summary: row.summary,
2738 payload: row.payload,
2739 turnId: row.turnId,
2740 createdAt: row.createdAt,
2741 };
2742 if (row.sequence !== null) {
2743 return Object.assign(activity, { sequence: row.sequence });
2744 }
2745 return activity;
2746 }),
2747 checkpoints: checkpointRows.map((row) => ({2928 checkpoints: checkpointRows.map((row) => ({
2748 turnId: row.turnId,2929 turnId: row.turnId,
2749 checkpointTurnCount: row.checkpointTurnCount,2930 checkpointTurnCount: row.checkpointTurnCount,
...@@ -2768,7 +2949,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2768,7 +2949,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2768 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (2949 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (
2769 threadId,2950 threadId,
2770 query,2951 query,
2771 ) => getThreadDetailByIdBounded(threadId, undefined, query);2952 ) =>
2953 getThreadDetailByIdBounded(threadId, undefined, {
2954 mode: "raw",
2955 ...(query === undefined ? {} : { query }),
2956 });
27722957
2773 // Bounds pathological fan-out: one user turn that spawned hundreds of2958 // Bounds pathological fan-out: one user turn that spawned hundreds of
2774 // subagent turns still pages in bounded chunks, at the cost of splitting the2959 // subagent turns still pages in bounded chunks, at the cost of splitting the
...@@ -2792,7 +2977,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2792,7 +2977,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2792 .withTransaction(2977 .withTransaction(
2793 Effect.gen(function* () {2978 Effect.gen(function* () {
2794 if (window?.turnLimit === undefined) {2979 if (window?.turnLimit === undefined) {
2795 const thread = yield* getThreadDetailById(threadId);2980 const thread = yield* getThreadDetailByIdBounded(threadId, undefined, {
2981 mode: "client",
2982 });
2796 if (Option.isNone(thread)) {2983 if (Option.isNone(thread)) {
2797 return Option.none<OrchestrationThreadDetailSnapshot>();2984 return Option.none<OrchestrationThreadDetailSnapshot>();
2798 }2985 }
...@@ -2845,7 +3032,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2845,7 +3032,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2845 ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }3032 ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }
2846 : undefined;3033 : undefined;
28473034
2848 const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds);3035 const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, {
3036 mode: "client",
3037 });
2849 if (Option.isNone(thread)) {3038 if (Option.isNone(thread)) {
2850 return Option.none<OrchestrationThreadDetailSnapshot>();3039 return Option.none<OrchestrationThreadDetailSnapshot>();
2851 }3040 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+4
...@@ -191,6 +191,10 @@ export interface ProjectionSnapshotQueryShape {...@@ -191,6 +191,10 @@ export interface ProjectionSnapshotQueryShape {
191 * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).191 * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).
192 * Without a window the full thread is returned with no `page` field —192 * Without a window the full thread is returned with no `page` field —
193 * pagination is strictly opt-in.193 * pagination is strictly opt-in.
194 *
195 * Activity payloads are projected for clients as they are read in small
196 * sequential batches. Callers still apply the full snapshot projector for
197 * collection-level activity pruning.
194 */198 */
195 readonly getThreadDetailSnapshot: (199 readonly getThreadDetailSnapshot: (
196 threadId: ThreadId,200 threadId: ThreadId,