@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.
Files changed (47) hide show
  1. package/README.md +41 -2
  2. package/dist/application/conversation-history.d.ts +8 -0
  3. package/dist/application/conversation-history.js +37 -2
  4. package/dist/application/task-room-service.d.ts +7 -0
  5. package/dist/application/task-room-service.js +19 -0
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1 -0
  9. package/dist/doctor.js +17 -4
  10. package/dist/notifications/fleet-producer.d.ts +0 -1
  11. package/dist/notifications/fleet-producer.js +65 -47
  12. package/dist/notifications/mission-ownership.d.ts +31 -0
  13. package/dist/notifications/mission-ownership.js +148 -0
  14. package/dist/notifications/outbox.d.ts +10 -0
  15. package/dist/notifications/outbox.js +28 -2
  16. package/dist/notifications/task-producer.d.ts +11 -1
  17. package/dist/notifications/task-producer.js +34 -5
  18. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  19. package/dist/rooms-tasks/layout-service.js +7 -2
  20. package/dist/rooms-tasks/layout.d.ts +5 -1
  21. package/dist/rooms-tasks/layout.js +7 -1
  22. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  23. package/dist/rooms-tasks/notification-manifest.js +65 -0
  24. package/dist/rooms-tasks/task-review.d.ts +2 -0
  25. package/dist/rooms-tasks/task-review.js +7 -5
  26. package/dist/rooms-tasks/task-state.d.ts +35 -0
  27. package/dist/rooms-tasks/task-state.js +151 -17
  28. package/dist/rooms-tasks/types.d.ts +10 -0
  29. package/dist/runtime-security.d.ts +1 -0
  30. package/dist/runtime-security.js +6 -0
  31. package/dist/security-versions.d.ts +20 -0
  32. package/dist/security-versions.js +70 -0
  33. package/dist/session/acp.d.ts +3 -0
  34. package/dist/session/acp.js +58 -1
  35. package/dist/session/codex-app-server.js +22 -1
  36. package/dist/session/conversation-types.d.ts +8 -2
  37. package/dist/session/session-ownership.d.ts +18 -0
  38. package/dist/session/session-ownership.js +74 -0
  39. package/dist/setup-tunnel-preflight.js +10 -3
  40. package/dist/supervisor/manager.js +9 -2
  41. package/dist/web/resource-views.d.ts +2 -0
  42. package/dist/web/resource-views.js +4 -2
  43. package/dist/web/runtime.js +1 -1
  44. package/dist/web/server.js +29 -8
  45. package/dist/web/workspace-tunnel.d.ts +2 -1
  46. package/dist/web/workspace-tunnel.js +29 -3
  47. package/package.json +6 -6
@@ -1,3 +1,4 @@
1
+ import { cloudflaredUnavailableError, cloudflaredVersionError, CLOUDFLARED_VERSION_TIMEOUT_MS, isSecureCloudflaredVersion, isSecureNodeVersion, nodeVersionError } from './security-versions.js';
1
2
  import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
2
3
  import { userInfo } from 'node:os';
3
4
  import { join } from 'node:path';
@@ -10,9 +11,8 @@ const UNCHANGED = 'Nothing was changed and this host was not bound.';
10
11
  * proof is sent: a failure afterwards cannot be retried with a fresh command.
11
12
  */
