@ours.network/fleet 1.0.3 → 1.0.5

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 (43) hide show
  1. package/README.md +13 -9
  2. package/dist/application/errors.d.ts +1 -1
  3. package/dist/application/role-command-service.d.ts +29 -1
  4. package/dist/application/role-command-service.js +41 -2
  5. package/dist/application/role-creation-service.d.ts +32 -1
  6. package/dist/application/role-creation-service.js +56 -10
  7. package/dist/application/role-removal-service.d.ts +19 -0
  8. package/dist/application/role-removal-service.js +13 -3
  9. package/dist/application/session-mutations.d.ts +7 -0
  10. package/dist/application/session-mutations.js +8 -0
  11. package/dist/application/task-room-service.d.ts +262 -0
  12. package/dist/application/task-room-service.js +571 -0
  13. package/dist/atomic-file.d.ts +5 -3
  14. package/dist/atomic-file.js +32 -8
  15. package/dist/build-info.json +4 -4
  16. package/dist/cli.js +39 -15
  17. package/dist/config.d.ts +6 -3
  18. package/dist/config.js +0 -17
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +26 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/owner-channel/attachments.d.ts +2 -4
  24. package/dist/owner-channel/attachments.js +6 -39
  25. package/dist/owner-channel/channel.d.ts +5 -0
  26. package/dist/owner-channel/channel.js +159 -21
  27. package/dist/owner-channel/commands.d.ts +43 -1
  28. package/dist/owner-channel/commands.js +137 -130
  29. package/dist/rooms-tasks/cli.js +301 -463
  30. package/dist/rooms-tasks/task-lists.d.ts +19 -0
  31. package/dist/rooms-tasks/task-lists.js +96 -0
  32. package/dist/rooms-tasks/task-state.d.ts +3 -0
  33. package/dist/rooms-tasks/task-state.js +281 -175
  34. package/dist/rooms-tasks/types.d.ts +11 -1
  35. package/dist/runner.js +10 -33
  36. package/dist/session/control.d.ts +10 -1
  37. package/dist/session/control.js +22 -21
  38. package/dist/watchdog/query.d.ts +2 -0
  39. package/dist/watchdog/query.js +7 -3
  40. package/dist/web/runtime.js +2 -0
  41. package/dist/web/server.d.ts +2 -0
  42. package/dist/web/server.js +88 -3
  43. package/package.json +1 -1
@@ -2,13 +2,26 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { createConnection, createServer } from 'node:net';
4
4
  import { join } from 'node:path';
5
- import { SessionControlError, interruptOutcome } from './types.js';
5
+ import { SessionControlError } from './types.js';
6
+ import { interruptSession, queueSessionPrompt, respondSessionPermission, respondSessionPermissionV2, } from '../application/session-mutations.js';
6
7
  const MAX_LINE_BYTES = 64 * 1024;
7
8
  /** Commands that require protocol version 3. */
8
9
  const V3_COMMANDS = new Set([
9
10
  'conversation_page', 'conversation_follow', 'submit_prompt_v2', 'interrupt_v2',
10
11
  'respond_permission_v2',
11
12
  ]);
