@ours.network/fleet 1.1.0-nightly.24 → 1.1.0-nightly.26
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 +36 -5
- package/dist/application/task-room-service.d.ts +30 -1
- package/dist/application/task-room-service.js +167 -104
- package/dist/briefing.js +15 -4
- package/dist/build-info.json +4 -4
- package/dist/cli.js +9 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -4
- package/dist/fleet-command-audit.d.ts +11 -0
- package/dist/fleet-command-audit.js +47 -3
- package/dist/init-wizard.js +1 -0
- package/dist/owner-channel/channel.d.ts +4 -0
- package/dist/owner-channel/channel.js +90 -8
- package/dist/owner-channel/commands.d.ts +7 -2
- package/dist/owner-channel/commands.js +36 -2
- package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
- package/dist/owner-channel/lifecycle-outbox.js +87 -0
- package/dist/preset-migration.d.ts +1 -1
- package/dist/preset-migration.js +23 -4
- package/dist/rooms-tasks/cli.js +139 -106
- package/dist/rooms-tasks/external-worker.d.ts +8 -0
- package/dist/rooms-tasks/external-worker.js +38 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +9 -2
- package/dist/rooms-tasks/provision.js +17 -27
- package/dist/runner.js +2 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/control.js +14 -1
- package/package.json +1 -1
- package/presets/fleet/agent_templates/Critic.yaml +1 -0
- package/presets/fleet/agent_templates/Developer.yaml +1 -0
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -0
- package/presets/fleet/roles/Critic.yaml +10 -1
- package/presets/fleet/roles/Developer.yaml +10 -1
- package/presets/fleet/roles/LocalCoordinator.yaml +14 -4
- package/presets/fleet/room_templates/pair.yaml +3 -0
- package/presets/fleet/room_templates/single.yaml +3 -1
- package/presets/fleet/room_templates/team.yaml +3 -0
- package/presets/manifest.json +1 -1
|
@@ -15,10 +15,11 @@ import { ACP_CANCEL_DEADLINE_EXCEEDED, CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED
|
|
|
15
15
|
import { VERSION } from '../version.js';
|
|
16
16
|
import { renderMarkdownFailure, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
|
|
17
17
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
18
|
-
import { FleetCommandAuditStore, lifecycleEventDigestBasis, renderFleetLifecycleEvent, } from '../fleet-command-audit.js';
|
|
18
|
+
import { beginFleetAuditCollection, consumeFleetAuditCollection, FleetCommandAuditStore, lifecycleEventDigestBasis, renderFleetLifecycleEvent, setFleetAuditLifecycleCheckpoint, } from '../fleet-command-audit.js';
|
|
19
19
|
import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
|
|
20
20
|
import { ownerNotices, } from './notices.js';
|
|
21
21
|
import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
|
|
22
|
+
import { FleetLifecycleOutbox } from './lifecycle-outbox.js';
|
|
22
23
|
import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
23
24
|
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
|
|
24
25
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
@@ -112,6 +113,7 @@ export class OwnerChannel {
|
|
|
112
113
|
fleetOps;
|
|
113
114
|
prepareRestart;
|
|
114
115
|
commandAudits;
|
|
116
|
+
lifecycleOutbox;
|
|
115
117
|
constructor(options) {
|
|
116
118
|
this.options = options;
|
|
117
119
|
this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
@@ -128,6 +130,7 @@ export class OwnerChannel {
|
|
|
128
130
|
});
|
|
129
131
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
130
132
|
this.commandAudits = new FleetCommandAuditStore(join(options.stateDir, '.owner-channel-command-audit.json'));
|
|
133
|
+
this.lifecycleOutbox = new FleetLifecycleOutbox(join(options.stateDir, '.owner-channel-lifecycle-outbox.json'));
|
|
131
134
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
132
135
|
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
133
136
|
this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
|
|
@@ -153,6 +156,8 @@ export class OwnerChannel {
|
|
|
153
156
|
options.log(`[${options.role}] owner message recovery state corrupt; message intake disabled`);
|
|
154
157
|
if (!this.attachmentRecovery.integrity())
|
|
155
158
|
options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
|
|
159
|
+
if (!this.lifecycleOutbox.integrity().ok)
|
|
160
|
+
options.log(`[${options.role}] owner lifecycle outbox corrupt; lifecycle delivery disabled`);
|
|
156
161
|
}
|
|
157
162
|
async start() {
|
|
158
163
|
this.stopping = false;
|
|
@@ -200,6 +205,7 @@ export class OwnerChannel {
|
|
|
200
205
|
this.startedOnce = true;
|
|
201
206
|
this.ready = true;
|
|
202
207
|
await this.flushPendingFleetCommandAudits();
|
|
208
|
+
await this.flushPendingFleetLifecycle();
|
|
203
209
|
// Do not make role startup wait for an old owner request to finish a turn.
|
|
204
210
|
// watchLoop itself drains before every establishment, including this first
|
|
205
211
|
// one, so there is no drain-to-tip race.
|
|
@@ -275,6 +281,37 @@ export class OwnerChannel {
|
|
|
275
281
|
...(event.configuration ? { configuration: event.configuration } : {}) }), `fleet-spawn\0${event.creationActionId}`, 0);
|
|
276
282
|
});
|
|
277
283
|
}
|
|
284
|
+
notifyFleetLifecycle(presentations) {
|
|
285
|
+
const run = this.managementTail.then(async () => {
|
|
286
|
+
// Acceptance is durable before delivery. A detached worker may exit
|
|
287
|
+
// while the Owner channel is reconnecting; startup/recovery flushes the
|
|
288
|
+
// pending event through the existing exactly-once send ledger.
|
|
289
|
+
this.lifecycleOutbox.enqueue(presentations);
|
|
290
|
+
if (this.ready && !this.stopping)
|
|
291
|
+
await this.flushPendingFleetLifecycle();
|
|
292
|
+
});
|
|
293
|
+
this.managementTail = run.then(() => undefined, () => undefined);
|
|
294
|
+
return run;
|
|
295
|
+
}
|
|
296
|
+
async flushPendingFleetLifecycle() {
|
|
297
|
+
for (const entry of this.lifecycleOutbox.pending()) {
|
|
298
|
+
try {
|
|
299
|
+
await this.sendProactiveMessage(renderFleetLifecycleEvent(entry.presentation), `fleet-lifecycle\0${lifecycleEventDigestBasis(entry.presentation)}`, 0);
|
|
300
|
+
this.lifecycleOutbox.finish(entry.digest, 'delivered');
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error instanceof DuplicateSendError) {
|
|
304
|
+
this.lifecycleOutbox.finish(entry.digest, error.status === 'delivered' ? 'delivered' : 'uncertain');
|
|
305
|
+
this.options.log(`[${this.options.role}] duplicate Fleet lifecycle event suppressed`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// If the send crossed the uncertain boundary, the proactive ledger
|
|
309
|
+
// will refuse it on the next flush. A pre-send outage stays pending.
|
|
310
|
+
this.logError('pending Fleet lifecycle delivery deferred', error);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
278
315
|
beginFleetCommandAudit(requestId, argv) {
|
|
279
316
|
const run = this.managementTail.then(async () => {
|
|
280
317
|
let attempt = this.commandAudits.begin(requestId, this.options.role, argv);
|
|
@@ -396,6 +433,7 @@ export class OwnerChannel {
|
|
|
396
433
|
if (superseded())
|
|
397
434
|
throw new Error('owner recovery epoch superseded');
|
|
398
435
|
this.ready = true;
|
|
436
|
+
await this.flushPendingFleetLifecycle();
|
|
399
437
|
this.watchTask = this.watchLoop(watchGeneration);
|
|
400
438
|
}
|
|
401
439
|
catch (error) {
|
|
@@ -1235,6 +1273,19 @@ export class OwnerChannel {
|
|
|
1235
1273
|
* agent itself can execute /force-restart, /model, or any other command.
|
|
1236
1274
|
*/
|
|
1237
1275
|
async handleCommand(sender, text, wireId) {
|
|
1276
|
+
const provisioningCommand = async (run) => {
|
|
1277
|
+
beginFleetAuditCollection();
|
|
1278
|
+
setFleetAuditLifecycleCheckpoint(events => this.notifyFleetLifecycle(events));
|
|
1279
|
+
try {
|
|
1280
|
+
return await run();
|
|
1281
|
+
}
|
|
1282
|
+
finally {
|
|
1283
|
+
setFleetAuditLifecycleCheckpoint(undefined);
|
|
1284
|
+
const remaining = consumeFleetAuditCollection().presentations;
|
|
1285
|
+
if (remaining?.length)
|
|
1286
|
+
await this.notifyFleetLifecycle(remaining);
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1238
1289
|
const ctx = {
|
|
1239
1290
|
authenticatedCid: sender.id,
|
|
1240
1291
|
role: this.options.role,
|
|
@@ -1257,13 +1308,44 @@ export class OwnerChannel {
|
|
|
1257
1308
|
recoverRoom: roomId => this.recoverRoomFromOwner(sender, roomId, wireId),
|
|
1258
1309
|
terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
|
|
1259
1310
|
recoverTask: taskId => this.recoverTaskFromOwner(sender, taskId, wireId),
|
|
1260
|
-
createTask: input =>
|
|
1261
|
-
|
|
1262
|
-
|
|
1311
|
+
createTask: input => provisioningCommand(async () => {
|
|
1312
|
+
const service = new TaskRoomApplicationService(this.options.configPath);
|
|
1313
|
+
const task = await service.createTask({
|
|
1314
|
+
...input,
|
|
1315
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
|
|
1316
|
+
});
|
|
1317
|
+
if (!input.backlog && !input.noRoom && task.room_id) {
|
|
1318
|
+
const outcome = service.taskProvisioningOutcome(task.task_id);
|
|
1319
|
+
if (outcome.kind === 'in_progress' && !outcome.next_action)
|
|
1320
|
+
await this.fleetOps.provisionTask(task.task_id);
|
|
1321
|
+
}
|
|
1322
|
+
return task;
|
|
1263
1323
|
}),
|
|
1264
|
-
startTask: taskId =>
|
|
1265
|
-
|
|
1324
|
+
startTask: taskId => provisioningCommand(async () => {
|
|
1325
|
+
const service = new TaskRoomApplicationService(this.options.configPath);
|
|
1326
|
+
const task = await service.startTask({
|
|
1327
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1328
|
+
});
|
|
1329
|
+
// A zero-duration observation repairs a ready notice after a crash or
|
|
1330
|
+
// duplicate start without extending the start command's existing wait.
|
|
1331
|
+
const outcome = await service.awaitTaskProvisioning({
|
|
1332
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
|
|
1333
|
+
taskId: task.task_id, waitMs: 0,
|
|
1334
|
+
});
|
|
1335
|
+
if (outcome.kind === 'in_progress' && !outcome.next_action)
|
|
1336
|
+
await this.fleetOps.provisionTask(task.task_id);
|
|
1337
|
+
return outcome;
|
|
1266
1338
|
}),
|
|
1339
|
+
awaitTask: taskId => provisioningCommand(async () => {
|
|
1340
|
+
const service = new TaskRoomApplicationService(this.options.configPath);
|
|
1341
|
+
const initial = service.taskProvisioningOutcome(taskId);
|
|
1342
|
+
if (initial.kind === 'in_progress')
|
|
1343
|
+
await this.fleetOps.provisionTask(taskId);
|
|
1344
|
+
return service.awaitTaskProvisioning({
|
|
1345
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1346
|
+
});
|
|
1347
|
+
}),
|
|
1348
|
+
taskProvisioningOutcome: taskId => new TaskRoomApplicationService(this.options.configPath).taskProvisioningOutcome(taskId),
|
|
1267
1349
|
listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
|
|
1268
1350
|
groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
|
|
1269
1351
|
listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
|
|
@@ -1294,9 +1376,9 @@ export class OwnerChannel {
|
|
|
1294
1376
|
getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
|
|
1295
1377
|
listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
|
|
1296
1378
|
getTemplateQuery: name => new TaskRoomApplicationService(this.options.configPath).getTemplate(name),
|
|
1297
|
-
createRoom: input => new TaskRoomApplicationService(this.options.configPath).createRoom({
|
|
1379
|
+
createRoom: input => provisioningCommand(() => new TaskRoomApplicationService(this.options.configPath).createRoom({
|
|
1298
1380
|
...input, actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
|
|
1299
|
-
}),
|
|
1381
|
+
})),
|
|
1300
1382
|
recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
|
|
1301
1383
|
readWorklogTail: maxChars => this.readWorklogTail(maxChars),
|
|
1302
1384
|
reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
|
|
2
2
|
import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent, TaskListRecord } from '../rooms-tasks/types.js';
|
|
3
3
|
import { type OwnerCommentsState } from './notices.js';
|
|
4
|
-
import type { CreateRoomRequest, CreateTaskRequest, TaskRoomApplicationService } from '../application/task-room-service.js';
|
|
4
|
+
import type { CreateRoomRequest, CreateTaskRequest, TaskProvisioningOutcome, TaskRoomApplicationService } from '../application/task-room-service.js';
|
|
5
5
|
import type { TaskState } from '../rooms-tasks/types.js';
|
|
6
6
|
/**
|
|
7
7
|
* Fleet-level effects a deterministic owner command may trigger. Production
|
|
@@ -19,6 +19,9 @@ export interface OwnerFleetOps {
|
|
|
19
19
|
settleTask(taskId: string): Promise<void>;
|
|
20
20
|
/** Settle an accepted task deletion outside the caller role's supervisor lifecycle. */
|
|
21
21
|
settleTaskDeletion(taskId: string): Promise<void>;
|
|
22
|
+
/** Continue an accepted provisioning saga until convergence. */
|
|
23
|
+
provisionTask(taskId: string): Promise<void>;
|
|
24
|
+
/** Explicit recovery may also route terminal and deletion intents. */
|
|
22
25
|
recoverTask(taskId: string): Promise<void>;
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
@@ -58,7 +61,9 @@ export interface OwnerCommandContext {
|
|
|
58
61
|
terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
|
|
59
62
|
recoverTask(taskId: string): Promise<void>;
|
|
60
63
|
createTask(input: Omit<CreateTaskRequest, 'actor'>): Promise<TaskRecord>;
|
|
61
|
-
startTask(taskId: string): Promise<TaskRecord>;
|
|
64
|
+
startTask(taskId: string): Promise<TaskProvisioningOutcome | TaskRecord>;
|
|
65
|
+
awaitTask?(taskId: string): Promise<TaskProvisioningOutcome>;
|
|
66
|
+
taskProvisioningOutcome?(taskId: string): TaskProvisioningOutcome;
|
|
62
67
|
listTasks(filter?: {
|
|
63
68
|
state?: TaskState | TaskState[];
|
|
64
69
|
list?: string;
|
|
@@ -106,6 +106,24 @@ const taskAction = (title, task, fields = []) => renderMarkdownResult({
|
|
|
106
106
|
...fields,
|
|
107
107
|
],
|
|
108
108
|
});
|
|
109
|
+
const taskProvisioningAction = (outcome) => {
|
|
110
|
+
if (outcome.kind === 'failed')
|
|
111
|
+
return renderMarkdownFailure({
|
|
112
|
+
kind: 'state', subject: `/task await ${outcome.task.task_id}`,
|
|
113
|
+
detail: outcome.blocker ?? 'Provisioning reached a terminal failure.',
|
|
114
|
+
action: outcome.next_action ?? `Run /task recover ${outcome.task.task_id}.`,
|
|
115
|
+
});
|
|
116
|
+
return taskAction(outcome.kind === 'ready' ? 'Room provisioning complete'
|
|
117
|
+
: outcome.next_action ? 'Room provisioning needs attention' : 'Room provisioning continues', outcome.task, [
|
|
118
|
+
...(outcome.room ? [{ label: 'Room', value: `${outcome.room.room_id} — ${outcome.room.room_name}` }] : []),
|
|
119
|
+
...(outcome.launch.template ? [{ label: 'Template', value: outcome.launch.template, kind: 'code' }] : []),
|
|
120
|
+
{ label: 'Members', value: `${outcome.members.active}/${outcome.members.expected} active; ${outcome.members.launched}/${outcome.members.expected} launched` },
|
|
121
|
+
...(outcome.blocker ? [{ label: 'Blocker', value: outcome.blocker }] : []),
|
|
122
|
+
...(outcome.next_action ? [{ label: 'Next action', value: outcome.next_action }] : []),
|
|
123
|
+
...(outcome.kind === 'in_progress'
|
|
124
|
+
? [{ label: 'Await', value: `/task await ${outcome.task.task_id}`, kind: 'code' }] : []),
|
|
125
|
+
]);
|
|
126
|
+
};
|
|
109
127
|
const roomAction = (title, room, fields = []) => renderMarkdownResult({
|
|
110
128
|
icon: '🏠', title,
|
|
111
129
|
fields: [
|
|
@@ -319,6 +337,12 @@ export const ownerCommands = [
|
|
|
319
337
|
title: titleParts.join(' '), origin: { type: 'owner_channel' },
|
|
320
338
|
backlog, template, noRoom, brief: trailingLines, list,
|
|
321
339
|
});
|
|
340
|
+
const provisioning = !backlog && !noRoom && t.room_id
|
|
341
|
+
? ctx.taskProvisioningOutcome?.(t.task_id) : undefined;
|
|
342
|
+
if (provisioning) {
|
|
343
|
+
await ctx.reply(taskProvisioningAction(provisioning));
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
322
346
|
await ctx.reply(taskAction('Task created', t, [
|
|
323
347
|
{ label: 'Title', value: t.title },
|
|
324
348
|
...(template ? [{ label: 'Template', value: template, kind: 'code' }] : []),
|
|
@@ -419,8 +443,17 @@ export const ownerCommands = [
|
|
|
419
443
|
case 'start': {
|
|
420
444
|
if (!rest[0])
|
|
421
445
|
throw new OwnerCommandUsageError('usage: /task start <id>');
|
|
422
|
-
const
|
|
423
|
-
await ctx.reply(
|
|
446
|
+
const started = await ctx.startTask(rest[0]);
|
|
447
|
+
await ctx.reply('kind' in started
|
|
448
|
+
? taskProvisioningAction(started) : taskAction('Task started', started));
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case 'await': {
|
|
452
|
+
if (!rest[0])
|
|
453
|
+
throw new OwnerCommandUsageError('usage: /task await <id>');
|
|
454
|
+
if (!ctx.awaitTask)
|
|
455
|
+
throw new Error('task await is unavailable');
|
|
456
|
+
await ctx.reply(taskProvisioningAction(await ctx.awaitTask(rest[0])));
|
|
424
457
|
break;
|
|
425
458
|
}
|
|
426
459
|
case 'block': {
|
|
@@ -731,6 +764,7 @@ export function fleetCliOps(role, configPath) {
|
|
|
731
764
|
closeRoom: roomId => launchFleetWorker(['room', '_delete', roomId], `room-delete-${roomId}`, configPath),
|
|
732
765
|
settleTask: taskId => launchFleetWorker(['task', '_settle', taskId], `task-settle-${taskId}`, configPath),
|
|
733
766
|
settleTaskDeletion: taskId => launchFleetWorker(['task', '_settle_delete', taskId], `task-delete-${taskId}`, configPath),
|
|
767
|
+
provisionTask: taskId => launchFleetWorker(['task', '_provision', taskId], `task-provision-${taskId}`, configPath),
|
|
734
768
|
recoverTask: taskId => launchFleetWorker(['task', '_recover', taskId], `task-recover-${taskId}`, configPath),
|
|
735
769
|
};
|
|
736
770
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type FleetAuditPresentation } from '../fleet-command-audit.js';
|
|
2
|
+
type LifecycleDelivery = 'pending' | 'delivered' | 'uncertain';
|
|
3
|
+
interface LifecycleOutboxEntry {
|
|
4
|
+
digest: string;
|
|
5
|
+
presentation: FleetAuditPresentation;
|
|
6
|
+
delivery: LifecycleDelivery;
|
|
7
|
+
}
|
|
8
|
+
/** Durable lifecycle acceptance queue; plaintext is limited to validated concise presentations. */
|
|
9
|
+
export declare class FleetLifecycleOutbox {
|
|
10
|
+
private readonly path;
|
|
11
|
+
private entries;
|
|
12
|
+
private corruptReason?;
|
|
13
|
+
constructor(path: string);
|
|
14
|
+
integrity(): {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
enqueue(presentations: FleetAuditPresentation[]): void;
|
|
19
|
+
pending(): readonly LifecycleOutboxEntry[];
|
|
20
|
+
finish(digest: string, delivery: Exclude<LifecycleDelivery, 'pending'>): void;
|
|
21
|
+
private assertHealthy;
|
|
22
|
+
private persist;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { lifecycleEventDigestBasis, validateFleetAuditPresentations, } from '../fleet-command-audit.js';
|
|
5
|
+
const DIGEST = /^[a-f0-9]{64}$/u;
|
|
6
|
+
const LIMIT = 5_000;
|
|
7
|
+
/** Durable lifecycle acceptance queue; plaintext is limited to validated concise presentations. */
|
|
8
|
+
export class FleetLifecycleOutbox {
|
|
9
|
+
path;
|
|
10
|
+
entries = [];
|
|
11
|
+
corruptReason;
|
|
12
|
+
constructor(path) {
|
|
13
|
+
this.path = path;
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
if (value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > LIMIT)
|
|
19
|
+
throw new Error('invalid lifecycle outbox');
|
|
20
|
+
const digests = new Set();
|
|
21
|
+
for (const entry of value.entries) {
|
|
22
|
+
validateFleetAuditPresentations([entry?.presentation]);
|
|
23
|
+
if (!DIGEST.test(entry.digest)
|
|
24
|
+
|| !['pending', 'delivered', 'uncertain'].includes(entry.delivery)
|
|
25
|
+
|| digests.has(entry.digest))
|
|
26
|
+
throw new Error('invalid lifecycle outbox entry');
|
|
27
|
+
digests.add(entry.digest);
|
|
28
|
+
}
|
|
29
|
+
this.entries = structuredClone(value.entries);
|
|
30
|
+
chmodSync(path, 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
this.corruptReason = 'invalid persisted Fleet lifecycle outbox';
|
|
34
|
+
this.entries = [];
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(path, 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch { /* remain fail-closed */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
integrity() {
|
|
42
|
+
return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
|
|
43
|
+
}
|
|
44
|
+
enqueue(presentations) {
|
|
45
|
+
this.assertHealthy();
|
|
46
|
+
validateFleetAuditPresentations(presentations);
|
|
47
|
+
let changed = false;
|
|
48
|
+
for (const presentation of presentations) {
|
|
49
|
+
const digest = createHash('sha256').update(lifecycleEventDigestBasis(presentation)).digest('hex');
|
|
50
|
+
if (this.entries.some(entry => entry.digest === digest))
|
|
51
|
+
continue;
|
|
52
|
+
if (this.entries.length >= LIMIT) {
|
|
53
|
+
const settled = this.entries.findIndex(entry => entry.delivery !== 'pending');
|
|
54
|
+
if (settled < 0)
|
|
55
|
+
throw new Error(`Fleet lifecycle outbox is limited to ${LIMIT} pending events`);
|
|
56
|
+
this.entries.splice(settled, 1);
|
|
57
|
+
}
|
|
58
|
+
this.entries.push({ digest, presentation: structuredClone(presentation), delivery: 'pending' });
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
if (changed)
|
|
62
|
+
this.persist();
|
|
63
|
+
}
|
|
64
|
+
pending() {
|
|
65
|
+
this.assertHealthy();
|
|
66
|
+
return this.entries.filter(entry => entry.delivery === 'pending').map(entry => structuredClone(entry));
|
|
67
|
+
}
|
|
68
|
+
finish(digest, delivery) {
|
|
69
|
+
this.assertHealthy();
|
|
70
|
+
const entry = this.entries.find(candidate => candidate.digest === digest);
|
|
71
|
+
if (!entry)
|
|
72
|
+
throw new Error('unknown Fleet lifecycle outbox entry');
|
|
73
|
+
if (entry.delivery === delivery)
|
|
74
|
+
return;
|
|
75
|
+
if (entry.delivery !== 'pending')
|
|
76
|
+
throw new Error('conflicting Fleet lifecycle delivery state');
|
|
77
|
+
entry.delivery = delivery;
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
assertHealthy() {
|
|
81
|
+
if (this.corruptReason)
|
|
82
|
+
throw new Error(this.corruptReason);
|
|
83
|
+
}
|
|
84
|
+
persist() {
|
|
85
|
+
replaceFileAtomically(this.path, `${JSON.stringify({ version: 1, entries: this.entries })}\n`, 0o600);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -31,7 +31,7 @@ interface MigrationHooks {
|
|
|
31
31
|
beforeLockClaim?: () => void;
|
|
32
32
|
beforeRoleDefaultPublish?: () => void;
|
|
33
33
|
}
|
|
34
|
-
/** Explicit adoption of exact packaged
|
|
34
|
+
/** Explicit adoption of exact known packaged role defaults; customized files are never changed. */
|
|
35
35
|
export declare function migratePackagedRoleDefaults(configuration: string, options?: {
|
|
36
36
|
write?: boolean;
|
|
37
37
|
}, hooks?: MigrationHooks): RoleDefaultMigrationResult;
|
package/dist/preset-migration.js
CHANGED
|
@@ -51,7 +51,23 @@ const V4_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
|
|
|
51
51
|
const V4_GENERATED_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
|
|
52
52
|
'agent_templates/LocalCoordinator.yaml': 'cb7c83a835dde9d45bac42cadce2f196f561d2460321c690cdef435f7076f97f',
|
|
53
53
|
});
|
|
54
|
-
const
|
|
54
|
+
const V5_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
|
|
55
|
+
'roles/LocalCoordinator.yaml': '52714be635060adca2bc7fe99c27c3184be6c78e3874a77e61a2fb135bac1278',
|
|
56
|
+
'roles/Developer.yaml': '04278f325c366035e82d8e7f4aff72badb297eaf2fb5afae38a28a5aaf9260c9',
|
|
57
|
+
'roles/Critic.yaml': 'bcb96c84534ca6fc466a8ee63f679785d347ed1cdf223efcc36ca5ed4ca7f3de',
|
|
58
|
+
'agent_templates/LocalCoordinator.yaml': 'c59980ab7d94dad4d4961ba308cf62342291d8f1c57cf2faf900e80136fa2e7d',
|
|
59
|
+
'agent_templates/Developer.yaml': '36f1d010a9c8591ae5ba422ef1ac26335b9f7f3382b6b8532844df754a56287e',
|
|
60
|
+
'agent_templates/Critic.yaml': '7025b842e260a9c91ebc64dd3a11162a82b025b287457c6cb6f6c8964b38cdfd',
|
|
61
|
+
'room_templates/single.yaml': '61051917b1f4f53a13687cc69d3f9ad98f1b02558f82b236b22aac942850395f',
|
|
62
|
+
'room_templates/pair.yaml': 'a20b2145fa84e5d61266385e629892734b98fc61be9d0c7647c3778ec1bb40a8',
|
|
63
|
+
'room_templates/team.yaml': 'c455f54a1f86106bfac7192299130dabfb91fa6179d2dedcc0573a384a902411',
|
|
64
|
+
});
|
|
65
|
+
const V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS = Object.freeze({
|
|
66
|
+
'agent_templates/LocalCoordinator.yaml': 'cb7c83a835dde9d45bac42cadce2f196f561d2460321c690cdef435f7076f97f',
|
|
67
|
+
'agent_templates/Developer.yaml': 'fdd953aab72d042e62780dd273598745aae59521b39edf4876d7676bb7189c83',
|
|
68
|
+
'agent_templates/Critic.yaml': '3e98a28f17876061cfadf984351aa59fdbe19629e514eff52c74546545c64a91',
|
|
69
|
+
});
|
|
70
|
+
const CURRENT_ROLE_DEFAULTS = new Set([
|
|
55
71
|
'roles/Coordinator.yaml', 'roles/LocalCoordinator.yaml', 'roles/Developer.yaml', 'roles/Critic.yaml',
|
|
56
72
|
'agent_templates/LocalCoordinator.yaml', 'agent_templates/Developer.yaml', 'agent_templates/Critic.yaml',
|
|
57
73
|
'room_templates/single.yaml', 'room_templates/pair.yaml', 'room_templates/team.yaml',
|
|
@@ -152,7 +168,7 @@ function validateRoleDefaultMigration(stageManifest) {
|
|
|
152
168
|
throw new Error(`Room Template '${template.name}' references missing Agent Template '${member.agent_template}'`);
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
|
-
/** Explicit adoption of exact packaged
|
|
171
|
+
/** Explicit adoption of exact known packaged role defaults; customized files are never changed. */
|
|
156
172
|
export function migratePackagedRoleDefaults(configuration, options = {}, hooks = {}) {
|
|
157
173
|
const configPath = resolve(configuration);
|
|
158
174
|
const root = splitRootFor(configPath);
|
|
@@ -176,6 +192,7 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
|
|
|
176
192
|
const known = new Set([
|
|
177
193
|
...Object.keys(V3_ROLE_DEFAULT_FINGERPRINTS),
|
|
178
194
|
...Object.keys(V4_ROLE_DEFAULT_FINGERPRINTS),
|
|
195
|
+
...Object.keys(V5_ROLE_DEFAULT_FINGERPRINTS),
|
|
179
196
|
]);
|
|
180
197
|
for (const relative of known) {
|
|
181
198
|
const path = join(root, relative);
|
|
@@ -187,14 +204,16 @@ export function migratePackagedRoleDefaults(configuration, options = {}, hooks =
|
|
|
187
204
|
V3_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
188
205
|
V4_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
189
206
|
V4_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
207
|
+
V5_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
208
|
+
V5_GENERATED_ROLE_DEFAULT_FINGERPRINTS[relative],
|
|
190
209
|
].some(expected => expected !== undefined && actual === expected);
|
|
191
210
|
if (recognized)
|
|
192
|
-
(
|
|
211
|
+
(CURRENT_ROLE_DEFAULTS.has(relative) ? replacements : removals).push(path);
|
|
193
212
|
else
|
|
194
213
|
preserved.push(path);
|
|
195
214
|
}
|
|
196
215
|
const additions = [];
|
|
197
|
-
for (const relative of
|
|
216
|
+
for (const relative of CURRENT_ROLE_DEFAULTS) {
|
|
198
217
|
const path = join(root, relative);
|
|
199
218
|
if (!existsSync(path))
|
|
200
219
|
additions.push(path);
|