@ours.network/fleet 1.0.3 → 1.0.5

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.
Files changed (43) hide show
  1. package/README.md +13 -9
  2. package/dist/application/errors.d.ts +1 -1
  3. package/dist/application/role-command-service.d.ts +29 -1
  4. package/dist/application/role-command-service.js +41 -2
  5. package/dist/application/role-creation-service.d.ts +32 -1
  6. package/dist/application/role-creation-service.js +56 -10
  7. package/dist/application/role-removal-service.d.ts +19 -0
  8. package/dist/application/role-removal-service.js +13 -3
  9. package/dist/application/session-mutations.d.ts +7 -0
  10. package/dist/application/session-mutations.js +8 -0
  11. package/dist/application/task-room-service.d.ts +262 -0
  12. package/dist/application/task-room-service.js +571 -0
  13. package/dist/atomic-file.d.ts +5 -3
  14. package/dist/atomic-file.js +32 -8
  15. package/dist/build-info.json +4 -4
  16. package/dist/cli.js +39 -15
  17. package/dist/config.d.ts +6 -3
  18. package/dist/config.js +0 -17
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +26 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/owner-channel/attachments.d.ts +2 -4
  24. package/dist/owner-channel/attachments.js +6 -39
  25. package/dist/owner-channel/channel.d.ts +5 -0
  26. package/dist/owner-channel/channel.js +159 -21
  27. package/dist/owner-channel/commands.d.ts +43 -1
  28. package/dist/owner-channel/commands.js +137 -130
  29. package/dist/rooms-tasks/cli.js +301 -463
  30. package/dist/rooms-tasks/task-lists.d.ts +19 -0
  31. package/dist/rooms-tasks/task-lists.js +96 -0
  32. package/dist/rooms-tasks/task-state.d.ts +3 -0
  33. package/dist/rooms-tasks/task-state.js +281 -175
  34. package/dist/rooms-tasks/types.d.ts +11 -1
  35. package/dist/runner.js +10 -33
  36. package/dist/session/control.d.ts +10 -1
  37. package/dist/session/control.js +22 -21
  38. package/dist/watchdog/query.d.ts +2 -0
  39. package/dist/watchdog/query.js +7 -3
  40. package/dist/web/runtime.js +2 -0
  41. package/dist/web/server.d.ts +2 -0
  42. package/dist/web/server.js +88 -3
  43. package/package.json +1 -1
