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

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 (44) hide show
  1. package/dist/application/conversation-history.d.ts +7 -0
  2. package/dist/application/conversation-history.js +65 -0
  3. package/dist/application/fleet-query-service.d.ts +1 -1
  4. package/dist/application/fleet-query-service.js +2 -2
  5. package/dist/application/resource-observer.d.ts +40 -0
  6. package/dist/application/resource-observer.js +278 -0
  7. package/dist/application/role-repository.d.ts +1 -1
  8. package/dist/application/role-repository.js +11 -5
  9. package/dist/application/task-layouts.js +2 -0
  10. package/dist/application/task-room-service.d.ts +2 -0
  11. package/dist/application/task-room-service.js +2 -2
  12. package/dist/build-info.json +4 -4
  13. package/dist/daemon-recovery.d.ts +13 -1
  14. package/dist/daemon-recovery.js +117 -3
  15. package/dist/monitor.d.ts +1 -0
  16. package/dist/monitor.js +1 -1
  17. package/dist/notifications/fleet-producer.d.ts +4 -1
  18. package/dist/notifications/fleet-producer.js +36 -7
  19. package/dist/notifications/target-cleanup.d.ts +1 -0
  20. package/dist/notifications/target-cleanup.js +1 -1
  21. package/dist/notifications/task-producer.d.ts +3 -1
  22. package/dist/notifications/task-producer.js +38 -5
  23. package/dist/rooms-tasks/add-member.js +2 -1
  24. package/dist/rooms-tasks/layout.js +2 -1
  25. package/dist/rooms-tasks/task-state.d.ts +1 -0
  26. package/dist/rooms-tasks/task-state.js +6 -1
  27. package/dist/runner.js +22 -7
  28. package/dist/subscriptions/login.d.ts +4 -3
  29. package/dist/subscriptions/login.js +14 -6
  30. package/dist/subscriptions/service.d.ts +1 -1
  31. package/dist/subscriptions/service.js +4 -4
  32. package/dist/subscriptions/store.js +3 -0
  33. package/dist/watchdog/query.d.ts +3 -1
  34. package/dist/watchdog/query.js +2 -1
  35. package/dist/web/events.d.ts +2 -0
  36. package/dist/web/events.js +9 -0
  37. package/dist/web/prefix-gateway.js +20 -10
  38. package/dist/web/resource-views.d.ts +124 -0
  39. package/dist/web/resource-views.js +424 -0
  40. package/dist/web/runtime.js +4 -1
  41. package/dist/web/server.d.ts +3 -0
  42. package/dist/web/server.js +170 -19
  43. package/docs/conversation-stream.md +6 -0
  44. package/package.json +1 -1
@@ -1,3 +1,7 @@
1
+ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor } from './resource-views.js';
2
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
+ import { stateRoot, defaultConfigPath } from '../paths.js';
4
+ import { conversationPreview } from '../application/conversation-history.js';
1
5
  import { registerConversationStream } from './conversation-stream.js';
2
6
  import { conversationHasAttachment } from '../application/conversation-history.js';
3
7
  import { readDeliveredFile } from '../file-delivery/store.js';
@@ -61,6 +65,12 @@ export async function buildWebServer(services, boundary, options = {}) {
61
65
  }
62
66
  auth.validateBoundary(request, false);
63
67
  });