12
13
  export async function setupTunnelPreflight({ exec = realExec, platform = process.platform, node = process.versions.node } = {}) {
13
- const [major, minor] = node.split('.').map(Number);
14
- if (major < 22 || (major === 22 && minor < 13))
15
- throw Error(`Tunnel setup requires Node.js 22.13 or newer; this is ${node}. Update Node.js, then run a fresh tunnel setup command. ${UNCHANGED}`);
14
+ if (!isSecureNodeVersion(node))
15
+ throw Error(`${nodeVersionError(node)} ${UNCHANGED}`);
16
16
  if (platform === 'linux') {
17
17
  const manager = await exec('systemctl', ['--user', 'show-environment'], { timeout: 10_000 });
18
18
  if (manager.code !== 0)
@@ -20,6 +20,13 @@ export async function setupTunnelPreflight({ exec = realExec, platform = process
20
20
  + `Log in directly as ${userInfo().username} (not through su or sudo), or run: sudo loginctl enable-linger ${userInfo().username} && export XDG_RUNTIME_DIR=/run/user/${process.getuid?.()} `
21
21
  + `Then run a fresh tunnel setup command. ${UNCHANGED}`);
22
22
  }
23
+ // Refuse an unsafe connector before anything is redeemed; the service checks its own
24
+ // PATH-selected cloudflared again before every tunnel start.
25
+ const version = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
26
+ if (version.code !== 0)
27
+ throw Error(`${cloudflaredUnavailableError(version.code === 127 ? 'not found' : 'version check failed')} ${UNCHANGED}`);
28
+ if (!isSecureCloudflaredVersion(version.stdout))
29
+ throw Error(`${cloudflaredVersionError(version.stdout)} ${UNCHANGED}`);
23
30
  const help = await exec('cloudflared', ['tunnel', 'run', '--help'], { timeout: 10_000 });
24
31
  if (help.code === 127)
25
32
  throw Error(`cloudflared is not installed or not on PATH. Install it, then run a fresh tunnel setup command. ${UNCHANGED}`);
@@ -160,8 +160,15 @@ export async function runFleetManager(entrypoint, deps = {}) {
160
160
  try {
161
161
  try {
162
162
  const config = producerConfig(process.env, undefined, log);
163
- if (config)
164
- notifications = new TaskNotificationProducer(config, log);
163
+ if (config) {
164
+ const [{ listTasks }, { TaskLayouts }] = await Promise.all([import('../rooms-tasks/task-state.js'), import('../application/task-layouts.js')]);
165
+ const layouts = new TaskLayouts();
166
+ notifications = new TaskNotificationProducer(config, log, fetch, {
167
+ // Unreadable task or layout evidence is not an empty inventory: it throws and the bootstrap run stays incomplete.
168
+ tasks: () => listTasks({ strict: true }),
169
+ layoutRooms: task => (layouts.view(task)?.rooms ?? []).flatMap(room => room.identity_cid ? [room.identity_cid] : []),
170
+ });
171
+ }
165
172
  }
166
173
  catch {
167
174
  log('[fleet] task notifications unavailable; committed events retained');
@@ -91,6 +91,8 @@ export interface ResourceDescriptor {
91
91
  list?: string;
92
92
  groupByList?: boolean;
93
93
  includeDeleting?: boolean;
94
+ /** Previews: each agent's newest assistant message time only, without its text. */
95
+ activityOnly?: boolean;
94
96
  }
95
97
  export declare function canonicalResourceDescriptor(input: unknown): ResourceDescriptor;
96
98
  interface BoundProjection {
@@ -156,9 +156,11 @@ export function canonicalResourceDescriptor(input) {
156
156
  throw Error('invalid resource descriptor');
157
157
  const value = input;
158
158
  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)
159
- || Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider'].includes(key)))
159
+ || Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider', 'activityOnly'].includes(key)))
160
160
  throw Error('invalid resource descriptor');
161
- for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting'])
161
+ if (value.activityOnly !== undefined && value.resource !== 'previews')
162
+ throw Error('invalid resource descriptor');
163
+ for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting', 'activityOnly'])
162
164
  if (value[key] !== undefined && typeof value[key] !== 'boolean')
163
165
  throw Error('invalid resource descriptor');
164
166
  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))))
@@ -310,7 +310,7 @@ export async function startWebConsole(options) {
310
310
  }
311
311
  let stopTunnel;
312
312
  try {
313
- stopTunnel = startWorkspaceTunnel(browserOrigin);
313
+ stopTunnel = await startWorkspaceTunnel(browserOrigin);
314
314
  }
315
315
  catch (error) {
316
316
  try {
@@ -2,7 +2,7 @@ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor }
2
2
  import { publishFleetResourceCommit } from '../application/resource-observer.js';
3
3
  import { stateRoot, defaultConfigPath } from '../paths.js';
4
4
  import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
5
- import { conversationPreview } from '../application/conversation-history.js';
5
+ import { conversationActivity, conversationPreview } from '../application/conversation-history.js';
6
6
  import { registerConversationStream } from './conversation-stream.js';
7
7
  import { conversationHasAttachment } from '../application/conversation-history.js';
8
8
  import { readDeliveredFile } from '../file-delivery/store.js';
@@ -26,7 +26,7 @@ import { WebAuth } from './auth.js';
26
26
  import { FleetEventBus } from './events.js';
27
27
  import { ROLE_NAME_RE } from '../config.js';
28
28
  import { TaskListError } from '../rooms-tasks/task-lists.js';
29
- import { TaskStateError } from '../rooms-tasks/task-state.js';
29
+ import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, TaskStateError } from '../rooms-tasks/task-state.js';
30
30
  import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
31
31
  import { isProvider } from '../subscriptions/store.js';
32
32
  const ONBOARDING_WORK = ['coordination', 'development', 'review'];
@@ -371,9 +371,12 @@ export async function buildWebServer(services, boundary, options = {}) {
371
371
  const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
372
372
  ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
373
373
  const api = requireTaskRooms();
374
+ // Navigation totals cover every task in scope, not only the filtered or visible cards.
375
+ const unreadTotal = () => api.listTasks({ strict, ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) })
376
+ .reduce((sum, task) => { const unread = contributionUnread(task); return sum + unread.progress + (unread.review ? 1 : 0); }, 0);
374
377
  return taskApi(() => query.groupByList === 'true'
375
- ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
376
- : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
378
+ ? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })), contribution_unread_total: unreadTotal() }
379
+ : { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))), contribution_unread_total: unreadTotal() });
377
380
  };
