@ours.network/fleet 1.2.0-nightly.79 → 1.2.0-nightly.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/resource-observer.d.ts +3 -0
- package/dist/application/resource-observer.js +3 -0
- package/dist/build-info.json +4 -4
- package/dist/notifications/fleet-producer.d.ts +9 -0
- package/dist/notifications/fleet-producer.js +221 -107
- package/dist/web/fleet-config-service.d.ts +3 -0
- package/dist/web/fleet-config-service.js +16 -2
- package/dist/web/resource-views.d.ts +2 -1
- package/dist/web/resource-views.js +31 -7
- package/dist/web/server.js +1 -1
- package/package.json +1 -1
|
@@ -13,6 +13,9 @@ export interface FleetResourceScope {
|
|
|
13
13
|
roots?: string[];
|
|
14
14
|
/** Overrides full Fleet discovery for narrow owners such as task delivery. */
|
|
15
15
|
paths?: string[];
|
|
16
|
+
/** Path routing precedes a coalesced committed callback. Undefined means
|
|
17
|
+
* unknown/recovered coverage and requires conservative discovery. */
|
|
18
|
+
onInvalidated?: (path: string | undefined) => void;
|
|
16
19
|
watch?: (path: string, listener: (event: string, filename: string | null) => void) => Pick<FSWatcher, 'on' | 'close'>;
|
|
17
20
|
}
|
|
18
21
|
/** Only call after durable persistence succeeds. Observers cannot fail a commit. */
|
|
@@ -231,6 +231,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
|
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
233
|
state = { ...state, position: state.position + 1, health: 'healthy' };
|
|
234
|
+
scope.onInvalidated?.(undefined);
|
|
234
235
|
// Reinstallation precedes one checkpoint recovery pass; later work is
|
|
235
236
|
// event driven again. Degraded retries inspect directory metadata only.
|
|
236
237
|
onCommitted({ ...state });
|
|
@@ -240,6 +241,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
|
|
|
240
241
|
const wake = (path) => {
|
|
241
242
|
if (disposed || state.health !== 'healthy')
|
|
242
243
|
return;
|
|
244
|
+
scope.onInvalidated?.(path);
|
|
243
245
|
state = { ...state, position: state.position + 1 };
|
|
244
246
|
for (const cursor of cursors)
|
|
245
247
|
if (path === undefined || cursor.accepts(path))
|
|
@@ -283,6 +285,7 @@ export function observeFleetResources(scope, onCommitted, signal) {
|
|
|
283
285
|
if (!added)
|
|
284
286
|
return;
|
|
285
287
|
state = { ...state, position: state.position + 1 };
|
|
288
|
+
scope.onInvalidated?.(undefined);
|
|
286
289
|
for (const cursor of cursors)
|
|
287
290
|
cursor.position = state.position;
|
|
288
291
|
}
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.80",
|
|
3
|
+
"buildId": "6177cc5cdd30",
|
|
4
|
+
"commit": "8e7ff4b04a6f6bf26e2de2710d18f9b5f0994657",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-19T22:26:47.749Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"service.refresh-v1",
|
|
9
9
|
"managed-cli.codex-reasoning-effort-v1",
|
|
@@ -10,12 +10,21 @@ export declare class FleetNotificationProducer {
|
|
|
10
10
|
private readonly queues;
|
|
11
11
|
private readonly observation;
|
|
12
12
|
private continuation?;
|
|
13
|
+
private continuationAt;
|
|
14
|
+
private readonly dirty;
|
|
15
|
+
private readonly retryAt;
|
|
16
|
+
private discoveryNeeded;
|
|
17
|
+
private taskRoles?;
|
|
13
18
|
private stopped;
|
|
14
19
|
private readonly existing;
|
|
15
20
|
private cleanup?;
|
|
16
21
|
constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
|
|
17
22
|
private names;
|
|
23
|
+
private segments;
|
|
18
24
|
poll(): void;
|
|
25
|
+
private accept;
|
|
26
|
+
private flush;
|
|
27
|
+
private scanRole;
|
|
19
28
|
private resume;
|
|
20
29
|
drain(): Promise<void>;
|
|
21
30
|
close(): Promise<void>;
|
|
@@ -2,9 +2,12 @@ import { observeFleetResources } from '../application/resource-observer.js';
|
|
|
2
2
|
import { roomsDir } from '../rooms-tasks/room-state.js';
|
|
3
3
|
import { cleanupDir, drainNotificationTargetCleanup, notificationAgentId, hasPendingNotificationTargetCleanup } from './target-cleanup.js';
|
|
4
4
|
import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { NotificationOutbox } from './outbox.js';
|
|
8
|
+
const BATCH_BYTES = 256 * 1024;
|
|
9
|
+
const TURN_ROLES = 4, TURN_MS = 8;
|
|
10
|
+
const RUNTIME_FILES = new Set(['.monitor-status', '.monitor-state.json', '.notify-cursor', '.session-events.jsonl', '.session-events.jsonl.1', '.subscription-usage.json']);
|
|
8
11
|
/** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
|
|
9
12
|
function taskRoleAssociations() {
|
|
10
13
|
const dir = roomsDir(), result = new Map();
|
|
@@ -49,23 +52,53 @@ export class FleetNotificationProducer {
|
|
|
49
52
|
queues = new Map();
|
|
50
53
|
observation = new AbortController();
|
|
51
54
|
continuation;
|
|
55
|
+
continuationAt = Infinity;
|
|
56
|
+
dirty = new Set();
|
|
57
|
+
retryAt = new Map();
|
|
58
|
+
discoveryNeeded = true;
|
|
59
|
+
taskRoles;
|
|
52
60
|
stopped = false;
|
|
53
|
-
existing = new
|
|
61
|
+
existing = new Map();
|
|
54
62
|
cleanup;
|
|
55
63
|
constructor(roots, stateDir, config, warn = () => { }) {
|
|
56
64
|
this.roots = roots;
|
|
57
65
|
this.stateDir = stateDir;
|
|
58
66
|
this.config = config;
|
|
59
67
|
this.warn = warn;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
let healthy = true;
|
|
69
|
+
observeFleetResources({ retryWatchFailures: true, roots, paths: [...roots, roomsDir(), cleanupDir()],
|
|
70
|
+
onInvalidated: path => this.accept(path) }, state => {
|
|
71
|
+
if (state.health !== 'healthy') {
|
|
72
|
+
healthy = false;
|
|
64
73
|
this.warn('Notification discovery observation unavailable; checkpoint retained');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!healthy || this.dirty.size || this.discoveryNeeded) {
|
|
77
|
+
// Unknown wakes and watch reinstallation advance coverage without a
|
|
78
|
+
// pathname. Keep one conservative discovery pass for those barriers.
|
|
79
|
+
if (!healthy)
|
|
80
|
+
this.discoveryNeeded = true;
|
|
81
|
+
healthy = true;
|
|
82
|
+
this.resume();
|
|
83
|
+
}
|
|
65
84
|
}, this.observation.signal);
|
|
85
|
+
// Freeze startup history boundaries before deferring any role's content
|
|
86
|
+
// work. Later appends/segments must not become part of the ignored history.
|
|
87
|
+
// This pass reads directory/file metadata only, never ledger contents.
|
|
66
88
|
for (const root of roots)
|
|
67
|
-
for (const name of this.names(root))
|
|
68
|
-
|
|
89
|
+
for (const name of this.names(root)) {
|
|
90
|
+
const roleDir = join(root, name), dir = join(roleDir, '.conversation');
|
|
91
|
+
try {
|
|
92
|
+
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
|
|
93
|
+
continue;
|
|
94
|
+
const segment = this.segments(dir).at(-1);
|
|
95
|
+
if (segment)
|
|
96
|
+
this.existing.set(roleDir, { segment, offset: statSync(join(dir, segment)).size, prompts: [] });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
this.existing.set(roleDir, null);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
69
102
|
this.poll();
|
|
70
103
|
}
|
|
71
104
|
names(root) {
|
|
@@ -76,7 +109,41 @@ export class FleetNotificationProducer {
|
|
|
76
109
|
return [];
|
|
77
110
|
}
|
|
78
111
|
}
|
|
112
|
+
segments(dir) {
|
|
113
|
+
return readdirSync(dir).filter(n => /^events-\d{6}\.jsonl$/.test(n) && !lstatSync(join(dir, n)).isSymbolicLink()).sort();
|
|
114
|
+
}
|
|
79
115
|
poll() {
|
|
116
|
+
if (this.stopped)
|
|
117
|
+
return;
|
|
118
|
+
this.discoveryNeeded = true;
|
|
119
|
+
this.flush();
|
|
120
|
+
}
|
|
121
|
+
accept(path) {
|
|
122
|
+
if (path === undefined) {
|
|
123
|
+
this.discoveryNeeded = true;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
for (const root of this.roots) {
|
|
127
|
+
const local = relative(resolve(root), path), parts = local.split(sep);
|
|
128
|
+
if (local === '..' || local.startsWith('..' + sep))
|
|
129
|
+
continue;
|
|
130
|
+
if (parts.length === 2 && RUNTIME_FILES.has(parts[1]))
|
|
131
|
+
return false;
|
|
132
|
+
if (parts.length >= 2 && parts[1] === '.conversation') {
|
|
133
|
+
if (parts.length <= 3) {
|
|
134
|
+
this.dirty.add(join(root, parts[0]));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Role replacement, unknown paths and metadata retain discovery. Room
|
|
139
|
+
// association and target-cleanup paths take the same conservative route.
|
|
140
|
+
this.discoveryNeeded = true;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
this.discoveryNeeded = true;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
flush() {
|
|
80
147
|
if (this.stopped)
|
|
81
148
|
return;
|
|
82
149
|
this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (target) => {
|
|
@@ -122,114 +189,161 @@ export class FleetNotificationProducer {
|
|
|
122
189
|
}
|
|
123
190
|
catch { }
|
|
124
191
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
192
|
+
if (this.discoveryNeeded) {
|
|
193
|
+
this.discoveryNeeded = false;
|
|
194
|
+
try {
|
|
195
|
+
this.taskRoles = taskRoleAssociations();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
this.taskRoles = undefined;
|
|
199
|
+
this.warn('Notification task association unavailable; delivering unclassified events');
|
|
200
|
+
}
|
|
201
|
+
for (const root of this.roots)
|
|
202
|
+
for (const name of this.names(root))
|
|
203
|
+
this.dirty.add(join(root, name));
|
|
128
204
|
}
|
|
129
|
-
|
|
130
|
-
|
|
205
|
+
// A turn reads at most four 256KiB batches (1MiB). The time bound is checked
|
|
206
|
+
// between roles; a synchronous filesystem operation cannot be preempted.
|
|
207
|
+
const started = performance.now();
|
|
208
|
+
let roles = 0;
|
|
209
|
+
for (const roleDir of this.dirty) {
|
|
210
|
+
if (roles >= TURN_ROLES || (roles && performance.now() - started >= TURN_MS))
|
|
211
|
+
break;
|
|
212
|
+
if ((this.retryAt.get(roleDir) ?? 0) > Date.now())
|
|
213
|
+
continue;
|
|
214
|
+
this.dirty.delete(roleDir);
|
|
215
|
+
this.retryAt.delete(roleDir);
|
|
216
|
+
roles++;
|
|
217
|
+
this.scanRole(roleDir, started + TURN_MS);
|
|
131
218
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
|
|
202
|
-
if (!current.prompts.includes(prompt))
|
|
203
|
-
current.prompts.push(prompt);
|
|
204
|
-
}
|
|
205
|
-
if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
|
|
206
|
-
current.messages[prompt] = event.messageId;
|
|
207
|
-
if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
|
|
208
|
-
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
|
|
209
|
-
this.resume(1000);
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
current.prompts = current.prompts.filter(p => p !== prompt);
|
|
213
|
-
delete current.messages[prompt];
|
|
214
|
-
}
|
|
215
|
-
current.offset += end - position + 1;
|
|
216
|
-
position = end + 1;
|
|
219
|
+
if (this.dirty.size) {
|
|
220
|
+
let next = Infinity;
|
|
221
|
+
for (const roleDir of this.dirty)
|
|
222
|
+
next = Math.min(next, this.retryAt.get(roleDir) ?? Date.now());
|
|
223
|
+
this.resume(Math.max(0, next - Date.now()));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
scanRole(roleDir, deadline) {
|
|
227
|
+
const roleId = basename(roleDir), dir = join(roleDir, '.conversation');
|
|
228
|
+
try {
|
|
229
|
+
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink())
|
|
230
|
+
return;
|
|
231
|
+
const segments = this.segments(dir);
|
|
232
|
+
if (!segments.length)
|
|
233
|
+
return;
|
|
234
|
+
let outbox = this.queues.get(roleDir);
|
|
235
|
+
if (!outbox) {
|
|
236
|
+
const key = createHash('sha256').update(roleDir).digest('hex');
|
|
237
|
+
outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
|
|
238
|
+
...value, target: { kind: 'agent', agentId: roleId } }), this.warn);
|
|
239
|
+
this.queues.set(roleDir, outbox);
|
|
240
|
+
}
|
|
241
|
+
if (!outbox.checkpoint) {
|
|
242
|
+
const initial = this.existing.get(roleDir);
|
|
243
|
+
if (initial === null)
|
|
244
|
+
throw Error('Notification startup ledger boundary unavailable');
|
|
245
|
+
outbox.saveCheckpoint(initial ?? { segment: segments[0], offset: 0, prompts: [] });
|
|
246
|
+
}
|
|
247
|
+
const saved = outbox.checkpoint;
|
|
248
|
+
const current = { ...saved, prompts: [...saved.prompts], messages: { ...saved.messages } };
|
|
249
|
+
let index = segments.indexOf(current.segment);
|
|
250
|
+
if (index < 0) {
|
|
251
|
+
this.warn('Notification ledger segment unavailable; checkpoint retained');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const file = join(dir, current.segment), size = statSync(file).size;
|
|
255
|
+
if (size < current.offset) {
|
|
256
|
+
this.warn('Notification ledger truncated; checkpoint retained');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let remaining = size - current.offset;
|
|
260
|
+
while (remaining === 0 && index < segments.length - 1) {
|
|
261
|
+
current.segment = segments[++index];
|
|
262
|
+
current.offset = 0;
|
|
263
|
+
remaining = statSync(join(dir, current.segment)).size;
|
|
264
|
+
}
|
|
265
|
+
if (remaining === 0) {
|
|
266
|
+
if (current.segment !== saved.segment)
|
|
267
|
+
outbox.saveCheckpoint(current);
|
|
268
|
+
return; // Latest EOF: no ledger open, allocation or read.
|
|
269
|
+
}
|
|
270
|
+
const fd = openSync(join(dir, current.segment), 'r'), buffer = Buffer.alloc(Math.min(BATCH_BYTES, remaining));
|
|
271
|
+
let count;
|
|
272
|
+
try {
|
|
273
|
+
count = readSync(fd, buffer, 0, buffer.length, current.offset);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
closeSync(fd);
|
|
277
|
+
}
|
|
278
|
+
let position = 0, blocked = false;
|
|
279
|
+
while (position < count) {
|
|
280
|
+
const end = buffer.indexOf(10, position);
|
|
281
|
+
if (end < 0 || end >= count) {
|
|
282
|
+
// Oversized model/media events are irrelevant to prompt/completion
|
|
283
|
+
// correlation. Skip in bounded chunks; retain partial small lines.
|
|
284
|
+
if (current.discarding || (position === 0 && count === BATCH_BYTES)) {
|
|
285
|
+
current.offset += count - position;
|
|
286
|
+
current.discarding = true;
|
|
287
|
+
this.warn('Notification observer skipping oversized ledger record');
|
|
217
288
|
}
|
|
218
|
-
|
|
219
|
-
outbox.saveCheckpoint(current);
|
|
289
|
+
break;
|
|
220
290
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
291
|
+
if (current.discarding) {
|
|
292
|
+
current.offset += end - position + 1;
|
|
293
|
+
position = end + 1;
|
|
294
|
+
current.discarding = false;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const event = JSON.parse(buffer.subarray(position, end).toString('utf8'));
|
|
298
|
+
if (event.schemaVersion !== 1 || event.roleId !== roleId)
|
|
299
|
+
throw new Error('invalid ledger event');
|
|
300
|
+
const prompt = `${event.sessionGeneration}:${event.promptId}`;
|
|
301
|
+
if (event.kind === 'prompt.admitted' && event.promptId && ['browser', 'owner_admin_console', 'owner_channel'].includes(event.source ?? '')) {
|
|
302
|
+
if (!current.prompts.includes(prompt))
|
|
303
|
+
current.prompts.push(prompt);
|
|
226
304
|
}
|
|
305
|
+
if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
|
|
306
|
+
current.messages[prompt] = event.messageId;
|
|
307
|
+
if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
|
|
308
|
+
if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(this.taskRoles ? { taskId: this.taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
|
|
309
|
+
blocked = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
current.prompts = current.prompts.filter(p => p !== prompt);
|
|
313
|
+
delete current.messages[prompt];
|
|
314
|
+
}
|
|
315
|
+
current.offset += end - position + 1;
|
|
316
|
+
position = end + 1;
|
|
317
|
+
if (performance.now() >= deadline)
|
|
318
|
+
break;
|
|
227
319
|
}
|
|
320
|
+
if (current.segment !== saved.segment || current.offset !== saved.offset)
|
|
321
|
+
outbox.saveCheckpoint(current);
|
|
322
|
+
if (blocked) {
|
|
323
|
+
this.retryAt.set(roleDir, Date.now() + 1000);
|
|
324
|
+
this.dirty.add(roleDir);
|
|
325
|
+
}
|
|
326
|
+
else if ((current.segment !== saved.segment || current.offset !== saved.offset)
|
|
327
|
+
&& (current.offset < statSync(join(dir, current.segment)).size || index < segments.length - 1))
|
|
328
|
+
this.dirty.add(roleDir);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
this.warn('Notification ledger admission pending; checkpoint will replay');
|
|
332
|
+
// Retained, not-yet-admitted ledger work needs its own next-due wake;
|
|
333
|
+
// the outbox sender can retry only events that were already admitted.
|
|
334
|
+
this.retryAt.set(roleDir, Date.now() + 1000);
|
|
335
|
+
this.dirty.add(roleDir);
|
|
336
|
+
}
|
|
228
337
|
}
|
|
229
338
|
resume(delay = 0) {
|
|
230
|
-
if (this.
|
|
339
|
+
if (this.stopped)
|
|
340
|
+
return;
|
|
341
|
+
const at = Date.now() + delay;
|
|
342
|
+
if (this.continuation && this.continuationAt <= at)
|
|
231
343
|
return;
|
|
232
|
-
|
|
344
|
+
clearTimeout(this.continuation);
|
|
345
|
+
this.continuationAt = at;
|
|
346
|
+
this.continuation = setTimeout(() => { this.continuation = undefined; this.continuationAt = Infinity; this.flush(); }, delay);
|
|
233
347
|
this.continuation.unref();
|
|
234
348
|
}
|
|
235
349
|
async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
|
|
@@ -47,6 +47,9 @@ export declare class FleetConfigService {
|
|
|
47
47
|
readonly path: string;
|
|
48
48
|
private readonly preflight;
|
|
49
49
|
private readonly beforeMutation?;
|
|
50
|
+
private validationInputs;
|
|
51
|
+
/** Cached during canonical reads; observer filtering must never parse YAML. */
|
|
52
|
+
resourceValidationInputs(): readonly string[];
|
|
50
53
|
constructor(options?: FleetConfigServiceOptions);
|
|
51
54
|
read(includeDefinitions?: boolean): ConfigReadResult;
|
|
52
55
|
preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, } from 'node:fs';
|
|
3
|
-
import { basename, dirname, extname, join } from 'node:path';
|
|
3
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
4
4
|
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
6
6
|
import { loadConfig, ROLE_NAME_RE, splitRootFor } from '../config.js';
|
|
@@ -18,6 +18,9 @@ export class FleetConfigService {
|
|
|
18
18
|
path;
|
|
19
19
|
preflight;
|
|
20
20
|
beforeMutation;
|
|
21
|
+
validationInputs = [];
|
|
22
|
+
/** Cached during canonical reads; observer filtering must never parse YAML. */
|
|
23
|
+
resourceValidationInputs() { return this.validationInputs; }
|
|
21
24
|
constructor(options = {}) {
|
|
22
25
|
this.path = options.configPath ?? defaultConfigPath();
|
|
23
26
|
this.preflight = options.preflight ?? (async () => emptyReport());
|
|
@@ -25,13 +28,24 @@ export class FleetConfigService {
|
|
|
25
28
|
}
|
|
26
29
|
read(includeDefinitions = false) {
|
|
27
30
|
const sources = readSources(this.path);
|
|
31
|
+
let raw;
|
|
28
32
|
try {
|
|
33
|
+
const manifest = parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false });
|
|
34
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
35
|
+
const vars = record(manifest.vars);
|
|
36
|
+
const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
|
|
37
|
+
const invite = record(record(manifest.rooms).owner).public_invite_file;
|
|
38
|
+
const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
|
|
39
|
+
// Record even invalid/missing references before validation, so repairing an
|
|
40
|
+
// already observed dependency can never retain a certified stale capture.
|
|
41
|
+
this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
|
|
42
|
+
...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
|
|
29
43
|
loadConfig(this.path, { yamlMode: 'strict' });
|
|
44
|
+
raw = modelFrom(sources, includeDefinitions);
|
|
30
45
|
}
|
|
31
46
|
catch (error) {
|
|
32
47
|
throw safeError(error, sourceSensitiveValues(sources));
|
|
33
48
|
}
|
|
34
|
-
const raw = modelFrom(sources, includeDefinitions);
|
|
35
49
|
const redacted = redactModel(raw);
|
|
36
50
|
return {
|
|
37
51
|
path: basename(this.path), exists: true, firstRun: false,
|
|
@@ -108,9 +108,10 @@ export declare class ResourceBindingError extends Error {
|
|
|
108
108
|
export declare class FleetResourceViews {
|
|
109
109
|
private scope;
|
|
110
110
|
private project;
|
|
111
|
+
private configurationInputs;
|
|
111
112
|
readonly producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
|
|
112
113
|
private connections;
|
|
113
|
-
constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown
|
|
114
|
+
constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>, configurationInputs?: () => readonly string[]);
|
|
114
115
|
open(response: ServerResponse, owner: string, authorized: () => boolean): {
|
|
115
116
|
connectionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
116
117
|
close: () => void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { relative, resolve, sep } from 'node:path';
|
|
2
|
-
import { agentsRoot, tmpRoot, stateRoot } from '../paths.js';
|
|
2
|
+
import { agentsRoot, tmpRoot, stateRoot, defaultConfigPath, fleetDDir } from '../paths.js';
|
|
3
|
+
import { splitRootFor } from '../config.js';
|
|
4
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { homedir } from 'node:os';
|
|
5
7
|
import { observeFleetResources } from '../application/resource-observer.js';
|
|
@@ -184,11 +186,13 @@ export class ResourceBindingError extends Error {
|
|
|
184
186
|
export class FleetResourceViews {
|
|
185
187
|
scope;
|
|
186
188
|
project;
|
|
189
|
+
configurationInputs;
|
|
187
190
|
producerGeneration = randomUUID();
|
|
188
191
|
connections = new Map();
|
|
189
|
-
constructor(scope, project) {
|
|
192
|
+
constructor(scope, project, configurationInputs = () => []) {
|
|
190
193
|
this.scope = scope;
|
|
191
194
|
this.project = project;
|
|
195
|
+
this.configurationInputs = configurationInputs;
|
|
192
196
|
}
|
|
193
197
|
open(response, owner, authorized) {
|
|
194
198
|
const id = randomUUID(), controller = new AbortController();
|
|
@@ -348,6 +352,21 @@ export class FleetResourceViews {
|
|
|
348
352
|
} });
|
|
349
353
|
}
|
|
350
354
|
affects(descriptor, path, structural = false) {
|
|
355
|
+
if (descriptor.resource === 'configuration') {
|
|
356
|
+
const manifest = resolve(this.scope.configPath ?? defaultConfigPath());
|
|
357
|
+
const root = resolve(splitRootFor(manifest)), changed = resolve(path);
|
|
358
|
+
const within = (candidate, parent) => {
|
|
359
|
+
const local = relative(parent, candidate);
|
|
360
|
+
return local === '' || (local !== '..' && !local.startsWith('..' + sep));
|
|
361
|
+
};
|
|
362
|
+
// Configuration and provenance read the manifest and split definitions;
|
|
363
|
+
// loadConfig also rejects legacy YAML files in the global fleet.d root.
|
|
364
|
+
// Ancestor replacement/unknown directory wakes remain conservative.
|
|
365
|
+
const legacy = resolve(fleetDDir());
|
|
366
|
+
return within(changed, root) || within(root, changed) || within(manifest, changed)
|
|
367
|
+
|| within(changed, legacy) || within(legacy, changed)
|
|
368
|
+
|| this.configurationInputs().some(input => within(resolve(input), changed));
|
|
369
|
+
}
|
|
351
370
|
// An explicitly selected manifest may use any pathname, including one
|
|
352
371
|
// otherwise reserved for an independent task receipt.
|
|
353
372
|
if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
|
|
@@ -389,10 +408,8 @@ export class FleetResourceViews {
|
|
|
389
408
|
return false;
|
|
390
409
|
if (descriptor.resource === 'previews')
|
|
391
410
|
return (descriptor.ids ?? []).includes(parts[0]);
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
if (descriptor.resource === 'configuration')
|
|
395
|
-
return false;
|
|
411
|
+
// Directory and roles intentionally remain conservative: live status
|
|
412
|
+
// includes activity. Configuration was classified by its inputs above.
|
|
396
413
|
}
|
|
397
414
|
return true;
|
|
398
415
|
}
|
|
@@ -404,7 +421,14 @@ export class FleetResourceViews {
|
|
|
404
421
|
if (view.running)
|
|
405
422
|
return;
|
|
406
423
|
view.running = true;
|
|
407
|
-
const task = connection.tail.then(() =>
|
|
424
|
+
const task = connection.tail.then(async () => {
|
|
425
|
+
// Resolved projection promises must not starve HTTP, timers or native
|
|
426
|
+
// watch delivery. Preserve serialization and coalesce this event batch.
|
|
427
|
+
await yieldToEventLoop();
|
|
428
|
+
if (!this.current(connection, view) || view.failed)
|
|
429
|
+
return;
|
|
430
|
+
await this.refresh(connection, view);
|
|
431
|
+
});
|
|
408
432
|
connection.tail = task.catch(() => { });
|
|
409
433
|
void task.finally(() => { view.running = false; if (view.dirty)
|
|
410
434
|
this.schedule(connection, view); }).catch(connection.close);
|
package/dist/web/server.js
CHANGED
|
@@ -424,7 +424,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
424
424
|
}
|
|
425
425
|
}
|
|
426
426
|
};
|
|
427
|
-
const resources = new FleetResourceViews(services.resourceScope ?? {}, project);
|
|
427
|
+
const resources = new FleetResourceViews(services.resourceScope ?? {}, project, () => services.configuration?.resourceValidationInputs?.() ?? []);
|
|
428
428
|
app.addHook('preClose', async () => resources.close());
|
|
429
429
|
app.get('/api/v1/resource-events', async (request, reply) => {
|
|
430
430
|
const browser = auth.authenticate(request);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.80",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|