@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.
- package/dist/application/task-room-service.d.ts +8 -0
- package/dist/application/task-room-service.js +15 -0
- package/dist/build-info.json +4 -4
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +22 -0
- package/dist/ops.js +6 -0
- package/dist/rooms-tasks/cli.js +24 -0
- package/dist/rooms-tasks/deletion.js +4 -0
- package/dist/rooms-tasks/task-contributions.d.ts +50 -0
- package/dist/rooms-tasks/task-contributions.js +169 -0
- package/dist/rooms-tasks/task-review.js +2 -0
- package/dist/rooms-tasks/task-state.d.ts +18 -1
- package/dist/rooms-tasks/task-state.js +56 -1
- package/dist/rooms-tasks/types.d.ts +17 -0
- package/dist/rooms-tasks/workspace-removal.d.ts +85 -0
- package/dist/rooms-tasks/workspace-removal.js +633 -0
- package/dist/rooms-tasks/workspace.d.ts +9 -1
- package/dist/rooms-tasks/workspace.js +13 -1
- package/dist/spawn.js +2 -0
- package/dist/web/server.js +44 -1
- package/docs/task-progress-review.md +2 -0
- package/docs/task-workspaces.md +51 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 {};
|