13
+ /** The one retained-range projection shared by polling and live-follow admission. */
14
+ export function retainedEventPage(session, since) {
15
+ const events = session.eventsSince(since);
16
+ const all = session.eventsSince(0);
17
+ return {
18
+ events,
19
+ snapshot: session.snapshot(),
20
+ firstSeq: all[0]?.seq ?? 0,
21
+ lastSeq: all.at(-1)?.seq ?? 0,
22
+ truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
23
+ };
24
+ }
12
25
  /**
13
26
  * One line saying what a control failure does — and does not — prove about the
14
27
  * agent. Only `offline` is evidence that it is gone; every other kind used to
@@ -230,7 +243,7 @@ export class RoleControlServer {
230
243
  // Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
231
244
  // for minutes; blocking here made every `send` into a busy agent time
232
245
  // out, and the timeout was then reported as a dead agent.
233
- const queued = await this.session.queuePrompt(request.text, {
246
+ const queued = await queueSessionPrompt(this.session, request.text, {
234
247
  origin: { kind: 'local-console' },
235
248
  });
236
249
  this.write(socket, {
@@ -244,7 +257,7 @@ export class RoleControlServer {
244
257
  case 'respond_permission': {
245
258
  if (!request.permissionId || !request.optionId)
246
259
  throw new SessionControlError('rejected', 'permissionId and optionId are required');
247
- const accepted = this.session.respondPermission(request.permissionId, request.optionId);
260
+ const accepted = respondSessionPermission(this.session, request.permissionId, request.optionId);
248
261
  this.write(socket, {
249
262
  version: 1, id: request.id, ok: accepted,
250
263
  result: { accepted },
@@ -256,7 +269,7 @@ export class RoleControlServer {
256
269
  case 'interrupt': {
257
270
  // Forced recovery cancelled the turn just as surely as a cooperative
258
271
  // stop did. Report HOW, never as a failed operation.
259
- const outcome = interruptOutcome(await this.session.interrupt('local-console'));
272
+ const outcome = await interruptSession(this.session, 'local-console');
260
273
  this.write(socket, { version: 1, id: request.id, ok: true, result: outcome });
261
274
  return;
262
275
  }
@@ -309,29 +322,17 @@ export class RoleControlServer {
309
322
  }
310
323
  case 'events_since': {
311
324
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
312
- const events = this.session.eventsSince(since);
313
- const all = this.session.eventsSince(0);
314
325
  this.write(socket, {
315
326
  version: 1, id: request.id, ok: true,
316
- result: {
317
- events, snapshot: this.session.snapshot(),
318
- firstSeq: all[0]?.seq ?? 0, lastSeq: all.at(-1)?.seq ?? 0,
319
- truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
320
- },
327
+ result: retainedEventPage(this.session, since),
321
328
  });
322
329
  return;
323
330
  }
324
331
  case 'follow': {
325
332
  const since = Number.isFinite(request.since) ? Number(request.since) : 0;
326
- const events = this.session.eventsSince(since);
327
- const all = this.session.eventsSince(0);
328
333
  this.write(socket, {
329
334
  version: 1, id: request.id, ok: true,
330
- result: {
331
- events, snapshot: this.session.snapshot(),
332
- firstSeq: all[0]?.seq ?? 0, lastSeq: all.at(-1)?.seq ?? 0,
333
- truncated: Boolean(all[0] && since > 0 && since < all[0].seq - 1),
334
- },
335
+ result: retainedEventPage(this.session, since),
335
336
  });
336
337
  const controller = request.controller !== false;
337
338
  if (controller)
@@ -399,7 +400,7 @@ export class RoleControlServer {
399
400
  this.write(socket, { version: 1, id: request.id, ok: true, result: existing });
400
401
  return;
401
402
  }
402
- const outcome = interruptOutcome(await this.session.interrupt('local-console'));
403
+ const outcome = await interruptSession(this.session, 'local-console');
403
404
  const receipt = {
404
405
  accepted: true, commandId: request.commandId, at: new Date().toISOString(),
405
406
  ...outcome,
@@ -417,9 +418,9 @@ export class RoleControlServer {
417
418
  if (!request.commandId?.trim() || !request.permissionId?.trim()
418
419
  || !request.optionId?.trim() || !request.sessionGeneration?.trim())
419
420
  throw new SessionControlError('rejected', 'commandId, permissionId, optionId and sessionGeneration are required');
420
- if (!this.session.respondPermissionV2)
421
+ const result = respondSessionPermissionV2(this.session, request.permissionId, request.optionId, request.sessionGeneration);
422
+ if (result === 'unavailable')
421
423
  throw new SessionControlError('rejected', 'generation-bound permission responses are unavailable for this role');
422
- const result = this.session.respondPermissionV2(request.permissionId, request.optionId, request.sessionGeneration);
423
424
  if (result === 'stale')
424
425
  throw new SessionControlError('rejected', 'stale_state: permission is settled, expired, invalid, or belongs to another session generation');
425
426
  this.write(socket, {
@@ -6,6 +6,8 @@ export interface WatchdogRoleFinding {
6
6
  status: WatchdogRoleStatus;
7
7
  reason: string;
8
8
  }
9
+ /** Shared configured-or-surviving-history addressability rule. */
10
+ export declare function watchdogAddressable(name: string, configured: readonly string[], historyExists?: (validName: string) => boolean): boolean;
9
11
  /**
10
12
  * Needs-attention integration: worst current finding per role across
11
13
  * every configured watchdog, for FleetQueryService.status() to fold into a
@@ -6,6 +6,12 @@ import { watchdogsRoot } from '../paths.js';
6
6
  import { WATCHDOG_STATUS_RANK } from './alerts.js';
7
7
  import { readSchedulerState } from './scheduler.js';
8
8
  import { listRuns, readReport } from './store.js';
9
+ /** Shared configured-or-surviving-history addressability rule. */
10
+ export function watchdogAddressable(name, configured, historyExists = validName => existsSync(join(watchdogsRoot(), validName))) {
11
+ if (configured.includes(name))
12
+ return true;
13
+ return ROLE_NAME_RE.test(name) && historyExists(name);
14
+ }
9
15
  /**
10
16
  * Needs-attention integration: worst current finding per role across
11
17
  * every configured watchdog, for FleetQueryService.status() to fold into a
@@ -115,9 +121,7 @@ export class WatchdogQueryService {
115
121
  */
