@ours.network/fleet 2.3.2 → 2.3.4

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,50 @@
1
+ import type { TaskRecord } from './types.js';
2
+ export declare const CONTRIBUTION_PAGE_MAX = 100;
3
+ export declare const CONTRIBUTION_CUTOFF_MAX = 200;
4
+ export interface ContributionItem {
5
+ /** `p:<task>:<entry>` or `r:<task>:<revision>`: a new review revision is a new item. */
6
+ id: string;
7
+ kind: 'progress' | 'review';
8
+ task_id: string;
9
+ mission_key?: string;
10
+ task_title: string;
11
+ task_state: TaskRecord['state'];
12
+ at: string;
13
+ excerpt: string;
14
+ unread: boolean;
15
+ entry_id?: string;
16
+ revision?: number;
17
+ }
18
+ /** What one task had when it was listed; the bulk read acknowledges nothing beyond it. */
19
+ export interface ContributionCutoff {
20
+ task_id: string;
21
+ progress: number;
22
+ progress_last: string | null;
23
+ review: number;
24
+ }
25
+ /**
26
+ * One page, newest first. The cursor is a position, not an item: it stays valid when the item it
27
+ * names is read, filtered out or deleted. `cutoff` lists every task with unread items as listed;
28
+ * `unavailable` counts task records that could not be read.
29
+ */
30
+ export declare function listTaskContributions(options?: {
31
+ before?: string;
32
+ limit?: number;
33
+ unread?: boolean;
34
+ }): {
35
+ items: ContributionItem[];
36
+ next_cursor: string | null;
37
+ unread_total: number;
38
+ cutoff: ContributionCutoff[];
39
+ unavailable: number;
40
+ };
41
+ export type ContributionReadResult = {
42
+ task_id: string;
43
+ } & ({
44
+ status: 'read';
45
+ read: number;
46
+ } | {
47
+ status: 'changed' | 'gone' | 'deleting' | 'failed';
48
+ });
49
+ /** Read every task of a captured cutoff up to that cutoff, each under its own lock; one task's outcome never blocks another. */
50
+ export declare function readTaskContributions(cutoff: ContributionCutoff[]): ContributionReadResult[];
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Progress entries and To review revisions of every task as one newest-first notification list,
3
+ * with the canonical read state of each task record. Nothing is copied: the task records stay the
4
+ * only read truth, and the per-task routes and the bulk read below change the same markers.
5
+ *
6
+ * The projection is retained in this process. Each request lists the task directory and stats each
7
+ * record; only a record whose inode, size or time changed is read again (every write replaces the
8
+ * file atomically). Room history and other services are never consulted.
9
+ */
10
+ import { readdirSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { acknowledgeTaskContributionsThrough, contributionUnread, getTask, reviewRevision, tasksDir, TaskStateError } from './task-state.js';
13
+ import { TaskReviewError } from './task-review.js';
14
+ const TASK_FILE = /^([0-9a-z]{9}[0-9a-f]{8})\.json$/;
15
+ const EXCERPT_MAX = 200;
16
+ export const CONTRIBUTION_PAGE_MAX = 100;
17
+ export const CONTRIBUTION_CUTOFF_MAX = 200;
18
+ let cache;
19
+ function excerpt(text) {
20
+ const points = [...text.replace(/\s+/g, ' ').trim()];
21
+ return points.length > EXCERPT_MAX ? points.slice(0, EXCERPT_MAX - 1).join('') + '…' : points.join('');
22
+ }
23
+ function reviewExcerpt(task) {
24
+ const line = task.review?.markdown.split('\n').map(text => text.replace(/^\s*(?:#{1,6}\s|>\s?|[-*+]\s|\d+\.\s)*/, '').trim()).find(Boolean);
25
+ if (line)
26
+ return excerpt(line);
27
+ const files = task.review?.artifacts.length ?? 0;
28
+ return files ? `${files} attached ${files === 1 ? 'file' : 'files'}` : 'The review request was cleared.';
29
+ }
30
+ const time = (at) => { const value = Date.parse(at); return Number.isFinite(value) ? value : 0; };
31
+ function project(task, stamp) {
32
+ const base = {
33
+ task_id: task.task_id, ...(task.key ? { mission_key: task.key } : {}), task_title: task.title, task_state: task.state,
34
+ };
35
+ const reads = task.contribution_reads, seen = new Set(reads?.progress_read);
36
+ const items = (task.progress ?? []).map(entry => ({
37
+ ...base, id: `p:${task.task_id}:${entry.id}`, kind: 'progress', at: entry.at, order: time(entry.at),
38
+ excerpt: excerpt(entry.text), unread: !!reads && !seen.has(entry.id), entry_id: entry.id,
39
+ }));
40
+ const unread = contributionUnread(task);
41
+ if (task.review) {
42
+ const revision = reviewRevision(task.review);
43
+ items.push({ ...base, id: `r:${task.task_id}:${revision}`, kind: 'review', at: task.review.updated_at,
44
+ order: time(task.review.updated_at), excerpt: reviewExcerpt(task), unread: unread.review, revision });
45
+ }
46
+ const progress = task.progress ?? [];
47
+ return { stamp, items, unread: unread.progress + (unread.review ? 1 : 0),
48
+ ...(unread.progress || unread.review ? { cutoff: { task_id: task.task_id, progress: progress.length,
49
+ progress_last: progress.at(-1)?.id ?? null, review: reviewRevision(task.review) } } : {}) };
50
+ }
51
+ /** Bring the retained projection up to date with the task directory; returns the tasks not readable now. */
52
+ function refresh() {
53
+ const dir = tasksDir();
54
+ if (cache?.dir !== dir)
55
+ cache = { dir, tasks: new Map() };
56
+ const current = cache;
57
+ let names;
58
+ // No task directory yet means no tasks; any other failure is an error, never an empty list.
59
+ try {
60
+ names = readdirSync(dir);
61
+ }
62
+ catch (error) {
63
+ if (error.code !== 'ENOENT')
64
+ throw error;
65
+ names = [];
66
+ }
67
+ const present = new Set();
68
+ let unavailable = 0;
69
+ for (const name of names) {
70
+ const id = TASK_FILE.exec(name)?.[1];
71
+ if (!id)
72
+ continue;
73
+ try {
74
+ // Writes replace the record by rename: a new inode, and a change time in nanoseconds.
75
+ const stat = statSync(join(dir, name), { bigint: true });
76
+ const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
77
+ present.add(id);
78
+ if (current.tasks.get(id)?.stamp === stamp)
79
+ continue;
80
+ const task = getTask(id);
81
+ current.index = undefined;
82
+ // A task pending deletion lists nothing and counts nothing.
83
+ if (task.deletion?.status === 'pending')
84
+ current.tasks.set(id, { stamp, items: [], unread: 0 });
85
+ else
86
+ current.tasks.set(id, project(task, stamp));
87
+ }
88
+ catch (error) {
89
+ if (error.code === 'ENOENT' || (error instanceof TaskStateError && /not found/.test(error.message)))
90
+ continue;
91
+ present.add(id);
92
+ unavailable++;
93
+ if (current.tasks.delete(id))
94
+ current.index = undefined;
95
+ }
96
+ }
97
+ for (const id of current.tasks.keys())
98
+ if (!present.has(id)) {
99
+ current.tasks.delete(id);
100
+ current.index = undefined;
101
+ }
102
+ return unavailable;
103
+ }
104
+ function index() {
105
+ return cache.index ??= [...cache.tasks.values()].flatMap(task => task.items)
106
+ .sort((a, b) => b.order - a.order || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
107
+ }
108
+ function encodeCursor(item) {
109
+ return Buffer.from(JSON.stringify([item.order, item.id])).toString('base64url');
110
+ }
111
+ function decodeCursor(cursor) {
112
+ try {
113
+ const [order, id] = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
114
+ if (typeof order === 'number' && Number.isFinite(order) && typeof id === 'string' && id.length <= 200)
115
+ return { order, id };
116
+ }
117
+ catch { /* invalid below */ }
118
+ throw new TaskReviewError('invalid_request', 'invalid cursor');
119
+ }
120
+ /**
121
+ * One page, newest first. The cursor is a position, not an item: it stays valid when the item it
122
+ * names is read, filtered out or deleted. `cutoff` lists every task with unread items as listed;
123
+ * `unavailable` counts task records that could not be read.
124
+ */
125
+ export function listTaskContributions(options = {}) {
126
+ const limit = options.limit ?? 50;
127
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > CONTRIBUTION_PAGE_MAX)
128
+ throw new TaskReviewError('invalid_request', `limit must be 1-${CONTRIBUTION_PAGE_MAX}`);
129
+ const after = options.before === undefined ? undefined : decodeCursor(options.before);
130
+ const unavailable = refresh();
131
+ const page = [];
132
+ let more = false;
133
+ for (const item of index()) {
134
+ if (after && (item.order > after.order || (item.order === after.order && item.id >= after.id)))
135
+ continue;
136
+ if (options.unread && !item.unread)
137
+ continue;
138
+ if (page.length === limit) {
139
+ more = true;
140
+ break;
141
+ }
142
+ page.push(item);
143
+ }
144
+ const tasks = [...cache.tasks.values()];
145
+ return {
146
+ items: page.map(({ order: _order, ...item }) => item),
147
+ next_cursor: more ? encodeCursor(page.at(-1)) : null,
148
+ unread_total: tasks.reduce((sum, task) => sum + task.unread, 0),
149
+ cutoff: tasks.flatMap(task => task.cutoff ? [task.cutoff] : []),
150
+ unavailable,
151
+ };
152
+ }
153
+ /** Read every task of a captured cutoff up to that cutoff, each under its own lock; one task's outcome never blocks another. */
154
+ export function readTaskContributions(cutoff) {
155
+ return cutoff.map(({ task_id, progress, progress_last, review }) => {
156
+ try {
157
+ const read = acknowledgeTaskContributionsThrough(task_id, { progress, progressLast: progress_last, review });
158
+ return read === 'changed' ? { task_id, status: 'changed' } : { task_id, status: 'read', read };
159
+ }
160
+ catch (error) {
161
+ if (error instanceof TaskStateError && /pending deletion/.test(error.message))
162
+ return { task_id, status: 'deleting' };
163
+ if (error instanceof TaskStateError && /not found/.test(error.message))
164
+ return { task_id, status: 'gone' };
165
+ // An unreadable or unwritable record fails alone; tasks already read stay read and are reported.
166
+ return { task_id, status: 'failed' };
167
+ }
168
+ });
169
+ }
@@ -512,6 +512,8 @@ export function insideDirectory(root, path) {
512
512
  export function taskWorkspaceRoot(task) {
513
513
  if (!task.workspace)
514
514
  throw new TaskReviewError('invalid_request', `task ${task.task_id} has no owned workspace`);
515
+ if (task.workspace_removal)
516
+ throw new TaskReviewError('invalid_request', `the working files of task ${task.task_id} were removed, so no file can be attached from them`);
515
517
  assertWorkspacePresent(task.workspace, 'task', task.task_id);
516
518
  return task.workspace.path;
517
519
  }
@@ -1,5 +1,5 @@
1
1
  import type { ArchivedMemberAbsence } from './types.js';
2
- import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
2
+ import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent, TaskWorkspaceRemoval } from './types.js';
3
3
  import { type LabelCandidate } from './member-naming.js';
4
4
  import { type TaskNotificationManifest } from './notification-manifest.js';
5
5
  export declare const tasksDir: () => string;
@@ -24,6 +24,18 @@ export declare function acknowledgeTaskReview(id: string, revision: number): {
24
24
  progress: number;
25
25
  review: boolean;
26
26
  };
27
+ /**
28
+ * Bulk read up to a captured cutoff: the first `progress` entries (Progress is append-only, so an
29
+ * entry appended after the capture has a higher index) and To review up to exactly `review`, never
30
+ * a newer revision. Both are written in one task change. Returns how many items that were unread
31
+ * are read now, or `changed` (nothing written) when the task no longer matches the cutoff, including a
32
+ * review revision the task never had; refuses a task pending deletion.
33
+ */
34
+ export declare function acknowledgeTaskContributionsThrough(id: string, cutoff: {
35
+ progress: number;
36
+ progressLast: string | null;
37
+ review: number;
38
+ }): number | 'changed';
27
39
  /** Atomic snapshots contain the events, including intermediate states between polls. */
28
40
  /** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
29
41
  * same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
@@ -283,4 +295,9 @@ export declare function updateTaskContribution<T>(id: string, change: (task: Tas
283
295
  changed: boolean;
284
296
  }): T;
285
297
  export declare function updateTaskBrief(id: string, brief: string, expectedBrief: string): TaskRecord;
298
+ /**
299
+ * Record a step of an explicit working-folder removal. Callers hold the task
300
+ * operation lock; lifecycle state is untouched.
301
+ */
302
+ export declare function updateTaskWorkspaceRemoval(id: string, change: (removal: TaskWorkspaceRemoval | undefined) => TaskWorkspaceRemoval | undefined): TaskRecord;
286
303
  export {};
@@ -266,6 +266,42 @@ export function acknowledgeTaskReview(id, revision) {
266
266
  return contributionUnread(task);
267
267
  });
268
268
  }
269
+ /**
270
+ * Bulk read up to a captured cutoff: the first `progress` entries (Progress is append-only, so an
271
+ * entry appended after the capture has a higher index) and To review up to exactly `review`, never
272
+ * a newer revision. Both are written in one task change. Returns how many items that were unread
273
+ * are read now, or `changed` (nothing written) when the task no longer matches the cutoff, including a
274
+ * review revision the task never had; refuses a task pending deletion.
275
+ */
276
+ export function acknowledgeTaskContributionsThrough(id, cutoff) {
277
+ assertCanonicalTaskId(id);
278
+ return withTaskLock(id, () => {
279
+ const task = readTask(id);
280
+ assertNoPendingDeletion(task);
281
+ const { progress, progressLast, review } = cutoff;
282
+ // The cutoff names its last entry: a different entry at that position reads nothing.
283
+ const entries = task.progress ?? [];
284
+ if (!Number.isSafeInteger(progress) || progress < 0 || progress > entries.length
285
+ || (progress > 0 ? entries[progress - 1].id : null) !== progressLast
286
+ || !Number.isSafeInteger(review) || review < 0 || review > reviewRevision(task.review))
287
+ return 'changed';
288
+ const reads = task.contribution_reads;
289
+ if (!reads)
290
+ return 0;
291
+ const seen = new Set(reads.progress_read);
292
+ const added = entries.slice(0, progress).map(entry => entry.id).filter(entryId => !seen.has(entryId));
293
+ const reviewRead = review > reads.review_revision;
294
+ if (!added.length && !reviewRead)
295
+ return 0;
296
+ task.contribution_reads = {
297
+ progress_read: [...reads.progress_read, ...added],
298
+ review_revision: reviewRead ? review : reads.review_revision,
299
+ };
300
+ writeTask(task);
301
+ // An older revision moves the marker but leaves the current package unread: it is not counted.
302
+ return added.length + (reviewRead && review === reviewRevision(task.review) ? 1 : 0);
303
+ });
304
+ }
269
305
  const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
270
306
  function typedTaskNotification(value, id) {
271
307
  if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId', 'delivery'].includes(key)))
@@ -482,7 +518,8 @@ export function createTask(input) {
482
518
  const fresh = readTask(outcome.taskId);
483
519
  if (outcome.kind === 'existing')
484
520
  assertNoPendingDeletion(fresh);
485
- if (fresh.workspace)
521
+ // A replayed create never recreates a folder the Owner removed.
522
+ if (fresh.workspace && !fresh.workspace_removal)
486
523
  ensureWorkspace(fresh.workspace);
487
524
  return fresh;
488
525
  });
@@ -1466,3 +1503,21 @@ export function updateTaskBrief(id, brief, expectedBrief) {
1466
1503
  return task;
1467
1504
  });
1468
1505
  }
