From 5a9897dacaa713b2c081f46669cd1e0edb0548d2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:48:21 -0700 Subject: [PATCH 1/3] feat(record): v2 task record view from the board drawer (TASK-562) A pilot pod's member can open a task's record from the board drawer: each record-v1 line's kind, actor and data in order, with JSONL (the served bytes) and OTLP downloads, in English and Chinese. The tasks list carries permissions.canReadRecord, computed by the same isRecordReader the record route uses, so observers who can read the board see no link. The approval card link waits for the pod-chain route in #2779; view paging and the X-Record-* exposed headers land with #2963. Co-Authored-By: Claude Opus 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- .../__tests__/service/tasks.priority.test.js | 21 +++ .../unit/services/recordService.test.js | 11 ++ backend/routes/podRecords.ts | 6 +- backend/routes/tasksApi.ts | 9 +- backend/services/recordService.ts | 11 ++ docs/plans/task-513-server-record.md | 22 +++ frontend/src/i18n/locales/en.json | 39 +++++ frontend/src/i18n/locales/zh-CN.json | 39 +++++ frontend/src/v2/V2App.tsx | 5 + frontend/src/v2/__tests__/V2PodBoard.test.tsx | 18 ++ .../src/v2/__tests__/V2TaskRecord.test.tsx | 150 +++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 4 + frontend/src/v2/components/V2PodBoard.tsx | 12 +- frontend/src/v2/components/V2RunTrace.css | 2 + frontend/src/v2/components/V2TaskRecord.tsx | 155 ++++++++++++++++++ 15 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2TaskRecord.test.tsx create mode 100644 frontend/src/v2/components/V2TaskRecord.tsx diff --git a/backend/__tests__/service/tasks.priority.test.js b/backend/__tests__/service/tasks.priority.test.js index ced40c5d0d..3e2001a2fd 100644 --- a/backend/__tests__/service/tasks.priority.test.js +++ b/backend/__tests__/service/tasks.priority.test.js @@ -44,6 +44,27 @@ test('create, edit and list use validated P0–P3 and priority order', async () expect(list.body.permissions.canWrite).toBe(true); }); +test('the record link is offered only to members of a record pilot pod', async () => { + const before = process.env.RECORD_V1_PODS; + const DMService = require('../../services/dmService'); + const observerAccess = jest.spyOn(DMService, 'canViewPod').mockResolvedValue(true); + const observer = await User.create({ username: 'record-observer', email: 'record-observer@test.com', password: 'Password123!', verified: true }); + try { + delete process.env.RECORD_V1_PODS; + const off = await request(app).get(api()).set('Authorization', `Bearer ${token}`).expect(200); + expect(off.body.permissions.canReadRecord).toBe(false); + process.env.RECORD_V1_PODS = String(pod._id); + const on = await request(app).get(api()).set('Authorization', `Bearer ${token}`).expect(200); + expect(on.body.permissions.canReadRecord).toBe(true); + // An observer may read the board but would get a 403 from the record route. + const watched = await request(app).get(api()).set('Authorization', `Bearer ${generateTestToken(observer._id)}`).expect(200); + expect(watched.body.permissions).toEqual({ canWrite: false, canReadRecord: false }); + } finally { + observerAccess.mockRestore(); + if (before === undefined) delete process.env.RECORD_V1_PODS; else process.env.RECORD_V1_PODS = before; + } +}); + test('migration copies prefixes silently, preserves explicit priority/revisions and is rerunnable', async () => { const at = new Date('2026-10-01T00:00:00Z'); const rows = [ diff --git a/backend/__tests__/unit/services/recordService.test.js b/backend/__tests__/unit/services/recordService.test.js index 122aefd921..b247b592b9 100644 --- a/backend/__tests__/unit/services/recordService.test.js +++ b/backend/__tests__/unit/services/recordService.test.js @@ -95,6 +95,17 @@ describe('recordService', () => { expect(record.isRecordEnabled('anything')).toBe(true); }); + it('reads lines only for actual members, never for a missing caller', () => { + const { Types } = jest.requireActual('mongoose'); + const member = new Types.ObjectId(); + expect(record.isRecordReader([member], String(member))).toBe(true); + expect(record.isRecordReader([member], new Types.ObjectId())).toBe(false); + expect(record.isRecordReader([member], undefined)).toBe(false); + expect(record.isRecordReader(undefined, String(member))).toBe(false); + // A role-bearing entry is not a schema member; the read route refuses it too. + expect(record.isRecordReader([{ userId: member, role: 'viewer' }], String(member))).toBe(false); + }); + it('takes the actor from the authenticated caller and never guesses one', () => { expect(record.requestActor({ agentUser: AGENT, agentTokenHash: 'h' })).toEqual({ type: 'agent', id: AGENT._id, owner: AGENT.botMetadata.ownerUserId, diff --git a/backend/routes/podRecords.ts b/backend/routes/podRecords.ts index 579363e743..2e9300deff 100644 --- a/backend/routes/podRecords.ts +++ b/backend/routes/podRecords.ts @@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit'; import mongoose from 'mongoose'; import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; import { - isRecordEnabled, readRecordExport, RecordStoreUnavailableError, toOtlp, + isRecordEnabled, isRecordReader, readRecordExport, RecordStoreUnavailableError, toOtlp, } from '../services/recordService'; /* eslint-disable @typescript-eslint/no-require-imports, global-require */ @@ -49,9 +49,7 @@ router.get('/:podId/record/tasks/:taskId', recordReadLimit, auth, async (req: Re const userId = req.userId || req.user?._id || req.agentUser?._id; const pod = await Pod.findById(podId).select('members type').lean(); if (!pod || !await DMService.canViewPod(userId, pod)) return notFound(res); - // Lean and unpopulated, so members are ObjectIds. - const isMember = (pod.members || []).some((member: unknown) => String(member) === String(userId)); - if (!isMember) return res.status(403).json({ error: 'record_requires_membership' }); + if (!isRecordReader(pod.members, userId)) return res.status(403).json({ error: 'record_requires_membership' }); const task = await Task.findOne({ podId: pod._id, taskId }).select('_id').lean(); if (!task) return notFound(res); diff --git a/backend/routes/tasksApi.ts b/backend/routes/tasksApi.ts index 6e15f259ef..afed387a21 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -30,7 +30,7 @@ const { recordTaskAttention, resolveTaskAttention } = require('../services/atten // eslint-disable-next-line global-require const { parseAssignee, toSingleLineTitle } = require('../utils/taskFieldNormalization'); // eslint-disable-next-line global-require -const { requestActor } = require('../services/recordService'); +const { isRecordEnabled, isRecordReader, requestActor } = require('../services/recordService'); // eslint-disable-next-line global-require const { recordTaskTransition } = require('../services/taskRecordService'); @@ -237,7 +237,12 @@ router.get('/:podId', taskReadIpRateLimit, auth, async (req: AuthReq, res: Res) // tick boundary could otherwise report lease states that never coexisted. const now = new Date(); return res.json({ - permissions: { canWrite: access.canWrite === true }, + permissions: { + canWrite: access.canWrite === true, + // The board shows a task's record link only to someone the record + // route will serve (routes/podRecords.ts). + canReadRecord: isRecordEnabled(podId) && isRecordReader((access.pod as { members?: unknown })?.members, userId), + }, tasks: tasks.sort(compareTaskPriority).map((task: Record) => ({ ...task, leaseState: deriveLeaseState(task, now) })), }); } catch (err) { diff --git a/backend/services/recordService.ts b/backend/services/recordService.ts index 999f067136..baa0d7383d 100644 --- a/backend/services/recordService.ts +++ b/backend/services/recordService.ts @@ -55,6 +55,17 @@ export const isRecordEnabled = (podId: unknown): boolean => { return list.includes('*') || (typeof podId === 'string' && list.includes(podId)); }; +/** + * Who may read a pod's record lines: actual members, agents included. The + * lines carry approver and credential-owner ids verbatim, so admins and + * agent-DM observers who pass canViewPod are not readers. `members` is the + * lean, unpopulated array, so each entry is an ObjectId. + */ +export const isRecordReader = (members: unknown, userId: unknown): boolean => ( + Boolean(userId) && Array.isArray(members) + && members.some((member: unknown) => String(member) === String(userId)) +); + /** * The spec's runId for a server chain (ADR-033 §8 q3, option a). Derived, so * the same pod and task always name the same chain, and so the id carries no diff --git a/docs/plans/task-513-server-record.md b/docs/plans/task-513-server-record.md index b3f1fdb249..6cb9cb38a5 100644 --- a/docs/plans/task-513-server-record.md +++ b/docs/plans/task-513-server-record.md @@ -51,6 +51,28 @@ endpoint. Every writer and the endpoint are off unless the pod is listed in process so one process appends in commit order, and never changes the source write's result. +### Decisions taken in slice 4 (TASK-562) + +- **One entry point per reader the route serves.** `GET /api/v1/tasks/:podId` + returns `permissions.canReadRecord`: the pod is in `RECORD_V1_PODS` and the + caller is an actual member. Both it and the record route use + `recordService.isRecordReader`, so the drawer never offers a link the route + answers with 403. Observers who can read the board see no link. +- **The view is `/v2/pods/:podId/tasks/:taskId/record`.** It reads the JSONL as + text, never through a JSON parse of the whole body, and shows each line's + kind, actor (by member name where the pod read gives one) and data. Unknown + kinds show by raw name (spec §3 rule 2). A line that does not parse makes + the whole record unreadable rather than partly shown. 403, 404 and 503 each + have their own message, and none reads as an empty record. +- **Downloads are the served bytes.** JSONL saves the text exactly as served; + OTLP saves `?format=otlp`. +- **Not in this change:** the approval card's link. Approval lines sit on the + grant's pod chain, read through `GET /api/pods/:podId/record`, which lands + with slice 2 (#2779). The card will link to its approval row's own pod, never + the pod it is displayed in. Paging in the view and the CORS + `exposedHeaders` for `X-Record-*` land with #2963, the change that adds the + cursor. Until then one read returns the whole chain. + ## Measured inputs (2026-10-25) - TASK-504 landed in #2684, merge `ab5132313ad399d8fb990d69bc39db0ccf99e055`. diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6ab3cda51f..8d77b6f2a8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -54,6 +54,44 @@ "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." }, + "taskRecord": { + "title": "Task record", + "back": "Back to board", + "intro": "What happened on this task, in order. Each entry carries the hash of the one before it, so a changed entry breaks the chain.", + "loading": "Loading record…", + "access": "This record isn't available. The pod may not keep records, or you no longer have access to it.", + "members": "Only members of this pod can read its record.", + "store": "The record store is unavailable right now. Try again shortly.", + "error": "Could not load the record. Try again.", + "unreadable": "This record could not be read.", + "retry": "Try again", + "counts_one": "{{count}} entry", + "counts_other": "{{count}} entries", + "run": "Run {{id}}", + "empty": "Nothing has been recorded for this task yet.", + "entry": "Entry {{number}}", + "downloadJsonl": "Download JSONL", + "downloadOtlp": "Download OTLP", + "downloadError": "Could not download the record. Try again.", + "actor": { + "unknown": "Unknown actor", + "board": "Board", + "system": "System: {{id}}", + "policy": "Policy: {{id}}", + "agent": "{{name}} (agent)" + }, + "kinds": { + "task_claimed": "Claimed", + "task_released": "Released", + "task_done": "Done", + "grant_issued": "Grant issued", + "grant_revoked": "Grant revoked", + "permission_requested": "Approval requested", + "permission_decided": "Approval decided", + "tool_call": "Tool call", + "tool_update": "Tool update" + } + }, "common": { "brandName": "Commonly", "brandWordmark": "commonly", @@ -1503,6 +1541,7 @@ "detail": { "updates": "Updates", "pr": "View PR", + "record": "View record", "priority": "Priority", "assignedTo": "Assigned to", "claimedBy": "Claimed by", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b580fed023..0eab7bcbba 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -54,6 +54,44 @@ "limitedCalls": "此轨迹受到存储大小限制,省略了{{calls}}次工具调用,大型内容字段也已截断。", "limitedContent": "此轨迹受到存储大小限制,大型内容字段已截断。" }, + "taskRecord": { + "title": "任务记录", + "back": "返回看板", + "intro": "按顺序列出此任务发生的事。每条记录都带有上一条的哈希,任何一条被改动,链就会断开。", + "loading": "正在加载记录…", + "access": "此记录不可用。该 Pod 可能没有开启记录,或你已无权访问它。", + "members": "只有此 Pod 的成员可以查看它的记录。", + "store": "记录存储暂时不可用,请稍后重试。", + "error": "无法加载记录,请重试。", + "unreadable": "无法读取此记录。", + "retry": "重试", + "counts_one": "{{count}}条记录", + "counts_other": "{{count}}条记录", + "run": "运行 {{id}}", + "empty": "此任务还没有任何记录。", + "entry": "第{{number}}条", + "downloadJsonl": "下载 JSONL", + "downloadOtlp": "下载 OTLP", + "downloadError": "无法下载记录,请重试。", + "actor": { + "unknown": "未知操作者", + "board": "看板", + "system": "系统:{{id}}", + "policy": "策略:{{id}}", + "agent": "{{name}}(智能体)" + }, + "kinds": { + "task_claimed": "已认领", + "task_released": "已释放", + "task_done": "已完成", + "grant_issued": "已签发授权", + "grant_revoked": "已撤销授权", + "permission_requested": "请求审批", + "permission_decided": "审批已决定", + "tool_call": "工具调用", + "tool_update": "工具更新" + } + }, "common": { "brandName": "Commonly", "brandWordmark": "commonly", @@ -1495,6 +1533,7 @@ "detail": { "updates": "更新记录", "pr": "查看 PR", + "record": "查看记录", "priority": "优先级", "assignedTo": "分配给", "claimedBy": "认领人", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index ff7e6423f9..82b36fd3d9 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -40,6 +40,7 @@ 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 V2TaskRecord = React.lazy(() => import('./components/V2TaskRecord')); const V2ActivityPage = React.lazy(() => import('./components/V2ActivityPage')); const V2ArtifactsPage = React.lazy(() => import('./components/V2ArtifactsPage')); const ChatRoom = React.lazy(() => import('../components/ChatRoom')); @@ -310,6 +311,10 @@ const V2App: React.FC = () => { path="pods/:podId/runs/:runId" element={feature('Run trace', , false, false)} /> + , false, false)} + /> , false, false, 'v2-feature--activity')} diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx index 9cff9ea468..1b16ecbe55 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -660,3 +660,21 @@ test('a delayed Follow response cannot replace another follower from the current expect(await screen.findByRole('button', { name: /Following/ })).toHaveAttribute('aria-pressed', 'true'); expect(document.querySelectorAll('.v2-board__watcher')).toHaveLength(2); }); + +test('the drawer links to the task record only when the server says the reader may read it', async () => { + const POD = 'aaaaaaaaaaaaaaaaaaaaaaa1'; + const task = { taskId: 'TASK-7', title: 'Recorded work', status: 'claimed', watchers: [] }; + let canReadRecord = true; + axios.get.mockImplementation((url) => Promise.resolve({ data: url.startsWith('/api/v1/tasks/') + ? { tasks: [task], permissions: { canWrite: true, canReadRecord } } : {} })); + const { unmount } = renderBoard(`/v2/pods/${POD}/board`); + fireEvent.click(await screen.findByTitle('TASK-7')); + expect(screen.getByRole('link', { name: 'View record' })).toHaveAttribute('href', `/v2/pods/${POD}/tasks/TASK-7/record`); + unmount(); + + canReadRecord = false; + renderBoard(`/v2/pods/${POD}/board`); + fireEvent.click(await screen.findByTitle('TASK-7')); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'View record' })).toBeNull(); +}); diff --git a/frontend/src/v2/__tests__/V2TaskRecord.test.tsx b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx new file mode 100644 index 0000000000..eb67f95619 --- /dev/null +++ b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import i18n from '../../i18n'; +import V2TaskRecord, { parseRecord, taskRecordPath } from '../components/V2TaskRecord'; + +const mockGet = jest.fn(); +const mockApi = { get: mockGet }; +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); + +const POD = 'aaaaaaaaaaaaaaaaaaaaaaa1'; +const HUMAN = 'bbbbbbbbbbbbbbbbbbbbbbb1'; +const AGENT = 'ccccccccccccccccccccccc1'; +const RUN = 'srv-task-0123456789abcdef0123456789abcdef'; +const line = (seq: number, kind: string, actor: unknown, data: Record = {}) => JSON.stringify({ + actor, data, kind, podId: POD, prev: seq === 1 ? null : 'a'.repeat(64), runId: RUN, + schema: 'seats.record', seq, taskId: 'TASK-7', ts: `2026-10-28T10:0${seq}:00.000Z`, v: 1, +}); +const JSONL = [ + line(1, 'task.claimed', { id: AGENT, owner: HUMAN, type: 'agent' }, { leaseUntil: '2026-10-28T10:31:00.000Z' }), + line(2, 'task.released', { id: AGENT, type: 'agent' }, { reason: 'blocked' }), + line(3, 'task.done', { id: 'board', type: 'system' }, { doneBy: HUMAN }), + line(4, 'harness.switched', null), +].join('\n').concat('\n'); +const recordUrl = `/api/pods/${POD}/record/tasks/TASK-7`; + +const wire = (record: () => Promise, otlp: () => Promise = () => Promise.resolve({ resourceSpans: [] })) => { + mockGet.mockImplementation((url: string) => { + if (url === recordUrl) return record(); + if (url === `${recordUrl}?format=otlp`) return otlp(); + if (url === `/api/pods/${POD}`) return Promise.resolve({ members: [{ _id: HUMAN, username: 'ana', displayName: 'Ana' }, { _id: AGENT, username: 'scout' }] }); + return Promise.reject(new Error(`unexpected ${url}`)); + }); +}; +const setup = (taskId = 'TASK-7') => render( + } /> +); +// jsdom's Blob has no text(); FileReader reads it the same way a browser would. +const readBlob = (blob: Blob) => new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.readAsText(blob); +}); +const httpError = (status: number) => () => Promise.reject(Object.assign(new Error('http'), { response: { status } })); + +beforeEach(async () => { mockGet.mockReset(); await i18n.changeLanguage('en'); }); +afterEach(async () => { await i18n.changeLanguage('en'); }); + +test('paths come from ids the record route accepts, never from arbitrary text', () => { + expect(taskRecordPath(POD, 'TASK-7')).toBe(`/v2/pods/${POD}/tasks/TASK-7/record`); + expect(taskRecordPath('pod-1', 'TASK-7')).toBeNull(); + expect(taskRecordPath(POD, '../admin')).toBeNull(); + expect(taskRecordPath(POD, 'https://evil.example')).toBeNull(); + expect(parseRecord('{"seq":1}\n\n{"seq":2}\n').map((entry) => entry.seq)).toEqual([1, 2]); +}); + +test('reads the JSONL as text and shows every entry with member names, unknown kinds by name', async () => { + wire(() => Promise.resolve(JSONL)); + setup(); + expect(await screen.findByText('Claimed')).toBeInTheDocument(); + expect(mockGet).toHaveBeenCalledWith(recordUrl, expect.objectContaining({ responseType: 'text' })); + // A one-line record is still text: the transform must not parse it as JSON. + const config = mockGet.mock.calls.find(([url]) => url === recordUrl)[1]; + expect(config.transformResponse[0](line(1, 'task.done', null))).toBe(line(1, 'task.done', null)); + expect(screen.getByText('4 entries')).toBeInTheDocument(); + expect(screen.getByText(`Run ${RUN}`)).toBeInTheDocument(); + expect(screen.getByText('Released')).toBeInTheDocument(); + expect(screen.getByText('blocked')).toBeInTheDocument(); + expect(screen.getByText('Done')).toBeInTheDocument(); + expect(screen.getByText('harness.switched')).toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByText(/scout \(agent\)/)).toHaveLength(2)); + expect(screen.getByText(/^Board ·/)).toBeInTheDocument(); + expect(screen.getByText(/^Unknown actor ·/)).toBeInTheDocument(); + expect(screen.getByText('Ana')).toBeInTheDocument(); + expect(screen.getByText('Entry 4')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Back to board' })).toHaveAttribute('href', `/v2/pods/${POD}/board`); +}); + +test('an empty chain says so rather than showing a blank page', async () => { + wire(() => Promise.resolve('')); + setup(); + expect(await screen.findByText('Nothing has been recorded for this task yet.')).toBeInTheDocument(); + expect(screen.getByText('0 entries')).toBeInTheDocument(); +}); + +test.each([ + [404, "This record isn't available. The pod may not keep records, or you no longer have access to it.", false], + [403, 'Only members of this pod can read its record.', false], + [503, 'The record store is unavailable right now. Try again shortly.', true], + [500, 'Could not load the record. Try again.', true], +])('a %i is its own message, never an empty record', async (status, message, retryable) => { + wire(httpError(status)); + setup(); + expect(await screen.findByRole('alert')).toHaveTextContent(message); + expect(screen.queryByText(/entries/)).toBeNull(); + expect(!!screen.queryByRole('button', { name: 'Try again' })).toBe(retryable); +}); + +test('a line that is not JSON makes the whole record unreadable, not partly shown', async () => { + wire(() => Promise.resolve(`${line(1, 'task.claimed', null)}\nnot json\n`)); + setup(); + expect(await screen.findByRole('alert')).toHaveTextContent('This record could not be read.'); + expect(screen.queryByText('Claimed')).toBeNull(); +}); + +test('a task id the route would refuse is never requested', async () => { + wire(() => Promise.resolve(JSONL)); + setup('bad id'); + expect(await screen.findByRole('alert')).toHaveTextContent("This record isn't available."); + expect(mockGet).not.toHaveBeenCalled(); +}); + +test('downloads the served JSONL bytes and the OTLP export as files', async () => { + const saved: Array<{ name: string; type: string; text: Promise }> = []; + const createObjectURL = jest.fn((blob: Blob) => { saved.push({ name: '', type: blob.type, text: readBlob(blob) }); return `blob:${saved.length}`; }); + const revokeObjectURL = jest.fn(); + Object.assign(URL, { createObjectURL, revokeObjectURL }); + const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function record(this: HTMLAnchorElement) { + saved[saved.length - 1].name = this.download; + }); + try { + let failOtlp = true; + wire(() => Promise.resolve(JSONL), () => (failOtlp ? Promise.reject(new Error('down')) : Promise.resolve({ resourceSpans: [{ id: 1 }] }))); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Download JSONL' })); + expect(saved[0].name).toBe('TASK-7.record.jsonl'); + expect(saved[0].type).toBe('application/x-ndjson'); + await expect(saved[0].text).resolves.toBe(JSONL); + + fireEvent.click(screen.getByRole('button', { name: 'Download OTLP' })); + expect(await screen.findByText('Could not download the record. Try again.')).toBeInTheDocument(); + expect(saved).toHaveLength(1); + failOtlp = false; + fireEvent.click(screen.getByRole('button', { name: 'Download OTLP' })); + await waitFor(() => expect(saved).toHaveLength(2)); + expect(saved[1].name).toBe('TASK-7.record.otlp.json'); + await expect(saved[1].text).resolves.toBe(JSON.stringify({ resourceSpans: [{ id: 1 }] })); + expect(screen.queryByText('Could not download the record. Try again.')).toBeNull(); + } finally { click.mockRestore(); } +}); + +test('Chinese copy covers the page', async () => { + await i18n.changeLanguage('zh-CN'); + wire(() => Promise.resolve(JSONL)); + setup(); + expect(await screen.findByText('已认领')).toBeInTheDocument(); + expect(screen.getByText('4条记录')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '下载 JSONL' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '返回看板' })).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 0f0ef26487..ba9b3de0c0 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5089,6 +5089,10 @@ describe('run trace layout', () => { expect(css).toMatch(/@media \(max-width: 760px\)/); expect(css).toMatch(/grid-template-columns: minmax\(0, 1fr\)/); }); + test('task record downloads wrap at 390 rather than push the page sideways (TASK-562)', () => { + expect(ruleBody(css, '.v2-task-record__downloads')).toContain('flex-wrap: wrap'); + expect(ruleBody(css, '.v2-task-record__entries')).toContain('list-style: none'); + }); }); test('TASK-531 landing FAQ keeps its measure, rhythm and question style', () => { diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx index f9ac01575e..eaa5e5a14b 100644 --- a/frontend/src/v2/components/V2PodBoard.tsx +++ b/frontend/src/v2/components/V2PodBoard.tsx @@ -13,7 +13,7 @@ // update + revert — the same call the v1 board makes, so both boards stay // behaviorally interchangeable while v1 winds down. import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; @@ -21,6 +21,7 @@ import { useV2Api } from '../hooks/useV2Api'; import { useSocket } from '../../context/SocketContext'; import V2Avatar from './V2Avatar'; import { useAuth } from '../../context/AuthContext'; +import { taskRecordPath } from './V2TaskRecord'; type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3'; const PRIORITIES: TaskPriority[] = ['P0', 'P1', 'P2', 'P3']; @@ -147,6 +148,7 @@ const V2PodBoard: React.FC = () => { const [tasks, setTasks] = useState([]); const [taskWriteAllowed, setTaskWriteAllowed] = useState(false); + const [recordReadAllowed, setRecordReadAllowed] = useState(false); const reloadTasksRef = useRef<() => void>(() => undefined); const taskLoadPendingRef = useRef(false); const [claimClock, setClaimClock] = useState(() => Date.now()); @@ -320,10 +322,11 @@ const V2PodBoard: React.FC = () => { const requestId = ++taskRequestRef.current; taskLoadPendingRef.current = true; try { - const data = await api.get<{ tasks: BoardTask[]; permissions?: { canWrite: boolean } }>(`/api/v1/tasks/${podId}`); + const data = await api.get<{ tasks: BoardTask[]; permissions?: { canWrite: boolean; canReadRecord?: boolean } }>(`/api/v1/tasks/${podId}`); if (requestId !== taskRequestRef.current) return; setTasks(data.tasks || []); setTaskWriteAllowed(data.permissions?.canWrite === true); + setRecordReadAllowed(data.permissions?.canReadRecord === true); setSelected((previous) => previous ? (data.tasks || []).find((task) => task.taskId === previous.taskId) || null : null); setClaimClock(Date.now()); setLoadError(false); @@ -803,6 +806,11 @@ const V2PodBoard: React.FC = () => { {t('board.detail.pr')} )} + {recordReadAllowed && taskRecordPath(podId, selected.taskId) && ( + + {t('board.detail.record')} + + )}
diff --git a/frontend/src/v2/components/V2RunTrace.css b/frontend/src/v2/components/V2RunTrace.css index 4fde1d2134..a91a6f0f31 100644 --- a/frontend/src/v2/components/V2RunTrace.css +++ b/frontend/src/v2/components/V2RunTrace.css @@ -13,6 +13,8 @@ .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; } +.v2-task-record__entries { list-style: none; margin: 24px 0; padding: 0; } +.v2-task-record__downloads { display: flex; gap: 8px; 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; } diff --git a/frontend/src/v2/components/V2TaskRecord.tsx b/frontend/src/v2/components/V2TaskRecord.tsx new file mode 100644 index 0000000000..327eeb120a --- /dev/null +++ b/frontend/src/v2/components/V2TaskRecord.tsx @@ -0,0 +1,155 @@ +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'; + +// Ids as the record route accepts them (backend/routes/podRecords.ts). +const POD_ID = /^[a-f\d]{24}$/i; +const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; + +/** The record page for one board task, or null for ids the route would refuse. */ +export const taskRecordPath = (podId: unknown, taskId: unknown): string | null => ( + typeof podId === 'string' && POD_ID.test(podId) && typeof taskId === 'string' && TASK_ID.test(taskId) + ? `/v2/pods/${podId}/tasks/${encodeURIComponent(taskId)}/record` : null +); + +/** One record-v1 line (packages/seats/docs/record-v1.md §1). */ +export interface RecordLine { + seq: number; ts: string; kind: string; runId: string; + actor: { type: string; id: string; owner?: string } | null; + data: Record; +} + +// Kinds the server writes today. A reader shows any other kind by its raw name. +const KNOWN_KINDS = new Set([ + 'task.claimed', 'task.released', 'task.done', 'grant.issued', 'grant.revoked', + 'permission.requested', 'permission.decided', 'tool.call', 'tool.update', +]); +// Data fields that hold a user id, shown by member name where one is known. +const PERSON_FIELDS = new Set(['doneBy', 'decidedBy', 'issuedBy']); + +type LoadError = 'access' | 'members' | 'store' | 'error' | 'unreadable'; +const STATUS_ERRORS: Record = { 403: 'members', 404: 'access', 503: 'store' }; + +export const parseRecord = (jsonl: string): RecordLine[] => jsonl.split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as RecordLine); + +const saveFile = (content: string, type: string, name: string) => { + const url = URL.createObjectURL(new Blob([content], { type })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +const V2TaskRecord: React.FC = () => { + const { t, i18n } = useTranslation(); + const { podId = '', taskId = '' } = useParams<{ podId: string; taskId: string }>(); + const api = useV2Api(); + const valid = taskRecordPath(podId, taskId) !== null; + const recordUrl = `/api/pods/${encodeURIComponent(podId)}/record/tasks/${encodeURIComponent(taskId)}`; + const [jsonl, setJsonl] = useState(null); + const [lines, setLines] = useState([]); + const [error, setError] = useState(null); + const [downloadError, setDownloadError] = useState(false); + const [names, setNames] = useState>({}); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + + useEffect(() => { + let live = true; + setJsonl(null); setLines([]); setError(null); setDownloadError(false); + if (!valid) { setError('access'); return undefined; } + // Text as served: one JSON object per line, never parsed as a whole. + api.get(recordUrl, { responseType: 'text', transformResponse: [(body: unknown) => body] }) + .then((body) => { + if (!live) return; + const text = typeof body === 'string' ? body : ''; + try { setLines(parseRecord(text)); setJsonl(text); } catch { setError('unreadable'); } + }) + .catch((err: { response?: { status?: number } }) => { + if (live) setError(STATUS_ERRORS[err.response?.status || 0] || 'error'); + }); + return () => { live = false; }; + }, [api, recordUrl, valid, attempt]); + + // Actor ids resolve to member names when the pod read allows it; ids stay otherwise. + useEffect(() => { + let live = true; + if (!valid) return undefined; + api.get<{ members?: Array<{ _id?: string; username?: string; displayName?: string }> }>(`/api/pods/${encodeURIComponent(podId)}`) + .then((pod) => { + if (!live) return; + setNames(Object.fromEntries((pod.members || []) + .filter((member) => member._id) + .map((member) => [String(member._id), member.displayName || member.username || String(member._id)]))); + }) + .catch(() => undefined); + return () => { live = false; }; + }, [api, podId, valid]); + + const personLabel = useCallback((id: string) => names[id] || id, [names]); + const actorLabel = (actor: RecordLine['actor']) => { + if (!actor) return t('taskRecord.actor.unknown'); + if (actor.type === 'system') return actor.id === 'board' ? t('taskRecord.actor.board') : t('taskRecord.actor.system', { id: actor.id }); + if (actor.type === 'policy') return t('taskRecord.actor.policy', { id: actor.id }); + if (actor.type === 'agent') return t('taskRecord.actor.agent', { name: personLabel(actor.id) }); + return personLabel(actor.id); + }; + const kindLabel = (kind: string) => (KNOWN_KINDS.has(kind) ? t(`taskRecord.kinds.${kind.replace('.', '_')}`) : kind); + const valueLabel = (key: string, value: unknown) => { + if (typeof value === 'string') return PERSON_FIELDS.has(key) ? personLabel(value) : value; + return typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value); + }; + const runId = lines[0]?.runId; + const fileBase = `${taskId}.record`; + + const downloadOtlp = async () => { + setDownloadError(false); + try { + const otlp = await api.get(`${recordUrl}?format=otlp`); + saveFile(JSON.stringify(otlp), 'application/json', `${fileBase}.otlp.json`); + } catch { setDownloadError(true); } + }; + + return
+ {t('taskRecord.back')} +

{t('taskRecord.title')}

+

{t('taskRecord.intro')}

+ {jsonl === null && !error &&

{t('taskRecord.loading')}

} + {error &&

{t(`taskRecord.${error}`)}

+ {error !== 'access' && error !== 'members' && } +
} + {jsonl !== null && <> +
+ {taskId} + {t('taskRecord.counts', { count: lines.length })} + {runId && {t('taskRecord.run', { id: runId })}} +
+ + +
+ {downloadError &&

{t('taskRecord.downloadError')}

} +
+ {!lines.length &&

{t('taskRecord.empty')}

} +
    + {lines.map((line) =>
  1. + {kindLabel(line.kind)} + {actorLabel(line.actor)} · + {t('taskRecord.entry', { number: line.seq })} + {Object.keys(line.data || {}).length > 0 &&
    + {Object.entries(line.data).map(([key, value]) => +
    {key}
    {valueLabel(key, value)}
    +
    )} +
    } +
  2. )} +
+ } +
; +}; +export default V2TaskRecord; From 297fdec056e7d65a28c946e1402c89ac3886c80d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:25:28 -0700 Subject: [PATCH 2/3] fix(record): refuse parseable lines that are not record entries (TASK-562) A stored line of null, a bare number or an actor without an id parsed cleanly and then threw at render. parseRecord now checks the fields the view reads, so such a record shows the unreadable state like bad JSON. Co-Authored-By: Claude Opus 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- .../src/v2/__tests__/V2TaskRecord.test.tsx | 14 +++++++++++++- frontend/src/v2/components/V2TaskRecord.tsx | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2TaskRecord.test.tsx b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx index eb67f95619..7698cec2a5 100644 --- a/frontend/src/v2/__tests__/V2TaskRecord.test.tsx +++ b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx @@ -51,7 +51,12 @@ test('paths come from ids the record route accepts, never from arbitrary text', expect(taskRecordPath('pod-1', 'TASK-7')).toBeNull(); expect(taskRecordPath(POD, '../admin')).toBeNull(); expect(taskRecordPath(POD, 'https://evil.example')).toBeNull(); - expect(parseRecord('{"seq":1}\n\n{"seq":2}\n').map((entry) => entry.seq)).toEqual([1, 2]); + expect(parseRecord(`${line(1, 'task.claimed', null)}\n\n${line(2, 'task.done', null)}\n`).map((entry) => entry.seq)).toEqual([1, 2]); +}); + +test('parseable text that is not a record entry is refused, not rendered', () => { + ['null', '7', '[]', '{"seq":1}', JSON.stringify({ ...JSON.parse(line(1, 'task.claimed', null)), actor: { type: 'agent' } })] + .forEach((bad) => expect(() => parseRecord(`${line(1, 'task.claimed', null)}\n${bad}\n`)).toThrow()); }); test('reads the JSONL as text and shows every entry with member names, unknown kinds by name', async () => { @@ -103,6 +108,13 @@ test('a line that is not JSON makes the whole record unreadable, not partly show expect(screen.queryByText('Claimed')).toBeNull(); }); +test('a JSON line that is not a record entry makes the record unreadable too', async () => { + wire(() => Promise.resolve(`${line(1, 'task.claimed', null)}\nnull\n`)); + setup(); + expect(await screen.findByRole('alert')).toHaveTextContent('This record could not be read.'); + expect(screen.queryByText('Claimed')).toBeNull(); +}); + test('a task id the route would refuse is never requested', async () => { wire(() => Promise.resolve(JSONL)); setup('bad id'); diff --git a/frontend/src/v2/components/V2TaskRecord.tsx b/frontend/src/v2/components/V2TaskRecord.tsx index 327eeb120a..1ddf388fdc 100644 --- a/frontend/src/v2/components/V2TaskRecord.tsx +++ b/frontend/src/v2/components/V2TaskRecord.tsx @@ -32,9 +32,26 @@ const PERSON_FIELDS = new Set(['doneBy', 'decidedBy', 'issuedBy']); type LoadError = 'access' | 'members' | 'store' | 'error' | 'unreadable'; const STATUS_ERRORS: Record = { 403: 'members', 404: 'access', 503: 'store' }; +const isObject = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); +// The fields this view reads. Parseable text of any other shape (null, a bare +// number, an actor without an id) is as unreadable as text that is not JSON. +const isRecordLine = (value: unknown): value is RecordLine => isObject(value) + && Number.isInteger(value.seq) && typeof value.ts === 'string' + && typeof value.kind === 'string' && typeof value.runId === 'string' + && (value.actor === null || (isObject(value.actor) + && typeof value.actor.type === 'string' && typeof value.actor.id === 'string')) + && isObject(value.data); + +/** Every line of a record, or a throw when any line is not a record-v1 entry. */ export const parseRecord = (jsonl: string): RecordLine[] => jsonl.split('\n') .filter((line) => line.trim()) - .map((line) => JSON.parse(line) as RecordLine); + .map((line) => { + const value: unknown = JSON.parse(line); + if (!isRecordLine(value)) throw new Error('not a record line'); + return value; + }); const saveFile = (content: string, type: string, name: string) => { const url = URL.createObjectURL(new Blob([content], { type })); From 6ae4dcddc20a660a23e4139dcc7727d60f06fbb5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:51:45 -0700 Subject: [PATCH 3/3] fix(record): look member names up in a Map, and test each parser guard alone (TASK-562) An actor id or doneBy value of `__proto__` or `constructor` resolved to an inherited Object property and reached the render. Names now live in a Map, so any id that is not a member shows as itself. Each parser guard gets its own one-field-wrong case; reverting any one guard, or the Map, fails a test. Co-Authored-By: Claude Opus 5.5 Co-authored-by: otto Agent-Model: claude/claude-opus-5-5/default --- .../src/v2/__tests__/V2TaskRecord.test.tsx | 28 ++++++++++++++++++- frontend/src/v2/components/V2TaskRecord.tsx | 8 ++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/V2TaskRecord.test.tsx b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx index 7698cec2a5..72cf56cd17 100644 --- a/frontend/src/v2/__tests__/V2TaskRecord.test.tsx +++ b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx @@ -55,10 +55,36 @@ test('paths come from ids the record route accepts, never from arbitrary text', }); test('parseable text that is not a record entry is refused, not rendered', () => { - ['null', '7', '[]', '{"seq":1}', JSON.stringify({ ...JSON.parse(line(1, 'task.claimed', null)), actor: { type: 'agent' } })] + ['null', '7', '[]', '{"seq":1}'] .forEach((bad) => expect(() => parseRecord(`${line(1, 'task.claimed', null)}\n${bad}\n`)).toThrow()); }); +// One field wrong at a time on an otherwise valid line, so each guard is tested on its own. +test.each([ + ['seq', { seq: 1.5 }], + ['ts', { ts: 7 }], + ['kind', { kind: null }], + ['runId', { runId: null }], + ['actor', { actor: 'agent' }], + ['actor.type', { actor: { type: 7, id: AGENT } }], + ['actor.id', { actor: { type: 'agent' } }], + ['data', { data: null }], +])('refuses an otherwise valid line whose %s is the wrong shape', (_field, patch) => { + expect(() => parseRecord(`${line(1, 'task.claimed', { id: AGENT, type: 'agent' })}\n`)).not.toThrow(); + const value = { ...JSON.parse(line(1, 'task.claimed', { id: AGENT, type: 'agent' })), ...patch }; + expect(() => parseRecord(`${JSON.stringify(value)}\n`)).toThrow(); +}); + +test('ids that name Object properties show as ids, never as an inherited value', async () => { + wire(() => Promise.resolve([ + line(1, 'task.claimed', { id: '__proto__', type: 'human' }), + line(2, 'task.done', { id: 'board', type: 'system' }, { doneBy: 'constructor' }), + ].join('\n'))); + setup(); + expect(await screen.findByText('__proto__ ·', { exact: false })).toBeInTheDocument(); + expect(screen.getByText('constructor')).toBeInTheDocument(); +}); + test('reads the JSONL as text and shows every entry with member names, unknown kinds by name', async () => { wire(() => Promise.resolve(JSONL)); setup(); diff --git a/frontend/src/v2/components/V2TaskRecord.tsx b/frontend/src/v2/components/V2TaskRecord.tsx index 1ddf388fdc..6813ebcd12 100644 --- a/frontend/src/v2/components/V2TaskRecord.tsx +++ b/frontend/src/v2/components/V2TaskRecord.tsx @@ -74,7 +74,7 @@ const V2TaskRecord: React.FC = () => { const [lines, setLines] = useState([]); const [error, setError] = useState(null); const [downloadError, setDownloadError] = useState(false); - const [names, setNames] = useState>({}); + const [names, setNames] = useState>(() => new Map()); const [attempt, setAttempt] = useState(0); const retry = useCallback(() => setAttempt((value) => value + 1), []); @@ -102,7 +102,9 @@ const V2TaskRecord: React.FC = () => { api.get<{ members?: Array<{ _id?: string; username?: string; displayName?: string }> }>(`/api/pods/${encodeURIComponent(podId)}`) .then((pod) => { if (!live) return; - setNames(Object.fromEntries((pod.members || []) + // A Map, not an object: an id like `__proto__` or `constructor` must + // never resolve to an inherited property and reach the render. + setNames(new Map((pod.members || []) .filter((member) => member._id) .map((member) => [String(member._id), member.displayName || member.username || String(member._id)]))); }) @@ -110,7 +112,7 @@ const V2TaskRecord: React.FC = () => { return () => { live = false; }; }, [api, podId, valid]); - const personLabel = useCallback((id: string) => names[id] || id, [names]); + const personLabel = useCallback((id: string) => names.get(id) || id, [names]); const actorLabel = (actor: RecordLine['actor']) => { if (!actor) return t('taskRecord.actor.unknown'); if (actor.type === 'system') return actor.id === 'board' ? t('taskRecord.actor.board') : t('taskRecord.actor.system', { id: actor.id });