@ours.network/fleet 2.1.1 → 2.2.0
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 +41 -2
- package/dist/application/conversation-history.d.ts +8 -0
- package/dist/application/conversation-history.js +37 -2
- package/dist/application/task-room-service.d.ts +7 -0
- package/dist/application/task-room-service.js +19 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1 -0
- package/dist/doctor.js +17 -4
- package/dist/notifications/fleet-producer.d.ts +0 -1
- package/dist/notifications/fleet-producer.js +65 -47
- package/dist/notifications/mission-ownership.d.ts +31 -0
- package/dist/notifications/mission-ownership.js +148 -0
- package/dist/notifications/outbox.d.ts +10 -0
- package/dist/notifications/outbox.js +28 -2
- package/dist/notifications/task-producer.d.ts +11 -1
- package/dist/notifications/task-producer.js +34 -5
- package/dist/rooms-tasks/layout-service.d.ts +2 -0
- package/dist/rooms-tasks/layout-service.js +7 -2
- package/dist/rooms-tasks/layout.d.ts +5 -1
- package/dist/rooms-tasks/layout.js +7 -1
- package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
- package/dist/rooms-tasks/notification-manifest.js +65 -0
- package/dist/rooms-tasks/task-review.d.ts +2 -0
- package/dist/rooms-tasks/task-review.js +7 -5
- package/dist/rooms-tasks/task-state.d.ts +35 -0
- package/dist/rooms-tasks/task-state.js +151 -17
- package/dist/rooms-tasks/types.d.ts +10 -0
- package/dist/runtime-security.d.ts +1 -0
- package/dist/runtime-security.js +6 -0
- package/dist/security-versions.d.ts +20 -0
- package/dist/security-versions.js +70 -0
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +58 -1
- package/dist/session/codex-app-server.js +22 -1
- package/dist/session/conversation-types.d.ts +8 -2
- package/dist/session/session-ownership.d.ts +18 -0
- package/dist/session/session-ownership.js +74 -0
- package/dist/setup-tunnel-preflight.js +10 -3
- package/dist/supervisor/manager.js +9 -2
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +4 -2
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +29 -8
- package/dist/web/workspace-tunnel.d.ts +2 -1
- package/dist/web/workspace-tunnel.js +29 -3
- package/package.json +6 -6
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
import { canonicalCid, pendingMissionCleanups, settleMissionCleanup, unavailableMissionCleanups, validRoomCid } from '../rooms-tasks/notification-manifest.js';
|
|
6
|
+
import { advanceNotificationRevision, notificationRoomCids, recordNotificationRooms } from '../rooms-tasks/task-state.js';
|
|
7
|
+
import { producerConfig } from './outbox.js';
|
|
8
|
+
/** The service predates mission ownership: it has no such route. Work is retained, never reported as done. */
|
|
9
|
+
export class MissionOwnershipUnsupported extends Error {
|
|
10
|
+
}
|
|
11
|
+
export class MissionOwnershipUnavailable extends Error {
|
|
12
|
+
}
|
|
13
|
+
export async function missionRequest(config, path, body, request = fetch) {
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = await request(config.origin + path, {
|
|
17
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
18
|
+
headers: { ...(config.gatewayCredential
|
|
19
|
+
? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token }
|
|
20
|
+
: { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new MissionOwnershipUnavailable('notification service unreachable');
|
|
26
|
+
}
|
|
27
|
+
const data = await response.json().catch(() => undefined);
|
|
28
|
+
if (response.status === 404)
|
|
29
|
+
throw new MissionOwnershipUnsupported('notification service does not support mission ownership');
|
|
30
|
+
return { status: response.status, data };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Register the task's current manifest. Only a 2xx response whose `contactIds` include every requested room proves
|
|
34
|
+
* coverage. A 409 (mission deleted, stale or conflicting revision) proves nothing about these rooms: the local revision
|
|
35
|
+
* is moved past the refused one, so a later retry registers the full union under a newer revision, and this attempt fails.
|
|
36
|
+
*/
|
|
37
|
+
async function registerMission(config, taskId, manifest, request) {
|
|
38
|
+
const { status, data } = await missionRequest(config, '/api/v1/missions/associate', { taskId, revision: manifest.revision, contactIds: manifest.room_cids }, request);
|
|
39
|
+
if (status >= 200 && status < 300) {
|
|
40
|
+
const registered = Array.isArray(data?.contactIds) ? new Set(data.contactIds.filter(validRoomCid).map(canonicalCid)) : undefined;
|
|
41
|
+
if (registered && manifest.room_cids.every(cid => registered.has(cid)))
|
|
42
|
+
return;
|
|
43
|
+
throw new MissionOwnershipUnavailable('notification service did not confirm every mission room');
|
|
44
|
+
}
|
|
45
|
+
if (status === 409)
|
|
46
|
+
advanceNotificationRevision(taskId, manifest.revision);
|
|
47
|
+
throw new MissionOwnershipUnavailable(`notification service refused room registration (HTTP ${status})`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Register a room as this task's before the Owner can join it: with own-room messages off, the service must know
|
|
51
|
+
* the room before the room can message the Owner. Unreachable service or unconfirmed coverage → throws, so room
|
|
52
|
+
* activation waits and retries. No producer configured → nothing to gate. An older service without the route →
|
|
53
|
+
* admitted with a warning (it cannot suppress own-room messages either).
|
|
54
|
+
*/
|
|
55
|
+
export async function associateRoomNotifications(taskId, roomCid, options = {}) {
|
|
56
|
+
const manifest = recordNotificationRooms(taskId, [roomCid]);
|
|
57
|
+
const config = 'config' in options ? options.config : producerConfig();
|
|
58
|
+
if (!config)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
await registerMission(config, taskId, manifest, options.request);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof MissionOwnershipUnsupported) {
|
|
65
|
+
options.warn?.('Notification service does not support mission rooms; own-room message preferences are unavailable');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (error instanceof MissionOwnershipUnavailable)
|
|
69
|
+
throw new Error('Waiting for notification service to register the mission room');
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Retire every queued deleted task's notifications; an intent is removed only after the service acknowledged it. */
|
|
74
|
+
export async function drainMissionCleanup(config, warn, request = fetch) {
|
|
75
|
+
let pending = false;
|
|
76
|
+
for (const intent of pendingMissionCleanups().slice(0, 32)) {
|
|
77
|
+
try {
|
|
78
|
+
const { status } = await missionRequest(config, '/api/v1/missions/delete', { taskId: intent.taskId, contactIds: intent.contactIds }, request);
|
|
79
|
+
if (status >= 200 && status < 300) {
|
|
80
|
+
if (!settleMissionCleanup(intent))
|
|
81
|
+
pending = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
warn(`Mission notification cleanup pending (HTTP ${status}); intent retained`);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
warn(error instanceof MissionOwnershipUnsupported
|
|
88
|
+
? 'Notification service does not support mission cleanup; cleanup pending'
|
|
89
|
+
: 'Mission notification cleanup pending; intent retained');
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// An unreadable intent stays pending (and its file retained): overall cleanup is never reported done around it.
|
|
94
|
+
const unavailable = unavailableMissionCleanups();
|
|
95
|
+
if (unavailable.length)
|
|
96
|
+
warn(`Mission notification cleanup unavailable for ${unavailable.length} unreadable intent(s); files retained`);
|
|
97
|
+
return pending || unavailable.length > 0 || pendingMissionCleanups().length > 32;
|
|
98
|
+
}
|
|
99
|
+
const bootstrapFile = () => join(stateRoot(), 'notifications', 'mission-bootstrap.json');
|
|
100
|
+
/** Only a missing file starts at 0. Unreadable, corrupt or out-of-range state is refused and its bytes are kept. */
|
|
101
|
+
function nextGeneration() {
|
|
102
|
+
let text;
|
|
103
|
+
try {
|
|
104
|
+
text = readFileSync(bootstrapFile(), 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT')
|
|
108
|
+
throw new MissionOwnershipUnavailable('mission bootstrap state unreadable');
|
|
109
|
+
}
|
|
110
|
+
let generation = 0;
|
|
111
|
+
if (text !== undefined) {
|
|
112
|
+
let value;
|
|
113
|
+
try {
|
|
114
|
+
value = JSON.parse(text);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new MissionOwnershipUnavailable('mission bootstrap state corrupt; file retained');
|
|
118
|
+
}
|
|
119
|
+
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value.generation : undefined;
|
|
120
|
+
if (typeof stored !== 'number' || !Number.isSafeInteger(stored) || stored < 0)
|
|
121
|
+
throw new MissionOwnershipUnavailable('mission bootstrap state invalid; file retained');
|
|
122
|
+
generation = stored;
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isSafeInteger(generation + 1))
|
|
125
|
+
throw new MissionOwnershipUnavailable('mission bootstrap generation exhausted');
|
|
126
|
+
mkdirSync(dirname(bootstrapFile()), { recursive: true, mode: 0o700 });
|
|
127
|
+
replaceFileAtomically(bootstrapFile(), JSON.stringify({ generation: generation + 1 }) + '\n', 0o600);
|
|
128
|
+
return generation + 1;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Register every existing task's rooms under one numbered run, then mark the run complete. Until a run completes
|
|
132
|
+
* the service refuses own-room OFF, so no room that existed before this release can escape the preference.
|
|
133
|
+
* The inventory is read in full before the run starts: an unreadable task or layout throws, and the run never
|
|
134
|
+
* completes on partial evidence. Every task must be confirmed by the service before completion.
|
|
135
|
+
*/
|
|
136
|
+
export async function bootstrapMissionRooms(config, tasks, layoutRooms, request = fetch) {
|
|
137
|
+
const inventory = tasks().filter(task => task.deletion?.status !== 'pending')
|
|
138
|
+
.map(task => ({ task, cids: [...notificationRoomCids(task), ...layoutRooms(task)] })).filter(entry => entry.cids.length);
|
|
139
|
+
const generation = nextGeneration();
|
|
140
|
+
const begin = await missionRequest(config, '/api/v1/missions/bootstrap', { generation, complete: false }, request);
|
|
141
|
+
if (begin.status < 200 || begin.status >= 300)
|
|
142
|
+
throw new MissionOwnershipUnavailable(`bootstrap refused (HTTP ${begin.status})`);
|
|
143
|
+
for (const { task, cids } of inventory)
|
|
144
|
+
await registerMission(config, task.task_id, recordNotificationRooms(task.task_id, cids), request);
|
|
145
|
+
const done = await missionRequest(config, '/api/v1/missions/bootstrap', { generation, complete: true }, request);
|
|
146
|
+
if (done.status < 200 || done.status >= 300)
|
|
147
|
+
throw new MissionOwnershipUnavailable(`bootstrap completion refused (HTTP ${done.status})`);
|
|
148
|
+
}
|
|
@@ -44,12 +44,22 @@ export declare class NotificationOutbox {
|
|
|
44
44
|
checkpoint?: unknown;
|
|
45
45
|
private running?;
|
|
46
46
|
private retired;
|
|
47
|
+
private again;
|
|
48
|
+
private failed;
|
|
49
|
+
private closed;
|
|
47
50
|
private timer;
|
|
48
51
|
constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
|
|
49
52
|
private persist;
|
|
50
53
|
advance(cursor: number): void;
|
|
51
54
|
saveCheckpoint(checkpoint: unknown): void;
|
|
52
55
|
enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Deliver soon after a durable enqueue instead of at the next recovery tick. A request
|
|
58
|
+
* during a pass (which iterates its own snapshot) runs one follow-up pass after it;
|
|
59
|
+
* after a failed pass only the recovery timer retries, so arrivals during an outage
|
|
60
|
+
* add no attempts.
|
|
61
|
+
*/
|
|
62
|
+
prompt(): void;
|
|
53
63
|
drain(): Promise<void>;
|
|
54
64
|
private drainOnce;
|
|
55
65
|
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
@@ -99,6 +99,9 @@ export class NotificationOutbox {
|
|
|
99
99
|
checkpoint;
|
|
100
100
|
running;
|
|
101
101
|
retired = false;
|
|
102
|
+
again = false;
|
|
103
|
+
failed = false;
|
|
104
|
+
closed = false;
|
|
102
105
|
timer;
|
|
103
106
|
constructor(file, config, project, warn = () => { }) {
|
|
104
107
|
this.file = file;
|
|
@@ -152,12 +155,34 @@ export class NotificationOutbox {
|
|
|
152
155
|
this.persist([...this.entries, { id, value, targetVersion: 1 }], cursor);
|
|
153
156
|
return true;
|
|
154
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Deliver soon after a durable enqueue instead of at the next recovery tick. A request
|
|
160
|
+
* during a pass (which iterates its own snapshot) runs one follow-up pass after it;
|
|
161
|
+
* after a failed pass only the recovery timer retries, so arrivals during an outage
|
|
162
|
+
* add no attempts.
|
|
163
|
+
*/
|
|
164
|
+
prompt() {
|
|
165
|
+
if (this.closed || this.retired || this.failed)
|
|
166
|
+
return;
|
|
167
|
+
if (this.running) {
|
|
168
|
+
this.again = true;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
void this.drain();
|
|
172
|
+
}
|
|
155
173
|
drain() {
|
|
156
174
|
if (this.retired)
|
|
157
175
|
return Promise.resolve();
|
|
158
|
-
return this.running ??= this.drainOnce().finally(() => {
|
|
176
|
+
return this.running ??= this.drainOnce().finally(() => {
|
|
177
|
+
this.running = undefined;
|
|
178
|
+
if (this.again) {
|
|
179
|
+
this.again = false;
|
|
180
|
+
this.prompt();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
159
183
|
}
|
|
160
184
|
async drainOnce() {
|
|
185
|
+
this.failed = false;
|
|
161
186
|
let attempts = 0;
|
|
162
187
|
for (const entry of [...this.entries]) {
|
|
163
188
|
if (!supported(entry))
|
|
@@ -188,6 +213,7 @@ export class NotificationOutbox {
|
|
|
188
213
|
this.persist(this.entries.filter(e => !same(entry.id)(e)));
|
|
189
214
|
}
|
|
190
215
|
catch {
|
|
216
|
+
this.failed = true;
|
|
191
217
|
this.warn('Notification producer delivery pending; retrying from durable outbox');
|
|
192
218
|
break;
|
|
193
219
|
}
|
|
@@ -195,5 +221,5 @@ export class NotificationOutbox {
|
|
|
195
221
|
}
|
|
196
222
|
/** Wait for the current send before clearing queued sends and retiring its target. */
|
|
197
223
|
async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist(this.entries.filter(entry => !supported(entry))); }
|
|
198
|
-
async close() { clearInterval(this.timer); await this.running; }
|
|
224
|
+
async close() { this.closed = true; clearInterval(this.timer); await this.running; }
|
|
199
225
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TaskRecord } from '../rooms-tasks/types.js';
|
|
1
2
|
import type { ProducerConfig } from './outbox.js';
|
|
2
3
|
/** The Fleet manager owns this sender even when the web console is stopped.
|
|
3
4
|
* Events are already durable in their owning task commit, so no second outbox
|
|
@@ -6,13 +7,22 @@ export declare class TaskNotificationProducer {
|
|
|
6
7
|
private readonly config;
|
|
7
8
|
private readonly warn;
|
|
8
9
|
private readonly request;
|
|
10
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
11
|
+
private readonly missions?;
|
|
9
12
|
private timer?;
|
|
10
13
|
private dirty;
|
|
11
14
|
private running?;
|
|
12
15
|
private offset;
|
|
13
16
|
private stopped;
|
|
14
17
|
private readonly shutdown;
|
|
15
|
-
|
|
18
|
+
private bootstrapped;
|
|
19
|
+
private bootstrapAt;
|
|
20
|
+
constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch,
|
|
21
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
22
|
+
missions?: {
|
|
23
|
+
tasks: () => TaskRecord[];
|
|
24
|
+
layoutRooms: (task: TaskRecord) => string[];
|
|
25
|
+
} | undefined);
|
|
16
26
|
drain(): Promise<void>;
|
|
17
27
|
private retry;
|
|
18
28
|
private drainOnce;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { observeFleetResources } from '../application/resource-observer.js';
|
|
2
2
|
import { tasksDir } from '../rooms-tasks/task-state.js';
|
|
3
|
-
import { acknowledgeTaskNotification, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
|
|
3
|
+
import { acknowledgeTaskNotification, convertRetiredTaskNotifications, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
|
|
4
|
+
import { bootstrapMissionRooms, drainMissionCleanup } from './mission-ownership.js';
|
|
4
5
|
/** The Fleet manager owns this sender even when the web console is stopped.
|
|
5
6
|
* Events are already durable in their owning task commit, so no second outbox
|
|
6
7
|
* admission/crash window is needed. A failed task does not block other tasks. */
|
|
@@ -8,16 +9,22 @@ export class TaskNotificationProducer {
|
|
|
8
9
|
config;
|
|
9
10
|
warn;
|
|
10
11
|
request;
|
|
12
|
+
missions;
|
|
11
13
|
timer;
|
|
12
14
|
dirty = false;
|
|
13
15
|
running;
|
|
14
16
|
offset = 0;
|
|
15
17
|
stopped = false;
|
|
16
18
|
shutdown = new AbortController();
|
|
17
|
-
|
|
19
|
+
bootstrapped = false;
|
|
20
|
+
bootstrapAt = 0;
|
|
21
|
+
constructor(config, warn = () => { }, request = fetch,
|
|
22
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
23
|
+
missions) {
|
|
18
24
|
this.config = config;
|
|
19
25
|
this.warn = warn;
|
|
20
26
|
this.request = request;
|
|
27
|
+
this.missions = missions;
|
|
21
28
|
// Subscribe before startup replay: a commit while fetch is pending needs a second pass.
|
|
22
29
|
observeFleetResources({ retryWatchFailures: true, paths: [tasksDir()], roots: [] }, state => {
|
|
23
30
|
if (state.health !== 'healthy')
|
|
@@ -49,15 +56,35 @@ export class TaskNotificationProducer {
|
|
|
49
56
|
});
|
|
50
57
|
return this.running;
|
|
51
58
|
}
|
|
52
|
-
retry() {
|
|
59
|
+
retry(delay = 3000) {
|
|
53
60
|
if (this.stopped || this.timer)
|
|
54
61
|
return;
|
|
55
|
-
this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); },
|
|
62
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); }, delay);
|
|
56
63
|
this.timer.unref();
|
|
57
64
|
}
|
|
58
65
|
async drainOnce() {
|
|
59
66
|
if (this.stopped)
|
|
60
67
|
return;
|
|
68
|
+
// Deletion first: a retired task's notifications go before any other delivery work in this pass.
|
|
69
|
+
if (convertRetiredTaskNotifications() || await drainMissionCleanup(this.config, this.warn, this.request))
|
|
70
|
+
this.retry();
|
|
71
|
+
if (this.missions && !this.bootstrapped && !this.stopped) {
|
|
72
|
+
// A failed run waits 30s: unavailable inventory or a refused registration needs repair, not a tight loop.
|
|
73
|
+
const wait = this.bootstrapAt - Date.now();
|
|
74
|
+
if (wait > 0)
|
|
75
|
+
this.retry(wait);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
await bootstrapMissionRooms(this.config, this.missions.tasks, this.missions.layoutRooms, this.request);
|
|
79
|
+
this.bootstrapped = true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
this.warn('Mission room registration pending; own-room message preference stays unavailable');
|
|
83
|
+
this.bootstrapAt = Date.now() + 30_000;
|
|
84
|
+
this.retry(30_000);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
61
88
|
const pending = pendingTaskNotifications(this.warn);
|
|
62
89
|
if (!pending.length)
|
|
63
90
|
return;
|
|
@@ -83,7 +110,9 @@ export class TaskNotificationProducer {
|
|
|
83
110
|
});
|
|
84
111
|
await response.body?.cancel();
|
|
85
112
|
if (!response.ok) {
|
|
86
|
-
this.warn(
|
|
113
|
+
this.warn(response.status === 400 && event.delivery
|
|
114
|
+
? 'Notification service does not support push-only task events; committed events retained'
|
|
115
|
+
: `Task notification delivery pending (HTTP ${response.status}); committed event retained`);
|
|
87
116
|
break;
|
|
88
117
|
}
|
|
89
118
|
acknowledgeTaskNotification(task.taskId, event.eventId);
|
|
@@ -30,6 +30,8 @@ export declare class RoomLayoutService {
|
|
|
30
30
|
supervisor(id?: string, templates?: Record<string, AgentTemplateDefinition>): NativeLayoutSupervisor;
|
|
31
31
|
private owner;
|
|
32
32
|
private engine;
|
|
33
|
+
/** A task's layout rooms are registered with the notification service before the Owner can join them. */
|
|
34
|
+
private beforeOwner;
|
|
33
35
|
open(id: string, cleanup?: boolean): RoomLayout;
|
|
34
36
|
/** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
|
|
35
37
|
create(name: string, bindings?: Record<string, LayoutInstance>, id?: string, scope?: {
|
|
@@ -178,11 +178,16 @@ export class RoomLayoutService {
|
|
|
178
178
|
const path = layoutRunPath(id);
|
|
179
179
|
if (existsSync(path))
|
|
180
180
|
assertLayoutFile(path);
|
|
181
|
-
return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner());
|
|
181
|
+
return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner(), this.beforeOwner(id));
|
|
182
|
+
}
|
|
183
|
+
/** A task's layout rooms are registered with the notification service before the Owner can join them. */
|
|
184
|
+
beforeOwner(id) {
|
|
185
|
+
const taskId = /^task-([0-9a-z]{9}[0-9a-f]{8})$/.exec(id)?.[1];
|
|
186
|
+
return taskId ? async (roomCid) => (await import('../notifications/mission-ownership.js')).associateRoomNotifications(taskId, roomCid) : undefined;
|
|
182
187
|
}
|
|
183
188
|
open(id, cleanup = false) {
|
|
184
189
|
const initial = this.engine(id, cleanup), snapshot = initial.snapshot();
|
|
185
|
-
return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner());
|
|
190
|
+
return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner(), this.beforeOwner(id));
|
|
186
191
|
}
|
|
187
192
|
/** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
|
|
188
193
|
async create(name, bindings = {}, id = `run-${randomUUID()}`, scope = {}) {
|
|
@@ -104,9 +104,13 @@ export declare class RoomLayout {
|
|
|
104
104
|
private file;
|
|
105
105
|
private supervisor;
|
|
106
106
|
private owner?;
|
|
107
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
108
|
+
private beforeOwner?;
|
|
107
109
|
private coworkAdapter?;
|
|
108
110
|
private readonly coworkFactory;
|
|
109
|
-
constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined
|
|
111
|
+
constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined,
|
|
112
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
113
|
+
beforeOwner?: ((roomCid: string) => Promise<void>) | undefined);
|
|
110
114
|
private get cowork();
|
|
111
115
|
snapshot(): RoomLayoutState;
|
|
112
116
|
private save;
|
|
@@ -39,12 +39,16 @@ export class RoomLayout {
|
|
|
39
39
|
file;
|
|
40
40
|
supervisor;
|
|
41
41
|
owner;
|
|
42
|
+
beforeOwner;
|
|
42
43
|
coworkAdapter;
|
|
43
44
|
coworkFactory;
|
|
44
|
-
constructor(file, cowork, supervisor, owner
|
|
45
|
+
constructor(file, cowork, supervisor, owner,
|
|
46
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
47
|
+
beforeOwner) {
|
|
45
48
|
this.file = file;
|
|
46
49
|
this.supervisor = supervisor;
|
|
47
50
|
this.owner = owner;
|
|
51
|
+
this.beforeOwner = beforeOwner;
|
|
48
52
|
this.coworkFactory = typeof cowork === 'function' ? cowork : () => cowork;
|
|
49
53
|
}
|
|
50
54
|
get cowork() { return this.coworkAdapter ??= this.coworkFactory(); }
|
|
@@ -161,6 +165,8 @@ export class RoomLayout {
|
|
|
161
165
|
activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
|
|
162
166
|
: [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
|
|
163
167
|
}), native => { room.native = native; s.order.push(key); });
|
|
168
|
+
if (s.owner && !room.owner_ready && this.beforeOwner)
|
|
169
|
+
await this.beforeOwner(room.native.identity_cid);
|
|
164
170
|
if (s.owner && !room.owner_ready)
|
|
165
171
|
await this.mutation(s, `owner:${key}`, async () => {
|
|
166
172
|
await this.cowork.acceptInvite(room.native.room_id, this.owner.invite, { role: s.owner.role, expected_cid: s.owner.cid });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification ownership of a task's rooms. Room identity CIDs are recorded when Fleet records the room, and are
|
|
3
|
+
* never removed (a room erased early stays attributable), so deleting the task can retire its rooms' notifications
|
|
4
|
+
* even after room records are gone.
|
|
5
|
+
*/
|
|
6
|
+
export interface TaskNotificationManifest {
|
|
7
|
+
revision: number;
|
|
8
|
+
room_cids: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare const canonicalCid: (cid: string) => string;
|
|
11
|
+
export declare function validRoomCid(cid: unknown): cid is string;
|
|
12
|
+
/** Union of rooms; the revision advances only when a room is added. */
|
|
13
|
+
export declare function withManifestRooms(manifest: TaskNotificationManifest | undefined, cids: string[]): TaskNotificationManifest;
|
|
14
|
+
/** Durable intent to retire a deleted task's notifications; written at deletion acceptance, merged, removed on service acknowledgement. */
|
|
15
|
+
export interface MissionCleanupIntent {
|
|
16
|
+
schema: 1;
|
|
17
|
+
taskId: string;
|
|
18
|
+
contactIds: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare const missionCleanupDir: () => string;
|
|
21
|
+
/** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
|
|
22
|
+
* file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
|
|
23
|
+
export declare function queueMissionCleanup(taskId: string, contactIds: string[], dir?: string): void;
|
|
24
|
+
/** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
|
|
25
|
+
export declare function pendingMissionCleanups(dir?: string): MissionCleanupIntent[];
|
|
26
|
+
/** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
|
|
27
|
+
export declare function unavailableMissionCleanups(dir?: string): string[];
|
|
28
|
+
/** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
|
|
29
|
+
export declare function settleMissionCleanup(intent: MissionCleanupIntent, dir?: string): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
const CID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
6
|
+
export const canonicalCid = (cid) => /^[a-f0-9]{64}$/i.test(cid) ? cid.toUpperCase() : cid;
|
|
7
|
+
export function validRoomCid(cid) { return typeof cid === 'string' && CID.test(cid); }
|
|
8
|
+
/** Union of rooms; the revision advances only when a room is added. */
|
|
9
|
+
export function withManifestRooms(manifest, cids) {
|
|
10
|
+
const current = manifest ?? { revision: 0, room_cids: [] };
|
|
11
|
+
const added = cids.filter(validRoomCid).map(canonicalCid).filter(cid => !current.room_cids.includes(cid));
|
|
12
|
+
return added.length ? { revision: current.revision + 1, room_cids: [...current.room_cids, ...new Set(added)] } : current;
|
|
13
|
+
}
|
|
14
|
+
export const missionCleanupDir = () => join(stateRoot(), 'notifications', 'mission-cleanup');
|
|
15
|
+
const TASK_ID = /^[0-9a-z]{9}[0-9a-f]{8}$/;
|
|
16
|
+
/** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
|
|
17
|
+
* file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
|
|
18
|
+
export function queueMissionCleanup(taskId, contactIds, dir = missionCleanupDir()) {
|
|
19
|
+
if (!TASK_ID.test(taskId))
|
|
20
|
+
throw new Error('invalid task ID');
|
|
21
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
const path = join(dir, `${taskId}.json`), previous = readMissionCleanup(path, taskId);
|
|
23
|
+
if (previous === 'unavailable')
|
|
24
|
+
throw new Error(`mission cleanup intent for ${taskId} is unreadable; file retained`);
|
|
25
|
+
const known = previous === 'absent' ? [] : previous.contactIds;
|
|
26
|
+
const merged = [...new Set([...known, ...contactIds.filter(validRoomCid).map(canonicalCid)])];
|
|
27
|
+
if (previous !== 'absent' && merged.length === known.length)
|
|
28
|
+
return;
|
|
29
|
+
replaceFileAtomically(path, JSON.stringify({ schema: 1, taskId, contactIds: merged }) + '\n', 0o600);
|
|
30
|
+
}
|
|
31
|
+
/** Only a missing file is absent. Unreadable, malformed or mismatched content is unavailable, never "no intent". */
|
|
32
|
+
function readMissionCleanup(path, taskId) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(path, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return error.code === 'ENOENT' ? 'absent' : 'unavailable';
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const value = JSON.parse(text);
|
|
42
|
+
if (value?.schema === 1 && value.taskId === taskId && Array.isArray(value.contactIds) && value.contactIds.every(validRoomCid))
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
catch { /* malformed */ }
|
|
46
|
+
return 'unavailable';
|
|
47
|
+
}
|
|
48
|
+
const intentFiles = (dir) => existsSync(dir) ? readdirSync(dir).filter(name => /^[0-9a-z]{9}[0-9a-f]{8}\.json$/.test(name)) : [];
|
|
49
|
+
/** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
|
|
50
|
+
export function pendingMissionCleanups(dir = missionCleanupDir()) {
|
|
51
|
+
return intentFiles(dir).map(name => readMissionCleanup(join(dir, name), name.slice(0, -5)))
|
|
52
|
+
.filter((intent) => typeof intent === 'object');
|
|
53
|
+
}
|
|
54
|
+
/** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
|
|
55
|
+
export function unavailableMissionCleanups(dir = missionCleanupDir()) {
|
|
56
|
+
return intentFiles(dir).filter(name => readMissionCleanup(join(dir, name), name.slice(0, -5)) === 'unavailable').map(name => name.slice(0, -5));
|
|
57
|
+
}
|
|
58
|
+
/** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
|
|
59
|
+
export function settleMissionCleanup(intent, dir = missionCleanupDir()) {
|
|
60
|
+
const path = join(dir, `${intent.taskId}.json`), current = readMissionCleanup(path, intent.taskId);
|
|
61
|
+
if (typeof current !== 'object' || current.contactIds.length !== intent.contactIds.length)
|
|
62
|
+
return false;
|
|
63
|
+
rmSync(path, { force: true });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -103,6 +103,8 @@ export declare function publicReview(review: TaskReview): TaskReview;
|
|
|
103
103
|
/** Compact summary for list and detail payloads; bodies are fetched separately. */
|
|
104
104
|
export declare function taskContributionSummary(task: TaskRecord): {
|
|
105
105
|
progress_count: number;
|
|
106
|
+
progress_unread: number;
|
|
107
|
+
review_unread: boolean;
|
|
106
108
|
review: {
|
|
107
109
|
has_markdown: boolean;
|
|
108
110
|
artifact_count: number;
|
|
@@ -12,7 +12,7 @@ import { open } from 'node:fs/promises';
|
|
|
12
12
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
13
|
import { stateRoot } from '../paths.js';
|
|
14
14
|
import { MAX_FILE_BYTES, openDirectory } from '../file-delivery/reader.js';
|
|
15
|
-
import { getTask, TaskStateError, updateTaskContribution } from './task-state.js';
|
|
15
|
+
import { contributionUnread, getTask, reviewRevision, TaskStateError, updateTaskContribution } from './task-state.js';
|
|
16
16
|
import { assertWorkspacePresent } from './workspace.js';
|
|
17
17
|
export const PROGRESS_TEXT_MAX = 4_000;
|
|
18
18
|
export const PROGRESS_MAX_ENTRIES = 200;
|
|
@@ -109,7 +109,7 @@ export function appendTaskProgress(taskId, input) {
|
|
|
109
109
|
// ── Review Markdown ────────────────────────────────────────────────────
|
|
110
110
|
/** Every change to the package gets the next revision, so readers can tell any two states apart. */
|
|
111
111
|
function nextRevision(review) {
|
|
112
|
-
return (review
|
|
112
|
+
return reviewRevision(review) + 1;
|
|
113
113
|
}
|
|
114
114
|
export function getTaskReview(taskId) {
|
|
115
115
|
assertTaskId(taskId);
|
|
@@ -544,17 +544,19 @@ export function publicProgressEntry(entry) {
|
|
|
544
544
|
return rest;
|
|
545
545
|
}
|
|
546
546
|
export function publicReview(review) {
|
|
547
|
-
return { ...review, revision: review
|
|
547
|
+
return { ...review, revision: reviewRevision(review), artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
|
|
548
548
|
}
|
|
549
549
|
/** Compact summary for list and detail payloads; bodies are fetched separately. */
|
|
550
550
|
export function taskContributionSummary(task) {
|
|
551
|
+
const unread = contributionUnread(task);
|
|
551
552
|
return {
|
|
552
553
|
progress_count: task.progress?.length ?? 0,
|
|
554
|
+
progress_unread: unread.progress,
|
|
555
|
+
review_unread: unread.review,
|
|
553
556
|
review: {
|
|
554
557
|
has_markdown: !!task.review?.markdown.trim(),
|
|
555
558
|
artifact_count: task.review?.artifacts.length ?? 0,
|
|
556
|
-
|
|
557
|
-
revision: task.review ? task.review.revision ?? 1 : 0,
|
|
559
|
+
revision: reviewRevision(task.review),
|
|
558
560
|
...(task.review ? { updated_at: task.review.updated_at } : {}),
|
|
559
561
|
},
|
|
560
562
|
};
|
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import type { ArchivedMemberAbsence } from './types.js';
|
|
2
2
|
import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
|
|
3
|
+
import { type TaskNotificationManifest } from './notification-manifest.js';
|
|
3
4
|
export declare const tasksDir: () => string;
|
|
4
5
|
export declare class TaskStateError extends Error {
|
|
5
6
|
}
|
|
7
|
+
/** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
|
|
8
|
+
export declare const reviewRevision: (review: {
|
|
9
|
+
revision?: number;
|
|
10
|
+
} | undefined) => number;
|
|
11
|
+
/** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
|
|
12
|
+
export declare function contributionUnread(task: Pick<TaskRecord, 'progress' | 'review' | 'contribution_reads'>): {
|
|
13
|
+
progress: number;
|
|
14
|
+
review: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
|
|
17
|
+
export declare function acknowledgeTaskProgress(id: string, ids: string[]): {
|
|
18
|
+
progress: number;
|
|
19
|
+
review: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
|
|
22
|
+
export declare function acknowledgeTaskReview(id: string, revision: number): {
|
|
23
|
+
progress: number;
|
|
24
|
+
review: boolean;
|
|
25
|
+
};
|
|
6
26
|
/** Atomic snapshots contain the events, including intermediate states between polls. */
|
|
27
|
+
/** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
|
|
28
|
+
* same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
|
|
29
|
+
* already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
|
|
30
|
+
* event queued. */
|
|
7
31
|
export declare function pendingTaskNotifications(warn?: (line: string) => void): Array<{
|
|
8
32
|
taskId: string;
|
|
9
33
|
events: TaskNotificationEvent[];
|
|
34
|
+
upgraded: string[];
|
|
10
35
|
}>;
|
|
36
|
+
/** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
|
|
37
|
+
* becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
|
|
38
|
+
export declare function convertRetiredTaskNotifications(): number;
|
|
11
39
|
/** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
|
|
12
40
|
export declare function acknowledgeTaskNotification(id: string, eventId: string): void;
|
|
13
41
|
/**
|
|
@@ -62,6 +90,13 @@ export declare function setTaskTerminalIntentError(id: string, error: string, re
|
|
|
62
90
|
export declare function finishTaskTerminalIntent(id: string): TaskRecord;
|
|
63
91
|
export declare function failTask(id: string, error: string): TaskRecord;
|
|
64
92
|
export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
|
|
93
|
+
/** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
|
|
94
|
+
export declare function recordNotificationRooms(id: string, cids: string[]): TaskNotificationManifest;
|
|
95
|
+
/** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
|
|
96
|
+
* then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
|
|
97
|
+
export declare function advanceNotificationRevision(id: string, refused: number): void;
|
|
98
|
+
/** Every room this task ever recorded, including the current one from records written before manifests existed. */
|
|
99
|
+
export declare function notificationRoomCids(task: Pick<TaskRecord, 'notification_manifest' | 'room_identity_cid'>): string[];
|
|
65
100
|
export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
|
|
66
101
|
export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
|
|
67
102
|
/**
|