@ours.network/fleet 1.2.0-nightly.70 → 1.2.0-nightly.72

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.
Files changed (58) hide show
  1. package/dist/account-origin.d.ts +4 -1
  2. package/dist/account-origin.js +27 -2
  3. package/dist/application/conversation-history.d.ts +7 -0
  4. package/dist/application/conversation-history.js +65 -0
  5. package/dist/application/fleet-query-service.d.ts +1 -1
  6. package/dist/application/fleet-query-service.js +2 -2
  7. package/dist/application/resource-observer.d.ts +40 -0
  8. package/dist/application/resource-observer.js +278 -0
  9. package/dist/application/role-removal-service.js +1 -1
  10. package/dist/application/role-repository.d.ts +1 -1
  11. package/dist/application/role-repository.js +11 -5
  12. package/dist/application/task-layouts.js +2 -0
  13. package/dist/application/task-room-service.d.ts +2 -0
  14. package/dist/application/task-room-service.js +2 -2
  15. package/dist/build-info.json +4 -4
  16. package/dist/daemon-recovery.d.ts +13 -1
  17. package/dist/daemon-recovery.js +117 -3
  18. package/dist/monitor.d.ts +1 -0
  19. package/dist/monitor.js +1 -1
  20. package/dist/notifications/fleet-producer.d.ts +4 -1
  21. package/dist/notifications/fleet-producer.js +39 -10
  22. package/dist/notifications/outbox.d.ts +14 -1
  23. package/dist/notifications/outbox.js +51 -10
  24. package/dist/notifications/target-cleanup.d.ts +10 -4
  25. package/dist/notifications/target-cleanup.js +35 -23
  26. package/dist/notifications/task-producer.d.ts +3 -1
  27. package/dist/notifications/task-producer.js +38 -5
  28. package/dist/ops.js +1 -1
  29. package/dist/paths.d.ts +3 -0
  30. package/dist/paths.js +3 -0
  31. package/dist/rooms-tasks/add-member.js +2 -1
  32. package/dist/rooms-tasks/layout.js +2 -1
  33. package/dist/rooms-tasks/task-state.d.ts +1 -0
  34. package/dist/rooms-tasks/task-state.js +27 -11
  35. package/dist/rooms-tasks/types.d.ts +4 -1
  36. package/dist/runner.js +22 -7
  37. package/dist/subscriptions/login.d.ts +4 -3
  38. package/dist/subscriptions/login.js +14 -6
  39. package/dist/subscriptions/service.d.ts +1 -1
  40. package/dist/subscriptions/service.js +4 -4
  41. package/dist/subscriptions/store.js +3 -0
  42. package/dist/tunnel-setup-input.js +7 -1
  43. package/dist/watchdog/query.d.ts +3 -1
  44. package/dist/watchdog/query.js +2 -1
  45. package/dist/web/auth.d.ts +2 -2
  46. package/dist/web/auth.js +4 -4
  47. package/dist/web/events.d.ts +2 -0
  48. package/dist/web/events.js +9 -0
  49. package/dist/web/prefix-gateway.js +20 -10
  50. package/dist/web/resource-views.d.ts +124 -0
  51. package/dist/web/resource-views.js +424 -0
  52. package/dist/web/runtime.js +6 -3
  53. package/dist/web/server.d.ts +3 -0
  54. package/dist/web/server.js +171 -20
  55. package/dist/workspace-enrollment.js +53 -28
  56. package/dist/workspace-replacement.js +28 -10
  57. package/docs/conversation-stream.md +6 -0
  58. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import { type FleetResourceScope, type FleetResourceState } from '../application/resource-observer.js';