@@ -1,11 +1,66 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
6
  import { TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
7
+ import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
7
8
  export const tasksDir = () => join(stateRoot(), 'tasks');
8
9
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
10
+ function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
11
+ const localTaskLocks = new Set();
12
+ function withTaskLock(id, fn) {
13
+ if (localTaskLocks.has(id))
14
+ return fn();
15
+ const path = taskLockPath(id);
16
+ const ownerPath = join(path, 'owner.json');
17
+ const token = randomUUID();
18
+ mkdirSync(join(tasksDir(), '.locks'), { recursive: true });
19
+ for (;;) {
20
+ const claimPath = `${path}.claim.${process.pid}.${randomUUID()}`;
21
+ try {
22
+ mkdirSync(claimPath);
23
+ writeFileSync(join(claimPath, 'owner.json'), JSON.stringify({ token, pid: process.pid }));
24
+ renameSync(claimPath, path);
25
+ break;
26
+ }
27
+ catch (error) {
28
+ rmSync(claimPath, { recursive: true, force: true });
29
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error.code ?? ''))
30
+ throw error;
31
+ let alive = false;
32
+ try {
33
+ const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
34
+ if (typeof owner.pid === 'number') {
35
+ try {
36
+ process.kill(owner.pid, 0);
37
+ alive = true;
38
+ }
39
+ catch { /* dead */ }
40
+ }
41
+ }
42
+ catch { /* corrupt legacy lock */ }
43
+ if (!alive) {
44
+ rmSync(path, { recursive: true, force: true });
45
+ continue;
46
+ }
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
48
+ }
49
+ }
50
+ localTaskLocks.add(id);
51
+ try {
52
+ return fn();
53
+ }
54
+ finally {
55
+ localTaskLocks.delete(id);
56
+ try {
57
+ const owner = JSON.parse(readFileSync(ownerPath, 'utf8'));
58
+ if (owner.token === token)
59
+ rmSync(path, { recursive: true, force: true });
60
+ }
61
+ catch { /* ownership cannot be proved */ }
62
+ }
63
+ }
9
64
  function generateTaskId() {
10
65
  const ts = Date.now().toString(36).padStart(9, '0');
11
66
  const rand = randomUUID().replace(/-/g, '').slice(0, 8);
@@ -27,11 +82,20 @@ function readTask(id) {
27
82
  const p = taskPath(id);
28
83
  if (!existsSync(p))
29
84
  throw new TaskStateError(`task not found: ${id}`);
30
- return JSON.parse(readFileSync(p, 'utf8'));
85
+ return presentTask(JSON.parse(readFileSync(p, 'utf8')));
31
86
  }
32
87
  function writeTask(record) {
33
88
  mkdirSync(tasksDir(), { recursive: true });
34
- replaceFileAtomically(taskPath(record.task_id), JSON.stringify(record, null, 2) + '\n');
89
+ const persisted = { ...record };
90
+ delete persisted.list_name;
91
+ replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
92
+ }
93
+ function presentTask(record) {
94
+ const listId = record.list_id ?? DEFAULT_TASK_LIST_ID;
95
+ const list = readTaskLists().find(item => item.list_id === listId);
96
+ if (!list)
97
+ throw new TaskStateError(`task ${record.task_id} references missing list ${listId}`);
98
+ return { ...record, list_id: listId, list_name: list.name };
35
99
  }
36
100
  // ── Lifecycle transitions ───────────────────────────────────────────────
37
101
  const VALID_TRANSITIONS = {
@@ -60,6 +124,7 @@ export function createTask(input) {
60
124
  const state = input.start === false ? 'backlog' : 'provisioning';
61
125
  const record = {
62
126
  task_id: generateTaskId(),
127
+ list_id: input.listId ?? DEFAULT_TASK_LIST_ID,
63
128
  title: input.title,
64
129
  brief: input.brief,
65
130
  brief_file: input.brief_file,
@@ -74,9 +139,9 @@ export function createTask(input) {
74
139
  started_at: state === 'provisioning' ? new Date().toISOString() : undefined,
75
140
  };
76
141
  writeTask(record);
77
- return record;
142
+ return readTask(record.task_id);
78
143
  }
79
- export function getTask(id) { return readTask(id); }
144
+ export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
80
145
  export function listTasks(filter) {
81
146
  const dir = tasksDir();
82
147
  if (!existsSync(dir))
@@ -87,222 +152,263 @@ export function listTasks(filter) {
87
152
  const tasks = [];
88
153
  for (const f of readdirSync(dir).filter(f => f.endsWith('.json'))) {
89
154
  try {
90
- const t = JSON.parse(readFileSync(join(dir, f), 'utf8'));
91
- if (!states || states.includes(t.state))
155
+ const id = f.slice(0, -5);
156
+ const t = withTaskLock(id, () => presentTask(JSON.parse(readFileSync(join(dir, f), 'utf8'))));
157
+ if ((!states || states.includes(t.state)) && (!filter?.listId || t.list_id === filter.listId))
92
158
  tasks.push(t);
93
159
  }
94
160
  catch { /* corrupt file, skip */ }
95
161
  }
96
- return tasks.sort((a, b) => a.created_at.localeCompare(b.created_at));
162
+ const order = new Map(readTaskLists().map((item, index) => [item.list_id, index]));
163
+ return tasks.sort((a, b) => (order.get(a.list_id) - order.get(b.list_id))
164
+ || a.created_at.localeCompare(b.created_at) || a.task_id.localeCompare(b.task_id));
165
+ }
166
+ export function moveTaskToList(id, listId) {
167
+ return withTaskLock(id, () => {
168
+ const task = readTask(id);
169
+ task.list_id = listId;
170
+ writeTask(task);
171
+ return readTask(id);
172
+ });
97
173
  }
98
174
  export function findByIdempotencyKey(key) {
99
175
  return listTasks().find(t => t.idempotency_key === key);
100
176
  }
101
177
  export function startTask(id) {
102
- const t = readTask(id);
103
- assertNoPendingTerminalIntent(t);
104
- assertTransition(t.state, 'provisioning');
105
- t.state = 'provisioning';
106
- t.started_at = new Date().toISOString();
107
- writeTask(t);
108
- return t;
178
+ return withTaskLock(id, () => {
179
+ const t = readTask(id);
180
+ assertNoPendingTerminalIntent(t);
181
+ assertTransition(t.state, 'provisioning');
182
+ t.state = 'provisioning';
183
+ t.started_at = new Date().toISOString();
184
+ writeTask(t);
185
+ return t;
186
+ });
109
187
  }
110
188
  export function activateTask(id) {
111
- const t = readTask(id);
112
- assertNoPendingTerminalIntent(t);
113
- assertTransition(t.state, 'active');
114
- t.state = 'active';
115
- delete t.blocked;
116
- writeTask(t);
117
- return t;
189
+ return withTaskLock(id, () => {
190
+ const t = readTask(id);
191
+ assertNoPendingTerminalIntent(t);
192
+ assertTransition(t.state, 'active');
193
+ t.state = 'active';
194
+ delete t.blocked;
195
+ writeTask(t);
196
+ return t;
197
+ });
118
198
  }
119
199
  export function blockTask(id, reason) {
120
- const t = readTask(id);
121
- assertNoPendingTerminalIntent(t);
122
- if (TASK_TERMINAL_STATES.includes(t.state))
123
- throw new TaskStateError(`cannot block a '${t.state}' task`);
124
- t.blocked = { reason, at: new Date().toISOString() };
125
- writeTask(t);
126
- return t;
200
+ return withTaskLock(id, () => {
201
+ const t = readTask(id);
202
+ assertNoPendingTerminalIntent(t);
203
+ if (TASK_TERMINAL_STATES.includes(t.state))
204
+ throw new TaskStateError(`cannot block a '${t.state}' task`);
205
+ t.blocked = { reason, at: new Date().toISOString() };
206
+ writeTask(t);
207
+ return t;
208
+ });
127
209
  }
128
210
  export function unblockTask(id) {
129
- const t = readTask(id);
130
- assertNoPendingTerminalIntent(t);
131
- if (!t.blocked)
132
- throw new TaskStateError('task is not blocked');
133
- delete t.blocked;
134
- writeTask(t);
135
- return t;
211
+ return withTaskLock(id, () => {
212
+ const t = readTask(id);
213
+ assertNoPendingTerminalIntent(t);
214
+ if (!t.blocked)
215
+ throw new TaskStateError('task is not blocked');
216
+ delete t.blocked;
217
+ writeTask(t);
218
+ return t;
219
+ });
136
220
  }
137
221
  export function reviewTask(id) {
138
- const t = readTask(id);
139
- assertNoPendingTerminalIntent(t);
140
- assertTransition(t.state, 'review');
141
- t.state = 'review';
142
- delete t.blocked;
143
- writeTask(t);
144
- return t;
222
+ return withTaskLock(id, () => {
223
+ const t = readTask(id);
224
+ assertNoPendingTerminalIntent(t);
225
+ assertTransition(t.state, 'review');
226
+ t.state = 'review';
227
+ delete t.blocked;
228
+ writeTask(t);
229
+ return t;
230
+ });
145
231
  }
146
232
  export function completeTask(id, outcome) {
147
- const t = readTask(id);
148
- assertNoPendingTerminalIntent(t);
149
- assertTransition(t.state, 'done');
150
- t.state = 'done';
151
- t.ended_at = new Date().toISOString();
152
- delete t.blocked;
153
- if (outcome)
154
- t.outcome = outcome;
155
- writeTask(t);
156
- return t;
233
+ return withTaskLock(id, () => {
234
+ const t = readTask(id);
235
+ assertNoPendingTerminalIntent(t);
236
+ assertTransition(t.state, 'done');
237
+ t.state = 'done';
238
+ t.ended_at = new Date().toISOString();
239
+ delete t.blocked;
240
+ if (outcome)
241
+ t.outcome = outcome;
242
+ writeTask(t);
243
+ return t;
244
+ });
157
245
  }
158
246
  export function cancelTask(id) {
159
- const t = readTask(id);
160
- assertNoPendingTerminalIntent(t);
161
- if (!TASK_CANCELLABLE_STATES.includes(t.state))
162
- throw new TaskStateError(`cannot cancel a '${t.state}' task`);
163
- t.state = 'cancelled';
164
- t.ended_at = new Date().toISOString();
165
- delete t.blocked;
166
- writeTask(t);
167
- return t;
247
+ return withTaskLock(id, () => {
248
+ const t = readTask(id);
249
+ assertNoPendingTerminalIntent(t);
250
+ if (!TASK_CANCELLABLE_STATES.includes(t.state))
251
+ throw new TaskStateError(`cannot cancel a '${t.state}' task`);
252
+ t.state = 'cancelled';
253
+ t.ended_at = new Date().toISOString();
254
+ delete t.blocked;
255
+ writeTask(t);
256
+ return t;
257
+ });
168
258
  }
169
259
  function sameOutcome(left, right) {
170
260
  return JSON.stringify(left) === JSON.stringify(right);
171
261
  }
172
262
  /** Persist the first terminal request. Callers serialize this with the task-operation lock. */
173
263
  export function beginTaskTerminalIntent(id, input) {
174
- const t = readTask(id);
175
- const existing = t.terminal_intent;
176
- if (existing) {
177
- if (existing.kind !== input.kind || existing.room_id !== input.roomId
178
- || !sameOutcome(existing.outcome, input.outcome)) {
179
- throw new TaskStateError(`task ${id} already has a conflicting '${existing.kind}' terminal intent`);
264
+ return withTaskLock(id, () => {
265
+ const t = readTask(id);
266
+ const existing = t.terminal_intent;
267
+ if (existing) {
268
+ if (existing.kind !== input.kind || existing.room_id !== input.roomId
269
+ || !sameOutcome(existing.outcome, input.outcome)) {
270
+ throw new TaskStateError(`task ${id} already has a conflicting '${existing.kind}' terminal intent`);
271
+ }
272
+ return t;
273
+ }
274
+ if (TASK_TERMINAL_STATES.includes(t.state)) {
275
+ throw new TaskStateError(`task ${id} is already in terminal state '${t.state}'`);
180
276
  }
277
+ if (input.kind === 'done' && t.state !== 'review') {
278
+ throw new TaskStateError(`cannot transition from '${t.state}' to 'done'`);
279
+ }
280
+ if (input.kind === 'cancelled' && !TASK_CANCELLABLE_STATES.includes(t.state)) {
281
+ throw new TaskStateError(`cannot cancel a '${t.state}' task`);
282
+ }
283
+ if (input.roomId !== undefined && t.room_id !== input.roomId) {
284
+ throw new TaskStateError(`task ${id} is not tied to room ${input.roomId}`);
285
+ }
286
+ t.terminal_intent = {
287
+ kind: input.kind,
288
+ status: 'pending',
289
+ room_id: input.roomId,
290
+ outcome: input.outcome,
291
+ accepted_at: new Date().toISOString(),
292
+ };
293
+ writeTask(t);
181
294
  return t;
182
- }
183
- if (TASK_TERMINAL_STATES.includes(t.state)) {
184
- throw new TaskStateError(`task ${id} is already in terminal state '${t.state}'`);
185
- }
186
- if (input.kind === 'done' && t.state !== 'review') {
187
- throw new TaskStateError(`cannot transition from '${t.state}' to 'done'`);
188
- }
189
- if (input.kind === 'cancelled' && !TASK_CANCELLABLE_STATES.includes(t.state)) {
190
- throw new TaskStateError(`cannot cancel a '${t.state}' task`);
191
- }
192
- if (input.roomId !== undefined && t.room_id !== input.roomId) {
193
- throw new TaskStateError(`task ${id} is not tied to room ${input.roomId}`);
194
- }
195
- t.terminal_intent = {
196
- kind: input.kind,
197
- status: 'pending',
198
- room_id: input.roomId,
199
- outcome: input.outcome,
200
- accepted_at: new Date().toISOString(),
201
- };
202
- writeTask(t);
203
- return t;
295
+ });
204
296
  }
205
297
  /** Record an actionable failure without claiming the requested terminal state. */
206
298
  export function setTaskTerminalIntentError(id, error, recoveryHint) {
207
- const t = readTask(id);
208
- if (!t.terminal_intent)
209
- throw new TaskStateError(`task ${id} has no terminal intent`);
210
- if (t.terminal_intent.status === 'settled')
299
+ return withTaskLock(id, () => {
300
+ const t = readTask(id);
301
+ if (!t.terminal_intent)
302
+ throw new TaskStateError(`task ${id} has no terminal intent`);
303
+ if (t.terminal_intent.status === 'settled')
304
+ return t;
305
+ t.terminal_intent.first_failure ??= error;
306
+ t.terminal_intent.first_recovery_hint ??= recoveryHint;
307
+ t.terminal_intent.error = error;
308
+ const previousErrorAt = Date.parse(t.terminal_intent.error_at ?? '');
309
+ t.terminal_intent.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
310
+ t.terminal_intent.recovery_hint = recoveryHint;
311
+ writeTask(t);
211
312
  return t;
212
- t.terminal_intent.first_failure ??= error;
213
- t.terminal_intent.first_recovery_hint ??= recoveryHint;
214
- t.terminal_intent.error = error;
215
- const previousErrorAt = Date.parse(t.terminal_intent.error_at ?? '');
216
- t.terminal_intent.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
217
- t.terminal_intent.recovery_hint = recoveryHint;
218
- writeTask(t);
219
- return t;
313
+ });
220
314
  }
221
315
  /** Atomically publish the task terminal state and settled audit cursor. */
222
316
  export function finishTaskTerminalIntent(id) {
223
- const t = readTask(id);
224
- const intent = t.terminal_intent;
225
- if (!intent)
226
- throw new TaskStateError(`task ${id} has no terminal intent`);
227
- if (intent.status === 'settled')
317
+ return withTaskLock(id, () => {
318
+ const t = readTask(id);
319
+ const intent = t.terminal_intent;
320
+ if (!intent)
321
+ throw new TaskStateError(`task ${id} has no terminal intent`);
322
+ if (intent.status === 'settled')
323
+ return t;
324
+ if (TASK_TERMINAL_STATES.includes(t.state)) {
325
+ throw new TaskStateError(`task ${id} reached terminal state '${t.state}' outside its intent`);
326
+ }
327
+ if (intent.kind === 'done')
328
+ assertTransition(t.state, 'done');
329
+ else if (!TASK_CANCELLABLE_STATES.includes(t.state)) {
330
+ throw new TaskStateError(`cannot cancel a '${t.state}' task`);
331
+ }
332
+ t.state = intent.kind;
333
+ t.ended_at = new Date().toISOString();
334
+ delete t.blocked;
335
+ if (intent.outcome)
336
+ t.outcome = intent.outcome;
337
+ intent.status = 'settled';
338
+ intent.settled_at = t.ended_at;
339
+ delete intent.error;
340
+ delete intent.error_at;
341
+ delete intent.recovery_hint;
342
+ writeTask(t);
228
343
  return t;
229
- if (TASK_TERMINAL_STATES.includes(t.state)) {
230
- throw new TaskStateError(`task ${id} reached terminal state '${t.state}' outside its intent`);
231
- }
232
- if (intent.kind === 'done')
233
- assertTransition(t.state, 'done');
234
- else if (!TASK_CANCELLABLE_STATES.includes(t.state)) {
235
- throw new TaskStateError(`cannot cancel a '${t.state}' task`);
236
- }
237
- t.state = intent.kind;
238
- t.ended_at = new Date().toISOString();
239
- delete t.blocked;
240
- if (intent.outcome)
241
- t.outcome = intent.outcome;
242
- intent.status = 'settled';
243
- intent.settled_at = t.ended_at;
244
- delete intent.error;
245
- delete intent.error_at;
246
- delete intent.recovery_hint;
247
- writeTask(t);
248
- return t;
344
+ });
249
345
  }
250
346
  export function failTask(id, error) {
251
- const t = readTask(id);
252
- assertNoPendingTerminalIntent(t);
253
- assertTransition(t.state, 'failed');
254
- t.state = 'failed';
255
- t.ended_at = new Date().toISOString();
256
- t.outcome = { summary: error };
257
- delete t.blocked;
258
- writeTask(t);
259
- return t;
347
+ return withTaskLock(id, () => {
348
+ const t = readTask(id);
349
+ assertNoPendingTerminalIntent(t);
350
+ assertTransition(t.state, 'failed');
351
+ t.state = 'failed';
352
+ t.ended_at = new Date().toISOString();
353
+ t.outcome = { summary: error };
354
+ delete t.blocked;
355
+ writeTask(t);
356
+ return t;
357
+ });
260
358
  }
261
359
  export function updateTaskRoom(id, roomId, roomIdentityCid) {
262
- const t = readTask(id);
263
- assertNoPendingTerminalIntent(t);
264
- t.room_id = roomId;
265
- t.room_identity_cid = roomIdentityCid;
266
- writeTask(t);
267
- return t;
360
+ return withTaskLock(id, () => {
361
+ const t = readTask(id);
362
+ assertNoPendingTerminalIntent(t);
363
+ t.room_id = roomId;
364
+ t.room_identity_cid = roomIdentityCid;
365
+ writeTask(t);
366
+ return t;
367
+ });
268
368
  }
269
369
  export function updateTaskTemplate(id, template) {
270
- const t = readTask(id);
271
- assertNoPendingTerminalIntent(t);
272
- t.template = template;
273
- writeTask(t);
274
- return t;
370
+ return withTaskLock(id, () => {
371
+ const t = readTask(id);
372
+ assertNoPendingTerminalIntent(t);
373
+ t.template = template;
374
+ writeTask(t);
375
+ return t;
376
+ });
275
377
  }
276
378
  export function updateTaskMembers(id, members) {
277
- const t = readTask(id);
278
- assertNoPendingTerminalIntent(t);
279
- t.member_roles = members;
280
- writeTask(t);
281
- return t;
379
+ return withTaskLock(id, () => {
380
+ const t = readTask(id);
381
+ assertNoPendingTerminalIntent(t);
382
+ t.member_roles = members;
383
+ writeTask(t);
384
+ return t;
385
+ });
282
386
  }
283
387
  /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
284
388
  export function deleteTask(id) {
285
- assertCanonicalTaskId(id);
286
- const p = taskPath(id);
287
- let task;
288
- try {
289
- task = JSON.parse(readFileSync(p, 'utf8'));
290
- }
291
- catch (error) {
292
- if (isNotFoundError(error))
293
- return false;
294
- throw error;
295
- }
296
- if (task.state !== 'done') {
297
- throw new TaskStateError(`cannot delete a '${task.state}' task; only 'done' tasks can be deleted`);
298
- }
299
- try {
300
- unlinkSync(p);
301
- }
302
- catch (error) {
303
- if (isNotFoundError(error))
304
- return false;
305
- throw error;
306
- }
307
- return true;
389
+ return withTaskLock(id, () => {
390
+ assertCanonicalTaskId(id);
391
+ const p = taskPath(id);
392
+ let task;
393
+ try {
394
+ task = JSON.parse(readFileSync(p, 'utf8'));
395
+ }
396
+ catch (error) {
397
+ if (isNotFoundError(error))
398
+ return false;
399
+ throw error;
400
+ }
401
+ if (task.state !== 'done') {
402
+ throw new TaskStateError(`cannot delete a '${task.state}' task; only 'done' tasks can be deleted`);
403
+ }
404
+ try {
405
+ unlinkSync(p);
406
+ }
407
+ catch (error) {
408
+ if (isNotFoundError(error))
409
+ return false;
410
+ throw error;
411
+ }
412
+ return true;
413
+ });
308
414
  }
@@ -24,7 +24,7 @@ export interface TaskMemberRole {
24
24
  cowork_role: string;
25
25
  }
26
26
  export interface TaskOrigin {
27
- type: 'cli' | 'owner_channel';
27
+ type: 'cli' | 'owner_channel' | 'web';
28
28
  owner_cid?: string;
29
29
  }
30
30
  export interface TaskOutcome {
@@ -46,6 +46,10 @@ export interface TaskTerminalIntent {
46
46
  }
47
47
  export interface TaskRecord {
48
48
  task_id: string;
49
+ /** Stable organizational list identifier. Missing legacy values mean `default`. */
50
+ list_id: string;
51
+ /** Derived presentation field; repositories must never persist it. */
52
+ list_name: string;
49
53
  title: string;
50
54
  brief?: string;
51
55
  brief_file?: string;
@@ -64,6 +68,12 @@ export interface TaskRecord {
64
68
  outcome?: TaskOutcome;
65
69
  terminal_intent?: TaskTerminalIntent;
66
70
  }
71
+ export interface TaskListRecord {
72
+ list_id: string;
73
+ name: string;
74
+ built_in: boolean;
75
+ created_at: string;
76
+ }
67
77
  export type RoomOrchestrationState = 'provisioning' | 'active' | 'closing' | 'closed';
68
78
  export type SagaPhase = 'persist_intent' | 'create_room' | 'attach_owner' | 'create_members' | 'join_role_groups' | 'wait_seats' | 'launch_work' | 'activate' | 'completed' | 'failed';
69
79
  export interface SagaCursor {
package/dist/runner.js CHANGED
@@ -23,7 +23,7 @@ import { OwnerChannel } from './owner-channel/channel.js';
23
23
  import { acquireOwnerBinderLease, OwnerBinderHandoffTimeoutError, } from './owner-channel/binder.js';
24
24
  import { RoleTurnArbiter } from './session/arbiter.js';
25
25
  import { ScheduledLoopManager, } from './loops/manager.js';
26
- import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
26
+ import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
27
27
  import { effectivePermissionMode } from './permissions.js';
28
28
  import { assertModelPinReachesChild, effectiveRoleModel, repinModelEnv } from './model-env.js';
29
29
  import { archiveTempState, markTempSupervisorActive, requestedTempStopReason, } from './temp-lifecycle.js';
@@ -92,11 +92,6 @@ export function harnessChildEnv(role, launchEnv, stateDir) {
92
92
  * avoid a runner↔spawn initialization cycle (spawn imports runner constants).
93
93
  */
94
94
  async function executeManagedSpawn(caller, configPath, requested, log) {
95
- const { options, inherited } = inheritCallerSpawnDefaults(caller, requested, configPath);
96
- const creationActionId = randomUUID();
97
- options.creationActionId = creationActionId;
98
- const spawnModule = await import('./spawn.js');
99
- const preview = spawnModule.spawnDryRun(options).resolvedRole;
100
95
  const runtimeBinPath = (() => {
101
96
  try {
102
97
  return realpathSync(process.argv[1]);
@@ -105,33 +100,15 @@ async function executeManagedSpawn(caller, configPath, requested, log) {
105
100
  return process.argv[1];
106
101
  }
107
102
  })();
108
- let statePath;
109
- if (options.temp) {
110
- statePath = await spawnModule.spawnTemp(options, runtimeBinPath);
111
- }
112
- else {
113
- const { pickBackend } = await import('./supervisor/index.js');
114
- const { WatchdogServiceManager } = await import('./watchdog/service.js');
115
- statePath = await spawnModule.spawnPermanent(options, {
116
- backend: pickBackend(), binPath: runtimeBinPath, log,
117
- watchdogService: new WatchdogServiceManager(),
118
- });
119
- }
120
- const result = {
121
- caller: caller.name,
122
- role: options.name,
123
- lifetime: options.temp ? 'temporary' : 'permanent',
124
- statePath,
125
- harness: preview.harness,
126
- session: preview.session,
127
- // Read back from the resolved environment, not from the request: the banner
128
- // must name the model the child will run, not the one that was asked for.
129
- ...(effectiveRoleModel(preview) ? { model: effectiveRoleModel(preview) } : {}),
130
- monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
131
- permissionMode: effectivePermissionMode(preview),
132
- inherited,
133
- creationActionId,
134
- };
103
+ const [{ RoleCreationService }, { pickBackend }, { WatchdogServiceManager }] = await Promise.all([
104
+ import('./application/role-creation-service.js'), import('./supervisor/index.js'),
105
+ import('./watchdog/service.js'),
106
+ ]);
107
+ const service = new RoleCreationService({ configPath,
108
+ ops: { backend: pickBackend(), binPath: runtimeBinPath, log,
109
+ watchdogService: new WatchdogServiceManager() },
110
+ binPath: runtimeBinPath, journal: false });
111
+ const result = await service.createManaged(caller, requested);
135
112
  log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
136
113
  + `harness=${result.harness} session=${result.session} `
137
114
  + `model=${result.model ?? '(harness default)'} `
@@ -1,5 +1,5 @@
1
1
  import { type Socket } from 'node:net';
2
- import type { ControlFailureKind, SessionHandle } from './types.js';
2
+ import type { ControlFailureKind, SessionEvent, SessionHandle, SessionSnapshot } from './types.js';
3
3
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
4
4
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
5
5
  import type { SpawnOpts } from '../spawn.js';
@@ -40,6 +40,15 @@ export interface ControlResponse {
40
40
  /** Why it failed, so the caller does not have to guess from the text. */
41
41
  kind?: ControlFailureKind;
42
42
  }
43
+ export interface RetainedEventPage {
44
+ events: SessionEvent[];
45
+ snapshot: SessionSnapshot;
46
+ firstSeq: number;
47
+ lastSeq: number;
48
+ truncated: boolean;
49
+ }
50
+ /** The one retained-range projection shared by polling and live-follow admission. */
51
+ export declare function retainedEventPage(session: SessionHandle, since: number): RetainedEventPage;
43
52
  /**
44
53
  * One line saying what a control failure does — and does not — prove about the
45
54
  * agent. Only `offline` is evidence that it is gone; every other kind used to