diff --git a/backend/__tests__/service/tasks.priority.test.js b/backend/__tests__/service/tasks.priority.test.js index ced40c5d0..3e2001a2f 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 122aefd92..b247b592b 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 579363e74..2e9300def 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 6e15f259e..afed387a2 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 999f06713..baa0d7383 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 b3f1fdb24..6cb9cb38a 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 6ab3cda51..8d77b6f2a 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 b580fed02..0eab7bcbb 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 ff7e6423f..82b36fd3d 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 9cff9ea46..1b16ecbe5 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 000000000..72cf56cd1 --- /dev/null +++ b/frontend/src/v2/__tests__/V2TaskRecord.test.tsx @@ -0,0 +1,188 @@ +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(`${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}'] + .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(); + 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 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'); + 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 0f0ef2648..ba9b3de0c 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 f9ac01575..eaa5e5a14 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 4fde1d213..a91a6f0f3 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 000000000..6813ebcd1 --- /dev/null +++ b/frontend/src/v2/components/V2TaskRecord.tsx @@ -0,0 +1,174 @@ +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' }; + +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) => { + 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 })); + 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>(() => new Map()); + 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; + // 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)]))); + }) + .catch(() => undefined); + return () => { live = false; }; + }, [api, podId, valid]); + + 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 }); + 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;