68
+ app.addHook('onResponse', async (request, reply) => {
69
+ if (reply.statusCode < 400 && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method) && !request.url.includes('/resource-subscriptions')) {
70
+ publishFleetResourceCommit(services.configuration?.path ?? defaultConfigPath());
71
+ publishFleetResourceCommit(stateRoot());
72
+ }
73
+ });
64
74
  app.post('/api/v1/layout-bindings/:id/control', async (request) => {
65
75
  // Initial support is direct same-host Fleet-to-Fleet transport.
66
76
  const forwarded = Object.keys(request.headers).some(name => name === 'forwarded' || name.startsWith('x-forwarded-') || name === 'x-real-ip' || name === 'via');
@@ -74,6 +84,8 @@ export async function buildWebServer(services, boundary, options = {}) {
74
84
  return services.layoutBindings.control(request.params.id, request.headers.authorization, request.body);
75
85
  });
76
86
  app.setErrorHandler(async (error, request, reply) => {
87
+ if (error instanceof ResourceBindingError)
88
+ return reply.code(error.status).send({ error: { code: 'resource_binding', message: error.message } });
77
89
  const privateToolParseError = ['/api/v1/roles/:id/ours/call', '/api/v1/layout-bindings/:id/control'].includes(request.routeOptions.url ?? '')
78
90
  && error instanceof Error && 'code' in error
79
91
  && typeof error.code === 'string' && error.code.startsWith('FST_ERR_CTP_');
@@ -183,8 +195,10 @@ export async function buildWebServer(services, boundary, options = {}) {
183
195
  auditDegraded: audit.degraded,
184
196
  };
185
197
  });
198
+ let onboardingObserved;
186
199
  app.get('/api/v1/onboarding', async (request) => {
187
- auth.authenticate(request);
200
+ const browser = auth.authenticate(request);
201
+ const revision = services.configuration?.read().revision;
188
202
  // Opening an existing workspace needs configuration state, not CLI/account discovery.
189
203
  // Keep the full report for the setup screens and older App clients.
190
204
  if (request.query.view === 'state')
@@ -202,7 +216,12 @@ export async function buildWebServer(services, boundary, options = {}) {
202
216
  // When it could not be asked, none is offered and the reason is reported.
203
217
  const codexModels = services.codexModels ? await services.codexModels() : undefined;
204
218
  const offered = (model) => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model));
205
- return { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
219
+ const payload = { harnesses, catalog: catalog.models.filter(offered), ...(codexModels ? { codexModels: codexModels.state === 'known' ? { state: 'known' } : codexModels } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
220
+ if (revision === services.configuration?.read().revision) {
221
+ onboardingObserved = { owner: browser.id, revision, payload };
222
+ events.publish('onboarding.changed', {});
223
+ }
224
+ return payload;
206
225
  });
207
226
  // First-time setup is what creates the persistent coordinator. A configuration that loads and
208
227
  // has it is set up, whatever its brains are called; its models are then changed by editing it.
@@ -283,6 +302,9 @@ export async function buildWebServer(services, boundary, options = {}) {
283
302
  app.get('/api/v1/roles', async (request) => {
284
303
  auth.authenticate(request);
285
304
  const query = request.query;
305
+ // Unbound REST retains the existing problem-bearing inventory. Bound reads
306
+ // are handled by the strict resource preHandler before this route.
307
+ services.invalidateResourceCaches?.();
286
308
  return { roles: await services.query.list(query.includeTemporary === 'true') };
287
309
  });
288
310
  const taskApi = async (fn) => {
@@ -309,9 +331,152 @@ export async function buildWebServer(services, boundary, options = {}) {
309
331
  throw new FleetError('capability_unavailable', 'task operations are unavailable');
310
332
  return services.taskRooms;
311
333
  };
334
+ const projectTasks = async (query, strict = true) => {
335
+ const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
336
+ if (query.state && query.state !== 'all' && !states.includes(query.state))
337
+ throw new FleetError('invalid_request', 'invalid task state filter');
338
+ if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
339
+ throw new FleetError('invalid_request', 'groupByList must be true or false');
340
+ if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
341
+ throw new FleetError('invalid_request', 'includeDeleting must be true or false');
342
+ const state = query.state && query.state !== 'all' ? query.state : undefined;
343
+ const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
344
+ ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
345
+ const api = requireTaskRooms();
346
+ return taskApi(() => query.groupByList === 'true'
347
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
348
+ : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
349
+ };
350
+ const project = async (descriptor, signal, owner = '') => {
351
+ signal.throwIfAborted();
352
+ services.invalidateResourceCaches?.();
353
+ switch (descriptor.resource) {
354
+ case 'task-layout':
355
+ try {
356
+ return { layout: await taskApi(() => requireTaskRooms().taskLayout(descriptor.entityId)) };
357
+ }
358
+ catch (error) {
359
+ if (error?.code === 'task_not_found' || error?.code === 'resource_not_found')
360
+ return { deleted: true, id: descriptor.entityId };
361
+ throw error;
362
+ }
363
+ case 'creation-action': return services.creation.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
364
+ case 'action': return services.commands.get(descriptor.entityId) ?? { deleted: true, id: descriptor.entityId };
365
+ case 'member-addition':
366
+ try {
367
+ return await taskApi(() => requireTaskRooms().memberAddition(descriptor.entityId, descriptor.receiptId));
368
+ }
369
+ catch (error) {
370
+ if (['task_not_found', 'resource_not_found'].includes(error?.code) || error?.message === 'Member request not found')
371
+ return { deleted: true, id: descriptor.receiptId };
372
+ throw error;
373
+ }
374
+ case 'login':
375
+ // A foreign login must never be delivered, including its URL/device code.
376
+ try {
377
+ return subscriptions().login(descriptor.entityId, owner).view();
378
+ }
379
+ catch (error) {
380
+ if (error?.code === 'resource_not_found')
381
+ return { deleted: true, id: descriptor.entityId };
382
+ throw error;
383
+ }
384
+ case 'subscriptions': return { providers: await subscriptions().list(true) };
385
+ case 'onboarding-health': {
386
+ const revision = services.configuration?.read().revision;
387
+ if (onboardingObserved && onboardingObserved.revision !== revision)
388
+ onboardingObserved = undefined;
389
+ const observed = onboardingObserved?.owner === owner ? onboardingObserved : undefined;
390
+ return { ...observed?.payload, observationCurrent: !!observed, configured: onboardingConfigured(), providers: services.subscriptions ? await services.subscriptions.list(true) : [] };
391
+ }
392
+ case 'roles': return { roles: await services.query.list(descriptor.includeTemporary ?? false, true) };
393
+ case 'tasks': return projectTasks({ state: descriptor.state, list: descriptor.list, groupByList: String(descriptor.groupByList ?? false), includeDeleting: String(descriptor.includeDeleting ?? false) });
394
+ case 'task-lists': return { lists: requireTaskRooms().listTaskLists() };
395
+ case 'configuration':
396
+ if (!services.configuration)
397
+ throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
398
+ return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
399
+ case 'directory': {
400
+ const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
401
+ return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
402
+ }
403
+ case 'previews': {
404
+ const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
405
+ const previews = {};
406
+ for (const id of descriptor.ids ?? []) {
407
+ signal.throwIfAborted();
408
+ const role = selected.get(id), dir = role && services.repository.stateDir(role);
409
+ if (!role)
410
+ continue;
411
+ try {
412
+ previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
413
+ }
414
+ catch {
415
+ signal.throwIfAborted();
416
+ // A damaged/unreadable ledger is unavailable, not empty or removed.
417
+ // Keep this selected row observable so a repair can refresh it,
418
+ // without retiring previews for the other selected roles.
419
+ previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
420
+ }
421
+ }
422
+ return { previews };
423
+ }
424
+ }
425
+ };
426
+ const resources = new FleetResourceViews(services.resourceScope ?? {}, project);
427
+ app.addHook('preClose', async () => resources.close());
428
+ app.get('/api/v1/resource-events', async (request, reply) => {
429
+ const browser = auth.authenticate(request);
430
+ reply.hijack();
431
+ reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
432
+ reply.raw.flushHeaders();
433
+ const opened = resources.open(reply.raw, browser.id, () => auth.sessionValid(browser));
434
+ const binding = auth.bindTransport(browser, opened.close);
435
+ reply.raw.once('close', () => binding());
436
+ });
437
+ app.post('/api/v1/resource-subscriptions', async (request, reply) => {
438
+ const browser = auth.authenticate(request, true), body = request.body;
439
+ const binding = resources.register(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch, body?.descriptor);
440
+ const retire = () => {
441
+ if (reply.raw.writableFinished)
442
+ return;
443
+ try {
444
+ resources.remove(browser.id, String(body.connectionId), String(body.viewId), body.subscriptionEpoch);
445
+ }
446
+ catch { /* Connection may already be closed. */ }
447
+ };
448
+ reply.raw.once('close', retire);
449
+ reply.raw.once('finish', () => reply.raw.off('close', retire));
450
+ if (reply.raw.destroyed)
451
+ retire();
452
+ return binding;
453
+ });
454
+ app.delete('/api/v1/resource-subscriptions', async (request) => {
455
+ const browser = auth.authenticate(request, true), body = request.body;
456
+ resources.remove(browser.id, String(body?.connectionId ?? ''), String(body?.viewId ?? ''), body?.subscriptionEpoch);
457
+ return { removed: true };
458
+ });
459
+ // Bound reads return precisely the registered initial capture, never an unrelated GET.
460
+ app.addHook('preHandler', async (request, reply) => {
461
+ if (request.method !== 'GET' || !request.headers['x-resource-connection'])
462
+ return;
463
+ const browser = auth.authenticate(request), headers = request.headers;
464
+ const baseline = await resources.bound(browser.id, String(headers['x-resource-connection']), String(headers['x-resource-view']), Number(headers['x-resource-epoch']), String(headers['x-resource-baseline']));
465
+ const expected = '/api/v1/' + baseline.descriptor.resource;
466
+ if (request.url.split('?')[0] !== expected)
467
+ throw new ResourceBindingError(409, 'resource route mismatch');
468
+ for (const name of ['Connection', 'View', 'Epoch', 'Baseline'])
469
+ reply.header('X-Resource-' + name, String(headers['x-resource-' + name.toLowerCase()]));
470
+ reply.header('X-Resource-Producer', baseline.producerGeneration).header('X-Resource-Revision', baseline.revision).header('X-Resource-Source-Generation', baseline.source.producerGeneration).header('X-Resource-Source-Position', baseline.source.position);
471
+ return reply.send(baseline.payload);
472
+ });
473
+ for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
474
+ app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
475
+ app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
476
+ app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(request.query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids }), new AbortController().signal); });
312
477
  app.get('/api/v1/task-lists', async (request) => {
313
478
  auth.authenticate(request);
314
- return { lists: requireTaskRooms().listTaskLists() };
479
+ return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
315
480
  });
316
481
  app.post('/api/v1/task-lists', async (request, reply) => {
317
482
  auth.authenticate(request, true);
@@ -334,21 +499,7 @@ export async function buildWebServer(services, boundary, options = {}) {
334
499
  });
335
500
  app.get('/api/v1/tasks', async (request) => {
336
501
  auth.authenticate(request);
337
- const query = request.query;
338
- const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
339
- if (query.state && query.state !== 'all' && !states.includes(query.state))
340
- throw new FleetError('invalid_request', 'invalid task state filter');
341
- if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
342
- throw new FleetError('invalid_request', 'groupByList must be true or false');
343
- if (query.includeDeleting !== undefined && !['true', 'false'].includes(query.includeDeleting))
344
- throw new FleetError('invalid_request', 'includeDeleting must be true or false');
345
- const state = query.state && query.state !== 'all' ? query.state : undefined;
346
- const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
347
- ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
348
- const api = requireTaskRooms();
349
- return taskApi(() => query.groupByList === 'true'
350
- ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
351
- : { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
502
+ return projectTasks(request.query, false);
352
503
  });
353
504
  app.delete('/api/v1/tasks/:id', async (request, reply) => {
354
505
  auth.authenticate(request, true);
@@ -558,7 +709,7 @@ export async function buildWebServer(services, boundary, options = {}) {
558
709
  auth.authenticate(request);
559
710
  if (!services.configuration)
560
711
  throw new FleetError('capability_unavailable', 'fleet configuration editing is unavailable');
561
- return services.configuration.read(request.query.includeDefinitions === 'true');
712
+ return project({ viewId: 'read', resource: 'configuration', includeDefinitions: request.query.includeDefinitions === 'true' }, new AbortController().signal);
562
713
  });
563
714
  app.post('/api/v1/configuration/preview', async (request) => {
564
715
  auth.authenticate(request, true);
@@ -188,3 +188,9 @@ explicit recovery and unsupported-host fallback. DOM mutation and fixture latenc
188
188
  measurements have no claim about real-provider inference or physical-device audio.
189
189
  The App and Fleet changes require coordinated upstream delivery; source qualification
190
190
  does not establish that installed or serving packages contain them.
191
+
192
+ ## Full resource delivery qualification
193
+
194
+ Directory, configuration/definitions, previews, provider/status, layout and admitted-operation views share the authenticated ephemeral resource connection with per-view epochs. Complete ready requires the canonical subscribed payload; a heartbeat does not certify a failed upstream watcher. User commands remain HTTP. Controller, voice and selected-daemon health are independent owners. The selected-daemon health deadline passes its cancellation signal through the public SDK selection/request API, cancelling underlying HTTP I/O as well as retiring the result.
195
+
196
+ The matching App source qualification composes the existing ACP/Codex browser fixture and records sixty-second healthy windows separately from initial/history/recovery/command/health/lease/long-poll traffic. Unit and HTTP producer checks are not proof of every rendered resource combination or installed deployment. Preserve existing room-layout suite sources and qualify released service/dependency versions separately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.70",
3
+ "version": "1.2.0-nightly.71",
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",