@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,633 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { withFileLock, withSynchronousFileLock } from '../atomic-file.js';
|
|
6
|
+
import { agentsRoot, stateRoot, tmpRoot } from '../paths.js';
|
|
7
|
+
import { listMembers } from '../supervisor/catalog.js';
|
|
8
|
+
import { readTempSupervisor, TEMP_SUPERVISOR_FILE } from '../temp-lifecycle.js';
|
|
9
|
+
import { validLayoutKey } from './layout-config.js';
|
|
10
|
+
import { roomsDir } from './room-state.js';
|
|
11
|
+
import { getTask, TaskStateError, updateTaskWorkspaceRemoval } from './task-state.js';
|
|
12
|
+
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
13
|
+
import { deleteWorkspace, validateWorkspace, workspaceEntriesPresent } from './workspace.js';
|
|
14
|
+
/** True when some directory on the way to `path` is ours and closed to group and others. */
|
|
15
|
+
function closedToOtherUsers(path) {
|
|
16
|
+
const uid = process.geteuid?.();
|
|
17
|
+
for (let dir = resolve(path);; dir = dirname(dir)) {
|
|
18
|
+
const stat = lstatSync(dir, { throwIfNoEntry: false });
|
|
19
|
+
if (stat && stat.uid === uid && (stat.mode & 0o011) === 0)
|
|
20
|
+
return true;
|
|
21
|
+
if (dirname(dir) === dir)
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function processesUsing(path, context) {
|
|
26
|
+
if (process.platform !== 'linux')
|
|
27
|
+
refuse('this platform cannot verify that no process is using the folder');
|
|
28
|
+
const uid = process.geteuid?.();
|
|
29
|
+
const othersClosed = closedToOtherUsers(path);
|
|
30
|
+
const target = filesystemLocation(path);
|
|
31
|
+
const manager = userManagerPid(uid);
|
|
32
|
+
let managerUse = null; // computed once, only when needed
|
|
33
|
+
const inside = (link) => contains(path, link.replace(/ \(deleted\)$/, ''));
|
|
34
|
+
const gone = (error) => ['ENOENT', 'ESRCH'].includes(error.code ?? '');
|
|
35
|
+
const denied = (error) => ['EACCES', 'EPERM'].includes(error.code ?? '');
|
|
36
|
+
const users = [];
|
|
37
|
+
const unverified = [];
|
|
38
|
+
for (const pid of readdirSync('/proc').filter(name => /^\d+$/.test(name))) {
|
|
39
|
+
let unreadable = false;
|
|
40
|
+
const read = (link) => {
|
|
41
|
+
try {
|
|
42
|
+
return readlinkSync(link);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (gone(error))
|
|
46
|
+
return undefined;
|
|
47
|
+
if (denied(error)) {
|
|
48
|
+
unreadable = true;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const links = ['cwd', 'root'].map(name => read(`/proc/${pid}/${name}`));
|
|
55
|
+
try {
|
|
56
|
+
for (const fd of readdirSync(`/proc/${pid}/fd`))
|
|
57
|
+
links.push(read(`/proc/${pid}/fd/${fd}`));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (denied(error))
|
|
61
|
+
unreadable = true;
|
|
62
|
+
else if (!gone(error))
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
if (links.some(link => link !== undefined && isAbsolute(link) && inside(link))) {
|
|
66
|
+
users.push(pid);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!unreadable)
|
|
70
|
+
continue;
|
|
71
|
+
const owner = lstatSync(`/proc/${pid}`, { throwIfNoEntry: false })?.uid;
|
|
72
|
+
if (owner === undefined)
|
|
73
|
+
continue; // exited meanwhile
|
|
74
|
+
if (owner !== uid && othersClosed)
|
|
75
|
+
continue;
|
|
76
|
+
const status = readProc(`/proc/${pid}/stat`);
|
|
77
|
+
if (status === undefined || /^\d+ \(.*\) [ZX] /s.test(status))
|
|
78
|
+
continue; // exited or a zombie: no cwd or files
|
|
79
|
+
// Nothing readable proves this process holds nothing in the folder: a retained cwd or
|
|
80
|
+
// descriptor survives namespace and chroot changes. Read-only context only informs the Owner.
|
|
81
|
+
// The short kernel name only: a command line may carry secrets.
|
|
82
|
+
const name = /^\d+ \((.*)\) /s.exec(status)?.[1]?.replace(/[^\w .:@()-]/g, '?') ?? '?';
|
|
83
|
+
const mounts = readProc(`/proc/${pid}/mountinfo`);
|
|
84
|
+
let hint;
|
|
85
|
+
if (manager && pid === manager) {
|
|
86
|
+
if (managerUse === null)
|
|
87
|
+
managerUse = userManagerUse(uid, [path, context.original]);
|
|
88
|
+
// A record naming the folder is positive evidence of use, not uncertainty.
|
|
89
|
+
if (managerUse !== undefined && !/cannot be|unknown/.test(managerUse)) {
|
|
90
|
+
users.push(`${pid} (user service manager: ${managerUse})`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
hint = managerUse === undefined ? "user service manager; its records name nothing in the folder" : `user service manager; ${managerUse}`;
|
|
94
|
+
}
|
|
95
|
+
else if (manager && isPamHelper(pid, manager, context.createdAt))
|
|
96
|
+
hint = 'PAM helper of the user service manager, started before the folder existed';
|
|
97
|
+
else if (mounts !== undefined && !reaches(mounts, target))
|
|
98
|
+
hint = 'own mount namespace without the folder';
|
|
99
|
+
const started = status.slice(status.lastIndexOf(')') + 2).split(' ')[19];
|
|
100
|
+
unverified.push({ token: `${pid}:${started}`, description: `${pid} ${name}${hint ? ` (${hint})` : ''}` });
|
|
101
|
+
}
|
|
102
|
+
return { users, unverified };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The user's service manager as attested by the system manager (root-owned PID 1),
|
|
106
|
+
* not by anything the process says about itself. Undefined when it cannot be asked.
|
|
107
|
+
*/
|
|
108
|
+
function userManagerPid(uid) {
|
|
109
|
+
if (uid === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
try {
|
|
112
|
+
const pid = execFileSync('systemctl', ['show', `user@${uid}.service`, '--property=MainPID', '--value'], { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
113
|
+
return /^[1-9]\d*$/.test(pid) ? pid : undefined;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const run = (command, args) => {
|
|
120
|
+
// The user manager's bus lives in the user's runtime directory, which a Fleet service may not inherit.
|
|
121
|
+
const env = { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.geteuid?.()}` };
|
|
122
|
+
try {
|
|
123
|
+
return execFileSync(command, args, { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const stderr = String(error.stderr ?? '').trim().split('\n')[0];
|
|
127
|
+
throw Error(stderr || error.message);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Whether the user service manager may use the folder, from its own read-only
|
|
132
|
+
* records: its attested working directory, socket units' paths and every
|
|
133
|
+
* file descriptor store (systemd 254+). Undefined when none names the folder;
|
|
134
|
+
* otherwise the reason, including any record that cannot be read.
|
|
135
|
+
*/
|
|
136
|
+
export function userManagerUse(uid, roots, exec = run) {
|
|
137
|
+
const hits = (candidate) => isAbsolute(candidate) && roots.some(root => within(root, candidate));
|
|
138
|
+
try {
|
|
139
|
+
let cwd = exec('systemctl', ['show', `user@${uid}.service`, '--property=WorkingDirectory', '--value']).trim().replace(/^[-!]+/, '');
|
|
140
|
+
if (cwd === '~')
|
|
141
|
+
cwd = process.env.HOME ?? '';
|
|
142
|
+
if (!cwd)
|
|
143
|
+
cwd = '/';
|
|
144
|
+
if (hits(cwd))
|
|
145
|
+
return `works in ${cwd}`;
|
|
146
|
+
const units = exec('systemctl', ['--user', 'show', '*', '--property=Id', '--property=NFileDescriptorStore', '--property=Listen'])
|
|
147
|
+
.split(/\n\s*\n/).map(block => {
|
|
148
|
+
const unit = {};
|
|
149
|
+
for (const line of block.split('\n').filter(Boolean))
|
|
150
|
+
(unit[line.slice(0, line.indexOf('='))] ??= []).push(line);
|
|
151
|
+
return unit;
|
|
152
|
+
});
|
|
153
|
+
if (!units.some(unit => unit.Id))
|
|
154
|
+
return 'its units cannot be listed';
|
|
155
|
+
for (const unit of units) {
|
|
156
|
+
const id = unit.Id?.[0]?.slice(3);
|
|
157
|
+
for (const line of unit.Listen ?? []) {
|
|
158
|
+
const listen = line.slice(7).replace(/ \([^)]*\)$/, '');
|
|
159
|
+
if (hits(listen))
|
|
160
|
+
return `${id} listens on ${listen}`;
|
|
161
|
+
}
|
|
162
|
+
const stored = Number(unit.NFileDescriptorStore?.[0]?.slice(21) ?? 0);
|
|
163
|
+
if (!id || !Number.isSafeInteger(stored) || stored < 0)
|
|
164
|
+
return `unit ${id ?? '?'} cannot be read`;
|
|
165
|
+
if (stored === 0)
|
|
166
|
+
continue;
|
|
167
|
+
const entries = JSON.parse(exec('systemd-analyze', ['--user', 'fdstore', id, '--json=short']));
|
|
168
|
+
if (!Array.isArray(entries))
|
|
169
|
+
return `the file descriptor store of ${id} cannot be read`;
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (typeof entry.path === 'string' && entry.path && hits(entry.path.replace(/ \(deleted\)$/, '')))
|
|
172
|
+
return `${id} stores ${entry.path}`;
|
|
173
|
+
if (!entry.path && ['directory', 'regular'].includes(entry.type ?? ''))
|
|
174
|
+
return `${id} stores a file whose path is unknown`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return `its records cannot be read (${String(error.message ?? error).split('\n')[0]})`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The manager's PAM session helper: a direct child named `(sd-pam)` in the
|
|
185
|
+
* manager's cgroup that started before the folder existed. It is forked when
|
|
186
|
+
* the session opens and then only waits, so it can hold nothing created later.
|
|
187
|
+
*/
|
|
188
|
+
function isPamHelper(pid, manager, folderCreatedAt) {
|
|
189
|
+
const stat = readProc(`/proc/${pid}/stat`);
|
|
190
|
+
if (!stat)
|
|
191
|
+
return false;
|
|
192
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
193
|
+
const cgroup = (id) => readProc(`/proc/${id}/cgroup`);
|
|
194
|
+
if (fields[1] !== manager || readProc(`/proc/${pid}/cmdline`)?.replace(/\0+$/, '') !== '(sd-pam)'
|
|
195
|
+
|| cgroup(pid) === undefined || cgroup(pid) !== cgroup(manager))
|
|
196
|
+
return false;
|
|
197
|
+
const boot = Number(/^btime (\d+)$/m.exec(readProc('/proc/stat') ?? '')?.[1]);
|
|
198
|
+
let ticks = 100;
|
|
199
|
+
try {
|
|
200
|
+
ticks = Number(run('getconf', ['CLK_TCK']).trim()) || 100;
|
|
201
|
+
}
|
|
202
|
+
catch { /* Linux default */ }
|
|
203
|
+
const started = (boot + Number(fields[19]) / ticks) * 1000;
|
|
204
|
+
return Number.isFinite(started) && Number.isFinite(folderCreatedAt) && started < folderCreatedAt;
|
|
205
|
+
}
|
|
206
|
+
function readProc(path) {
|
|
207
|
+
try {
|
|
208
|
+
return readFileSync(path, 'utf8');
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (['ENOENT', 'ESRCH', 'EINVAL'].includes(error.code ?? ''))
|
|
212
|
+
return undefined;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Where `path` lives: its device and its path within that filesystem, from our own mount table. */
|
|
217
|
+
function filesystemLocation(path) {
|
|
218
|
+
const dev = lstatSync(path).dev;
|
|
219
|
+
// glibc dev_t encoding, as Node reports it on Linux.
|
|
220
|
+
const major = (Math.floor(dev / 256) & 0xfff) | (Math.floor(dev / 2 ** 32) & ~0xfff);
|
|
221
|
+
const minor = (dev & 0xff) | (Math.floor(dev / 4096) & ~0xff & 0xfffff);
|
|
222
|
+
const device = `${major}:${minor}`;
|
|
223
|
+
let best;
|
|
224
|
+
for (const line of readFileSync('/proc/self/mountinfo', 'utf8').split('\n')) {
|
|
225
|
+
const fields = line.split(' ');
|
|
226
|
+
if (fields[2] !== device)
|
|
227
|
+
continue;
|
|
228
|
+
const point = unescapeMount(fields[4]);
|
|
229
|
+
if (contains(point, path) && (!best || point.length > best.point.length))
|
|
230
|
+
best = { point, root: unescapeMount(fields[3]) };
|
|
231
|
+
}
|
|
232
|
+
if (!best)
|
|
233
|
+
throw Error(`no mount of device ${device} contains ${path}`);
|
|
234
|
+
return { device, inside: join(best.root, relative(best.point, path)) };
|
|
235
|
+
}
|
|
236
|
+
function unescapeMount(value = '') {
|
|
237
|
+
return value.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)));
|
|
238
|
+
}
|
|
239
|
+
/** True when some mount in the table exposes the filesystem location, or anything under it. */
|
|
240
|
+
export function reaches(mountinfo, target) {
|
|
241
|
+
return mountinfo.split('\n').some(line => {
|
|
242
|
+
const fields = line.split(' ');
|
|
243
|
+
if (fields[2] !== target.device)
|
|
244
|
+
return false;
|
|
245
|
+
const root = unescapeMount(fields[3]);
|
|
246
|
+
return contains(root, target.inside) || contains(target.inside, root);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** A use of the folder found after its rename; `restore` exists only for a freshly renamed intact folder. */
|
|
250
|
+
class InUse extends Error {
|
|
251
|
+
restore;
|
|
252
|
+
constructor(message, restore) {
|
|
253
|
+
super(message);
|
|
254
|
+
this.restore = restore;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function refuse(reason) {
|
|
258
|
+
throw new TaskStateError(`cannot remove working files: ${reason}`);
|
|
259
|
+
}
|
|
260
|
+
/** Component containment: `root` itself or a descendant (`..name` is a descendant). */
|
|
261
|
+
function contains(root, path) {
|
|
262
|
+
const rel = relative(root, path);
|
|
263
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Resolve symlinks through the deepest existing ancestor, following dangling
|
|
267
|
+
* links too: a link to a removed folder still names that folder.
|
|
268
|
+
*/
|
|
269
|
+
function canonical(path) {
|
|
270
|
+
const absolute = resolve(path);
|
|
271
|
+
let existing = absolute;
|
|
272
|
+
const rest = [];
|
|
273
|
+
for (let links = 0;;) {
|
|
274
|
+
try {
|
|
275
|
+
return join(realpathSync(existing), ...rest);
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code !== 'ENOENT')
|
|
279
|
+
throw error;
|
|
280
|
+
if (lstatSync(existing, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
281
|
+
if (++links > 40)
|
|
282
|
+
throw Error(`too many symlinks in ${path}`);
|
|
283
|
+
existing = resolve(dirname(existing), readlinkSync(existing));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const parent = dirname(existing);
|
|
287
|
+
if (parent === existing)
|
|
288
|
+
return absolute;
|
|
289
|
+
rest.unshift(basename(existing));
|
|
290
|
+
existing = parent;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** True when `path`, literally or through symlinks, names the folder or a descendant. */
|
|
295
|
+
function within(root, path) {
|
|
296
|
+
return contains(root, resolve(path)) || contains(root, canonical(path));
|
|
297
|
+
}
|
|
298
|
+
function exists(path) {
|
|
299
|
+
try {
|
|
300
|
+
lstatSync(path);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === 'ENOENT')
|
|
305
|
+
return false;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Every room record, strictly: the general listing skips corrupt records,
|
|
311
|
+
* which here would turn unknown room ownership into a claim that none remains.
|
|
312
|
+
*/
|
|
313
|
+
function assertNoRoomRecord(task) {
|
|
314
|
+
let names;
|
|
315
|
+
try {
|
|
316
|
+
names = readdirSync(roomsDir());
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error.code === 'ENOENT')
|
|
320
|
+
return;
|
|
321
|
+
refuse(`room records cannot be read (${String(error)})`);
|
|
322
|
+
}
|
|
323
|
+
for (const name of names.filter(name => name.endsWith('.json'))) {
|
|
324
|
+
let room;
|
|
325
|
+
try {
|
|
326
|
+
room = JSON.parse(readFileSync(join(roomsDir(), name), 'utf8'));
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
refuse(`room record ${name} cannot be read, so its owner is unknown (${String(error)})`);
|
|
330
|
+
}
|
|
331
|
+
if (!room || typeof room !== 'object' || typeof room.room_id !== 'string')
|
|
332
|
+
refuse(`room record ${name} is invalid, so its owner is unknown`);
|
|
333
|
+
if (room.task_id === task.task_id || room.workspace?.path === task.workspace.path)
|
|
334
|
+
refuse(`room ${room.room_id} of this task is still open; close it first`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function readRole(dir) {
|
|
338
|
+
const file = join(dir, 'role.yaml');
|
|
339
|
+
const entry = lstatSync(file);
|
|
340
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
341
|
+
throw Error('unsafe role.yaml');
|
|
342
|
+
const role = parse(readFileSync(file, 'utf8'));
|
|
343
|
+
if (!role || typeof role !== 'object')
|
|
344
|
+
throw Error('invalid role.yaml');
|
|
345
|
+
return role;
|
|
346
|
+
}
|
|
347
|
+
/** Agent state that is not archived may still run in, or return to, this folder. */
|
|
348
|
+
function assertNoAgentUsesFolder(task, path, deps) {
|
|
349
|
+
let members;
|
|
350
|
+
try {
|
|
351
|
+
members = listMembers();
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
refuse(`the Fleet supervisor catalog cannot be read (${String(error)})`);
|
|
355
|
+
}
|
|
356
|
+
for (const member of members)
|
|
357
|
+
if (member.taskOwner?.taskId === task.task_id)
|
|
358
|
+
refuse(`agent ${member.name} of this mission is still registered with the Fleet supervisor`);
|
|
359
|
+
let entries;
|
|
360
|
+
try {
|
|
361
|
+
entries = readdirSync(tmpRoot(), { withFileTypes: true });
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (error.code !== 'ENOENT')
|
|
365
|
+
refuse(`agent state cannot be read (${String(error)})`);
|
|
366
|
+
entries = [];
|
|
367
|
+
}
|
|
368
|
+
for (const entry of entries) {
|
|
369
|
+
const dir = join(tmpRoot(), entry.name);
|
|
370
|
+
if (entry.isSymbolicLink())
|
|
371
|
+
refuse(`agent state ${entry.name} is a symlink, so its use of this folder is unknown`);
|
|
372
|
+
if (!entry.isDirectory())
|
|
373
|
+
continue;
|
|
374
|
+
let role, owner, uses;
|
|
375
|
+
try {
|
|
376
|
+
role = readRole(dir);
|
|
377
|
+
// A supervisor record that exists but does not parse proves nothing about its owner.
|
|
378
|
+
const supervisor = readTempSupervisor(dir);
|
|
379
|
+
if (!supervisor)
|
|
380
|
+
throw Error(`${exists(join(dir, TEMP_SUPERVISOR_FILE)) ? 'invalid' : 'missing'} ${TEMP_SUPERVISOR_FILE}`);
|
|
381
|
+
owner = supervisor?.taskOwner;
|
|
382
|
+
const startup = role.roomMemberStartup;
|
|
383
|
+
if (owner?.taskId === task.task_id || startup?.task_id === task.task_id)
|
|
384
|
+
refuse(`agent ${entry.name} of this mission has not been retired`);
|
|
385
|
+
uses = (typeof role.cwd === 'string' && within(path, role.cwd))
|
|
386
|
+
|| (typeof startup?.workspace?.path === 'string' && within(path, startup.workspace.path));
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (error instanceof TaskStateError)
|
|
390
|
+
throw error;
|
|
391
|
+
refuse(`agent ${entry.name} has unreadable state, so its use of this folder is unknown (${String(error)})`);
|
|
392
|
+
}
|
|
393
|
+
if (uses)
|
|
394
|
+
refuse(`agent ${entry.name} works in this folder`);
|
|
395
|
+
}
|
|
396
|
+
// A permanent agent's state records the cwd it was last started with, whatever configuration started it.
|
|
397
|
+
let permanent;
|
|
398
|
+
try {
|
|
399
|
+
permanent = readdirSync(agentsRoot(), { withFileTypes: true });
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
if (error.code !== 'ENOENT')
|
|
403
|
+
refuse(`permanent agent state cannot be read (${String(error)})`);
|
|
404
|
+
permanent = [];
|
|
405
|
+
}
|
|
406
|
+
for (const entry of permanent) {
|
|
407
|
+
if (entry.isSymbolicLink())
|
|
408
|
+
refuse(`permanent agent state ${entry.name} is a symlink, so its use of this folder is unknown`);
|
|
409
|
+
if (!entry.isDirectory())
|
|
410
|
+
continue;
|
|
411
|
+
let uses;
|
|
412
|
+
try {
|
|
413
|
+
const file = join(agentsRoot(), entry.name, '.cwd');
|
|
414
|
+
if (!exists(file))
|
|
415
|
+
continue;
|
|
416
|
+
const stat = lstatSync(file);
|
|
417
|
+
if (!stat.isFile())
|
|
418
|
+
throw Error('unsafe .cwd');
|
|
419
|
+
const cwd = readFileSync(file, 'utf8').trim();
|
|
420
|
+
uses = !!cwd && within(path, cwd);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
refuse(`permanent agent ${entry.name} has unreadable state, so its use of this folder is unknown (${String(error)})`);
|
|
424
|
+
}
|
|
425
|
+
if (uses)
|
|
426
|
+
refuse(`permanent agent ${entry.name} works in this folder`);
|
|
427
|
+
}
|
|
428
|
+
if (!deps.permanentCwds)
|
|
429
|
+
refuse('the Fleet configuration cannot be read to check permanent agents');
|
|
430
|
+
for (const cwd of deps.permanentCwds) {
|
|
431
|
+
let uses;
|
|
432
|
+
try {
|
|
433
|
+
uses = !!cwd && within(path, cwd);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
refuse(`a permanent agent's working directory ${cwd} cannot be resolved (${String(error)})`);
|
|
437
|
+
}
|
|
438
|
+
if (uses)
|
|
439
|
+
refuse(`a permanent agent is configured to work in this folder (${cwd})`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** A layout task's rooms and agents must have finished closing. */
|
|
443
|
+
function assertLayoutClosed(task) {
|
|
444
|
+
if (!task.layout)
|
|
445
|
+
return;
|
|
446
|
+
const runId = task.layout.run_id;
|
|
447
|
+
if (!validLayoutKey(runId))
|
|
448
|
+
refuse('the mission layout run is invalid');
|
|
449
|
+
let state;
|
|
450
|
+
try {
|
|
451
|
+
state = JSON.parse(readFileSync(join(stateRoot(), 'layouts', `${runId}.json`), 'utf8'));
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
// A Done layout task started its run; missing state cannot prove its agents were retired.
|
|
455
|
+
refuse(`the mission layout state cannot be read, so its rooms and agents cannot be proven closed (${String(error)})`);
|
|
456
|
+
}
|
|
457
|
+
if (!state?.closed || state.closing || state.uncertain)
|
|
458
|
+
refuse('the mission rooms and agents are still being closed; retry after the close finishes');
|
|
459
|
+
for (const [key, participant] of Object.entries(state.participants ?? {}))
|
|
460
|
+
if (participant.owned && participant.instance && !participant.retired)
|
|
461
|
+
refuse(`mission agent ${key} has not been retired`);
|
|
462
|
+
}
|
|
463
|
+
export function assertTaskWorkspaceRemovable(task, deps) {
|
|
464
|
+
if (task.deletion)
|
|
465
|
+
refuse('the task is being deleted');
|
|
466
|
+
if (task.state !== 'done')
|
|
467
|
+
refuse(`only a Done task's working files can be removed; this task is ${task.state}`);
|
|
468
|
+
if (task.terminal_intent && task.terminal_intent.status !== 'settled')
|
|
469
|
+
refuse('completion of the task is still being settled');
|
|
470
|
+
if (!task.workspace)
|
|
471
|
+
refuse('this task has no Fleet-owned working folder');
|
|
472
|
+
try {
|
|
473
|
+
validateWorkspace(task.workspace, 'task', task.task_id);
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
refuse(`the recorded folder is not this task's Fleet-owned folder (${error.message})`);
|
|
477
|
+
}
|
|
478
|
+
assertNoRoomRecord(task);
|
|
479
|
+
assertLayoutClosed(task);
|
|
480
|
+
assertNoAgentUsesFolder(task, task.workspace.path, deps);
|
|
481
|
+
for (const artifact of task.outcome?.artifacts ?? [])
|
|
482
|
+
if (within(task.workspace.path, resolve(task.workspace.path, artifact)))
|
|
483
|
+
refuse(`the task outcome lists ${artifact}, which is inside the folder`);
|
|
484
|
+
}
|
|
485
|
+
/** Retained text that names the folder: shown to the Owner, never used as a link target. */
|
|
486
|
+
function folderMentions(task) {
|
|
487
|
+
const path = task.workspace?.path;
|
|
488
|
+
if (!path)
|
|
489
|
+
return [];
|
|
490
|
+
const fields = [
|
|
491
|
+
['description', task.brief], ['outcome', task.outcome?.summary], ['to_review', task.review?.markdown],
|
|
492
|
+
...(task.progress ?? []).map((entry) => [`progress:${entry.id}`, entry.text]),
|
|
493
|
+
];
|
|
494
|
+
return fields.filter(([, text]) => text?.includes(path)).map(([name]) => name);
|
|
495
|
+
}
|
|
496
|
+
function errorText(error) {
|
|
497
|
+
return error instanceof Error ? error.message : String(error);
|
|
498
|
+
}
|
|
499
|
+
export function removeTaskWorkspace(taskId, actor, deps = {}) {
|
|
500
|
+
return withFileLock(taskOperationLockPath(taskId), () => {
|
|
501
|
+
const task = getTask(taskId);
|
|
502
|
+
const previous = task.workspace_removal;
|
|
503
|
+
if (previous?.completed_at && task.workspace && !task.deletion) {
|
|
504
|
+
validateWorkspace(task.workspace, 'task', taskId);
|
|
505
|
+
if (!workspaceEntriesPresent(task.workspace))
|
|
506
|
+
return { task, result: 'already_removed', mentions: folderMentions(task) };
|
|
507
|
+
}
|
|
508
|
+
assertTaskWorkspaceRemovable(task, deps);
|
|
509
|
+
if (process.platform !== 'linux' && !deps.processesUsing)
|
|
510
|
+
refuse('this platform cannot verify that no process is using the folder');
|
|
511
|
+
const workspace = task.workspace;
|
|
512
|
+
// Only a first attempt that finds nothing on disk is "already missing";
|
|
513
|
+
// a retry after a partial removal is a completed removal.
|
|
514
|
+
const missing = !previous && !workspaceEntriesPresent(workspace);
|
|
515
|
+
const now = () => new Date().toISOString();
|
|
516
|
+
// Mark the intent first: a launch fenced after this point refuses the folder,
|
|
517
|
+
// and one fenced before it is visible to the scan below.
|
|
518
|
+
updateTaskWorkspaceRemoval(taskId, current => {
|
|
519
|
+
const { error: _error, error_at: _errorAt, completed_at: _completed, ...rest } = current ?? { started_at: now(), actor };
|
|
520
|
+
return rest;
|
|
521
|
+
});
|
|
522
|
+
try {
|
|
523
|
+
withSynchronousFileLock(taskFolderUseLockPath(taskId), () => assertNoAgentUsesFolder(task, workspace.path, deps));
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
updateTaskWorkspaceRemoval(taskId, () => previous);
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
deleteWorkspace(workspace, 'task', taskId, {
|
|
531
|
+
// A process that entered the folder before the rename keeps it as its cwd or open file;
|
|
532
|
+
// after the rename no new one can find it. Restore the folder rather than pull it away.
|
|
533
|
+
beforeRemove: (tomb, restore) => {
|
|
534
|
+
let use;
|
|
535
|
+
try {
|
|
536
|
+
use = (deps.processesUsing ?? processesUsing)(tomb, { original: workspace.path, createdAt: Date.parse(task.created_at) });
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
throw new InUse(`processes using the folder cannot be checked (${errorText(error)})`, restore);
|
|
540
|
+
}
|
|
541
|
+
if (use.users.length)
|
|
542
|
+
throw new InUse(`process ${use.users.join(', ')} is using the folder; stop it and retry`, restore);
|
|
543
|
+
// Use that can be neither proven nor excluded is refused, never assumed absent.
|
|
544
|
+
if (use.unverified.length)
|
|
545
|
+
throw new InUse(`${use.unverified.length} process(es) of this user cannot be checked for use of the folder: `
|
|
546
|
+
+ use.unverified.map(entry => entry.description).join('; '), restore);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (error instanceof InUse) {
|
|
552
|
+
let message = `cannot remove working files: ${error.message}`;
|
|
553
|
+
if (error.restore) {
|
|
554
|
+
// A freshly renamed, intact folder goes back: a refusal, not a failed removal.
|
|
555
|
+
error.restore();
|
|
556
|
+
updateTaskWorkspaceRemoval(taskId, () => previous);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// A partial tombstone keeps its token provenance so a later retry can resume it.
|
|
560
|
+
updateTaskWorkspaceRemoval(taskId, current => ({ ...current, error: error.message, error_at: now() }));
|
|
561
|
+
message += '; the partly removed folder is kept for retry';
|
|
562
|
+
}
|
|
563
|
+
throw new TaskStateError(message);
|
|
564
|
+
}
|
|
565
|
+
const message = errorText(error);
|
|
566
|
+
updateTaskWorkspaceRemoval(taskId, current => ({ ...current, error: message, error_at: now() }));
|
|
567
|
+
refuse(`${message}. Nothing outside the folder was touched; fix the cause and retry`);
|
|
568
|
+
}
|
|
569
|
+
const removed = updateTaskWorkspaceRemoval(taskId, current => ({
|
|
570
|
+
...current, completed_at: now(), ...(missing ? { already_missing: true } : {}),
|
|
571
|
+
}));
|
|
572
|
+
return { task: removed, result: missing ? 'already_missing' : 'removed', mentions: folderMentions(removed) };
|
|
573
|
+
}, {}, TASK_OPERATION_LOCK_STALE_MS);
|
|
574
|
+
}
|
|
575
|
+
/** Record-only readback for task payloads; never touches the filesystem. */
|
|
576
|
+
export function workingFilesSummary(task) {
|
|
577
|
+
const removal = task.workspace_removal;
|
|
578
|
+
if (!task.workspace)
|
|
579
|
+
return { state: 'none' };
|
|
580
|
+
if (!removal)
|
|
581
|
+
return { state: 'present' };
|
|
582
|
+
if (removal.completed_at)
|
|
583
|
+
return { state: 'removed', started_at: removal.started_at, removed_at: removal.completed_at };
|
|
584
|
+
if (removal.error)
|
|
585
|
+
return { state: 'failed', started_at: removal.started_at, error: removal.error };
|
|
586
|
+
return { state: 'removing', started_at: removal.started_at };
|
|
587
|
+
}
|
|
588
|
+
/** Serializes agent state creation in a task folder with the removal's in-use scan. */
|
|
589
|
+
function taskFolderUseLockPath(taskId) {
|
|
590
|
+
return join(stateRoot(), 'locks', 'task-folder-use', encodeURIComponent(taskId));
|
|
591
|
+
}
|
|
592
|
+
/** Task IDs whose folder `cwd` names, literally or through symlinks. */
|
|
593
|
+
function taskFoldersOf(cwd) {
|
|
594
|
+
const base = join(stateRoot(), 'workspaces', 'tasks');
|
|
595
|
+
const ids = new Set();
|
|
596
|
+
for (const path of [resolve(cwd), canonical(cwd)]) {
|
|
597
|
+
const rel = relative(base, path);
|
|
598
|
+
if (rel && contains(base, path))
|
|
599
|
+
ids.add(rel.split(sep)[0]);
|
|
600
|
+
}
|
|
601
|
+
return [...ids].filter(id => /^[a-zA-Z0-9_-]{1,80}$/.test(id)).sort();
|
|
602
|
+
}
|
|
603
|
+
export function assertCwdNotInRemovedTaskFolder(cwd) {
|
|
604
|
+
if (!cwd)
|
|
605
|
+
return;
|
|
606
|
+
for (const taskId of taskFoldersOf(cwd)) {
|
|
607
|
+
let task;
|
|
608
|
+
try {
|
|
609
|
+
task = getTask(taskId);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (task.workspace_removal)
|
|
615
|
+
throw new TaskStateError(`the working files of task ${taskId} were removed; choose another working directory`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Launch fence: an agent never starts in, and so never recreates, the folder
|
|
620
|
+
* of a task whose working files were removed. Creating its state under the
|
|
621
|
+
* folder's use lock makes it visible to a concurrent removal's scan.
|
|
622
|
+
*/
|
|
623
|
+
export function withTaskFolderUseFence(cwd, work) {
|
|
624
|
+
const ids = cwd ? taskFoldersOf(cwd) : [];
|
|
625
|
+
const fenced = (index) => {
|
|
626
|
+
if (index === ids.length) {
|
|
627
|
+
assertCwdNotInRemovedTaskFolder(cwd);
|
|
628
|
+
return work();
|
|
629
|
+
}
|
|
630
|
+
return withSynchronousFileLock(taskFolderUseLockPath(ids[index]), () => fenced(index + 1));
|
|
631
|
+
};
|
|
632
|
+
return fenced(0);
|
|
633
|
+
}
|
|
@@ -32,4 +32,12 @@ export declare function auditWorkspaceGit(path: string, options?: {
|
|
|
32
32
|
* resumes that exact tombstone even if rm already removed its marker.
|
|
33
33
|
*/
|
|
34
34
|
export declare function assertWorkspaceDeletable(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
|
|
35
|
-
|
|
35
|
+
/** True while the live folder, its deletion tombstone or a provisioning stage exists. */
|
|
36
|
+
export declare function workspaceEntriesPresent(w: OwnedWorkspace): boolean;
|
|
37
|
+
export declare function deleteWorkspace(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string, options?: {
|
|
38
|
+
/**
|
|
39
|
+
* Runs on the tombstone before removal. `restoreTombstone` is given only when this call
|
|
40
|
+
* renamed the intact, marker-proven folder; a resumed partial tombstone must stay put.
|
|
41
|
+
*/
|
|
42
|
+
beforeRemove?(tomb: string, restoreTombstone?: () => void): void;
|
|
43
|
+
}): void;
|
|
@@ -298,16 +298,23 @@ export function assertWorkspaceDeletable(w, owner, id) {
|
|
|
298
298
|
if (stat(staging(w, 'deleting')))
|
|
299
299
|
assertSafeAncestors(staging(w, 'deleting'));
|
|
300
300
|
}
|
|
301
|
-
|
|
301
|
+
/** True while the live folder, its deletion tombstone or a provisioning stage exists. */
|
|
302
|
+
export function workspaceEntriesPresent(w) {
|
|
303
|
+
validateWorkspace(w);
|
|
304
|
+
return [w.path, staging(w, 'deleting'), staging(w, 'provisioning')].some(path => stat(path) !== undefined);
|
|
305
|
+
}
|
|
306
|
+
export function deleteWorkspace(w, owner, id, options = {}) {
|
|
302
307
|
assertWorkspaceDeletable(w, owner, id);
|
|
303
308
|
const tomb = staging(w, 'deleting');
|
|
304
309
|
const pending = staging(w, 'provisioning');
|
|
310
|
+
let renamed = false;
|
|
305
311
|
if (stat(w.path)) {
|
|
306
312
|
if (stat(tomb))
|
|
307
313
|
fail('both live and deleting workspace exist');
|
|
308
314
|
markedForDeletion(w);
|
|
309
315
|
auditWorkspaceGit(w.path, { forDeletion: true });
|
|
310
316
|
renameSync(w.path, tomb);
|
|
317
|
+
renamed = true;
|
|
311
318
|
}
|
|
312
319
|
if (stat(pending)) {
|
|
313
320
|
if (readdirSync(pending).length)
|
|
@@ -317,6 +324,11 @@ export function deleteWorkspace(w, owner, id) {
|
|
|
317
324
|
rmSync(pending, { recursive: true });
|
|
318
325
|
}
|
|
319
326
|
if (stat(tomb)) {
|
|
327
|
+
options.beforeRemove?.(tomb, renamed ? () => {
|
|
328
|
+
if (stat(w.path))
|
|
329
|
+
fail('both live and deleting workspace exist');
|
|
330
|
+
renameSync(tomb, w.path);
|
|
331
|
+
} : undefined);
|
|
320
332
|
// This exact token-derived tombstone has already passed the marker and
|
|
321
333
|
// Git audit; partial removal may have consumed those proofs on disk.
|
|
322
334
|
prepareOwnedDeletionTree(tomb);
|