@ours.network/fleet 1.2.0-nightly.13 → 1.2.0-nightly.14
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 +3 -0
- package/dist/application/task-room-service.js +1 -1
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/build-info.json +4 -4
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +1 -0
- package/dist/fleet-command-audit.js +9 -5
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/rooms-tasks/close.d.ts +2 -2
- package/dist/rooms-tasks/close.js +128 -22
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +41 -9
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/room-state.d.ts +3 -1
- package/dist/rooms-tasks/room-state.js +37 -7
- package/dist/rooms-tasks/task-state.d.ts +10 -4
- package/dist/rooms-tasks/task-state.js +54 -12
- package/dist/rooms-tasks/types.d.ts +2 -0
- package/dist/temp-lifecycle.d.ts +5 -0
- package/dist/temp-lifecycle.js +28 -12
- package/docs/task-workspaces.md +34 -2
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { eraseMemberArtifacts } from './erasure.js';
|
|
1
2
|
import { proveArchivedAbsence, verifyArchivedAbsence, verifyArchivedMemberStillAbsent } from './archived-absence.js';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { withFileLock } from '../atomic-file.js';
|
|
@@ -5,12 +6,12 @@ import { agentDir } from '../paths.js';
|
|
|
5
6
|
import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
|
|
6
7
|
import { collectWorkspaceArchives } from './workspace-artifacts.js';
|
|
7
8
|
import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
|
|
8
|
-
import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
|
|
9
|
+
import { closeManagedRoom, identityCidPresent, inspectMember, assertMemberIdentityAbsent, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
|
|
9
10
|
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
10
11
|
import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
|
|
11
12
|
import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
|
|
12
13
|
import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
|
|
13
|
-
import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError,
|
|
14
|
+
import { advanceTaskDeletionMember, settleAbsentTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, beginTaskArchiveCleanup, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
|
|
14
15
|
export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
15
16
|
import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
|
|
16
17
|
/**
|
|
@@ -43,6 +44,7 @@ function tolerantCowork(cowork) {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
return {
|
|
47
|
+
...(cowork.getRoom ? { getRoom: cowork.getRoom.bind(cowork) } : {}),
|
|
46
48
|
closeRoom: roomId => tolerate(() => cowork.closeRoom(roomId)),
|
|
47
49
|
deleteRoom: roomId => tolerate(() => cowork.deleteRoom(roomId)),
|
|
48
50
|
};
|
|
@@ -51,6 +53,7 @@ function cursorSeat(cursor) {
|
|
|
51
53
|
return {
|
|
52
54
|
role_name: cursor.name, identity_cid: cursor.identity_cid,
|
|
53
55
|
slot: cursor.name, cowork_role: 'member', seat_state: 'active',
|
|
56
|
+
...(cursor.launch_id ? { retirement: { phase: 'identity_absent', launch_id: cursor.launch_id, archive_path: cursor.archive_path, updated_at: cursor.updated_at } } : {}),
|
|
54
57
|
};
|
|
55
58
|
}
|
|
56
59
|
/**
|
|
@@ -63,8 +66,15 @@ function cursorSeat(cursor) {
|
|
|
63
66
|
async function retireCursorMember(taskId, cursor, deps) {
|
|
64
67
|
let phase = cursor.phase;
|
|
65
68
|
let launchId = cursor.launch_id;
|
|
66
|
-
if (phase === 'identity_absent')
|
|
69
|
+
if (phase === 'identity_absent') {
|
|
70
|
+
if (!deps.roomClose?.inspectMember) {
|
|
71
|
+
if ((deps.hasTempState ?? (name => existsSync(agentDir(name, true))))(cursor.name))
|
|
72
|
+
throw new Error('Retired deletion member has replacement live state');
|
|
73
|
+
if (await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid))
|
|
74
|
+
throw new Error('Retired deletion member identity reappeared');
|
|
75
|
+
}
|
|
67
76
|
return;
|
|
77
|
+
}
|
|
68
78
|
const seat = cursorSeat(cursor);
|
|
69
79
|
const roomClose = deps.roomClose ?? {};
|
|
70
80
|
if (phase === 'pending') {
|
|
@@ -88,7 +98,16 @@ async function retireCursorMember(taskId, cursor, deps) {
|
|
|
88
98
|
phase = 'stop_requested';
|
|
89
99
|
launchId = ownership.launchId;
|
|
90
100
|
}
|
|
101
|
+
if (!roomClose.inspectMember && !existsSync(agentDir(cursor.name, true))) {
|
|
102
|
+
await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
|
|
103
|
+
if (existsSync(agentDir(cursor.name, true)))
|
|
104
|
+
throw new Error('Deletion member acquired replacement state');
|
|
105
|
+
settleAbsentTaskDeletionMember(taskId, cursor.name);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
91
108
|
if (phase === 'stop_requested') {
|
|
109
|
+
if (!roomClose.inspectMember && (await inspectMember(seat)).launchId !== launchId)
|
|
110
|
+
throw new Error('Deletion member launch changed before stop');
|
|
92
111
|
await (roomClose.requestStop
|
|
93
112
|
?? (async (role) => { await stopTempSupervisor(role); }))(cursor.name);
|
|
94
113
|
await (roomClose.waitForLivenessAbsent ?? waitForLivenessAbsent)(cursor.name, launchId);
|
|
@@ -140,8 +159,6 @@ export async function settleTaskDeletion(input) {
|
|
|
140
159
|
// aborts settlement (fail closed).
|
|
141
160
|
ensureTaskDeletionReceipt(taskId);
|
|
142
161
|
const records = listRoomRecords().filter(room => room.task_id === taskId);
|
|
143
|
-
if (task.deletion.workspace_cleanup_started_at && records.length)
|
|
144
|
-
throw new TaskStateError(`task ${taskId} room records reappeared after workspace cleanup began`);
|
|
145
162
|
const recordIds = new Set(records.map(room => room.room_id));
|
|
146
163
|
const recordedRoomId = task.deletion.room_id ?? task.room_id;
|
|
147
164
|
const needsCowork = records.length > 0 || recordedRoomId !== undefined;
|
|
@@ -153,8 +170,15 @@ export async function settleTaskDeletion(input) {
|
|
|
153
170
|
const seats = closed?.member_seats ?? record.member_seats;
|
|
154
171
|
const proofs = [];
|
|
155
172
|
for (const seat of seats) {
|
|
156
|
-
if (!seat.identity_cid && seat.retirement?.
|
|
157
|
-
|
|
173
|
+
if (!seat.identity_cid && !seat.retirement?.absence_verified && !['never-launched', 'absent-verified'].includes(seat.retirement?.launch_id ?? '')) {
|
|
174
|
+
const prior = task.deletion.archived_absences?.find(proof => proof.name === seat.role_name);
|
|
175
|
+
if (task.deletion.workspace_cleanup_started_at && prior && !existsSync(prior.archive_path)) {
|
|
176
|
+
await verifyArchivedMemberStillAbsent(prior);
|
|
177
|
+
proofs.push(prior);
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
proofs.push(await proveArchivedAbsence(seat));
|
|
181
|
+
}
|
|
158
182
|
}
|
|
159
183
|
// No await between the last live-state fence and durable checkpoint.
|
|
160
184
|
for (const proof of proofs) {
|
|
@@ -166,7 +190,9 @@ export async function settleTaskDeletion(input) {
|
|
|
166
190
|
importTaskDeletionRetirementEvidence(taskId, seats, proofs);
|
|
167
191
|
await cowork.deleteRoom(record.room_id);
|
|
168
192
|
for (const proof of proofs)
|
|
169
|
-
await verifyArchivedAbsence(proof);
|
|
193
|
+
await (task.deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
|
|
194
|
+
beginTaskArchiveCleanup(taskId);
|
|
195
|
+
await eraseMemberArtifacts('room', record.room_id, seats, [record.room_id]);
|
|
170
196
|
for (const proof of proofs)
|
|
171
197
|
if (existsSync(agentDir(proof.name, true)))
|
|
172
198
|
throw new Error('Archived member replacement before room unlink');
|
|
@@ -216,11 +242,17 @@ export async function settleTaskDeletion(input) {
|
|
|
216
242
|
for (const proof of task.deletion.archived_absences ?? [])
|
|
217
243
|
if (existsSync(agentDir(proof.name, true)))
|
|
218
244
|
throw new Error('Archived member replacement before task unlink');
|
|
245
|
+
for (const name of task.deletion.absent_members ?? []) {
|
|
246
|
+
if (existsSync(agentDir(name, true)))
|
|
247
|
+
throw new Error('Absent member acquired replacement state');
|
|
248
|
+
await assertMemberIdentityAbsent({ role_name: name, slot: name, cowork_role: 'member', seat_state: 'removed' });
|
|
249
|
+
}
|
|
219
250
|
if (cleanup.snapshotHash)
|
|
220
251
|
releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
|
|
252
|
+
await eraseMemberArtifacts('task', taskId, task.deletion.members.map(cursorSeat), task.deletion.room_id ? [task.deletion.room_id] : []);
|
|
221
253
|
if (task.workspace) {
|
|
222
254
|
assertWorkspaceDeletable(task.workspace, 'task', taskId);
|
|
223
|
-
|
|
255
|
+
beginTaskArchiveCleanup(taskId);
|
|
224
256
|
collectWorkspaceArchives(task.workspace);
|
|
225
257
|
deleteWorkspace(task.workspace, 'task', taskId);
|
|
226
258
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RoomMemberSeat } from './types.js';
|
|
2
|
+
/** Erase only proven launch artifacts. Each atomic rename transfers ownership to
|
|
3
|
+
* a random, durable tombstone before recursive removal can destroy its proof.
|
|
4
|
+
* The caller holds the task/room lifecycle lock and has retired all writers.
|
|
5
|
+
*/
|
|
6
|
+
export declare function eraseMemberArtifacts(owner: 'room' | 'task', id: string, seats: readonly RoomMemberSeat[], roomIds: readonly string[]): Promise<void>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { eraseResourcePresentations } from '../erased-resources.js';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { dirname, join, relative } from 'node:path';
|
|
5
|
+
import { parse } from 'yaml';
|
|
6
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
7
|
+
import { agentDir, stateRoot } from '../paths.js';
|
|
8
|
+
import { readTempSupervisor, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
|
|
9
|
+
import { assertSafeAncestors, auditWorkspaceGit } from './workspace.js';
|
|
10
|
+
function entries(path) {
|
|
11
|
+
try {
|
|
12
|
+
return readdirSync(path);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT')
|
|
16
|
+
return [];
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function json(path) {
|
|
21
|
+
try {
|
|
22
|
+
if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())
|
|
23
|
+
return undefined;
|
|
24
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code === 'ENOENT')
|
|
28
|
+
return undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function fingerprint(path) {
|
|
33
|
+
const stat = lstatSync(path);
|
|
34
|
+
const files = stat.isDirectory() ? ['creation.json', '.temp-supervisor.json', '.identity', 'role.yaml', 'state.json', 'instance.json'] : [''];
|
|
35
|
+
const hash = createHash('sha256');
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const target = file ? join(path, file) : path;
|
|
38
|
+
if (existsSync(target)) {
|
|
39
|
+
if (!lstatSync(target).isFile() || lstatSync(target).isSymbolicLink())
|
|
40
|
+
throw new Error('Unsafe erasure proof');
|
|
41
|
+
hash.update(file).update(readFileSync(target));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return hash.digest('hex');
|
|
45
|
+
}
|
|
46
|
+
/** Erase only proven launch artifacts. Each atomic rename transfers ownership to
|
|
47
|
+
* a random, durable tombstone before recursive removal can destroy its proof.
|
|
48
|
+
* The caller holds the task/room lifecycle lock and has retired all writers.
|
|
49
|
+
*/
|
|
50
|
+
export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
|
|
51
|
+
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id))
|
|
52
|
+
throw new Error('Invalid erasure owner');
|
|
53
|
+
const root = stateRoot();
|
|
54
|
+
const manifestPath = join(root, 'erasure', `${owner}-${id}.json`);
|
|
55
|
+
assertSafeAncestors(dirname(manifestPath));
|
|
56
|
+
let manifest = json(manifestPath);
|
|
57
|
+
const names = new Set(seats.map(s => s.role_name));
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
if (!/^[a-zA-Z0-9_-]{1,120}$/.test(name))
|
|
60
|
+
throw new Error('Invalid erasure member');
|
|
61
|
+
if (existsSync(agentDir(name, true)))
|
|
62
|
+
throw new Error(`Member '${name}' still has live state during erasure`);
|
|
63
|
+
}
|
|
64
|
+
if (!manifest) {
|
|
65
|
+
const paths = [];
|
|
66
|
+
const launches = seats.flatMap(seat => {
|
|
67
|
+
const launchId = seat.launch?.launch_id ?? seat.retirement?.launch_id;
|
|
68
|
+
return launchId ? [{ role: seat.role_name, launchId }] : [];
|
|
69
|
+
});
|
|
70
|
+
const actions = new Map(seats.map(s => [s.role_name, new Set([s.launch?.action_id].filter((v) => !!v))]));
|
|
71
|
+
const recovery = join(root, 'recovery', 'temporary');
|
|
72
|
+
assertSafeAncestors(recovery);
|
|
73
|
+
for (const entry of entries(recovery)) {
|
|
74
|
+
const path = join(recovery, entry);
|
|
75
|
+
const stat = lstatSync(path);
|
|
76
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
77
|
+
continue;
|
|
78
|
+
const supervisor = readTempSupervisor(path);
|
|
79
|
+
if (!supervisor || !names.has(supervisor.role))
|
|
80
|
+
continue;
|
|
81
|
+
const seat = seats.find(s => s.role_name === supervisor.role);
|
|
82
|
+
const creation = json(join(path, 'creation.json'));
|
|
83
|
+
let room;
|
|
84
|
+
const roleFile = join(path, 'role.yaml');
|
|
85
|
+
if (existsSync(roleFile) && lstatSync(roleFile).isFile() && !lstatSync(roleFile).isSymbolicLink()) {
|
|
86
|
+
const role = parse(readFileSync(roleFile, 'utf8'));
|
|
87
|
+
if (role?.name === supervisor.role)
|
|
88
|
+
room = role.roomMemberStartup?.room_id;
|
|
89
|
+
}
|
|
90
|
+
const exactLaunch = seat.launch?.launch_id === supervisor.launchId || seat.retirement?.launch_id === supervisor.launchId;
|
|
91
|
+
const exactAction = creation?.role === supervisor.role && creation.creationActionId === seat.launch?.action_id;
|
|
92
|
+
// Older attempts belong only when the launch descriptor pins the room.
|
|
93
|
+
if (!(exactLaunch && (exactAction || !seat.launch?.action_id)) && !(room && roomIds.includes(room)))
|
|
94
|
+
continue;
|
|
95
|
+
if (existsSync(join(path, '.identity')) && readFileSync(join(path, '.identity'), 'utf8').trim() !== supervisor.role)
|
|
96
|
+
throw new Error('Archive identity mismatch during erasure');
|
|
97
|
+
if (await tempSupervisorLiveness(path) !== 'stopped')
|
|
98
|
+
throw new Error('Archive writer is not stopped');
|
|
99
|
+
if (creation?.role === supervisor.role && typeof creation.creationActionId === 'string')
|
|
100
|
+
actions.get(supervisor.role).add(creation.creationActionId);
|
|
101
|
+
auditWorkspaceGit(path);
|
|
102
|
+
launches.push({ role: supervisor.role, launchId: supervisor.launchId });
|
|
103
|
+
paths.push(path);
|
|
104
|
+
}
|
|
105
|
+
const privateRoot = join(root, 'private-ours');
|
|
106
|
+
assertSafeAncestors(privateRoot);
|
|
107
|
+
for (const entry of entries(privateRoot)) {
|
|
108
|
+
const path = join(privateRoot, entry);
|
|
109
|
+
if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink())
|
|
110
|
+
continue;
|
|
111
|
+
if (entry === 'launches' || entry === 'room-inputs') {
|
|
112
|
+
for (const file of entries(path)) {
|
|
113
|
+
const child = join(path, file), value = json(child);
|
|
114
|
+
if (!value)
|
|
115
|
+
continue;
|
|
116
|
+
const launchOwned = entry === 'launches' && value.role === value.identity
|
|
117
|
+
&& actions.get(value.role)?.has(value.action);
|
|
118
|
+
const roomOwned = entry === 'room-inputs' && roomIds.includes(value.room_id)
|
|
119
|
+
&& names.has(value.identity_name);
|
|
120
|
+
const readyOwned = entry === 'room-inputs' && file.endsWith('.ready.json')
|
|
121
|
+
&& roomIds.includes(value.room) && seats.some(seat => seat.invite_id === value.invite && (!seat.identity_cid || seat.identity_cid === value.cid));
|
|
122
|
+
if (launchOwned || roomOwned || readyOwned)
|
|
123
|
+
paths.push(child);
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const state = json(join(path, 'state.json')), instance = json(join(path, 'instance.json'));
|
|
128
|
+
if (state?.lifetime === 'temporary' && actions.get(state.name)?.has(state.action)
|
|
129
|
+
&& instance?.role === state.name && instance?.temporary === true && instance?.instance === state.instance) {
|
|
130
|
+
auditWorkspaceGit(path);
|
|
131
|
+
paths.push(path);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
manifest = { launches, version: 1, token: randomUUID(), paths, phases: {}, stamps: Object.fromEntries(paths.map(path => {
|
|
135
|
+
const stat = lstatSync(path);
|
|
136
|
+
return [path, { dev: stat.dev, ino: stat.ino, proof: fingerprint(path) }];
|
|
137
|
+
})) };
|
|
138
|
+
mkdirSync(dirname(manifestPath), { recursive: true, mode: 0o700 });
|
|
139
|
+
replaceFileAtomically(manifestPath, JSON.stringify(manifest));
|
|
140
|
+
}
|
|
141
|
+
if (manifest.version !== 1 || !/^[a-f0-9-]{36}$/.test(manifest.token) || !Array.isArray(manifest.paths))
|
|
142
|
+
throw new Error('Invalid erasure manifest');
|
|
143
|
+
for (const path of manifest.paths) {
|
|
144
|
+
const rel = relative(root, path);
|
|
145
|
+
if (!rel.startsWith('recovery/temporary/') && !rel.startsWith('private-ours/'))
|
|
146
|
+
throw new Error('Invalid erasure artifact path');
|
|
147
|
+
if (rel.split('/').includes('..'))
|
|
148
|
+
throw new Error('Invalid erasure artifact traversal');
|
|
149
|
+
assertSafeAncestors(dirname(path));
|
|
150
|
+
const tombstone = `${path}.erasing-${manifest.token}`;
|
|
151
|
+
if (manifest.phases[path] === 'removed')
|
|
152
|
+
continue;
|
|
153
|
+
if (existsSync(path) && !existsSync(tombstone) && manifest.phases[path] !== 'renamed') {
|
|
154
|
+
const stat = lstatSync(path), stamp = manifest.stamps[path];
|
|
155
|
+
if (stat.isSymbolicLink() || !stamp || stamp.dev !== stat.dev || stamp.ino !== stat.ino || stamp.proof !== fingerprint(path))
|
|
156
|
+
throw new Error('Erasure source ownership changed');
|
|
157
|
+
renameSync(path, tombstone);
|
|
158
|
+
}
|
|
159
|
+
manifest.phases[path] = 'renamed';
|
|
160
|
+
replaceFileAtomically(manifestPath, JSON.stringify(manifest));
|
|
161
|
+
rmSync(tombstone, { recursive: true, force: true });
|
|
162
|
+
manifest.phases[path] = 'removed';
|
|
163
|
+
replaceFileAtomically(manifestPath, JSON.stringify(manifest));
|
|
164
|
+
}
|
|
165
|
+
eraseResourcePresentations([{ kind: owner, id }, ...roomIds.map(id => ({ kind: 'room', id })),
|
|
166
|
+
...seats.map(seat => ({ kind: 'agent', id: seat.role_name }))]);
|
|
167
|
+
eraseTerminationEvents(manifest.launches);
|
|
168
|
+
rmSync(manifestPath, { force: true });
|
|
169
|
+
}
|
|
@@ -31,6 +31,8 @@ export declare function updateMemberStartup(id: string, roleName: string, update
|
|
|
31
31
|
export declare function activateRoom(id: string): RoomOrchestrationRecord;
|
|
32
32
|
export declare function closeRoom(id: string): RoomOrchestrationRecord;
|
|
33
33
|
export declare function beginRoomClose(id: string): RoomOrchestrationRecord;
|
|
34
|
-
export declare function advanceMemberRetirement(id: string, roleName: string, phase: MemberRetirementPhase, launchId: string, archivePath?: string): RoomOrchestrationRecord;
|
|
34
|
+
export declare function advanceMemberRetirement(id: string, roleName: string, phase: MemberRetirementPhase, launchId: string, archivePath?: string, absenceVerified?: boolean): RoomOrchestrationRecord;
|
|
35
35
|
export declare function advanceRoomClose(id: string, phase: RoomClosePhase): RoomOrchestrationRecord;
|
|
36
36
|
export declare function setRoomCloseError(id: string, error: string, recoveryHint: string): RoomOrchestrationRecord;
|
|
37
|
+
/** Settlement-only binding recovery; never publishes a seat or changes a launch. */
|
|
38
|
+
export declare function recordRetirementMemberCids(id: string, recovered: ReadonlyArray<RoomMemberSeat>): RoomOrchestrationRecord;
|
|
@@ -7,14 +7,23 @@ import { getTask } from './task-state.js';
|
|
|
7
7
|
import { storedRoomLaunchPolicy } from './types.js';
|
|
8
8
|
import { releaseLaunchSnapshot } from './launch-snapshot.js';
|
|
9
9
|
export const roomsDir = () => join(stateRoot(), 'rooms');
|
|
10
|
-
function roomPath(id) {
|
|
10
|
+
function roomPath(id) {
|
|
11
|
+
if (!/^[a-zA-Z0-9_-]{1,120}$/.test(id))
|
|
12
|
+
throw new RoomStateError('Invalid room ID');
|
|
13
|
+
return join(roomsDir(), `${id}.json`);
|
|
14
|
+
}
|
|
11
15
|
export class RoomStateError extends Error {
|
|
12
16
|
}
|
|
13
17
|
function readRoom(id) {
|
|
14
18
|
const p = roomPath(id);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT')
|
|
24
|
+
throw new RoomStateError(`room not found: ${id}`);
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
18
27
|
}
|
|
19
28
|
function writeRoom(record) {
|
|
20
29
|
mkdirSync(roomsDir(), { recursive: true });
|
|
@@ -54,8 +63,10 @@ export function getRoomRecord(id) {
|
|
|
54
63
|
try {
|
|
55
64
|
return readRoom(id);
|
|
56
65
|
}
|
|
57
|
-
catch {
|
|
58
|
-
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof RoomStateError && error.message === `room not found: ${id}`)
|
|
68
|
+
return undefined;
|
|
69
|
+
throw error;
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
/** Remove a terminal orchestration record from the live room inventory. */
|
|
@@ -211,7 +222,7 @@ export function beginRoomClose(id) {
|
|
|
211
222
|
const RETIREMENT_ORDER = [
|
|
212
223
|
'stop_requested', 'liveness_absent', 'archive_secured', 'identity_absent',
|
|
213
224
|
];
|
|
214
|
-
export function advanceMemberRetirement(id, roleName, phase, launchId, archivePath) {
|
|
225
|
+
export function advanceMemberRetirement(id, roleName, phase, launchId, archivePath, absenceVerified = false) {
|
|
215
226
|
const r = readRoom(id);
|
|
216
227
|
if (r.state !== 'closing')
|
|
217
228
|
throw new RoomStateError(`room ${id} is not closing`);
|
|
@@ -228,6 +239,7 @@ export function advanceMemberRetirement(id, roleName, phase, launchId, archivePa
|
|
|
228
239
|
throw new RoomStateError(`room ${id} member ${roleName} retirement cannot move backward to ${phase}`);
|
|
229
240
|
}
|
|
230
241
|
seat.retirement = {
|
|
242
|
+
...(absenceVerified || previous?.absence_verified ? { absence_verified: true } : {}),
|
|
231
243
|
phase,
|
|
232
244
|
launch_id: launchId,
|
|
233
245
|
updated_at: new Date().toISOString(),
|
|
@@ -280,3 +292,21 @@ export function setRoomCloseError(id, error, recoveryHint) {
|
|
|
280
292
|
writeRoom(r);
|
|
281
293
|
return r;
|
|
282
294
|
}
|
|
295
|
+
/** Settlement-only binding recovery; never publishes a seat or changes a launch. */
|
|
296
|
+
export function recordRetirementMemberCids(id, recovered) {
|
|
297
|
+
const room = readRoom(id);
|
|
298
|
+
if (room.state !== 'closing')
|
|
299
|
+
throw new RoomStateError('Room is not closing');
|
|
300
|
+
for (const proof of recovered) {
|
|
301
|
+
const seat = room.member_seats.find(value => value.role_name === proof.role_name);
|
|
302
|
+
if (!seat || seat.launch?.action_id !== proof.launch?.action_id || seat.invite_id !== proof.invite_id)
|
|
303
|
+
throw new RoomStateError('Member changed during identity recovery');
|
|
304
|
+
if (!proof.identity_cid)
|
|
305
|
+
continue;
|
|
306
|
+
if (seat.identity_cid && seat.identity_cid.toLowerCase() !== proof.identity_cid.toLowerCase())
|
|
307
|
+
throw new RoomStateError('Member identity changed during recovery');
|
|
308
|
+
seat.identity_cid = proof.identity_cid;
|
|
309
|
+
}
|
|
310
|
+
writeRoom(room);
|
|
311
|
+
return room;
|
|
312
|
+
}
|
|
@@ -62,9 +62,8 @@ export declare function getDeletingTask(id: string): TaskRecord;
|
|
|
62
62
|
export declare function taskDeletionState(id: string): 'none' | 'pending' | 'absent';
|
|
63
63
|
/**
|
|
64
64
|
* Durable, surface-independent audit evidence for a permanent deletion. The
|
|
65
|
-
* receipt
|
|
66
|
-
*
|
|
67
|
-
* room content.
|
|
65
|
+
* receipt supports in-flight recovery only: written with acceptance intent,
|
|
66
|
+
* then erased before settlement is reported. It may contain a bounded title.
|
|
68
67
|
*/
|
|
69
68
|
export interface TaskDeletionReceipt {
|
|
70
69
|
schema_version: 1;
|
|
@@ -88,7 +87,7 @@ export declare function readTaskDeletionReceipt(id: string): TaskDeletionReceipt
|
|
|
88
87
|
* settlement rather than deleting resources without audit evidence.
|
|
89
88
|
*/
|
|
90
89
|
export declare function ensureTaskDeletionReceipt(id: string): void;
|
|
91
|
-
/**
|
|
90
|
+
/** Erase the in-flight receipt; also heals a crash after task unlink. */
|
|
92
91
|
export declare function completeTaskDeletionReceipt(id: string): void;
|
|
93
92
|
export type TaskDeletionAcceptance = {
|
|
94
93
|
status: 'accepted' | 'pending';
|
|
@@ -129,6 +128,7 @@ interface SeatEvidence {
|
|
|
129
128
|
action_id?: string;
|
|
130
129
|
};
|
|
131
130
|
retirement?: {
|
|
131
|
+
absence_verified?: boolean;
|
|
132
132
|
phase: TaskDeletionMemberPhase;
|
|
133
133
|
launch_id: string;
|
|
134
134
|
archive_path?: string;
|
|
@@ -166,4 +166,10 @@ export declare function beginTaskWorkspaceCleanup(id: string): void;
|
|
|
166
166
|
* records are the idempotent already-settled outcome.
|
|
167
167
|
*/
|
|
168
168
|
export declare function unlinkDeletedTask(id: string): boolean;
|
|
169
|
+
/** Called only after current temp and exact identity absence have been checked. */
|
|
170
|
+
export declare function settleAbsentTaskDeletionMember(id: string, name: string): void;
|
|
171
|
+
/** Room-only erasure keeps the parent Task but removes its dead room/member links. */
|
|
172
|
+
export declare function detachDeletedRoom(taskId: string, roomId: string): void;
|
|
173
|
+
/** A completed room may consume its archives while other task members still retire. */
|
|
174
|
+
export declare function beginTaskArchiveCleanup(id: string): void;
|
|
169
175
|
export {};
|
|
@@ -535,14 +535,10 @@ export function ensureTaskDeletionReceipt(id) {
|
|
|
535
535
|
writeDeletionReceiptForIntent(stored);
|
|
536
536
|
});
|
|
537
537
|
}
|
|
538
|
-
/**
|
|
538
|
+
/** Erase the in-flight receipt; also heals a crash after task unlink. */
|
|
539
539
|
export function completeTaskDeletionReceipt(id) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
return;
|
|
543
|
-
receipt.settled_at = new Date().toISOString();
|
|
544
|
-
receipt.result = 'deleted';
|
|
545
|
-
replaceFileAtomically(deletionReceiptPath(id), JSON.stringify(receipt, null, 2) + '\n');
|
|
540
|
+
assertCanonicalTaskId(id);
|
|
541
|
+
rmSync(deletionReceiptPath(id), { force: true });
|
|
546
542
|
}
|
|
547
543
|
/**
|
|
548
544
|
* Persist the first-wins durable deletion intent. Accepts every lifecycle
|
|
@@ -561,8 +557,10 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
561
557
|
stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
562
558
|
}
|
|
563
559
|
catch (error) {
|
|
564
|
-
if (isNotFoundError(error))
|
|
560
|
+
if (isNotFoundError(error)) {
|
|
561
|
+
completeTaskDeletionReceipt(id);
|
|
565
562
|
return { status: 'already_absent' };
|
|
563
|
+
}
|
|
566
564
|
throw error;
|
|
567
565
|
}
|
|
568
566
|
if (stored.deletion?.status === 'pending') {
|
|
@@ -712,12 +710,17 @@ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
|
|
|
712
710
|
const evidence = seat.retirement;
|
|
713
711
|
if (evidence?.phase !== 'identity_absent')
|
|
714
712
|
throw new TaskStateError(`task ${id} deletion cannot checkpoint member '${seat.role_name}' before completed retirement`);
|
|
715
|
-
const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED
|
|
713
|
+
const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED
|
|
714
|
+
|| evidence.launch_id === DELETION_MEMBER_ABSENT_VERIFIED || evidence.absence_verified === true;
|
|
716
715
|
if (!neverLaunched && evidence.archive_path === undefined)
|
|
717
716
|
throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' retirement evidence lacks its archive`);
|
|
718
717
|
if (!seat.identity_cid) {
|
|
719
|
-
if (neverLaunched)
|
|
720
|
-
|
|
718
|
+
if (neverLaunched) {
|
|
719
|
+
stored.deletion.absent_members ??= [];
|
|
720
|
+
if (!stored.deletion.absent_members.includes(seat.role_name))
|
|
721
|
+
stored.deletion.absent_members.push(seat.role_name);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
721
724
|
const proof = proofs.find(value => value.name === seat.role_name);
|
|
722
725
|
if (proof && proof.launch_id === evidence.launch_id && proof.archive_path === evidence.archive_path
|
|
723
726
|
&& proof.launch_id === seat.launch?.launch_id && proof.action_id === seat.launch?.action_id) {
|
|
@@ -761,7 +764,7 @@ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
|
|
|
761
764
|
export function beginTaskWorkspaceCleanup(id) {
|
|
762
765
|
withTaskLock(id, () => {
|
|
763
766
|
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
764
|
-
if (stored.deletion?.status !== 'pending'
|
|
767
|
+
if (stored.deletion?.status !== 'pending'
|
|
765
768
|
|| stored.deletion.members.some(member => member.phase !== 'identity_absent'))
|
|
766
769
|
throw new TaskStateError('Task is not ready for workspace cleanup');
|
|
767
770
|
stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
|
|
@@ -798,3 +801,42 @@ export function unlinkDeletedTask(id) {
|
|
|
798
801
|
return true;
|
|
799
802
|
});
|
|
800
803
|
}
|
|
804
|
+
/** Called only after current temp and exact identity absence have been checked. */
|
|
805
|
+
export function settleAbsentTaskDeletionMember(id, name) {
|
|
806
|
+
withTaskLock(id, () => {
|
|
807
|
+
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
808
|
+
if (stored.deletion?.status !== 'pending')
|
|
809
|
+
throw new TaskStateError('Task is not deleting');
|
|
810
|
+
const member = stored.deletion.members.find(value => value.name === name);
|
|
811
|
+
if (!member)
|
|
812
|
+
throw new TaskStateError('Unknown deletion member');
|
|
813
|
+
member.phase = 'identity_absent';
|
|
814
|
+
member.launch_id ??= DELETION_MEMBER_ABSENT_VERIFIED;
|
|
815
|
+
member.updated_at = new Date().toISOString();
|
|
816
|
+
writeTask(stored);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/** Room-only erasure keeps the parent Task but removes its dead room/member links. */
|
|
820
|
+
export function detachDeletedRoom(taskId, roomId) {
|
|
821
|
+
withTaskLock(taskId, () => {
|
|
822
|
+
if (!existsSync(taskPath(taskId)))
|
|
823
|
+
return;
|
|
824
|
+
const stored = JSON.parse(readFileSync(taskPath(taskId), 'utf8'));
|
|
825
|
+
if (stored.room_id !== roomId || stored.deletion?.status === 'pending')
|
|
826
|
+
return;
|
|
827
|
+
delete stored.room_id;
|
|
828
|
+
delete stored.room_identity_cid;
|
|
829
|
+
stored.member_roles = [];
|
|
830
|
+
writeTask(stored);
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/** A completed room may consume its archives while other task members still retire. */
|
|
834
|
+
export function beginTaskArchiveCleanup(id) {
|
|
835
|
+
withTaskLock(id, () => {
|
|
836
|
+
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
837
|
+
if (stored.deletion?.status !== 'pending')
|
|
838
|
+
throw new TaskStateError('Task is not deleting');
|
|
839
|
+
stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
|
|
840
|
+
writeTask(stored);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
@@ -88,6 +88,7 @@ export interface TaskDeletionIntent {
|
|
|
88
88
|
/** Snapshot of managed members at acceptance; the missing-room retirement evidence. */
|
|
89
89
|
members: TaskDeletionMemberCursor[];
|
|
90
90
|
archived_absences?: ArchivedMemberAbsence[];
|
|
91
|
+
absent_members?: string[];
|
|
91
92
|
/** Archive proofs were verified before owned workspace cleanup may consume them. */
|
|
92
93
|
workspace_cleanup_started_at?: string;
|
|
93
94
|
error?: string;
|
|
@@ -212,6 +213,7 @@ export type RoomHistoryEvidence = {
|
|
|
212
213
|
};
|
|
213
214
|
export type MemberRetirementPhase = 'stop_requested' | 'liveness_absent' | 'archive_secured' | 'identity_absent';
|
|
214
215
|
export interface MemberRetirement {
|
|
216
|
+
absence_verified?: boolean;
|
|
215
217
|
phase: MemberRetirementPhase;
|
|
216
218
|
launch_id: string;
|
|
217
219
|
updated_at: string;
|
package/dist/temp-lifecycle.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export declare function makeTempSupervisorLauncher(options?: {
|
|
|
44
44
|
}): SupervisorLauncher;
|
|
45
45
|
export declare function markTempSupervisorActive(dir: string, pid?: number): Promise<void>;
|
|
46
46
|
export declare function requestedTempStopReason(dir: string): 'operator-stop' | undefined;
|
|
47
|
+
/** Explicit erasure removes only exact retired role/launch events. */
|
|
48
|
+
export declare function eraseTerminationEvents(launches: ReadonlyArray<{
|
|
49
|
+
role: string;
|
|
50
|
+
launchId: string;
|
|
51
|
+
}>): void;
|
|
47
52
|
/** Move retired state out of the live roster without deleting any evidence. */
|
|
48
53
|
export declare function archiveTempState(role: string, reason: TempTerminationReason, outcome: TempTerminationRecord['outcome'], detail: string, now?: Date): string | undefined;
|
|
49
54
|
/** Resolve only an exact role+launch archive; names and timestamps are never proof. */
|
package/dist/temp-lifecycle.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
|
+
import { replaceFileAtomically, withFileLock, withSynchronousFileLock } from './atomic-file.js';
|
|
5
5
|
import { realExec } from './exec.js';
|
|
6
6
|
import { stateRoot, tmpRoot } from './paths.js';
|
|
7
7
|
export const TEMP_SUPERVISOR_FILE = '.temp-supervisor.json';
|
|
@@ -133,18 +133,34 @@ function appendTermination(dir, record) {
|
|
|
133
133
|
replaceFileAtomically(join(dir, TEMP_GLOBAL_TERMINATION_MARKER), line);
|
|
134
134
|
}
|
|
135
135
|
function appendGlobalTermination(line, checkExisting = false) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
|
|
137
|
+
mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
|
|
138
|
+
const path = join(archiveRoot(), 'terminations.jsonl');
|
|
139
|
+
// Recovery may revisit a .retiring directory after a crash between the
|
|
140
|
+
// global append and final rename. Avoid duplicating that exact event.
|
|
141
|
+
if (checkExisting) {
|
|
142
|
+
try {
|
|
143
|
+
if (readFileSync(path, 'utf8').split('\n').includes(line.trimEnd()))
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
catch { /* the journal does not exist yet */ }
|
|
144
147
|
}
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
+
appendFileSync(path, line, { mode: 0o600 });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Explicit erasure removes only exact retired role/launch events. */
|
|
152
|
+
export function eraseTerminationEvents(launches) {
|
|
153
|
+
withSynchronousFileLock(join(stateRoot(), 'locks', 'termination-journal'), () => {
|
|
154
|
+
const path = join(archiveRoot(), 'terminations.jsonl');
|
|
155
|
+
if (!existsSync(path))
|
|
156
|
+
return;
|
|
157
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
158
|
+
const kept = lines.filter(line => {
|
|
159
|
+
const row = JSON.parse(line);
|
|
160
|
+
return !launches.some(launch => launch.role === row.role && launch.launchId === row.launchId);
|
|
161
|
+
});
|
|
162
|
+
replaceFileAtomically(path, kept.length ? kept.join('\n') + '\n' : '');
|
|
163
|
+
});
|
|
148
164
|
}
|
|
149
165
|
/** Pick a sibling path without overwriting evidence from an earlier attempt. */
|
|
150
166
|
function collisionSafeArchivePaths(targetBase, retiringBase) {
|