@ours.network/fleet 1.2.0-nightly.19 → 1.2.0-nightly.20
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 +10 -1
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/runtime.d.ts +6 -0
- package/dist/agent-ours/runtime.js +36 -0
- package/dist/agent-ours/service.js +2 -1
- package/dist/application/conversation-history.d.ts +6 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +8 -0
- package/dist/application/session-control.js +21 -3
- package/dist/application/task-layouts.d.ts +89 -0
- package/dist/application/task-layouts.js +248 -0
- package/dist/application/task-room-service.d.ts +82 -0
- package/dist/application/task-room-service.js +197 -9
- package/dist/build-info.json +4 -4
- package/dist/cli.js +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/init-wizard.js +4 -2
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/cli.js +20 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -3
- package/dist/rooms-tasks/layout-cli.d.ts +2 -0
- package/dist/rooms-tasks/layout-cli.js +81 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +39 -0
- package/dist/rooms-tasks/layout-service.js +173 -0
- package/dist/rooms-tasks/layout.d.ts +102 -0
- package/dist/rooms-tasks/layout.js +227 -0
- package/dist/rooms-tasks/live-readiness.js +2 -1
- package/dist/rooms-tasks/provision.d.ts +3 -0
- package/dist/rooms-tasks/provision.js +44 -22
- package/dist/rooms-tasks/room-state.d.ts +1 -1
- package/dist/rooms-tasks/room-state.js +3 -1
- package/dist/rooms-tasks/task-state.d.ts +19 -0
- package/dist/rooms-tasks/task-state.js +58 -6
- package/dist/rooms-tasks/types.d.ts +14 -0
- package/dist/runner.d.ts +7 -2
- package/dist/runner.js +52 -4
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +7 -0
- package/dist/session/arbiter.d.ts +3 -0
- package/dist/session/arbiter.js +20 -2
- package/dist/session/codex-app-server.d.ts +3 -0
- package/dist/session/codex-app-server.js +25 -1
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +36 -2
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-types.d.ts +5 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +1 -1
- package/dist/temp-lifecycle.js +1 -1
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +4 -1
- package/dist/web/auth.js +23 -2
- package/dist/web/control.js +2 -1
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +20 -0
- package/dist/web/prefix-gateway.js +150 -0
- package/dist/web/runtime.d.ts +4 -0
- package/dist/web/runtime.js +19 -14
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.js +275 -24
- package/docs/room-layouts.md +92 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +14 -13
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
package/README.md
CHANGED
|
@@ -139,7 +139,11 @@ the caller's owner channel with the caller and spawned-role details. This is an
|
|
|
139
139
|
honest-actor convenience and attribution path, not a security boundary; host
|
|
140
140
|
shells and deliberately bypassed absolute binaries retain direct behavior.
|
|
141
141
|
|
|
142
|
-
## Local web
|
|
142
|
+
## Local web API
|
|
143
|
+
|
|
144
|
+
Frontend source and builds are maintained in the standalone `ours-web` repository. Fleet builds only its backend API. Configure a separately built frontend static root when serving the unified application through the gateway. The former console source is preserved there under `legacy-fleet/`.
|
|
145
|
+
|
|
146
|
+
### Console API usage
|
|
143
147
|
|
|
144
148
|
The interactive console is packaged with `@ours.network/fleet` and binds to
|
|
145
149
|
IPv4 loopback by default. Remote or proxy exposure is always explicit:
|
|
@@ -1811,3 +1815,8 @@ recreate identities, redeem invites, or rewrite lifecycle state. Temporary room
|
|
|
1811
1815
|
remain transient across host reboot; retained launch records do not restore them.
|
|
1812
1816
|
|
|
1813
1817
|
See [predefined Brain models and runtime requirements](presets/README.md) for the verified model/effort matrix and regeneration instructions.
|
|
1818
|
+
|
|
1819
|
+
### YAML room layouts
|
|
1820
|
+
|
|
1821
|
+
Room layouts describe room composition with shared or fresh participant sessions and independent room closure.
|
|
1822
|
+
See [Room layouts](docs/room-layouts.md) for YAML examples and CLI commands.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Read-only operations exposed by the identity-owning supervisor. */
|
|
2
|
+
export interface AgentHistoryQuery {
|
|
3
|
+
peer_cid: string;
|
|
4
|
+
limit?: number;
|
|
5
|
+
before_seq?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SupervisorCorrespondence {
|
|
8
|
+
contacts(): Promise<unknown>;
|
|
9
|
+
history(request: AgentHistoryQuery): Promise<unknown>;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentHistoryQuery } from './correspondence.js';
|
|
1
2
|
import type { OursClient } from '@ours.network/sdk/client';
|
|
2
3
|
import { RuntimeJournal, type RuntimeState } from './state.js';
|
|
3
4
|
export interface AssignedIdentity {
|
|
@@ -47,6 +48,11 @@ export declare class AgentOursRuntime {
|
|
|
47
48
|
prepare(room?: RoomAdmission, timeoutMs?: number): Promise<void>;
|
|
48
49
|
/** Admission covers SDK calls, response rendering and agent filesystem callbacks. */
|
|
49
50
|
admit(): Promise<() => void>;
|
|
51
|
+
/** Trusted supervisor-only admission for a later room; keeps the live harness
|
|
52
|
+
* and original startup assignment. Not exposed as an agent lifecycle tool. */
|
|
53
|
+
joinAdditionalRoom(invite: string, expectedRoomCid: string): Promise<void>;
|
|
54
|
+
readContacts(): Promise<unknown>;
|
|
55
|
+
readHistory(request: AgentHistoryQuery): Promise<unknown>;
|
|
50
56
|
startHarness<T>(start: () => Promise<T>): Promise<T>;
|
|
51
57
|
suspend(): Promise<void>;
|
|
52
58
|
terminal(): Promise<void>;
|
|
@@ -201,6 +201,42 @@ export class AgentOursRuntime {
|
|
|
201
201
|
throw error;
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
/** Trusted supervisor-only admission for a later room; keeps the live harness
|
|
205
|
+
* and original startup assignment. Not exposed as an agent lifecycle tool. */
|
|
206
|
+
async joinAdditionalRoom(invite, expectedRoomCid) {
|
|
207
|
+
const release = await this.admit();
|
|
208
|
+
try {
|
|
209
|
+
const contact = await this.deps.client.addContact({ invite });
|
|
210
|
+
if (contact.cid !== expectedRoomCid)
|
|
211
|
+
throw Error('ADDITIONAL_ROOM_CID_MISMATCH');
|
|
212
|
+
await this.verify();
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
release();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async readContacts() {
|
|
219
|
+
const release = await this.admit();
|
|
220
|
+
try {
|
|
221
|
+
const data = await this.deps.client.listContacts();
|
|
222
|
+
await this.verify();
|
|
223
|
+
return { identity: { name: this.state.name, cid: this.state.cid, generation: this.state.generation }, ...data };
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
release();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async readHistory(request) {
|
|
230
|
+
const release = await this.admit();
|
|
231
|
+
try {
|
|
232
|
+
const data = await this.deps.client.listHistory(request);
|
|
233
|
+
await this.verify();
|
|
234
|
+
return { identity: { name: this.state.name, cid: this.state.cid, generation: this.state.generation }, ...data };
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
release();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
204
240
|
async startHarness(start) {
|
|
205
241
|
if (!this.accepting || this.state.phase !== 'READY')
|
|
206
242
|
throw Error('RUNTIME_NOT_READY');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { socketPath as privateSocketPath } from '../socket-path.js';
|
|
1
2
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
@@ -207,7 +208,7 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
|
207
208
|
await identities.add(role.identity);
|
|
208
209
|
const bridgeDir = join(stateDir, '.ours-bridge');
|
|
209
210
|
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
210
|
-
const capability = randomBytes(32).toString('hex'), socket = join(bridgeDir, `g${generation}.sock`);
|
|
211
|
+
const capability = randomBytes(32).toString('hex'), socket = privateSocketPath(join(bridgeDir, `g${generation}.sock`));
|
|
211
212
|
const endpoint = await startMcpEndpoint({
|
|
212
213
|
socket,
|
|
213
214
|
capability,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ConversationPageView } from './session-control.js';
|
|
2
|
+
/** Read the existing ACP session ledger without opening its writer/recovery store. */
|
|
3
|
+
export declare function resumedConversationPage(stateDir: string, request: {
|
|
4
|
+
after?: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
}, live: ConversationPageView, expectedSessionId: string): Promise<ConversationPageView>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
/** Read the existing ACP session ledger without opening its writer/recovery store. */
|
|
6
|
+
export async function resumedConversationPage(stateDir, request, live, expectedSessionId) {
|
|
7
|
+
let sessionId;
|
|
8
|
+
let files;
|
|
9
|
+
try {
|
|
10
|
+
sessionId = (await readFile(join(stateDir, '.acp-session-id'), 'utf8')).trim();
|
|
11
|
+
files = (await readdir(join(stateDir, '.conversation'))).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return live;
|
|
15
|
+
}
|
|
16
|
+
if (!sessionId || sessionId !== expectedSessionId || !files.length)
|
|
17
|
+
return live;
|
|
18
|
+
const after = Number(request.after ?? 0);
|
|
19
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
20
|
+
return live;
|
|
21
|
+
const limit = Math.min(1000, Math.max(1, Number.isFinite(request.limit) ? Math.floor(request.limit) : 200));
|
|
22
|
+
const events = [];
|
|
23
|
+
let next = String(after);
|
|
24
|
+
let first;
|
|
25
|
+
let hasMore = false;
|
|
26
|
+
scan: for (const file of files) {
|
|
27
|
+
const input = createReadStream(join(stateDir, '.conversation', file), { encoding: 'utf8' });
|
|
28
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
29
|
+
try {
|
|
30
|
+
for await (const line of lines) {
|
|
31
|
+
let event;
|
|
32
|
+
try {
|
|
33
|
+
event = JSON.parse(line);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const cursor = Number(event.seq);
|
|
39
|
+
if (!Number.isSafeInteger(cursor) || cursor <= after)
|
|
40
|
+
continue;
|
|
41
|
+
const historical = ['fleet.task_created', 'prompt.admitted', 'message.chunk', 'message.replace', 'tool.upsert', 'tool.content_chunk', 'turn.completed'].includes(event.kind);
|
|
42
|
+
const visible = event.source !== 'agent_replay' && (event.sessionGeneration === live.snapshot.sessionGeneration || (historical && event.acpSessionId === sessionId));
|
|
43
|
+
if (!visible) {
|
|
44
|
+
next = String(cursor);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (events.length === limit) {
|
|
48
|
+
hasMore = true;
|
|
49
|
+
break scan;
|
|
50
|
+
}
|
|
51
|
+
first ??= String(cursor);
|
|
52
|
+
events.push(event);
|
|
53
|
+
next = String(cursor);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
lines.close();
|
|
58
|
+
input.destroy();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// If a fresh/resumed session changed during the read, use the live page only.
|
|
62
|
+
if ((await readFile(join(stateDir, '.acp-session-id'), 'utf8')).trim() !== sessionId)
|
|
63
|
+
return live;
|
|
64
|
+
return { ...live, events, firstAvailableCursor: first, nextCursor: next, hasMore };
|
|
65
|
+
}
|
|
@@ -24,7 +24,7 @@ export declare class FleetQueryService {
|
|
|
24
24
|
private readonly options;
|
|
25
25
|
private readonly control;
|
|
26
26
|
constructor(options: FleetQueryOptions);
|
|
27
|
-
list(): Promise<Array<{
|
|
27
|
+
list(includeTemporary?: boolean): Promise<Array<{
|
|
28
28
|
role: RoleRecord;
|
|
29
29
|
status: RoleStatus;
|
|
30
30
|
capabilities: RoleCapabilities;
|
|
@@ -75,9 +75,9 @@ export class FleetQueryService {
|
|
|
75
75
|
this.options = options;
|
|
76
76
|
this.control = options.control ?? controlRequest;
|
|
77
77
|
}
|
|
78
|
-
async list() {
|
|
78
|
+
async list(includeTemporary = false) {
|
|
79
79
|
const roles = (await this.options.repository.list())
|
|
80
|
-
.filter(role => role.configured && role.lifetime === 'permanent');
|
|
80
|
+
.filter(role => (role.configured && role.lifetime === 'permanent') || (includeTemporary && role.lifetime === 'temporary'));
|
|
81
81
|
return Promise.all(roles.map(async (role) => {
|
|
82
82
|
const status = await this.status(role);
|
|
83
83
|
return { role, status, capabilities: roleCapabilities(role, status, this.options.capabilityContext) };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const PROVENANCE_KINDS: {
|
|
2
|
+
readonly role: "roles";
|
|
3
|
+
readonly brain: "brains";
|
|
4
|
+
readonly template: "agent_templates";
|
|
5
|
+
readonly layout: "room_layouts";
|
|
6
|
+
};
|
|
7
|
+
export type ProvenanceKind = keyof typeof PROVENANCE_KINDS;
|
|
8
|
+
export type ProvenanceStatus = 'predefined' | 'changed' | 'custom';
|
|
9
|
+
export interface ProvenanceEntry {
|
|
10
|
+
status: ProvenanceStatus;
|
|
11
|
+
preset?: string;
|
|
12
|
+
}
|
|
13
|
+
export type Provenance = Record<ProvenanceKind, Record<string, ProvenanceEntry>>;
|
|
14
|
+
export declare class PresetProvenance {
|
|
15
|
+
private configPath?;
|
|
16
|
+
private presetRoot;
|
|
17
|
+
constructor(configPath?: string | undefined, presetRoot?: string);
|
|
18
|
+
read(): Provenance;
|
|
19
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Where each configured definition came from, compared with the packaged presets.
|
|
2
|
+
* A definition is `predefined` when it has the same value as the shipped preset of
|
|
3
|
+
* the same name, `changed` when a preset of that name exists but the value differs,
|
|
4
|
+
* and `custom` otherwise. Values are compared after parsing, so the formatting the
|
|
5
|
+
* configuration editor writes does not count as a change. Preset text is returned
|
|
6
|
+
* so users can see exactly what ships; it contains no host data.
|
|
7
|
+
*/
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { basename, extname, join } from 'node:path';
|
|
11
|
+
import { parse } from 'yaml';
|
|
12
|
+
import { defaultConfigPath } from '../paths.js';
|
|
13
|
+
import { splitRootFor } from '../config.js';
|
|
14
|
+
import { packagedPresetRoot } from '../preset-bootstrap.js';
|
|
15
|
+
export const PROVENANCE_KINDS = { role: 'roles', brain: 'brains', template: 'agent_templates', layout: 'room_layouts' };
|
|
16
|
+
const MAX_BYTES = 256 * 1024;
|
|
17
|
+
/** Regular, bounded YAML files of one directory, by definition name. Anything else is skipped. */
|
|
18
|
+
function yamlFiles(dir) {
|
|
19
|
+
const files = new Map();
|
|
20
|
+
if (!existsSync(dir) || !lstatSync(dir).isDirectory())
|
|
21
|
+
return files;
|
|
22
|
+
for (const file of readdirSync(dir).sort()) {
|
|
23
|
+
if (!/\.ya?ml$/.test(file))
|
|
24
|
+
continue;
|
|
25
|
+
const path = join(dir, file), stat = lstatSync(path);
|
|
26
|
+
if (!stat.isFile() || stat.size > MAX_BYTES)
|
|
27
|
+
continue;
|
|
28
|
+
files.set(basename(file, extname(file)), readFileSync(path, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
const parsed = (text) => { try {
|
|
33
|
+
return parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return Symbol('unparseable');
|
|
37
|
+
} };
|
|
38
|
+
export class PresetProvenance {
|
|
39
|
+
configPath;
|
|
40
|
+
presetRoot;
|
|
41
|
+
constructor(configPath, presetRoot = join(packagedPresetRoot(), 'fleet')) {
|
|
42
|
+
this.configPath = configPath;
|
|
43
|
+
this.presetRoot = presetRoot;
|
|
44
|
+
}
|
|
45
|
+
read() {
|
|
46
|
+
const split = splitRootFor(this.configPath ?? defaultConfigPath());
|
|
47
|
+
const result = {};
|
|
48
|
+
for (const [kind, dir] of Object.entries(PROVENANCE_KINDS)) {
|
|
49
|
+
const presets = yamlFiles(join(this.presetRoot, dir)), installed = yamlFiles(join(split, dir));
|
|
50
|
+
result[kind] = Object.fromEntries([...installed].map(([name, text]) => {
|
|
51
|
+
const preset = presets.get(name);
|
|
52
|
+
if (preset === undefined)
|
|
53
|
+
return [name, { status: 'custom' }];
|
|
54
|
+
return [name, { status: isDeepStrictEqual(parsed(text), parsed(preset)) ? 'predefined' : 'changed', preset }];
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { type RoomLayoutDefinition } from '../rooms-tasks/layout-config.js';
|
|
3
|
+
export declare const ABSENT_REVISION = "absent";
|
|
4
|
+
export type LayoutDefinitionBody = Omit<RoomLayoutDefinition, 'sourceFile'>;
|
|
5
|
+
export type LayoutListEntry = {
|
|
6
|
+
name: string;
|
|
7
|
+
revision: string;
|
|
8
|
+
file: string;
|
|
9
|
+
definition: LayoutDefinitionBody;
|
|
10
|
+
issues: string[];
|
|
11
|
+
definition_hash: string;
|
|
12
|
+
} | {
|
|
13
|
+
name: string;
|
|
14
|
+
revision: string;
|
|
15
|
+
file: string;
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export declare class RoomLayoutDefinitions {
|
|
19
|
+
private configPath?;
|
|
20
|
+
private deps;
|
|
21
|
+
constructor(configPath?: string | undefined, deps?: {
|
|
22
|
+
loadConfiguration?: typeof loadConfig;
|
|
23
|
+
});
|
|
24
|
+
private root;
|
|
25
|
+
private pathFor;
|
|
26
|
+
private currentRevision;
|
|
27
|
+
/** Agent-template references that do not resolve in the current Fleet configuration. */
|
|
28
|
+
private templateIssues;
|
|
29
|
+
/** The configured default (`rooms.defaults.template`, else `tasks.default_room_template`) when it names a usable layout. */
|
|
30
|
+
defaultLayout(entries?: LayoutListEntry[]): string | undefined;
|
|
31
|
+
list(): LayoutListEntry[];
|
|
32
|
+
validate(raw: unknown): {
|
|
33
|
+
definition?: LayoutDefinitionBody;
|
|
34
|
+
issues: string[];
|
|
35
|
+
};
|
|
36
|
+
save(name: string, expectedRevision: string, raw: unknown): Promise<{
|
|
37
|
+
name: string;
|
|
38
|
+
revision: string;
|
|
39
|
+
definition: LayoutDefinitionBody;
|
|
40
|
+
}>;
|
|
41
|
+
remove(name: string, expectedRevision: string): Promise<{
|
|
42
|
+
name: string;
|
|
43
|
+
deleted: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Web authoring of `fleet/room_layouts/<name>.yaml`.
|
|
2
|
+
* Writes are revision-guarded (sha256 of the file bytes) and validated with the
|
|
3
|
+
* same parser the CLI uses. Retained layout runs keep their own snapshots, so
|
|
4
|
+
* editing or deleting a definition never changes an existing run.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
8
|
+
import { basename, extname, join } from 'node:path';
|
|
9
|
+
import { stringify } from 'yaml';
|
|
10
|
+
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
11
|
+
import { parseFleetDocument } from '../config-yaml.js';
|
|
12
|
+
import { defaultConfigPath } from '../paths.js';
|
|
13
|
+
import { loadConfig, splitRootFor } from '../config.js';
|
|
14
|
+
import { assertLayoutFile, validLayoutKey, validateRoomLayout } from '../rooms-tasks/layout-config.js';
|
|
15
|
+
import { FleetError } from './errors.js';
|
|
16
|
+
import { layoutDefinitionHash } from './task-layouts.js';
|
|
17
|
+
export const ABSENT_REVISION = 'absent';
|
|
18
|
+
const revisionOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
export class RoomLayoutDefinitions {
|
|
20
|
+
configPath;
|
|
21
|
+
deps;
|
|
22
|
+
constructor(configPath, deps = {}) {
|
|
23
|
+
this.configPath = configPath;
|
|
24
|
+
this.deps = deps;
|
|
25
|
+
}
|
|
26
|
+
root() { return join(splitRootFor(this.configPath ?? defaultConfigPath()), 'room_layouts'); }
|
|
27
|
+
pathFor(name) {
|
|
28
|
+
if (!validLayoutKey(name))
|
|
29
|
+
throw new FleetError('invalid_request', 'layout name must start with a letter and use letters, digits, - or _');
|
|
30
|
+
return join(this.root(), `${name}.yaml`);
|
|
31
|
+
}
|
|
32
|
+
currentRevision(path) {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return ABSENT_REVISION;
|
|
35
|
+
assertLayoutFile(path);
|
|
36
|
+
return revisionOf(readFileSync(path));
|
|
37
|
+
}
|
|
38
|
+
/** Agent-template references that do not resolve in the current Fleet configuration. */
|
|
39
|
+
templateIssues(definition) {
|
|
40
|
+
let templates = {};
|
|
41
|
+
try {
|
|
42
|
+
templates = (this.deps.loadConfiguration ?? loadConfig)(this.configPath).agentTemplates ?? {};
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return [`fleet configuration unavailable: ${error.message}`];
|
|
46
|
+
}
|
|
47
|
+
return Object.entries(definition.participants).flatMap(([key, participant]) => participant.agent_template && !Object.hasOwn(templates, participant.agent_template)
|
|
48
|
+
? [`participant ${key}: agent template not found: ${participant.agent_template}`] : []);
|
|
49
|
+
}
|
|
50
|
+
/** The configured default (`rooms.defaults.template`, else `tasks.default_room_template`) when it names a usable layout. */
|
|
51
|
+
defaultLayout(entries = this.list()) {
|
|
52
|
+
let name;
|
|
53
|
+
try {
|
|
54
|
+
const config = (this.deps.loadConfiguration ?? loadConfig)(this.configPath);
|
|
55
|
+
name = config.rooms?.defaults?.template ?? config.tasks?.default_room_template;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const entry = entries.find(e => e.name === name);
|
|
61
|
+
return entry && !('error' in entry) && !entry.issues.length ? entry.name : undefined;
|
|
62
|
+
}
|
|
63
|
+
list() {
|
|
64
|
+
const root = this.root();
|
|
65
|
+
if (!existsSync(root))
|
|
66
|
+
return [];
|
|
67
|
+
assertLayoutFile(root, true);
|
|
68
|
+
return readdirSync(root).filter(file => /\.ya?ml$/.test(file)).sort().map(file => {
|
|
69
|
+
const name = basename(file, extname(file)), path = join(root, file);
|
|
70
|
+
try {
|
|
71
|
+
if (!validLayoutKey(name))
|
|
72
|
+
throw Error(`invalid room layout id: ${name}`);
|
|
73
|
+
assertLayoutFile(path);
|
|
74
|
+
const bytes = readFileSync(path);
|
|
75
|
+
const definition = validateRoomLayout(parseFleetDocument(path, bytes.toString('utf8'), 'strict').value, file);
|
|
76
|
+
return { name, file, revision: revisionOf(bytes), definition, issues: this.templateIssues(definition), definition_hash: layoutDefinitionHash(definition) };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Only a trusted regular file may be read to offer an overwrite revision.
|
|
80
|
+
let revision = ABSENT_REVISION;
|
|
81
|
+
try {
|
|
82
|
+
assertLayoutFile(path);
|
|
83
|
+
revision = revisionOf(readFileSync(path));
|
|
84
|
+
}
|
|
85
|
+
catch { /* not trusted: never read */ }
|
|
86
|
+
return { name, file, revision, error: error.message };
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
validate(raw) {
|
|
91
|
+
let definition;
|
|
92
|
+
try {
|
|
93
|
+
definition = validateRoomLayout(raw, 'room layout');
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return { issues: [error.message] };
|
|
97
|
+
}
|
|
98
|
+
return { definition, issues: this.templateIssues(definition) };
|
|
99
|
+
}
|
|
100
|
+
async save(name, expectedRevision, raw) {
|
|
101
|
+
const path = this.pathFor(name);
|
|
102
|
+
const { definition, issues } = this.validate(raw);
|
|
103
|
+
if (!definition || issues.length)
|
|
104
|
+
throw new FleetError('invalid_request', issues.join('; '));
|
|
105
|
+
const root = this.root();
|
|
106
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
107
|
+
assertLayoutFile(root, true);
|
|
108
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
109
|
+
const current = this.currentRevision(path);
|
|
110
|
+
if (current !== expectedRevision)
|
|
111
|
+
throw new FleetError('stale_state', 'room layout changed since it was loaded; reload before saving');
|
|
112
|
+
const { description, participants, rooms } = definition;
|
|
113
|
+
const text = stringify({ version: 1, ...(description ? { description } : {}), participants, rooms });
|
|
114
|
+
replaceFileAtomically(path, text, 0o600);
|
|
115
|
+
return { name, revision: revisionOf(text), definition };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async remove(name, expectedRevision) {
|
|
119
|
+
const path = this.pathFor(name);
|
|
120
|
+
return withFileLock(`${path}.lock`, async () => {
|
|
121
|
+
const current = this.currentRevision(path);
|
|
122
|
+
if (current === ABSENT_REVISION)
|
|
123
|
+
return { name, deleted: false };
|
|
124
|
+
if (current !== expectedRevision)
|
|
125
|
+
throw new FleetError('stale_state', 'room layout changed since it was loaded; reload before deleting');
|
|
126
|
+
unlinkSync(path);
|
|
127
|
+
return { name, deleted: true };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentHistoryQuery } from '../agent-ours/correspondence.js';
|
|
1
2
|
import { controlRequest } from '../session/control.js';
|
|
2
3
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt } from '../session/conversation-types.js';
|
|
3
4
|
import type { SessionSnapshot } from '../session/types.js';
|
|
@@ -23,6 +24,8 @@ export interface InterruptReceipt {
|
|
|
23
24
|
reasonCode?: string;
|
|
24
25
|
}
|
|
25
26
|
export interface RoleSessionControl {
|
|
27
|
+
agentContacts?(): Promise<unknown>;
|
|
28
|
+
agentHistory?(request: AgentHistoryQuery): Promise<unknown>;
|
|
26
29
|
describe(): Promise<SessionDescriptor>;
|
|
27
30
|
snapshot(): Promise<SessionSnapshot>;
|
|
28
31
|
recentOutput(request?: {
|
|
@@ -45,6 +48,7 @@ export interface RoleSessionControl {
|
|
|
45
48
|
commandId: string;
|
|
46
49
|
text: string;
|
|
47
50
|
actorBrowserSession: string;
|
|
51
|
+
expectedSessionGeneration?: string;
|
|
48
52
|
source: 'owner_admin_console';
|
|
49
53
|
}): Promise<PromptReceipt>;
|
|
50
54
|
interruptV2?(commandId: string): Promise<InterruptReceipt & {
|
|
@@ -75,6 +79,9 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
|
|
|
75
79
|
private readonly stateDir;
|
|
76
80
|
private readonly request;
|
|
77
81
|
constructor(stateDir: string, request?: typeof controlRequest);
|
|
82
|
+
agentContacts(): Promise<unknown>;
|
|
83
|
+
agentHistory(request: AgentHistoryQuery): Promise<unknown>;
|
|
84
|
+
private requireCorrespondence;
|
|
78
85
|
describe(): Promise<SessionDescriptor>;
|
|
79
86
|
snapshot(): Promise<SessionSnapshot>;
|
|
80
87
|
recentOutput(request?: {
|
|
@@ -91,6 +98,7 @@ export declare class RoleSessionControlAdapter implements RoleSessionControl {
|
|
|
91
98
|
commandId: string;
|
|
92
99
|
text: string;
|
|
93
100
|
actorBrowserSession: string;
|
|
101
|
+
expectedSessionGeneration?: string;
|
|
94
102
|
source: 'owner_admin_console';
|
|
95
103
|
}): Promise<PromptReceipt>;
|
|
96
104
|
interruptV2(commandId: string): Promise<InterruptReceipt & {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resumedConversationPage } from './conversation-history.js';
|
|
1
2
|
import { controlRequest, followConversation } from '../session/control.js';
|
|
2
3
|
import { FleetError, normalizeError } from './errors.js';
|
|
3
4
|
const visibleEvents = (events) => events.filter(event => event.kind !== 'thought');
|
|
@@ -8,6 +9,10 @@ export class RoleSessionControlAdapter {
|
|
|
8
9
|
this.stateDir = stateDir;
|
|
9
10
|
this.request = request;
|
|
10
11
|
}
|
|
12
|
+
async agentContacts() { await this.requireCorrespondence(); return this.call('agent_contacts'); }
|
|
13
|
+
async agentHistory(request) { await this.requireCorrespondence(); return this.call('agent_history', { agentHistory: request }); }
|
|
14
|
+
async requireCorrespondence() { if (!(await this.describe()).features.includes('agent_correspondence'))
|
|
15
|
+
throw new FleetError('capability_unavailable', 'This session needs an updated supervisor to show contacts and history.'); }
|
|
11
16
|
async describe() {
|
|
12
17
|
const response = await this.call('snapshot');
|
|
13
18
|
const result = response;
|
|
@@ -58,9 +63,20 @@ export class RoleSessionControlAdapter {
|
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
async conversationPage(request = {}) {
|
|
61
|
-
|
|
66
|
+
const live = await this.call('conversation_page', {
|
|
62
67
|
after: request.after, limit: request.limit,
|
|
63
68
|
});
|
|
69
|
+
const snapshot = await this.snapshot();
|
|
70
|
+
if (snapshot.backend !== 'acp' || !snapshot.sessionId)
|
|
71
|
+
return live;
|
|
72
|
+
try {
|
|
73
|
+
const history = await resumedConversationPage(this.stateDir, request, live, snapshot.sessionId);
|
|
74
|
+
const check = await this.call('conversation_page', { limit: 1 });
|
|
75
|
+
return check.snapshot.sessionGeneration === live.snapshot.sessionGeneration ? history : await this.call('conversation_page', request);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return live;
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
async submitPromptV2(request) {
|
|
66
82
|
if (!request.text.trim())
|
|
@@ -68,13 +84,15 @@ export class RoleSessionControlAdapter {
|
|
|
68
84
|
if (Buffer.byteLength(request.text) > 32 * 1024)
|
|
69
85
|
throw new FleetError('invalid_request', 'text exceeds 32 KiB');
|
|
70
86
|
try {
|
|
71
|
-
return await this.call('submit_prompt_v2', {
|
|
87
|
+
return await this.call(request.expectedSessionGeneration ? 'submit_voice_prompt' : 'submit_prompt_v2', {
|
|
72
88
|
commandId: request.commandId, text: request.text, actor: request.actorBrowserSession,
|
|
73
|
-
source: request.source,
|
|
89
|
+
source: request.source, expectedSessionGeneration: request.expectedSessionGeneration,
|
|
74
90
|
});
|
|
75
91
|
}
|
|
76
92
|
catch (error) {
|
|
77
93
|
const fleetError = normalizeError(error);
|
|
94
|
+
if (fleetError.message.includes('session_changed:'))
|
|
95
|
+
throw new FleetError('stale_state', 'Agent session changed. Record a new voice message.');
|
|
78
96
|
if (fleetError.message.includes('idempotency_conflict'))
|
|
79
97
|
throw new FleetError('idempotency_conflict', 'this command id was already used with a different prompt body');
|
|
80
98
|
throw fleetError;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type RoomLayoutDefinition } from '../rooms-tasks/layout-config.js';
|
|
2
|
+
import { RoomLayoutService } from '../rooms-tasks/layout-service.js';
|
|
3
|
+
import type { TaskLayoutLink, TaskRecord } from '../rooms-tasks/types.js';
|
|
4
|
+
export type TaskLayoutOperation = 'open' | 'close-room' | 'close';
|
|
5
|
+
export interface TaskLayoutOperationRecord {
|
|
6
|
+
/** Only the worker holding the current ID may update the record. */
|
|
7
|
+
id: string;
|
|
8
|
+
operation: TaskLayoutOperation;
|
|
9
|
+
room?: string;
|
|
10
|
+
status: 'launching' | 'running' | 'succeeded' | 'failed';
|
|
11
|
+
error?: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TaskLayoutRoomView {
|
|
15
|
+
key: string;
|
|
16
|
+
goal: string;
|
|
17
|
+
members: string[];
|
|
18
|
+
roles?: Record<string, string>;
|
|
19
|
+
state: 'declared' | 'provisioning' | 'active' | 'closed';
|
|
20
|
+
room_id?: string;
|
|
21
|
+
identity_cid?: string;
|
|
22
|
+
ready: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface TaskLayoutView {
|
|
25
|
+
name: string;
|
|
26
|
+
run_id: string;
|
|
27
|
+
created: boolean;
|
|
28
|
+
closed: boolean;
|
|
29
|
+
closing: boolean;
|
|
30
|
+
uncertain?: string;
|
|
31
|
+
rooms: TaskLayoutRoomView[];
|
|
32
|
+
participants: Array<{
|
|
33
|
+
key: string;
|
|
34
|
+
agent_template?: string;
|
|
35
|
+
owned: boolean;
|
|
36
|
+
retired: boolean;
|
|
37
|
+
agent?: string;
|
|
38
|
+
cid?: string;
|
|
39
|
+
}>;
|
|
40
|
+
operation?: TaskLayoutOperationRecord;
|
|
41
|
+
}
|
|
42
|
+
/** Hash of the authorable definition only (never the source file path). */
|
|
43
|
+
export declare function layoutDefinitionHash(definition: RoomLayoutDefinition): string;
|
|
44
|
+
/** Always-current brief for layout participants; rooms may open long after the run snapshot.
|
|
45
|
+
* It lives in Fleet's private layout state, never in the agent-writable task workspace. */
|
|
46
|
+
export declare function taskBriefPath(task: Pick<TaskRecord, 'task_id'>): string;
|
|
47
|
+
export declare function writeTaskBrief(task: Pick<TaskRecord, 'task_id' | 'title' | 'brief'>): void;
|
|
48
|
+
/** Task context reaches every participant through the room contract (briefing text).
|
|
49
|
+
* The contract references the brief file instead of copying the brief, so later edits reach later rooms. */
|
|
50
|
+
export declare function taskScopedDefinition(definition: RoomLayoutDefinition, task: Pick<TaskRecord, 'task_id' | 'title' | 'brief' | 'workspace'>): RoomLayoutDefinition;
|
|
51
|
+
/** Opening rooms requires a started, live task; checked again by the worker before any side effect. */
|
|
52
|
+
export declare function assertLayoutOpenable(task: TaskRecord): void;
|
|
53
|
+
export declare class TaskLayouts {
|
|
54
|
+
private configPath?;
|
|
55
|
+
private deps;
|
|
56
|
+
constructor(configPath?: string | undefined, deps?: {
|
|
57
|
+
service?: RoomLayoutService;
|
|
58
|
+
launch?: (args: string[], operation: string) => Promise<void>;
|
|
59
|
+
binPath?: () => string | undefined;
|
|
60
|
+
});
|
|
61
|
+
private service;
|
|
62
|
+
private operationPath;
|
|
63
|
+
private provenancePath;
|
|
64
|
+
/** Serializes run creation, worker preconditions and deletion's removal of the run. */
|
|
65
|
+
private withTaskLock;
|
|
66
|
+
/** Resolve a layout for task creation: it must exist, parse and reference known Agent Templates. */
|
|
67
|
+
resolve(name: string): {
|
|
68
|
+
name: string;
|
|
69
|
+
definition_hash: string;
|
|
70
|
+
};
|
|
71
|
+
runExists(link: TaskLayoutLink): boolean;
|
|
72
|
+
/** Idempotent: reuse a run whose provenance matches this task, or snapshot one from the pinned source. */
|
|
73
|
+
ensureRun(task: TaskRecord, precondition?: (current: TaskRecord) => void): Promise<void>;
|
|
74
|
+
private verifyProvenance;
|
|
75
|
+
readOperation(runId: string): TaskLayoutOperationRecord | undefined;
|
|
76
|
+
/** Update only the still-current operation; a superseded or forgotten one is never resurrected. */
|
|
77
|
+
private updateOperation;
|
|
78
|
+
private writeOperation;
|
|
79
|
+
/** Sanitized read model: no Agent Template snapshots, owner fingerprints or session internals. */
|
|
80
|
+
view(task: TaskRecord): TaskLayoutView | undefined;
|
|
81
|
+
/** Accept an operation and run it in a detached worker; the outcome is recorded for polling. */
|
|
82
|
+
launch(task: TaskRecord, operation: TaskLayoutOperation, room?: string): Promise<TaskLayoutOperationRecord>;
|
|
83
|
+
/** Worker body. Errors are recorded and rethrown; unknown engine outcomes stay in run state for inspection. */
|
|
84
|
+
/** Without an ID (direct CLI use) the worker claims a new current operation. */
|
|
85
|
+
run(task: TaskRecord, operation: TaskLayoutOperation, room?: string, operationId?: string): Promise<void>;
|
|
86
|
+
private perform;
|
|
87
|
+
/** Deletion: close every layout resource, then remove the run and its operation record. */
|
|
88
|
+
closeAndForget(task: Pick<TaskRecord, 'task_id' | 'layout'>): Promise<void>;
|
|
89
|
+
}
|