@ours.network/fleet 0.19.0-nightly.13 → 0.19.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.
@@ -1,11 +1,92 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
3
+ import { RoomStateError } from '../rooms-tasks/room-state.js';
4
+ import { TaskStateError } from '../rooms-tasks/task-state.js';
5
+ import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
3
6
  import { OWNER_COMMENT_LABEL, ownerNotices } from './notices.js';
4
7
  /** A malformed invocation; the dispatcher answers it with annotated help. */
5
8
  class OwnerCommandUsageError extends Error {
6
9
  }
7
10
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
8
11
  const REPLY_MAX_CHARS = 3_500;
12
+ const taskListRecord = (task) => `${taskStatus(task.state)} ${markdownCode(task.task_id)} — ${markdownProse(task.title)}`
13
+ + (task.blocked ? ` — 🚧 Blocked: ${markdownProse(task.blocked.reason)}` : '');
14
+ const roomListRecord = (room) => `${roomStatus(room.state)} ${markdownCode(room.room_id)} — ${markdownProse(room.room_name)}`
15
+ + (room.task_id ? ` — Task ${markdownCode(room.task_id)}` : '');
16
+ const TASK_STATE_WORD = '(?:backlog|provisioning|active|review|done|cancelled|failed)';
17
+ const SAFE_ID_WORD = '[A-Za-z0-9_-]{1,128}';
18
+ function isKnownOwnerTaskState(message) {
19
+ return [
20
+ new RegExp(`^cannot transition from '${TASK_STATE_WORD}' to '${TASK_STATE_WORD}'$`, 'u'),
21
+ new RegExp(`^task ${SAFE_ID_WORD} has a pending '(?:done|cancelled)' terminal intent$`, 'u'),
22
+ new RegExp(`^cannot (?:block|cancel) a '${TASK_STATE_WORD}' task$`, 'u'),
23
+ /^task is not blocked$/u,
24
+ new RegExp(`^task ${SAFE_ID_WORD} already has a conflicting '(?:done|cancelled)' terminal intent$`, 'u'),
25
+ new RegExp(`^task ${SAFE_ID_WORD} is already in terminal state '${TASK_STATE_WORD}'$`, 'u'),
26
+ new RegExp(`^cannot delete a '${TASK_STATE_WORD}' task; only 'done' tasks can be deleted$`, 'u'),
27
+ ].some(pattern => pattern.test(message));
28
+ }
29
+ function isKnownOwnerRoomState(message) {
30
+ return [
31
+ new RegExp(`^room ${SAFE_ID_WORD} is not closing$`, 'u'),
32
+ new RegExp(`^room ${SAFE_ID_WORD} has no recorded member .{1,128}$`, 'u'),
33
+ new RegExp(`^room ${SAFE_ID_WORD} close cannot move backward to [A-Za-z_]+$`, 'u'),
34
+ ].some(pattern => pattern.test(message));
35
+ }
36
+ function ownerTaskFailure(error) {
37
+ if (error instanceof TaskStateError) {
38
+ const missing = /^task not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
39
+ if (missing)
40
+ return {
41
+ kind: 'not_found', detail: `Task ${missing[1]} was not found.`,
42
+ action: 'Run /task list to find a valid task ID.',
43
+ };
44
+ if (isKnownOwnerTaskState(error.message))
45
+ return {
46
+ kind: 'state', detail: 'The current task state does not allow that action.',
47
+ action: 'Run /task show <id> to inspect the current task state.',
48
+ };
49
+ }
50
+ return {
51
+ kind: 'unexpected',
52
+ action: 'Retry once; if it repeats, inspect the role logs.',
53
+ };
54
+ }
55
+ function ownerRoomFailure(error) {
56
+ if (error instanceof RoomStateError) {
57
+ const missing = /^room not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
58
+ if (missing)
59
+ return {
60
+ kind: 'not_found', detail: `Room ${missing[1]} was not found.`,
61
+ action: 'Run /room list to find a valid room ID.',
62
+ };
63
+ if (isKnownOwnerRoomState(error.message))
64
+ return {
65
+ kind: 'state', detail: 'The current room state does not allow that action.',
66
+ action: 'Run /room show <id> to inspect the current room state.',
67
+ };
68
+ }
69
+ return {
70
+ kind: 'unexpected',
71
+ action: 'Retry once; if it repeats, inspect the role logs.',
72
+ };
73
+ }
74
+ const taskAction = (title, task, fields = []) => renderMarkdownResult({
75
+ icon: '📋', title,
76
+ fields: [
77
+ { label: 'ID', value: task.task_id, kind: 'code' },
78
+ { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
79
+ ...fields,
80
+ ],
81
+ });
82
+ const roomAction = (title, room, fields = []) => renderMarkdownResult({
83
+ icon: '🏠', title,
84
+ fields: [
85
+ { label: 'ID', value: room.room_id, kind: 'code' },
86
+ { label: 'Status', value: roomStatus(room.state), kind: 'markdown' },
87
+ ...fields,
88
+ ],
89
+ });
9
90
  const strip = (value, max) => String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max);
