feat(agent): per-turn attribution columns and the capture policy for persisted tool I/O - #2071
feat(agent): per-turn attribution columns and the capture policy for persisted tool I/O#2071GabrielDrapor wants to merge 24 commits into
Conversation
PR overviewThis pull request adds per-turn attribution, durable streaming message persistence, shared tool-I/O redaction, and per-session chat stream isolation. One security concern remains open: an authenticated user can cause multi-megabyte tool arguments to bypass the capture budgets and consume Postgres storage and write capacity. Exploitation requires an authenticated project user and is limited to resource exhaustion. Open issues (1)
Scanned with Semgrep · TruffleHog · Trident review. View in Trident Fixed/addressed: 1 · PR risk: 4/10 |
There was a problem hiding this comment.
1 issue found across 12 files
Confidence score: 3/5
- In
frontend/ee/agent/src/stream-persister.ts, non-allowlisted tool rows omit exit code and duration while retaining only byte count andisError, which can make execution outcomes and performance unavailable forbash,read, andwrit...; pass status and timing through capture and persist those fields.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="frontend/ee/agent/src/stream-persister.ts">
<violation number="1" location="frontend/ee/agent/src/stream-persister.ts:66">
P2: For non-allowlisted tools, these rows record only byte count and `isError`; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so `bash`, `read`, `write`, `git_clone`, and `check_github_access` retain the required size-only diagnostics.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as Client
participant API as Agent API (index.ts)
participant SM as SessionManager
participant CP as CapturePolicy
participant DB as Postgres (Prisma)
participant Worker as Worker Processors
participant Trace as TraceRoot SDK
Note over Client,DB: Per-Turn Attribution Flow (NEW)
Client->>API: POST /sessions (with optional executionId)
API->>DB: createSession(executionId)
DB-->>API: Session created
Client->>API: POST /messages (prompt)
API->>DB: Find session (with executionId)
DB-->>API: Session
alt Session.userId === null
alt Request has x-user-id
Note over API: Attribution: rca_followup
else
Note over API: Attribution: rca_execution
end
else
Note over API: Attribution: chat
end
API->>SM: appendMessage("user", prompt, attribution)
SM->>DB: Create AIMessage with turnKind, executionId, initiatorUserId
SM->>SM: deriveAttribution() fallback
DB-->>SM: Message persisted
SM-->>API: Message record
Note over API,DB: Agent Run + Tool Capture
API->>API: Create StreamPersister with attribution
loop Agent events
API->>API: Process tool_execution_start
API->>API: Store args in pendingToolArgs
API->>API: Process tool_execution_end
API->>API: applyCapturePolicy(toolName, args, result)
API->>CP: Redact args (deep)
API->>CP: Check output allowlist
alt Tool in OUTPUT_ALLOWLIST
CP->>CP: Redact result, truncate to 8KB
CP-->>API: Keep result + outputBytes
else Tool not allowlisted
CP-->>API: Withhold result, keep outputBytes only
end
API->>SM: appendMessage("tool_step", metadata, attribution)
SM->>DB: Insert tool_step row with captured I/O
end
API->>SM: appendMessage("assistant", response, attribution)
SM->>DB: Insert assistant message
Note over Worker,DB: Detector/Digest Attribution (CHANGED)
Worker->>DB: Insert detector messages with turnKind="detector"
Worker->>DB: Insert digest messages with turnKind="digest"
Note over DB: Migration Backfill
DB->>DB: Add turn_kind columns
DB->>DB: Backfill turn_kind from legacy kind column
DB->>DB: Set initiator_user_id from session
DB->>DB: Add indexes for turnKind queries
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
| result: event.result, | ||
| args: captured.args, | ||
| ...(captured.result !== undefined ? { result: captured.result } : {}), | ||
| outputBytes: captured.outputBytes, |
There was a problem hiding this comment.
P2: For non-allowlisted tools, these rows record only byte count and isError; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so bash, read, write, git_clone, and check_github_access retain the required size-only diagnostics.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/ee/agent/src/stream-persister.ts, line 66:
<comment>For non-allowlisted tools, these rows record only byte count and `isError`; they omit exit code and duration. Pass execution status and timing through the capture path and persist those fields so `bash`, `read`, `write`, `git_clone`, and `check_github_access` retain the required size-only diagnostics.</comment>
<file context>
@@ -50,11 +54,18 @@ export class StreamPersister {
- result: event.result,
+ args: captured.args,
+ ...(captured.result !== undefined ? { result: captured.result } : {}),
+ outputBytes: captured.outputBytes,
+ ...(captured.truncated ? { truncated: true } : {}),
+ ...(captured.withheld ? { withheld: captured.withheld } : {}),
</file context>
There was a problem hiding this comment.
Half right, and the half that holds is fixed: duration is now recorded for every step, which matters most exactly where output is withheld — it and the byte count are all that survives. Exit code is not available: pi's tool_execution_end carries isError and the result, no status field, so there is nothing to pass through. Surfacing it would need the same SDK-side channel tracked for the tool span id (traceroot-ai/traceroot-ts#150).
e31e851 to
46ce6f1
Compare
|
The stack was restructured so agent self-trace does not share a stack with the chat-session work. #2067–#2070 are now independent and based on So the 10 commits from #1960 and #1961 ride along here for now, and the only commits belonging to this PR are the last two:
Once #1961 merges to |
| const args = redactDeep(input.args); | ||
| const raw = toText(input.result); | ||
| const outputBytes = Buffer.byteLength(raw, "utf8"); | ||
| if (!OUTPUT_ALLOWLIST.has(input.toolName)) { | ||
| return { args, outputBytes, truncated: false, withheld: "not-allowlisted" }; | ||
| } | ||
| if (state.spentBytes >= budget.perRunBytes) { | ||
| return { args, outputBytes, truncated: false, withheld: "budget" }; | ||
| } | ||
| const { text, truncated } = truncateTo(redactSecrets(raw), budget.perStepBytes); | ||
| state.spentBytes += Buffer.byteLength(text, "utf8"); | ||
| return { args, result: text, outputBytes, truncated, withheld: null }; |
There was a problem hiding this comment.
Low: Unbounded tool arguments
applyCapturePolicy redacts input.args but applies perStepBytes and perRunBytes only to the serialized tool result, while StreamPersister writes captured.args directly into tool_step metadata at line 64. An authenticated project user can send a message to /api/v1/projects/p/sessions/s/messages that causes a tool call with a multi-megabyte argument such as { "command": "A".repeat(10000000) }, and the argument is persisted even after the run's 256 KB budget is exhausted. This lets the attacker consume excessive Postgres row storage and write capacity through oversized tool-I/O records. Bound the redacted serialized arguments and include their bytes in the per-step and per-run budgets before persisting them.
There was a problem hiding this comment.
Same finding as the P2 on line 71 and fixed with it: args are bounded and charged to the step/run budget rather than written through unbounded. Thanks for pinning the exact write path in StreamPersister — that is what made the omission obvious.
46ce6f1 to
d9ed5e7
Compare
d9ed5e7 to
b4c9f8c
Compare
6fdda5b to
a896331
Compare
There was a problem hiding this comment.
1 issue found across 10 files (changes from recent commits).
Confidence score: 3/5
frontend/packages/core/src/lib/capture-policy.ts:capArgsresets the 8 KB limit for each argument leaf, so a step with multiple leaves can exceed the intended per-step capture ceiling and produce oversized output; track one remaining byte budget across all arguments before applying the output limit.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="frontend/packages/core/src/lib/capture-policy.ts">
<violation number="1" location="frontend/packages/core/src/lib/capture-policy.ts:125">
P2: A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because `capArgs` resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| if (typeof value === "string") { | ||
| const remaining = budget.perRunBytes - state.spentBytes; | ||
| if (remaining <= 0) return "[withheld: budget]"; | ||
| const { text } = truncateTo(value, Math.min(budget.perStepBytes, remaining)); |
There was a problem hiding this comment.
P2: A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because capArgs resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At frontend/packages/core/src/lib/capture-policy.ts, line 125:
<comment>A step with multiple argument leaves can exceed the 8 KB per-step capture ceiling because `capArgs` resets the limit for every leaf, then the output gets a separate limit. Track one per-step remaining byte budget across the complete persisted args and result, including structural JSON bytes, before updating the run total.</comment>
<file context>
@@ -68,16 +89,48 @@ export function applyCapturePolicy(
+ if (typeof value === "string") {
+ const remaining = budget.perRunBytes - state.spentBytes;
+ if (remaining <= 0) return "[withheld: budget]";
+ const { text } = truncateTo(value, Math.min(budget.perStepBytes, remaining));
+ state.spentBytes += Buffer.byteLength(text, "utf8");
+ return text;
</file context>
There was a problem hiding this comment.
Valid — the per-leaf reset let an args object with N string fields keep N × perStepBytes, and the result then got its own allowance on top. One remaining-byte budget is now shared across every leaf and the result. Writing the test found a related off-by-three: with less room left than the truncation marker itself, truncateTo still appended it and went over; it keeps nothing in that case now.
0e0394b to
1de5464
Compare
17a8a11 to
dc1d6e2
Compare
dc1d6e2 to
6845175
Compare
The streaming hook held a single messages array and one reader for the whole chat, so an in-flight SSE run kept writing into whatever session the user switched to, and returning to the streaming session showed it frozen (the DB only gets the assistant row when the run completes). Rework useAIStream around per-session state: each run writes to its own session's message bucket, streaming flags are per session, and runs are cancelled per session (send-again, delete) or all at once (close panel, project switch, unmount). useAiChat renders only the active session's bucket, skips the DB history load for sessions that are still streaming so their live progress stays visible, and no longer aborts anything on new-session or session-switch — background runs keep accumulating and can be watched live by switching back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two sends racing before the first POST /sessions resolved each saw a null active-session ref and created separate sessions. ensureSession now parks the in-flight creation promise in a ref and hands it to every caller until it settles, so concurrent sends land in one session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
The shared pending-creation promise outlived a project switch: a send in the new project could adopt a session created for the old one. The project-switch reset now aborts in-flight creations and drops the pending promise, and a settling creation only clears the ref when it still owns it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
A send whose session creation was still in flight could clobber a session boundary the user crossed in the meantime: clicking New Session left the pending creation in pendingSessionRef (so the next send reused the old session instead of opening a fresh one), and the send's post-await commit restored the old session as active, undoing the New Session click, a history selection, or an externally opened RCA session. Every boundary (New Session, history selection, an initialSessionId arriving, close, project switch) now bumps a generation ref; a send only commits its session as active when no boundary was crossed while its creation was in flight. The message itself still delivers to the session it was created for, which stays reachable via history — matching how background streams already behave. New Session also drops the shared pending creation (without aborting it) and syncs the active-session ref so a send in the same tick can't reuse the previous session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
The session-boundary epoch stopped a late-resolving send from re-activating its session, but the message itself still dispatched — so a run could start in a panel the user had closed or a project they had left. Hard boundaries (close, project switch) now bump their own generation, and a send that crossed one returns before dispatch. Soft boundaries (New Session, history selection) keep the existing behavior: the send continues in the background and stays reachable via history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
handleClose aborted the in-flight creation but left pendingSessionRef to the promise's own settle-time self-clear. When the response is already on the wire and the abort settles late, a send after reopening rode the pre-close creation — landing the new conversation in a session built with the closed conversation's trace context. Clear the ref at the boundary, matching handleNewSession; the self-clear's identity check makes the late settle harmless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CLSaVXhLYJxH6DS9mDjiB
…sy state isSending was a single boolean shared by every send, so a pre-close send settling late cleared it while a post-reopen send was still waiting on its session creation — briefly showing the empty greeting and dropping the stop button. Track a count with functional updaters instead; the busy state holds until every in-flight send has wound down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
…vives reload Tool-call bubbles existed only as SSE events: the agent service forwarded them to the client and persisted a single assistant row (with thinking deltas silently concatenated in) when the run finished, so reloading a session dropped every tool step and collapsed interleaved text. Add a StreamPersister that mirrors the run into durable rows: assistant text flushes as a segment at each tool boundary (final segment carries the token usage), each tool call becomes a tool_step row whose metadata holds args/result/isError, and thinking goes to segment metadata instead of content. Inserts are chained so the synchronous event callback cannot interleave them, and a failed run still persists what it produced. History loaders map the persisted rows back into the exact live bubble shapes — tool bubbles, thinking, and the token/cost footer — via a shared mapDbMessages util. buildContext now also restores assistant rows as plain text turns; previously the model lost its own answers on any agent rebuild (service restart or mid-session model switch). Tool steps stay UI-only so the agent re-invokes tools instead of replaying stale results. Billing counted raw assistant rows as chat runs, which would have billed every tool boundary as an extra run once segments landed; the run count and the overage cutoff now count only usage-carrying rows (the final segment — exactly one per run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…store Review follow-ups on the tool-step persistence work: - A run that ends at a tool boundary (no trailing text) previously dropped its token usage entirely, so it escaped run counting and billed cost. StreamPersister.finish now persists a content-less usage-carrying assistant row; buildContext skips such rows and the history mapper folds their usage into the previous assistant bubble, mirroring the live UI. - The error path now passes the accumulated usage to finish, so tokens a run consumed before failing still count toward the meters. - A failing pricing lookup no longer rejects toTokenUsage (which left the SSE response hanging); it falls back to $0 with the tokens kept. - Persisted cost is a Prisma Decimal (a string over JSON); the history mapper coerces it to a number so the usage footer doesn't throw. - The cumulative session token total now survives reload via the final segment's metadata. - Test hygiene: the persister serialization test drains microtasks instead of sleeping, and buildContext's empty-session branch is actually covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
The history mapper folded a content-less usage row into the nearest assistant bubble anywhere earlier in the transcript, so a tool-only run following a normal answer overwrote that answer's usage. The fold now stops at the preceding user turn; a run with no text bubble of its own keeps the carrier as its own row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXy4SCR4Et43F3ejgZTxTw
…execution_id, initiator_user_id) DB unreachable in this worktree (no .env) — migration.sql was hand-written to match the plan verbatim and validated only via `prisma generate`/`tsc`, not applied; appendMessage's return type was widened to return the created row (Promise<AIMessage> in place of Promise<void>) a release early, since Task 13 needs the row id for turnTraceId and the wider return type is a superset any existing caller can ignore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
…, truncate, budget) The redactSecrets gh*_ pattern keeps the plan's documented fix ($1 capture group preserving the prefix) instead of the initial pattern + normalizer, since the normalizer is a no-op once the prefix is preserved on match. Updated the pre-existing "records tool args..." persister test to use download_traces (allowlisted) instead of get_traces so its result assertion still holds under the new policy — get_traces is not allowlisted and would now have its result withheld. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
…s project Six review findings on this PR, all real. **Redaction missed the most common shapes.** The assignment pattern required a 3+ uppercase prefix, so bare `TOKEN=`, `PASSWORD=` and lowercase `api_key=` — what a .env file or a printed environment actually looks like — were persisted verbatim; `Bearer` was case-sensitive, so an echoed lowercase header survived. Both are now case-insensitive, with the name matched on a word boundary so `monkey=` and `token_count=` stay readable. A test pins each shape; it caught an over-broad first attempt that redacted `monkey=business`. **Budgets governed only half of what a step writes.** Args are captured for every tool and a `write` call carries its file body in them, but only the result was bounded. Args are now capped and charged per string leaf (truncating the serialised JSON would leave unparseable metadata), and a step near the run limit takes only the remaining budget instead of a full step on top of it. The truncation marker is reserved rather than appended after the cut — otherwise every truncated capture sat a few bytes over the cap, which a test caught. **Steps recorded no timing.** For a non-allowlisted tool, "what ran and how much came back" is all that survives; a duration makes that useful. Recorded from the start event for every step. **executionId was taken on trust.** It becomes the attribution on every message in the session, so an id from another project would attribute this project's turns to that one. Validated against the project before storing, with the check extracted so it is testable. **capture-policy was on the client barrel.** Same issue as rca-executions: it uses the Node-only Buffer global and `@traceroot/core` is imported by client components. Moved to a subpath export, and the barrel test now covers both. Co-Authored-By: Claude <noreply@anthropic.com>
A truthiness guard skips validation for `executionId: ""` and then stores the empty string, so the session's first message fails the foreign key rather than the route returning the documented 400. The value is normalised to undefined before the check, so an empty or non-string id is simply absent. Co-Authored-By: Claude <noreply@anthropic.com>
…clock Two follow-up review findings on the capture policy. **The per-step cap reset for every args leaf.** An args object with five string fields could keep five times perStepBytes, and the result then got its own allowance on top. One remaining-byte budget is now shared by every leaf and the result, so the step ceiling means what it says. Writing the test surfaced a related off-by-three: with less room left than the truncation marker itself, truncateTo still appended it and pushed the capture over the allowance; it now keeps nothing in that case. **Duration used a wall clock.** Date.now() steps with NTP, DST and manual changes, so a step's persisted durationMs could come out wrong or negative. Measured with performance.now() instead. Co-Authored-By: Claude <noreply@anthropic.com>
… of a hardcoded list New subpath exports (rca-executions, capture-policy) were dropped by the image build's rewrite, so the worker/agent images could not resolve them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DopjNH21gLaDtWeeMDEHX
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
Args of every tool are persisted, including `write` file bodies and `bash` command lines, and allowlisted results are JSON-stringified before redaction. The patterns missed the shapes those actually arrive in: - quoted assignment values (`PASSWORD="…"`, `export DB_PASSWORD='…'`) — the value class stopped at the opening quote; - the colon form (`"password":"…"`, `api_key: …`, `x-api-key: …`) — the dominant shape for span attributes in download_traces output; - Stripe keys (`sk_live_…` / `sk_test_…`) — the pattern required `sk-`; - the password segment of a `scheme://user:pass@host` URL; - PEM private-key blocks, with or without their footer. The known false positives of the name pattern (`sort_key=`, `primary_key=`, `?key=`) are documented rather than special-cased: tool_step rows are display only and never fed back to the model. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…ags consistent truncateTo cut the byte buffer mid-codepoint, so the decoder emitted a 3-byte U+FFFD for the fragment and both the returned text and the bytes charged to the run could exceed the budget the policy exists to enforce. Back the cut off to a codepoint boundary; the multibyte test asserts the byte length of the result and spentBytes against the budgets directly. Two flag inconsistencies while here: args that used up the step allowance left the result as `result: ""` (an empty string that reads as real output) where a spent run budget yields `withheld: "budget"` — both now withhold; and `truncated` only reflected the result, so a cut `bash` command line reported false. capArgs reports whether any leaf was cut and it is OR-ed into the flag. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…icy surface redactDeep and capArgs walked the same args tree twice; redaction now happens in capArgs's string branch, immediately before the cut, so "redact before truncate" is one line instead of two functions. DEFAULT_CAPTURE_BUDGET and OUTPUT_ALLOWLIST had no consumer outside the module anywhere in the stack and are no longer exported. The dangling "Capture policy" comment on the core barrel is folded into the existing NOTE about subpath-only modules. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…Attribution Both appendMessage callers pass the turn's attribution, so the fallback that derived one from the session (and its per-row aIMessage.count query and the extra userId/executionId select) never ran — and it encoded a different rule (prior-assistant count) from the one the route uses (x-user-id presence). Dead code with a second, contradictory rule is what a later contributor "fixes" into use. Delete it and make attribution required; it moves ahead of the optional metadata/tokenUsage, so the signature is now appendMessage(role, content, attribution, metadata?, tokenUsage?) The persister's AppendMessageFn loses the `attribution?` parameter it never passed (the route binds attribution into the closure). The hand-written TurnKind union is replaced by Prisma's generated enum type, re-exported from the core barrel like the other Prisma types. The "later turn → rca_followup" test only echoed its own input and is replaced by tests of what appendMessage actually does: the columns it writes, the legacy `kind` per turnKind, and the missing-session error. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
… drop durationMs
The persister owned its own `{ spentBytes }` while the SDK's captureToolIo
hook (next PR) charges the run's accumulator — two budgets that agree only
while both see the identical event sequence, and that double the effective
cap the moment they diverge. StreamPersister takes an optional `state` in a
constructor options object so the route can hand it the run's accumulator;
omitted, it still allocates its own. The capture-policy docstring no longer
claims the two stores hold "exactly the same content" — that depends on the
shared state, which the docstring now says.
durationMs and the pendingToolStart map go: nothing in the stack reads them,
and once tool spans carry start/end the span is the timing source.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
A non-string executionId (a number, an object, null) was coerced to undefined
and the session created unattributed — a worker bug sending `{executionId: 123}`
would have produced unattributed executions with no error. Any executionId
present in the body must now be a non-empty string, or the route returns 400;
"" and whitespace take the same path instead of being silently dropped.
No route in ee/agent had a test, which left the executionId normalisation, the
400 branches and the per-turn attribution ternary (x-user-id present → chat /
rca_followup, absent → rca_execution) uncovered. routes.test.ts drives the Hono
app through app.request() with the service's start-up stubbed, and runs the
real StreamPersister and capture policy under the route's own closure to lock
the rule that every row a turn produces — user, text, thinking-only, tool_step,
usage carrier, and the rows persisted on the error path — carries the same
attribution.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
…ion_id a foreign key ai_messages.execution_id had an ON DELETE SET NULL foreign key but no index, so deleting an execution (cascaded from a finding delete) scanned the table to null it — and the agent-trace viewer reads a finding's turns by execution. ai_sessions.execution_id had neither, so it could dangle after an execution delete. Both now carry the FK (SET NULL / NO ACTION, matching the execution table's own relations) and an index. The two session↔execution relations are named so Prisma can tell the execution that opened a session apart from the session an execution records once it completes. The speculative ix_ai_message_workspace_turnkind_time is dropped: metering still groups by `kind`, and nothing in the stack queries by turnKind yet. The unmerged migration is edited in place. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
6845175 to
48b930c
Compare
Rebased onto the fixed attribution tip. #2070 dropped advanceLatest, setExecutionTraceStatus, detector_rcas.latest_execution_id and detector_rca_executions.result; the worker now finishes a finding through finishFindingIfLatest (one guarded write for done and failed alike), writes the execution row's traceStatus/sessionId/finishedAt in one update, and gates the pre-run "running" flip on the attempt still being the highest (markFindingRunningIfLatest). This PR's own failFindingIfLatest is gone — it duplicated the new helper. runRcaSession's execution identity is required; a stream that carried output but no `trace` frame records `failed`. #2071 made attribution a required appendMessage argument and let the persister charge an external capture budget: the route now builds the persister inside the traced run and hands it the run's own accumulator, so spans and rows share one budget. The trace kind is derived from the turn's attribution, and the parent-execution lookup for a follow-up only runs when that follow-up will be traced and can no longer fail the turn. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
Since #2071 the persister stores a tool step's result as text (serialised, then redacted and truncated) and records why a result was not kept (`withheld`), whether the capture was cut (`truncated`) and the real output size (`outputBytes`). The sidebar still JSON.stringified the metadata value, so a reloaded step showed a one-line escaped string where the live stream had shown a pretty-printed object, and a withheld `bash` result reloaded as a bubble with no output and no explanation. Parse an intact JSON result back into the value the live stream rendered (a truncated one cannot be valid JSON, a plain-string result never was; both stay text), and render the capture verdict: [output withheld: not-allowlisted · 44 bytes] [captured I/O truncated · 90,000 bytes of output] Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DXLChvn8HPA42wsuDUFYo
Two changes that both write to Postgres and are therefore independent of any trace flag.
Attribution.
ai_messagesgainsturn_kind(rca_execution | rca_followup | chat | detector | digest, set per turn at write time),execution_id(FK todetector_rca_executions) andinitiator_user_id;ai_sessionsgainsexecution_id. Today all of this is inferred fromai_sessions.user_id IS NULL. The migration fillsturn_kinddeterministically from the existingkindcolumn — a column rename in effect, not a backfill — so metering never needs a dual read path.Capture policy for persisted tool I/O, one module applied identically to span attributes and
tool_stepmetadata:ghp_/gho_,sk-,AKIA, bearer tokens,.env-style assignments)download_traces,download_session,submit_result, i.e. data the customer already owns in TraceRoot.bash,read,write,git_clone,check_github_accessrecord exit code, byte count and duration onlyLands before any emit, because the step rows write to Postgres immediately and are not behind the flag.
Closes #2062. Part of #2058.