3
+ export declare function abortableProjection<T>(read: Promise<T>, signal: AbortSignal): Promise<T>;
4
+ export interface ResourceFrameBinding {
5
+ readonly protocolVersion: 1;
6
+ readonly producerGeneration: string;
7
+ readonly subscriptionEpoch: number;
8
+ readonly viewId: string;
9
+ readonly sequence: number;
10
+ readonly revision: number;
11
+ readonly baselineId?: string;
12
+ }
13
+ export type ResourceHealthReason = 'auth_required' | 'unsupported' | 'upstream_unavailable';
14
+ export type ResourceEnvelope = ResourceFrameBinding & ({
15
+ readonly kind: 'snapshot';
16
+ readonly payload: unknown;
17
+ } | {
18
+ readonly kind: 'upsert';
19
+ readonly payload: unknown;
20
+ } | {
21
+ readonly kind: 'delete';
22
+ readonly payload: {
23
+ readonly id: string;
24
+ readonly revision: number;
25
+ };
26
+ } | {
27
+ readonly kind: 'ready';
28
+ readonly payload: {
29
+ readonly baselineId: string;
30
+ readonly source: FleetResourceState;
31
+ };
32
+ } | {
33
+ readonly kind: 'coverage';
34
+ readonly payload: {
35
+ readonly source: FleetResourceState;
36
+ };
37
+ } | {
38
+ readonly kind: 'resync';
39
+ readonly payload: {
40
+ readonly reason: 'capacity' | 'projection_unavailable';
41
+ };
42
+ } | {
43
+ readonly kind: 'health';
44
+ readonly payload: {
45
+ readonly healthy: false;
46
+ readonly reason: ResourceHealthReason;
47
+ };
48
+ });
49
+ export interface ResourceWireFrame {
50
+ event: 'resource' | 'resource-chunk';
51
+ data: Record<string, unknown>;
52
+ }
53
+ export declare const RESOURCE_FRAME_BYTES: number;
54
+ export declare const RESOURCE_PENDING_BYTES: number;
55
+ export declare const RESOURCE_ASSEMBLY_BYTES: number;
56
+ /** Lazily yield byte chunks so backpressure never queues an entire large record.
57
+ * Base64 prevents splitting a Unicode scalar from corrupting JSON/SSE parsing. */
58
+ export declare function resourceFrames(envelope: ResourceEnvelope): Generator<ResourceWireFrame>;
59
+ /** One Fleet HTTP writer. The active record owns the assembly budget;
60
+ * waiting live records share the smaller pending-output budget. */
61
+ export declare class ResourceOutput {
62
+ #private;
63
+ readonly response: ServerResponse;
64
+ readonly signal: AbortSignal;
65
+ readonly failed: (error: Error) => void;
66
+ constructor(response: ServerResponse, signal: AbortSignal, failed: (error: Error) => void);
67
+ send(envelope: ResourceEnvelope): Promise<void>;
68
+ close(error?: Error): void;
69
+ }
70
+ export interface ResourceDescriptor {
71
+ viewId: string;
72
+ resource: 'directory' | 'roles' | 'tasks' | 'task-lists' | 'configuration' | 'previews' | 'task-layout' | 'creation-action' | 'action' | 'member-addition' | 'login' | 'onboarding-health' | 'subscriptions';
73
+ entityId?: string;
74
+ receiptId?: string;
75
+ provider?: 'claude' | 'codex';
76
+ includeTemporary?: boolean;
77
+ includeDefinitions?: boolean;
78
+ ids?: string[];
79
+ state?: string;
80
+ list?: string;
81
+ groupByList?: boolean;
82
+ includeDeleting?: boolean;
83
+ }
84
+ export declare function canonicalResourceDescriptor(input: unknown): ResourceDescriptor;
85
+ interface BoundProjection {
86
+ payload: unknown;
87
+ source: FleetResourceState;
88
+ producerGeneration: string;
89
+ revision: number;
90
+ baselineId: string;
91
+ descriptor: ResourceDescriptor;
92
+ }
93
+ export declare class ResourceBindingError extends Error {
94
+ readonly status: number;
95
+ constructor(status: number, message: string);
96
+ }
97
+ /** Same committed snapshot/ready/bound-read contract as Messenger. No controllers,
98
+ * notification reads or presence are acquired by this delivery owner. */
99
+ export declare class FleetResourceViews {
100
+ private scope;
101
+ private project;
102
+ readonly producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
103
+ private connections;
104
+ constructor(scope: FleetResourceScope, project: (descriptor: ResourceDescriptor, signal: AbortSignal, owner: string) => Promise<unknown>);
105
+ open(response: ServerResponse, owner: string, authorized: () => boolean): {
106
+ connectionId: `${string}-${string}-${string}-${string}-${string}`;
107
+ close: () => void;
108
+ };
109
+ register(owner: string, connectionId: string, viewId: string, epoch: number, input: unknown): {
110
+ subscriptionEpoch: number;
111
+ baselineId: string;
112
+ };
113
+ remove(owner: string, connectionId: string, viewId: string, epoch: number): void;
114
+ bound(owner: string, connectionId: string, viewId: string, epoch: number, baselineId: string): Promise<BoundProjection>;
115
+ close(): void;
116
+ private get;
117
+ private current;
118
+ private retire;
119
+ private emit;
120
+ private affects;
121
+ private schedule;
122
+ private refresh;
123
+ }
124
+ export {};
@@ -0,0 +1,424 @@
1
+ import { relative, resolve, sep } from 'node:path';
2
+ import { agentsRoot, tmpRoot, stateRoot } from '../paths.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { observeFleetResources } from '../application/resource-observer.js';
5
+ export async function abortableProjection(read, signal) {
6
+ signal.throwIfAborted();
7
+ let onAbort;
8
+ const aborted = new Promise((_, reject) => {
9
+ onAbort = () => reject(signal.reason ?? new Error('resource projection aborted'));
10
+ signal.addEventListener('abort', onAbort, { once: true });
11
+ });
12
+ try {
13
+ return await Promise.race([read, aborted]);
14
+ }
15
+ finally {
16
+ signal.removeEventListener('abort', onAbort);
17
+ }
18
+ }
19
+ export const RESOURCE_FRAME_BYTES = 512 * 1024;
20
+ export const RESOURCE_PENDING_BYTES = 8 * 1024 * 1024;
21
+ export const RESOURCE_ASSEMBLY_BYTES = 16 * 1024 * 1024;
22
+ const CHUNK_BYTES = 256 * 1024;
23
+ /** Lazily yield byte chunks so backpressure never queues an entire large record.
24
+ * Base64 prevents splitting a Unicode scalar from corrupting JSON/SSE parsing. */
25
+ export function* resourceFrames(envelope) {
26
+ const bytes = Buffer.from(JSON.stringify(envelope));
27
+ if (bytes.length > RESOURCE_ASSEMBLY_BYTES)
28
+ throw new Error('resource assembly limit exceeded');
29
+ const ordinary = { event: 'resource', data: envelope };
30
+ if (Buffer.byteLength(`event: resource\ndata: ${bytes.toString('utf8')}\n\n`) <= RESOURCE_FRAME_BYTES) {
31
+ yield ordinary;
32
+ return;
33
+ }
34
+ const id = `${envelope.viewId}:${envelope.subscriptionEpoch}:${envelope.sequence}`;
35
+ const binding = { protocolVersion: 1, id, producerGeneration: envelope.producerGeneration,
36
+ viewId: envelope.viewId, subscriptionEpoch: envelope.subscriptionEpoch, revision: envelope.revision,
37
+ sequence: envelope.sequence };
38
+ yield { event: 'resource-chunk', data: { ...binding, type: 'start', totalBytes: bytes.length, parts: Math.ceil(bytes.length / CHUNK_BYTES) } };
39
+ for (let offset = 0, index = 0; offset < bytes.length; offset += CHUNK_BYTES, index++) {
40
+ yield { event: 'resource-chunk', data: { ...binding, type: 'chunk', index, data: bytes.subarray(offset, offset + CHUNK_BYTES).toString('base64') } };
41
+ }
42
+ yield { event: 'resource-chunk', data: { ...binding, type: 'commit', totalBytes: bytes.length } };
43
+ }
44
+ /** One Fleet HTTP writer. The active record owns the assembly budget;
45
+ * waiting live records share the smaller pending-output budget. */
46
+ export class ResourceOutput {
47
+ response;
48
+ signal;
49
+ failed;
50
+ #pending = [];
51
+ #pendingBytes = 0;
52
+ #active;
53
+ #closed = false;
54
+ #releaseDrain;
55
+ #abort = () => this.close();
56
+ constructor(response, signal, failed) {
57
+ this.response = response;
58
+ this.signal = signal;
59
+ this.failed = failed;
60
+ if (signal.aborted)
61
+ this.close();
62
+ else {
63
+ signal.addEventListener('abort', this.#abort, { once: true });
64
+ response.once('close', this.#abort);
65
+ }
66
+ }
67
+ send(envelope) {
68
+ if (this.#closed)
69
+ return Promise.reject(new Error('resource output closed'));
70
+ const bytes = Buffer.byteLength(JSON.stringify(envelope));
71
+ if (bytes > RESOURCE_ASSEMBLY_BYTES)
72
+ return this.#fail(new Error('resource assembly limit exceeded'));
73
+ if (this.#active && this.#pendingBytes + bytes > RESOURCE_PENDING_BYTES) {
74
+ return this.#fail(new Error('resource pending limit exceeded'));
75
+ }
76
+ return new Promise((resolve, reject) => {
77
+ const item = { envelope, bytes, resolve, reject };
78
+ if (!this.#active) {
79
+ this.#active = item;
80
+ void this.#pump();
81
+ }
82
+ else {
83
+ this.#pending.push(item);
84
+ this.#pendingBytes += bytes;
85
+ }
86
+ });
87
+ }
88
+ #fail(error) {
89
+ this.close(error);
90
+ this.failed(error);
91
+ return Promise.reject(error);
92
+ }
93
+ close(error = new Error('resource output closed')) {
94
+ if (this.#closed)
95
+ return;
96
+ this.#closed = true;
97
+ this.signal.removeEventListener('abort', this.#abort);
98
+ this.response.removeListener('close', this.#abort);
99
+ this.#releaseDrain?.();
100
+ this.#active?.reject(error);
101
+ for (const item of this.#pending.splice(0))
102
+ item.reject(error);
103
+ this.#pendingBytes = 0;
104
+ if (!this.response.destroyed && !this.response.writableEnded)
105
+ this.response.end();
106
+ }
107
+ async #pump() {
108
+ try {
109
+ while (this.#active && !this.#closed) {
110
+ const item = this.#active;
111
+ for (const frame of resourceFrames(item.envelope)) {
112
+ if (this.#closed)
113
+ break;
114
+ const writable = this.response.write(`event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`);
115
+ if (!writable)
116
+ await this.#drain();
117
+ }
118
+ if (this.#closed)
119
+ break;
120
+ item.resolve();
121
+ this.#active = this.#pending.shift();
122
+ if (this.#active)
123
+ this.#pendingBytes -= this.#active.bytes;
124
+ }
125
+ }
126
+ catch (error) {
127
+ const failure = error instanceof Error ? error : new Error('resource output failed');
128
+ this.close(failure);
129
+ this.failed(failure);
130
+ }
131
+ }
132
+ #drain() {
133
+ if (this.#closed)
134
+ return Promise.resolve();
135
+ return new Promise(resolve => {
136
+ const release = () => {
137
+ this.response.removeListener('drain', release);
138
+ this.#releaseDrain = undefined;
139
+ resolve();
140
+ };
141
+ this.#releaseDrain = release;
142
+ this.response.once('drain', release);
143
+ });
144
+ }
145
+ }
146
+ export function canonicalResourceDescriptor(input) {
147
+ if (!input || typeof input !== 'object' || Array.isArray(input))
148
+ throw Error('invalid resource descriptor');
149
+ const value = input;
150
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(value.viewId) || !['directory', 'roles', 'tasks', 'task-lists', 'configuration', 'previews', 'task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health', 'subscriptions'].includes(value.resource)
151
+ || Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider'].includes(key)))
152
+ throw Error('invalid resource descriptor');
153
+ for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting'])
154
+ if (value[key] !== undefined && typeof value[key] !== 'boolean')
155
+ throw Error('invalid resource descriptor');
156
+ if (value.ids !== undefined && (!Array.isArray(value.ids) || value.ids.length > 500 || value.ids.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(id))))
157
+ throw Error('invalid resource ids');
158
+ if (value.state !== undefined && !['all', 'backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'].includes(value.state))
159
+ throw Error('invalid resource state');
160
+ if (value.list !== undefined && (typeof value.list !== 'string' || value.list.length > 256))
161
+ throw Error('invalid resource list');
162
+ for (const key of ['entityId', 'receiptId'])
163
+ if (value[key] !== undefined && (typeof value[key] !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(value[key])))
164
+ throw Error('invalid resource target');
165
+ if (['task-layout', 'creation-action', 'action', 'member-addition', 'login'].includes(value.resource) && !value.entityId)
166
+ throw Error('resource target required');
167
+ if (value.resource === 'member-addition' && !value.receiptId)
168
+ throw Error('receipt required');
169
+ if (value.resource === 'login' && !['claude', 'codex'].includes(value.provider ?? ''))
170
+ throw Error('provider required');
171
+ return { ...value, ...(value.ids ? { ids: [...new Set(value.ids)] } : {}) };
172
+ }
173
+ export class ResourceBindingError extends Error {
174
+ status;
175
+ constructor(status, message) {
176
+ super(message);
177
+ this.status = status;
178
+ }
179
+ }
180
+ /** Same committed snapshot/ready/bound-read contract as Messenger. No controllers,
181
+ * notification reads or presence are acquired by this delivery owner. */
182
+ export class FleetResourceViews {
183
+ scope;
184
+ project;
185
+ producerGeneration = randomUUID();
186
+ connections = new Map();
187
+ constructor(scope, project) {
188
+ this.scope = scope;
189
+ this.project = project;
190
+ }
191
+ open(response, owner, authorized) {
192
+ const id = randomUUID(), controller = new AbortController();
193
+ let connection;
194
+ let timer;
195
+ const close = () => {
196
+ if (controller.signal.aborted)
197
+ return;
198
+ controller.abort();
199
+ clearInterval(timer);
200
+ this.connections.delete(id);
201
+ if (connection) {
202
+ for (const view of connection.views.values())
203
+ this.retire(view);
204
+ connection.views.clear();
205
+ connection.output.close();
206
+ }
207
+ response.off('close', close);
208
+ };
209
+ const output = new ResourceOutput(response, controller.signal, close);
210
+ const observer = observeFleetResources(this.scope, state => {
211
+ if (!connection || controller.signal.aborted)
212
+ return;
213
+ if (state.health !== 'healthy') {
214
+ const pending = [...connection.views.values()].map(view => {
215
+ view.failed = true;
216
+ view.rejectInitial(Error('resource observation unavailable'));
217
+ view.controller.abort();
218
+ return this.emit(connection, view, 'health', { healthy: false, reason: state.health === 'unsupported' ? 'unsupported' : 'upstream_unavailable' });
219
+ });
220
+ if (!pending.length)
221
+ response.write(`event: resource-service-health\ndata: ${JSON.stringify({ protocolVersion: 1, healthy: false, reason: state.health === 'unsupported' ? 'unsupported' : 'upstream_unavailable' })}\n\n`);
222
+ void Promise.allSettled(pending).then(close);
223
+ }
224
+ else
225
+ for (const view of connection.views.values())
226
+ if (view.source.snapshot().position !== view.scheduledPosition)
227
+ this.schedule(connection, view);
228
+ }, controller.signal);
229
+ connection = { id, owner, controller, output, views: new Map(), epochs: new Map(), observer, tail: Promise.resolve(), close };
230
+ this.connections.set(id, connection);
231
+ response.once('close', close);
232
+ response.write(`event: resource-connection\ndata: ${JSON.stringify({ protocolVersion: 1, connectionId: id, producerGeneration: this.producerGeneration, continuity: 'reset', baselineRequired: true })}\n\n`);
233
+ if (observer.snapshot().health !== 'healthy') {
234
+ response.write(`event: resource-service-health\ndata: ${JSON.stringify({ protocolVersion: 1, healthy: false, reason: observer.snapshot().health === 'unsupported' ? 'unsupported' : 'upstream_unavailable' })}\n\n`);
235
+ close();
236
+ }
237
+ else {
238
+ timer = setInterval(() => {
239
+ if (!authorized()) {
240
+ close();
241
+ return;
242
+ }
243
+ for (const view of connection.views.values())
244
+ if (!view.failed && view.covered && view.covered.position === view.source.snapshot().position)
245
+ void this.emit(connection, view, 'coverage', { source: view.covered }).catch(close);
246
+ // Keep the transport live with no subscribed views; never claim source coverage from bytes.
247
+ if (!connection.views.size)
248
+ response.write(': keepalive\n\n');
249
+ }, 2000);
250
+ timer.unref();
251
+ }
252
+ return { connectionId: id, close };
253
+ }
254
+ register(owner, connectionId, viewId, epoch, input) {
255
+ const connection = this.get(owner, connectionId);
256
+ let descriptor;
257
+ try {
258
+ descriptor = canonicalResourceDescriptor(input);
259
+ }
260
+ catch {
261
+ throw new ResourceBindingError(400, 'invalid resource descriptor');
262
+ }
263
+ if (descriptor.viewId !== viewId || !Number.isSafeInteger(epoch) || epoch < 1)
264
+ throw new ResourceBindingError(400, 'invalid resource epoch');
265
+ if ((connection.epochs.get(viewId) ?? 0) >= epoch)
266
+ throw new ResourceBindingError(409, 'obsolete resource epoch');
267
+ if (connection.observer.snapshot().health !== 'healthy')
268
+ throw new ResourceBindingError(503, 'resource observation unavailable');
269
+ if (!connection.epochs.has(viewId) && connection.epochs.size >= 256)
270
+ throw new ResourceBindingError(503, 'resource epoch capacity exceeded');
271
+ if (!connection.views.has(viewId) && connection.views.size >= 64)
272
+ throw new ResourceBindingError(409, 'resource view capacity exceeded');
273
+ const old = connection.views.get(viewId);
274
+ if (old)
275
+ this.retire(old);
276
+ let resolveInitial, rejectInitial;
277
+ const initial = new Promise((resolve, reject) => { resolveInitial = resolve; rejectInitial = reject; });
278
+ void initial.catch(() => { });
279
+ const controller = new AbortController();
280
+ const source = connection.observer.cursor(path => this.affects(descriptor, path), controller.signal);
281
+ // Independent agents contribute ordered, last-observed statuses. Their
282
+ // ledger commits wake the view but do not revoke its structural capture.
283
+ const captureSource = descriptor.resource === 'directory' || descriptor.resource === 'roles'
284
+ ? connection.observer.cursor(path => this.affects(descriptor, path, true), controller.signal)
285
+ : source;
286
+ const view = { source, captureSource, scheduledPosition: -1, descriptor, epoch, baselineId: randomUUID(), controller, revision: 0, sequence: 0, dirty: false, running: false, failed: false, initial, resolveInitial, rejectInitial };
287
+ connection.views.set(viewId, view);
288
+ connection.epochs.set(viewId, epoch);
289
+ this.schedule(connection, view);
290
+ return { subscriptionEpoch: epoch, baselineId: view.baselineId };
291
+ }
292
+ remove(owner, connectionId, viewId, epoch) {
293
+ const connection = this.get(owner, connectionId), view = connection.views.get(viewId);
294
+ if (typeof viewId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(viewId) || !Number.isSafeInteger(epoch) || epoch < 1)
295
+ throw new ResourceBindingError(400, 'invalid resource epoch');
296
+ if (!connection.epochs.has(viewId) && connection.epochs.size >= 256)
297
+ throw new ResourceBindingError(503, 'resource epoch capacity exceeded');
298
+ // Independent DELETE can arrive before the POST it retires.
299
+ connection.epochs.set(viewId, Math.max(connection.epochs.get(viewId) ?? 0, epoch));
300
+ if (view && epoch >= view.epoch) {
301
+ this.retire(view);
302
+ connection.views.delete(viewId);
303
+ }
304
+ }
305
+ async bound(owner, connectionId, viewId, epoch, baselineId) {
306
+ const connection = this.get(owner, connectionId), view = connection.views.get(viewId);
307
+ if (!view || view.epoch !== epoch || view.baselineId !== baselineId)
308
+ throw new ResourceBindingError(409, 'resource binding mismatch');
309
+ const baseline = await view.initial;
310
+ if (!this.current(connection, view) || view.failed)
311
+ throw new ResourceBindingError(409, 'resource binding retired');
312
+ return baseline;
313
+ }
314
+ close() { for (const connection of this.connections.values())
315
+ connection.close(); }
316
+ get(owner, id) {
317
+ const connection = this.connections.get(id);
318
+ if (!connection || connection.owner !== owner)
319
+ throw new ResourceBindingError(409, 'unknown resource connection');
320
+ return connection;
321
+ }
322
+ current(connection, view) { return !connection.controller.signal.aborted && !view.controller.signal.aborted && connection.views.get(view.descriptor.viewId) === view; }
323
+ retire(view) { view.controller.abort(); view.rejectInitial(new ResourceBindingError(409, 'resource binding retired')); }
324
+ emit(connection, view, kind, payload) {
325
+ return connection.output.send({ protocolVersion: 1, producerGeneration: this.producerGeneration, viewId: view.descriptor.viewId, subscriptionEpoch: view.epoch, baselineId: view.baselineId, sequence: ++view.sequence, revision: view.revision, kind, payload });
326
+ }
327
+ affects(descriptor, path, structural = false) {
328
+ // Only classify known activity writes under the configured role roots.
329
+ // Parent/unknown events and every other file retain full invalidation.
330
+ // The canonical web producer atomically replaces hashed checkpoint files.
331
+ // These delivery acknowledgements are not role/task/config membership;
332
+ // retain full wakes without requiring an outbox-quiet capture window.
333
+ if (structural && /^[a-f0-9]{64}\.json$/.test(relative(resolve(stateRoot(), 'web', 'notification-outboxes'), path)))
334
+ return false;
335
+ const roots = this.scope.roots ?? [agentsRoot(), tmpRoot()];
336
+ for (const root of roots) {
337
+ const parts = relative(resolve(root), path).split(sep);
338
+ if (parts[0] === '..')
339
+ continue;
340
+ // Both session backends append diagnostic events as well as durable
341
+ // conversation events; ACP usage updates also persist observed usage.
342
+ // Monitor cursor/state and its presented status are per-agent activity.
343
+ // None changes role membership/configuration. Keep these as full wakes.
344
+ if (structural && parts.length === 2 && ['.session-events.jsonl', '.session-events.jsonl.1', '.subscription-usage.json', '.notify-cursor', '.monitor-state.json', '.monitor-status'].includes(parts[1]))
345
+ return false;
346
+ if (parts.length < 3 || parts[1] !== '.conversation')
347
+ continue;
348
+ if (structural && (descriptor.resource === 'directory' || descriptor.resource === 'roles'))
349
+ return false;
350
+ if (descriptor.resource === 'previews')
351
+ return (descriptor.ids ?? []).includes(parts[0]);
352
+ // Configuration/provenance reads no conversation data. Directory and
353
+ // roles intentionally remain conservative: live status includes activity.
354
+ if (descriptor.resource === 'configuration')
355
+ return false;
356
+ }
357
+ return true;
358
+ }
359
+ schedule(connection, view) {
360
+ if (!this.current(connection, view) || view.failed)
361
+ return;
362
+ view.scheduledPosition = view.source.snapshot().position;
363
+ view.dirty = true;
364
+ if (view.running)
365
+ return;
366
+ view.running = true;
367
+ const task = connection.tail.then(() => this.refresh(connection, view));
368
+ connection.tail = task.catch(() => { });
369
+ void task.finally(() => { view.running = false; if (view.dirty)
370
+ this.schedule(connection, view); }).catch(connection.close);
371
+ }
372
+ async refresh(connection, view) {
373
+ try {
374
+ if (!view.dirty || !this.current(connection, view))
375
+ return;
376
+ view.dirty = false;
377
+ let payload, source;
378
+ for (let attempt = 0; attempt < 3; attempt++) {
379
+ view.controller.signal.throwIfAborted();
380
+ const before = view.captureSource.snapshot();
381
+ const covered = view.source.snapshot();
382
+ payload = await abortableProjection(this.project(view.descriptor, view.controller.signal, connection.owner), view.controller.signal);
383
+ view.controller.signal.throwIfAborted();
384
+ const after = view.captureSource.snapshot();
385
+ if (after.health !== 'healthy')
386
+ throw Error('resource observation unavailable');
387
+ // Certify only the starting wake barrier. Status commits during the
388
+ // asynchronous canonical read stay dirty and are drained in order by
389
+ // the next serialized capture, even if this read happened to see them.
390
+ if (before.position === after.position) {
391
+ source = covered;
392
+ break;
393
+ }
394
+ }
395
+ if (!source)
396
+ throw Error('resource projection unstable');
397
+ if (!this.current(connection, view))
398
+ return;
399
+ const first = view.payload === undefined, changed = first || JSON.stringify(payload, (key, value) => key === 'observedAt' ? undefined : value) !== JSON.stringify(view.payload, (key, value) => key === 'observedAt' ? undefined : value);
400
+ view.covered = source;
401
+ if (changed) {
402
+ view.revision++;
403
+ await this.emit(connection, view, 'snapshot', payload);
404
+ if (!this.current(connection, view))
405
+ return;
406
+ view.payload = payload;
407
+ }
408
+ if (first) {
409
+ await this.emit(connection, view, 'ready', { baselineId: view.baselineId, source });
410
+ if (this.current(connection, view))
411
+ view.resolveInitial({ payload, source, producerGeneration: this.producerGeneration, revision: view.revision, baselineId: view.baselineId, descriptor: view.descriptor });
412
+ }
413
+ else
414
+ await this.emit(connection, view, 'coverage', { source });
415
+ }
416
+ catch (error) {
417
+ if (!this.current(connection, view))
418
+ return;
419
+ view.failed = true;
420
+ view.rejectInitial(error instanceof Error ? error : Error('projection failed'));
421
+ await this.emit(connection, view, 'resync', { reason: 'projection_unavailable' }).catch(connection.close);
422
+ }
423
+ }
424
+ }
@@ -55,12 +55,13 @@ const CONFIG_CACHE_TTL_MS = 5_000;
55
55
  */
56
56
  function cachedConfigProvider(configPath) {
57
57
  let cached;
58
- return () => {
58
+ const read = () => {
59
59
  const now = Date.now();
60
60
  if (!cached || now - cached.at >= CONFIG_CACHE_TTL_MS)
61
61
  cached = { at: now, cfg: loadConfig(configPath) };
62
62
  return cached.cfg;
63
63
  };
64
+ return Object.assign(read, { invalidate: () => { cached = undefined; } });
64
65
  }
65
66
  export async function startWebConsole(options) {
66
67
  const notificationConfig = producerConfig(process.env, undefined, options.log);
@@ -78,10 +79,10 @@ export async function startWebConsole(options) {
78
79
  }
79
80
  const access = options.access ?? new WebAccessStore(webDir).read();
80
81
  const bindingFile = resolve(stateRoot(), 'workspace', 'binding.json');
81
- let appOrigin = 'https://app.ours.network';
82
+ let appOrigin;
82
83
  try {
83
84
  if (existsSync(bindingFile))
84
- appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin ?? appOrigin);
85
+ appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin);
85
86
  }
86
87
  catch (error) {
87
88
  lock.release();
@@ -196,6 +197,8 @@ export async function startWebConsole(options) {
196
197
  try {
197
198
  server = await buildWebServer({
198
199
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration, observeRole,
200
+ resourceScope: { configPath: configuration.path, subscribeMutations: listener => events.subscribeResources(listener) },
201
+ invalidateResourceCaches: () => { watchdogConfigProvider.invalidate(); watchdogFindings.invalidate(); },
199
202
  taskRooms: new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath }),
200
203
  roomLayouts: new RoomLayoutDefinitions(options.configPath),
201
204
  layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
@@ -1,3 +1,4 @@
1
+ import type { FleetResourceScope } from '../application/resource-observer.js';
1
2
  import type { ConversationRoleObservation, ConversationRoleState } from '../application/conversation-role-observer.js';
2
3
  import { type InitAnswers } from '../init-wizard.js';
3
4
  import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
@@ -24,6 +25,8 @@ import type { OfferedModels } from '../subscriptions/cli.js';
24
25
  import type { SubscriptionService } from '../subscriptions/service.js';
25
26
  import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
26
27
  export interface WebServices {
28
+ resourceScope?: FleetResourceScope;
29
+ invalidateResourceCaches?(): void;
27
30
  observeRole?(roleId: string, listener: (state: ConversationRoleState) => void): ConversationRoleObservation;
28
31
  layoutBindings?: Pick<LayoutBindingGrants, 'control'>;
29
32
  query: FleetQueryService;