Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/__tests__/service/tasks.priority.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
11 changes: 11 additions & 0 deletions backend/__tests__/unit/services/recordService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions backend/routes/podRecords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);

Expand Down
9 changes: 7 additions & 2 deletions backend/routes/tasksApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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<string, unknown>) => ({ ...task, leaseState: deriveLeaseState(task, now) })),
});
} catch (err) {
Expand Down
11 changes: 11 additions & 0 deletions backend/services/recordService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/plans/task-513-server-record.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1503,6 +1541,7 @@
"detail": {
"updates": "Updates",
"pr": "View PR",
"record": "View record",
"priority": "Priority",
"assignedTo": "Assigned to",
"claimedBy": "Claimed by",
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1495,6 +1533,7 @@
"detail": {
"updates": "更新记录",
"pr": "查看 PR",
"record": "查看记录",
"priority": "优先级",
"assignedTo": "分配给",
"claimedBy": "认领人",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -310,6 +311,10 @@ const V2App: React.FC = () => {
path="pods/:podId/runs/:runId"
element={feature('Run trace', <V2RunTrace />, false, false)}
/>
<Route
path="pods/:podId/tasks/:taskId/record"
element={feature('Task record', <V2TaskRecord />, false, false)}
/>
<Route
path="activity"
element={feature('Activity', <V2ActivityPage />, false, false, 'v2-feature--activity')}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/v2/__tests__/V2PodBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Loading
Loading