@ours.network/fleet 0.19.0-nightly.3 → 0.19.0-nightly.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +4 -4
- package/dist/doctor.js +125 -1
- package/dist/owner-channel/commands.js +324 -25
- package/dist/rooms-tasks/cli.js +119 -9
- package/dist/rooms-tasks/index.d.ts +1 -0
- package/dist/rooms-tasks/index.js +1 -0
- package/dist/rooms-tasks/provision.d.ts +21 -0
- package/dist/rooms-tasks/provision.js +229 -0
- package/dist/rooms-tasks/room-state.d.ts +1 -0
- package/dist/rooms-tasks/room-state.js +1 -0
- package/dist/rooms-tasks/task-state.d.ts +1 -0
- package/dist/rooms-tasks/task-state.js +1 -0
- package/dist/rooms-tasks/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.19.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "0.19.0-nightly.4",
|
|
3
|
+
"buildId": "27ab2ac6d2c0",
|
|
4
|
+
"commit": "0213a2667a2d24e14297f7364f42880997451742",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-08-
|
|
6
|
+
"builtAt": "2026-08-23T12:52:41.918Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"monitor.interrupt.after_tool"
|
|
9
9
|
]
|
package/dist/doctor.js
CHANGED
|
@@ -106,6 +106,126 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
106
106
|
ok: true,
|
|
107
107
|
detail: `warning: ${diagnostic.message}`,
|
|
108
108
|
});
|
|
109
|
+
// ── Rooms/tasks checks (§5.3) ────────────────────────────────────────
|
|
110
|
+
if (loaded.ok && loaded.rooms) {
|
|
111
|
+
const rooms = loaded.rooms;
|
|
112
|
+
// Cowork socket reachability
|
|
113
|
+
const coworkConfig = rooms.cowork?.config;
|
|
114
|
+
try {
|
|
115
|
+
const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
|
|
116
|
+
const adapter = createCoworkAdapter({ configPath: coworkConfig });
|
|
117
|
+
const reachable = await adapter.available();
|
|
118
|
+
checks.push({
|
|
119
|
+
name: 'cowork', ok: reachable,
|
|
120
|
+
detail: reachable
|
|
121
|
+
? 'management socket reachable'
|
|
122
|
+
: `management socket unreachable${coworkConfig ? ` (config: ${coworkConfig})` : ''}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
checks.push({
|
|
127
|
+
name: 'cowork', ok: false,
|
|
128
|
+
detail: `management socket error${coworkConfig ? ` (config: ${coworkConfig})` : ''}: ${e?.message ?? e}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Owner CID shape
|
|
132
|
+
const cidOk = /^[0-9a-fA-F]{64}$/.test(rooms.owner.expected_cid);
|
|
133
|
+
checks.push({
|
|
134
|
+
name: 'rooms: owner CID', ok: cidOk,
|
|
135
|
+
detail: cidOk ? 'valid 64-hex CID' : `invalid CID shape: ${rooms.owner.expected_cid.slice(0, 16)}...`,
|
|
136
|
+
});
|
|
137
|
+
// Invite presence (never content)
|
|
138
|
+
checks.push({
|
|
139
|
+
name: 'rooms: owner invite',
|
|
140
|
+
ok: !!loaded.ownerInviteFingerprint,
|
|
141
|
+
detail: loaded.ownerInviteFingerprint
|
|
142
|
+
? `present (fingerprint: ${loaded.ownerInviteFingerprint.slice(0, 12)}...)`
|
|
143
|
+
: 'not configured — room owner attachment will use waiting_owner_invite',
|
|
144
|
+
});
|
|
145
|
+
// Template validity and referenced role validity
|
|
146
|
+
const { listTemplates, resolveTemplate } = await import('./rooms-tasks/templates.js');
|
|
147
|
+
const customs = loaded.roomTemplates ?? {};
|
|
148
|
+
const templates = listTemplates(customs);
|
|
149
|
+
const roleNames = new Set(roles.map(r => r.name));
|
|
150
|
+
for (const t of templates) {
|
|
151
|
+
const badRefs = t.members
|
|
152
|
+
.filter(m => m.role_ref && !roleNames.has(m.role_ref))
|
|
153
|
+
.map(m => m.role_ref);
|
|
154
|
+
if (badRefs.length) {
|
|
155
|
+
checks.push({
|
|
156
|
+
name: `template: ${t.name}@${t.version}`,
|
|
157
|
+
ok: true,
|
|
158
|
+
detail: `warning: role_ref(s) ${badRefs.join(', ')} not found in configured roles`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Default template validity
|
|
163
|
+
const defaultTemplate = loaded.rooms.defaults?.template
|
|
164
|
+
?? loaded.tasks?.default_room_template;
|
|
165
|
+
if (defaultTemplate) {
|
|
166
|
+
const resolved = resolveTemplate(defaultTemplate, customs);
|
|
167
|
+
checks.push({
|
|
168
|
+
name: 'rooms: default template',
|
|
169
|
+
ok: !!resolved,
|
|
170
|
+
detail: resolved
|
|
171
|
+
? `${resolved.name}@${resolved.version}`
|
|
172
|
+
: `template '${defaultTemplate}' not found`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Shared-daemon selection coherence
|
|
176
|
+
if (rooms.cowork?.config) {
|
|
177
|
+
const { existsSync: exists } = await import('node:fs');
|
|
178
|
+
const configOk = exists(rooms.cowork.config);
|
|
179
|
+
checks.push({
|
|
180
|
+
name: 'rooms: cowork config', ok: configOk,
|
|
181
|
+
detail: configOk
|
|
182
|
+
? `cowork config at ${rooms.cowork.config}`
|
|
183
|
+
: `cowork config not found: ${rooms.cowork.config}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Hard prerelease capability check
|
|
187
|
+
try {
|
|
188
|
+
const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
|
|
189
|
+
const adapter = createCoworkAdapter({ configPath: coworkConfig });
|
|
190
|
+
const roomList = await adapter.listRooms();
|
|
191
|
+
checks.push({
|
|
192
|
+
name: 'rooms: capability', ok: true,
|
|
193
|
+
detail: `cowork room management operational (${roomList.length} room(s))`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
const msg = e?.message ?? String(e);
|
|
198
|
+
const isProto = msg.includes('protocol') || msg.includes('invalid');
|
|
199
|
+
checks.push({
|
|
200
|
+
name: 'rooms: capability', ok: false,
|
|
201
|
+
detail: isProto
|
|
202
|
+
? `cowork does not support room management protocol — upgrade ours-cowork to prerelease`
|
|
203
|
+
: `room management check failed: ${msg}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
// Stale task/room warnings
|
|
207
|
+
try {
|
|
208
|
+
const { listTasks } = await import('./rooms-tasks/task-state.js');
|
|
209
|
+
const { listRoomRecords } = await import('./rooms-tasks/room-state.js');
|
|
210
|
+
const tasks = listTasks();
|
|
211
|
+
const roomRecords = listRoomRecords();
|
|
212
|
+
const roomIds = new Set(roomRecords.map(r => r.room_id));
|
|
213
|
+
const taskIds = new Set(tasks.map(t => t.task_id));
|
|
214
|
+
const orphanedTasks = tasks.filter(t => t.room_id && !roomIds.has(t.room_id));
|
|
215
|
+
const orphanedRooms = roomRecords.filter(r => r.task_id && !taskIds.has(r.task_id));
|
|
216
|
+
if (orphanedTasks.length)
|
|
217
|
+
checks.push({
|
|
218
|
+
name: 'rooms: stale tasks', ok: true,
|
|
219
|
+
detail: `warning: ${orphanedTasks.length} task(s) reference missing rooms: ${orphanedTasks.map(t => t.task_id).join(', ')}`,
|
|
220
|
+
});
|
|
221
|
+
if (orphanedRooms.length)
|
|
222
|
+
checks.push({
|
|
223
|
+
name: 'rooms: stale rooms', ok: true,
|
|
224
|
+
detail: `warning: ${orphanedRooms.length} room(s) reference missing tasks: ${orphanedRooms.map(r => r.room_id).join(', ')}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch { /* state dir may not exist yet */ }
|
|
228
|
+
}
|
|
109
229
|
if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
|
|
110
230
|
const tmux = await exec('tmux', ['-V']);
|
|
111
231
|
checks.push({
|
|
@@ -405,7 +525,11 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
405
525
|
function loadConfigResult(configPath, yamlMode) {
|
|
406
526
|
try {
|
|
407
527
|
const cfg = loadConfig(configPath, { yamlMode });
|
|
408
|
-
return {
|
|
528
|
+
return {
|
|
529
|
+
ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics,
|
|
530
|
+
rooms: cfg.rooms, roomTemplates: cfg.roomTemplates, tasks: cfg.tasks,
|
|
531
|
+
ownerInviteFingerprint: cfg.ownerInviteFingerprint,
|
|
532
|
+
};
|
|
409
533
|
}
|
|
410
534
|
catch (e) {
|
|
411
535
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -159,14 +159,20 @@ export const ownerCommands = [
|
|
|
159
159
|
},
|
|
160
160
|
},
|
|
161
161
|
{
|
|
162
|
-
name: 'task',
|
|
163
|
-
|
|
162
|
+
name: 'task',
|
|
163
|
+
usage: '/task <create|list|show|start|block|unblock|review|done|cancel|recover> ...',
|
|
164
|
+
summary: 'task lifecycle subcommands',
|
|
164
165
|
execute: async (ctx, args) => {
|
|
165
166
|
if (!args)
|
|
166
|
-
throw new OwnerCommandUsageError('usage: /task <id>');
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
throw new OwnerCommandUsageError('usage: /task <subcommand> <id>');
|
|
168
|
+
const argLines = args.trim().split('\n');
|
|
169
|
+
const firstLineTokens = argLines[0].trim().split(/\s+/);
|
|
170
|
+
const sub = firstLineTokens[0];
|
|
171
|
+
const rest = firstLineTokens.slice(1);
|
|
172
|
+
const trailingLines = argLines.slice(1).join('\n').trim() || undefined;
|
|
173
|
+
const showTask = async (id) => {
|
|
174
|
+
const { getTask } = await import('../rooms-tasks/task-state.js');
|
|
175
|
+
const t = getTask(id);
|
|
170
176
|
const lines = [
|
|
171
177
|
`📋 Task: ${t.task_id}`,
|
|
172
178
|
`Title: ${t.title}`,
|
|
@@ -177,8 +183,157 @@ export const ownerCommands = [
|
|
|
177
183
|
`Created: ${t.created_at}`,
|
|
178
184
|
];
|
|
179
185
|
await ctx.reply(lines.join('\n'));
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
switch (sub) {
|
|
189
|
+
case 'create': {
|
|
190
|
+
if (!rest[0])
|
|
191
|
+
throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
|
|
192
|
+
const backlog = rest.includes('--backlog');
|
|
193
|
+
const tplFlag = rest.find(r => r.startsWith('--template='));
|
|
194
|
+
const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
|
|
195
|
+
const noRoom = rest.includes('--no-room');
|
|
196
|
+
const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
|
|
197
|
+
if (!titleParts.length)
|
|
198
|
+
throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
|
|
199
|
+
const { createTask } = await import('../rooms-tasks/task-state.js');
|
|
200
|
+
const t = createTask({
|
|
201
|
+
title: titleParts.join(' '),
|
|
202
|
+
origin: { type: 'owner_channel' },
|
|
203
|
+
start: !backlog,
|
|
204
|
+
...(template ? { template: { name: template, version: 1, content_hash: '' } } : {}),
|
|
205
|
+
...(noRoom ? { no_room: true } : {}),
|
|
206
|
+
...(trailingLines ? { brief: trailingLines } : {}),
|
|
207
|
+
});
|
|
208
|
+
const lines = [`Task ${t.task_id} created · ${t.state}`];
|
|
209
|
+
if (template)
|
|
210
|
+
lines.push(`Template: ${template}`);
|
|
211
|
+
if (noRoom)
|
|
212
|
+
lines.push('No room requested');
|
|
213
|
+
await ctx.reply(lines.join('\n'));
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'list': {
|
|
217
|
+
const { listTasks } = await import('../rooms-tasks/task-state.js');
|
|
218
|
+
const filter = rest[0];
|
|
219
|
+
const stateMap = {
|
|
220
|
+
backlog: 'backlog', active: 'active', blocked: 'active',
|
|
221
|
+
done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
|
|
222
|
+
};
|
|
223
|
+
const stateFilter = filter && stateMap[filter]
|
|
224
|
+
? { state: stateMap[filter] }
|
|
225
|
+
: undefined;
|
|
226
|
+
const tasks = listTasks(stateFilter);
|
|
227
|
+
if (filter === 'blocked') {
|
|
228
|
+
const blocked = tasks.filter(t => t.blocked);
|
|
229
|
+
if (!blocked.length) {
|
|
230
|
+
await ctx.reply('📋 No blocked tasks.');
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
|
|
234
|
+
await ctx.reply(tail(`📋 Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (!tasks.length) {
|
|
238
|
+
await ctx.reply('📋 No tasks.');
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
|
|
242
|
+
await ctx.reply(tail(`📋 Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'show': {
|
|
246
|
+
if (!rest[0])
|
|
247
|
+
throw new OwnerCommandUsageError('usage: /task show <id>');
|
|
248
|
+
await showTask(rest[0]);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case 'start': {
|
|
252
|
+
if (!rest[0])
|
|
253
|
+
throw new OwnerCommandUsageError('usage: /task start <id>');
|
|
254
|
+
const { startTask } = await import('../rooms-tasks/task-state.js');
|
|
255
|
+
const t = startTask(rest[0]);
|
|
256
|
+
await ctx.reply(`✅ Task ${t.task_id} → provisioning`);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case 'block': {
|
|
260
|
+
if (!rest[0] || !rest[1])
|
|
261
|
+
throw new OwnerCommandUsageError('usage: /task block <id> <reason>');
|
|
262
|
+
const { blockTask } = await import('../rooms-tasks/task-state.js');
|
|
263
|
+
const reason = rest.slice(1).join(' ');
|
|
264
|
+
const t = blockTask(rest[0], reason);
|
|
265
|
+
await ctx.reply(`🚧 Task ${t.task_id} blocked: ${reason}`);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case 'unblock': {
|
|
269
|
+
if (!rest[0])
|
|
270
|
+
throw new OwnerCommandUsageError('usage: /task unblock <id>');
|
|
271
|
+
const { unblockTask } = await import('../rooms-tasks/task-state.js');
|
|
272
|
+
const t = unblockTask(rest[0]);
|
|
273
|
+
await ctx.reply(`✅ Task ${t.task_id} unblocked`);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 'review': {
|
|
277
|
+
if (!rest[0])
|
|
278
|
+
throw new OwnerCommandUsageError('usage: /task review <id>');
|
|
279
|
+
const { reviewTask } = await import('../rooms-tasks/task-state.js');
|
|
280
|
+
const t = reviewTask(rest[0]);
|
|
281
|
+
await ctx.reply(`📝 Task ${t.task_id} → review`);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case 'done': {
|
|
285
|
+
if (!rest[0])
|
|
286
|
+
throw new OwnerCommandUsageError('usage: /task done <id> [summary]');
|
|
287
|
+
const { completeTask } = await import('../rooms-tasks/task-state.js');
|
|
288
|
+
const summary = rest.slice(1).join(' ') || undefined;
|
|
289
|
+
const t = completeTask(rest[0], summary ? { summary } : undefined);
|
|
290
|
+
await ctx.reply(`✅ Task ${t.task_id} → done`);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case 'cancel': {
|
|
294
|
+
if (rest.length < 2 || rest[0] !== rest[1])
|
|
295
|
+
throw new OwnerCommandUsageError('destructive: /task cancel <id> <id> — provide the task ID twice');
|
|
296
|
+
const { cancelTask } = await import('../rooms-tasks/task-state.js');
|
|
297
|
+
const t = cancelTask(rest[0]);
|
|
298
|
+
await ctx.reply(`🗑️ Task ${t.task_id} → cancelled`);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case 'recover': {
|
|
302
|
+
if (!rest[0])
|
|
303
|
+
throw new OwnerCommandUsageError('usage: /task recover <id>');
|
|
304
|
+
const { getTask } = await import('../rooms-tasks/task-state.js');
|
|
305
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
306
|
+
const t = getTask(rest[0]);
|
|
307
|
+
const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
|
|
308
|
+
const hints = [];
|
|
309
|
+
if (t.state === 'provisioning' && room) {
|
|
310
|
+
if (room.provisioning_detail === 'waiting_cowork')
|
|
311
|
+
hints.push('Cowork socket unreachable');
|
|
312
|
+
if (room.provisioning_detail === 'waiting_owner_invite')
|
|
313
|
+
hints.push('Owner invite missing or invalid');
|
|
314
|
+
if (room.provisioning_detail === 'owner_cid_mismatch')
|
|
315
|
+
hints.push('Owner CID mismatch');
|
|
316
|
+
if (room.provisioning_detail === 'member_failed')
|
|
317
|
+
hints.push(`Member failed at step ${room.saga.step_index}`);
|
|
318
|
+
}
|
|
319
|
+
const lines = [`Task ${t.task_id} · ${t.state}`];
|
|
320
|
+
if (room)
|
|
321
|
+
lines.push(`Room ${room.room_id} · ${room.state} · saga: ${room.saga.phase}`);
|
|
322
|
+
if (hints.length)
|
|
323
|
+
lines.push(`Hints: ${hints.join('; ')}`);
|
|
324
|
+
else
|
|
325
|
+
lines.push('No automated recovery actions available.');
|
|
326
|
+
await ctx.reply(lines.join('\n'));
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
// bare /task <id> → show
|
|
331
|
+
await showTask(sub);
|
|
332
|
+
}
|
|
180
333
|
}
|
|
181
334
|
catch (e) {
|
|
335
|
+
if (e instanceof OwnerCommandUsageError)
|
|
336
|
+
throw e;
|
|
182
337
|
await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
|
|
183
338
|
}
|
|
184
339
|
},
|
|
@@ -195,30 +350,125 @@ export const ownerCommands = [
|
|
|
195
350
|
}),
|
|
196
351
|
},
|
|
197
352
|
{
|
|
198
|
-
name: 'room',
|
|
199
|
-
|
|
353
|
+
name: 'room',
|
|
354
|
+
usage: '/room <create|list|show|close|recover> ...',
|
|
355
|
+
summary: 'room lifecycle subcommands',
|
|
200
356
|
execute: async (ctx, args) => {
|
|
201
357
|
if (!args)
|
|
202
|
-
throw new OwnerCommandUsageError('usage: /room <id>');
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
358
|
+
throw new OwnerCommandUsageError('usage: /room <subcommand> <id>');
|
|
359
|
+
const argLines = args.trim().split('\n');
|
|
360
|
+
const firstLineTokens = argLines[0].trim().split(/\s+/);
|
|
361
|
+
const sub = firstLineTokens[0];
|
|
362
|
+
const rest = firstLineTokens.slice(1);
|
|
363
|
+
const roomTrailingLines = argLines.slice(1).join('\n').trim() || undefined;
|
|
364
|
+
const showRoom = async (id) => {
|
|
365
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
366
|
+
const r = getRoomRecord(id);
|
|
367
|
+
if (!r)
|
|
368
|
+
return ctx.reply(`⚠️ room not found: ${id}`);
|
|
369
|
+
const lines = [
|
|
370
|
+
`🏠 Room: ${r.room_id}`,
|
|
371
|
+
`Name: ${r.room_name}`,
|
|
372
|
+
`State: ${r.state}`,
|
|
373
|
+
`Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
|
|
374
|
+
...(r.task_id ? [`Task: ${r.task_id}`] : []),
|
|
375
|
+
...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
|
|
376
|
+
...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
|
|
377
|
+
`Created: ${r.created_at}`,
|
|
378
|
+
];
|
|
379
|
+
await ctx.reply(lines.join('\n'));
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
switch (sub) {
|
|
383
|
+
case 'create': {
|
|
384
|
+
const tplFlag = rest.find(r => r.startsWith('--template='));
|
|
385
|
+
const templateName = tplFlag?.slice('--template='.length);
|
|
386
|
+
const nameParts = rest.filter(r => !r.startsWith('--'));
|
|
387
|
+
if (!nameParts.length)
|
|
388
|
+
throw new OwnerCommandUsageError('usage: /room create --template=<name> <room name>');
|
|
389
|
+
const { randomUUID } = await import('node:crypto');
|
|
390
|
+
const { createRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
391
|
+
const { resolveTemplate, snapshotTemplate } = await import('../rooms-tasks/templates.js');
|
|
392
|
+
let templateSnapshot;
|
|
393
|
+
if (templateName) {
|
|
394
|
+
const tpl = resolveTemplate(templateName, {});
|
|
395
|
+
if (!tpl)
|
|
396
|
+
throw new OwnerCommandUsageError(`unknown template: ${templateName}`);
|
|
397
|
+
templateSnapshot = snapshotTemplate(tpl);
|
|
398
|
+
}
|
|
399
|
+
const r = createRoomRecord({
|
|
400
|
+
room_id: randomUUID().replace(/-/g, '').slice(0, 16),
|
|
401
|
+
room_name: nameParts.join(' '),
|
|
402
|
+
...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
|
|
403
|
+
...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
|
|
404
|
+
});
|
|
405
|
+
const lines = [`🏠 Room ${r.room_id} created · ${r.state}`];
|
|
406
|
+
if (templateName)
|
|
407
|
+
lines.push(`Template: ${templateName}`);
|
|
408
|
+
await ctx.reply(lines.join('\n'));
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'list': {
|
|
412
|
+
const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
|
|
413
|
+
const filter = rest[0];
|
|
414
|
+
let rooms = listRoomRecords();
|
|
415
|
+
if (filter === 'active')
|
|
416
|
+
rooms = rooms.filter(r => r.state === 'active');
|
|
417
|
+
if (!rooms.length) {
|
|
418
|
+
await ctx.reply('🏠 No rooms.');
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
|
|
422
|
+
await ctx.reply(tail(`🏠 Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
case 'show': {
|
|
426
|
+
if (!rest[0])
|
|
427
|
+
throw new OwnerCommandUsageError('usage: /room show <id>');
|
|
428
|
+
await showRoom(rest[0]);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
case 'close': {
|
|
432
|
+
if (rest.length < 2 || rest[0] !== rest[1])
|
|
433
|
+
throw new OwnerCommandUsageError('destructive: /room close <id> <id> — provide the room ID twice');
|
|
434
|
+
const { closeRoom } = await import('../rooms-tasks/room-state.js');
|
|
435
|
+
const r = closeRoom(rest[0]);
|
|
436
|
+
await ctx.reply(`🔒 Room ${r.room_id} → closed`);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
case 'recover': {
|
|
440
|
+
if (!rest[0])
|
|
441
|
+
throw new OwnerCommandUsageError('usage: /room recover <id>');
|
|
442
|
+
const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
|
|
443
|
+
const r = getRoomRecord(rest[0]);
|
|
444
|
+
if (!r) {
|
|
445
|
+
await ctx.reply(`⚠️ room not found: ${rest[0]}`);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
const lines = [`🏠 Room ${r.room_id} · ${r.state} · saga: ${r.saga.phase}`];
|
|
449
|
+
if (r.saga.error)
|
|
450
|
+
lines.push(`Error: ${r.saga.error}`);
|
|
451
|
+
if (r.provisioning_detail)
|
|
452
|
+
lines.push(`Detail: ${r.provisioning_detail}`);
|
|
453
|
+
lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
|
|
454
|
+
await ctx.reply(lines.join('\n'));
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
default:
|
|
458
|
+
// bare /room <id> → show
|
|
459
|
+
await showRoom(sub);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
if (e instanceof OwnerCommandUsageError)
|
|
464
|
+
throw e;
|
|
465
|
+
await ctx.reply(`⚠️ ${e instanceof Error ? e.message : String(e)}`);
|
|
466
|
+
}
|
|
218
467
|
},
|
|
219
468
|
},
|
|
220
469
|
{
|
|
221
|
-
name: 'templates',
|
|
470
|
+
name: 'templates', aliases: ['template-list'],
|
|
471
|
+
summary: 'list available room templates',
|
|
222
472
|
execute: noArgs('/templates', async (ctx) => {
|
|
223
473
|
const { listTemplates } = await import('../rooms-tasks/templates.js');
|
|
224
474
|
const templates = listTemplates({});
|
|
@@ -231,6 +481,55 @@ export const ownerCommands = [
|
|
|
231
481
|
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
232
482
|
}),
|
|
233
483
|
},
|
|
484
|
+
{
|
|
485
|
+
name: 'template',
|
|
486
|
+
usage: '/template <show|list> <name[@version]>',
|
|
487
|
+
summary: 'template subcommands (show, list)',
|
|
488
|
+
execute: async (ctx, args) => {
|
|
489
|
+
if (!args)
|
|
490
|
+
throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
|
|
491
|
+
const parts = args.trim().split(/\s+/);
|
|
492
|
+
const sub = parts[0];
|
|
493
|
+
const showTemplate = async (nameStr) => {
|
|
494
|
+
const { resolveTemplate } = await import('../rooms-tasks/templates.js');
|
|
495
|
+
const t = resolveTemplate(nameStr, {});
|
|
496
|
+
if (!t)
|
|
497
|
+
return ctx.reply(`⚠️ template not found: ${nameStr}`);
|
|
498
|
+
const lines = [
|
|
499
|
+
`📐 Template: ${t.name}@${t.version}`,
|
|
500
|
+
`Description: ${t.description}`,
|
|
501
|
+
...(t.builtin ? ['Source: built-in'] : []),
|
|
502
|
+
...(t.contract ? [`Contract: ${t.contract}`] : []),
|
|
503
|
+
'Members:',
|
|
504
|
+
...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → role_ref: ${m.role_ref}`),
|
|
505
|
+
];
|
|
506
|
+
await ctx.reply(lines.join('\n'));
|
|
507
|
+
};
|
|
508
|
+
switch (sub) {
|
|
509
|
+
case 'show': {
|
|
510
|
+
if (!parts[1])
|
|
511
|
+
throw new OwnerCommandUsageError('usage: /template show <name[@version]>');
|
|
512
|
+
await showTemplate(parts[1]);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case 'list': {
|
|
516
|
+
const { listTemplates } = await import('../rooms-tasks/templates.js');
|
|
517
|
+
const templates = listTemplates({});
|
|
518
|
+
if (!templates.length)
|
|
519
|
+
return ctx.reply('📐 No templates.');
|
|
520
|
+
const lines = templates.map(t => {
|
|
521
|
+
const tag = t.builtin ? ' (built-in)' : '';
|
|
522
|
+
return `${t.name}@${t.version}${tag} ${t.description}`;
|
|
523
|
+
});
|
|
524
|
+
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
default:
|
|
528
|
+
// bare /template <name> → show
|
|
529
|
+
await showTemplate(sub);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
},
|
|
234
533
|
];
|
|
235
534
|
/** Trimmed slash-prefixed text is a command attempt and is never forwarded. */
|
|
236
535
|
export const isOwnerCommandText = (text) => text.trim().startsWith('/');
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { loadConfig, ConfigError } from '../config.js';
|
|
3
|
+
import { provisionMembers, cleanupMembers, getBinPath } from './provision.js';
|
|
3
4
|
import { resolveTemplate, listTemplates, snapshotTemplate, hashTemplate, } from './templates.js';
|
|
4
|
-
import { createTask, getTask, listTasks, startTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, } from './task-state.js';
|
|
5
|
-
import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, advanceSaga, setOwnerSeat, setSagaError, } from './room-state.js';
|
|
5
|
+
import { createTask, getTask, listTasks, startTask, activateTask, blockTask, unblockTask, reviewTask, completeTask, cancelTask, updateTaskRoom, } from './task-state.js';
|
|
6
|
+
import { createRoomRecord, getRoomRecord, listRoomRecords, closeRoom as closeRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, } from './room-state.js';
|
|
6
7
|
import { createCoworkAdapter, CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
|
|
7
8
|
function die(e) {
|
|
8
9
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -71,7 +72,25 @@ async function provisionRoom(cfg, input) {
|
|
|
71
72
|
throw error;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
+
record = advanceSaga(record.room_id, 'create_members', 3);
|
|
76
|
+
if (input.template && input.template.members.length > 0) {
|
|
77
|
+
record = await provisionMembers({
|
|
78
|
+
cfg,
|
|
79
|
+
cowork,
|
|
80
|
+
roomId: record.room_id,
|
|
81
|
+
taskId: input.taskId,
|
|
82
|
+
template: input.template,
|
|
83
|
+
binPath: getBinPath(),
|
|
84
|
+
brief: input.brief,
|
|
85
|
+
goal: input.goal,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
record = activateRoom(record.room_id);
|
|
90
|
+
if (input.taskId)
|
|
91
|
+
activateTask(input.taskId);
|
|
92
|
+
}
|
|
93
|
+
return record;
|
|
75
94
|
}
|
|
76
95
|
export function registerTemplateCommands(parent, cOpt) {
|
|
77
96
|
const templateCmd = parent.command('template').description('room template operations');
|
|
@@ -387,18 +406,32 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
387
406
|
die(e);
|
|
388
407
|
}
|
|
389
408
|
});
|
|
390
|
-
taskCmd.command('done <id>')
|
|
409
|
+
cOpt(taskCmd.command('done <id>'))
|
|
391
410
|
.description('complete a task')
|
|
392
411
|
.option('--summary <text>', 'completion summary')
|
|
393
412
|
.option('--summary-file <path>', 'completion summary from file')
|
|
394
413
|
.option('--json', 'JSON output')
|
|
395
|
-
.action((id, opts) => {
|
|
414
|
+
.action(async (id, opts) => {
|
|
396
415
|
try {
|
|
397
416
|
let summary = opts.summary;
|
|
398
417
|
if (opts.summaryFile)
|
|
399
418
|
summary = readFileSync(opts.summaryFile, 'utf8');
|
|
400
419
|
const outcome = summary ? { summary } : undefined;
|
|
401
420
|
const t = completeTask(id, outcome);
|
|
421
|
+
if (t.room_id) {
|
|
422
|
+
const cfg = loadCfg(opts);
|
|
423
|
+
const shouldClose = cfg.tasks?.close_room_on_done
|
|
424
|
+
?? cfg.rooms?.defaults?.close_when_task_done
|
|
425
|
+
?? false;
|
|
426
|
+
if (shouldClose) {
|
|
427
|
+
try {
|
|
428
|
+
await coworkFor(cfg).closeRoom(t.room_id);
|
|
429
|
+
closeRoomRecord(t.room_id);
|
|
430
|
+
}
|
|
431
|
+
catch { /* room close is best-effort on task done */ }
|
|
432
|
+
await cleanupMembers({ roomId: t.room_id, taskId: id }).catch(() => { });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
402
435
|
if (opts.json) {
|
|
403
436
|
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
404
437
|
return;
|
|
@@ -409,14 +442,23 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
409
442
|
die(e);
|
|
410
443
|
}
|
|
411
444
|
});
|
|
412
|
-
taskCmd.command('cancel <id> <confirm-id>')
|
|
445
|
+
cOpt(taskCmd.command('cancel <id> <confirm-id>'))
|
|
413
446
|
.description('cancel a task (requires ID twice for confirmation)')
|
|
414
447
|
.option('--json', 'JSON output')
|
|
415
|
-
.action((id, confirmId, opts) => {
|
|
448
|
+
.action(async (id, confirmId, opts) => {
|
|
416
449
|
try {
|
|
417
450
|
if (id !== confirmId)
|
|
418
451
|
die(new Error('confirmation ID must match task ID'));
|
|
419
452
|
const t = cancelTask(id);
|
|
453
|
+
if (t.room_id) {
|
|
454
|
+
const cfg = loadCfg(opts);
|
|
455
|
+
await cleanupMembers({
|
|
456
|
+
roomId: t.room_id,
|
|
457
|
+
taskId: id,
|
|
458
|
+
closeCoworkRoom: true,
|
|
459
|
+
cowork: coworkFor(cfg),
|
|
460
|
+
}).catch(() => { });
|
|
461
|
+
}
|
|
420
462
|
if (opts.json) {
|
|
421
463
|
console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
|
|
422
464
|
return;
|
|
@@ -427,11 +469,12 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
427
469
|
die(e);
|
|
428
470
|
}
|
|
429
471
|
});
|
|
430
|
-
taskCmd.command('recover <id>')
|
|
472
|
+
cOpt(taskCmd.command('recover <id>'))
|
|
431
473
|
.description('attempt to recover a stuck task')
|
|
432
474
|
.option('--json', 'JSON output')
|
|
433
|
-
.action((id, opts) => {
|
|
475
|
+
.action(async (id, opts) => {
|
|
434
476
|
try {
|
|
477
|
+
const cfg = loadCfg(opts);
|
|
435
478
|
const t = getTask(id);
|
|
436
479
|
const room = t.room_id ? getRoomRecord(t.room_id) : undefined;
|
|
437
480
|
const result = {
|
|
@@ -448,6 +491,28 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
448
491
|
result.recovery_actions.push('Owner CID mismatch — verify rooms.owner.expected_cid matches Messenger identity');
|
|
449
492
|
if (room.provisioning_detail === 'member_failed')
|
|
450
493
|
result.recovery_actions.push(`Member creation failed at saga step ${room.saga.step_index} — inspect and retry`);
|
|
494
|
+
const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
|
|
495
|
+
if (resumable.includes(room.saga.phase)) {
|
|
496
|
+
const template = t.template ? resolveRoomTemplate(cfg, t.template.name) : undefined;
|
|
497
|
+
if (template) {
|
|
498
|
+
try {
|
|
499
|
+
await provisionMembers({
|
|
500
|
+
cfg,
|
|
501
|
+
cowork: coworkFor(cfg),
|
|
502
|
+
roomId: room.room_id,
|
|
503
|
+
taskId: t.task_id,
|
|
504
|
+
template,
|
|
505
|
+
binPath: getBinPath(),
|
|
506
|
+
brief: t.brief,
|
|
507
|
+
goal: t.title,
|
|
508
|
+
});
|
|
509
|
+
result.recovery_actions.push('Provisioning resumed successfully');
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
result.recovery_actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
451
516
|
}
|
|
452
517
|
if (opts.json) {
|
|
453
518
|
console.log(JSON.stringify({ schema_version: 1, ...result }, null, 2));
|
|
@@ -580,6 +645,27 @@ export function registerRoomCommands(parent, cOpt) {
|
|
|
580
645
|
die(e);
|
|
581
646
|
}
|
|
582
647
|
});
|
|
648
|
+
cOpt(roomCmd.command('open <id>'))
|
|
649
|
+
.description('open room in Cowork local console')
|
|
650
|
+
.option('--json', 'JSON output')
|
|
651
|
+
.action(async (id, opts) => {
|
|
652
|
+
try {
|
|
653
|
+
const cfg = loadCfg(opts);
|
|
654
|
+
const room = await coworkFor(cfg).getRoom(id);
|
|
655
|
+
if (!room)
|
|
656
|
+
die(new Error(`room not found: ${id}`));
|
|
657
|
+
const url = `http://localhost:4460/room/${id}`;
|
|
658
|
+
if (opts.json) {
|
|
659
|
+
console.log(JSON.stringify({ schema_version: 1, room_id: id, url, room_name: room.room_name }, null, 2));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
console.log(`Room ${id} — ${room.room_name}`);
|
|
663
|
+
console.log(`Local console: ${url}`);
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
die(e);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
583
669
|
cOpt(roomCmd.command('members <id>'))
|
|
584
670
|
.description('show room members')
|
|
585
671
|
.option('--json', 'JSON output')
|
|
@@ -669,6 +755,30 @@ export function registerRoomCommands(parent, cOpt) {
|
|
|
669
755
|
actions.push('Check ours-cowork service status');
|
|
670
756
|
if (r?.provisioning_detail === 'waiting_owner_invite')
|
|
671
757
|
actions.push('Rotate rooms.owner.public_invite in config, then re-run recover');
|
|
758
|
+
if (r && r.state === 'provisioning') {
|
|
759
|
+
const resumable = ['create_members', 'join_role_groups', 'wait_seats', 'launch_work', 'activate'];
|
|
760
|
+
if (resumable.includes(r.saga.phase) && r.template_snapshot) {
|
|
761
|
+
try {
|
|
762
|
+
const template = resolveRoomTemplate(cfg, r.template_snapshot.name);
|
|
763
|
+
if (template) {
|
|
764
|
+
await provisionMembers({
|
|
765
|
+
cfg,
|
|
766
|
+
cowork: adapter,
|
|
767
|
+
roomId: r.room_id,
|
|
768
|
+
taskId: r.task_id,
|
|
769
|
+
template,
|
|
770
|
+
binPath: getBinPath(),
|
|
771
|
+
goal: r.room_name,
|
|
772
|
+
});
|
|
773
|
+
r = getRoomRecord(id);
|
|
774
|
+
actions.push('Provisioning resumed successfully');
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
actions.push(`Resume failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
672
782
|
if (opts.json) {
|
|
673
783
|
console.log(JSON.stringify({ schema_version: 1, room: cowork, orchestration: r ?? null, recovery_actions: actions }, null, 2));
|
|
674
784
|
return;
|
|
@@ -4,4 +4,5 @@ export * from './config.js';
|
|
|
4
4
|
export * from './task-state.js';
|
|
5
5
|
export * from './room-state.js';
|
|
6
6
|
export * from './cowork-adapter.js';
|
|
7
|
+
export { provisionMembers, cleanupMembers } from './provision.js';
|
|
7
8
|
export { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './cli.js';
|
|
@@ -4,4 +4,5 @@ export * from './config.js';
|
|
|
4
4
|
export * from './task-state.js';
|
|
5
5
|
export * from './room-state.js';
|
|
6
6
|
export * from './cowork-adapter.js';
|
|
7
|
+
export { provisionMembers, cleanupMembers } from './provision.js';
|
|
7
8
|
export { registerTemplateCommands, registerTaskCommands, registerRoomCommands } from './cli.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CoworkAdapter } from './cowork-adapter.js';
|
|
2
|
+
import type { RoomOrchestrationRecord, TemplateSnapshot } from './types.js';
|
|
3
|
+
import { type FleetConfig } from '../config.js';
|
|
4
|
+
export declare function getBinPath(): string;
|
|
5
|
+
export interface ProvisionMembersInput {
|
|
6
|
+
cfg: FleetConfig;
|
|
7
|
+
cowork: CoworkAdapter;
|
|
8
|
+
roomId: string;
|
|
9
|
+
taskId?: string;
|
|
10
|
+
template: TemplateSnapshot;
|
|
11
|
+
binPath: string;
|
|
12
|
+
brief?: string;
|
|
13
|
+
goal?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
|
|
16
|
+
export declare function cleanupMembers(input: {
|
|
17
|
+
roomId: string;
|
|
18
|
+
taskId?: string;
|
|
19
|
+
closeCoworkRoom?: boolean;
|
|
20
|
+
cowork?: CoworkAdapter;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { attachOursClient, } from '@ours.network/sdk/client';
|
|
4
|
+
import { advanceSaga, setSagaError, updateMemberSeats, activateRoom, getRoomRecord, closeRoom as closeRoomRecord, } from './room-state.js';
|
|
5
|
+
import { activateTask, updateTaskMembers, failTask, } from './task-state.js';
|
|
6
|
+
import { spawnTemp } from '../spawn.js';
|
|
7
|
+
import { findRole } from '../config.js';
|
|
8
|
+
export function getBinPath() {
|
|
9
|
+
try {
|
|
10
|
+
return realpathSync(process.argv[1]);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return process.argv[1];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function shortId(id) { return id.slice(0, 8); }
|
|
17
|
+
function expandMembers(template, prefix) {
|
|
18
|
+
const result = [];
|
|
19
|
+
for (const slot of template.members) {
|
|
20
|
+
for (let i = 1; i <= slot.count; i++) {
|
|
21
|
+
result.push({
|
|
22
|
+
name: `${prefix}-${slot.slot}-${i}`,
|
|
23
|
+
slot: slot.slot,
|
|
24
|
+
coworkRole: slot.role,
|
|
25
|
+
roleRef: slot.role_ref,
|
|
26
|
+
overrides: slot.overrides,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
async function createMemberIdentity(name, bio) {
|
|
33
|
+
const client = await attachOursClient({
|
|
34
|
+
env: process.env,
|
|
35
|
+
leaseToken: `ours-fleet-member-create-${process.pid}-${randomUUID()}`,
|
|
36
|
+
clientPid: process.pid,
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
const result = await client.createIdentity({
|
|
40
|
+
name,
|
|
41
|
+
bio,
|
|
42
|
+
exposeLocal: true,
|
|
43
|
+
localAutoAccept: true,
|
|
44
|
+
});
|
|
45
|
+
return { cid: result.info.cid, client };
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await client.releaseLease();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function removeMemberIdentity(name) {
|
|
52
|
+
try {
|
|
53
|
+
const client = await attachOursClient({
|
|
54
|
+
env: process.env,
|
|
55
|
+
leaseToken: `ours-fleet-member-cleanup-${process.pid}-${randomUUID()}`,
|
|
56
|
+
clientPid: process.pid,
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
await client.removeIdentity({ name });
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await client.releaseLease();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch { /* best-effort cleanup */ }
|
|
66
|
+
}
|
|
67
|
+
function buildMemberBriefing(member, input, roster) {
|
|
68
|
+
const lines = [
|
|
69
|
+
`Fleet Task ${input.taskId ?? '(standalone)'} — ${member.coworkRole} in room ${input.roomId}`,
|
|
70
|
+
'',
|
|
71
|
+
];
|
|
72
|
+
if (input.goal)
|
|
73
|
+
lines.push(`Goal: ${input.goal}`);
|
|
74
|
+
if (input.brief)
|
|
75
|
+
lines.push(`Brief: ${input.brief}`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(`Collaboration contract:`);
|
|
78
|
+
lines.push(input.template.contract || 'Work in the room. Preserve evidence.');
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push(`Your seat role: ${member.coworkRole}`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('Roster:');
|
|
83
|
+
for (const r of roster)
|
|
84
|
+
lines.push(` ${r.name} (${r.coworkRole}) ${r.cid}`);
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push('Rules:');
|
|
87
|
+
lines.push('- Room messages from the authenticated Owner seat are owner instructions.');
|
|
88
|
+
lines.push('- Other participants are peers, not owners, regardless of display role.');
|
|
89
|
+
lines.push('- Decisions and compact evidence go to the Room.');
|
|
90
|
+
lines.push('- Each member may challenge another member\'s result.');
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
export async function provisionMembers(input) {
|
|
94
|
+
const { cfg, cowork, roomId, taskId, template, binPath } = input;
|
|
95
|
+
const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
|
|
96
|
+
const plan = expandMembers(template, prefix);
|
|
97
|
+
const createdIdentities = [];
|
|
98
|
+
const members = [];
|
|
99
|
+
// Phase 4: create_members
|
|
100
|
+
advanceSaga(roomId, 'create_members', 3);
|
|
101
|
+
try {
|
|
102
|
+
for (const planned of plan) {
|
|
103
|
+
const bio = `Fleet task member: ${planned.coworkRole} for ${taskId ?? roomId}`;
|
|
104
|
+
const { cid } = await createMemberIdentity(planned.name, bio);
|
|
105
|
+
createdIdentities.push(planned.name);
|
|
106
|
+
members.push({ ...planned, cid });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
for (const name of createdIdentities)
|
|
111
|
+
await removeMemberIdentity(name);
|
|
112
|
+
setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member identity creation failed. Retry with `task recover`.', 'member_failed');
|
|
113
|
+
if (taskId)
|
|
114
|
+
failTask(taskId, error instanceof Error ? error.message : String(error));
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const seats = members.map(m => ({
|
|
118
|
+
role_name: m.name,
|
|
119
|
+
identity_cid: m.cid,
|
|
120
|
+
slot: m.slot,
|
|
121
|
+
cowork_role: m.coworkRole,
|
|
122
|
+
seat_state: 'pending',
|
|
123
|
+
}));
|
|
124
|
+
updateMemberSeats(roomId, seats);
|
|
125
|
+
if (taskId) {
|
|
126
|
+
const taskMembers = members.map(m => ({
|
|
127
|
+
name: m.name,
|
|
128
|
+
identity_cid: m.cid,
|
|
129
|
+
slot: m.slot,
|
|
130
|
+
cowork_role: m.coworkRole,
|
|
131
|
+
}));
|
|
132
|
+
updateTaskMembers(taskId, taskMembers);
|
|
133
|
+
}
|
|
134
|
+
// Phase 5: join_role_groups — serial by Cowork role
|
|
135
|
+
advanceSaga(roomId, 'join_role_groups', 4);
|
|
136
|
+
const roleGroups = new Map();
|
|
137
|
+
for (const m of members) {
|
|
138
|
+
const group = roleGroups.get(m.coworkRole) ?? [];
|
|
139
|
+
group.push(m);
|
|
140
|
+
roleGroups.set(m.coworkRole, group);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
for (const [coworkRole, group] of roleGroups) {
|
|
144
|
+
const { invite } = await cowork.issueInvite(roomId, {
|
|
145
|
+
role: coworkRole,
|
|
146
|
+
min_accepts: group.length,
|
|
147
|
+
});
|
|
148
|
+
// invite is used in-memory only — NEVER persisted
|
|
149
|
+
for (const member of group) {
|
|
150
|
+
await cowork.acceptInvite(roomId, invite, {
|
|
151
|
+
role: coworkRole,
|
|
152
|
+
expected_cid: member.cid,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
for (const name of createdIdentities)
|
|
159
|
+
await removeMemberIdentity(name);
|
|
160
|
+
setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Role-group admission failed. Retry with `task recover`.', 'member_failed');
|
|
161
|
+
if (taskId)
|
|
162
|
+
failTask(taskId, error instanceof Error ? error.message : String(error));
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
// Phase 6: wait_seats
|
|
166
|
+
advanceSaga(roomId, 'wait_seats', 5);
|
|
167
|
+
const coworkSeats = await cowork.getSeats(roomId);
|
|
168
|
+
const memberCids = new Set(members.map(m => m.cid));
|
|
169
|
+
const allActive = [...memberCids].every(cid => coworkSeats.some(s => s.identity_cid === cid && s.seat_state === 'active'));
|
|
170
|
+
if (!allActive) {
|
|
171
|
+
advanceSaga(roomId, 'wait_seats', 5, 'waiting_seats');
|
|
172
|
+
// Seats are pending — return record for caller to retry later
|
|
173
|
+
return getRoomRecord(roomId);
|
|
174
|
+
}
|
|
175
|
+
const activeSeats = seats.map(s => ({ ...s, seat_state: 'active' }));
|
|
176
|
+
updateMemberSeats(roomId, activeSeats);
|
|
177
|
+
// Phase 7: launch_work
|
|
178
|
+
advanceSaga(roomId, 'launch_work', 6);
|
|
179
|
+
try {
|
|
180
|
+
for (const member of members) {
|
|
181
|
+
const briefing = buildMemberBriefing(member, input, members);
|
|
182
|
+
let refRole;
|
|
183
|
+
try {
|
|
184
|
+
refRole = findRole(cfg, member.roleRef);
|
|
185
|
+
}
|
|
186
|
+
catch { /* no ref role */ }
|
|
187
|
+
const model = member.overrides?.model ?? refRole?.model;
|
|
188
|
+
const harness = member.overrides?.harness ?? refRole?.harness;
|
|
189
|
+
const cwd = member.overrides?.cwd ?? refRole?.cwd;
|
|
190
|
+
const persona = member.overrides?.persona ?? refRole?.persona;
|
|
191
|
+
await spawnTemp({
|
|
192
|
+
name: member.name,
|
|
193
|
+
temp: true,
|
|
194
|
+
identity: member.name,
|
|
195
|
+
mission: briefing,
|
|
196
|
+
model,
|
|
197
|
+
harness,
|
|
198
|
+
cwd,
|
|
199
|
+
persona,
|
|
200
|
+
surface: 'agent',
|
|
201
|
+
}, binPath);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
setSagaError(roomId, error instanceof Error ? error.message : String(error), 'Member launch failed. Some agents may be running. Retry with `task recover`.', 'member_failed');
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
// Phase 8: activate
|
|
209
|
+
advanceSaga(roomId, 'activate', 7);
|
|
210
|
+
const record = activateRoom(roomId);
|
|
211
|
+
if (taskId)
|
|
212
|
+
activateTask(taskId);
|
|
213
|
+
return record;
|
|
214
|
+
}
|
|
215
|
+
export async function cleanupMembers(input) {
|
|
216
|
+
const room = getRoomRecord(input.roomId);
|
|
217
|
+
if (!room)
|
|
218
|
+
return;
|
|
219
|
+
for (const seat of room.member_seats) {
|
|
220
|
+
await removeMemberIdentity(seat.role_name);
|
|
221
|
+
}
|
|
222
|
+
if (input.closeCoworkRoom && input.cowork) {
|
|
223
|
+
try {
|
|
224
|
+
await input.cowork.closeRoom(input.roomId);
|
|
225
|
+
}
|
|
226
|
+
catch { /* best-effort */ }
|
|
227
|
+
}
|
|
228
|
+
closeRoomRecord(input.roomId);
|
|
229
|
+
}
|
|
@@ -39,6 +39,7 @@ export interface TaskRecord {
|
|
|
39
39
|
state: TaskState;
|
|
40
40
|
blocked?: TaskBlocked;
|
|
41
41
|
template?: TaskTemplateRef;
|
|
42
|
+
no_room?: boolean;
|
|
42
43
|
room_id?: string;
|
|
43
44
|
room_identity_cid?: string;
|
|
44
45
|
member_roles: TaskMemberRole[];
|
|
@@ -69,6 +70,7 @@ export interface RoomOrchestrationRecord {
|
|
|
69
70
|
room_id: string;
|
|
70
71
|
room_identity_cid?: string;
|
|
71
72
|
room_name: string;
|
|
73
|
+
goal?: string;
|
|
72
74
|
task_id?: string;
|
|
73
75
|
template_snapshot?: TemplateSnapshot;
|
|
74
76
|
saga: SagaCursor;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.19.0-nightly.
|
|
3
|
+
"version": "0.19.0-nightly.4",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|