116
122
  requireKnown(name) {
117
123
  const cfg = this.cfgProvider();
118
- if (cfg.watchdogs.some(wd => wd.name === name))
119
- return;
120
- if (ROLE_NAME_RE.test(name) && existsSync(join(watchdogsRoot(), name)))
124
+ if (watchdogAddressable(name, cfg.watchdogs.map(wd => wd.name)))
121
125
  return;
122
126
  throw new FleetError('role_not_found', `no such watchdog '${name}'`);
123
127
  }
@@ -31,6 +31,7 @@ import { startWebControlServer } from './control.js';
31
31
  import { WebAccessStore, validatePublicOrigin } from './access.js';
32
32
  import { buildWatchdogFindings, cachedWatchdogFindingsProvider, WatchdogQueryService } from '../watchdog/query.js';
33
33
  import { latestReport } from '../watchdog/store.js';
34
+ import { TaskRoomApplicationService } from '../application/task-room-service.js';
34
35
  const CONFIG_CACHE_TTL_MS = 5_000;
35
36
  /**
36
37
  * loadConfig re-parses YAML from disk on every call; the watchdog list/reports
@@ -159,6 +160,7 @@ export async function startWebConsole(options) {
159
160
  try {
160
161
  server = await buildWebServer({
161
162
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
163
+ taskRooms: new TaskRoomApplicationService(options.configPath),
162
164
  topology: readTopology, topologyDrafts, topologyPromote,
163
165
  terminalUpgrade: terminalAvailable
164
166
  ? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
@@ -15,6 +15,7 @@ import type { MergedTopology } from './topology-model.js';
15
15
  import type { TopologyDraftStore } from './topology-draft-store.js';
16
16
  import type { TopologyPromoteService } from './topology-promote.js';
17
17
  import type { RoleRemovalService } from '../application/role-removal-service.js';
18
+ import type { TaskRoomApplicationService } from '../application/task-room-service.js';
18
19
  export interface WebServices {
19
20
  query: FleetQueryService;
20
21
  repository: RoleRepository;
@@ -30,6 +31,7 @@ export interface WebServices {
30
31
  topologyDrafts?: TopologyDraftStore;
31
32
  topologyPromote?: TopologyPromoteService;
32
33
  removal?: RoleRemovalService;
34
+ taskRooms?: TaskRoomApplicationService;
33
35
  terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
34
36
  }
35
37
  export interface WebServer {
@@ -11,8 +11,10 @@ import { AuditSink } from './audit.js';
11
11
  import { WebAuth } from './auth.js';
12
12
  import { FleetEventBus } from './events.js';
13
13
  import { ROLE_NAME_RE } from '../config.js';
14
+ import { TaskListError } from '../rooms-tasks/task-lists.js';
15
+ import { TaskStateError } from '../rooms-tasks/task-state.js';
14
16
  const statusFor = (code) => ({
15
- role_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
17
+ role_not_found: 404, resource_not_found: 404, unauthorized: 401, forbidden: 403, conflict: 409,
16
18
  idempotency_conflict: 409, stale_state: 409, rate_limited: 429,
17
19
  invalid_request: 400, capability_unavailable: 409, prerequisite_unavailable: 503,
18
20
  }[code] ?? 500);
@@ -128,6 +130,89 @@ export async function buildWebServer(services, boundary, options = {}) {
128
130
  auth.authenticate(request);
129
131
  return { roles: await services.query.list() };
130
132
  });
133
+ const taskApi = async (fn) => {
134
+ try {
135
+ return await fn();
136
+ }
137
+ catch (error) {
138
+ if (error instanceof TaskListError) {
139
+ const code = error.code === 'list_not_found' ? 'resource_not_found'
140
+ : ['duplicate_name', 'destination_required', 'same_destination', 'default_immutable'].includes(error.code)
141
+ ? 'conflict' : 'invalid_request';
142
+ throw new FleetError(code, error.message);
143
+ }
144
+ if (error instanceof TaskStateError) {
145
+ throw new FleetError(error.message.startsWith('task not found:') ? 'resource_not_found' : 'conflict', error.message);
146
+ }
147
+ throw error;
148
+ }
149
+ };
150
+ const requireTaskRooms = () => {
151
+ if (!services.taskRooms)
152
+ throw new FleetError('capability_unavailable', 'task operations are unavailable');
153
+ return services.taskRooms;
154
+ };
155
+ app.get('/api/v1/task-lists', async (request) => {
156
+ auth.authenticate(request);
157
+ return { lists: requireTaskRooms().listTaskLists() };
158
+ });
159
+ app.post('/api/v1/task-lists', async (request, reply) => {
160
+ auth.authenticate(request, true);
161
+ const name = String(request.body?.name ?? '');
162
+ const list = await taskApi(() => requireTaskRooms().createTaskList({ actor: { kind: 'local_control', surface: 'web' }, name }));
163
+ reply.code(201);
164
+ return { list };
165
+ });
166
+ app.patch('/api/v1/task-lists/:name', async (request) => {
167
+ auth.authenticate(request, true);
168
+ const name = request.params.name;
169
+ const newName = String(request.body?.name ?? '');
170
+ return { list: await taskApi(() => requireTaskRooms().renameTaskList({ actor: { kind: 'local_control', surface: 'web' }, name, newName })) };
171
+ });
172
+ app.delete('/api/v1/task-lists/:name', async (request) => {
173
+ auth.authenticate(request, true);
174
+ const name = request.params.name;
175
+ const destination = request.query.destination;
176
+ return taskApi(() => requireTaskRooms().deleteTaskList({ actor: { kind: 'local_control', surface: 'web' }, name, destination }));
177
+ });
178
+ app.get('/api/v1/tasks', async (request) => {
179
+ auth.authenticate(request);
180
+ const query = request.query;
181
+ const states = ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'];
182
+ if (query.state && query.state !== 'all' && !states.includes(query.state))
183
+ throw new FleetError('invalid_request', 'invalid task state filter');
184
+ if (query.groupByList !== undefined && !['true', 'false'].includes(query.groupByList))
185
+ throw new FleetError('invalid_request', 'groupByList must be true or false');
186
+ const state = query.state && query.state !== 'all' ? query.state : undefined;
187
+ const filter = { ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}) };
188
+ return taskApi(() => query.groupByList === 'true'
189
+ ? { groups: requireTaskRooms().groupedTasks(filter) }
190
+ : { tasks: requireTaskRooms().listTasks(filter) });
191
+ });
192
+ app.post('/api/v1/tasks', async (request, reply) => {
193
+ auth.authenticate(request, true);
194
+ const body = request.body;
195
+ if (typeof body?.title !== 'string' || !body.title)
196
+ throw new FleetError('invalid_request', 'title is required');
197
+ const task = await taskApi(() => requireTaskRooms().createTask({
198
+ actor: { kind: 'local_control', surface: 'web' }, title: body.title,
199
+ brief: typeof body.brief === 'string' ? body.brief : undefined,
200
+ template: typeof body.template === 'string' ? body.template : undefined,
201
+ backlog: body.backlog === true, noRoom: body.noRoom === true,
202
+ list: typeof body.list === 'string' ? body.list : undefined,
203
+ idempotencyKey: typeof request.headers['idempotency-key'] === 'string'
204
+ ? request.headers['idempotency-key'] : undefined,
205
+ origin: { type: 'web' },
206
+ }));
207
+ reply.code(201);
208
+ return { task };
209
+ });
210
+ app.patch('/api/v1/tasks/:id/list', async (request) => {
211
+ auth.authenticate(request, true);
212
+ const taskId = request.params.id;
213
+ const list = String(request.body?.list ?? '');
214
+ return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
215
+ });
131
216
  app.get('/api/v1/configuration', async (request) => {
132
217
  auth.authenticate(request);
133
218
  if (!services.configuration)
@@ -209,7 +294,7 @@ export async function buildWebServer(services, boundary, options = {}) {
209
294
  throw new FleetError('invalid_request', 'invalid role name');
210
295
  if (!services.removal)
211
296
  throw new FleetError('capability_unavailable', 'role removal is unavailable');
212
- return services.removal.preview(request.params.id);
297
+ return services.removal.previewWeb(request.params.id);
213
298
  });
214
299
  app.post('/api/v1/roles/:id/remove', async (request) => {
215
300
  const session = auth.authenticate(request, true);
@@ -218,7 +303,7 @@ export async function buildWebServer(services, boundary, options = {}) {
218
303
  if (!services.removal)
219
304
  throw new FleetError('capability_unavailable', 'role removal is unavailable');
220
305
  const body = request.body;
221
- const result = await services.removal.remove({ role: request.params.id, ...body });
306
+ const result = await services.removal.removeWeb({ role: request.params.id, ...body });
222
307
  events.publish('role.removed', { role: result.role }, result.role);
223
308
  await audit.record({ requestId: request.id, browser: session.id, roleId: result.role, action: 'role.remove', result: 'succeeded' });
224
309
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",