@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.
- package/README.md +13 -9
- package/dist/application/errors.d.ts +1 -1
- package/dist/application/role-command-service.d.ts +29 -1
- package/dist/application/role-command-service.js +41 -2
- package/dist/application/role-creation-service.d.ts +32 -1
- package/dist/application/role-creation-service.js +56 -10
- package/dist/application/role-removal-service.d.ts +19 -0
- package/dist/application/role-removal-service.js +13 -3
- package/dist/application/session-mutations.d.ts +7 -0
- package/dist/application/session-mutations.js +8 -0
- package/dist/application/task-room-service.d.ts +262 -0
- package/dist/application/task-room-service.js +571 -0
- package/dist/atomic-file.d.ts +5 -3
- package/dist/atomic-file.js +32 -8
- package/dist/build-info.json +4 -4
- package/dist/cli.js +39 -15
- package/dist/config.d.ts +6 -3
- package/dist/config.js +0 -17
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +26 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -4
- package/dist/owner-channel/attachments.js +6 -39
- package/dist/owner-channel/channel.d.ts +5 -0
- package/dist/owner-channel/channel.js +159 -21
- package/dist/owner-channel/commands.d.ts +43 -1
- package/dist/owner-channel/commands.js +137 -130
- package/dist/rooms-tasks/cli.js +301 -463
- package/dist/rooms-tasks/task-lists.d.ts +19 -0
- package/dist/rooms-tasks/task-lists.js +96 -0
- package/dist/rooms-tasks/task-state.d.ts +3 -0
- package/dist/rooms-tasks/task-state.js +281 -175
- package/dist/rooms-tasks/types.d.ts +11 -1
- package/dist/runner.js +10 -33
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +22 -21
- package/dist/watchdog/query.d.ts +2 -0
- package/dist/watchdog/query.js +7 -3
- package/dist/web/runtime.js +2 -0
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +88 -3
- package/package.json +1 -1
package/dist/session/control.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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, {
|
package/dist/watchdog/query.d.ts
CHANGED
|
@@ -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
|
package/dist/watchdog/query.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/web/runtime.js
CHANGED
|
@@ -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)
|
package/dist/web/server.d.ts
CHANGED
|
@@ -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 {
|
package/dist/web/server.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
"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",
|