From 752173a2ca22dad1c7c4b4ca2c9cc1ca72e9be0a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:01:38 -0700 Subject: [PATCH 1/7] feat(traces): link native messages to bounded run audit Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- .../service/runTraceToolCalls.test.js | 24 ++++ .../__tests__/unit/routes/runTraces.test.js | 112 ++++++++++++++++++ .../agentMessageService.threadWrites.test.js | 23 ++++ .../services/approvalActionService.test.js | 8 ++ .../nativeRuntimeService.hostedOffer.test.js | 1 + backend/models/AgentRun.ts | 13 ++ backend/models/ApprovalAction.ts | 3 + backend/models/ToolCall.ts | 31 +++++ backend/routes/runTraces.ts | 67 +++++++++++ backend/server.ts | 1 + backend/services/agentMessageService.ts | 12 +- backend/services/agentRunLimits.ts | 79 ++++++++++++ backend/services/approvalActionService.ts | 6 + backend/services/nativeRuntimeService.ts | 9 ++ docs/development/run-traces.md | 66 +++++++++++ .../preview/components-run-trace.html | 11 ++ frontend/src/i18n/locales/en.json | 27 +++++ frontend/src/i18n/locales/zh-CN.json | 27 +++++ frontend/src/v2/V2App.tsx | 5 + frontend/src/v2/__tests__/V2RunTrace.test.tsx | 55 +++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 11 ++ frontend/src/v2/components/V2MessageRow.tsx | 8 +- frontend/src/v2/components/V2RunTrace.css | 20 ++++ frontend/src/v2/components/V2RunTrace.tsx | 76 ++++++++++++ frontend/src/v2/components/V2RunTraceLink.tsx | 21 ++++ frontend/src/v2/hooks/useV2PodDetail.ts | 1 + 26 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 backend/__tests__/service/runTraceToolCalls.test.js create mode 100644 backend/__tests__/unit/routes/runTraces.test.js create mode 100644 backend/routes/runTraces.ts create mode 100644 backend/services/agentRunLimits.ts create mode 100644 docs/development/run-traces.md create mode 100644 frontend/design-system/preview/components-run-trace.html create mode 100644 frontend/src/v2/__tests__/V2RunTrace.test.tsx create mode 100644 frontend/src/v2/components/V2RunTrace.css create mode 100644 frontend/src/v2/components/V2RunTrace.tsx create mode 100644 frontend/src/v2/components/V2RunTraceLink.tsx diff --git a/backend/__tests__/service/runTraceToolCalls.test.js b/backend/__tests__/service/runTraceToolCalls.test.js new file mode 100644 index 0000000000..8ad8c4916a --- /dev/null +++ b/backend/__tests__/service/runTraceToolCalls.test.js @@ -0,0 +1,24 @@ +/** Read joins must execute against real PostgreSQL, not only pg-mem. */ +const ToolCall = require('../../models/ToolCall'); +const { pool } = require('../../config/db-pg'); +const run = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip; +const grantId = `lark-trace-${Date.now()}`; +const podId = `${grantId}-pod`; +run('run trace attribution on real PostgreSQL', () => { + afterAll(async () => { await pool.query('DELETE FROM tool_calls WHERE grant_id = $1', [grantId]); }); + it('reads only referenced calls in the authorized pod, with current approval outcome', async () => { + const record = (id, over = {}) => ({ callId: `${grantId}-${id}`, grantId, podId, + agentUserId: 'agent', credentialOwnerId: 'credential-owner', tool: 'vendor.read', + argsDigest: 'a'.repeat(64), outcome: 'ok', ...over }); + await ToolCall.create(record('ok')); + await ToolCall.create(record('pending', { outcome: 'pending_approval', approvalId: 'approval' })); + await ToolCall.create(record('resolved', { outcome: 'failed', approvalId: 'approval' })); + await ToolCall.create(record('foreign', { podId: 'another-pod' })); + const ids = ['ok', 'pending', 'foreign'].map((id) => `${grantId}-${id}`); + const rows = await ToolCall.listRunReferences(podId, ids); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.callId.endsWith('-ok'))).toMatchObject({ grantId, credentialOwnerId: 'credential-owner', outcome: 'ok' }); + expect(rows.find((r) => r.callId.endsWith('-pending')).outcome).toBe('superseded'); + expect(await ToolCall.listRunReferences(podId, [])).toEqual([]); + }); +}); diff --git a/backend/__tests__/unit/routes/runTraces.test.js b/backend/__tests__/unit/routes/runTraces.test.js new file mode 100644 index 0000000000..32d03d8601 --- /dev/null +++ b/backend/__tests__/unit/routes/runTraces.test.js @@ -0,0 +1,112 @@ +jest.mock('../../../config/db-pg', () => { + const { newDb } = require('pg-mem'); + const { Pool } = newDb().adapters.createPg(); + return { pool: new Pool() }; +}); +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +jest.mock('../../../middleware/auth', () => (req, res, next) => { + if (!req.get('x-user')) return res.sendStatus(401); + req.user = { id: req.get('x-user') }; + return next(); +}); +jest.mock('../../../services/dmService', () => ({ + canViewPod: jest.fn(async (userId, pod) => Boolean(pod?.members.some((m) => String(m) === userId))), +})); +const AgentRun = require('../../../models/AgentRun'); +const Pod = require('../../../models/Pod'); +const ToolCall = require('../../../models/ToolCall'); +const { pool } = require('../../../config/db-pg'); +const { boundRunValue, RUN_TRACE_BYTES } = require('../../../services/agentRunLimits'); +const { BSON } = require('mongodb'); +const MEMBER = new mongoose.Types.ObjectId(); +const POD = new mongoose.Types.ObjectId(); +const OTHER = new mongoose.Types.ObjectId(); +let mongo; +let app; +let run; +beforeAll(async () => { + mongo = await MongoMemoryServer.create(); + await mongoose.connect(mongo.getUri()); + app = express(); + app.use('/api/pods', require('../../../routes/runTraces')); +}); +afterAll(async () => { await mongoose.disconnect(); await mongo.stop(); }); +beforeEach(async () => { + await AgentRun.deleteMany({}); + await Pod.deleteMany({}); + await Pod.create({ _id: POD, name: 'Trace pod', type: 'team', members: [MEMBER], createdBy: MEMBER }); + run = await AgentRun.create({ + podId: POD, agentName: 'trace-seat', instanceId: 'default', trigger: 'mention', status: 'succeeded', + startedAt: new Date('2020-01-01'), + turns: [{ turnIndex: 0, model: 'model', elapsedMs: 1, llmResponseText: 'private prompt', + toolCalls: [{ name: 'vendor.read', args: { secret: 'PRIVATE_ARGS' }, result: 'PRIVATE_RESULT', callId: 'trace-call', outcome: 'ok' }] }], + }); + await ToolCall.create({ callId: `unused-${run.id}`, grantId: 'grant', podId: String(POD), agentUserId: 'seat', tool: 'vendor.read', argsDigest: 'a'.repeat(64), outcome: 'ok' }); + await pool.query('DELETE FROM tool_calls WHERE call_id IN ($1, $2)', ['trace-call', 'foreign-call']); + await ToolCall.create({ callId: 'trace-call', grantId: 'grant', podId: String(POD), credentialOwnerId: 'owner', agentUserId: 'seat', tool: 'vendor.read', argsDigest: 'a'.repeat(64), outcome: 'ok' }); + await ToolCall.create({ callId: 'foreign-call', grantId: 'foreign-grant', podId: String(OTHER), credentialOwnerId: 'foreign-owner', agentUserId: 'seat', tool: 'vendor.read', argsDigest: 'a'.repeat(64), outcome: 'failed' }); +}); +const get = (pod = POD, id = run.id, user = MEMBER) => request(app).get(`/api/pods/${pod}/runs/${id}`).set('x-user', String(user)); + +test('requires authentication and current pod access, not knowledge of a run ID', async () => { + expect((await request(app).get(`/api/pods/${POD}/runs/${run.id}`)).status).toBe(401); + expect((await get(POD, run.id, OTHER)).status).toBe(404); + expect((await get(OTHER)).status).toBe(404); + expect((await get(POD, 'bad-id')).status).toBe(404); +}); +test('returns durable turn + PG attribution after card retention without content', async () => { + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.body.run.turns[0].toolCalls[0].trail).toMatchObject({ callId: 'trace-call', grantId: 'grant', credentialOwnerId: 'owner', outcome: 'ok' }); + expect(JSON.stringify(res.body)).not.toMatch(/PRIVATE_|private prompt|argsDigest/); + expect(res.body.retention).toEqual({ cardDefaultDays: 30, traceExpiresAt: null }); +}); +test('cross-pod references never join a foreign trail', async () => { + run.turns[0].toolCalls[0].callId = 'foreign-call'; + await run.save(); + const res = await get(); + expect(res.status).toBe(200); + expect(res.body.run.turns[0].toolCalls[0]).toMatchObject({ outcome: 'unavailable', trail: null }); + expect(JSON.stringify(res.body)).not.toMatch(/foreign-owner|foreign-grant/); +}); +test('PG failure is explicit instead of an empty audit', async () => { + jest.spyOn(ToolCall, 'listRunReferences').mockRejectedValueOnce(new Error('db offline')); + expect((await get()).status).toBe(503); +}); +test('save caps UTF-8 args/results, response text and aggregate BSON size', async () => { + const huge = '界'.repeat(10000); + run.turns = Array.from({ length: 140 }, (_, turnIndex) => ({ + turnIndex, model: 'm', elapsedMs: 1, llmResponseText: huge, + toolCalls: Array.from({ length: turnIndex === 0 ? 70 : 0 }, () => ({ name: 'read', elapsedMs: 1, args: huge, result: huge })), + })); + await run.save(); + const stored = await AgentRun.findById(run.id).lean(); + expect(stored.traceTruncated).toBe(true); + expect(stored.turns[0].toolCalls[0].args).toEqual({ truncated: true, originalBytes: Buffer.byteLength(JSON.stringify(huge)) }); + expect(stored.omittedTurns).toBeGreaterThan(0); + expect(stored.omittedToolCalls).toBeGreaterThan(0); + expect(Buffer.byteLength(JSON.stringify(stored.turns))).toBeLessThan(RUN_TRACE_BYTES); + expect(BSON.calculateObjectSize(JSON.parse(JSON.stringify(stored)))).toBeLessThan(8 * 1024 * 1024); + expect(boundRunValue({ small: 'kept' })).toEqual({ small: 'kept' }); + expect(boundRunValue([...[...Array(300)].map(() => 0)])).toMatchObject({ truncated: true }); + await run.save(); + expect(run.omittedTurns).toBe(stored.omittedTurns); +}); + + +test('aggregate budget caps many individually valid values before Mongo insert', async () => { + run.turns = Array.from({ length: 80 }, (_, turnIndex) => ({ + turnIndex, model: 'm', elapsedMs: 1, + toolCalls: Array.from({ length: 10 }, () => ({ name: 'read', elapsedMs: 1, + args: 'x'.repeat(4000), result: 'y'.repeat(4000) })), + })); + await run.save(); + const stored = await AgentRun.findById(run.id).lean(); + expect(stored.omittedToolCalls).toBeGreaterThan(0); + expect(BSON.calculateObjectSize(JSON.parse(JSON.stringify(stored.turns)))).toBeLessThan(RUN_TRACE_BYTES); +}); diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index 3b679d5444..95575f893e 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -430,3 +430,26 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { expect(mockFirstTask).not.toHaveBeenCalled(); }); }); + + +test('server run identity survives PG storage and live message broadcast with existing card fields', async () => { + const runId = 'aaaaaaaaaaaaaaaaaaaaa111'; + await AgentMessageService.postMessage({ + agentName: 'sprint-review', instanceId: 'default', podId: POD, + content: 'Run complete.', messageType: 'card', + payload: { kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId: 'forged' }, runId, + }); + expect(PGMessage.create.mock.calls[0][5]).toEqual({ + kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId, + }); + expect(emitted.find((entry) => entry.event === 'newMessage').payload.payload.runId).toBe(runId); +}); + + +test('an agent-authored payload cannot claim another run', async () => { + await AgentMessageService.postMessage({ + agentName: 'sprint-review', instanceId: 'default', podId: POD, + content: 'A card.', payload: { kind: 'ui', runId: 'aaaaaaaaaaaaaaaaaaaaa111' }, + }); + expect(PGMessage.create.mock.calls[0][5]).toEqual({ kind: 'ui' }); +}); diff --git a/backend/__tests__/unit/services/approvalActionService.test.js b/backend/__tests__/unit/services/approvalActionService.test.js index 2409cfaa48..b5f2d435c6 100644 --- a/backend/__tests__/unit/services/approvalActionService.test.js +++ b/backend/__tests__/unit/services/approvalActionService.test.js @@ -490,3 +490,11 @@ describe('approved connect_local_agent execution — the seat, never the token', expect(mockInstallUpsert).not.toHaveBeenCalled(); }); }); + + +test('approval card rebuilds keep their originating run after a transition', () => { + const { buildCardPayload } = require('../../../services/approvalActionService'); + const row = flaggedRow({ runId: 'aaaaaaaaaaaaaaaaaaaaa111' }); + expect(buildCardPayload(row).runId).toBe('aaaaaaaaaaaaaaaaaaaaa111'); + expect(buildCardPayload({ ...row, status: 'resolved', decision: 'declined' }).runId).toBe('aaaaaaaaaaaaaaaaaaaaa111'); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js index 89aa7d7b01..35e9a2d543 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedOffer.test.js @@ -90,6 +90,7 @@ describe('commonly_offer_hosted_agent', () => { const posted = AgentMessageService.postMessage.mock.calls[0][0]; expect(posted.content).toBe(REPLY); expect(posted.podId).toBe(POD); + expect(posted.runId).toBe('run-1'); expect(posted.payload).toEqual({ kind: 'starts', starts: [{ id: 'run-hosted', label: 'Run it here', href: `/v2/agents/byo?pod=${POD}&mode=hosted` }], diff --git a/backend/models/AgentRun.ts b/backend/models/AgentRun.ts index 25fa789322..96ad06ea03 100644 --- a/backend/models/AgentRun.ts +++ b/backend/models/AgentRun.ts @@ -1,3 +1,4 @@ +import { boundAgentRun } from '../services/agentRunLimits'; import mongoose, { Document, Model, Schema, Types } from 'mongoose'; /** @@ -78,6 +79,9 @@ export interface IAgentRun extends Document { triggerEventId?: Types.ObjectId; status: AgentRunStatus; turns: IAgentRunTurn[]; + traceTruncated?: boolean; + omittedTurns?: number; + omittedToolCalls?: number; totalTokens: number; /** Seconds the turn's run-code sandbox ran (TASK-266); absent when no code ran. */ sandboxSeconds?: number; @@ -150,6 +154,9 @@ const AgentRunSchema = new Schema( default: 'queued', }, turns: { type: [AgentRunTurnSchema], default: [] }, + traceTruncated: { type: Boolean }, + omittedTurns: { type: Number }, + omittedToolCalls: { type: Number }, totalTokens: { type: Number, default: 0 }, sandboxSeconds: { type: Number }, webSearches: { type: Number }, @@ -167,6 +174,12 @@ const AgentRunSchema = new Schema( { timestamps: true }, ); +// Bound every document save, including writers outside the native loop. +AgentRunSchema.pre('validate', function capTrace() { + const bounded = boundAgentRun(this.toObject()); + this.set(bounded); +}); + AgentRunSchema.index({ podId: 1, agentName: 1, instanceId: 1, startedAt: -1 }); // The per-user ceiling's count (D5) — sparse: only stamped rows carry userId. AgentRunSchema.index({ userId: 1, startedAt: -1 }, { sparse: true }); diff --git a/backend/models/ApprovalAction.ts b/backend/models/ApprovalAction.ts index ce742e2818..07db54a6ba 100644 --- a/backend/models/ApprovalAction.ts +++ b/backend/models/ApprovalAction.ts @@ -55,6 +55,8 @@ export interface IApprovalAction extends Document { // Message id of the rendered card. String because messages live in PG // (serial int) with a Mongo fallback (ObjectId hex). messageId?: string; + /** Originating native run; persists when the card changes state. */ + runId?: Types.ObjectId; ownerUserId: Types.ObjectId; agentName: string; /** Server-only executor identity for tool_call; never in the shared card. */ @@ -89,6 +91,7 @@ const ApprovalActionSchema = new Schema( { podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true }, messageId: { type: String }, + runId: { type: Schema.Types.ObjectId, ref: 'AgentRun' }, ownerUserId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, agentName: { type: String, required: true, lowercase: true, trim: true }, agentUserId: { type: String }, diff --git a/backend/models/ToolCall.ts b/backend/models/ToolCall.ts index d6716eb2b9..f5b0dc539d 100644 --- a/backend/models/ToolCall.ts +++ b/backend/models/ToolCall.ts @@ -267,6 +267,37 @@ class ToolCall { ); } + /** Only server-recorded references from an already-authorized run. */ + static async listRunReferences(podId: string, callIds: string[]): Promise> { + if (!callIds.length) return []; + const db = await ensureTables(); + const ids = [...new Set(callIds)].slice(0, 8192); + const placeholders = ids.map((_id, index) => `$${index + 2}`).join(', '); + const result = await db.query( + `SELECT tc.call_id, tc.grant_id, tc.credential_owner_id, tc.tool, + tc.occurred_at, tc.duration_ms, + CASE WHEN tc.outcome = 'pending_approval' AND resolved.approval_id IS NOT NULL + THEN 'superseded' ELSE tc.outcome END AS effective_outcome + FROM tool_calls tc + LEFT JOIN (SELECT DISTINCT grant_id, approval_id FROM tool_calls + WHERE pod_id = $1 AND outcome <> 'pending_approval' + AND approval_id IS NOT NULL) resolved + ON resolved.grant_id = tc.grant_id AND resolved.approval_id = tc.approval_id + WHERE tc.pod_id = $1 AND tc.call_id IN (${placeholders})`, + [podId, ...ids], + ); + return result.rows.map((row) => ({ + callId: String(row.call_id), grantId: String(row.grant_id), + credentialOwnerId: row.credential_owner_id ? String(row.credential_owner_id) : null, + outcome: String(row.effective_outcome), tool: String(row.tool), + at: new Date(String(row.occurred_at)), + durationMs: row.duration_ms == null ? null : Number(row.duration_ms), + })); + } + static async listForGrant(grantId: string, limit = 100): Promise { const db = await ensureTables(); const result = await db.query( diff --git a/backend/routes/runTraces.ts b/backend/routes/runTraces.ts new file mode 100644 index 0000000000..c040ae4ff8 --- /dev/null +++ b/backend/routes/runTraces.ts @@ -0,0 +1,67 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import AgentRun from '../models/AgentRun'; +import ToolCall from '../models/ToolCall'; + +const auth = require('../middleware/auth'); +const Pod = require('../models/Pod'); +const DMService = require('../services/dmService'); +const router = express.Router(); + +// Trace access follows today's pod access, never the original grant audience +// or the continued existence of the card. Public pod listing is insufficient. +router.get('/:podId/runs/:runId', auth, async (req, res) => { + try { + const { podId, runId } = req.params; + if (!mongoose.isObjectIdOrHexString(podId) || !mongoose.isObjectIdOrHexString(runId)) { + return res.status(404).json({ error: 'trace_not_found' }); + } + const userId = (req as express.Request & { user?: { id: string }; userId?: string }).user?.id + || (req as express.Request & { userId?: string }).userId; + const pod = await Pod.findById(podId).select('members type').lean(); + if (!await DMService.canViewPod(userId, pod)) { + return res.status(404).json({ error: 'trace_not_found' }); + } + // Do not load content-bearing args, results or LLM text even transiently. + const run = await AgentRun.findOne({ _id: runId, podId }) + .select([ + 'agentName instanceId status trigger startedAt completedAt totalTokens errorKind', + 'traceTruncated omittedTurns omittedToolCalls', + 'turns.turnIndex turns.model turns.elapsedMs turns.promptTokens turns.completionTokens turns.totalTokens', + 'turns.toolCalls.name turns.toolCalls.elapsedMs turns.toolCalls.callId turns.toolCalls.outcome', + ].join(' ')) + .lean(); + if (!run) return res.status(404).json({ error: 'trace_not_found' }); + const callIds = run.turns.flatMap((turn) => turn.toolCalls.flatMap((call) => (call.callId ? [call.callId] : []))); + // A PG outage is an error, never an apparently empty successful audit. + const trail = await ToolCall.listRunReferences(podId, callIds); + const byId = new Map(trail.map((call) => [call.callId, call])); + res.set('Cache-Control', 'no-store'); + return res.json({ + run: { + id: String(run._id), podId, agentName: run.agentName, instanceId: run.instanceId, + status: run.status, trigger: run.trigger, startedAt: run.startedAt, + completedAt: run.completedAt, totalTokens: run.totalTokens, errorKind: run.errorKind, + traceTruncated: Boolean(run.traceTruncated), omittedTurns: run.omittedTurns || 0, + omittedToolCalls: run.omittedToolCalls || 0, + turns: run.turns.map((turn) => ({ + turnIndex: turn.turnIndex, model: turn.model, elapsedMs: turn.elapsedMs, + promptTokens: turn.promptTokens, completionTokens: turn.completionTokens, totalTokens: turn.totalTokens, + toolCalls: turn.toolCalls.map((call) => ({ + name: call.name, elapsedMs: call.elapsedMs, callId: call.callId, + outcome: call.callId ? byId.get(call.callId)?.outcome || 'unavailable' : call.outcome || 'recorded', + trail: call.callId ? byId.get(call.callId) || null : undefined, + })), + })), + }, + // A default, not a fabricated per-message expiry: Pro protection and + // disk pressure can change message retention. Neither deletes this trace. + retention: { cardDefaultDays: 30, traceExpiresAt: null }, + }); + } catch (error) { + console.error('[run-trace] read failed:', (error as Error).message); + return res.status(503).json({ error: 'trace_unavailable' }); + } +}); + +module.exports = router; diff --git a/backend/server.ts b/backend/server.ts index 13a0e2de4b..68a134bd15 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -248,6 +248,7 @@ app.use('/api', podInvitesRoutes); app.use('/api', attributionRoutes); // Before podRoutes: its `/:type/:id` catch-all would answer `/:podId/grants` as type=, id='grants' (Wren 67721). app.use('/api/pods', require('./routes/grants').podGrantsRouter); // tools plan §6: GET /api/pods/:podId/grants, the page's list +app.use('/api/pods', require('./routes/runTraces')); app.use('/api/pods', podRoutes); app.use('/api/billing', require('./routes/billing')); app.use('/api/messages', messageRoutes); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 1114c2079c..e7c6b9a7e7 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -157,6 +157,8 @@ interface MetadataDoc { } interface PostMessageOptions { + /** Server-owned AgentRun identity. Never forwarded from runtime HTTP input. */ + runId?: string; agentName: string; podId: unknown; content: string; @@ -1571,6 +1573,14 @@ class AgentMessageService { } } + // Provenance is kernel-owned even when the presentation is agent-authored. + let messagePayload = payload; + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + messagePayload = { ...payload } as Record; + delete (messagePayload as Record).runId; + } + if (options.runId) messagePayload = { ...(messagePayload as object || {}), runId: options.runId }; + const posted = await AgentMessageService._postToTarget({ agentName, instanceId, @@ -1578,7 +1588,7 @@ class AgentMessageService { content: sanitizedContent, messageType, metadata, - payload, + payload: messagePayload, relayCard, agentUser, displayName, diff --git a/backend/services/agentRunLimits.ts b/backend/services/agentRunLimits.ts new file mode 100644 index 0000000000..29b061fd15 --- /dev/null +++ b/backend/services/agentRunLimits.ts @@ -0,0 +1,79 @@ +import { BSON } from 'mongodb'; +import type { IAgentRunTurn, IAgentRunTurnToolCall } from '../models/AgentRun'; + +// An audit summary, not another copy of tool content. Keep turns below two +// MiB in BSON, leaving room for top-level metrics far below Mongo's 16 MiB. +// Count limits also bound the amount of trace the browser has to render. +export const RUN_TRACE_BYTES = 2 * 1024 * 1024; +export const RUN_VALUE_BYTES = 4096; +const MAX_TURNS = 128; +const MAX_CALLS_PER_TURN = 64; + +const text = (value: unknown, limit = 256): string | undefined => ( + typeof value === 'string' ? value.slice(0, limit) : undefined +); + +export function boundRunValue(value: unknown): unknown { + if (value === undefined) return undefined; + try { + const encoded = JSON.stringify(value); + const bytes = Buffer.byteLength(encoded ?? 'null'); + // Limit nested/array overhead independently of its encoded byte size. + let nodes = 0; + JSON.stringify(value, (_key, entry) => { nodes += 1; return entry; }); + return bytes <= RUN_VALUE_BYTES && nodes <= 256 + ? value : { truncated: true, originalBytes: bytes }; + } catch { return { truncated: true, unserializable: true }; } +} + +export function boundAgentRun(run: { + turns?: IAgentRunTurn[]; traceTruncated?: boolean; omittedTurns?: number; + omittedToolCalls?: number; errorMessage?: string; +}) { + let omittedTurns = run.omittedTurns || 0; + let omittedToolCalls = run.omittedToolCalls || 0; + let truncated = Boolean(run.traceTruncated); + let remaining = RUN_TRACE_BYTES; + const turns: IAgentRunTurn[] = []; + for (const original of run.turns || []) { + if (turns.length >= MAX_TURNS) { + omittedTurns += 1; + omittedToolCalls += original.toolCalls.length; + continue; + } + const turn: IAgentRunTurn = { + turnIndex: original.turnIndex, model: text(original.model) || 'unknown', + promptTokens: original.promptTokens, completionTokens: original.completionTokens, + totalTokens: original.totalTokens, elapsedMs: original.elapsedMs, + liteLLMCallId: text(original.liteLLMCallId), + llmResponseText: text(original.llmResponseText, 4096), toolCalls: [], + }; + if (original.llmResponseText !== turn.llmResponseText) truncated = true; + const overhead = BSON.calculateObjectSize(JSON.parse(JSON.stringify(turn))) + 32; + if (remaining < overhead) { + omittedTurns += 1; + omittedToolCalls += original.toolCalls.length; + continue; + } + remaining -= overhead; + for (const originalCall of original.toolCalls) { + const call: IAgentRunTurnToolCall = { + name: text(originalCall.name) || 'unknown', elapsedMs: originalCall.elapsedMs, + args: boundRunValue(originalCall.args), result: boundRunValue(originalCall.result), + error: text(originalCall.error, 1024), callId: text(originalCall.callId), + outcome: text(originalCall.outcome), + }; + if (call.args !== originalCall.args || call.result !== originalCall.result + || call.error !== originalCall.error) truncated = true; + const bytes = BSON.calculateObjectSize(JSON.parse(JSON.stringify(call))) + 16; + if (turn.toolCalls.length >= MAX_CALLS_PER_TURN || remaining < bytes) omittedToolCalls += 1; + else { turn.toolCalls.push(call); remaining -= bytes; } + } + turns.push(turn); + } + return { + turns, omittedTurns, omittedToolCalls, + traceTruncated: truncated || omittedTurns > 0 || omittedToolCalls > 0, + errorMessage: text(run.errorMessage, 1000), + }; +} diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index 576b124043..d25e1a6479 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -51,6 +51,7 @@ const LOCAL_AGENT_SCOPES = [ ]; export interface CardPayload { + runId?: string; kind: typeof CARD_KIND; approvalId: string; actionType: ApprovalActionType; @@ -86,6 +87,7 @@ const legacyToolCallOutcome = (row: IApprovalAction): Partial => { export const buildCardPayload = (row: IApprovalAction): CardPayload => ({ kind: CARD_KIND, + ...(row.runId ? { runId: String(row.runId) } : {}), approvalId: String(row._id), actionType: row.actionType, summary: row.summary, @@ -127,6 +129,8 @@ export const buildOwnerCardPayload = ( }; interface ProposeOptions { + /** Internal runtime identity, never copied from the agent HTTP body. */ + runId?: string; podId: string; agentName: string; instanceId: string; @@ -471,6 +475,7 @@ export const proposeAction = async (options: ProposeOptions): Promise +Run trace · Signal + + +
+Back to pod

Run trace

+

Cards have a 30-day default retention. Pod policy and storage limits can change it. This trace persists after the card expires.

+
Adasucceeded · mentionRecorded turns: 1 · 1264 tokens
+
  1. Turn 1

    model · 845 ms · 1264 tokens

    +
    linear.search_issuesok · 241 ms
    Grant
    grant_12345678-1234-1234-1234-123456789abc
    Credential owner
    owner-id-recorded-at-call-time
    Outcome
    ok
+
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4ab9bb0a73..f8a51fdbb1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1,4 +1,31 @@ { + "runTrace": { + "title": "Run trace", + "back": "Back to pod", + "view": "View run trace", + "linkRetention": "Cards: 30-day default. Trace persists.", + "retention": "Cards have a 30-day default retention. Pod policy and storage limits can change it. This trace persists after the card expires.", + "loading": "Loading run trace…", + "access": "This trace is unavailable, or you no longer have access to its pod.", + "error": "Could not load the run trace. Try again.", + "retry": "Try again", + "counts": "Recorded turns: {{turns}} · {{tokens}} tokens", + "stopped": "Stopped: {{kind}}", + "running": "This run is still in progress.", + "refresh": "Refresh trace", + "limited": "This stored trace was size-limited. {{turns}} turns and {{calls}} tool calls are omitted. Large content fields are also capped.", + "empty": "No turns recorded yet.", + "turn": "Turn {{number}}", + "tokens": "tokens", + "noTools": "No tool calls.", + "call": "Call {{id}}", + "grant": "Grant", + "owner": "Credential owner", + "outcome": "Outcome", + "notRecorded": "Not recorded", + "unavailable": "Broker trail unavailable.", + "builtIn": "Built-in tool. No connector credential." + }, "common": { "brandName": "Commonly", "brandWordmark": "commonly", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 51e6c8fe45..78247343cd 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1,4 +1,31 @@ { + "runTrace": { + "title": "运行轨迹", + "back": "返回 Pod", + "view": "查看运行轨迹", + "linkRetention": "卡片默认保留 30 天,轨迹持续保留。", + "retention": "卡片默认保留 30 天,Pod 策略和存储限制可能改变期限。卡片过期后,运行轨迹仍会保留。", + "loading": "正在加载运行轨迹…", + "access": "此轨迹不可用,或你已无权访问对应的 Pod。", + "error": "无法加载运行轨迹,请重试。", + "retry": "重试", + "counts": "已记录 {{turns}} 轮 · {{tokens}} 个 token", + "stopped": "停止原因:{{kind}}", + "running": "此运行尚未结束。", + "refresh": "刷新轨迹", + "limited": "此轨迹受到存储大小限制,省略了 {{turns}} 轮和 {{calls}} 次工具调用,大型内容字段也已截断。", + "empty": "尚未记录任何轮次。", + "turn": "第 {{number}} 轮", + "tokens": "个 token", + "noTools": "无工具调用。", + "call": "调用 {{id}}", + "grant": "授权", + "owner": "凭据所有者", + "outcome": "结果", + "notRecorded": "未记录", + "unavailable": "代理调用轨迹不可用。", + "builtIn": "内置工具,未使用连接器凭据。" + }, "common": { "brandName": "Commonly", "brandWordmark": "commonly", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index bc826d2c15..ba71c593f1 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -39,6 +39,7 @@ const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO')); const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage')); const V2ConnectorsPage = React.lazy(() => import('./components/V2ConnectorsPage')); const V2PodBoard = React.lazy(() => import('./components/V2PodBoard')); +const V2RunTrace = React.lazy(() => import('./components/V2RunTrace')); const V2ActivityPage = React.lazy(() => import('./components/V2ActivityPage')); const V2ArtifactsPage = React.lazy(() => import('./components/V2ArtifactsPage')); const ChatRoom = React.lazy(() => import('../components/ChatRoom')); @@ -304,6 +305,10 @@ const V2App: React.FC = () => { path="artifacts" element={feature('Artifacts', , false, false, 'v2-feature--artifacts')} /> + , false, false)} + /> , false, false, 'v2-feature--activity')} diff --git a/frontend/src/v2/__tests__/V2RunTrace.test.tsx b/frontend/src/v2/__tests__/V2RunTrace.test.tsx new file mode 100644 index 0000000000..ff2edc15bf --- /dev/null +++ b/frontend/src/v2/__tests__/V2RunTrace.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import V2RunTrace from '../components/V2RunTrace'; +import V2RunTraceLink from '../components/V2RunTraceLink'; + +const mockGet = jest.fn(); +const mockApi = { get: mockGet }; +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); +const POD = 'aaaaaaaaaaaaaaaaaaaaaaa1'; +const RUN = 'bbbbbbbbbbbbbbbbbbbbbbb1'; +const trace = { + id: RUN, podId: POD, agentName: 'Ada', instanceId: 'default', status: 'succeeded', + trigger: 'mention', startedAt: '2026-10-25T01:00:00Z', totalTokens: 123, + traceTruncated: false, omittedTurns: 0, omittedToolCalls: 0, + turns: [{ turnIndex: 0, model: 'model', elapsedMs: 12, totalTokens: 123, + toolCalls: [{ name: 'vendor.read', elapsedMs: 5, callId: 'call-1', outcome: 'ok', + trail: { grantId: 'grant-1', credentialOwnerId: 'owner-1', outcome: 'ok' } }] }], +}; +const setup = () => render( + } /> +); +beforeEach(() => { mockGet.mockReset(); }); +test('server-bound link derives its destination from IDs and refuses arbitrary URLs', () => { + const { rerender } = render(); + expect(screen.getByRole('link', { name: 'View run trace' })).toHaveAttribute('href', `/v2/pods/${POD}/runs/${RUN}`); + rerender(); + expect(screen.queryByRole('link')).toBeNull(); +}); +test('reads recorded turns and PG attribution, with honest retention wording', async () => { + mockGet.mockResolvedValue({ run: trace }); + setup(); + await screen.findByText('vendor.read'); + expect(mockGet).toHaveBeenCalledWith(`/api/pods/${POD}/runs/${RUN}`); + expect(screen.getByText('grant-1')).toBeInTheDocument(); + expect(screen.getByText('owner-1')).toBeInTheDocument(); + expect(screen.getByText(/30-day default retention/)).toBeInTheDocument(); + expect(screen.getByText(/This trace persists after the card expires/)).toBeInTheDocument(); +}); +test('refusal can retry and never presents an empty successful trace', async () => { + mockGet.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ run: trace }); + setup(); + await screen.findByRole('alert'); + expect(screen.queryByText('No turns recorded yet.')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + await screen.findByText('vendor.read'); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); +}); +test('discloses truncation and missing broker rows without inventing attribution', async () => { + mockGet.mockResolvedValue({ run: { ...trace, traceTruncated: true, omittedTurns: 2, omittedToolCalls: 3, + turns: [{ ...trace.turns[0], toolCalls: [{ name: 'vendor.read', callId: 'gone', outcome: 'unavailable', trail: null, elapsedMs: 1 }] }] } }); + setup(); + await screen.findByText('Broker trail unavailable.'); + expect(screen.getByText(/2 turns and 3 tool calls are omitted/)).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 134fcf3ea2..9b8dff3215 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4973,3 +4973,14 @@ test('TASK-435 runtime columns can shrink on phones without dropping the observe expect(ruleBody(css, '.v2-runtime-controls__observed')).toContain('overflow-wrap: anywhere'); expect(css).toContain('content: attr(data-label)'); }); + +describe('run trace layout', () => { + const css = read('../components/V2RunTrace.css'); + test('long attribution IDs wrap, phone records stack, and links meet touch height', () => { + expect(css).toContain('overflow-wrap: anywhere'); + expect(css).toContain('min-width: 0'); + expect(css).toContain('min-height: 44px'); + expect(css).toMatch(/@media \(max-width: 600px\)/); + expect(css).toMatch(/grid-template-columns: minmax\(0, 1fr\)/); + }); +}); diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index c6caaba2f4..e50f6004ac 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -8,6 +8,7 @@ import V2GithubPrCard, { parseGithubPrUrls } from './V2GithubPrCard'; import V2ApprovalCard from './V2ApprovalCard'; import V2BYOHandoffCard from './V2BYOHandoffCard'; import V2GigCard from './V2GigCard'; +import V2RunTraceLink from './V2RunTraceLink'; import V2DecisionCard, { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard'; import V2MessageActions, { type V2RenderedReaction } from './V2MessageActions'; import { V2Message } from '../hooks/useV2PodDetail'; @@ -478,13 +479,13 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d // content regex — the first message class where `payload` is the truth // and `content` is only the plain-text fallback for legacy surfaces. if (message.payload?.kind === 'approval-card') { - return ; + return <>; } if (message.payload?.kind === 'byo-handoff') { - return ; + return <>; } if (message.payload?.kind === 'gig-card') { - return ; + return <>; } // §3.8 system event card. Detected by content shape (commonly-bot only @@ -682,6 +683,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d {isDecisionRuling && · {t('activity.decision.ruledShort')}} )} + {grouped && time && } {(() => { // Quoted context for replies. POST responses carry a normalized diff --git a/frontend/src/v2/components/V2RunTrace.css b/frontend/src/v2/components/V2RunTrace.css new file mode 100644 index 0000000000..f0c949b91a --- /dev/null +++ b/frontend/src/v2/components/V2RunTrace.css @@ -0,0 +1,20 @@ +.v2-run-trace { max-width: 880px; min-width: 0; color: var(--v2-text-primary); } +.v2-run-trace h1 { font-family: var(--v2-font-display); font-size: 32px; letter-spacing: -0.03em; margin: 16px 0; } +.v2-run-trace h2 { font-family: var(--v2-font-display); font-size: 20px; margin: 0; } +.v2-run-trace__back, .v2-run-trace-link a { color: var(--v2-text-primary); min-height: 44px; display: inline-flex; align-items: center; } +.v2-run-trace__retention, .v2-run-trace-link span { color: var(--v2-text-muted); font-size: 12px; } +.v2-run-trace__summary, .v2-run-trace__call { display: grid; gap: 8px; border: 1px solid var(--v2-border); border-radius: 6px; padding: 16px; margin: 12px 0; min-width: 0; } +.v2-run-trace__meta, .v2-run-trace code, .v2-run-trace dd { font-family: var(--v2-font-mono); font-size: 12px; } +.v2-run-trace code, .v2-run-trace dd, .v2-run-trace__call strong { overflow-wrap: anywhere; } +.v2-run-trace__turns { list-style: none; margin: 24px 0; padding: 0; } +.v2-run-trace__turns > li { border-top: 1px solid var(--v2-border-soft); padding-top: 16px; margin-top: 16px; } +.v2-run-trace dl { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 8px; margin: 8px 0; } +.v2-run-trace dd { margin: 0; } +.v2-run-trace button { min-height: 44px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: 4px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-run-trace button:focus-visible, .v2-run-trace a:focus-visible, .v2-run-trace-link a:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; } +.v2-run-trace-link { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +@media (max-width: 600px) { + .v2-run-trace dl { grid-template-columns: minmax(0, 1fr); gap: 4px; } + .v2-run-trace dd { margin-bottom: 8px; } + .v2-run-trace-link { display: grid; gap: 0; } +} diff --git a/frontend/src/v2/components/V2RunTrace.tsx b/frontend/src/v2/components/V2RunTrace.tsx new file mode 100644 index 0000000000..2fc9503b21 --- /dev/null +++ b/frontend/src/v2/components/V2RunTrace.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useParams } from 'react-router-dom'; +import { useV2Api } from '../hooks/useV2Api'; +import './V2RunTrace.css'; + +export interface RunTrace { + id: string; podId: string; agentName: string; instanceId: string; status: string; + trigger: string; startedAt: string; completedAt?: string; totalTokens: number; + errorKind?: string; traceTruncated: boolean; omittedTurns: number; omittedToolCalls: number; + turns: Array<{ + turnIndex: number; model: string; elapsedMs: number; totalTokens?: number; + toolCalls: Array<{ + name: string; elapsedMs: number; callId?: string; outcome: string; + trail?: { grantId: string; credentialOwnerId: string | null; outcome: string } | null; + }>; + }>; +} + +const V2RunTrace: React.FC = () => { + const { t, i18n } = useTranslation(); + const { podId, runId } = useParams<{ podId: string; runId: string }>(); + const api = useV2Api(); + const [trace, setTrace] = useState(null); + const [error, setError] = useState(''); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + useEffect(() => { + let live = true; + setTrace(null); setError(''); + api.get<{ run: RunTrace }>(`/api/pods/${encodeURIComponent(podId || '')}/runs/${encodeURIComponent(runId || '')}`) + .then(({ run }) => { if (live) setTrace(run); }) + .catch((err: { response?: { status?: number } }) => { + if (live) setError(err.response?.status === 404 + ? t('runTrace.access') : t('runTrace.error')); + }); + return () => { live = false; }; + }, [api, podId, runId, attempt, t]); + + return
+ {t('runTrace.back')} +

{t('runTrace.title')}

+

{t('runTrace.retention')}

+ {!trace && !error &&

{t('runTrace.loading')}

} + {error &&

{error}

} + {trace && <> +
+ {trace.agentName}{trace.status} · {trace.trigger} + {t('runTrace.counts', { turns: trace.turns.length, tokens: trace.totalTokens })} + + {trace.id} + {trace.errorKind && {t('runTrace.stopped', { kind: trace.errorKind })}} + {['queued', 'running'].includes(trace.status) &&

{t('runTrace.running')}

} +
+ {trace.traceTruncated &&

{t('runTrace.limited', { turns: trace.omittedTurns, calls: trace.omittedToolCalls })}

} + {!trace.turns.length &&

{t('runTrace.empty')}

} +
    + {trace.turns.map((turn) =>
  1. +

    {t('runTrace.turn', { number: turn.turnIndex + 1 })}

    +

    {turn.model} · {turn.elapsedMs} ms · {turn.totalTokens ?? '—'} {t('runTrace.tokens')}

    + {!turn.toolCalls.length &&

    {t('runTrace.noTools')}

    } + {turn.toolCalls.map((call, index) =>
    + {call.name}{call.outcome} · {call.elapsedMs} ms + {call.callId && {t('runTrace.call', { id: call.callId })}} + {call.trail ?
    +
    {t('runTrace.grant')}
    {call.trail.grantId}
    +
    {t('runTrace.owner')}
    {call.trail.credentialOwnerId || t('runTrace.notRecorded')}
    +
    {t('runTrace.outcome')}
    {call.trail.outcome}
    +
    : call.callId ?

    {t('runTrace.unavailable')}

    :

    {t('runTrace.builtIn')}

    } +
    )} +
  2. )} +
+ } +
; +}; +export default V2RunTrace; diff --git a/frontend/src/v2/components/V2RunTraceLink.tsx b/frontend/src/v2/components/V2RunTraceLink.tsx new file mode 100644 index 0000000000..4276054b14 --- /dev/null +++ b/frontend/src/v2/components/V2RunTraceLink.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import './V2RunTrace.css'; + +/** IDs come from the stored message, never from an agent-authored URL. */ +export const runTracePath = (podId: unknown, runId: unknown): string | null => ( + typeof podId === 'string' && /^[a-f\d]{24}$/i.test(podId) + && typeof runId === 'string' && /^[a-f\d]{24}$/i.test(runId) + ? `/v2/pods/${podId}/runs/${runId}` : null +); + +const V2RunTraceLink: React.FC<{ podId: string; runId?: string }> = ({ podId, runId }) => { + const { t } = useTranslation(); + const href = runTracePath(podId, runId); + return href ?
+ {t('runTrace.view')} + {t('runTrace.linkRetention')} +
: null; +}; +export default V2RunTraceLink; diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 3342066812..6cf9f8b7ef 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -85,6 +85,7 @@ export interface V2Message { // (approval-card and byo-handoff). Server-composed; survives normalizeMessage via // the `...raw` spread — declared here so consumers can read it typed. payload?: { + runId?: string; kind?: string; elements?: unknown[]; // kind 'starts': offers rendered as buttons under the message (first From 598e021c3bdbda4d4f85b3f27a98b3b1ca3b04e4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:09:05 -0700 Subject: [PATCH 2/7] fix(traces): limit reads and align Chinese numeral spacing Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- backend/__tests__/unit/routes/runTraces.test.js | 9 +++++++++ backend/routes/runTraces.ts | 8 +++++++- docs/development/run-traces.md | 2 ++ .../src/i18n/__tests__/zhNumberUnitSpacing.test.ts | 4 +++- frontend/src/i18n/locales/zh-CN.json | 10 +++++----- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/unit/routes/runTraces.test.js b/backend/__tests__/unit/routes/runTraces.test.js index 32d03d8601..ddd544e669 100644 --- a/backend/__tests__/unit/routes/runTraces.test.js +++ b/backend/__tests__/unit/routes/runTraces.test.js @@ -110,3 +110,12 @@ test('aggregate budget caps many individually valid values before Mongo insert', expect(stored.omittedToolCalls).toBeGreaterThan(0); expect(BSON.calculateObjectSize(JSON.parse(JSON.stringify(stored.turns)))).toBeLessThan(RUN_TRACE_BYTES); }); + + +test('limits repeated trace reads before authentication and database work', async () => { + let status; + for (let attempt = 0; attempt < 125 && status !== 429; attempt += 1) { + status = (await request(app).get(`/api/pods/${POD}/runs/bad-id`)).status; + } + expect(status).toBe(429); +}); diff --git a/backend/routes/runTraces.ts b/backend/routes/runTraces.ts index c040ae4ff8..dcca2eb2f2 100644 --- a/backend/routes/runTraces.ts +++ b/backend/routes/runTraces.ts @@ -1,4 +1,6 @@ import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; import mongoose from 'mongoose'; import AgentRun from '../models/AgentRun'; import ToolCall from '../models/ToolCall'; @@ -7,10 +9,14 @@ const auth = require('../middleware/auth'); const Pod = require('../models/Pod'); const DMService = require('../services/dmService'); const router = express.Router(); +const traceReadLimit = rateLimit({ + windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); // Trace access follows today's pod access, never the original grant audience // or the continued existence of the card. Public pod listing is insufficient. -router.get('/:podId/runs/:runId', auth, async (req, res) => { +router.get('/:podId/runs/:runId', traceReadLimit, auth, async (req, res) => { try { const { podId, runId } = req.params; if (!mongoose.isObjectIdOrHexString(podId) || !mongoose.isObjectIdOrHexString(runId)) { diff --git a/docs/development/run-traces.md b/docs/development/run-traces.md index 26718f4347..0929aaa086 100644 --- a/docs/development/run-traces.md +++ b/docs/development/run-traces.md @@ -21,6 +21,8 @@ TASK-499 adds a read-only link from a native hosted agent's message to its current pod-read access as the existing grant trail (`DMService.canViewPod`). Wrong pod/run combinations, malformed IDs and denied access return 404. Knowledge of a run ID or the public pod listing grants no access. +- Reads are limited to 120 per minute per IP using the shared Cloudflare-aware + key, before authentication or database access. - The response is `{run, retention}`. `run.turns[].toolCalls[]` holds name, duration, outcome, optional callId, and `trail` with grantId and the credential owner's ID. A RunTrace last-N renderer can flatten these calls and slice the diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index ee9cfdfdca..39260aaab8 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -25,7 +25,7 @@ const NUMERAL_PLACEHOLDERS = [ // bare numbers, or numbers formatted with grouping separators (Intl.NumberFormat) 'count', 'formattedCount', 'uses', 'maxUses', 'complete', 'total', 'attempt', 'revision', 'position', 'cap', 'turns', 'notes', 'days', 'signups', 'active', - 'working', 'needsYou', 'minutes', 'calls', + 'working', 'needsYou', 'minutes', 'calls', 'number', 'tokens', // a formatted duration, closed because the ruling names this key explicitly: // tools.budgetWindow renders 「每1小时可调用5次」 'window', @@ -46,6 +46,8 @@ const OPAQUE_PLACEHOLDERS = [ 'holder', // Runtime ownership names the visible blocking Pods and installers. 'blockers', 'harness', 'effort', 'model', + // runTrace.call inserts the opaque PG call ID, not a number. + 'id', ]; // The exception runs the safe way: a name that is usually opaque and is a number diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 78247343cd..56d85f1615 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -3,19 +3,19 @@ "title": "运行轨迹", "back": "返回 Pod", "view": "查看运行轨迹", - "linkRetention": "卡片默认保留 30 天,轨迹持续保留。", - "retention": "卡片默认保留 30 天,Pod 策略和存储限制可能改变期限。卡片过期后,运行轨迹仍会保留。", + "linkRetention": "卡片默认保留30天,轨迹持续保留。", + "retention": "卡片默认保留30天,Pod 策略和存储限制可能改变期限。卡片过期后,运行轨迹仍会保留。", "loading": "正在加载运行轨迹…", "access": "此轨迹不可用,或你已无权访问对应的 Pod。", "error": "无法加载运行轨迹,请重试。", "retry": "重试", - "counts": "已记录 {{turns}} 轮 · {{tokens}} 个 token", + "counts": "已记录{{turns}}轮 · {{tokens}}个 token", "stopped": "停止原因:{{kind}}", "running": "此运行尚未结束。", "refresh": "刷新轨迹", - "limited": "此轨迹受到存储大小限制,省略了 {{turns}} 轮和 {{calls}} 次工具调用,大型内容字段也已截断。", + "limited": "此轨迹受到存储大小限制,省略了{{turns}}轮和{{calls}}次工具调用,大型内容字段也已截断。", "empty": "尚未记录任何轮次。", - "turn": "第 {{number}} 轮", + "turn": "第{{number}}轮", "tokens": "个 token", "noTools": "无工具调用。", "call": "调用 {{id}}", From 3e834e0871ddce2bed919bda87a208c7c0985ca7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:04:34 -0700 Subject: [PATCH 3/7] test: pin run trace provenance through native output and approvals Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- .../models/ApprovalAction.runTrace.test.js | 23 ++++++++++++++++++ .../services/approvalActionService.test.js | 24 +++++++++++++++++++ .../nativeRuntimeService.byoHandoff.test.js | 1 + .../nativeRuntimeService.webSearch.test.js | 8 +++---- frontend/src/v2/components/V2RunTrace.css | 4 ++-- 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 backend/__tests__/unit/models/ApprovalAction.runTrace.test.js diff --git a/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js new file mode 100644 index 0000000000..659721d41d --- /dev/null +++ b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js @@ -0,0 +1,23 @@ +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb } = require('../../utils/testUtils'); +const ApprovalAction = require('../../../models/ApprovalAction'); + +beforeAll(setupMongoDb); +afterAll(closeMongoDb); + +test('the originating run persists across an approval save, reload and transition', async () => { + const runId = new mongoose.Types.ObjectId(); + const action = await ApprovalAction.create({ + podId: new mongoose.Types.ObjectId(), ownerUserId: new mongoose.Types.ObjectId(), + agentName: 'trace-seat', actionType: 'create_pod', params: { name: 'Research' }, + summary: 'Create a research pod', runId, + }); + const loaded = await ApprovalAction.findById(action._id); + expect(String(loaded.runId)).toBe(String(runId)); + loaded.status = 'resolved'; + loaded.decision = 'declined'; + await loaded.save(); + const resolved = await ApprovalAction.findById(action._id).lean(); + expect(String(resolved.runId)).toBe(String(runId)); + expect(resolved.status).toBe('resolved'); +}); diff --git a/backend/__tests__/unit/services/approvalActionService.test.js b/backend/__tests__/unit/services/approvalActionService.test.js index b5f2d435c6..79ef9a26e1 100644 --- a/backend/__tests__/unit/services/approvalActionService.test.js +++ b/backend/__tests__/unit/services/approvalActionService.test.js @@ -498,3 +498,27 @@ test('approval card rebuilds keep their originating run after a transition', () expect(buildCardPayload(row).runId).toBe('aaaaaaaaaaaaaaaaaaaaa111'); expect(buildCardPayload({ ...row, status: 'resolved', decision: 'declined' }).runId).toBe('aaaaaaaaaaaaaaaaaaaaa111'); }); + + +test('a proposal carries its originating run into the saved action and posted card', async () => { + const { proposeAction } = require('../../../services/approvalActionService'); + const runId = 'aaaaaaaaaaaaaaaaaaaaa111'; + const row = flaggedRow({ runId }); + mockPodFindById.mockReturnValue({ select: () => ({ lean: async () => ({ createdBy: OWNER }) }) }); + mockInstallFindOne.mockResolvedValue({ installedBy: OWNER }); + mockApprovalCreate.mockResolvedValue(row); + mockPostMessage.mockResolvedValue({ success: true, message: { id: 42 } }); + + const result = await proposeAction({ + podId: 'pod-1', agentName: 'guide', instanceId: 'udefault', runId, + actionType: 'create_pod', params: { name: 'Research', type: 'chat' }, summary: 'Create a research pod', + }); + + expect(result.ok).toBe(true); + expect(mockApprovalCreate).toHaveBeenCalledWith(expect.objectContaining({ runId })); + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ + runId, payload: expect.objectContaining({ runId, kind: 'approval-card' }), + })); + expect(row.messageId).toBe('42'); + expect(row.save).toHaveBeenCalled(); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index 57e20660ca..3b8a23b6a2 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -109,6 +109,7 @@ describe('commonly_offer_local_agent', () => { const posted = AgentMessageService.postMessage.mock.calls[0][0]; expect(posted.messageType).toBe('card'); expect(posted.payload.kind).toBe('byo-handoff'); + expect(posted.runId).toBe('run-1'); expect(posted.payload.agentName).toBe('sam-xu-opencode'); expect(posted.payload.commands.mac).toContain('commonly agent attach opencode'); expect(posted.payload.commands.mac).toContain('--memory-from scout --memory-instance default'); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index b2e282e0f7..c8e881e5c9 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -210,7 +210,7 @@ describe('native turn with commonly_web_search', () => { expect(offeredIn(1)).toContain('linear_list_issues'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: 'issue result', noOutboundRelay: true, + content: 'issue result', noOutboundRelay: true, runId: 'run-1', })); }); @@ -286,7 +286,7 @@ describe('native turn with commonly_web_search', () => { expect(offeredIn(1)).toContain('linear_list_issues'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: 'Here is what shipped.', noOutboundRelay: true, + content: 'Here is what shipped.', noOutboundRelay: true, runId: 'run-1', })); }); @@ -329,7 +329,7 @@ describe('native turn with commonly_web_search', () => { await runAgent(proposalInstall, mention); - expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true })); + expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true, runId: 'run-1' })); }); test('a failed hosted turn after a broker call keeps its fixed line inside the pod', async () => { @@ -346,7 +346,7 @@ describe('native turn with commonly_web_search', () => { expect(result.status).toBe('failed'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ runFailed: true }), - noOutboundRelay: true, + noOutboundRelay: true, runId: 'run-1', })); }); diff --git a/frontend/src/v2/components/V2RunTrace.css b/frontend/src/v2/components/V2RunTrace.css index f0c949b91a..adacf320ca 100644 --- a/frontend/src/v2/components/V2RunTrace.css +++ b/frontend/src/v2/components/V2RunTrace.css @@ -3,14 +3,14 @@ .v2-run-trace h2 { font-family: var(--v2-font-display); font-size: 20px; margin: 0; } .v2-run-trace__back, .v2-run-trace-link a { color: var(--v2-text-primary); min-height: 44px; display: inline-flex; align-items: center; } .v2-run-trace__retention, .v2-run-trace-link span { color: var(--v2-text-muted); font-size: 12px; } -.v2-run-trace__summary, .v2-run-trace__call { display: grid; gap: 8px; border: 1px solid var(--v2-border); border-radius: 6px; padding: 16px; margin: 12px 0; min-width: 0; } +.v2-run-trace__summary, .v2-run-trace__call { display: grid; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-lg); padding: 16px; margin: 12px 0; min-width: 0; } .v2-run-trace__meta, .v2-run-trace code, .v2-run-trace dd { font-family: var(--v2-font-mono); font-size: 12px; } .v2-run-trace code, .v2-run-trace dd, .v2-run-trace__call strong { overflow-wrap: anywhere; } .v2-run-trace__turns { list-style: none; margin: 24px 0; padding: 0; } .v2-run-trace__turns > li { border-top: 1px solid var(--v2-border-soft); padding-top: 16px; margin-top: 16px; } .v2-run-trace dl { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 8px; margin: 8px 0; } .v2-run-trace dd { margin: 0; } -.v2-run-trace button { min-height: 44px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: 4px; background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-run-trace button { min-height: 44px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } .v2-run-trace button:focus-visible, .v2-run-trace a:focus-visible, .v2-run-trace-link a:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; } .v2-run-trace-link { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } @media (max-width: 600px) { From 6d41f36597014abac85ea7fda7c18925ded7b81c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:06:01 -0700 Subject: [PATCH 4/7] fix: match trace controls and localized disclosure to Signal Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- frontend/src/i18n/locales/en.json | 23 ++++++++++++-- frontend/src/i18n/locales/zh-CN.json | 23 ++++++++++++-- frontend/src/v2/__tests__/V2RunTrace.test.tsx | 31 +++++++++++++++++-- .../v2/__tests__/v2-layout-invariants.test.ts | 2 +- frontend/src/v2/components/V2RunTrace.css | 7 +++-- frontend/src/v2/components/V2RunTrace.tsx | 12 ++++--- 6 files changed, 84 insertions(+), 14 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f8a51fdbb1..42dec3c1e0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -13,7 +13,6 @@ "stopped": "Stopped: {{kind}}", "running": "This run is still in progress.", "refresh": "Refresh trace", - "limited": "This stored trace was size-limited. {{turns}} turns and {{calls}} tool calls are omitted. Large content fields are also capped.", "empty": "No turns recorded yet.", "turn": "Turn {{number}}", "tokens": "tokens", @@ -24,7 +23,27 @@ "outcome": "Outcome", "notRecorded": "Not recorded", "unavailable": "Broker trail unavailable.", - "builtIn": "Built-in tool. No connector credential." + "builtIn": "Built-in tool. No connector credential.", + "status": { + "queued": "Queued", + "running": "Running", + "succeeded": "Succeeded", + "failed": "Failed", + "interrupted": "Interrupted" + }, + "trigger": { + "mention": "Mention", + "heartbeat": "Heartbeat", + "task.assigned": "Task assignment", + "chat.message": "Chat message", + "pod.join": "Pod join", + "first_contact": "First contact", + "manual": "Manual" + }, + "limitedBoth": "This stored trace was size-limited. {{turns}} turns and {{calls}} tool calls are omitted. Large content fields are also capped.", + "limitedTurns": "This stored trace was size-limited. {{turns}} turns are omitted. Large content fields are also capped.", + "limitedCalls": "This stored trace was size-limited. {{calls}} tool calls are omitted. Large content fields are also capped.", + "limitedContent": "This stored trace was size-limited. Large content fields are capped." }, "common": { "brandName": "Commonly", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 56d85f1615..b2b2fa5e6b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -13,7 +13,6 @@ "stopped": "停止原因:{{kind}}", "running": "此运行尚未结束。", "refresh": "刷新轨迹", - "limited": "此轨迹受到存储大小限制,省略了{{turns}}轮和{{calls}}次工具调用,大型内容字段也已截断。", "empty": "尚未记录任何轮次。", "turn": "第{{number}}轮", "tokens": "个 token", @@ -24,7 +23,27 @@ "outcome": "结果", "notRecorded": "未记录", "unavailable": "代理调用轨迹不可用。", - "builtIn": "内置工具,未使用连接器凭据。" + "builtIn": "内置工具,未使用连接器凭据。", + "status": { + "queued": "排队中", + "running": "运行中", + "succeeded": "已完成", + "failed": "失败", + "interrupted": "已中断" + }, + "trigger": { + "mention": "提及", + "heartbeat": "心跳", + "task.assigned": "任务分配", + "chat.message": "聊天消息", + "pod.join": "加入 Pod", + "first_contact": "首次联系", + "manual": "手动" + }, + "limitedBoth": "此轨迹受到存储大小限制,省略了{{turns}}轮和{{calls}}次工具调用,大型内容字段也已截断。", + "limitedTurns": "此轨迹受到存储大小限制,省略了{{turns}}轮,大型内容字段也已截断。", + "limitedCalls": "此轨迹受到存储大小限制,省略了{{calls}}次工具调用,大型内容字段也已截断。", + "limitedContent": "此轨迹受到存储大小限制,大型内容字段已截断。" }, "common": { "brandName": "Commonly", diff --git a/frontend/src/v2/__tests__/V2RunTrace.test.tsx b/frontend/src/v2/__tests__/V2RunTrace.test.tsx index ff2edc15bf..7bb230fc91 100644 --- a/frontend/src/v2/__tests__/V2RunTrace.test.tsx +++ b/frontend/src/v2/__tests__/V2RunTrace.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import i18n from '../../i18n'; import V2RunTrace from '../components/V2RunTrace'; import V2RunTraceLink from '../components/V2RunTraceLink'; @@ -20,7 +21,8 @@ const trace = { const setup = () => render( } /> ); -beforeEach(() => { mockGet.mockReset(); }); +beforeEach(async () => { mockGet.mockReset(); await i18n.changeLanguage('en'); }); +afterEach(async () => { await i18n.changeLanguage('en'); }); test('server-bound link derives its destination from IDs and refuses arbitrary URLs', () => { const { rerender } = render(); expect(screen.getByRole('link', { name: 'View run trace' })).toHaveAttribute('href', `/v2/pods/${POD}/runs/${RUN}`); @@ -53,3 +55,28 @@ test('discloses truncation and missing broker rows without inventing attribution await screen.findByText('Broker trail unavailable.'); expect(screen.getByText(/2 turns and 3 tool calls are omitted/)).toBeInTheDocument(); }); + +test.each([ + [0, 2, '2 tool calls are omitted', '0 turns'], + [2, 0, '2 turns are omitted', '0 tool calls'], + [0, 0, 'Large content fields are capped', 'are omitted'], +])('size disclosure omits empty counts (%i turns, %i calls)', async (turns, calls, expected, absent) => { + mockGet.mockResolvedValue({ run: { ...trace, traceTruncated: true, omittedTurns: turns, omittedToolCalls: calls } }); + setup(); + await screen.findByText(new RegExp(expected)); + expect(screen.queryByText(new RegExp(absent))).not.toBeInTheDocument(); +}); +test('Chinese status, trigger, region label and nonzero truncation clause are localized', async () => { + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + mockGet.mockResolvedValue({ run: { ...trace, traceTruncated: true, omittedToolCalls: 2 } }); + setup(); + await screen.findByText('已完成 · 提及'); + expect(screen.getByRole('region', { name: '运行轨迹' })).toBeInTheDocument(); + expect(screen.getByText(/省略了2次工具调用/)).toBeInTheDocument(); + expect(screen.queryByText(/0轮/)).not.toBeInTheDocument(); +}); +test('future enum values remain visible instead of showing translation keys', async () => { + mockGet.mockResolvedValue({ run: { ...trace, status: 'future-status', trigger: 'future-trigger' } }); + setup(); + await screen.findByText('future-status · future-trigger'); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9b8dff3215..4ec2ee9764 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4980,7 +4980,7 @@ describe('run trace layout', () => { expect(css).toContain('overflow-wrap: anywhere'); expect(css).toContain('min-width: 0'); expect(css).toContain('min-height: 44px'); - expect(css).toMatch(/@media \(max-width: 600px\)/); + expect(css).toMatch(/@media \(max-width: 760px\)/); expect(css).toMatch(/grid-template-columns: minmax\(0, 1fr\)/); }); }); diff --git a/frontend/src/v2/components/V2RunTrace.css b/frontend/src/v2/components/V2RunTrace.css index adacf320ca..4fde1d2134 100644 --- a/frontend/src/v2/components/V2RunTrace.css +++ b/frontend/src/v2/components/V2RunTrace.css @@ -1,7 +1,7 @@ .v2-run-trace { max-width: 880px; min-width: 0; color: var(--v2-text-primary); } .v2-run-trace h1 { font-family: var(--v2-font-display); font-size: 32px; letter-spacing: -0.03em; margin: 16px 0; } .v2-run-trace h2 { font-family: var(--v2-font-display); font-size: 20px; margin: 0; } -.v2-run-trace__back, .v2-run-trace-link a { color: var(--v2-text-primary); min-height: 44px; display: inline-flex; align-items: center; } +.v2-run-trace__back, .v2-run-trace-link a { color: var(--v2-text-primary); min-height: 32px; display: inline-flex; align-items: center; } .v2-run-trace__retention, .v2-run-trace-link span { color: var(--v2-text-muted); font-size: 12px; } .v2-run-trace__summary, .v2-run-trace__call { display: grid; gap: 8px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-lg); padding: 16px; margin: 12px 0; min-width: 0; } .v2-run-trace__meta, .v2-run-trace code, .v2-run-trace dd { font-family: var(--v2-font-mono); font-size: 12px; } @@ -10,10 +10,11 @@ .v2-run-trace__turns > li { border-top: 1px solid var(--v2-border-soft); padding-top: 16px; margin-top: 16px; } .v2-run-trace dl { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 8px; margin: 8px 0; } .v2-run-trace dd { margin: 0; } -.v2-run-trace button { min-height: 44px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } +.v2-run-trace button { min-height: 32px; padding: 0 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-text-primary); cursor: pointer; } .v2-run-trace button:focus-visible, .v2-run-trace a:focus-visible, .v2-run-trace-link a:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 2px; } .v2-run-trace-link { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } -@media (max-width: 600px) { +@media (max-width: 760px) { + .v2-run-trace__back, .v2-run-trace-link a, .v2-run-trace button { min-height: 44px; } .v2-run-trace dl { grid-template-columns: minmax(0, 1fr); gap: 4px; } .v2-run-trace dd { margin-bottom: 8px; } .v2-run-trace-link { display: grid; gap: 0; } diff --git a/frontend/src/v2/components/V2RunTrace.tsx b/frontend/src/v2/components/V2RunTrace.tsx index 2fc9503b21..f5d2d79c5d 100644 --- a/frontend/src/v2/components/V2RunTrace.tsx +++ b/frontend/src/v2/components/V2RunTrace.tsx @@ -37,22 +37,26 @@ const V2RunTrace: React.FC = () => { return () => { live = false; }; }, [api, podId, runId, attempt, t]); - return
+ const enumLabel = (group: 'status' | 'trigger', value: string) => t(`runTrace.${group}.${value}`, { defaultValue: value }); + const limitedKey = trace?.omittedTurns && trace?.omittedToolCalls ? 'limitedBoth' + : trace?.omittedTurns ? 'limitedTurns' : trace?.omittedToolCalls ? 'limitedCalls' : 'limitedContent'; + + return
{t('runTrace.back')} -

{t('runTrace.title')}

+

{t('runTrace.title')}

{t('runTrace.retention')}

{!trace && !error &&

{t('runTrace.loading')}

} {error &&

{error}

} {trace && <>
- {trace.agentName}{trace.status} · {trace.trigger} + {trace.agentName}{enumLabel('status', trace.status)} · {enumLabel('trigger', trace.trigger)} {t('runTrace.counts', { turns: trace.turns.length, tokens: trace.totalTokens })} {trace.id} {trace.errorKind && {t('runTrace.stopped', { kind: trace.errorKind })}} {['queued', 'running'].includes(trace.status) &&

{t('runTrace.running')}

}
- {trace.traceTruncated &&

{t('runTrace.limited', { turns: trace.omittedTurns, calls: trace.omittedToolCalls })}

} + {trace.traceTruncated &&

{t(`runTrace.${limitedKey}`, { turns: trace.omittedTurns, calls: trace.omittedToolCalls })}

} {!trace.turns.length &&

{t('runTrace.empty')}

}
    {trace.turns.map((turn) =>
  1. From 5b2653279db645344a046fbfba0bc485914f0b65 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:24:16 -0700 Subject: [PATCH 5/7] fix: preserve hosted approval trace origins and place trace links after content Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- .../unit/models/ApprovalAction.runTrace.test.js | 5 ++++- .../agentMessageService.threadWrites.test.js | 6 +++--- .../unit/services/approvalActionService.test.js | 9 +++++---- .../services/grantBrokerProjectionService.test.js | 4 ++++ .../nativeRuntimeService.hostedBroker.test.js | 2 ++ .../services/nativeRuntimeService.webSearch.test.js | 8 ++++---- .../unit/services/toolBrokerService.test.js | 6 +++++- backend/models/ApprovalAction.ts | 2 ++ backend/services/agentMessageService.ts | 5 ++++- backend/services/approvalActionService.ts | 9 +++++++-- backend/services/grantBrokerProjectionService.ts | 6 +++++- backend/services/nativeRuntimeService.ts | 8 ++++++++ backend/services/toolBrokerService.ts | 5 +++++ docs/development/run-traces.md | 8 ++++++++ frontend/src/i18n/locales/en.json | 8 ++++++++ frontend/src/i18n/locales/zh-CN.json | 8 ++++++++ frontend/src/v2/__tests__/V2MessageRow.test.tsx | 13 +++++++++++++ frontend/src/v2/__tests__/V2RunTrace.test.tsx | 1 + frontend/src/v2/components/V2MessageRow.tsx | 8 ++++---- frontend/src/v2/components/V2RunTrace.tsx | 6 +++--- frontend/src/v2/hooks/useV2PodDetail.ts | 1 + 21 files changed, 104 insertions(+), 24 deletions(-) diff --git a/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js index 659721d41d..8990b90805 100644 --- a/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js +++ b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js @@ -7,17 +7,20 @@ afterAll(closeMongoDb); test('the originating run persists across an approval save, reload and transition', async () => { const runId = new mongoose.Types.ObjectId(); + const runPodId = new mongoose.Types.ObjectId(); const action = await ApprovalAction.create({ podId: new mongoose.Types.ObjectId(), ownerUserId: new mongoose.Types.ObjectId(), agentName: 'trace-seat', actionType: 'create_pod', params: { name: 'Research' }, - summary: 'Create a research pod', runId, + summary: 'Create a research pod', runId, runPodId, }); const loaded = await ApprovalAction.findById(action._id); expect(String(loaded.runId)).toBe(String(runId)); + expect(String(loaded.runPodId)).toBe(String(runPodId)); loaded.status = 'resolved'; loaded.decision = 'declined'; await loaded.save(); const resolved = await ApprovalAction.findById(action._id).lean(); expect(String(resolved.runId)).toBe(String(runId)); + expect(String(resolved.runPodId)).toBe(String(runPodId)); expect(resolved.status).toBe('resolved'); }); diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index 95575f893e..d03790d564 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -437,10 +437,10 @@ test('server run identity survives PG storage and live message broadcast with ex await AgentMessageService.postMessage({ agentName: 'sprint-review', instanceId: 'default', podId: POD, content: 'Run complete.', messageType: 'card', - payload: { kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId: 'forged' }, runId, + payload: { kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId: 'forged', runPodId: 'forged-pod' }, runId, runPodId: POD, }); expect(PGMessage.create.mock.calls[0][5]).toEqual({ - kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId, + kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId, runPodId: POD, }); expect(emitted.find((entry) => entry.event === 'newMessage').payload.payload.runId).toBe(runId); }); @@ -449,7 +449,7 @@ test('server run identity survives PG storage and live message broadcast with ex test('an agent-authored payload cannot claim another run', async () => { await AgentMessageService.postMessage({ agentName: 'sprint-review', instanceId: 'default', podId: POD, - content: 'A card.', payload: { kind: 'ui', runId: 'aaaaaaaaaaaaaaaaaaaaa111' }, + content: 'A card.', payload: { kind: 'ui', runId: 'aaaaaaaaaaaaaaaaaaaaa111', runPodId: POD }, }); expect(PGMessage.create.mock.calls[0][5]).toEqual({ kind: 'ui' }); }); diff --git a/backend/__tests__/unit/services/approvalActionService.test.js b/backend/__tests__/unit/services/approvalActionService.test.js index 79ef9a26e1..b6091dd9b5 100644 --- a/backend/__tests__/unit/services/approvalActionService.test.js +++ b/backend/__tests__/unit/services/approvalActionService.test.js @@ -503,21 +503,22 @@ test('approval card rebuilds keep their originating run after a transition', () test('a proposal carries its originating run into the saved action and posted card', async () => { const { proposeAction } = require('../../../services/approvalActionService'); const runId = 'aaaaaaaaaaaaaaaaaaaaa111'; - const row = flaggedRow({ runId }); + const runPodId = 'bbbbbbbbbbbbbbbbbbbbb111'; + const row = flaggedRow({ runId, runPodId }); mockPodFindById.mockReturnValue({ select: () => ({ lean: async () => ({ createdBy: OWNER }) }) }); mockInstallFindOne.mockResolvedValue({ installedBy: OWNER }); mockApprovalCreate.mockResolvedValue(row); mockPostMessage.mockResolvedValue({ success: true, message: { id: 42 } }); const result = await proposeAction({ - podId: 'pod-1', agentName: 'guide', instanceId: 'udefault', runId, + podId: 'pod-1', agentName: 'guide', instanceId: 'udefault', runId, runPodId, actionType: 'create_pod', params: { name: 'Research', type: 'chat' }, summary: 'Create a research pod', }); expect(result.ok).toBe(true); - expect(mockApprovalCreate).toHaveBeenCalledWith(expect.objectContaining({ runId })); + expect(mockApprovalCreate).toHaveBeenCalledWith(expect.objectContaining({ runId, runPodId })); expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ - runId, payload: expect.objectContaining({ runId, kind: 'approval-card' }), + runId, runPodId, payload: expect.objectContaining({ runId, runPodId, kind: 'approval-card' }), })); expect(row.messageId).toBe('42'); expect(row.save).toHaveBeenCalled(); diff --git a/backend/__tests__/unit/services/grantBrokerProjectionService.test.js b/backend/__tests__/unit/services/grantBrokerProjectionService.test.js index cb5aa1bdcf..ecffe4f766 100644 --- a/backend/__tests__/unit/services/grantBrokerProjectionService.test.js +++ b/backend/__tests__/unit/services/grantBrokerProjectionService.test.js @@ -203,6 +203,8 @@ describe('dispatchHostedBrokerTool', () => { agentName: 'kai', instanceId: 'default', noOutboundRelay: true, + runId: 'aaaaaaaaaaaaaaaaaaaaa111', + runPodId: POD_A, }); expect(mockCallTool).toHaveBeenCalledWith({ @@ -214,6 +216,8 @@ describe('dispatchHostedBrokerTool', () => { // so the seat-confinement refusal does not apply to it (Wren, TASK-175). hostedTurn: true, noOutboundRelay: true, + runId: 'aaaaaaaaaaaaaaaaaaaaa111', + runPodId: POD_A, tool: 'github.list_issues', args: { grantId: 'grant-someone-else', agentUserId: OTHER_SEAT, state: 'open' }, }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js b/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js index dde65c9ad8..13ad489ffb 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.hostedBroker.test.js @@ -229,6 +229,8 @@ describe('a hosted run in a granted pod', () => { // seat-confinement refusal is out of scope for it (Wren, TASK-175). hostedTurn: true, noOutboundRelay: false, + runId: 'run-1', + runPodId: POD, tool: 'github.list_issues', args: { state: 'open' }, }); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index c8e881e5c9..58f583f83c 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -210,7 +210,7 @@ describe('native turn with commonly_web_search', () => { expect(offeredIn(1)).toContain('linear_list_issues'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: 'issue result', noOutboundRelay: true, runId: 'run-1', + content: 'issue result', noOutboundRelay: true, runId: 'run-1', runPodId: install.podId, })); }); @@ -286,7 +286,7 @@ describe('native turn with commonly_web_search', () => { expect(offeredIn(1)).toContain('linear_list_issues'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ - content: 'Here is what shipped.', noOutboundRelay: true, runId: 'run-1', + content: 'Here is what shipped.', noOutboundRelay: true, runId: 'run-1', runPodId: install.podId, })); }); @@ -329,7 +329,7 @@ describe('native turn with commonly_web_search', () => { await runAgent(proposalInstall, mention); - expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true, runId: 'run-1' })); + expect(mockProposeAction).toHaveBeenCalledWith(expect.objectContaining({ noOutboundRelay: true, runId: 'run-1', runPodId: install.podId })); }); test('a failed hosted turn after a broker call keeps its fixed line inside the pod', async () => { @@ -346,7 +346,7 @@ describe('native turn with commonly_web_search', () => { expect(result.status).toBe('failed'); expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ runFailed: true }), - noOutboundRelay: true, runId: 'run-1', + noOutboundRelay: true, runId: 'run-1', runPodId: install.podId, })); }); diff --git a/backend/__tests__/unit/services/toolBrokerService.test.js b/backend/__tests__/unit/services/toolBrokerService.test.js index 2019f3b11d..c2b0bf9e43 100644 --- a/backend/__tests__/unit/services/toolBrokerService.test.js +++ b/backend/__tests__/unit/services/toolBrokerService.test.js @@ -469,13 +469,17 @@ describe('tool broker guard rails', () => { tools: ['github.close_issue'], writeMode: 'write-with-confirm', budget: { calls: 1 }, })); await expect(callTool({ - grantId: 'grant-1', agentUserId: 'agent-a', tool: 'github.close_issue', args: { issueNumber: 7 }, + grantId: 'grant-1', agentUserId: 'agent-a', tool: 'github.close_issue', + runId: 'aaaaaaaaaaaaaaaaaaaaa111', runPodId: 'bbbbbbbbbbbbbbbbbbbbb111', args: { issueNumber: 7 }, })).rejects.toMatchObject({ code: 'approval_required' }); expect(mockReserveBudgetLineage).not.toHaveBeenCalled(); expect(mockToolCall.create).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'pending_approval', reason: 'approval_required', })); expect(mockGithub.closeIssue).not.toHaveBeenCalled(); + expect(require('../../../services/approvalActionService').proposeAction).toHaveBeenCalledWith(expect.objectContaining({ + runId: 'aaaaaaaaaaaaaaaaaaaaa111', runPodId: 'bbbbbbbbbbbbbbbbbbbbb111', + })); }); }); diff --git a/backend/models/ApprovalAction.ts b/backend/models/ApprovalAction.ts index 07db54a6ba..2bf7e090b5 100644 --- a/backend/models/ApprovalAction.ts +++ b/backend/models/ApprovalAction.ts @@ -57,6 +57,7 @@ export interface IApprovalAction extends Document { messageId?: string; /** Originating native run; persists when the card changes state. */ runId?: Types.ObjectId; + runPodId?: Types.ObjectId; ownerUserId: Types.ObjectId; agentName: string; /** Server-only executor identity for tool_call; never in the shared card. */ @@ -92,6 +93,7 @@ const ApprovalActionSchema = new Schema( podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true }, messageId: { type: String }, runId: { type: Schema.Types.ObjectId, ref: 'AgentRun' }, + runPodId: { type: Schema.Types.ObjectId, ref: 'Pod' }, ownerUserId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, agentName: { type: String, required: true, lowercase: true, trim: true }, agentUserId: { type: String }, diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index e7c6b9a7e7..7fcd6759d8 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -159,6 +159,7 @@ interface MetadataDoc { interface PostMessageOptions { /** Server-owned AgentRun identity. Never forwarded from runtime HTTP input. */ runId?: string; + runPodId?: string; agentName: string; podId: unknown; content: string; @@ -1578,8 +1579,10 @@ class AgentMessageService { if (payload && typeof payload === 'object' && !Array.isArray(payload)) { messagePayload = { ...payload } as Record; delete (messagePayload as Record).runId; + delete (messagePayload as Record).runPodId; } - if (options.runId) messagePayload = { ...(messagePayload as object || {}), runId: options.runId }; + if (options.runId) messagePayload = { ...(messagePayload as object || {}), runId: options.runId, + ...(options.runPodId ? { runPodId: options.runPodId } : {}) }; const posted = await AgentMessageService._postToTarget({ agentName, diff --git a/backend/services/approvalActionService.ts b/backend/services/approvalActionService.ts index d25e1a6479..1b183db278 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -52,6 +52,7 @@ const LOCAL_AGENT_SCOPES = [ export interface CardPayload { runId?: string; + runPodId?: string; kind: typeof CARD_KIND; approvalId: string; actionType: ApprovalActionType; @@ -87,7 +88,8 @@ const legacyToolCallOutcome = (row: IApprovalAction): Partial => { export const buildCardPayload = (row: IApprovalAction): CardPayload => ({ kind: CARD_KIND, - ...(row.runId ? { runId: String(row.runId) } : {}), + ...(row.runId ? { runId: String(row.runId), + ...(row.runPodId ? { runPodId: String(row.runPodId) } : {}) } : {}), approvalId: String(row._id), actionType: row.actionType, summary: row.summary, @@ -131,6 +133,7 @@ export const buildOwnerCardPayload = ( interface ProposeOptions { /** Internal runtime identity, never copied from the agent HTTP body. */ runId?: string; + runPodId?: string; podId: string; agentName: string; instanceId: string; @@ -475,7 +478,8 @@ export const proposeAction = async (options: ProposeOptions): Promise => { const mapped = projection.dispatch.get(name); @@ -265,6 +267,8 @@ export const dispatchHostedBrokerTool = async ( tool: mapped.tool, args, noOutboundRelay, + runId, + runPodId, // A hosted turn has no shell, web or file tools: there is nothing for a // sandbox to confine, so the seat-confinement refusal does not apply // (Wren, TASK-175 74882). This is the only caller that sets it. diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 2a9b571800..5b8d3168b8 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -797,6 +797,7 @@ export async function dispatchTool( noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, runId: ctx.runId, + ...(ctx.runId ? { runPodId: String(ctx.podId) } : {}), }); return { content: { ok: true, posted: true, result } }; } @@ -843,6 +844,7 @@ export async function dispatchTool( noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, runId: ctx.runId, + ...(ctx.runId ? { runPodId: String(ctx.podId) } : {}), }); return { content: { ok: true, posted: true, result } }; } @@ -891,6 +893,7 @@ export async function dispatchTool( noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, runId: ctx.runId, + ...(ctx.runId ? { runPodId: String(ctx.podId) } : {}), authorKind: 'kernel', }); return { content: { ok: true, posted: true, result } }; @@ -944,6 +947,7 @@ export async function dispatchTool( const { proposeAction } = require('./approvalActionService'); const result = await proposeAction({ runId: ctx.runId, + ...(ctx.runId ? { runPodId: String(ctx.podId) } : {}), podId: String(ctx.podId), agentName: ctx.agentName, instanceId: ctx.instanceId, @@ -2119,6 +2123,8 @@ export async function runAgent( // dispatcher handles it below. const brokerCall = webTaintedAway ? null : await dispatchHostedBrokerTool({ projection: broker, + runId: dispatchCtx.runId, + runPodId: String(dispatchCtx.podId), name: toolName, args: parsedArgs, agentUserId: seatUserId, @@ -2286,6 +2292,7 @@ export async function runAgent( noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), answeringMessageId: dispatchCtx.answeringMessageId, runId, + runPodId: String(podId), }); // The message service suppresses a bare NO_REPLY (and other // silent output) without throwing. That is a normal declined turn, @@ -2333,6 +2340,7 @@ export async function runAgent( noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen), answeringMessageId: dispatchCtx.answeringMessageId, runId, + runPodId: String(podId), }); } catch (postErr) { console.warn('[native-runtime] failed-run line not posted:', (postErr as Error).message); diff --git a/backend/services/toolBrokerService.ts b/backend/services/toolBrokerService.ts index c7f6a70a37..c99176cb9c 100644 --- a/backend/services/toolBrokerService.ts +++ b/backend/services/toolBrokerService.ts @@ -102,6 +102,9 @@ export interface BrokerCallInput { hostedTurn?: boolean; /** Server-set native turn marker. A parked approval card must stay in the pod. */ noOutboundRelay?: boolean; + /** Server-owned origin for approval cards posted into another pod. */ + runId?: string; + runPodId?: string; } export interface BrokerCallResult { @@ -1266,6 +1269,8 @@ export const callTool = async (input: BrokerCallInput): Promise).grantId || input.grantId), diff --git a/docs/development/run-traces.md b/docs/development/run-traces.md index 0929aaa086..3b29b23054 100644 --- a/docs/development/run-traces.md +++ b/docs/development/run-traces.md @@ -66,3 +66,11 @@ approval outcomes. Message-service and native-runtime tests cover stored/socket provenance. UI tests cover valid links, retry, retention and incomplete trails. Real browser checks use the production components with fixture API responses at 390/1440 in English/Chinese; they are layout proof, not a production walk. + + +Connector approval cards may be posted into the credential owner's private room. +Their server-bound `payload.runPodId` preserves the originating run's pod beside +`runId`, so the link uses the original trace route. Both client payload fields +are stripped by the writer; hosted runtime context supplies them separately. +ApprovalAction persists both through decisions. Reading still requires access +to the original pod; receiving the approval card grants no trace access. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 42dec3c1e0..d56d44a0f2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -40,6 +40,14 @@ "first_contact": "First contact", "manual": "Manual" }, + "outcomes": { + "ok": "ok", + "failed": "failed", + "refused": "refused", + "pending": "pending", + "pending_approval": "pending approval", + "unavailable": "unavailable" + }, "limitedBoth": "This stored trace was size-limited. {{turns}} turns and {{calls}} tool calls are omitted. Large content fields are also capped.", "limitedTurns": "This stored trace was size-limited. {{turns}} turns are omitted. Large content fields are also capped.", "limitedCalls": "This stored trace was size-limited. {{calls}} tool calls are omitted. Large content fields are also capped.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b2b2fa5e6b..a1532ac1a8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -40,6 +40,14 @@ "first_contact": "首次联系", "manual": "手动" }, + "outcomes": { + "ok": "成功", + "failed": "失败", + "refused": "已拒绝", + "pending": "等待中", + "pending_approval": "待批准", + "unavailable": "不可用" + }, "limitedBoth": "此轨迹受到存储大小限制,省略了{{turns}}轮和{{calls}}次工具调用,大型内容字段也已截断。", "limitedTurns": "此轨迹受到存储大小限制,省略了{{turns}}轮,大型内容字段也已截断。", "limitedCalls": "此轨迹受到存储大小限制,省略了{{calls}}次工具调用,大型内容字段也已截断。", diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index b8e4f6afbf..6fdaa0a099 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -305,3 +305,16 @@ describe('V2MessageRow', () => { expect(screen.getByText('Pixel and codex started a DM').closest('.v2-syscard')).toHaveClass('v2-syscard--action'); }); }); + +it('server-bound origin pod links a private approval to its original run without widening trace access', () => { + const runPodId = 'aaaaaaaaaaaaaaaaaaaaaaa1'; + const runId = 'bbbbbbbbbbbbbbbbbbbbbbb1'; + render(); + const link = screen.getByRole('link', { name: 'View run trace' }); + expect(link).toHaveAttribute('href', `/v2/pods/${runPodId}/runs/${runId}`); + const content = link.closest('.v2-msg__body').querySelector('.v2-msg__content'); + expect(content.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); diff --git a/frontend/src/v2/__tests__/V2RunTrace.test.tsx b/frontend/src/v2/__tests__/V2RunTrace.test.tsx index 7bb230fc91..34c65c379b 100644 --- a/frontend/src/v2/__tests__/V2RunTrace.test.tsx +++ b/frontend/src/v2/__tests__/V2RunTrace.test.tsx @@ -71,6 +71,7 @@ test('Chinese status, trigger, region label and nonzero truncation clause are lo mockGet.mockResolvedValue({ run: { ...trace, traceTruncated: true, omittedToolCalls: 2 } }); setup(); await screen.findByText('已完成 · 提及'); + expect(screen.getByText('成功 · 5 ms')).toBeInTheDocument(); expect(screen.getByRole('region', { name: '运行轨迹' })).toBeInTheDocument(); expect(screen.getByText(/省略了2次工具调用/)).toBeInTheDocument(); expect(screen.queryByText(/0轮/)).not.toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index e50f6004ac..7a11c3cb1d 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -479,13 +479,13 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d // content regex — the first message class where `payload` is the truth // and `content` is only the plain-text fallback for legacy surfaces. if (message.payload?.kind === 'approval-card') { - return <>; + return <>; } if (message.payload?.kind === 'byo-handoff') { - return <>; + return <>; } if (message.payload?.kind === 'gig-card') { - return <>; + return <>; } // §3.8 system event card. Detected by content shape (commonly-bot only @@ -683,7 +683,6 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d {isDecisionRuling && · {t('activity.decision.ruledShort')}} )} - {grouped && time && } {(() => { // Quoted context for replies. POST responses carry a normalized @@ -790,6 +789,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d number={pr.number} /> ))} + {(() => { // Sprint B5: real reactions come from message.reactions (server- // aggregated `[{emoji, count, mine}]`); legacy fixtures use the diff --git a/frontend/src/v2/components/V2RunTrace.tsx b/frontend/src/v2/components/V2RunTrace.tsx index f5d2d79c5d..a4bd523dc0 100644 --- a/frontend/src/v2/components/V2RunTrace.tsx +++ b/frontend/src/v2/components/V2RunTrace.tsx @@ -37,7 +37,7 @@ const V2RunTrace: React.FC = () => { return () => { live = false; }; }, [api, podId, runId, attempt, t]); - const enumLabel = (group: 'status' | 'trigger', value: string) => t(`runTrace.${group}.${value}`, { defaultValue: value }); + const enumLabel = (group: 'status' | 'trigger' | 'outcomes', value: string) => t(`runTrace.${group}.${value}`, { defaultValue: value }); const limitedKey = trace?.omittedTurns && trace?.omittedToolCalls ? 'limitedBoth' : trace?.omittedTurns ? 'limitedTurns' : trace?.omittedToolCalls ? 'limitedCalls' : 'limitedContent'; @@ -64,12 +64,12 @@ const V2RunTrace: React.FC = () => {

    {turn.model} · {turn.elapsedMs} ms · {turn.totalTokens ?? '—'} {t('runTrace.tokens')}

    {!turn.toolCalls.length &&

    {t('runTrace.noTools')}

    } {turn.toolCalls.map((call, index) =>
    - {call.name}{call.outcome} · {call.elapsedMs} ms + {call.name}{enumLabel('outcomes', call.outcome)} · {call.elapsedMs} ms {call.callId && {t('runTrace.call', { id: call.callId })}} {call.trail ?
    {t('runTrace.grant')}
    {call.trail.grantId}
    {t('runTrace.owner')}
    {call.trail.credentialOwnerId || t('runTrace.notRecorded')}
    -
    {t('runTrace.outcome')}
    {call.trail.outcome}
    +
    {t('runTrace.outcome')}
    {enumLabel('outcomes', call.trail.outcome)}
    : call.callId ?

    {t('runTrace.unavailable')}

    :

    {t('runTrace.builtIn')}

    }
    )}
  2. )} diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 6cf9f8b7ef..db931d8bd6 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -86,6 +86,7 @@ export interface V2Message { // the `...raw` spread — declared here so consumers can read it typed. payload?: { runId?: string; + runPodId?: string; kind?: string; elements?: unknown[]; // kind 'starts': offers rendered as buttons under the message (first From 598090368072989d3aca1c805d0156211f0f2490 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:48:01 -0700 Subject: [PATCH 6/7] Record refused native message posts as failed trace calls Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- .../nativeRuntimeService.webSearch.test.js | 15 +++++++++++++++ backend/services/nativeRuntimeService.ts | 3 +++ frontend/src/v2/__tests__/V2MessageRow.test.tsx | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index 58f583f83c..67e778cdc3 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js @@ -124,6 +124,21 @@ describe('native turn with commonly_web_search', () => { mockProposeAction.mockResolvedValue({ ok: true, approvalId: 'approval-1' }); }); + test.each([ + [{ success: false, refused: true, reason: 'consecutive_agent_cap' }, 'failed', false], + [{ success: true, message: { id: 'posted-1' } }, 'ok', true], + ])('records the actual post result %j as %s', async (postResult, outcome, posted) => { + AgentMessageService.postMessage.mockResolvedValueOnce(postResult); + axios.post.mockResolvedValueOnce(toolCalls(call('p1', 'commonly_post_message', { content: 'Here is the update.' }))) + .mockResolvedValueOnce(finalReply); + await runAgent(install, mention); + const recorded = savedRun.turns[0].toolCalls[0]; + expect(recorded.name).toBe('commonly_post_message'); + expect(recorded.outcome).toBe(outcome); + expect(recorded.error).toBe(posted ? undefined : 'message_post_refused'); + expect(JSON.parse(toolMessages(1)[0].content)).toEqual(expect.objectContaining({ posted, ok: posted })); + }); + test('results reach the model marked untrusted; the run keeps the count and the addresses only', async () => { axios.post .mockResolvedValueOnce(toolCalls(call('c1', 'commonly_web_search', { query: 'node 26 release' }))) diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5b8d3168b8..fc41700008 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -799,6 +799,9 @@ export async function dispatchTool( runId: ctx.runId, ...(ctx.runId ? { runPodId: String(ctx.podId) } : {}), }); + if (result?.refused) { + return { content: { ok: false, posted: false, result }, error: 'message_post_refused' }; + } return { content: { ok: true, posted: true, result } }; } diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index 6fdaa0a099..b70566503a 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -306,7 +306,7 @@ describe('V2MessageRow', () => { }); }); -it('server-bound origin pod links a private approval to its original run without widening trace access', () => { +it('server-bound origin pod links a plain message after its content', () => { const runPodId = 'aaaaaaaaaaaaaaaaaaaaaaa1'; const runId = 'bbbbbbbbbbbbbbbbbbbbbbb1'; render( { + const runPodId = 'aaaaaaaaaaaaaaaaaaaaaaa1', runId = 'bbbbbbbbbbbbbbbbbbbbbbb1'; + render(); + expect(screen.getByTestId('approval-card')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View run trace' })).toHaveAttribute('href', `/v2/pods/${runPodId}/runs/${runId}`); +}); From 0f0d8cfeeef54b7da8cd016db6a3b824db4dda0a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:00:03 -0700 Subject: [PATCH 7/7] fix: restrict run trace authorization identities to pod members Co-authored-by: lark Agent-Model: codex/gpt-6.1-sol/high --- .../__tests__/unit/routes/runTraces.test.js | 24 ++++++++++++++++--- backend/routes/runTraces.ts | 10 +++++++- docs/development/run-traces.md | 10 ++++---- frontend/src/v2/__tests__/V2RunTrace.test.tsx | 12 ++++++++++ frontend/src/v2/components/V2RunTrace.tsx | 6 ++--- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/unit/routes/runTraces.test.js b/backend/__tests__/unit/routes/runTraces.test.js index ddd544e669..3769f4fc83 100644 --- a/backend/__tests__/unit/routes/runTraces.test.js +++ b/backend/__tests__/unit/routes/runTraces.test.js @@ -13,11 +13,9 @@ jest.mock('../../../middleware/auth', () => (req, res, next) => { req.user = { id: req.get('x-user') }; return next(); }); -jest.mock('../../../services/dmService', () => ({ - canViewPod: jest.fn(async (userId, pod) => Boolean(pod?.members.some((m) => String(m) === userId))), -})); const AgentRun = require('../../../models/AgentRun'); const Pod = require('../../../models/Pod'); +const User = require('../../../models/User'); const ToolCall = require('../../../models/ToolCall'); const { pool } = require('../../../config/db-pg'); const { boundRunValue, RUN_TRACE_BYTES } = require('../../../services/agentRunLimits'); @@ -38,6 +36,7 @@ afterAll(async () => { await mongoose.disconnect(); await mongo.stop(); }); beforeEach(async () => { await AgentRun.deleteMany({}); await Pod.deleteMany({}); + await User.deleteMany({}); await Pod.create({ _id: POD, name: 'Trace pod', type: 'team', members: [MEMBER], createdBy: MEMBER }); run = await AgentRun.create({ podId: POD, agentName: 'trace-seat', instanceId: 'default', trigger: 'mention', status: 'succeeded', @@ -66,6 +65,25 @@ test('returns durable turn + PG attribution after card retention without content expect(JSON.stringify(res.body)).not.toMatch(/PRIVATE_|private prompt|argsDigest/); expect(res.body.retention).toEqual({ cardDefaultDays: 30, traceExpiresAt: null }); }); +test.each(['global admin', 'agent-dm observer'])( + 'returns trace metadata without authorization identities to a non-member %s', async (viewerType) => { + if (viewerType === 'global admin') { + await User.create({ _id: OTHER, username: 'trace-admin', email: 'trace-admin@example.test', + role: 'admin', authProviders: [{ provider: 'github', providerId: 'trace-admin' }] }); + } else { + await Pod.updateOne({ _id: POD }, { $set: { type: 'agent-dm' } }); + await Pod.create({ name: 'Shared team', type: 'team', members: [MEMBER, OTHER], createdBy: MEMBER }); + } + const res = await get(POD, run.id, OTHER); + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + const call = res.body.run.turns[0].toolCalls[0]; + expect(call.outcome).toBe('ok'); + expect(call.trail).toEqual({ callId: 'trace-call', tool: 'vendor.read', outcome: 'ok', + at: expect.any(String), durationMs: null }); + expect(JSON.stringify(res.body)).not.toMatch(/grantId|credentialOwnerId|PRIVATE_|private prompt|argsDigest/); + }, +); test('cross-pod references never join a foreign trail', async () => { run.turns[0].toolCalls[0].callId = 'foreign-call'; await run.save(); diff --git a/backend/routes/runTraces.ts b/backend/routes/runTraces.ts index dcca2eb2f2..b45e29efea 100644 --- a/backend/routes/runTraces.ts +++ b/backend/routes/runTraces.ts @@ -28,6 +28,10 @@ router.get('/:podId/runs/:runId', traceReadLimit, auth, async (req, res) => { if (!await DMService.canViewPod(userId, pod)) { return res.status(404).json({ error: 'trace_not_found' }); } + // Pod-read bypasses admit admins and agent-DM observers, but authorization + // identities are visible only to actual origin members. This pod is lean + // and unpopulated, so members are ObjectIds. + const isOriginMember = pod.members.some((member: unknown) => String(member) === String(userId)); // Do not load content-bearing args, results or LLM text even transiently. const run = await AgentRun.findOne({ _id: runId, podId }) .select([ @@ -41,7 +45,11 @@ router.get('/:podId/runs/:runId', traceReadLimit, auth, async (req, res) => { const callIds = run.turns.flatMap((turn) => turn.toolCalls.flatMap((call) => (call.callId ? [call.callId] : []))); // A PG outage is an error, never an apparently empty successful audit. const trail = await ToolCall.listRunReferences(podId, callIds); - const byId = new Map(trail.map((call) => [call.callId, call])); + const byId = new Map(trail.map((call) => [call.callId, { + callId: call.callId, tool: call.tool, outcome: call.outcome, + at: call.at, durationMs: call.durationMs, + ...(isOriginMember ? { grantId: call.grantId, credentialOwnerId: call.credentialOwnerId } : {}), + }])); res.set('Cache-Control', 'no-store'); return res.json({ run: { diff --git a/docs/development/run-traces.md b/docs/development/run-traces.md index 3b29b23054..d43481ee04 100644 --- a/docs/development/run-traces.md +++ b/docs/development/run-traces.md @@ -24,9 +24,11 @@ TASK-499 adds a read-only link from a native hosted agent's message to its - Reads are limited to 120 per minute per IP using the shared Cloudflare-aware key, before authentication or database access. - The response is `{run, retention}`. `run.turns[].toolCalls[]` holds name, - duration, outcome, optional callId, and `trail` with grantId and the credential - owner's ID. A RunTrace last-N renderer can flatten these calls and slice the - last 1–5. The server joins **only** recorded callIds in the authorized pod. + duration, outcome, optional callId, and `trail`. Only origin-pod members + receive `trail.grantId` and `trail.credentialOwnerId`. Global admins and + agent-DM observers admitted by pod-read access receive the other trail + metadata (callId, tool, outcome, at, durationMs) without either identity. A + RunTrace last-N renderer can flatten these calls and slice the last 1–5. The server joins **only** recorded callIds in the authorized pod. A missing broker record is `outcome:'unavailable', trail:null`. No raw tool arguments, results, LLM text, error prose or argument digests are returned. - An unavailable PostgreSQL read returns 503, not a successful empty audit. @@ -36,7 +38,7 @@ TASK-499 adds a read-only link from a native hosted agent's message to its ## Storage bounds Every `AgentRun` document save validates a bounded trace: 4,096 serialized UTF-8 -bytes and 256 JSON nodes per args/result value, 4,096 characters of LLM response +bytes and 256 JSON nodes per args/result value, 4,096 UTF-16 code units of LLM response text per turn, 128 stored turns, 64 stored tool calls per turn, and a 2 MiB BSON budget for the stored turns. Oversized args/results become a truncation marker with their original byte count, not a prefix of potentially secret content. diff --git a/frontend/src/v2/__tests__/V2RunTrace.test.tsx b/frontend/src/v2/__tests__/V2RunTrace.test.tsx index 34c65c379b..74870a8af7 100644 --- a/frontend/src/v2/__tests__/V2RunTrace.test.tsx +++ b/frontend/src/v2/__tests__/V2RunTrace.test.tsx @@ -39,6 +39,18 @@ test('reads recorded turns and PG attribution, with honest retention wording', a expect(screen.getByText(/30-day default retention/)).toBeInTheDocument(); expect(screen.getByText(/This trace persists after the card expires/)).toBeInTheDocument(); }); +test.each(['en', 'zh-CN'])('withheld authorization identities have no labels or missing-record claims (%s)', async (language) => { + await act(async () => { await i18n.changeLanguage(language); }); + mockGet.mockResolvedValue({ run: { ...trace, turns: [{ ...trace.turns[0], toolCalls: [{ + ...trace.turns[0].toolCalls[0], trail: { outcome: 'ok' }, + }] }] } }); + setup(); + await screen.findByText('vendor.read'); + expect(screen.queryByText(i18n.t('runTrace.grant'))).not.toBeInTheDocument(); + expect(screen.queryByText(i18n.t('runTrace.owner'))).not.toBeInTheDocument(); + expect(screen.queryByText(i18n.t('runTrace.notRecorded'))).not.toBeInTheDocument(); + expect(screen.getByText(i18n.t('runTrace.outcome'))).toBeInTheDocument(); +}); test('refusal can retry and never presents an empty successful trace', async () => { mockGet.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ run: trace }); setup(); diff --git a/frontend/src/v2/components/V2RunTrace.tsx b/frontend/src/v2/components/V2RunTrace.tsx index a4bd523dc0..72182c1a3d 100644 --- a/frontend/src/v2/components/V2RunTrace.tsx +++ b/frontend/src/v2/components/V2RunTrace.tsx @@ -12,7 +12,7 @@ export interface RunTrace { turnIndex: number; model: string; elapsedMs: number; totalTokens?: number; toolCalls: Array<{ name: string; elapsedMs: number; callId?: string; outcome: string; - trail?: { grantId: string; credentialOwnerId: string | null; outcome: string } | null; + trail?: { grantId?: string; credentialOwnerId?: string | null; outcome: string } | null; }>; }>; } @@ -67,8 +67,8 @@ const V2RunTrace: React.FC = () => { {call.name}{enumLabel('outcomes', call.outcome)} · {call.elapsedMs} ms {call.callId && {t('runTrace.call', { id: call.callId })}} {call.trail ?
    -
    {t('runTrace.grant')}
    {call.trail.grantId}
    -
    {t('runTrace.owner')}
    {call.trail.credentialOwnerId || t('runTrace.notRecorded')}
    + {call.trail.grantId !== undefined && <>
    {t('runTrace.grant')}
    {call.trail.grantId}
    } + {call.trail.credentialOwnerId !== undefined && <>
    {t('runTrace.owner')}
    {call.trail.credentialOwnerId || t('runTrace.notRecorded')}
    }
    {t('runTrace.outcome')}
    {enumLabel('outcomes', call.trail.outcome)}
    : call.callId ?

    {t('runTrace.unavailable')}

    :

    {t('runTrace.builtIn')}

    } )}