@ours.network/fleet 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/dist/application/errors.d.ts +1 -1
- package/dist/application/role-command-service.d.ts +29 -1
- package/dist/application/role-command-service.js +41 -2
- package/dist/application/role-creation-service.d.ts +32 -1
- package/dist/application/role-creation-service.js +56 -10
- package/dist/application/role-removal-service.d.ts +19 -0
- package/dist/application/role-removal-service.js +13 -3
- package/dist/application/session-mutations.d.ts +7 -0
- package/dist/application/session-mutations.js +8 -0
- package/dist/application/task-room-service.d.ts +262 -0
- package/dist/application/task-room-service.js +571 -0
- package/dist/atomic-file.d.ts +5 -3
- package/dist/atomic-file.js +32 -8
- package/dist/build-info.json +4 -4
- package/dist/cli.js +39 -15
- package/dist/config.d.ts +6 -3
- package/dist/config.js +0 -17
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +26 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -4
- package/dist/owner-channel/attachments.js +6 -39
- package/dist/owner-channel/channel.d.ts +5 -0
- package/dist/owner-channel/channel.js +159 -21
- package/dist/owner-channel/commands.d.ts +43 -1
- package/dist/owner-channel/commands.js +137 -130
- package/dist/rooms-tasks/cli.js +301 -463
- package/dist/rooms-tasks/task-lists.d.ts +19 -0
- package/dist/rooms-tasks/task-lists.js +96 -0
- package/dist/rooms-tasks/task-state.d.ts +3 -0
- package/dist/rooms-tasks/task-state.js +281 -175
- package/dist/rooms-tasks/types.d.ts +11 -1
- package/dist/runner.js +10 -33
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +22 -21
- package/dist/watchdog/query.d.ts +2 -0
- package/dist/watchdog/query.js +7 -3
- package/dist/web/runtime.js +2 -0
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +88 -3
- package/package.json +1 -1
|
@@ -94,16 +94,9 @@ function parseTranscription(value, wireId) {
|
|
|
94
94
|
fileWireId: wireId,
|
|
95
95
|
} };
|
|
96
96
|
}
|
|
97
|
-
// Voice notes ride "<base>; x-ours-kind=voice-message" verbatim end to end
|
|
98
|
-
// (the recorder's real container varies: audio/webm Chrome/Android, audio/mp4
|
|
99
|
-
// iOS Safari, audio/ogg fallback), so policy for voice messages applies to the
|
|
100
|
-
// base container type. Ordinary files keep exact allowlist matching.
|
|
101
97
|
function baseMime(mime) {
|
|
102
98
|
return mime.split(';')[0].trim();
|
|
103
99
|
}
|
|
104
|
-
function policyMime(file) {
|
|
105
|
-
return file.kind === 'voice_message' ? baseMime(file.mime) : file.mime;
|
|
106
|
-
}
|
|
107
100
|
export function validateAttachmentSelection(files, config) {
|
|
108
101
|
if (!config.enabled)
|
|
109
102
|
return 'attachments are disabled for this owner channel';
|
|
@@ -111,11 +104,6 @@ export function validateAttachmentSelection(files, config) {
|
|
|
111
104
|
return `the request exceeds the ${config.max_files_per_request}-file limit`;
|
|
112
105
|
let total = 0;
|
|
113
106
|
for (const file of files) {
|
|
114
|
-
const mime = policyMime(file);
|
|
115
|
-
if (file.kind === 'voice_message' && !mime.startsWith('audio/'))
|
|
116
|
-
return `voice-message MIME type ${file.mime || '(missing)'} is not an audio container`;
|
|
117
|
-
if (!config.allowed_mime.includes(mime))
|
|
118
|
-
return `MIME type ${file.mime || '(missing)'} is not allowed`;
|
|
119
107
|
if (file.size > config.max_file_bytes)
|
|
120
108
|
return `a file exceeds the ${config.max_file_bytes}-byte limit`;
|
|
121
109
|
total += file.size;
|
|
@@ -126,7 +114,7 @@ export function validateAttachmentSelection(files, config) {
|
|
|
126
114
|
}
|
|
127
115
|
/**
|
|
128
116
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
129
|
-
* `enabled
|
|
117
|
+
* `enabled`: that is owner -> agent admission policy.
|
|
130
118
|
*/
|
|
131
119
|
export function validateAttachmentRelaySelection(files, config) {
|
|
132
120
|
if (files.length > config.max_files_per_request)
|
|
@@ -157,7 +145,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
|
|
|
157
145
|
await chmod(dir, 0o700);
|
|
158
146
|
return dir;
|
|
159
147
|
}
|
|
160
|
-
export async function admitAttachments(files, dir, config
|
|
148
|
+
export async function admitAttachments(files, dir, config) {
|
|
161
149
|
const admitted = [];
|
|
162
150
|
let total = 0;
|
|
163
151
|
for (let index = 0; index < files.length; index++) {
|
|
@@ -184,11 +172,8 @@ export async function admitAttachments(files, dir, config, options = {}) {
|
|
|
184
172
|
total += bytes.length;
|
|
185
173
|
if (total > config.max_request_bytes)
|
|
186
174
|
throw new Error('retrieved attachments exceed the request size limit');
|
|
187
|
-
const declaredMime =
|
|
188
|
-
const detectedMime = detectMime(bytes, declaredMime);
|
|
189
|
-
if ((options.mimePolicy ?? 'strict') === 'strict'
|
|
190
|
-
&& !mimeCompatible(declaredMime, detectedMime))
|
|
191
|
-
throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
|
|
175
|
+
const declaredMime = file.mime;
|
|
176
|
+
const detectedMime = detectMime(bytes, baseMime(declaredMime));
|
|
192
177
|
const filename = sanitizeFilename(file.filename);
|
|
193
178
|
const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
|
|
194
179
|
const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
|
|
@@ -417,6 +402,8 @@ export function sanitizeFilename(value) {
|
|
|
417
402
|
return clean || 'attachment.bin';
|
|
418
403
|
}
|
|
419
404
|
function detectMime(bytes, declared) {
|
|
405
|
+
if (bytes.length === 0)
|
|
406
|
+
return 'application/octet-stream';
|
|
420
407
|
if (bytes.subarray(0, 5).toString() === '%PDF-')
|
|
421
408
|
return 'application/pdf';
|
|
422
409
|
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
@@ -443,27 +430,7 @@ function detectMime(bytes, declared) {
|
|
|
443
430
|
return 'application/x-cfb';
|
|
444
431
|
const text = bytes.toString('utf8');
|
|
445
432
|
if (!text.includes('\ufffd') && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) {
|
|
446
|
-
if (declared === 'application/json') {
|
|
447
|
-
try {
|
|
448
|
-
JSON.parse(text);
|
|
449
|
-
return 'application/json';
|
|
450
|
-
}
|
|
451
|
-
catch { }
|
|
452
|
-
}
|
|
453
433
|
return 'text/plain';
|
|
454
434
|
}
|
|
455
435
|
return 'application/octet-stream';
|
|
456
436
|
}
|
|
457
|
-
function mimeCompatible(declared, detected) {
|
|
458
|
-
if (declared === detected)
|
|
459
|
-
return true;
|
|
460
|
-
if (['audio/wav', 'audio/x-wav'].includes(declared) && detected === 'audio/wav')
|
|
461
|
-
return true;
|
|
462
|
-
if (detected === 'application/zip' && declared.startsWith('application/vnd.openxmlformats-officedocument.'))
|
|
463
|
-
return true;
|
|
464
|
-
if (detected === 'application/x-cfb' && [
|
|
465
|
-
'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
|
|
466
|
-
].includes(declared))
|
|
467
|
-
return true;
|
|
468
|
-
return false;
|
|
469
|
-
}
|
|
@@ -19,6 +19,8 @@ export interface OwnerChannelOptions {
|
|
|
19
19
|
client?: OursOps;
|
|
20
20
|
/** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
|
|
21
21
|
fleet?: OwnerFleetOps;
|
|
22
|
+
/** Read-only restart validation; production uses the shared lifecycle service. */
|
|
23
|
+
prepareRestart?: (role: string, mode: 'keep' | 'fresh') => Promise<void>;
|
|
22
24
|
/** Forwarded to fleet CLI invocations spawned for owner commands. */
|
|
23
25
|
configPath?: string;
|
|
24
26
|
/** Deterministic clock/process seams for binder handoff tests. */
|
|
@@ -164,6 +166,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
164
166
|
private binder?;
|
|
165
167
|
private binderOwnedInternally;
|
|
166
168
|
private readonly fleetOps;
|
|
169
|
+
private readonly prepareRestart;
|
|
167
170
|
constructor(options: OwnerChannelOptions);
|
|
168
171
|
start(): Promise<void>;
|
|
169
172
|
drain(): Promise<void>;
|
|
@@ -222,7 +225,9 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
222
225
|
* before an external worker starts a saga that can retire this process.
|
|
223
226
|
*/
|
|
224
227
|
private closeRoomFromOwner;
|
|
228
|
+
private recoverRoomFromOwner;
|
|
225
229
|
/** Carry a task terminal request through a worker that survives this role. */
|
|
230
|
+
private recoverTaskFromOwner;
|
|
226
231
|
private terminalTaskFromOwner;
|
|
227
232
|
/** Code-point-safe tail of the worklog, or undefined when there is none. */
|
|
228
233
|
private readWorklogTail;
|
|
@@ -2,11 +2,17 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { canonicalCid } from '../config.js';
|
|
6
6
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
7
|
-
import {
|
|
7
|
+
import { TaskRoomApplicationService } from '../application/task-room-service.js';
|
|
8
|
+
import { RoleLifecycleService } from '../application/role-command-service.js';
|
|
9
|
+
import { RoleRepository } from '../application/role-repository.js';
|
|
10
|
+
import { FleetQueryService } from '../application/fleet-query-service.js';
|
|
11
|
+
import { interruptSession, queueSessionPrompt } from '../application/session-mutations.js';
|
|
12
|
+
import { pickBackend } from '../supervisor/index.js';
|
|
13
|
+
import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, } from '../session/types.js';
|
|
8
14
|
import { VERSION } from '../version.js';
|
|
9
|
-
import { renderMarkdownFailure, renderMarkdownResult, taskStatus, } from '../rooms-tasks/markdown.js';
|
|
15
|
+
import { renderMarkdownFailure, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
|
|
10
16
|
import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
|
|
11
17
|
import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
|
|
12
18
|
import { ownerNotices, } from './notices.js';
|
|
@@ -74,10 +80,21 @@ export class OwnerChannel {
|
|
|
74
80
|
binder;
|
|
75
81
|
binderOwnedInternally = false;
|
|
76
82
|
fleetOps;
|
|
83
|
+
prepareRestart;
|
|
77
84
|
constructor(options) {
|
|
78
85
|
this.options = options;
|
|
79
86
|
this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
|
|
80
87
|
this.fleetOps = options.fleet ?? fleetCliOps(options.role, options.configPath);
|
|
88
|
+
this.prepareRestart = options.prepareRestart ?? (async (role, mode) => {
|
|
89
|
+
const backend = pickBackend();
|
|
90
|
+
const repository = new RoleRepository({ configPath: options.configPath });
|
|
91
|
+
const query = new FleetQueryService({ repository, supervisor: backend });
|
|
92
|
+
const lifecycle = new RoleLifecycleService({ repository,
|
|
93
|
+
ops: { backend, binPath: process.argv[1], log: options.log },
|
|
94
|
+
configPath: options.configPath,
|
|
95
|
+
status: async (roleId) => (await query.detail(roleId)).status });
|
|
96
|
+
await lifecycle.prepareRestart({ roleIds: [role], mode });
|
|
97
|
+
});
|
|
81
98
|
this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
|
|
82
99
|
this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
|
|
83
100
|
this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
|
|
@@ -92,7 +109,6 @@ export class OwnerChannel {
|
|
|
92
109
|
this.attachmentConfig = options.config.attachments ?? {
|
|
93
110
|
enabled: true, max_files_per_request: 4, max_file_bytes: 10 * 1024 * 1024,
|
|
94
111
|
max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
|
|
95
|
-
allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
|
|
96
112
|
};
|
|
97
113
|
const integrity = this.authorizationIntegrity();
|
|
98
114
|
if (!integrity.ok)
|
|
@@ -793,7 +809,7 @@ export class OwnerChannel {
|
|
|
793
809
|
outbox = this.outboxDir(originWireId);
|
|
794
810
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
795
811
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
796
|
-
const queued = await this.options.session
|
|
812
|
+
const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
|
|
797
813
|
interrupt: this.options.config.interrupt,
|
|
798
814
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
799
815
|
origin: { kind: 'owner', requestId,
|
|
@@ -918,7 +934,7 @@ export class OwnerChannel {
|
|
|
918
934
|
let queued;
|
|
919
935
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
920
936
|
try {
|
|
921
|
-
queued = await this.options.session
|
|
937
|
+
queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
|
|
922
938
|
interrupt: this.options.config.interrupt,
|
|
923
939
|
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
924
940
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
@@ -977,7 +993,7 @@ export class OwnerChannel {
|
|
|
977
993
|
harness: this.options.harness,
|
|
978
994
|
version: VERSION,
|
|
979
995
|
snapshot: () => this.options.session.snapshot(),
|
|
980
|
-
interrupt:
|
|
996
|
+
interrupt: () => interruptSession(this.options.session, 'owner'),
|
|
981
997
|
runHarnessCommand: command => this.runHarnessCommand(sender, command, wireId),
|
|
982
998
|
restart: mode => this.restartSelf(sender, mode, wireId),
|
|
983
999
|
comments: () => this.commentsState(),
|
|
@@ -990,7 +1006,51 @@ export class OwnerChannel {
|
|
|
990
1006
|
},
|
|
991
1007
|
fleetList: () => this.fleetOps.list(),
|
|
992
1008
|
closeRoom: roomId => this.closeRoomFromOwner(sender, roomId, wireId),
|
|
1009
|
+
recoverRoom: roomId => this.recoverRoomFromOwner(sender, roomId, wireId),
|
|
993
1010
|
terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
|
|
1011
|
+
recoverTask: taskId => this.recoverTaskFromOwner(sender, taskId, wireId),
|
|
1012
|
+
createTask: input => new TaskRoomApplicationService(this.options.configPath).createTask({
|
|
1013
|
+
...input,
|
|
1014
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
|
|
1015
|
+
}),
|
|
1016
|
+
startTask: taskId => new TaskRoomApplicationService(this.options.configPath).startTask({
|
|
1017
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1018
|
+
}),
|
|
1019
|
+
listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
|
|
1020
|
+
groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
|
|
1021
|
+
listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
|
|
1022
|
+
createTaskList: name => new TaskRoomApplicationService(this.options.configPath).createTaskList({
|
|
1023
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name,
|
|
1024
|
+
}),
|
|
1025
|
+
renameTaskList: (name, newName) => new TaskRoomApplicationService(this.options.configPath).renameTaskList({
|
|
1026
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, newName,
|
|
1027
|
+
}),
|
|
1028
|
+
deleteTaskList: (name, destination) => new TaskRoomApplicationService(this.options.configPath).deleteTaskList({
|
|
1029
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, destination,
|
|
1030
|
+
}),
|
|
1031
|
+
moveTask: (taskId, list) => new TaskRoomApplicationService(this.options.configPath).moveTask({
|
|
1032
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, list,
|
|
1033
|
+
}),
|
|
1034
|
+
getTask: taskId => new TaskRoomApplicationService(this.options.configPath).getTask(taskId),
|
|
1035
|
+
blockTask: (taskId, reason) => new TaskRoomApplicationService(this.options.configPath).blockTask({
|
|
1036
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, reason,
|
|
1037
|
+
}),
|
|
1038
|
+
unblockTask: taskId => new TaskRoomApplicationService(this.options.configPath).unblockTask({
|
|
1039
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1040
|
+
}),
|
|
1041
|
+
reviewTask: taskId => new TaskRoomApplicationService(this.options.configPath).reviewTask({
|
|
1042
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1043
|
+
}),
|
|
1044
|
+
deleteTask: taskId => new TaskRoomApplicationService(this.options.configPath).deleteTask({
|
|
1045
|
+
actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
|
|
1046
|
+
}),
|
|
1047
|
+
listRoomQueries: filter => new TaskRoomApplicationService(this.options.configPath).listRooms(filter),
|
|
1048
|
+
getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
|
|
1049
|
+
listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
|
|
1050
|
+
getTemplateQuery: name => new TaskRoomApplicationService(this.options.configPath).getTemplate(name),
|
|
1051
|
+
createRoom: input => new TaskRoomApplicationService(this.options.configPath).createRoom({
|
|
1052
|
+
...input, actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
|
|
1053
|
+
}),
|
|
994
1054
|
recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
|
|
995
1055
|
readWorklogTail: maxChars => this.readWorklogTail(maxChars),
|
|
996
1056
|
reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
|
|
@@ -1009,7 +1069,7 @@ export class OwnerChannel {
|
|
|
1009
1069
|
/** Queue raw slash text to the harness and report the turn's outcome. */
|
|
1010
1070
|
async runHarnessCommand(sender, command, wireId) {
|
|
1011
1071
|
const requestId = this.requestId(wireId);
|
|
1012
|
-
const queued = await this.options.session
|
|
1072
|
+
const queued = await queueSessionPrompt(this.options.session, command, {
|
|
1013
1073
|
origin: { kind: 'owner', requestId },
|
|
1014
1074
|
});
|
|
1015
1075
|
this.inFlight.add(wireId);
|
|
@@ -1036,6 +1096,7 @@ export class OwnerChannel {
|
|
|
1036
1096
|
*/
|
|
1037
1097
|
async restartSelf(sender, mode, wireId) {
|
|
1038
1098
|
const command = mode === 'fresh' ? '/force-restart' : '/restart';
|
|
1099
|
+
await this.prepareRestart(this.options.role, mode);
|
|
1039
1100
|
await this.send(sender.id, ownerNotices.restarting(this.options.role, command, mode), wireId);
|
|
1040
1101
|
this.state.remember(wireId);
|
|
1041
1102
|
this.options.log(`[${this.options.role}] owner requested ${command}`);
|
|
@@ -1046,8 +1107,9 @@ export class OwnerChannel {
|
|
|
1046
1107
|
* before an external worker starts a saga that can retire this process.
|
|
1047
1108
|
*/
|
|
1048
1109
|
async closeRoomFromOwner(sender, roomId, wireId) {
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1110
|
+
const app = new TaskRoomApplicationService(this.options.configPath);
|
|
1111
|
+
const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
|
|
1112
|
+
await app.requestRoomDeletion({ actor, roomId });
|
|
1051
1113
|
await this.send(sender.id, renderMarkdownFailure({
|
|
1052
1114
|
kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
|
|
1053
1115
|
detail: 'The deletion request was accepted and is still being settled.',
|
|
@@ -1058,24 +1120,97 @@ export class OwnerChannel {
|
|
|
1058
1120
|
await this.fleetOps.closeRoom(roomId);
|
|
1059
1121
|
}
|
|
1060
1122
|
catch (error) {
|
|
1061
|
-
await
|
|
1123
|
+
await app.recordRoomSettlementError({ actor, roomId,
|
|
1124
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1125
|
+
recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
|
|
1062
1126
|
throw error;
|
|
1063
1127
|
}
|
|
1064
1128
|
}
|
|
1129
|
+
async recoverRoomFromOwner(sender, roomId, wireId) {
|
|
1130
|
+
const app = new TaskRoomApplicationService(this.options.configPath);
|
|
1131
|
+
const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
|
|
1132
|
+
const result = await app.recoverRoom({ actor, roomId });
|
|
1133
|
+
if (result.kind === 'deletion_worker_required') {
|
|
1134
|
+
await this.send(sender.id, renderMarkdownFailure({ kind: 'pending',
|
|
1135
|
+
subject: `/room recover ${roomId}`,
|
|
1136
|
+
detail: 'The deletion recovery is still being settled.',
|
|
1137
|
+
action: `Run /room recover ${roomId} if deletion remains pending.` }), wireId);
|
|
1138
|
+
this.state.remember(wireId);
|
|
1139
|
+
try {
|
|
1140
|
+
await this.fleetOps.closeRoom(roomId);
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
await app.recordRoomSettlementError({ actor, roomId,
|
|
1144
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1145
|
+
recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const r = result.orchestration;
|
|
1151
|
+
await this.send(sender.id, renderMarkdownResult({ icon: '🛟', title: 'Room recovery',
|
|
1152
|
+
fields: [{ label: 'Room', value: result.room.room_id, kind: 'code' },
|
|
1153
|
+
{ label: 'Status', value: roomStatus(result.room.state), kind: 'markdown' },
|
|
1154
|
+
...(r ? [{ label: 'Saga', value: r.saga.phase, kind: 'code' }] : [])],
|
|
1155
|
+
sections: result.issues.length ? [{ heading: 'Next steps', items: result.issues }]
|
|
1156
|
+
: [{ heading: 'Result', items: ['No recovery action is needed.'] }] }), wireId);
|
|
1157
|
+
this.state.remember(wireId);
|
|
1158
|
+
}
|
|
1065
1159
|
/** Carry a task terminal request through a worker that survives this role. */
|
|
1160
|
+
async recoverTaskFromOwner(sender, taskId, wireId) {
|
|
1161
|
+
const app = new TaskRoomApplicationService(this.options.configPath);
|
|
1162
|
+
const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
|
|
1163
|
+
const begin = await app.beginTaskRecovery({ actor, taskId });
|
|
1164
|
+
if (begin.kind === 'terminal_worker_required') {
|
|
1165
|
+
await this.send(sender.id, renderMarkdownFailure({
|
|
1166
|
+
kind: 'pending', subject: `/task recover ${taskId}`,
|
|
1167
|
+
detail: 'The recovery request was accepted and is still being settled.',
|
|
1168
|
+
action: `Run /task recover ${taskId} if it remains pending.`,
|
|
1169
|
+
}), wireId);
|
|
1170
|
+
this.state.remember(wireId);
|
|
1171
|
+
try {
|
|
1172
|
+
await this.fleetOps.recoverTask(taskId);
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
await app.recordSettlementError({ actor, taskId,
|
|
1176
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1177
|
+
recoveryHint: `External settle worker failed to start. Retry /task recover ${taskId}.` });
|
|
1178
|
+
throw error;
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
const { task, room, issues } = begin.result;
|
|
1183
|
+
const hints = issues.map(issue => issue.code === 'waiting_cowork' ? 'Cowork socket unreachable'
|
|
1184
|
+
: issue.code === 'waiting_owner_invite' ? 'Owner invite missing or invalid'
|
|
1185
|
+
: issue.code === 'owner_cid_mismatch' ? 'Owner CID mismatch'
|
|
1186
|
+
: issue.code === 'member_failed' ? `Member failed at step ${issue.stepIndex}`
|
|
1187
|
+
: issue.code === 'resume_failed' ? `Resume failed: ${issue.error}`
|
|
1188
|
+
: issue.code === 'provisioning_resumed' ? 'Provisioning resumed successfully'
|
|
1189
|
+
: issue.code);
|
|
1190
|
+
await this.send(sender.id, renderMarkdownResult({
|
|
1191
|
+
icon: '🛟', title: 'Task recovery',
|
|
1192
|
+
fields: [{ label: 'Task', value: task.task_id, kind: 'code' },
|
|
1193
|
+
{ label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
|
|
1194
|
+
...(room ? [{ label: 'Room', value: room.room_id, kind: 'code' },
|
|
1195
|
+
{ label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
|
|
1196
|
+
{ label: 'Saga', value: room.saga.phase, kind: 'code' }] : [])],
|
|
1197
|
+
sections: hints.length ? [{ heading: 'Next steps', items: hints }]
|
|
1198
|
+
: [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
|
|
1199
|
+
}), wireId);
|
|
1200
|
+
this.state.remember(wireId);
|
|
1201
|
+
}
|
|
1066
1202
|
async terminalTaskFromOwner(sender, taskId, kind, outcome, wireId) {
|
|
1067
|
-
const
|
|
1068
|
-
const {
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
if (accepted.terminal_intent?.status === 'settled') {
|
|
1203
|
+
const app = new TaskRoomApplicationService(this.options.configPath);
|
|
1204
|
+
const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
|
|
1205
|
+
const plan = kind === 'done'
|
|
1206
|
+
? await app.completeTask({ actor, taskId, outcome })
|
|
1207
|
+
: await app.cancelTask({ actor, taskId });
|
|
1208
|
+
if (!plan.settlementRequired) {
|
|
1074
1209
|
await this.send(sender.id, renderMarkdownResult({
|
|
1075
1210
|
icon: '📋', title: 'Task terminal action complete',
|
|
1076
1211
|
fields: [
|
|
1077
1212
|
{ label: 'ID', value: taskId, kind: 'code' },
|
|
1078
|
-
{ label: 'Status', value: taskStatus(
|
|
1213
|
+
{ label: 'Status', value: taskStatus(plan.task.state), kind: 'markdown' },
|
|
1079
1214
|
],
|
|
1080
1215
|
}), wireId);
|
|
1081
1216
|
this.state.remember(wireId);
|
|
@@ -1091,7 +1226,10 @@ export class OwnerChannel {
|
|
|
1091
1226
|
await this.fleetOps.settleTask(taskId);
|
|
1092
1227
|
}
|
|
1093
1228
|
catch (error) {
|
|
1094
|
-
await
|
|
1229
|
+
await app.recordSettlementError({
|
|
1230
|
+
actor, taskId, error: error instanceof Error ? error.message : String(error),
|
|
1231
|
+
recoveryHint: `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`,
|
|
1232
|
+
});
|
|
1095
1233
|
throw error;
|
|
1096
1234
|
}
|
|
1097
1235
|
}
|
|
@@ -1224,7 +1362,7 @@ export class OwnerChannel {
|
|
|
1224
1362
|
}
|
|
1225
1363
|
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
1226
1364
|
retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
|
|
1227
|
-
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig
|
|
1365
|
+
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
|
|
1228
1366
|
const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
|
|
1229
1367
|
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
1230
1368
|
try {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
|
|
2
|
-
import type { TaskOutcome, TaskTerminalIntent } from '../rooms-tasks/types.js';
|
|
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';
|
|
5
|
+
import type { TaskState } from '../rooms-tasks/types.js';
|
|
4
6
|
/**
|
|
5
7
|
* Fleet-level effects a deterministic owner command may trigger. Production
|
|
6
8
|
* uses the detached CLI (`fleetCliOps`); tests inject fakes so no command can
|
|
@@ -15,6 +17,7 @@ export interface OwnerFleetOps {
|
|
|
15
17
|
closeRoom(roomId: string): Promise<void>;
|
|
16
18
|
/** Resume a task terminal intent outside the caller role's supervisor lifecycle. */
|
|
17
19
|
settleTask(taskId: string): Promise<void>;
|
|
20
|
+
recoverTask(taskId: string): Promise<void>;
|
|
18
21
|
}
|
|
19
22
|
/**
|
|
20
23
|
* The narrow capability surface a command executor sees. Everything here is
|
|
@@ -47,8 +50,47 @@ export interface OwnerCommandContext {
|
|
|
47
50
|
fleetList(): Promise<string>;
|
|
48
51
|
/** Persist acceptance, acknowledge it, then launch the external close worker. */
|
|
49
52
|
closeRoom(roomId: string): Promise<void>;
|
|
53
|
+
recoverRoom(roomId: string): Promise<void>;
|
|
50
54
|
/** Persist terminal intent, acknowledge it, then launch the external settle worker. */
|
|
51
55
|
terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
|
|
56
|
+
recoverTask(taskId: string): Promise<void>;
|
|
57
|
+
createTask(input: Omit<CreateTaskRequest, 'actor'>): Promise<TaskRecord>;
|
|
58
|
+
startTask(taskId: string): Promise<TaskRecord>;
|
|
59
|
+
listTasks(filter?: {
|
|
60
|
+
state?: TaskState | TaskState[];
|
|
61
|
+
list?: string;
|
|
62
|
+
}): TaskRecord[];
|
|
63
|
+
groupedTasks(filter?: {
|
|
64
|
+
state?: TaskState | TaskState[];
|
|
65
|
+
list?: string;
|
|
66
|
+
}): Array<{
|
|
67
|
+
list: TaskListRecord;
|
|
68
|
+
tasks: TaskRecord[];
|
|
69
|
+
}>;
|
|
70
|
+
listTaskLists(): TaskListRecord[];
|
|
71
|
+
createTaskList(name: string): Promise<TaskListRecord>;
|
|
72
|
+
renameTaskList(name: string, newName: string): Promise<TaskListRecord>;
|
|
73
|
+
deleteTaskList(name: string, destination?: string): Promise<{
|
|
74
|
+
deleted: TaskListRecord;
|
|
75
|
+
moved: number;
|
|
76
|
+
destination?: TaskListRecord;
|
|
77
|
+
}>;
|
|
78
|
+
moveTask(taskId: string, list: string): Promise<TaskRecord>;
|
|
79
|
+
getTask(taskId: string): {
|
|
80
|
+
task: TaskRecord;
|
|
81
|
+
orchestration: RoomOrchestrationRecord | undefined;
|
|
82
|
+
};
|
|
83
|
+
blockTask(taskId: string, reason: string): TaskRecord;
|
|
84
|
+
unblockTask(taskId: string): TaskRecord;
|
|
85
|
+
reviewTask(taskId: string): TaskRecord;
|
|
86
|
+
deleteTask(taskId: string): boolean;
|
|
87
|
+
listRoomQueries(filter?: {
|
|
88
|
+
state?: 'active' | 'provisioning';
|
|
89
|
+
}): ReturnType<TaskRoomApplicationService['listRooms']>;
|
|
90
|
+
getRoomQuery(id: string): ReturnType<TaskRoomApplicationService['getRoomDetail']>;
|
|
91
|
+
listTemplateQueries(): ReturnType<TaskRoomApplicationService['listTemplates']>;
|
|
92
|
+
getTemplateQuery(name: string): ReturnType<TaskRoomApplicationService['getTemplate']>;
|
|
93
|
+
createRoom(input: Omit<CreateRoomRequest, 'actor'>): Promise<RoomOrchestrationRecord>;
|
|
52
94
|
recentEvents(limit: number): SessionEvent[];
|
|
53
95
|
readWorklogTail(maxChars: number): Promise<string | undefined>;
|
|
54
96
|
reply(text: string): Promise<void>;
|