10
91
  /** Like `strip`, but keeps newlines so multi-line listings stay readable. */
11
92
  const stripMultiline = (value, max) => String(value).replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ').slice(0, max);
@@ -150,13 +231,10 @@ export const ownerCommands = [
150
231
  const { listTasks } = await import('../rooms-tasks/task-state.js');
151
232
  const stateFilter = args?.trim() || undefined;
152
233
  const tasks = listTasks(stateFilter && stateFilter !== 'all' ? { state: stateFilter } : undefined);
153
- if (!tasks.length)
154
- return ctx.reply('📋 No tasks.');
155
- const lines = tasks.map(t => {
156
- const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
157
- return `${t.task_id} ${t.state}${blocked} ${t.title}`;
158
- });
159
- await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
234
+ await ctx.reply(renderMarkdownList({
235
+ icon: '📋', title: 'Tasks', empty: 'No tasks found.',
236
+ records: tasks.map(taskListRecord),
237
+ }));
160
238
  },
161
239
  },
162
240
  {
@@ -174,16 +252,19 @@ export const ownerCommands = [
174
252
  const showTask = async (id) => {
175
253
  const { getTask } = await import('../rooms-tasks/task-state.js');
176
254
  const t = getTask(id);
177
- const lines = [
178
- `📋 Task: ${t.task_id}`,
179
- `Title: ${t.title}`,
180
- `State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`,
181
- ...(t.template ? [`Template: ${t.template.name}@${t.template.version}`] : []),
182
- ...(t.room_id ? [`Room: ${t.room_id}`] : []),
183
- `Origin: ${t.origin.type}`,
184
- `Created: ${t.created_at}`,
185
- ];
186
- await ctx.reply(lines.join('\n'));
255
+ await ctx.reply(renderMarkdownResult({
256
+ icon: '📋', title: 'Task details',
257
+ fields: [
258
+ { label: 'ID', value: t.task_id, kind: 'code' },
259
+ { label: 'Title', value: t.title },
260
+ { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
261
+ ...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
262
+ ...(t.template ? [{ label: 'Template', value: `${t.template.name}@${t.template.version}`, kind: 'code' }] : []),
263
+ ...(t.room_id ? [{ label: 'Room', value: t.room_id, kind: 'code' }] : []),
264
+ { label: 'Origin', value: t.origin.type },
265
+ { label: 'Created', value: t.created_at, kind: 'code' },
266
+ ],
267
+ }));
187
268
  };
188
269
  try {
189
270
  switch (sub) {
@@ -206,12 +287,11 @@ export const ownerCommands = [
206
287
  ...(noRoom ? { no_room: true } : {}),
207
288
  ...(trailingLines ? { brief: trailingLines } : {}),
208
289
  });
209
- const lines = [`Task ${t.task_id} created · ${t.state}`];
210
- if (template)
211
- lines.push(`Template: ${template}`);
212
- if (noRoom)
213
- lines.push('No room requested');
214
- await ctx.reply(lines.join('\n'));
290
+ await ctx.reply(taskAction('Task created', t, [
291
+ { label: 'Title', value: t.title },
292
+ ...(template ? [{ label: 'Template', value: template, kind: 'code' }] : []),
293
+ ...(noRoom ? [{ label: 'Room', value: 'Not requested' }] : []),
294
+ ]));
215
295
  break;
216
296
  }
217
297
  case 'list': {
@@ -227,20 +307,16 @@ export const ownerCommands = [
227
307
  const tasks = listTasks(stateFilter);
228
308
  if (filter === 'blocked') {
229
309
  const blocked = tasks.filter(t => t.blocked);
230
- if (!blocked.length) {
231
- await ctx.reply('📋 No blocked tasks.');
232
- break;
233
- }
234
- const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
235
- await ctx.reply(tail(`📋 Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
236
- break;
237
- }
238
- if (!tasks.length) {
239
- await ctx.reply('📋 No tasks.');
310
+ await ctx.reply(renderMarkdownList({
311
+ icon: '🚧', title: 'Blocked tasks', empty: 'No blocked tasks found.',
312
+ records: blocked.map(taskListRecord),
313
+ }));
240
314
  break;
241
315
  }
242
- const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
243
- await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
316
+ await ctx.reply(renderMarkdownList({
317
+ icon: '📋', title: 'Tasks', empty: 'No tasks found.',
318
+ records: tasks.map(taskListRecord),
319
+ }));
244
320
  break;
245
321
  }
246
322
  case 'show': {
@@ -254,7 +330,7 @@ export const ownerCommands = [
254
330
  throw new OwnerCommandUsageError('usage: /task start <id>');
255
331
  const { startTask } = await import('../rooms-tasks/task-state.js');
256
332
  const t = startTask(rest[0]);
257
- await ctx.reply(`✅ Task ${t.task_id} → provisioning`);
333
+ await ctx.reply(taskAction('Task started', t));
258
334
  break;
259
335
  }
260
336
  case 'block': {
@@ -263,7 +339,7 @@ export const ownerCommands = [
263
339
  const { blockTask } = await import('../rooms-tasks/task-state.js');
264
340
  const reason = rest.slice(1).join(' ');
265
341
  const t = blockTask(rest[0], reason);
266
- await ctx.reply(`🚧 Task ${t.task_id} blocked: ${reason}`);
342
+ await ctx.reply(taskAction('Task blocked', t, [{ label: 'Reason', value: reason }]));
267
343
  break;
268
344
  }
269
345
  case 'unblock': {
@@ -271,7 +347,7 @@ export const ownerCommands = [
271
347
  throw new OwnerCommandUsageError('usage: /task unblock <id>');
272
348
  const { unblockTask } = await import('../rooms-tasks/task-state.js');
273
349
  const t = unblockTask(rest[0]);
274
- await ctx.reply(`✅ Task ${t.task_id} unblocked`);
350
+ await ctx.reply(taskAction('Task unblocked', t));
275
351
  break;
276
352
  }
277
353
  case 'review': {
@@ -279,7 +355,7 @@ export const ownerCommands = [
279
355
  throw new OwnerCommandUsageError('usage: /task review <id>');
280
356
  const { reviewTask } = await import('../rooms-tasks/task-state.js');
281
357
  const t = reviewTask(rest[0]);
282
- await ctx.reply(`📝 Task ${t.task_id} → review`);
358
+ await ctx.reply(taskAction('Task ready for review', t));
283
359
  break;
284
360
  }
285
361
  case 'done': {
@@ -300,9 +376,10 @@ export const ownerCommands = [
300
376
  throw new OwnerCommandUsageError('destructive: /task delete <id> <id> — provide the task ID twice');
301
377
  const { deleteTask } = await import('../rooms-tasks/task-state.js');
302
378
  const deleted = deleteTask(rest[0]);
303
- await ctx.reply(deleted
304
- ? `🗑️ Task ${rest[0]} deleted from backlog`
305
- : `🗑️ Task ${rest[0]} already absent`);
379
+ await ctx.reply(renderMarkdownResult({
380
+ icon: '🗑️', title: deleted ? 'Task deleted' : 'Task already absent',
381
+ fields: [{ label: 'ID', value: rest[0], kind: 'code' }],
382
+ }));
306
383
  break;
307
384
  }
308
385
  case 'recover': {
@@ -327,14 +404,21 @@ export const ownerCommands = [
327
404
  if (room.provisioning_detail === 'member_failed')
328
405
  hints.push(`Member failed at step ${room.saga.step_index}`);
329
406
  }
330
- const lines = [`Task ${t.task_id} · ${t.state}`];
331
- if (room)
332
- lines.push(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
333
- if (hints.length)
334
- lines.push(`Hints: ${hints.join('; ')}`);
335
- else
336
- lines.push('No automated recovery actions available.');
337
- await ctx.reply(lines.join('\n'));
407
+ await ctx.reply(renderMarkdownResult({
408
+ icon: '🛟', title: 'Task recovery',
409
+ fields: [
410
+ { label: 'Task', value: t.task_id, kind: 'code' },
411
+ { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
412
+ ...(room ? [
413
+ { label: 'Room', value: room.room_id, kind: 'code' },
414
+ { label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
415
+ { label: 'Saga', value: room.saga.phase, kind: 'code' },
416
+ ] : []),
417
+ ],
418
+ sections: hints.length
419
+ ? [{ heading: 'Next steps', items: hints }]
420
+ : [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
421
+ }));
338
422
  break;
339
423
  }
340
424
  default:
@@ -345,7 +429,13 @@ export const ownerCommands = [
345
429
  catch (e) {
346
430
  if (e instanceof OwnerCommandUsageError)
347
431
  throw e;
348
- await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
432
+ const failure = ownerTaskFailure(e);
433
+ await ctx.reply(renderMarkdownFailure({
434
+ kind: failure.kind,
435
+ subject: `/task ${sub}`,
436
+ ...(failure.detail ? { detail: failure.detail } : {}),
437
+ action: failure.action,
438
+ }));
349
439
  }
350
440
  },
351
441
  },
@@ -354,10 +444,10 @@ export const ownerCommands = [
354
444
  execute: noArgs('/rooms', async (ctx) => {
355
445
  const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
356
446
  const rooms = listRoomRecords().filter(room => room.state === 'active' || room.state === 'provisioning');
357
- if (!rooms.length)
358
- return ctx.reply('🏠 No rooms.');
359
- const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
360
- await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
447
+ await ctx.reply(renderMarkdownList({
448
+ icon: '🏠', title: 'Rooms', empty: 'No rooms found.',
449
+ records: rooms.map(roomListRecord),
450
+ }));
361
451
  }),
362
452
  },
363
453
  {
@@ -376,18 +466,23 @@ export const ownerCommands = [
376
466
  const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
377
467
  const r = getRoomRecord(id);
378
468
  if (!r || r.state === 'closing' || r.state === 'closed')
379
- return ctx.reply(`⚠️ room not found: ${id}`);
380
- const lines = [
381
- `🏠 Room: ${r.room_id}`,
382
- `Name: ${r.room_name}`,
383
- `State: ${r.state}`,
384
- `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
385
- ...(r.task_id ? [`Task: ${r.task_id}`] : []),
386
- ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
387
- ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
388
- `Created: ${r.created_at}`,
389
- ];
390
- await ctx.reply(lines.join('\n'));
469
+ return ctx.reply(renderMarkdownFailure({
470
+ kind: 'not_found', subject: `/room show ${id}`,
471
+ detail: `Room ${id} was not found.`, action: 'Run /room list to find a valid room ID.',
472
+ }));
473
+ await ctx.reply(renderMarkdownResult({
474
+ icon: '🏠', title: 'Room details',
475
+ fields: [
476
+ { label: 'ID', value: r.room_id, kind: 'code' },
477
+ { label: 'Name', value: r.room_name },
478
+ { label: 'Status', value: roomStatus(r.state), kind: 'markdown' },
479
+ { label: 'Saga', value: `${r.saga.phase} (step ${r.saga.step_index})` },
480
+ ...(r.task_id ? [{ label: 'Task', value: r.task_id, kind: 'code' }] : []),
481
+ ...(r.provisioning_detail ? [{ label: 'Detail', value: r.provisioning_detail }] : []),
482
+ ...(r.saga.error ? [{ label: 'Last error', value: 'Provisioning failure recorded; inspect role logs.' }] : []),
483
+ { label: 'Created', value: r.created_at, kind: 'code' },
484
+ ],
485
+ }));
391
486
  };
392
487
  try {
393
488
  switch (sub) {
@@ -413,10 +508,10 @@ export const ownerCommands = [
413
508
  ...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
414
509
  ...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
415
510
  });
416
- const lines = [`🏠 Room ${r.room_id} created · ${r.state}`];
417
- if (templateName)
418
- lines.push(`Template: ${templateName}`);
419
- await ctx.reply(lines.join('\n'));
511
+ await ctx.reply(roomAction('Room created', r, [
512
+ { label: 'Name', value: r.room_name },
513
+ ...(templateName ? [{ label: 'Template', value: templateName, kind: 'code' }] : []),
514
+ ]));
420
515
  break;
421
516
  }
422
517
  case 'list': {
@@ -425,12 +520,10 @@ export const ownerCommands = [
425
520
  let rooms = listRoomRecords().filter(room => room.state === 'active' || room.state === 'provisioning');
426
521
  if (filter === 'active')
427
522
  rooms = rooms.filter(r => r.state === 'active');
428
- if (!rooms.length) {
429
- await ctx.reply('🏠 No rooms.');
430
- break;
431
- }
432
- const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
433
- await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
523
+ await ctx.reply(renderMarkdownList({
524
+ icon: '🏠', title: 'Rooms', empty: 'No rooms found.',
525
+ records: rooms.map(roomListRecord),
526
+ }));
434
527
  break;
435
528
  }
436
529
  case 'show': {
@@ -452,20 +545,29 @@ export const ownerCommands = [
452
545
  const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
453
546
  const r = getRoomRecord(rest[0]);
454
547
  if (!r) {
455
- await ctx.reply(`⚠️ room not found: ${rest[0]}`);
548
+ await ctx.reply(renderMarkdownFailure({
549
+ kind: 'not_found', subject: `/room recover ${rest[0]}`,
550
+ detail: `Room ${rest[0]} was not found.`, action: 'Run /room list to find a valid room ID.',
551
+ }));
456
552
  break;
457
553
  }
458
554
  if (r.state === 'closing' || r.state === 'closed') {
459
555
  await ctx.closeRoom(r.room_id);
460
556
  break;
461
557
  }
462
- const lines = [`🏠 Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`];
463
- if (r.saga.error)
464
- lines.push(`Error: ${r.saga.error}`);
465
- if (r.provisioning_detail)
466
- lines.push(`Detail: ${r.provisioning_detail}`);
467
- lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
468
- await ctx.reply(lines.join('\n'));
558
+ await ctx.reply(renderMarkdownResult({
559
+ icon: '🛟', title: 'Room recovery',
560
+ fields: [
561
+ { label: 'Room', value: r.room_id, kind: 'code' },
562
+ { label: 'Status', value: roomStatus(r.state), kind: 'markdown' },
563
+ { label: 'Saga', value: r.saga.phase, kind: 'code' },
564
+ ...(r.saga.error ? [{ label: 'Last error', value: 'Provisioning failure recorded; inspect role logs.' }] : []),
565
+ ...(r.provisioning_detail ? [{ label: 'Detail', value: r.provisioning_detail }] : []),
566
+ ],
567
+ sections: [{ heading: 'Next step', items: [r.saga.error
568
+ ? 'Run ours-fleet room recover from the CLI for full recovery.'
569
+ : 'No recovery action is needed.'] }],
570
+ }));
469
571
  break;
470
572
  }
471
573
  default:
@@ -476,7 +578,13 @@ export const ownerCommands = [
476
578
  catch (e) {
477
579
  if (e instanceof OwnerCommandUsageError)
478
580
  throw e;
479
- await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
581
+ const failure = ownerRoomFailure(e);
582
+ await ctx.reply(renderMarkdownFailure({
583
+ kind: failure.kind,
584
+ subject: `/room ${sub}`,
585
+ ...(failure.detail ? { detail: failure.detail } : {}),
586
+ action: failure.action,
587
+ }));
480
588
  }
481
589
  },
482
590
  },
@@ -575,7 +683,10 @@ export async function dispatchOwnerCommand(text, ctx) {
575
683
  }
576
684
  catch (error) {
577
685
  if (error instanceof OwnerCommandUsageError)
578
- return ctx.reply(ownerCommandHelp(error.message));
686
+ return ctx.reply(renderMarkdownFailure({
687
+ kind: 'usage', subject: token, detail: error.message,
688
+ action: `Use ${command.usage ?? `/${command.name}`} or run /help.`,
689
+ }));
579
690
  // The failure notice carries no internal detail; the channel logs it.
580
691
  await ctx.reply(ownerNotices.commandFailed(command.usage ?? `/${command.name}`));
581
692
  throw error;