1506
+ /**
1507
+ * Record a step of an explicit working-folder removal. Callers hold the task
1508
+ * operation lock; lifecycle state is untouched.
1509
+ */
1510
+ export function updateTaskWorkspaceRemoval(id, change) {
1511
+ assertCanonicalTaskId(id);
1512
+ return withTaskLock(id, () => {
1513
+ const task = readTask(id);
1514
+ assertNoPendingDeletion(task);
1515
+ const next = change(task.workspace_removal);
1516
+ if (next)
1517
+ task.workspace_removal = next;
1518
+ else
1519
+ delete task.workspace_removal;
1520
+ writeTask(task);
1521
+ return task;
1522
+ });
1523
+ }
@@ -99,6 +99,21 @@ export interface TaskDeletionIntent {
99
99
  first_failure?: string;
100
100
  first_recovery_hint?: string;
101
101
  }
102
+ /**
103
+ * Explicit removal of a Done task's owned working folder. The task record,
104
+ * Progress, To review files and agent archives stay; only the folder goes.
105
+ * `started_at` without `completed_at` means removal began and has not been
106
+ * confirmed: it may still be running or have been interrupted.
107
+ */
108
+ export interface TaskWorkspaceRemoval {
109
+ started_at: string;
110
+ actor: TaskDeletionActor;
111
+ completed_at?: string;
112
+ /** The recorded folder was already absent when removal first ran. */
113
+ already_missing?: true;
114
+ error?: string;
115
+ error_at?: string;
116
+ }
102
117
  export interface TaskLayoutLink {
103
118
  name: string;
104
119
  /** Deterministic `task-<task_id>` Room Layout run ID. */
@@ -173,6 +188,8 @@ export interface TaskRecord {
173
188
  /** Rooms whose notifications belong to this task; only grows. */
174
189
  notification_manifest?: import('./notification-manifest.js').TaskNotificationManifest;
175
190
  workspace?: import('./workspace.js').OwnedWorkspace;
191
+ /** Set once the Owner asked to remove the working folder; the record is never recreated from it. */
192
+ workspace_removal?: TaskWorkspaceRemoval;
176
193
  task_id: string;
177
194
  /**
178
195
  * Immutable human mission key (`MSN-1`), issued once from a durable
@@ -0,0 +1,85 @@
1
+ import type { TaskDeletionActor, TaskRecord } from './types.js';
2
+ /**
3
+ * Explicit Owner removal of a Done task's working folder. Only the exact
4
+ * recorded task-owned folder is removed, through the guarded workspace
5
+ * deletion (marker, Git registration, symlink and mount fences). The task
6
+ * record, Progress, To review files (a separate store), agent archives and
7
+ * rooms' history are never touched. Every precondition is re-read under the
8
+ * task-operation lock that serializes terminal settlement, deletion and
9
+ * recovery; anything that cannot be proven idle is refused, never stopped.
10
+ */
11
+ export type WorkspaceRemovalResult = 'removed' | 'already_removed' | 'already_missing';
12
+ export interface WorkspaceRemovalOutcome {
13
+ task: TaskRecord;
14
+ result: WorkspaceRemovalResult;
15
+ /** Retained text fields that mention the removed folder; those paths no longer resolve. */
16
+ mentions: string[];
17
+ }
18
+ export interface WorkspaceRemovalDeps {
19
+ /** Working directories of configured permanent agents; undefined when the configuration is unreadable. */
20
+ permanentCwds?: readonly (string | undefined)[];
21
+ /** Test seam: processes whose cwd, root or open files are inside `path`, or cannot be read. */
22
+ processesUsing?(path: string, context: ProcessCheckContext): ProcessUse;
23
+ }
24
+ /**
25
+ * Every process whose cwd, root or open file descriptor is inside `path`, or
26
+ * whose use cannot be excluded. Only Linux exposes this without extra tools;
27
+ * elsewhere removal is refused. When a process's entries are unreadable (for
28
+ * example non-dumpable, or in a container), it is excluded only by evidence:
29
+ * it is a zombie, its mount table cannot reach the folder's filesystem
30
+ * location, it is the user's service manager (attested by the system manager)
31
+ * whose own records name nothing in the folder, or it is that manager's PAM
32
+ * helper started before the folder existed; otherwise it is reported.
33
+ * Other users' unreadable processes are skipped when a private ancestor
34
+ * closes the folder to them; a superuser process is outside this check.
35
+ */
36
+ /** A same-user process whose cwd and descriptors cannot be read. */
37
+ export interface UnverifiedProcess {
38
+ token: string;
39
+ description: string;
40
+ }
41
+ export interface ProcessUse {
42
+ /** Processes proven to use the folder. */
43
+ users: string[];
44
+ /** Processes whose use can be neither proven nor excluded. */
45
+ unverified: UnverifiedProcess[];
46
+ }
47
+ export interface ProcessCheckContext {
48
+ /** The folder's recorded path; configuration names it, not its tombstone. */
49
+ original: string;
50
+ /** When the folder came into existence (ms since the epoch). */
51
+ createdAt: number;
52
+ }
53
+ export declare function processesUsing(path: string, context: ProcessCheckContext): ProcessUse;
54
+ declare const run: (command: string, args: string[]) => string;
55
+ /**
56
+ * Whether the user service manager may use the folder, from its own read-only
57
+ * records: its attested working directory, socket units' paths and every
58
+ * file descriptor store (systemd 254+). Undefined when none names the folder;
59
+ * otherwise the reason, including any record that cannot be read.
60
+ */
61
+ export declare function userManagerUse(uid: number, roots: string[], exec?: typeof run): string | undefined;
62
+ /** True when some mount in the table exposes the filesystem location, or anything under it. */
63
+ export declare function reaches(mountinfo: string, target: {
64
+ device: string;
65
+ inside: string;
66
+ }): boolean;
67
+ export declare function assertTaskWorkspaceRemovable(task: TaskRecord, deps: WorkspaceRemovalDeps): void;
68
+ export declare function removeTaskWorkspace(taskId: string, actor: TaskDeletionActor, deps?: WorkspaceRemovalDeps): Promise<WorkspaceRemovalOutcome>;
69
+ export interface WorkingFilesSummary {
70
+ /** `removing`: removal began and is not confirmed — still running or interrupted; retry resumes it. */
71
+ state: 'none' | 'present' | 'removing' | 'failed' | 'removed';
72
+ started_at?: string;
73
+ removed_at?: string;
74
+ error?: string;
75
+ }
76
+ /** Record-only readback for task payloads; never touches the filesystem. */
77
+ export declare function workingFilesSummary(task: TaskRecord): WorkingFilesSummary;
78
+ export declare function assertCwdNotInRemovedTaskFolder(cwd: string | undefined): void;
79
+ /**
80
+ * Launch fence: an agent never starts in, and so never recreates, the folder
81
+ * of a task whose working files were removed. Creating its state under the
82
+ * folder's use lock makes it visible to a concurrent removal's scan.
83
+ */
84
+ export declare function withTaskFolderUseFence<T>(cwd: string | undefined, work: () => T): T;
85
+ export {};