@ours.network/fleet 0.18.1 → 0.19.0-nightly.10
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/README.md +86 -90
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +5 -5
- package/dist/cli.js +12 -37
- package/dist/config.d.ts +14 -6
- package/dist/config.js +58 -31
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +71 -94
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +143 -12
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -200
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -70
- package/dist/harness/types.d.ts +4 -54
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +36 -30
- package/dist/owner-channel/channel.js +338 -291
- package/dist/owner-channel/commands.d.ts +9 -0
- package/dist/owner-channel/commands.js +393 -0
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/rooms-tasks/cli.d.ts +4 -0
- package/dist/rooms-tasks/cli.js +1102 -0
- package/dist/rooms-tasks/close.d.ts +21 -0
- package/dist/rooms-tasks/close.js +159 -0
- package/dist/rooms-tasks/config.d.ts +15 -0
- package/dist/rooms-tasks/config.js +174 -0
- package/dist/rooms-tasks/cowork-adapter.d.ts +77 -0
- package/dist/rooms-tasks/cowork-adapter.js +272 -0
- package/dist/rooms-tasks/external-worker.d.ts +2 -0
- package/dist/rooms-tasks/external-worker.js +40 -0
- package/dist/rooms-tasks/index.d.ts +10 -0
- package/dist/rooms-tasks/index.js +10 -0
- package/dist/rooms-tasks/provision.d.ts +21 -0
- package/dist/rooms-tasks/provision.js +243 -0
- package/dist/rooms-tasks/room-state.d.ts +28 -0
- package/dist/rooms-tasks/room-state.js +221 -0
- package/dist/rooms-tasks/task-state.d.ts +43 -0
- package/dist/rooms-tasks/task-state.js +277 -0
- package/dist/rooms-tasks/templates.d.ts +6 -0
- package/dist/rooms-tasks/templates.js +94 -0
- package/dist/rooms-tasks/terminal.d.ts +23 -0
- package/dist/rooms-tasks/terminal.js +53 -0
- package/dist/rooms-tasks/types.d.ts +187 -0
- package/dist/rooms-tasks/types.js +20 -0
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/temp-lifecycle.d.ts +9 -2
- package/dist/temp-lifecycle.js +61 -0
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { loadConfig, ConfigError } from '../config.js';
|
|
3
|
+
import { provisionMembers, getBinPath } from './provision.js';
|
|
4
|
+
import { acceptManagedRoomClose, closeManagedRoom, recordManagedRoomCloseError, } from './close.js';
|
|
5
|
+
import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerminalIntent, } from './terminal.js';
|
|
6
|
+
import { launchFleetWorker } from './external-worker.js';
|
|
7
|
+
import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
|
|
8
|
+
import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, updateTaskRoom, updateTaskTemplate, TaskStateError, } from './task-state.js';
|
|
9
|
+
import { createRoomRecord, getRoomRecord, listRoomRecords, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
|
|
10
|
+
import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
|
|
11
|
+
import { TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from './types.js';
|
|
12
|
+
function die(e) {
|
|
13
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
14
|
+
process.stderr.write(`error: ${msg}\n`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const PUBLIC_SETTLE_WAIT_MS = 60_000;
|
|
18
|
+
const PUBLIC_SETTLE_POLL_MS = 100;
|
|
19
|
+
const sleep = (ms) => new Promise(resolve => { setTimeout(resolve, ms); });
|
|
20
|
+
function errorText(error) {
|
|
21
|
+
return error instanceof Error ? error.message : String(error);
|
|
22
|
+
}
|
|
23
|
+
async function launchTaskSettleWorker(taskId, configPath) {
|
|
24
|
+
const previousErrorAt = getTask(taskId).terminal_intent?.error_at;
|
|
25
|
+
try {
|
|
26
|
+
await launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
await recordTaskTerminalIntentError(taskId, errorText(error), `External settle worker failed to start. Retry task recover ${taskId}.`);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
const task = getTask(taskId);
|
|
35
|
+
if (task.terminal_intent?.status === 'settled')
|
|
36
|
+
return { task, timedOut: false };
|
|
37
|
+
if (task.terminal_intent?.error_at !== previousErrorAt && task.terminal_intent?.error) {
|
|
38
|
+
throw new TaskStateError(task.terminal_intent.error);
|
|
39
|
+
}
|
|
40
|
+
await sleep(PUBLIC_SETTLE_POLL_MS);
|
|
41
|
+
}
|
|
42
|
+
return { task: getTask(taskId), timedOut: true };
|
|
43
|
+
}
|
|
44
|
+
async function launchRoomCloseWorker(roomId, configPath) {
|
|
45
|
+
const previousErrorAt = getRoomRecord(roomId)?.close?.error_at;
|
|
46
|
+
try {
|
|
47
|
+
await launchFleetWorker(['room', '_close', roomId], `room-close-${roomId}`, configPath);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await recordManagedRoomCloseError(roomId, errorText(error), `External close worker failed to start. Retry room recover ${roomId}.`);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
const deadline = Date.now() + PUBLIC_SETTLE_WAIT_MS;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
const room = getRoomRecord(roomId);
|
|
56
|
+
if (room.state === 'closed')
|
|
57
|
+
return { room, timedOut: false };
|
|
58
|
+
if (room.close?.error_at !== previousErrorAt && room.close?.error) {
|
|
59
|
+
throw new RoomStateError(room.close.error);
|
|
60
|
+
}
|
|
61
|
+
await sleep(PUBLIC_SETTLE_POLL_MS);
|
|
62
|
+
}
|
|
63
|
+
return { room: getRoomRecord(roomId), timedOut: true };
|
|
64
|
+
}
|
|
65
|
+
function loadCfg(opts) {
|
|
66
|
+
return loadConfig(opts.configuration);
|
|
67
|
+
}
|
|
68
|
+
function allTemplates(cfg) {
|
|
69
|
+
return cfg.roomTemplates ?? {};
|
|
70
|
+
}
|
|
71
|
+
function coworkFor(cfg) {
|
|
72
|
+
if (!cfg.rooms)
|
|
73
|
+
throw new ConfigError('rooms: configuration is required before creating or querying rooms');
|
|
74
|
+
return createCoworkAdapter({ configPath: cfg.rooms.cowork?.config });
|
|
75
|
+
}
|
|
76
|
+
function resolveRoomTemplate(cfg, name) {
|
|
77
|
+
if (!name)
|
|
78
|
+
return undefined;
|
|
79
|
+
const template = resolveTemplate(name, allTemplates(cfg));
|
|
80
|
+
if (!template)
|
|
81
|
+
throw new Error(`template not found: ${name}`);
|
|
82
|
+
return snapshotTemplate(template);
|
|
83
|
+
}
|
|
84
|
+
async function provisionRoom(cfg, input) {
|
|
85
|
+
const rooms = cfg.rooms;
|
|
86
|
+
if (!rooms)
|
|
87
|
+
throw new ConfigError('rooms: configuration is required');
|
|
88
|
+
const attachOwner = rooms.defaults?.attach_owner !== false;
|
|
89
|
+
if (attachOwner && !cfg.ownerInvite)
|
|
90
|
+
throw new ConfigError('rooms.owner: public_invite or public_invite_file is required when attach_owner is enabled');
|
|
91
|
+
const cowork = coworkFor(cfg);
|
|
92
|
+
const goal = input.goal?.trim() || input.name;
|
|
93
|
+
const briefing = input.brief?.trim() || input.template?.contract?.trim() || goal;
|
|
94
|
+
const created = await cowork.createRoom({
|
|
95
|
+
room_name: input.name,
|
|
96
|
+
goal,
|
|
97
|
+
briefing,
|
|
98
|
+
quiet_membership: input.template?.room?.quiet_membership,
|
|
99
|
+
anonymous: input.template?.room?.anonymous,
|
|
100
|
+
});
|
|
101
|
+
let record = createRoomRecord({
|
|
102
|
+
room_id: created.room_id,
|
|
103
|
+
room_name: input.name,
|
|
104
|
+
room_identity_cid: created.identity_cid,
|
|
105
|
+
task_id: input.taskId,
|
|
106
|
+
template_snapshot: input.template,
|
|
107
|
+
});
|
|
108
|
+
input.onCreated?.(record);
|
|
109
|
+
record = advanceSaga(record.room_id, 'create_room', 1);
|
|
110
|
+
if (attachOwner) {
|
|
111
|
+
try {
|
|
112
|
+
record = advanceSaga(record.room_id, 'attach_owner', 2);
|
|
113
|
+
const accepted = await cowork.acceptInvite(record.room_id, cfg.ownerInvite, {
|
|
114
|
+
role: rooms.owner.role,
|
|
115
|
+
expected_cid: rooms.owner.expected_cid,
|
|
116
|
+
});
|
|
117
|
+
record = setOwnerSeat(record.room_id, accepted.seat_cid, cfg.ownerInviteFingerprint ?? '');
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const mismatch = error instanceof CoworkProtocolError && /CID|expected/i.test(error.message);
|
|
121
|
+
setSagaError(record.room_id, error instanceof Error ? error.message : String(error), mismatch
|
|
122
|
+
? 'Verify rooms.owner.expected_cid and rotate the configured invite if necessary.'
|
|
123
|
+
: 'Rotate rooms.owner.public_invite or public_invite_file, then run room recover.', mismatch ? 'owner_cid_mismatch' : 'waiting_owner_invite');
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
record = advanceSaga(record.room_id, 'create_members', 3);
|
|
128
|
+
if (input.template && input.template.members.length > 0) {
|
|
129
|
+
record = await provisionMembers({
|
|
130
|
+
cfg,
|
|
131
|
+
cowork,
|
|
132
|
+
roomId: record.room_id,
|
|
133
|
+
taskId: input.taskId,
|
|
134
|
+
template: input.template,
|
|
135
|
+
binPath: getBinPath(),
|
|
136
|
+
brief: input.brief,
|
|
137
|
+
goal: input.goal,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
record = activateRoom(record.room_id);
|
|
142
|
+
if (input.taskId)
|
|
143
|
+
activateTask(input.taskId);
|
|
144
|
+
}
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
147
|
+
export function registerTemplateCommands(parent, cOpt) {
|
|
148
|
+
const templateCmd = parent.command('template').description('room template operations');
|
|
149
|
+
cOpt(templateCmd.command('list'))
|
|
150
|
+
.description('list available room templates')
|
|
151
|
+
.option('--json', 'JSON output')
|
|
152
|
+
.action((opts) => {
|
|
153
|
+
try {
|
|
154
|
+
const cfg = loadCfg(opts);
|
|
155
|
+
const templates = listTemplates(allTemplates(cfg));
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
console.log(JSON.stringify({ schema_version: 1, templates }, null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!templates.length) {
|
|
161
|
+
console.log('No templates configured.');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
for (const t of templates) {
|
|
165
|
+
const tag = t.builtin ? ' (built-in)' : '';
|
|
166
|
+
console.log(`${t.name}@${t.version}${tag} ${t.description}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
die(e);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
cOpt(templateCmd.command('show <name>'))
|
|
174
|
+
.description('show template details')
|
|
175
|
+
.option('--json', 'JSON output')
|
|
176
|
+
.action((name, opts) => {
|
|
177
|
+
try {
|
|
178
|
+
const cfg = loadCfg(opts);
|
|
179
|
+
const t = resolveTemplate(name, allTemplates(cfg));
|
|
180
|
+
if (!t)
|
|
181
|
+
die(new Error(`template not found: ${name}`));
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
console.log(JSON.stringify({ schema_version: 1, template: { ...t, content_hash: hashTemplate(t) } }, null, 2));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log(`${t.name}@${t.version} ${t.description}`);
|
|
187
|
+
if (t.contract)
|
|
188
|
+
console.log(`\nContract:\n${t.contract}`);
|
|
189
|
+
console.log(`\nMembers:`);
|
|
190
|
+
for (const m of t.members)
|
|
191
|
+
console.log(` ${m.slot}: ${m.count}× ${m.role} (ref: ${m.role_ref})`);
|
|
192
|
+
console.log(`\nContent hash: ${hashTemplate(t)}`);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
die(e);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
cOpt(templateCmd.command('validate'))
|
|
199
|
+
.description('validate all room templates')
|
|
200
|
+
.option('--json', 'JSON output')
|
|
201
|
+
.action((opts) => {
|
|
202
|
+
try {
|
|
203
|
+
const cfg = loadCfg(opts);
|
|
204
|
+
const templates = listTemplates(allTemplates(cfg));
|
|
205
|
+
const problems = [];
|
|
206
|
+
for (const t of templates) {
|
|
207
|
+
const issues = [];
|
|
208
|
+
for (const m of t.members) {
|
|
209
|
+
if (!cfg.roles.some(r => r.name === m.role_ref))
|
|
210
|
+
issues.push(`member ${m.slot}: role_ref '${m.role_ref}' not found in fleet roles`);
|
|
211
|
+
}
|
|
212
|
+
if (issues.length)
|
|
213
|
+
problems.push({ template: `${t.name}@${t.version}`, issues });
|
|
214
|
+
}
|
|
215
|
+
if (opts.json) {
|
|
216
|
+
console.log(JSON.stringify({ schema_version: 1, valid: !problems.length, problems }, null, 2));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!problems.length) {
|
|
220
|
+
console.log('All templates valid.');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const p of problems) {
|
|
224
|
+
console.log(`${p.template}:`);
|
|
225
|
+
for (const i of p.issues)
|
|
226
|
+
console.log(` - ${i}`);
|
|
227
|
+
}
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
die(e);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
export function registerTaskCommands(parent, cOpt) {
|
|
236
|
+
const taskCmd = parent.command('task').description('task lifecycle operations');
|
|
237
|
+
cOpt(taskCmd.command('create'))
|
|
238
|
+
.description('create a new task')
|
|
239
|
+
.requiredOption('--title <title>', 'task title')
|
|
240
|
+
.option('--template <name>', 'room template')
|
|
241
|
+
.option('--brief <text>', 'task brief')
|
|
242
|
+
.option('--brief-file <path>', 'task brief from file')
|
|
243
|
+
.option('--backlog', 'create in backlog (do not start immediately)')
|
|
244
|
+
.option('--no-room', 'create task without a room')
|
|
245
|
+
.option('--idempotency-key <key>', 'idempotency key')
|
|
246
|
+
.option('--json', 'JSON output')
|
|
247
|
+
.action(async (opts) => {
|
|
248
|
+
try {
|
|
249
|
+
const cfg = loadCfg(opts);
|
|
250
|
+
let templateRef;
|
|
251
|
+
if (opts.template) {
|
|
252
|
+
const t = resolveTemplate(opts.template, allTemplates(cfg));
|
|
253
|
+
if (!t)
|
|
254
|
+
die(new Error(`template not found: ${opts.template}`));
|
|
255
|
+
const snap = snapshotTemplate(t);
|
|
256
|
+
templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
|
|
257
|
+
}
|
|
258
|
+
else if (opts.room !== false) {
|
|
259
|
+
const defaultTpl = cfg.tasks?.default_room_template ?? cfg.rooms?.defaults?.template ?? 'team';
|
|
260
|
+
const t = resolveTemplate(defaultTpl, allTemplates(cfg));
|
|
261
|
+
if (t) {
|
|
262
|
+
const snap = snapshotTemplate(t);
|
|
263
|
+
templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let brief = opts.brief;
|
|
267
|
+
if (opts.briefFile) {
|
|
268
|
+
brief = readFileSync(opts.briefFile, 'utf8');
|
|
269
|
+
}
|
|
270
|
+
const origin = { type: 'cli' };
|
|
271
|
+
let record = createTask({
|
|
272
|
+
title: opts.title,
|
|
273
|
+
brief,
|
|
274
|
+
brief_file: opts.briefFile,
|
|
275
|
+
template: templateRef,
|
|
276
|
+
origin,
|
|
277
|
+
idempotency_key: opts.idempotencyKey,
|
|
278
|
+
start: !opts.backlog,
|
|
279
|
+
});
|
|
280
|
+
if (!opts.backlog && opts.room !== false && templateRef && !record.room_id) {
|
|
281
|
+
const template = resolveRoomTemplate(cfg, templateRef.name);
|
|
282
|
+
try {
|
|
283
|
+
await provisionRoom(cfg, {
|
|
284
|
+
name: opts.title,
|
|
285
|
+
goal: opts.title,
|
|
286
|
+
brief,
|
|
287
|
+
template,
|
|
288
|
+
taskId: record.task_id,
|
|
289
|
+
onCreated: room => {
|
|
290
|
+
record = updateTaskRoom(record.task_id, room.room_id, room.room_identity_cid);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
record = getTask(record.task_id);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error instanceof CoworkUnavailableError) {
|
|
297
|
+
blockTask(record.task_id, 'Cowork management socket is unavailable');
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (opts.json) {
|
|
303
|
+
console.log(JSON.stringify({ schema_version: 1, task: record }, null, 2));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
console.log(`Task ${record.task_id} created · ${record.state}`);
|
|
307
|
+
if (templateRef)
|
|
308
|
+
console.log(`Template: ${templateRef.name}@${templateRef.version}`);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
die(e);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
cOpt(taskCmd.command('list'))
|
|
315
|
+
.description('list tasks')
|
|
316
|
+
.option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
|
|
317
|
+
.option('--json', 'JSON output')
|
|
318
|
+
.action((opts) => {
|
|
319
|
+
try {
|
|
320
|
+
let stateFilter;
|
|
321
|
+
if (opts.state && opts.state !== 'all') {
|
|
322
|
+
stateFilter = opts.state;
|
|
323
|
+
}
|
|
324
|
+
const tasks = listTasks(stateFilter ? { state: stateFilter } : undefined);
|
|
325
|
+
if (opts.json) {
|
|
326
|
+
console.log(JSON.stringify({ schema_version: 1, tasks }, null, 2));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!tasks.length) {
|
|
330
|
+
console.log('No tasks.');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
for (const t of tasks) {
|
|
334
|
+
const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
|
|
335
|
+
console.log(`${t.task_id} ${t.state}${blocked} ${t.title}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
die(e);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
taskCmd.command('show <id>')
|
|
343
|
+
.description('show task details')
|
|
344
|
+
.option('--json', 'JSON output')
|
|
345
|
+
.action((id, opts) => {
|
|
346
|
+
try {
|
|
347
|
+
const t = getTask(id);
|
|
348
|
+
if (opts.json) {
|
|
349
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
console.log(`Task: ${t.task_id}`);
|
|
353
|
+
console.log(`Title: ${t.title}`);
|
|
354
|
+
console.log(`State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`);
|
|
355
|
+
if (t.template)
|
|
356
|
+
console.log(`Template: ${t.template.name}@${t.template.version}`);
|
|
357
|
+
if (t.room_id)
|
|
358
|
+
console.log(`Room: ${t.room_id}`);
|
|
359
|
+
if (t.room_identity_cid)
|
|
360
|
+
console.log(`Room CID: ${t.room_identity_cid}`);
|
|
361
|
+
if (t.member_roles.length) {
|
|
362
|
+
console.log('Members:');
|
|
363
|
+
for (const m of t.member_roles)
|
|
364
|
+
console.log(` ${m.name} (${m.cowork_role}) ${m.identity_cid}`);
|
|
365
|
+
}
|
|
366
|
+
console.log(`Origin: ${t.origin.type}`);
|
|
367
|
+
console.log(`Created: ${t.created_at}`);
|
|
368
|
+
if (t.started_at)
|
|
369
|
+
console.log(`Started: ${t.started_at}`);
|
|
370
|
+
if (t.ended_at)
|
|
371
|
+
console.log(`Ended: ${t.ended_at}`);
|
|
372
|
+
if (t.outcome)
|
|
373
|
+
console.log(`Outcome: ${t.outcome.summary}`);
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
die(e);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
cOpt(taskCmd.command('start <id>'))
|
|
380
|
+
.description('start a backlog task')
|
|
381
|
+
.option('--json', 'JSON output')
|
|
382
|
+
.action(async (id, opts) => {
|
|
383
|
+
try {
|
|
384
|
+
const cfg = loadCfg(opts);
|
|
385
|
+
let t = startTask(id);
|
|
386
|
+
if (t.template && !t.room_id) {
|
|
387
|
+
const template = resolveRoomTemplate(cfg, t.template.name);
|
|
388
|
+
if (!template || template.content_hash !== t.template.content_hash)
|
|
389
|
+
throw new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`);
|
|
390
|
+
await provisionRoom(cfg, {
|
|
391
|
+
name: t.title,
|
|
392
|
+
goal: t.title,
|
|
393
|
+
brief: t.brief,
|
|
394
|
+
template,
|
|
395
|
+
taskId: t.task_id,
|
|
396
|
+
onCreated: room => {
|
|
397
|
+
t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
t = getTask(t.task_id);
|
|
401
|
+
}
|
|
402
|
+
if (opts.json) {
|
|
403
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
console.log(`Task ${t.task_id} · provisioning`);
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
die(e);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
taskCmd.command('block <id>')
|
|
413
|
+
.description('mark a task as blocked')
|
|
414
|
+
.requiredOption('--reason <reason>', 'blocking reason')
|
|
415
|
+
.option('--json', 'JSON output')
|
|
416
|
+
.action((id, opts) => {
|
|
417
|
+
try {
|
|
418
|
+
const t = blockTask(id, opts.reason);
|
|
419
|
+
if (opts.json) {
|
|
420
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
console.log(`Task ${t.task_id} · blocked: ${opts.reason}`);
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
die(e);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
taskCmd.command('unblock <id>')
|
|
430
|
+
.description('unblock a task')
|
|
431
|
+
.option('--json', 'JSON output')
|
|
432
|
+
.action((id, opts) => {
|
|
433
|
+
try {
|
|
434
|
+
const t = unblockTask(id);
|
|
435
|
+
if (opts.json) {
|
|
436
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
console.log(`Task ${t.task_id} · unblocked`);
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
die(e);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
taskCmd.command('review <id>')
|
|
446
|
+
.description('move a task to review')
|
|
447
|
+
.option('--json', 'JSON output')
|
|
448
|
+
.action((id, opts) => {
|
|
449
|
+
try {
|
|
450
|
+
const t = reviewTask(id);
|
|
451
|
+
if (opts.json) {
|
|
452
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
console.log(`Task ${t.task_id} · review`);
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
die(e);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
cOpt(taskCmd.command('done <id>'))
|
|
462
|
+
.description('complete a task')
|
|
463
|
+
.option('--summary <text>', 'completion summary')
|
|
464
|
+
.option('--summary-file <path>', 'completion summary from file')
|
|
465
|
+
.option('--json', 'JSON output')
|
|
466
|
+
.action(async (id, opts) => {
|
|
467
|
+
try {
|
|
468
|
+
let summary = opts.summary;
|
|
469
|
+
if (opts.summaryFile)
|
|
470
|
+
summary = readFileSync(opts.summaryFile, 'utf8');
|
|
471
|
+
const outcome = summary ? { summary } : undefined;
|
|
472
|
+
const current = getTask(id);
|
|
473
|
+
if (TASK_TERMINAL_STATES.includes(current.state))
|
|
474
|
+
throw new TaskStateError(`task ${id} is already in terminal state '${current.state}'`);
|
|
475
|
+
if (current.state !== 'review')
|
|
476
|
+
throw new TaskStateError(`cannot transition from '${current.state}' to 'done'`);
|
|
477
|
+
let roomId;
|
|
478
|
+
let cfg;
|
|
479
|
+
if (current.room_id) {
|
|
480
|
+
cfg = loadCfg(opts);
|
|
481
|
+
const shouldClose = cfg.tasks?.close_room_on_done
|
|
482
|
+
?? cfg.rooms?.defaults?.close_when_task_done
|
|
483
|
+
?? false;
|
|
484
|
+
if (shouldClose)
|
|
485
|
+
roomId = current.room_id;
|
|
486
|
+
}
|
|
487
|
+
await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId, outcome });
|
|
488
|
+
const settled = roomId
|
|
489
|
+
? await launchTaskSettleWorker(id, opts.configuration)
|
|
490
|
+
: { task: getTask(id), timedOut: false };
|
|
491
|
+
const t = settled.task;
|
|
492
|
+
if (opts.json) {
|
|
493
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (settled.timedOut) {
|
|
497
|
+
console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
console.log(`Task ${t.task_id} · done`);
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
die(e);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
cOpt(taskCmd.command('cancel <id> <confirm-id>'))
|
|
507
|
+
.description('cancel a task (requires ID twice for confirmation)')
|
|
508
|
+
.option('--json', 'JSON output')
|
|
509
|
+
.action(async (id, confirmId, opts) => {
|
|
510
|
+
try {
|
|
511
|
+
if (id !== confirmId)
|
|
512
|
+
die(new Error('confirmation ID must match task ID'));
|
|
513
|
+
const current = getTask(id);
|
|
514
|
+
if (!TASK_CANCELLABLE_STATES.includes(current.state))
|
|
515
|
+
throw new TaskStateError(`cannot cancel a '${current.state}' task`);
|
|
516
|
+
if (current.room_id)
|
|
517
|
+
loadCfg(opts);
|
|
518
|
+
await acceptTaskTerminalIntent({
|
|
519
|
+
taskId: id, kind: 'cancelled', roomId: current.room_id,
|
|
520
|
+
});
|
|
521
|
+
const settled = current.room_id
|
|
522
|
+
? await launchTaskSettleWorker(id, opts.configuration)
|
|
523
|
+
: { task: getTask(id), timedOut: false };
|
|
524
|
+
const t = settled.task;
|
|
525
|
+
if (opts.json) {
|
|
526
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (settled.timedOut) {
|
|
530
|
+
console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
console.log(`Task ${t.task_id} · cancelled`);
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
die(e);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
cOpt(taskCmd.command('recover <id>'))
|
|
540
|
+
.description('attempt to recover a stuck task')
|
|
541
|
+
.option('--json', 'JSON output')
|
|
542
|
+
.action(async (id, opts) => {
|
|
543
|
+
try {
|
|
544
|
+
const cfg = loadCfg(opts);
|
|
545
|
+
let t = getTask(id);
|
|
546
|
+
let terminalTimedOut = false;
|
|
547
|
+
if (t.terminal_intent?.status === 'pending') {
|
|
548
|
+
const settled = await launchTaskSettleWorker(id, opts.configuration);
|
|
549
|
+
t = settled.task;
|
|
550
|
+
terminalTimedOut = settled.timedOut;
|
|
551
|
+
}
|
|
552
|
+
const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
|
|
553
|
+
const result = {
|
|
554
|
+
task: t,
|
|
555
|
+
room: room ?? null,
|
|
556
|
+
recovery_actions: [],
|
|
557
|
+
};
|
|
558
|
+
if (terminalTimedOut) {
|
|
559
|
+
result.recovery_actions.push(`Terminal intent remains pending — retry task recover ${id}`);
|
|
560
|
+
}
|
|
561
|
+
if (t.state === 'provisioning' && room) {
|
|
562
|
+
if (room.provisioning_detail === 'waiting_cowork')
|
|
563
|
+
result.recovery_actions.push('Cowork management socket unreachable — check ours-cowork service');
|
|
564
|
+
if (room.provisioning_detail === 'waiting_owner_invite')
|
|
565
|
+
result.recovery_actions.push('Owner invite invalid or expired — rotate rooms.owner.public_invite in config');
|
|
566
|
+
if (room.provisioning_detail === 'owner_cid_mismatch')
|
|
567
|
+
result.recovery_actions.push('Owner CID mismatch — verify rooms.owner.expected_cid matches Messenger identity');
|
|
568
|
+
if (room.provisioning_detail === 'member_failed')
|
|
569
|
+
result.recovery_actions.push(`Member creation failed at saga step ${room.saga.step_index} — inspect and retry`);
|
|
570
|
+
const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
|
|
571
|
+
if (resumable.includes(room.saga.phase)) {
|
|
572
|
+
const template = t.template ? resolveRoomTemplate(cfg, t.template.name) : undefined;
|
|
573
|
+
if (template) {
|
|
574
|
+
try {
|
|
575
|
+
await provisionMembers({
|
|
576
|
+
cfg,
|
|
577
|
+
cowork: coworkFor(cfg),
|
|
578
|
+
roomId: room.room_id,
|
|
579
|
+
taskId: t.task_id,
|
|
580
|
+
template,
|
|
581
|
+
binPath: getBinPath(),
|
|
582
|
+
brief: t.brief,
|
|
583
|
+
goal: t.title,
|
|
584
|
+
});
|
|
585
|
+
result.recovery_actions.push('Provisioning resumed successfully');
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
result.recovery_actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (opts.json) {
|
|
594
|
+
console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
console.log(`Task ${t.task_id} · ${t.state}`);
|
|
598
|
+
if (room)
|
|
599
|
+
console.log(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
|
|
600
|
+
if (result.recovery_actions.length) {
|
|
601
|
+
console.log('Recovery actions:');
|
|
602
|
+
for (const a of result.recovery_actions)
|
|
603
|
+
console.log(` - ${a}`);
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
console.log('No automated recovery actions available.');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
die(e);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
cOpt(taskCmd.command('_settle <id>', { hidden: true }))
|
|
614
|
+
.description('internal: settle a previously accepted task terminal intent')
|
|
615
|
+
.option('--json', 'JSON output')
|
|
616
|
+
.action(async (id, opts) => {
|
|
617
|
+
try {
|
|
618
|
+
const cfg = loadCfg(opts);
|
|
619
|
+
const t = await settleTaskTerminalIntent({ taskId: id, cowork: coworkFor(cfg) });
|
|
620
|
+
if (opts.json) {
|
|
621
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
console.log(`Task ${t.task_id} · ${t.state}`);
|
|
625
|
+
}
|
|
626
|
+
catch (e) {
|
|
627
|
+
await recordTaskTerminalIntentError(id, errorText(e), `External settle worker failed. Retry task recover ${id}.`).catch(() => { });
|
|
628
|
+
die(e);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
cOpt(taskCmd.command('work <id>'))
|
|
632
|
+
.description('ensure a task has a room and agents running')
|
|
633
|
+
.option('--template <name>', 'room template')
|
|
634
|
+
.option('--json', 'JSON output')
|
|
635
|
+
.action(async (id, opts) => {
|
|
636
|
+
try {
|
|
637
|
+
const cfg = loadCfg(opts);
|
|
638
|
+
let t = getTask(id);
|
|
639
|
+
if (TASK_TERMINAL_STATES.includes(t.state))
|
|
640
|
+
die(new Error(`task ${id} is in terminal state '${t.state}'`));
|
|
641
|
+
if (t.state === 'active' && t.room_id) {
|
|
642
|
+
// An explicit --template must agree with the room the task already
|
|
643
|
+
// runs in; a conflicting override is an error, not a silent no-op.
|
|
644
|
+
if (opts.template) {
|
|
645
|
+
const override = resolveTemplate(opts.template, allTemplates(cfg));
|
|
646
|
+
if (!override)
|
|
647
|
+
die(new Error(`template not found: ${opts.template}`));
|
|
648
|
+
const overrideSnap = snapshotTemplate(override);
|
|
649
|
+
const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
|
|
650
|
+
if (roomSnap && (roomSnap.name !== overrideSnap.name || roomSnap.content_hash !== overrideSnap.content_hash))
|
|
651
|
+
die(new Error(`template ${overrideSnap.name}@${overrideSnap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
|
|
652
|
+
}
|
|
653
|
+
if (opts.json) {
|
|
654
|
+
console.log(JSON.stringify({ schema_version: 1, task: t, status: 'already_active' }, null, 2));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
console.log('Task already has an active room');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Select and validate the template snapshot before any state change or
|
|
661
|
+
// provisioning. An explicit --template re-pins the task; a stored ref
|
|
662
|
+
// must still match its snapshot (same contract as `task start`).
|
|
663
|
+
const templateName = opts.template
|
|
664
|
+
?? t.template?.name
|
|
665
|
+
?? cfg.tasks?.default_room_template
|
|
666
|
+
?? cfg.rooms?.defaults?.template
|
|
667
|
+
?? 'single';
|
|
668
|
+
const resolved = resolveTemplate(templateName, allTemplates(cfg));
|
|
669
|
+
if (!resolved)
|
|
670
|
+
die(new Error(`template not found: ${templateName}`));
|
|
671
|
+
const snap = snapshotTemplate(resolved);
|
|
672
|
+
if (!opts.template && t.template && snap.content_hash !== t.template.content_hash)
|
|
673
|
+
die(new Error(`task template snapshot no longer matches ${t.template.name}@${t.template.version}`));
|
|
674
|
+
// A provisioned room is pinned to its snapshot: never resume or re-pin
|
|
675
|
+
// an existing room under a different template.
|
|
676
|
+
if (t.room_id) {
|
|
677
|
+
const roomSnap = getRoomRecord(t.room_id)?.template_snapshot ?? t.template;
|
|
678
|
+
if (roomSnap && (roomSnap.name !== snap.name || roomSnap.content_hash !== snap.content_hash))
|
|
679
|
+
die(new Error(`template ${snap.name}@${snap.version} does not match room ${t.room_id}'s provisioned template ${roomSnap.name}@${roomSnap.version}`));
|
|
680
|
+
}
|
|
681
|
+
let templateRef = t.template;
|
|
682
|
+
if (!templateRef || templateRef.name !== snap.name || templateRef.content_hash !== snap.content_hash) {
|
|
683
|
+
templateRef = { name: snap.name, version: snap.version, content_hash: snap.content_hash };
|
|
684
|
+
t = updateTaskTemplate(t.task_id, templateRef);
|
|
685
|
+
}
|
|
686
|
+
if (t.state === 'backlog') {
|
|
687
|
+
t = startTask(id);
|
|
688
|
+
}
|
|
689
|
+
if (!t.room_id) {
|
|
690
|
+
try {
|
|
691
|
+
await provisionRoom(cfg, {
|
|
692
|
+
name: t.title,
|
|
693
|
+
goal: t.title,
|
|
694
|
+
brief: t.brief,
|
|
695
|
+
template: snap,
|
|
696
|
+
taskId: t.task_id,
|
|
697
|
+
onCreated: room => {
|
|
698
|
+
t = updateTaskRoom(t.task_id, room.room_id, room.room_identity_cid);
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
t = getTask(t.task_id);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (error instanceof CoworkUnavailableError) {
|
|
705
|
+
blockTask(t.task_id, 'Cowork management socket is unavailable');
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
else if (t.state === 'provisioning') {
|
|
711
|
+
// The task already has a room mid-provisioning (e.g. seats were still
|
|
712
|
+
// pending on the last run). Resume it exactly as `task recover` does.
|
|
713
|
+
const room = getRoomRecord(t.room_id);
|
|
714
|
+
const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
|
|
715
|
+
if (room && resumable.includes(room.saga.phase)) {
|
|
716
|
+
try {
|
|
717
|
+
await provisionMembers({
|
|
718
|
+
cfg,
|
|
719
|
+
cowork: coworkFor(cfg),
|
|
720
|
+
roomId: room.room_id,
|
|
721
|
+
taskId: t.task_id,
|
|
722
|
+
template: snap,
|
|
723
|
+
binPath: getBinPath(),
|
|
724
|
+
brief: t.brief,
|
|
725
|
+
goal: t.title,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
if (error instanceof CoworkUnavailableError) {
|
|
730
|
+
blockTask(t.task_id, 'Cowork management socket is unavailable');
|
|
731
|
+
}
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
t = getTask(t.task_id);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
die(new Error(`task ${id} has room ${t.room_id} in a non-resumable state — run 'task recover ${id}'`));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (opts.json) {
|
|
741
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
console.log(`Task ${t.task_id} · ${t.state}`);
|
|
745
|
+
if (templateRef)
|
|
746
|
+
console.log(`Template: ${templateRef.name}@${templateRef.version}`);
|
|
747
|
+
if (t.room_id)
|
|
748
|
+
console.log(`Room: ${t.room_id}`);
|
|
749
|
+
if (t.member_roles.length) {
|
|
750
|
+
console.log('Agents:');
|
|
751
|
+
for (const m of t.member_roles)
|
|
752
|
+
console.log(` ${m.name} (${m.cowork_role})`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch (e) {
|
|
756
|
+
die(e);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
cOpt(taskCmd.command('finish <id>'))
|
|
760
|
+
.description('finish a task: transition to done and close its room')
|
|
761
|
+
.option('--summary <text>', 'completion summary')
|
|
762
|
+
.option('--summary-file <path>', 'completion summary from file')
|
|
763
|
+
.option('--json', 'JSON output')
|
|
764
|
+
.action(async (id, opts) => {
|
|
765
|
+
try {
|
|
766
|
+
let summary = opts.summary;
|
|
767
|
+
if (opts.summaryFile)
|
|
768
|
+
summary = readFileSync(opts.summaryFile, 'utf8');
|
|
769
|
+
const outcome = summary ? { summary } : undefined;
|
|
770
|
+
let t = getTask(id);
|
|
771
|
+
if (TASK_TERMINAL_STATES.includes(t.state))
|
|
772
|
+
die(new Error(`task ${id} is already in terminal state '${t.state}'`));
|
|
773
|
+
if (t.state === 'active') {
|
|
774
|
+
reviewTask(id);
|
|
775
|
+
}
|
|
776
|
+
if (t.room_id)
|
|
777
|
+
loadCfg(opts); // validate configuration before durable acceptance
|
|
778
|
+
await acceptTaskTerminalIntent({ taskId: id, kind: 'done', roomId: t.room_id, outcome });
|
|
779
|
+
const settled = t.room_id
|
|
780
|
+
? await launchTaskSettleWorker(id, opts.configuration)
|
|
781
|
+
: { task: getTask(id), timedOut: false };
|
|
782
|
+
t = settled.task;
|
|
783
|
+
if (opts.json) {
|
|
784
|
+
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (settled.timedOut) {
|
|
788
|
+
console.log(`Task ${t.task_id} · ${t.state} (terminal intent accepted/pending; run task recover ${id})`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
console.log(`Task ${t.task_id} · done`);
|
|
792
|
+
}
|
|
793
|
+
catch (e) {
|
|
794
|
+
die(e);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
export function registerRoomCommands(parent, cOpt) {
|
|
799
|
+
const roomCmd = parent.command('room').description('room orchestration operations');
|
|
800
|
+
cOpt(roomCmd.command('create'))
|
|
801
|
+
.description('create a standalone room')
|
|
802
|
+
.requiredOption('--name <name>', 'room name')
|
|
803
|
+
.option('--template <name>', 'room template')
|
|
804
|
+
.option('--goal <text>', 'room goal')
|
|
805
|
+
.option('--brief <text>', 'room briefing')
|
|
806
|
+
.option('--brief-file <path>', 'room briefing from file')
|
|
807
|
+
.option('--json', 'JSON output')
|
|
808
|
+
.action(async (opts) => {
|
|
809
|
+
try {
|
|
810
|
+
const cfg = loadCfg(opts);
|
|
811
|
+
let brief = opts.brief;
|
|
812
|
+
if (opts.briefFile)
|
|
813
|
+
brief = readFileSync(opts.briefFile, 'utf8');
|
|
814
|
+
let templateSnapshot;
|
|
815
|
+
if (opts.template) {
|
|
816
|
+
const t = resolveTemplate(opts.template, allTemplates(cfg));
|
|
817
|
+
if (!t)
|
|
818
|
+
die(new Error(`template not found: ${opts.template}`));
|
|
819
|
+
templateSnapshot = snapshotTemplate(t);
|
|
820
|
+
}
|
|
821
|
+
const record = await provisionRoom(cfg, {
|
|
822
|
+
name: opts.name,
|
|
823
|
+
goal: opts.goal,
|
|
824
|
+
brief,
|
|
825
|
+
template: templateSnapshot,
|
|
826
|
+
});
|
|
827
|
+
if (opts.json) {
|
|
828
|
+
console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
console.log(`Room ${record.room_id} created · ${record.state}`);
|
|
832
|
+
if (templateSnapshot)
|
|
833
|
+
console.log(`Template: ${templateSnapshot.name}@${templateSnapshot.version}`);
|
|
834
|
+
}
|
|
835
|
+
catch (e) {
|
|
836
|
+
die(e);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
cOpt(roomCmd.command('list'))
|
|
840
|
+
.description('list rooms')
|
|
841
|
+
.option('--state <state>', 'filter by state (active|provisioning|closing|closed|all)')
|
|
842
|
+
.option('--json', 'JSON output')
|
|
843
|
+
.action(async (opts) => {
|
|
844
|
+
try {
|
|
845
|
+
const cfg = loadCfg(opts);
|
|
846
|
+
let stateFilter;
|
|
847
|
+
if (opts.state && opts.state !== 'all') {
|
|
848
|
+
stateFilter = opts.state;
|
|
849
|
+
}
|
|
850
|
+
const local = new Map(listRoomRecords().map(room => [room.room_id, room]));
|
|
851
|
+
const coworkRooms = await coworkFor(cfg).listRooms();
|
|
852
|
+
const rooms = coworkRooms
|
|
853
|
+
.filter(room => !stateFilter || room.state === stateFilter)
|
|
854
|
+
.map(room => ({ ...room, orchestration: local.get(room.room_id) ?? null }));
|
|
855
|
+
if (opts.json) {
|
|
856
|
+
console.log(JSON.stringify({ schema_version: 1, rooms }, null, 2));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (!rooms.length) {
|
|
860
|
+
console.log('No rooms.');
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
for (const r of rooms)
|
|
864
|
+
console.log(`${r.room_id} ${r.state} ${r.room_name}${r.orchestration?.task_id ? ` (task: ${r.orchestration.task_id})` : ''}`);
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
die(e);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
cOpt(roomCmd.command('show <id>'))
|
|
871
|
+
.description('show room details')
|
|
872
|
+
.option('--json', 'JSON output')
|
|
873
|
+
.action(async (id, opts) => {
|
|
874
|
+
try {
|
|
875
|
+
const cfg = loadCfg(opts);
|
|
876
|
+
const cowork = await coworkFor(cfg).getRoom(id);
|
|
877
|
+
if (!cowork)
|
|
878
|
+
die(new Error(`room not found: ${id}`));
|
|
879
|
+
const r = getRoomRecord(id);
|
|
880
|
+
if (opts.json) {
|
|
881
|
+
console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null }, null, 2));
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
console.log(`Room: ${cowork.room_id}`);
|
|
885
|
+
console.log(`Name: ${cowork.room_name}`);
|
|
886
|
+
console.log(`State: ${cowork.state}`);
|
|
887
|
+
console.log(`Identity CID: ${cowork.identity_cid}`);
|
|
888
|
+
if (r?.task_id)
|
|
889
|
+
console.log(`Task: ${r.task_id}`);
|
|
890
|
+
if (r)
|
|
891
|
+
console.log(`Saga: ${r.saga.phase} (step ${r.saga.step_index})`);
|
|
892
|
+
if (r?.provisioning_detail)
|
|
893
|
+
console.log(`Detail: ${r.provisioning_detail}`);
|
|
894
|
+
if (r?.saga.error)
|
|
895
|
+
console.log(`Error: ${r.saga.error}`);
|
|
896
|
+
if (cowork.seats.length) {
|
|
897
|
+
console.log('Members:');
|
|
898
|
+
for (const s of cowork.seats)
|
|
899
|
+
console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
|
|
900
|
+
}
|
|
901
|
+
if (r)
|
|
902
|
+
console.log(`Tracked by Fleet since: ${r.created_at}`);
|
|
903
|
+
}
|
|
904
|
+
catch (e) {
|
|
905
|
+
die(e);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
cOpt(roomCmd.command('open <id>'))
|
|
909
|
+
.description('open room in Cowork local console')
|
|
910
|
+
.option('--json', 'JSON output')
|
|
911
|
+
.action(async (id, opts) => {
|
|
912
|
+
try {
|
|
913
|
+
const cfg = loadCfg(opts);
|
|
914
|
+
const room = await coworkFor(cfg).getRoom(id);
|
|
915
|
+
if (!room)
|
|
916
|
+
die(new Error(`room not found: ${id}`));
|
|
917
|
+
const url = `http://localhost:4460/room/${id}`;
|
|
918
|
+
if (opts.json) {
|
|
919
|
+
console.log(JSON.stringify({ schema_version: 1, room_id: id, url, room_name: room.room_name }, null, 2));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
console.log(`Room ${id} — ${room.room_name}`);
|
|
923
|
+
console.log(`Local console: ${url}`);
|
|
924
|
+
}
|
|
925
|
+
catch (e) {
|
|
926
|
+
die(e);
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
cOpt(roomCmd.command('members <id>'))
|
|
930
|
+
.description('show room members')
|
|
931
|
+
.option('--json', 'JSON output')
|
|
932
|
+
.action(async (id, opts) => {
|
|
933
|
+
try {
|
|
934
|
+
const cfg = loadCfg(opts);
|
|
935
|
+
const r = getRoomRecord(id);
|
|
936
|
+
const members = await coworkFor(cfg).getSeats(id);
|
|
937
|
+
if (opts.json) {
|
|
938
|
+
console.log(JSON.stringify({
|
|
939
|
+
schema_version: 1,
|
|
940
|
+
room_id: id,
|
|
941
|
+
members,
|
|
942
|
+
owner_seat_cid: r?.owner_seat_cid ?? null,
|
|
943
|
+
}, null, 2));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
console.log(`Room ${id}${r ? ` — ${r.room_name}` : ''}`);
|
|
947
|
+
if (r?.owner_seat_cid)
|
|
948
|
+
console.log(`Owner: ${r.owner_seat_cid}`);
|
|
949
|
+
if (!members.length) {
|
|
950
|
+
console.log('No members.');
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
for (const s of members)
|
|
954
|
+
console.log(` ${s.identity_cid} (${s.role}) ${s.seat_state}`);
|
|
955
|
+
}
|
|
956
|
+
catch (e) {
|
|
957
|
+
die(e);
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
cOpt(roomCmd.command('close <id> <confirm-id>'))
|
|
961
|
+
.description('close a room (requires ID twice for confirmation)')
|
|
962
|
+
.option('--json', 'JSON output')
|
|
963
|
+
.action(async (id, confirmId, opts) => {
|
|
964
|
+
try {
|
|
965
|
+
if (id !== confirmId)
|
|
966
|
+
die(new Error('confirmation ID must match room ID'));
|
|
967
|
+
coworkFor(loadCfg(opts));
|
|
968
|
+
const existing = getRoomRecord(id);
|
|
969
|
+
if (!existing)
|
|
970
|
+
throw new Error(`room not found in Fleet orchestration: ${id}`);
|
|
971
|
+
await acceptManagedRoomClose(id);
|
|
972
|
+
const settled = await launchRoomCloseWorker(id, opts.configuration);
|
|
973
|
+
const r = settled.room;
|
|
974
|
+
if (opts.json) {
|
|
975
|
+
console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (settled.timedOut) {
|
|
979
|
+
console.log(`Room ${id} · closing (accepted/pending; run room recover ${id})`);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
console.log(`Room ${id} · closed`);
|
|
983
|
+
}
|
|
984
|
+
catch (e) {
|
|
985
|
+
die(e);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
cOpt(roomCmd.command('recover <id>'))
|
|
989
|
+
.description('attempt to recover a stuck room')
|
|
990
|
+
.option('--json', 'JSON output')
|
|
991
|
+
.action(async (id, opts) => {
|
|
992
|
+
try {
|
|
993
|
+
const cfg = loadCfg(opts);
|
|
994
|
+
const adapter = coworkFor(cfg);
|
|
995
|
+
let r = getRoomRecord(id);
|
|
996
|
+
if (r?.state === 'closing') {
|
|
997
|
+
const settled = await launchRoomCloseWorker(id, opts.configuration);
|
|
998
|
+
r = settled.room;
|
|
999
|
+
const cowork = r.state === 'closed' ? await adapter.getRoom(id) : undefined;
|
|
1000
|
+
if (opts.json) {
|
|
1001
|
+
console.log(JSON.stringify({
|
|
1002
|
+
schema_version: 1, room: cowork ?? null, orchestration: r,
|
|
1003
|
+
recovery_actions: [settled.timedOut
|
|
1004
|
+
? `Closing remains pending — retry room recover ${id}`
|
|
1005
|
+
: 'Closing saga resumed successfully'],
|
|
1006
|
+
}, null, 2));
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
console.log(`Room ${id} · ${r.state} · close: ${r.close?.phase ?? 'unknown'}`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const cowork = await adapter.recoverRoom(id);
|
|
1013
|
+
r = getRoomRecord(id);
|
|
1014
|
+
if (r && !r.owner_seat_cid
|
|
1015
|
+
&& (r.provisioning_detail === 'waiting_owner_invite'
|
|
1016
|
+
|| r.provisioning_detail === 'owner_cid_mismatch')) {
|
|
1017
|
+
const expected = cfg.rooms.owner.expected_cid.toLowerCase();
|
|
1018
|
+
const existing = (await adapter.getSeats(id))
|
|
1019
|
+
.find(seat => seat.identity_cid.toLowerCase() === expected && seat.seat_state !== 'removed');
|
|
1020
|
+
if (!existing && !cfg.ownerInvite)
|
|
1021
|
+
throw new ConfigError('rooms.owner: configure public_invite or public_invite_file before recovery');
|
|
1022
|
+
let acceptedCid = existing?.identity_cid;
|
|
1023
|
+
if (!acceptedCid) {
|
|
1024
|
+
const accepted = await adapter.acceptInvite(id, cfg.ownerInvite, {
|
|
1025
|
+
role: cfg.rooms.owner.role,
|
|
1026
|
+
expected_cid: cfg.rooms.owner.expected_cid,
|
|
1027
|
+
});
|
|
1028
|
+
acceptedCid = accepted.seat_cid;
|
|
1029
|
+
}
|
|
1030
|
+
setOwnerSeat(id, acceptedCid, cfg.ownerInviteFingerprint ?? '');
|
|
1031
|
+
r = advanceSaga(id, 'create_members', 3);
|
|
1032
|
+
}
|
|
1033
|
+
const actions = [];
|
|
1034
|
+
if (r?.saga.error)
|
|
1035
|
+
actions.push(`Last error: ${r.saga.error}`);
|
|
1036
|
+
if (r?.saga.recovery_hint)
|
|
1037
|
+
actions.push(r.saga.recovery_hint);
|
|
1038
|
+
if (r?.provisioning_detail === 'waiting_cowork')
|
|
1039
|
+
actions.push('Check ours-cowork service status');
|
|
1040
|
+
if (r?.provisioning_detail === 'waiting_owner_invite')
|
|
1041
|
+
actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
|
|
1042
|
+
if (r && r.state === 'provisioning') {
|
|
1043
|
+
const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
|
|
1044
|
+
if (resumable.includes(r.saga.phase) && r.template_snapshot) {
|
|
1045
|
+
try {
|
|
1046
|
+
const template = resolveRoomTemplate(cfg, r.template_snapshot.name);
|
|
1047
|
+
if (template) {
|
|
1048
|
+
await provisionMembers({
|
|
1049
|
+
cfg,
|
|
1050
|
+
cowork: adapter,
|
|
1051
|
+
roomId: r.room_id,
|
|
1052
|
+
taskId: r.task_id,
|
|
1053
|
+
template,
|
|
1054
|
+
binPath: getBinPath(),
|
|
1055
|
+
goal: r.room_name,
|
|
1056
|
+
});
|
|
1057
|
+
r = getRoomRecord(id);
|
|
1058
|
+
actions.push('Provisioning resumed successfully');
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (opts.json) {
|
|
1067
|
+
console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
console.log(`Room ${cowork.room_id} · ${cowork.state}${r ? ` · saga: ${r.saga.phase}` : ''}`);
|
|
1071
|
+
if (actions.length) {
|
|
1072
|
+
console.log('Recovery:');
|
|
1073
|
+
for (const a of actions)
|
|
1074
|
+
console.log(` - ${a}`);
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
console.log('No recovery actions needed.');
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
die(e);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
cOpt(roomCmd.command('_close <id>', { hidden: true }))
|
|
1085
|
+
.description('internal: settle an accepted room close')
|
|
1086
|
+
.option('--json', 'JSON output')
|
|
1087
|
+
.action(async (id, opts) => {
|
|
1088
|
+
try {
|
|
1089
|
+
const cfg = loadCfg(opts);
|
|
1090
|
+
const r = await closeManagedRoom({ roomId: id, cowork: coworkFor(cfg) });
|
|
1091
|
+
if (opts.json) {
|
|
1092
|
+
console.log(JSON.stringify({ schema_version: 1, room_id: id, state: r.state, orchestration: r }, null, 2));
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
console.log(`Room ${id} · ${r.state}`);
|
|
1096
|
+
}
|
|
1097
|
+
catch (e) {
|
|
1098
|
+
await recordManagedRoomCloseError(id, errorText(e), `External close worker failed. Retry room recover ${id}.`).catch(() => { });
|
|
1099
|
+
die(e);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|