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/models/ApprovalAction.runTrace.test.js b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js new file mode 100644 index 0000000000..8990b90805 --- /dev/null +++ b/backend/__tests__/unit/models/ApprovalAction.runTrace.test.js @@ -0,0 +1,26 @@ +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 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, 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/routes/runTraces.test.js b/backend/__tests__/unit/routes/runTraces.test.js new file mode 100644 index 0000000000..3769f4fc83 --- /dev/null +++ b/backend/__tests__/unit/routes/runTraces.test.js @@ -0,0 +1,139 @@ +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(); +}); +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'); +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 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', + 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.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(); + 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); +}); + + +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/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index 3b679d5444..d03790d564 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', runPodId: 'forged-pod' }, runId, runPodId: POD, + }); + expect(PGMessage.create.mock.calls[0][5]).toEqual({ + kind: 'starts', starts: [{ id: 'test', label: 'Test' }], runId, runPodId: POD, + }); + 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', 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 2409cfaa48..b6091dd9b5 100644 --- a/backend/__tests__/unit/services/approvalActionService.test.js +++ b/backend/__tests__/unit/services/approvalActionService.test.js @@ -490,3 +490,36 @@ 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'); +}); + + +test('a proposal carries its originating run into the saved action and posted card', async () => { + const { proposeAction } = require('../../../services/approvalActionService'); + const runId = 'aaaaaaaaaaaaaaaaaaaaa111'; + 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, 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, runPodId })); + expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ + 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.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.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.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/__tests__/unit/services/nativeRuntimeService.webSearch.test.js b/backend/__tests__/unit/services/nativeRuntimeService.webSearch.test.js index b2e282e0f7..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' }))) @@ -210,7 +225,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', runPodId: install.podId, })); }); @@ -286,7 +301,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', runPodId: install.podId, })); }); @@ -329,7 +344,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', runPodId: install.podId })); }); test('a failed hosted turn after a broker call keeps its fixed line inside the pod', async () => { @@ -346,7 +361,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', 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/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..2bf7e090b5 100644 --- a/backend/models/ApprovalAction.ts +++ b/backend/models/ApprovalAction.ts @@ -55,6 +55,9 @@ 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; + runPodId?: Types.ObjectId; ownerUserId: Types.ObjectId; agentName: string; /** Server-only executor identity for tool_call; never in the shared card. */ @@ -89,6 +92,8 @@ 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/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..b45e29efea --- /dev/null +++ b/backend/routes/runTraces.ts @@ -0,0 +1,81 @@ +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'; + +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', traceReadLimit, 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' }); + } + // 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([ + '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, { + 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: { + 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..7fcd6759d8 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -157,6 +157,9 @@ interface MetadataDoc { } interface PostMessageOptions { + /** Server-owned AgentRun identity. Never forwarded from runtime HTTP input. */ + runId?: string; + runPodId?: string; agentName: string; podId: unknown; content: string; @@ -1571,6 +1574,16 @@ 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; + delete (messagePayload as Record).runPodId; + } + if (options.runId) messagePayload = { ...(messagePayload as object || {}), runId: options.runId, + ...(options.runPodId ? { runPodId: options.runPodId } : {}) }; + const posted = await AgentMessageService._postToTarget({ agentName, instanceId, @@ -1578,7 +1591,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..1b183db278 100644 --- a/backend/services/approvalActionService.ts +++ b/backend/services/approvalActionService.ts @@ -51,6 +51,8 @@ const LOCAL_AGENT_SCOPES = [ ]; export interface CardPayload { + runId?: string; + runPodId?: string; kind: typeof CARD_KIND; approvalId: string; actionType: ApprovalActionType; @@ -86,6 +88,8 @@ const legacyToolCallOutcome = (row: IApprovalAction): Partial => { export const buildCardPayload = (row: IApprovalAction): CardPayload => ({ kind: CARD_KIND, + ...(row.runId ? { runId: String(row.runId), + ...(row.runPodId ? { runPodId: String(row.runPodId) } : {}) } : {}), approvalId: String(row._id), actionType: row.actionType, summary: row.summary, @@ -127,6 +131,9 @@ 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; @@ -471,6 +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 5a05762b8b..fc41700008 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -618,6 +618,7 @@ export const toolsForConfig = ( // --- tool dispatcher ------------------------------------------------------- interface DispatchContext { + runId?: string; installation: any; podId: string; agentName: string; @@ -795,7 +796,12 @@ export async function dispatchTool( // bridge appears between this check and message storage. noOutboundRelay: Boolean(ctx.webTainted || ctx.brokerCallSeen), answeringMessageId: ctx.answeringMessageId, + 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 } }; } @@ -840,6 +846,8 @@ export async function dispatchTool( payload: { kind: 'starts', starts: [{ id: 'run-hosted', label: HOSTED_OFFER_LABEL, href }] }, 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 } }; } @@ -887,6 +895,8 @@ export async function dispatchTool( payload, 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 } }; @@ -939,6 +949,8 @@ export async function dispatchTool( // fallback never double-posts narration beside the card. 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, @@ -1800,6 +1812,7 @@ export async function runAgent( } const dispatchCtx: DispatchContext = { + runId, installation, podId, agentName, @@ -2113,6 +2126,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, @@ -2173,6 +2188,7 @@ export async function runAgent( : parsedArgs, result: result.recorded ?? result.content, error: result.error, + outcome: result.error ? 'failed' : 'ok', elapsedMs: elapsed, }); if (toolName === 'commonly_post_message' && !result.error) { @@ -2278,6 +2294,8 @@ export async function runAgent( metadata: { source: 'native-runtime', fallback: true }, 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, @@ -2324,6 +2342,8 @@ export async function runAgent( metadata: { source: 'native-runtime', runFailed: true }, 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 new file mode 100644 index 0000000000..d43481ee04 --- /dev/null +++ b/docs/development/run-traces.md @@ -0,0 +1,78 @@ +# Run traces + +TASK-499 adds a read-only link from a native hosted agent's message to its +`AgentRun`, including the referenced PostgreSQL `tool_calls` audit records. + +## Contract + +- Message `payload.runId` is a server-owned Mongo ObjectId string. The native + runtime passes the internal `runId` option to `AgentMessageService`, which + removes any authored `payload.runId` before stamping the real identity. +- Existing payload fields remain intact. Plain text messages may carry just + `{runId}`. Native hosted/local offers carry it alongside their kernel card. + Native approval proposals persist it on `ApprovalAction`, so resolving the + card does not remove its trace link. +- External CLI turns have no `AgentRun` today. Do not invent a run identity for + them. A missing runId means no trace link; it does not mean no tools ran. +- `V2RunTraceLink({podId, runId})` derives + `/v2/pods/:podId/runs/:runId`, never an agent-authored URL. It validates both + IDs before rendering. TASK-498 can reuse this component. +- `GET /api/pods/:podId/runs/:runId` requires human authentication and the same + 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`. 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. + Responses use `Cache-Control: no-store`. The view supports retry and explicit + refresh for a running trace; it does not claim to stream live turns. + +## 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 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. +Call names/IDs/errors and run error text are capped too. `traceTruncated`, +`omittedTurns` and `omittedToolCalls` disclose lossy storage. The loop's real +`totalTokens` remains the aggregate; a truncated trace is not a complete replay. + +The save hook covers native run creation/final persistence. Existing atomic +updates only change status/error fields; future writers must not bypass the +hook with a direct `$push` of unbounded turns. No historical backfill is run. + +## Retention + +Message/card retention defaults to 30 days, with configurable retention, +Pro-protected pods and disk-pressure step-down exceptions. The UI says +**30-day default**, not an exact per-card expiry promise. Neither `AgentRun` nor +`tool_calls` has the message retention job's TTL. The trace URL remains readable +without looking up the original message, including after that message expires. +Pod deletion, synthetic-account purge and loss of pod access still apply; +“persists” does not override deletion or authorization. + +## Proof + +The focused route suite uses real Mongo and pg-mem. A separate service suite +runs the new query on real Postgres, covering cross-pod IDs and superseded +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/design-system/preview/components-run-trace.html b/frontend/design-system/preview/components-run-trace.html new file mode 100644 index 0000000000..b62b61e429 --- /dev/null +++ b/frontend/design-system/preview/components-run-trace.html @@ -0,0 +1,11 @@ + +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/__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/en.json b/frontend/src/i18n/locales/en.json index 4ab9bb0a73..d56d44a0f2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1,4 +1,58 @@ { + "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", + "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.", + "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" + }, + "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.", + "limitedContent": "This stored trace was size-limited. Large content fields are capped." + }, "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..a1532ac1a8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1,4 +1,58 @@ { + "runTrace": { + "title": "运行轨迹", + "back": "返回 Pod", + "view": "查看运行轨迹", + "linkRetention": "卡片默认保留30天,轨迹持续保留。", + "retention": "卡片默认保留30天,Pod 策略和存储限制可能改变期限。卡片过期后,运行轨迹仍会保留。", + "loading": "正在加载运行轨迹…", + "access": "此轨迹不可用,或你已无权访问对应的 Pod。", + "error": "无法加载运行轨迹,请重试。", + "retry": "重试", + "counts": "已记录{{turns}}轮 · {{tokens}}个 token", + "stopped": "停止原因:{{kind}}", + "running": "此运行尚未结束。", + "refresh": "刷新轨迹", + "empty": "尚未记录任何轮次。", + "turn": "第{{number}}轮", + "tokens": "个 token", + "noTools": "无工具调用。", + "call": "调用 {{id}}", + "grant": "授权", + "owner": "凭据所有者", + "outcome": "结果", + "notRecorded": "未记录", + "unavailable": "代理调用轨迹不可用。", + "builtIn": "内置工具,未使用连接器凭据。", + "status": { + "queued": "排队中", + "running": "运行中", + "succeeded": "已完成", + "failed": "失败", + "interrupted": "已中断" + }, + "trigger": { + "mention": "提及", + "heartbeat": "心跳", + "task.assigned": "任务分配", + "chat.message": "聊天消息", + "pod.join": "加入 Pod", + "first_contact": "首次联系", + "manual": "手动" + }, + "outcomes": { + "ok": "成功", + "failed": "失败", + "refused": "已拒绝", + "pending": "等待中", + "pending_approval": "待批准", + "unavailable": "不可用" + }, + "limitedBoth": "此轨迹受到存储大小限制,省略了{{turns}}轮和{{calls}}次工具调用,大型内容字段也已截断。", + "limitedTurns": "此轨迹受到存储大小限制,省略了{{turns}}轮,大型内容字段也已截断。", + "limitedCalls": "此轨迹受到存储大小限制,省略了{{calls}}次工具调用,大型内容字段也已截断。", + "limitedContent": "此轨迹受到存储大小限制,大型内容字段已截断。" + }, "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__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index b8e4f6afbf..b70566503a 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -305,3 +305,29 @@ 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 plain message after its content', () => { + 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(); +}); + +it('the approval-card branch links to its server-bound original pod', () => { + 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}`); +}); diff --git a/frontend/src/v2/__tests__/V2RunTrace.test.tsx b/frontend/src/v2/__tests__/V2RunTrace.test.tsx new file mode 100644 index 0000000000..74870a8af7 --- /dev/null +++ b/frontend/src/v2/__tests__/V2RunTrace.test.tsx @@ -0,0 +1,95 @@ +import React from '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'; + +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(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}`); + 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.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(); + 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(); +}); + +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.getByText('成功 · 5 ms')).toBeInTheDocument(); + 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 134fcf3ea2..4ec2ee9764 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: 760px\)/); + 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..7a11c3cb1d 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 @@ -788,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.css b/frontend/src/v2/components/V2RunTrace.css new file mode 100644 index 0000000000..4fde1d2134 --- /dev/null +++ b/frontend/src/v2/components/V2RunTrace.css @@ -0,0 +1,21 @@ +.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: 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; } +.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: 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: 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 new file mode 100644 index 0000000000..72182c1a3d --- /dev/null +++ b/frontend/src/v2/components/V2RunTrace.tsx @@ -0,0 +1,80 @@ +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]); + + 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'; + + return
+ {t('runTrace.back')} +

{t('runTrace.title')}

+

{t('runTrace.retention')}

+ {!trace && !error &&

{t('runTrace.loading')}

} + {error &&

{error}

} + {trace && <> +
+ {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.${limitedKey}`, { 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}{enumLabel('outcomes', call.outcome)} · {call.elapsedMs} ms + {call.callId && {t('runTrace.call', { id: call.callId })}} + {call.trail ?
    + {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')}

    } +
    )} +
  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..db931d8bd6 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -85,6 +85,8 @@ 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; + runPodId?: string; kind?: string; elements?: unknown[]; // kind 'starts': offers rendered as buttons under the message (first