378
381
  const project = async (descriptor, signal, owner = '') => {
379
382
  signal.throwIfAborted();
@@ -426,7 +429,8 @@ export async function buildWebServer(services, boundary, options = {}) {
426
429
  return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
427
430
  case 'directory': {
428
431
  const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
429
- return { roles, tasks: tasks.tasks, lists: requireTaskRooms().listTaskLists() };
432
+ const { tasks: rows, contribution_unread_total } = tasks;
433
+ return { roles, tasks: rows, contribution_unread_total, lists: requireTaskRooms().listTaskLists() };
430
434
  }
431
435
  case 'previews': {
432
436
  const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
@@ -437,14 +441,15 @@ export async function buildWebServer(services, boundary, options = {}) {
437
441
  if (!role)
438
442
  continue;
439
443
  try {
440
- previews[id] = dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
444
+ previews[id] = descriptor.activityOnly ? (dir ? await conversationActivity(dir, signal) : { at: '' })
445
+ : dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
441
446
  }
442
447
  catch {
443
448
  signal.throwIfAborted();
444
449
  // A damaged/unreadable ledger is unavailable, not empty or removed.
445
450
  // Keep this selected row observable so a repair can refresh it,
446
451
  // without retiring previews for the other selected roles.
447
- previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
452
+ previews[id] = descriptor.activityOnly ? { at: '', unavailable: true } : { text: 'Preview unavailable', at: '', unavailable: true };
448
453
  }
449
454
  }
450
455
  return { previews };
@@ -505,7 +510,7 @@ export async function buildWebServer(services, boundary, options = {}) {
505
510
  for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
506
511
  app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
507
512
  app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
508
- 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); });
513
+ app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const query = request.query, ids = String(query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids, ...(query.activityOnly === 'true' ? { activityOnly: true } : {}) }), new AbortController().signal); });
509
514
  app.get('/api/v1/task-lists', async (request) => {
510
515
  auth.authenticate(request);
511
516
  return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
@@ -619,6 +624,22 @@ export async function buildWebServer(services, boundary, options = {}) {
619
624
  reply.code(201);
620
625
  return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
621
626
  });
627
+ /** Acknowledge Progress entries the browser actually showed; returns the task's remaining unread. */
628
+ app.post('/api/v1/tasks/:id/progress/read', async (request) => {
629
+ auth.authenticate(request, true);
630
+ const ids = request.body?.ids;
631
+ if (!Array.isArray(ids) || ids.length > 200 || ids.some(id => typeof id !== 'string' || id.length > 64))
632
+ throw new FleetError('invalid_request', 'ids must be at most 200 progress entry IDs');
633
+ return { unread: await reviewApi(() => acknowledgeTaskProgress(request.params.id, ids)) };
634
+ });
635
+ /** Acknowledge the To review revision the browser fetched and rendered. */
636
+ app.post('/api/v1/tasks/:id/review/read', async (request) => {
637
+ auth.authenticate(request, true);
638
+ const revision = request.body?.revision;
639
+ if (!Number.isSafeInteger(revision) || revision < 0)
640
+ throw new FleetError('invalid_request', 'revision must be a non-negative integer');
641
+ return { unread: await reviewApi(() => acknowledgeTaskReview(request.params.id, revision)) };
642
+ });
622
643
  app.get('/api/v1/tasks/:id/review', async (request) => {
623
644
  auth.authenticate(request);
624
645
  const review = await reviewApi(() => getTaskReview(request.params.id));
@@ -1 +1,2 @@
1
- export declare function startWorkspaceTunnel(origin: string): () => Promise<void>;
1
+ import { type Exec } from '../exec.js';
2
+ export declare function startWorkspaceTunnel(origin: string, exec?: Exec): Promise<() => Promise<void>>;
@@ -1,8 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { realExec } from '../exec.js';
5
+ import { assertSecureCloudflaredVersion, cloudflaredUnavailableError, CLOUDFLARED_VERSION_TIMEOUT_MS } from '../security-versions.js';
4
6
  import { stateRoot } from '../paths.js';
5
- export function startWorkspaceTunnel(origin) {
7
+ export async function startWorkspaceTunnel(origin, exec = realExec) {
6
8
  const dir = join(stateRoot(), 'workspace'), file = join(dir, 'tunnel.json');
7
9
  if (!existsSync(file))
8
10
  return async () => { };
@@ -15,7 +17,31 @@ export function startWorkspaceTunnel(origin) {
15
17
  const stat = lstatSync(tokenFile);
16
18
  if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())
17
19
  throw Error('Connector credential must be an owned private file');
18
- let stopped = false, child, timer, backoff = 1000;
20
+ // The connector is the executable named cloudflared on this process's PATH, the
21
+ // same lookup spawn uses. Its version is verified before the first start and
22
+ // before every reconnect, so a downgrade is refused and an upgrade is picked up.
23
+ const verify = async () => {
24
+ const result = await exec('cloudflared', ['--version'], { timeout: CLOUDFLARED_VERSION_TIMEOUT_MS });
25
+ if (result.code !== 0)
26
+ throw Error(cloudflaredUnavailableError(result.code === 127 ? 'not found' : 'version check failed'));
27
+ assertSecureCloudflaredVersion(result.stdout);
28
+ };
29
+ await verify();
30
+ let stopped = false, child, timer, backoff = 1000, reported = '';
31
+ const schedule = () => { if (stopped)
32
+ return; timer = setTimeout(reconnect, backoff); backoff = Math.min(30000, backoff * 2); };
33
+ // A refused or unverifiable connector is never started; retry after the backoff.
34
+ const reconnect = () => {
35
+ timer = undefined;
36
+ void verify().then(() => { reported = ''; launch(); }, (error) => {
37
+ if (stopped)
38
+ return;
39
+ if (error.message !== reported)
40
+ console.error(`Workspace tunnel not restarted: ${error.message}`);
41
+ reported = error.message;
42
+ schedule();
43
+ });
44
+ };
19
45
  const launch = () => {
20
46
  if (stopped)
21
47
  return;
@@ -23,7 +49,7 @@ export function startWorkspaceTunnel(origin) {
23
49
  let scheduled = false;
24
50
  // Keep an error listener for the child's whole lifetime, including shutdown.
25
51
  const retry = () => { if (scheduled || stopped)
26
- return; scheduled = true; timer = setTimeout(launch, backoff); backoff = Math.min(30000, backoff * 2); };
52
+ return; scheduled = true; schedule(); };
27
53
  child.on('error', retry);
28
54
  child.once('exit', retry);
29
55
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
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",
@@ -30,7 +30,7 @@
30
30
  "docs/task-progress-review.md"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=22.13.0"
33
+ "node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1 <27"
34
34
  },
35
35
  "scripts": {
36
36
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -49,9 +49,9 @@
49
49
  "dependencies": {
50
50
  "@agentclientprotocol/sdk": "^1.3.0",
51
51
  "@fastify/websocket": "^11.2.0",
52
- "@ours.network/cli": "4.0.0",
53
- "@ours.network/mcp": "2.0.0",
54
- "@ours.network/sdk": "5.0.0",
52
+ "@ours.network/cli": "4.0.1",
53
+ "@ours.network/mcp": "2.0.1",
54
+ "@ours.network/sdk": "5.0.1",
55
55
  "commander": "^12.1.0",
56
56
  "fastify": "^5.4.0",
57
57
  "qrcode": "1.5.4",
@@ -65,7 +65,7 @@
65
65
  "@agentclientprotocol/codex-acp": "1.10.0"
66
66
  },
67
67
  "devDependencies": {
68
- "@ours.network/daemon": "5.0.0",
68
+ "@ours.network/daemon": "5.0.1",
69
69
  "@types/node": "^22.20.4",
70
70
  "@types/qrcode": "^1.5.6",
71
71
  "@types/ws": "^8.18.1",