@ours.network/fleet 1.2.0-nightly.80 → 1.2.0-nightly.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { publishFleetResourceCommit } from '../application/resource-observer.js';
2
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
2
+ import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
6
+ import { incarnationEnded, processIncarnation } from '../process-incarnation.js';
6
7
  import { stateRoot } from '../paths.js';
7
8
  import { planWorkspace, ensureWorkspace } from './workspace.js';
8
9
  import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
@@ -11,56 +12,125 @@ export const tasksDir = () => join(stateRoot(), 'tasks');
11
12
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
12
13
  function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
13
14
  const localTaskLocks = new Set();
15
+ /**
16
+ * Remove a lock atomically: rename it to a unique name first, so no other
17
+ * process ever observes a half-removed lock.
18
+ */
19
+ function retireLock(path) {
20
+ const retired = `${path}.retired.${process.pid}.${randomUUID()}`;
21
+ try {
22
+ renameSync(path, retired);
23
+ }
24
+ catch (error) {
25
+ if (error.code === 'ENOENT')
26
+ return;
27
+ throw error;
28
+ }
29
+ rmSync(retired, { recursive: true, force: true });
30
+ }
31
+ /** A legacy lock directory without an owner file younger than this may still be releasing. */
32
+ const LEGACY_OWNERLESS_LOCK_GRACE_MS = 5_000;
33
+ /**
34
+ * Hold a task's cross-process lock while `fn` runs.
35
+ *
36
+ * The lock is a file holding its owner record, published complete by hard-linking
37
+ * a written temporary file: link never replaces an existing lock, and there is no
38
+ * moment at which a live claimer has an ownerless lock that could look abandoned.
39
+ * A lock is reclaimed only when its owner process has provably ended; reclaimers
40
+ * take turns and remove only the lock they observed. Directory locks are left by
41
+ * earlier versions; they are honored, and an ownerless one is reclaimed once it is
42
+ * old enough, since this version never creates one.
43
+ */
14
44
  function withTaskLock(id, fn) {
15
45
  if (localTaskLocks.has(id))
16
46
  return fn();
17
47
  const path = taskLockPath(id);
18
- const ownerPath = join(path, 'owner.json');
19
48
  const token = randomUUID();
20
49
  mkdirSync(join(tasksDir(), '.locks'), { recursive: true });
21
- for (;;) {
22
- const claimPath = `${path}.claim.${process.pid}.${randomUUID()}`;
50
+ const readOwner = () => {
23
51
  try {
24
- mkdirSync(claimPath);
25
- writeFileSync(join(claimPath, 'owner.json'), JSON.stringify({ token, pid: process.pid }));
26
- renameSync(claimPath, path);
27
- break;
52
+ return JSON.parse(readFileSync(path, 'utf8'));
28
53
  }
29
54
  catch (error) {
30
- rmSync(claimPath, { recursive: true, force: true });
31
- if (!['EEXIST', 'ENOTEMPTY'].includes(error.code ?? ''))
32
- throw error;
55
+ if (error.code !== 'EISDIR')
56
+ return undefined;
57
+ try {
58
+ return JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8'));
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ };
65
+ /** Who holds the lock now, and whether it can be proved abandoned. */
66
+ const holder = () => {
67
+ let stat;
68
+ try {
69
+ stat = lstatSync(path);
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ const owner = readOwner();
75
+ if (owner && typeof owner.pid === 'number') {
33
76
  let alive = false;
34
77
  try {
35
- const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
36
- if (typeof owner.pid === 'number') {
37
- try {
38
- process.kill(owner.pid, 0);
39
- alive = true;
40
- }
41
- catch { /* dead */ }
42
- }
78
+ process.kill(owner.pid, 0);
79
+ alive = true;
80
+ }
81
+ catch (error) {
82
+ alive = error.code !== 'ESRCH';
43
83
  }
44
- catch { /* corrupt legacy lock */ }
45
- if (!alive) {
46
- rmSync(path, { recursive: true, force: true });
84
+ return { token: owner.token, stale: !alive || incarnationEnded(owner.pid, owner) };
85
+ }
86
+ return {
87
+ token: undefined,
88
+ stale: stat.isDirectory() && Date.now() - stat.mtimeMs > LEGACY_OWNERLESS_LOCK_GRACE_MS,
89
+ };
90
+ };
91
+ const claimPath = `${path}.claim.${process.pid}.${randomUUID()}`;
92
+ writeFileSync(claimPath, JSON.stringify({ token, pid: process.pid, ...processIncarnation(process.pid) }));
93
+ try {
94
+ for (;;) {
95
+ try {
96
+ linkSync(claimPath, path);
97
+ break;
98
+ }
99
+ catch (error) {
100
+ if (error.code !== 'EEXIST')
101
+ throw error;
102
+ }
103
+ const observed = holder();
104
+ if (observed?.stale) {
105
+ try {
106
+ withSynchronousFileLock(`${path}.reclaim`, () => {
107
+ const current = holder();
108
+ if (current?.stale && current.token === observed.token)
109
+ retireLock(path);
110
+ });
111
+ }
112
+ catch (error) {
113
+ // A contender stalled inside the reclaim section: keep waiting like any other poll.
114
+ if (!(error instanceof Error) || !/^Timed out acquiring synchronous/.test(error.message))
115
+ throw error;
116
+ }
47
117
  continue;
48
118
  }
49
119
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
50
120
  }
51
121
  }
122
+ finally {
123
+ rmSync(claimPath, { force: true });
124
+ }
52
125
  localTaskLocks.add(id);
53
126
  try {
54
127
  return fn();
55
128
  }
56
129
  finally {
57
130
  localTaskLocks.delete(id);
58
- try {
59
- const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
60
- if (owner.token === token)
61
- rmSync(path, { recursive: true, force: true });
62
- }
63
- catch { /* ownership cannot be proved */ }
131
+ // Only this live owner removes its lock; reclaimers never take a live owner's lock.
132
+ if (readOwner()?.token === token)
133
+ retireLock(path);
64
134
  }
65
135
  }
66
136
  function generateTaskId() {
@@ -985,6 +1055,22 @@ export function updateTaskLayout(id, expected, next) {
985
1055
  return { task: readTask(id), ...(releasedSnapshot ? { releasedSnapshot } : {}) };
986
1056
  });
987
1057
  }
1058
+ /**
1059
+ * Progress and To review edits: one read-modify-write under the task lock, so
1060
+ * concurrent writers never lose each other's changes. Lifecycle state is untouched.
1061
+ */
1062
+ export function updateTaskContribution(id, change) {
1063
+ assertCanonicalTaskId(id);
1064
+ return withTaskLock(id, () => {
1065
+ const task = readTask(id);
1066
+ assertNoPendingDeletion(task);
1067
+ assertNoPendingTerminalIntent(task);
1068
+ const { value, changed } = change(task);
1069
+ if (changed)
1070
+ writeTask(task);
1071
+ return value;
1072
+ });
1073
+ }
988
1074
  export function updateTaskBrief(id, brief, expectedBrief) {
989
1075
  assertCanonicalTaskId(id);
990
1076
  return withTaskLock(id, () => {
@@ -0,0 +1,35 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { publicProgressEntry } from './task-review.js';
3
+ import type { TaskContributionAuthor, TaskProgressEntry } from './types.js';
4
+ /** The task this agent belongs to, from supervisor launch records; never from tool arguments. */
5
+ export interface TaskToolBinding {
6
+ taskId: string;
7
+ role: string;
8
+ identityCid?: string;
9
+ /** Launched as a task-owned temporary member (layout participant). */
10
+ taskOwned: boolean;
11
+ }
12
+ export declare const TASK_TOOL_NAMES: readonly ["fleet_task_progress_list", "fleet_task_progress_append", "fleet_task_review_show", "fleet_task_review_set", "fleet_task_review_detach"];
13
+ export declare const PROGRESS_PAGE_MAX = 50;
14
+ /** One chronological page of the log; `next_after` continues after the last returned entry. */
15
+ export declare function progressPage(entries: TaskProgressEntry[], input: {
16
+ after?: string;
17
+ limit?: number;
18
+ }): {
19
+ progress: ReturnType<typeof publicProgressEntry>[];
20
+ total: number;
21
+ next_after?: string;
22
+ };
23
+ /**
24
+ * Confirm the binding against the task record: a listed member must match by
25
+ * name and identity; an unlisted caller must be a task-owned launch.
26
+ */
27
+ export declare function verifiedTaskAuthor(binding: TaskToolBinding): TaskContributionAuthor;
28
+ /**
29
+ * Task Progress and To review tools on the agent's own managed MCP endpoint.
30
+ * The task and author come from the caller's supervisor, so an agent can only
31
+ * read and write the task it was launched for. Review files are attached with
32
+ * `ours-fleet task review-package <id> --attach <path>`, which reads the file
33
+ * under the agent's own permissions.
34
+ */
35
+ export declare function registerTaskTools(server: McpServer, binding: () => TaskToolBinding | undefined, admit: () => Promise<() => void>): void;
@@ -0,0 +1,134 @@
1
+ import { z } from 'zod';
2
+ import { appendTaskProgress, detachTaskReviewArtifact, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, setTaskReviewMarkdown, TaskReviewError, PROGRESS_TEXT_MAX, REVIEW_MARKDOWN_MAX, } from './task-review.js';
3
+ import { getTask, TaskStateError } from './task-state.js';
4
+ export const TASK_TOOL_NAMES = Object.freeze([
5
+ 'fleet_task_progress_list', 'fleet_task_progress_append',
6
+ 'fleet_task_review_show', 'fleet_task_review_set', 'fleet_task_review_detach',
7
+ ]);
8
+ /** Compact JSON: a result is carried twice (text and structured) inside one bounded bridge frame. */
9
+ function result(value) {
10
+ return {
11
+ content: [{ type: 'text', text: JSON.stringify(value) }],
12
+ structuredContent: value,
13
+ isError: false,
14
+ };
15
+ }
16
+ export const PROGRESS_PAGE_MAX = 50;
17
+ /** Serialized entry bytes per page, well inside the bridge's frame limit even at maximal escaping. */
18
+ const PROGRESS_PAGE_BYTES = 200_000;
19
+ /** One chronological page of the log; `next_after` continues after the last returned entry. */
20
+ export function progressPage(entries, input) {
21
+ const limit = input.limit ?? PROGRESS_PAGE_MAX;
22
+ if (!Number.isInteger(limit) || limit < 1 || limit > PROGRESS_PAGE_MAX)
23
+ throw new TaskReviewError('invalid_request', `limit must be 1..${PROGRESS_PAGE_MAX}`);
24
+ let start = 0;
25
+ if (input.after !== undefined) {
26
+ start = entries.findIndex(entry => entry.id === input.after) + 1;
27
+ if (!start)
28
+ throw new TaskReviewError('invalid_request', 'after must be the id of an entry in this log');
29
+ }
30
+ const page = [];
31
+ let bytes = 0;
32
+ for (const entry of entries.slice(start, start + limit)) {
33
+ const visible = publicProgressEntry(entry), size = Buffer.byteLength(JSON.stringify(visible));
34
+ if (page.length && bytes + size > PROGRESS_PAGE_BYTES)
35
+ break;
36
+ page.push(visible);
37
+ bytes += size;
38
+ }
39
+ const end = start + page.length;
40
+ return { progress: page, total: entries.length, ...(end < entries.length ? { next_after: entries[end - 1].id } : {}) };
41
+ }
42
+ function failure(code, message) {
43
+ return { content: [{ type: 'text', text: JSON.stringify({ error: code, message }) }], isError: true };
44
+ }
45
+ /**
46
+ * Confirm the binding against the task record: a listed member must match by
47
+ * name and identity; an unlisted caller must be a task-owned launch.
48
+ */
49
+ export function verifiedTaskAuthor(binding) {
50
+ const task = getTask(binding.taskId);
51
+ const member = task.member_roles.find(item => item.name === binding.role);
52
+ if (member ? (binding.identityCid && member.identity_cid !== binding.identityCid) : !binding.taskOwned)
53
+ throw new TaskReviewError('conflict', `agent ${binding.role} is not a member of task ${binding.taskId}`);
54
+ return { kind: 'agent', role: binding.role, ...(binding.identityCid ? { identity_cid: binding.identityCid } : {}), surface: 'mcp' };
55
+ }
56
+ /**
57
+ * Task Progress and To review tools on the agent's own managed MCP endpoint.
58
+ * The task and author come from the caller's supervisor, so an agent can only
59
+ * read and write the task it was launched for. Review files are attached with
60
+ * `ours-fleet task review-package <id> --attach <path>`, which reads the file
61
+ * under the agent's own permissions.
62
+ */
63
+ export function registerTaskTools(server, binding, admit) {
64
+ const run = async (operation) => {
65
+ let release;
66
+ try {
67
+ release = await admit();
68
+ }
69
+ catch {
70
+ return failure('runtime_not_ready', 'the managed runtime is not admitting tool calls right now; nothing was read or changed');
71
+ }
72
+ try {
73
+ const bound = binding();
74
+ if (!bound)
75
+ return failure('task_unavailable', 'this agent was not launched for a Fleet task');
76
+ try {
77
+ return result(operation(bound, verifiedTaskAuthor(bound)));
78
+ }
79
+ catch (error) {
80
+ if (error instanceof TaskReviewError)
81
+ return failure(error.code, error.message);
82
+ if (error instanceof TaskStateError)
83
+ return failure('task_unavailable', error.message);
84
+ return failure('task_store_failed', 'the task could not be read or updated; nothing was changed');
85
+ }
86
+ }
87
+ finally {
88
+ release();
89
+ }
90
+ };
91
+ server.registerTool('fleet_task_progress_list', {
92
+ description: 'Read the Progress milestone log of the task you were launched for, oldest first, one page at a time. '
93
+ + 'When next_after is present, call again with after set to it for the following entries.',
94
+ inputSchema: {
95
+ after: z.string().optional().describe('Entry id from the previous page\'s next_after; omit for the first page.'),
96
+ limit: z.number().int().optional().describe(`Maximum entries per page (1..${PROGRESS_PAGE_MAX}, default ${PROGRESS_PAGE_MAX}). Pages are also bounded in size.`),
97
+ },
98
+ }, async (args) => run(bound => ({ task_id: bound.taskId, ...progressPage(listTaskProgress(bound.taskId), args) })));
99
+ server.registerTool('fleet_task_progress_append', {
100
+ description: 'Append ONE significant, verified milestone to your task\'s Progress log (e.g. root cause found, PR opened with link, '
101
+ + 'measured result with its scope, final deliverable ready). Not for routine status, handoffs or review chatter. '
102
+ + 'Entries are permanent and shown to the Owner as written. Repeating a call with the same request_key and text returns the original entry.',
103
+ inputSchema: {
104
+ text: z.string().describe(`Free-form milestone text, kept exactly as written (max ${PROGRESS_TEXT_MAX} characters).`),
105
+ request_key: z.string().optional().describe('Stable key for this milestone so a retried call cannot add it twice (e.g. "pr-opened-123").'),
106
+ },
107
+ }, async (args) => run((bound, author) => {
108
+ const appended = appendTaskProgress(bound.taskId, { text: args.text, requestKey: args.request_key, author });
109
+ return { task_id: bound.taskId, entry: publicProgressEntry(appended.entry), replayed: appended.replayed };
110
+ }));
111
+ server.registerTool('fleet_task_review_show', {
112
+ description: 'Read your task\'s To review package: its Markdown and the retained files attached to it.',
113
+ inputSchema: {},
114
+ }, async () => run(bound => {
115
+ const review = getTaskReview(bound.taskId);
116
+ return { task_id: bound.taskId, review: review ? publicReview(review) : null };
117
+ }));
118
+ server.registerTool('fleet_task_review_set', {
119
+ description: 'Replace the Markdown of your task\'s To review package: the self-contained material the Owner reviews without reading the room or task description '
120
+ + '(outcome, clickable PR/spec links, validation and CI, independent verdict, limits, decisions needed). '
121
+ + 'Pass the Markdown you last read as expected_markdown ("" when empty); a concurrent change is refused instead of overwritten.',
122
+ inputSchema: {
123
+ markdown: z.string().describe(`Review Markdown (max ${REVIEW_MARKDOWN_MAX} characters). Links use the same safe rules as task descriptions.`),
124
+ expected_markdown: z.string().describe('The current Markdown as you last read it, or "" if there is none.'),
125
+ },
126
+ }, async (args) => run((bound, author) => ({
127
+ task_id: bound.taskId,
128
+ review: publicReview(setTaskReviewMarkdown(bound.taskId, { markdown: args.markdown, expectedMarkdown: args.expected_markdown, author })),
129
+ })));
130
+ server.registerTool('fleet_task_review_detach', {
131
+ description: 'Remove one file from your task\'s To review package by artifact_id (from fleet_task_review_show).',
132
+ inputSchema: { artifact_id: z.string() },
133
+ }, async (args) => run((bound, author) => ({ task_id: bound.taskId, detached: detachTaskReviewArtifact(bound.taskId, args.artifact_id, { author }) })));
134
+ }
@@ -116,7 +116,50 @@ export interface TaskNotificationEvent {
116
116
  };
117
117
  taskId: string;
118
118
  }
119
+ /** Who wrote a progress entry or review change, as established by the Fleet surface. */
120
+ export type TaskContributionAuthor = {
121
+ kind: 'agent';
122
+ role: string;
123
+ identity_cid?: string;
124
+ surface: 'mcp' | 'cli';
125
+ } | {
126
+ kind: 'local_control';
127
+ surface: 'cli' | 'web';
128
+ };
129
+ /** One append-only milestone; text is stored exactly as authored. */
130
+ export interface TaskProgressEntry {
131
+ id: string;
132
+ at: string;
133
+ author: TaskContributionAuthor;
134
+ text: string;
135
+ /** Caller retry key, scoped to the author. */
136
+ request_key?: string;
137
+ }
138
+ /** Metadata of a retained review file; bytes live in Fleet's private task review store. */
139
+ export interface TaskReviewArtifact {
140
+ id: string;
141
+ name: string;
142
+ mime: string;
143
+ size: number;
144
+ sha256: string;
145
+ added_at: string;
146
+ added_by: TaskContributionAuthor;
147
+ request_key?: string;
148
+ }
149
+ /** Self-contained material the Owner reviews: Markdown plus retained files. */
150
+ export interface TaskReview {
151
+ /** Increases with every change to the Markdown or the files; absent on packages written before it. */
152
+ revision?: number;
153
+ markdown: string;
154
+ updated_at: string;
155
+ updated_by: TaskContributionAuthor;
156
+ artifacts: TaskReviewArtifact[];
157
+ }
119
158
  export interface TaskRecord {
159
+ /** Optional milestone log; absent on tasks that never recorded one. */
160
+ progress?: TaskProgressEntry[];
161
+ /** Optional To review package; absent until an agent prepares it. */
162
+ review?: TaskReview;
120
163
  /** Pending lifecycle events, committed atomically with their task change. */
121
164
  notification_events?: TaskNotificationEvent[];
122
165
  workspace?: import('./workspace.js').OwnedWorkspace;
@@ -291,9 +291,10 @@ export class FleetResourceViews {
291
291
  void initial.catch(() => { });
292
292
  const controller = new AbortController();
293
293
  const source = connection.observer.cursor(path => this.affects(descriptor, path), controller.signal);
294
- // Independent agents contribute ordered, last-observed statuses. Their
295
- // ledger commits wake the view but do not revoke its structural capture.
296
- const captureSource = descriptor.resource === 'directory' || descriptor.resource === 'roles'
294
+ // Independent agents contribute ordered, last-observed statuses and completed
295
+ // preview records. Activity wakes follow-up reads without revoking structural
296
+ // capture; the read still certifies only its starting source watermark.
297
+ const captureSource = descriptor.resource === 'directory' || descriptor.resource === 'roles' || descriptor.resource === 'previews'
297
298
  ? connection.observer.cursor(path => this.affects(descriptor, path, true), controller.signal)
298
299
  : source;
299
300
  const view = { source, captureSource, scheduledPosition: -1, descriptor, epoch, baselineId: randomUUID(), controller, revision: 0, sequence: 0, dirty: false, running: false, failed: false, initial, resolveInitial, rejectInitial };
@@ -406,8 +407,15 @@ export class FleetResourceViews {
406
407
  continue;
407
408
  if (structural && (descriptor.resource === 'directory' || descriptor.resource === 'roles'))
408
409
  return false;
409
- if (descriptor.resource === 'previews')
410
+ if (descriptor.resource === 'previews') {
411
+ // conversationPreview reads bounded completed records, independently per
412
+ // role. These known writer paths still wake ordered follow-up captures.
413
+ // Unknown children, parent replacement and explicit config paths retain
414
+ // their capture fence; do not waive the whole conversation subtree.
415
+ if (structural && parts.length === 3 && /^(?:events-\d{6}\.jsonl|manifest\.json)$/.test(parts[2]))
416
+ return false;
410
417
  return (descriptor.ids ?? []).includes(parts[0]);
418
+ }
411
419
  // Directory and roles intentionally remain conservative: live status
412
420
  // includes activity. Configuration was classified by its inputs above.
413
421
  }
@@ -26,6 +26,7 @@ import { FleetEventBus } from './events.js';
26
26
  import { ROLE_NAME_RE } from '../config.js';
27
27
  import { TaskListError } from '../rooms-tasks/task-lists.js';
28
28
  import { TaskStateError } from '../rooms-tasks/task-state.js';
29
+ import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
29
30
  import { isProvider } from '../subscriptions/store.js';
30
31
  const ONBOARDING_WORK = ['coordination', 'development', 'review'];
31
32
  const statusFor = (code) => ({
@@ -189,7 +190,7 @@ export async function buildWebServer(services, boundary, options = {}) {
189
190
  app.get('/api/v1/meta', async (request) => {
190
191
  auth.authenticate(request);
191
192
  return {
192
- version: VERSION, api: { major: 1, minor: 0 },
193
+ version: VERSION, api: { major: 1, minor: 1 },
193
194
  websocketProtocols: [
194
195
  'ours-fleet-events.v1', 'ours-fleet-conversation.v1',
195
196
  ],
@@ -327,6 +328,31 @@ export async function buildWebServer(services, boundary, options = {}) {
327
328
  throw error;
328
329
  }
329
330
  };
331
+ const reviewApi = async (fn) => taskApi(async () => {
332
+ try {
333
+ return await fn();
334
+ }
335
+ catch (error) {
336
+ if (error instanceof TaskReviewError) {
337
+ const code = error.code === 'not_found' ? 'resource_not_found' : error.code === 'limit_exceeded' ? 'conflict' : error.code;
338
+ throw new FleetError(code, error.message);
339
+ }
340
+ if (error instanceof TaskStateError && error.message.startsWith('invalid task ID'))
341
+ throw new FleetError('resource_not_found', 'task not found');
342
+ throw error;
343
+ }
344
+ });
345
+ /** Browser task payloads carry Progress/To review counts; bodies have their own routes. */
346
+ const wireTask = (task) => {
347
+ const { progress: _progress, review: _review, ...rest } = task;
348
+ return { ...rest, ...taskContributionSummary(task) };
349
+ };
350
+ const wireTaskResult = (result) => {
351
+ const value = result;
352
+ if (value && typeof value === 'object' && value.task && typeof value.task === 'object')
353
+ return { ...value, task: wireTask(value.task) };
354
+ return result;
355
+ };
330
356
  const requireTaskRooms = () => {
331
357
  if (!services.taskRooms)
332
358
  throw new FleetError('capability_unavailable', 'task operations are unavailable');
@@ -345,8 +371,8 @@ export async function buildWebServer(services, boundary, options = {}) {
345
371
  ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
346
372
  const api = requireTaskRooms();
347
373
  return taskApi(() => query.groupByList === 'true'
348
- ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
349
- : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
374
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
375
+ : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
350
376
  };
351
377
  const project = async (descriptor, signal, owner = '') => {
352
378
  signal.throwIfAborted();
@@ -551,27 +577,70 @@ export async function buildWebServer(services, boundary, options = {}) {
551
577
  origin: { type: 'web' },
552
578
  }));
553
579
  reply.code(201);
554
- return { task };
580
+ return { task: wireTask(task) };
555
581
  });
556
582
  app.patch('/api/v1/tasks/:id/list', async (request) => {
557
583
  auth.authenticate(request, true);
558
584
  const taskId = request.params.id;
559
585
  const list = String(request.body?.list ?? '');
560
- return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
586
+ return { task: wireTask(await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list }))) };
561
587
  });
562
588
  app.patch('/api/v1/tasks/:id/description', async (request) => {
563
589
  auth.authenticate(request, true);
564
590
  const body = request.body;
565
591
  if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
566
592
  throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
567
- return { task: await taskApi(() => requireTaskRooms().editTaskDescription({
593
+ return { task: wireTask(await taskApi(() => requireTaskRooms().editTaskDescription({
568
594
  actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
569
595
  brief: body.brief, expectedBrief: body.expectedBrief,
570
- })) };
596
+ }))) };
571
597
  });
572
598
  app.get('/api/v1/tasks/:id', async (request) => {
573
599
  auth.authenticate(request);
574
- return taskApi(() => requireTaskRooms().getTask(request.params.id));
600
+ return wireTaskResult(await taskApi(() => requireTaskRooms().getTask(request.params.id)));
601
+ });
602
+ app.get('/api/v1/tasks/:id/progress', async (request) => {
603
+ auth.authenticate(request);
604
+ return { progress: (await reviewApi(() => listTaskProgress(request.params.id))).map(publicProgressEntry) };
605
+ });
606
+ app.post('/api/v1/tasks/:id/progress', async (request, reply) => {
607
+ auth.authenticate(request, true);
608
+ const body = request.body;
609
+ const header = request.headers['idempotency-key'];
610
+ const requestKey = typeof body?.requestKey === 'string' ? body.requestKey : typeof header === 'string' ? header : undefined;
611
+ if (typeof body?.text !== 'string' || (body.requestKey !== undefined && typeof body.requestKey !== 'string'))
612
+ throw new FleetError('invalid_request', 'text must be a string; requestKey, when given, a string');
613
+ const result = await reviewApi(() => appendTaskProgress(request.params.id, {
614
+ text: body.text, requestKey, author: { kind: 'local_control', surface: 'web' },
615
+ }));
616
+ if (!result.replayed)
617
+ reply.code(201);
618
+ return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
619
+ });
620
+ app.get('/api/v1/tasks/:id/review', async (request) => {
621
+ auth.authenticate(request);
622
+ const review = await reviewApi(() => getTaskReview(request.params.id));
623
+ return { review: review ? publicReview(review) : null };
624
+ });
625
+ app.put('/api/v1/tasks/:id/review', async (request) => {
626
+ auth.authenticate(request, true);
627
+ const body = request.body;
628
+ if (typeof body?.markdown !== 'string' || typeof body.expectedMarkdown !== 'string')
629
+ throw new FleetError('invalid_request', 'markdown and expectedMarkdown must be strings');
630
+ const review = await reviewApi(() => setTaskReviewMarkdown(request.params.id, {
631
+ markdown: body.markdown, expectedMarkdown: body.expectedMarkdown,
632
+ author: { kind: 'local_control', surface: 'web' },
633
+ }));
634
+ return { review: publicReview(review) };
635
+ });
636
+ app.get('/api/v1/tasks/:id/review/artifacts/:artifactId', async (request, reply) => {
637
+ auth.authenticate(request);
638
+ const { artifact, bytes } = await reviewApi(() => readTaskReviewArtifact(request.params.id, request.params.artifactId));
639
+ reply.header('Cache-Control', 'private, no-store');
640
+ reply.header('X-Content-Type-Options', 'nosniff');
641
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
642
+ reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name).replace(/'/g, '%27')}`);
643
+ return reply.type('application/octet-stream').send(bytes);
575
644
  });
576
645
  app.post('/api/v1/tasks/:id/members', async (request, reply) => {
577
646
  auth.authenticate(request, true);
@@ -601,7 +670,7 @@ export async function buildWebServer(services, boundary, options = {}) {
601
670
  const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
602
671
  taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
603
672
  await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
604
- return { task: requireTaskRooms().withLayoutRooms(task) };
673
+ return { task: wireTask(requireTaskRooms().withLayoutRooms(task)) };
605
674
  });
606
675
  app.get('/api/v1/tasks/:id/layout', async (request) => {
607
676
  auth.authenticate(request);
@@ -673,7 +742,7 @@ export async function buildWebServer(services, boundary, options = {}) {
673
742
  const body = (request.body ?? {});
674
743
  const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
675
744
  const api = requireTaskRooms();
676
- return taskApi(async () => {
745
+ return wireTaskResult(await taskApi(async () => {
677
746
  switch (action) {
678
747
  case 'start': {
679
748
  // A task's layout can be chosen or changed but never removed, so this check cannot go stale.
@@ -701,7 +770,7 @@ export async function buildWebServer(services, boundary, options = {}) {
701
770
  return { task, pending };
702
771
  }
703
772
  }
704
- });
773
+ }));
705
774
  });
706
775
  }
707
776
  app.get('/api/v1/configuration/provenance', async (request) => {