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

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.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Task Progress (append-only milestone log) and To review (self-contained
3
+ * review package: Markdown plus retained files).
4
+ *
5
+ * Entries and review metadata live in the task record and change only under
6
+ * the task lock. Review file bytes live in Fleet's private per-task store,
7
+ * outside the agent-writable workspace, and are removed with the task.
8
+ */
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
11
+ import { open } from 'node:fs/promises';
12
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
+ import { stateRoot } from '../paths.js';
14
+ import { MAX_FILE_BYTES, openDirectory } from '../file-delivery/reader.js';
15
+ import { getTask, TaskStateError, updateTaskContribution } from './task-state.js';
16
+ import { assertWorkspacePresent } from './workspace.js';
17
+ export const PROGRESS_TEXT_MAX = 4_000;
18
+ export const PROGRESS_MAX_ENTRIES = 200;
19
+ export const REVIEW_MARKDOWN_MAX = 50_000;
20
+ export const REVIEW_MAX_ARTIFACTS = 20;
21
+ export const REVIEW_ARTIFACT_MAX_BYTES = MAX_FILE_BYTES;
22
+ /** Stored bytes per task, including copies not yet referenced. */
23
+ const REVIEW_STORE_MAX_BYTES = 200 * 1024 * 1024;
24
+ const REVIEW_STORE_MAX_FILES = 40;
25
+ /** An unreferenced copy older than this belongs to a failed or abandoned attach. */
26
+ const ORPHAN_AGE_MS = 10 * 60 * 1000;
27
+ const REQUEST_KEY = /^[A-Za-z0-9._:-]{1,128}$/;
28
+ const TASK_ID = /^[0-9a-z]{9}[0-9a-f]{8}$/;
29
+ const ARTIFACT_ID = /^[a-f0-9-]{36}$/;
30
+ /** Types a browser can open safely from a blob; everything else is download-only. */
31
+ const MIME_BY_EXTENSION = {
32
+ '.md': 'text/markdown', '.markdown': 'text/markdown', '.txt': 'text/plain', '.log': 'text/plain',
33
+ '.json': 'application/json', '.csv': 'text/csv', '.diff': 'text/x-diff', '.patch': 'text/x-diff',
34
+ '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
35
+ '.gif': 'image/gif', '.webp': 'image/webp', '.html': 'text/html', '.svg': 'image/svg+xml',
36
+ };
37
+ export class TaskReviewError extends Error {
38
+ code;
39
+ constructor(code, message) {
40
+ super(message);
41
+ this.code = code;
42
+ }
43
+ }
44
+ function assertTaskId(taskId) {
45
+ if (!TASK_ID.test(taskId))
46
+ throw new TaskStateError('invalid task ID: expected the canonical 17-character lowercase ID');
47
+ }
48
+ /**
49
+ * Retry keys are scoped to the author: an agent by its authenticated identity
50
+ * CID (stable across MCP and CLI), or by role name when no CID is known; the
51
+ * local operator by surface.
52
+ */
53
+ function authorKey(author) {
54
+ if (author.kind !== 'agent')
55
+ return `local_control:${author.surface}`;
56
+ return author.identity_cid ? `agent-cid:${author.identity_cid}` : `agent:${author.role}`;
57
+ }
58
+ function checkRequestKey(requestKey) {
59
+ if (requestKey !== undefined && !REQUEST_KEY.test(requestKey))
60
+ throw new TaskReviewError('invalid_request', 'request_key must be 1-128 characters of letters, digits, ".", "_", ":" or "-"');
61
+ }
62
+ export function reviewArtifactMime(name) {
63
+ return MIME_BY_EXTENSION[extname(name).toLowerCase()] ?? 'application/octet-stream';
64
+ }
65
+ export function checkArtifactName(name) {
66
+ if (typeof name !== 'string' || !name || Buffer.byteLength(name) > 240 || /[/\\\x00-\x1f\x7f]/.test(name)
67
+ || name === '.' || name === '..' || name.trim() !== name)
68
+ throw new TaskReviewError('invalid_request', 'file name must be 1-240 bytes without slashes, control characters or surrounding spaces');
69
+ return name;
70
+ }
71
+ // ── Progress ───────────────────────────────────────────────────────────
72
+ export function listTaskProgress(taskId) {
73
+ assertTaskId(taskId);
74
+ return getTask(taskId).progress ?? [];
75
+ }
76
+ /**
77
+ * Append one milestone. Text is kept exactly as written; whitespace-only text is
78
+ * rejected. A retry with the same author-scoped request_key returns the original
79
+ * entry; the same key with different text is a conflict.
80
+ */
81
+ export function appendTaskProgress(taskId, input) {
82
+ assertTaskId(taskId);
83
+ if (typeof input.text !== 'string' || !input.text.trim())
84
+ throw new TaskReviewError('invalid_request', 'progress text is required');
85
+ if (input.text.length > PROGRESS_TEXT_MAX)
86
+ throw new TaskReviewError('invalid_request', `progress text maximum is ${PROGRESS_TEXT_MAX} characters`);
87
+ checkRequestKey(input.requestKey);
88
+ return updateTaskContribution(taskId, task => {
89
+ const progress = task.progress ?? [];
90
+ if (input.requestKey) {
91
+ const previous = progress.find(entry => entry.request_key === input.requestKey
92
+ && sameAuthor(entry.author, input.author));
93
+ if (previous) {
94
+ if (previous.text !== input.text)
95
+ throw new TaskReviewError('idempotency_conflict', 'request_key was already used for a different progress entry');
96
+ return { value: { entry: previous, replayed: true }, changed: false };
97
+ }
98
+ }
99
+ if (progress.length >= PROGRESS_MAX_ENTRIES)
100
+ throw new TaskReviewError('limit_exceeded', `task progress is limited to ${PROGRESS_MAX_ENTRIES} entries`);
101
+ const entry = {
102
+ id: randomUUID(), at: new Date().toISOString(), author: input.author, text: input.text,
103
+ ...(input.requestKey ? { request_key: input.requestKey } : {}),
104
+ };
105
+ task.progress = [...progress, entry];
106
+ return { value: { entry, replayed: false }, changed: true };
107
+ });
108
+ }
109
+ // ── Review Markdown ────────────────────────────────────────────────────
110
+ /** Every change to the package gets the next revision, so readers can tell any two states apart. */
111
+ function nextRevision(review) {
112
+ return (review?.revision ?? 0) + 1;
113
+ }
114
+ export function getTaskReview(taskId) {
115
+ assertTaskId(taskId);
116
+ return getTask(taskId).review;
117
+ }
118
+ /**
119
+ * Replace the review Markdown. `expectedMarkdown` is what the writer last read
120
+ * ('' when none); a different current value is a stale edit. Re-sending the
121
+ * value already stored succeeds, so retries are safe.
122
+ */
123
+ export function setTaskReviewMarkdown(taskId, input) {
124
+ assertTaskId(taskId);
125
+ if (typeof input.markdown !== 'string' || typeof input.expectedMarkdown !== 'string')
126
+ throw new TaskReviewError('invalid_request', 'markdown and expected markdown must be strings');
127
+ if (input.markdown.length > REVIEW_MARKDOWN_MAX)
128
+ throw new TaskReviewError('invalid_request', `review markdown maximum is ${REVIEW_MARKDOWN_MAX} characters`);
129
+ return updateTaskContribution(taskId, task => {
130
+ const current = task.review?.markdown ?? '';
131
+ if (current === input.markdown && task.review)
132
+ return { value: task.review, changed: false };
133
+ if (current !== input.expectedMarkdown)
134
+ throw new TaskReviewError('conflict', 'The review package changed elsewhere. Read it again before replacing it.');
135
+ task.review = {
136
+ revision: nextRevision(task.review), markdown: input.markdown, updated_at: new Date().toISOString(),
137
+ updated_by: input.author, artifacts: task.review?.artifacts ?? [],
138
+ };
139
+ return { value: task.review, changed: true };
140
+ });
141
+ }
142
+ // ── Review artifact store ──────────────────────────────────────────────
143
+ export const reviewStoreRoot = () => join(stateRoot(), 'task-review-artifacts');
144
+ export function reviewStoreDirectory(taskId) {
145
+ assertTaskId(taskId);
146
+ return join(reviewStoreRoot(), taskId);
147
+ }
148
+ function privateDirectory(path) {
149
+ mkdirSync(path, { recursive: true, mode: 0o700 });
150
+ const stat = lstatSync(path);
151
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
152
+ throw new TaskReviewError('conflict', 'task review store is not a private directory');
153
+ }
154
+ /** Remove copies no record references once they are old enough to be abandoned. */
155
+ function sweepOrphans(dir, referenced, now = Date.now()) {
156
+ let files = 0, bytes = 0;
157
+ for (const name of readdirSync(dir)) {
158
+ const match = /^([a-f0-9-]{36})\.(data|part)$/.exec(name);
159
+ const path = join(dir, name), stat = lstatSync(path);
160
+ if (match && !referenced.has(match[1]) && now - stat.mtimeMs > ORPHAN_AGE_MS && stat.isFile()) {
161
+ unlinkSync(path);
162
+ continue;
163
+ }
164
+ files++;
165
+ bytes += stat.size;
166
+ }
167
+ return { files, bytes };
168
+ }
169
+ const changed = () => new TaskReviewError('conflict', 'the file changed while it was being copied');
170
+ /** Read the whole source once, bounded by its declared size, then confirm it did not change. */
171
+ async function consume(source, signal, sink) {
172
+ const reader = source.body.getReader();
173
+ try {
174
+ const hash = createHash('sha256');
175
+ let size = 0;
176
+ for (;;) {
177
+ signal?.throwIfAborted();
178
+ const chunk = await reader.read();
179
+ if (chunk.done)
180
+ break;
181
+ if (size + chunk.value.length > source.size)
182
+ throw changed();
183
+ hash.update(chunk.value);
184
+ sink(chunk.value, size);
185
+ size += chunk.value.length;
186
+ }
187
+ if (size !== source.size)
188
+ throw changed();
189
+ await source.verify?.();
190
+ return { size, sha256: hash.digest('hex') };
191
+ }
192
+ finally {
193
+ await reader.cancel().catch(() => { });
194
+ await source.close();
195
+ }
196
+ }
197
+ function syncDirectory(path) {
198
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
199
+ try {
200
+ fsyncSync(fd);
201
+ }
202
+ finally {
203
+ closeSync(fd);
204
+ }
205
+ }
206
+ const inDir = (dir, name) => `/proc/self/fd/${dir}/${name}`;
207
+ /**
208
+ * Admit one copy under the task lock: the task exists and is not being
209
+ * deleted, the quota holds, and the reservation is made at once by creating
210
+ * the partial file at its declared size in the pinned store directory. A
211
+ * deletion accepted later removes the whole store; this copy then only writes
212
+ * into an unlinked directory and its commit is refused.
213
+ */
214
+ function reserveCopy(taskId, dir, size) {
215
+ return updateTaskContribution(taskId, task => {
216
+ privateDirectory(reviewStoreRoot());
217
+ privateDirectory(dir);
218
+ const pinned = openSync(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
219
+ try {
220
+ const opened = fstatSync(pinned), named = lstatSync(dir);
221
+ if (opened.dev !== named.dev || opened.ino !== named.ino)
222
+ throw new TaskReviewError('conflict', 'task review store moved');
223
+ const used = sweepOrphans(inDir(pinned, ''), new Set((task.review?.artifacts ?? []).map(artifact => artifact.id)));
224
+ if (used.files >= REVIEW_STORE_MAX_FILES || used.bytes + size > REVIEW_STORE_MAX_BYTES)
225
+ throw new TaskReviewError('limit_exceeded', 'the task review store is full; detach files you no longer need');
226
+ const id = randomUUID();
227
+ const fd = openSync(inDir(pinned, `${id}.part`), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
228
+ try {
229
+ ftruncateSync(fd, size);
230
+ }
231
+ catch (error) {
232
+ closeSync(fd);
233
+ unlinkSync(inDir(pinned, `${id}.part`));
234
+ throw error;
235
+ }
236
+ return { value: { id, fd, dir: pinned }, changed: false };
237
+ }
238
+ catch (error) {
239
+ closeSync(pinned);
240
+ throw error;
241
+ }
242
+ });
243
+ }
244
+ /**
245
+ * Copy into the pinned store, then make the bytes durable before any record
246
+ * can reference them: file fsync, rename, then fsync of the store directory
247
+ * and of the directories that may have been created for it.
248
+ */
249
+ async function copyIntoStore(taskId, dir, source, signal) {
250
+ let reservation;
251
+ try {
252
+ reservation = reserveCopy(taskId, dir, source.size);
253
+ const target = reservation;
254
+ const digest = await consume(source, signal, (chunk, at) => {
255
+ let offset = 0;
256
+ while (offset < chunk.length) {
257
+ const written = writeSync(target.fd, chunk, offset, chunk.length - offset, at + offset);
258
+ if (!written)
259
+ throw new Error('short review artifact write');
260
+ offset += written;
261
+ }
262
+ });
263
+ fchmodSync(target.fd, 0o400);
264
+ fsyncSync(target.fd);
265
+ renameSync(inDir(target.dir, `${target.id}.part`), inDir(target.dir, `${target.id}.data`));
266
+ fsyncSync(target.dir);
267
+ syncDirectory(reviewStoreRoot());
268
+ syncDirectory(stateRoot());
269
+ return { id: target.id, ...digest };
270
+ }
271
+ catch (error) {
272
+ if (reservation) {
273
+ for (const name of [`${reservation.id}.part`, `${reservation.id}.data`])
274
+ try {
275
+ unlinkSync(inDir(reservation.dir, name));
276
+ }
277
+ catch { /* absent, or removed with the store */ }
278
+ }
279
+ throw error;
280
+ }
281
+ finally {
282
+ if (reservation) {
283
+ closeSync(reservation.fd);
284
+ closeSync(reservation.dir);
285
+ }
286
+ else
287
+ await source.close();
288
+ }
289
+ }
290
+ function removeStoredFile(dir, id) {
291
+ try {
292
+ unlinkSync(join(dir, `${id}.data`));
293
+ }
294
+ catch (error) {
295
+ if (error.code !== 'ENOENT')
296
+ throw error;
297
+ }
298
+ }
299
+ function sameAuthor(a, b) {
300
+ return authorKey(a) === authorKey(b);
301
+ }
302
+ /**
303
+ * Attach one file to the review package: reserve under the task lock, copy
304
+ * into the private store, then commit metadata under the lock again; any
305
+ * failure removes the copy. A retry with the same author-scoped request_key
306
+ * is compared by content without storing another copy: identical content
307
+ * returns the original artifact, different content is a conflict. Without a
308
+ * key an interrupted call may or may not have attached: read the package
309
+ * before retrying.
310
+ */
311
+ export async function attachTaskReviewArtifact(taskId, input) {
312
+ let dir, name, previous;
313
+ try {
314
+ dir = reviewStoreDirectory(taskId);
315
+ name = checkArtifactName(input.name);
316
+ checkRequestKey(input.requestKey);
317
+ if (!Number.isSafeInteger(input.source.size) || input.source.size < 0 || input.source.size > REVIEW_ARTIFACT_MAX_BYTES)
318
+ throw new TaskReviewError('limit_exceeded', 'review files are limited to 20 MiB');
319
+ previous = input.requestKey ? getTask(taskId).review?.artifacts.find(artifact => artifact.request_key === input.requestKey && sameAuthor(artifact.added_by, input.author)) : undefined;
320
+ }
321
+ catch (error) {
322
+ await input.source.close();
323
+ throw error;
324
+ }
325
+ const replay = (artifact, digest) => {
326
+ if (artifact.name !== name || artifact.sha256 !== digest.sha256)
327
+ throw new TaskReviewError('idempotency_conflict', 'request_key was already used for a different review file');
328
+ return { artifact, replayed: true };
329
+ };
330
+ if (previous) {
331
+ const digest = await consume(input.source, input.signal, () => { });
332
+ input.beforeReplay?.();
333
+ // Confirm under the lock that the original is still part of the package and the task still accepts writes.
334
+ return updateTaskContribution(taskId, task => {
335
+ const current = task.review?.artifacts.find(artifact => artifact.request_key === input.requestKey && sameAuthor(artifact.added_by, input.author));
336
+ if (!current)
337
+ throw new TaskReviewError('not_found', 'the file attached with this request_key was removed meanwhile; attach it again with a new key');
338
+ return { value: replay(current, digest), changed: false };
339
+ });
340
+ }
341
+ const stored = await copyIntoStore(taskId, dir, input.source, input.signal);
342
+ try {
343
+ input.beforeCommit?.(stored);
344
+ const result = updateTaskContribution(taskId, task => {
345
+ const review = task.review ?? { markdown: '', updated_at: new Date().toISOString(), updated_by: input.author, artifacts: [] };
346
+ // A concurrent attach with the same key may have committed meanwhile.
347
+ const raced = input.requestKey ? review.artifacts.find(artifact => artifact.request_key === input.requestKey && sameAuthor(artifact.added_by, input.author)) : undefined;
348
+ if (raced)
349
+ return { value: replay(raced, stored), changed: false };
350
+ if (review.artifacts.length >= REVIEW_MAX_ARTIFACTS)
351
+ throw new TaskReviewError('limit_exceeded', `a review package holds at most ${REVIEW_MAX_ARTIFACTS} files`);
352
+ const artifact = {
353
+ id: stored.id, name, mime: reviewArtifactMime(name), size: stored.size, sha256: stored.sha256,
354
+ added_at: new Date().toISOString(), added_by: input.author,
355
+ ...(input.requestKey ? { request_key: input.requestKey } : {}),
356
+ };
357
+ task.review = { ...review, revision: nextRevision(task.review), updated_at: artifact.added_at,
358
+ updated_by: input.author, artifacts: [...review.artifacts, artifact] };
359
+ return { value: { artifact, replayed: false }, changed: true };
360
+ });
361
+ if (result.replayed)
362
+ removeStoredFile(dir, stored.id);
363
+ return result;
364
+ }
365
+ catch (error) {
366
+ try {
367
+ removeStoredFile(dir, stored.id);
368
+ }
369
+ catch { /* the sweep removes it later */ }
370
+ throw error;
371
+ }
372
+ }
373
+ /** Remove a file from the package; repeating it after success reports not found. */
374
+ export function detachTaskReviewArtifact(taskId, artifactId, input) {
375
+ const dir = reviewStoreDirectory(taskId);
376
+ const removed = updateTaskContribution(taskId, task => {
377
+ const artifact = task.review?.artifacts.find(item => item.id === artifactId);
378
+ if (!artifact || !task.review)
379
+ throw new TaskReviewError('not_found', 'review file not found');
380
+ task.review = { ...task.review, revision: nextRevision(task.review), updated_at: new Date().toISOString(),
381
+ updated_by: input.author, artifacts: task.review.artifacts.filter(item => item.id !== artifactId) };
382
+ return { value: artifact, changed: true };
383
+ });
384
+ try {
385
+ removeStoredFile(dir, removed.id);
386
+ }
387
+ catch { /* an unreferenced copy is swept later */ }
388
+ return removed;
389
+ }
390
+ /** Bytes of a referenced artifact, integrity-checked; only the owning task can name it. */
391
+ export async function readTaskReviewArtifact(taskId, artifactId) {
392
+ assertTaskId(taskId);
393
+ if (!ARTIFACT_ID.test(artifactId))
394
+ throw new TaskReviewError('not_found', 'review file not found');
395
+ const artifact = getTask(taskId).review?.artifacts.find(item => item.id === artifactId);
396
+ if (!artifact)
397
+ throw new TaskReviewError('not_found', 'review file not found');
398
+ const root = await openDirectory(reviewStoreDirectory(taskId)).catch(() => {
399
+ throw new TaskReviewError('not_found', 'review file is no longer stored');
400
+ });
401
+ try {
402
+ let fd;
403
+ try {
404
+ fd = openSync(`/proc/self/fd/${root.fd}/${artifact.id}.data`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
405
+ }
406
+ catch {
407
+ throw new TaskReviewError('not_found', 'review file is no longer stored');
408
+ }
409
+ try {
410
+ const stat = fstatSync(fd);
411
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.size !== artifact.size)
412
+ throw new TaskReviewError('conflict', 'stored review file failed its integrity check');
413
+ const bytes = readFileSync(fd);
414
+ if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256)
415
+ throw new TaskReviewError('conflict', 'stored review file failed its integrity check');
416
+ return { artifact, bytes };
417
+ }
418
+ finally {
419
+ closeSync(fd);
420
+ }
421
+ }
422
+ finally {
423
+ await root.close();
424
+ }
425
+ }
426
+ /** Task deletion: remove every retained review file of the task. */
427
+ export function deleteTaskReviewStore(taskId) {
428
+ const dir = reviewStoreDirectory(taskId);
429
+ let stat;
430
+ try {
431
+ stat = lstatSync(dir);
432
+ }
433
+ catch (error) {
434
+ if (error.code === 'ENOENT')
435
+ return;
436
+ throw error;
437
+ }
438
+ if (stat.isSymbolicLink() || !stat.isDirectory())
439
+ throw new Error(`unsafe task review store ${dir}`);
440
+ rmSync(dir, { recursive: true, force: true });
441
+ }
442
+ // ── Sources ────────────────────────────────────────────────────────────
443
+ /** True when `path` names an entry strictly inside `root`, compared by path components. */
444
+ export function insideDirectory(root, path) {
445
+ const rel = relative(root, path);
446
+ return !!rel && !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`);
447
+ }
448
+ /** The task's owned workspace, validated against its ownership marker. */
449
+ export function taskWorkspaceRoot(task) {
450
+ if (!task.workspace)
451
+ throw new TaskReviewError('invalid_request', `task ${task.task_id} has no owned workspace`);
452
+ assertWorkspacePresent(task.workspace, 'task', task.task_id);
453
+ return task.workspace.path;
454
+ }
455
+ /** Identity and change times of an opened file; any difference means it changed or was replaced. */
456
+ async function fingerprint(file) {
457
+ const stat = await file.stat({ bigint: true });
458
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.nlink].join(':');
459
+ }
460
+ async function fileStream(file, size) {
461
+ const opened = await fingerprint(file);
462
+ const verify = async () => { if (await fingerprint(file) !== opened)
463
+ throw changed(); };
464
+ let closed = false;
465
+ const close = async () => { if (!closed) {
466
+ closed = true;
467
+ await file.close();
468
+ } };
469
+ let position = 0;
470
+ const body = new ReadableStream({
471
+ async pull(controller) {
472
+ await verify();
473
+ const buffer = Buffer.alloc(Math.min(1024 * 1024, Math.max(1, size - position + 1)));
474
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
475
+ if (!bytesRead) {
476
+ controller.close();
477
+ return;
478
+ }
479
+ position += bytesRead;
480
+ controller.enqueue(buffer.subarray(0, bytesRead));
481
+ },
482
+ });
483
+ return { size, body, close, verify };
484
+ }
485
+ /**
486
+ * Open a file inside the task workspace in this process's own OS context (the
487
+ * CLI). Every component below the pinned workspace is opened without
488
+ * following symlinks; the file must be one regular, singly-linked file.
489
+ */
490
+ export async function openTaskWorkspaceFile(task, input, cwd = process.cwd()) {
491
+ const root = taskWorkspaceRoot(task);
492
+ if (typeof input !== 'string' || !input || input.length > 4096 || /[\x00-\x1f]/.test(input))
493
+ throw new TaskReviewError('invalid_request', 'invalid file path');
494
+ if (input.split(/[\\/]/).includes('..'))
495
+ throw new TaskReviewError('invalid_request', 'the file path must not contain ".." components');
496
+ const absolute = resolve(cwd, input);
497
+ if (!insideDirectory(root, absolute))
498
+ throw new TaskReviewError('invalid_request', `review files must be inside the task workspace ${root}`);
499
+ const parts = relative(root, absolute).split(sep);
500
+ const flags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
501
+ let dir = await openDirectory(root);
502
+ try {
503
+ for (const part of parts.slice(0, -1)) {
504
+ const next = await open(`/proc/self/fd/${dir.fd}/${part}`, flags | constants.O_DIRECTORY).catch(() => {
505
+ throw new TaskReviewError('invalid_request', 'the file path must not pass through symlinks or missing directories');
506
+ });
507
+ await dir.close();
508
+ dir = next;
509
+ }
510
+ const file = await open(`/proc/self/fd/${dir.fd}/${parts.at(-1)}`, flags).catch((error) => {
511
+ throw new TaskReviewError('invalid_request', error.code === 'ELOOP' ? 'the file must not be a symlink' : 'file not found or not readable');
512
+ });
513
+ try {
514
+ const stat = await file.stat();
515
+ if (!stat.isFile())
516
+ throw new TaskReviewError('invalid_request', 'only regular files can be attached');
517
+ if (stat.nlink !== 1)
518
+ throw new TaskReviewError('invalid_request', 'hard-linked files cannot be attached');
519
+ if (stat.size > REVIEW_ARTIFACT_MAX_BYTES)
520
+ throw new TaskReviewError('limit_exceeded', 'review files are limited to 20 MiB');
521
+ return { ...await fileStream(file, stat.size), name: basename(absolute) };
522
+ }
523
+ catch (error) {
524
+ await file.close();
525
+ throw error;
526
+ }
527
+ }
528
+ finally {
529
+ await dir.close();
530
+ }
531
+ }
532
+ /** Size of the store, for diagnostics and tests. */
533
+ export function reviewStoreFiles(taskId) {
534
+ try {
535
+ return readdirSync(reviewStoreDirectory(taskId)).sort();
536
+ }
537
+ catch {
538
+ return [];
539
+ }
540
+ }
541
+ /** Wire shapes: retry keys stay internal. */
542
+ export function publicProgressEntry(entry) {
543
+ const { request_key: _key, ...rest } = entry;
544
+ return rest;
545
+ }
546
+ export function publicReview(review) {
547
+ return { ...review, revision: review.revision ?? 1, artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
548
+ }
549
+ /** Compact summary for list and detail payloads; bodies are fetched separately. */
550
+ export function taskContributionSummary(task) {
551
+ return {
552
+ progress_count: task.progress?.length ?? 0,
553
+ review: {
554
+ has_markdown: !!task.review?.markdown.trim(),
555
+ artifact_count: task.review?.artifacts.length ?? 0,
556
+ // Packages written before revisions existed count as revision 1 until their next change.
557
+ revision: task.review ? task.review.revision ?? 1 : 0,
558
+ ...(task.review ? { updated_at: task.review.updated_at } : {}),
559
+ },
560
+ };
561
+ }
@@ -199,5 +199,13 @@ export declare function updateTaskLayout(id: string, expected: string | null, ne
199
199
  task: TaskRecord;
200
200
  releasedSnapshot?: string;
201
201
  };
202
+ /**
203
+ * Progress and To review edits: one read-modify-write under the task lock, so
204
+ * concurrent writers never lose each other's changes. Lifecycle state is untouched.
205
+ */
206
+ export declare function updateTaskContribution<T>(id: string, change: (task: TaskRecord) => {
207
+ value: T;
208
+ changed: boolean;
209
+ }): T;
202
210
  export declare function updateTaskBrief(id: string, brief: string, expectedBrief: string): TaskRecord;
203
211
  export {};