@ours.network/fleet 0.15.6 → 0.16.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.
- package/README.md +44 -7
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +29 -2
- package/dist/owner-channel/channel.d.ts +9 -0
- package/dist/owner-channel/channel.js +35 -4
- package/dist/owner-channel/commands.d.ts +8 -0
- package/dist/owner-channel/commands.js +12 -1
- package/dist/owner-channel/notices.d.ts +19 -0
- package/dist/owner-channel/notices.js +16 -0
- package/dist/web/fleet-config-service.js +51 -15
- package/dist/web/runtime.js +9 -2
- package/dist/web/server.d.ts +6 -2
- package/dist/web/server.js +50 -0
- package/dist/web/topology-draft-store.d.ts +80 -0
- package/dist/web/topology-draft-store.js +337 -0
- package/dist/web/topology-model.d.ts +62 -0
- package/dist/web/topology-model.js +220 -0
- package/dist/web/topology-promote.d.ts +31 -0
- package/dist/web/topology-promote.js +168 -0
- package/dist/web/yaml-document-edit.d.ts +31 -0
- package/dist/web/yaml-document-edit.js +401 -0
- package/dist/web-app/assets/{TerminalView-hZpyUFY_.js → TerminalView-Mxxypj9w.js} +1 -1
- package/dist/web-app/assets/index-CsHEL0f6.js +10 -0
- package/dist/web-app/assets/{index-COg4Azq1.css → index-DuC-xnX4.css} +1 -1
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-Cde9auW0.js +0 -10
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, } from 'node:fs';
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
|
-
import { stringify } from 'yaml';
|
|
5
4
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
6
5
|
import { loadConfig } from '../config.js';
|
|
7
6
|
import { parseFleetDocument } from '../config-yaml.js';
|
|
8
7
|
import { defaultConfigPath } from '../paths.js';
|
|
9
8
|
import { FleetError } from '../application/errors.js';
|
|
9
|
+
import { redactSourceSecrets, renderModelOntoSource } from './yaml-document-edit.js';
|
|
10
10
|
export const REDACTED_ENV_VALUE = '__OURS_FLEET_SECRET_REDACTED__';
|
|
11
11
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
12
|
+
const DIFF_CONTEXT_LINES = 3;
|
|
12
13
|
const emptyReport = () => ({ ok: true, checks: [] });
|
|
13
14
|
export class FleetConfigService {
|
|
14
15
|
path;
|
|
@@ -32,13 +33,13 @@ export class FleetConfigService {
|
|
|
32
33
|
this.assertRevision(baseRevision, currentSource);
|
|
33
34
|
const current = parseFleetDocument(this.path, currentSource, 'strict').value;
|
|
34
35
|
const restored = restoreRedactions(assertModel(model), current);
|
|
35
|
-
const
|
|
36
|
-
const candidate = this.validateCandidate(
|
|
37
|
-
const
|
|
36
|
+
const nextSource = render(currentSource, restored);
|
|
37
|
+
const candidate = this.validateCandidate(nextSource);
|
|
38
|
+
const redactedNext = redactModel(restored);
|
|
38
39
|
const preflight = await this.preflight(candidate.path).finally(candidate.remove);
|
|
39
40
|
return {
|
|
40
41
|
valid: true, revision: digest(currentSource), normalizedModel: redactedNext.model,
|
|
41
|
-
diff:
|
|
42
|
+
diff: sourceDiff(currentSource, nextSource),
|
|
42
43
|
redactions: redactedNext.paths,
|
|
43
44
|
impact: restartImpact(current, restored), preflight,
|
|
44
45
|
};
|
|
@@ -49,13 +50,12 @@ export class FleetConfigService {
|
|
|
49
50
|
this.assertRevision(baseRevision, currentSource);
|
|
50
51
|
const current = parseFleetDocument(this.path, currentSource, 'strict').value;
|
|
51
52
|
const restored = restoreRedactions(assertModel(model), current);
|
|
52
|
-
const nextSource =
|
|
53
|
+
const nextSource = render(currentSource, restored);
|
|
53
54
|
const candidate = this.validateCandidate(nextSource);
|
|
54
55
|
const preflight = await this.preflight(candidate.path).finally(candidate.remove);
|
|
55
56
|
// The lock coordinates trusted web/agent writers. An operator's editor does
|
|
56
57
|
// not take it, so re-check immediately before replacement as well.
|
|
57
58
|
this.assertRevision(baseRevision, this.readSource());
|
|
58
|
-
const redactedCurrent = redactModel(current);
|
|
59
59
|
const redactedNext = redactModel(restored);
|
|
60
60
|
let backup;
|
|
61
61
|
if (existsSync(this.path)) {
|
|
@@ -67,7 +67,7 @@ export class FleetConfigService {
|
|
|
67
67
|
return {
|
|
68
68
|
saved: true, valid: true, revision: digest(currentSource), newRevision: digest(nextSource),
|
|
69
69
|
normalizedModel: redactedNext.model,
|
|
70
|
-
diff:
|
|
70
|
+
diff: sourceDiff(currentSource, nextSource),
|
|
71
71
|
redactions: redactedNext.paths, impact: restartImpact(current, restored), preflight,
|
|
72
72
|
backup,
|
|
73
73
|
};
|
|
@@ -110,8 +110,17 @@ function assertModel(value) {
|
|
|
110
110
|
throw new FleetError('invalid_request', 'model must be a JSON object');
|
|
111
111
|
return structuredClone(value);
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Apply the edited model onto the user's own document rather than re-serializing
|
|
115
|
+
* it, so comments, key order and formatting outside the edit survive the save.
|
|
116
|
+
*/
|
|
117
|
+
function render(currentSource, model) {
|
|
118
|
+
try {
|
|
119
|
+
return renderModelOntoSource(currentSource, model);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
throw new FleetError('invalid_request', error.message);
|
|
123
|
+
}
|
|
115
124
|
}
|
|
116
125
|
function digest(source) {
|
|
117
126
|
return createHash('sha256').update(source).digest('hex');
|
|
@@ -173,12 +182,39 @@ function restoreRedactions(next, current) {
|
|
|
173
182
|
}
|
|
174
183
|
return next;
|
|
175
184
|
}
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Unified-style diff of the real file text with env secrets masked. Common
|
|
187
|
+
* leading/trailing lines are trimmed, so a surgical edit reviews as a small
|
|
188
|
+
* hunk instead of the whole configuration twice.
|
|
189
|
+
*/
|
|
190
|
+
function sourceDiff(beforeSource, afterSource) {
|
|
191
|
+
const before = splitLines(redactSourceSecrets(beforeSource, REDACTED_ENV_VALUE));
|
|
192
|
+
const after = splitLines(redactSourceSecrets(afterSource, REDACTED_ENV_VALUE));
|
|
193
|
+
let head = 0;
|
|
194
|
+
while (head < before.length && head < after.length && before[head] === after[head])
|
|
195
|
+
head += 1;
|
|
196
|
+
let tail = 0;
|
|
197
|
+
while (tail < before.length - head && tail < after.length - head
|
|
198
|
+
&& before[before.length - 1 - tail] === after[after.length - 1 - tail])
|
|
199
|
+
tail += 1;
|
|
200
|
+
if (head === before.length && head === after.length)
|
|
178
201
|
return '';
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
202
|
+
const start = Math.max(0, head - DIFF_CONTEXT_LINES);
|
|
203
|
+
const trailing = Math.min(DIFF_CONTEXT_LINES, tail);
|
|
204
|
+
const beforeSpan = before.length - tail + trailing - start;
|
|
205
|
+
const afterSpan = after.length - tail + trailing - start;
|
|
206
|
+
return [
|
|
207
|
+
'--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
|
|
208
|
+
`@@ -${start + 1},${beforeSpan} +${start + 1},${afterSpan} @@`,
|
|
209
|
+
...before.slice(start, head).map(line => ` ${line}`),
|
|
210
|
+
...before.slice(head, before.length - tail).map(line => `-${line}`),
|
|
211
|
+
...after.slice(head, after.length - tail).map(line => `+${line}`),
|
|
212
|
+
...before.slice(before.length - tail, before.length - tail + trailing).map(line => ` ${line}`),
|
|
213
|
+
'',
|
|
214
|
+
].join('\n');
|
|
215
|
+
}
|
|
216
|
+
function splitLines(source) {
|
|
217
|
+
return source.replace(/\n$/, '').split('\n');
|
|
182
218
|
}
|
|
183
219
|
function objectKeys(value) {
|
|
184
220
|
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
|
package/dist/web/runtime.js
CHANGED
|
@@ -19,7 +19,9 @@ import { AuditSink } from './audit.js';
|
|
|
19
19
|
import { FleetEventBus } from './events.js';
|
|
20
20
|
import { buildWebServer } from './server.js';
|
|
21
21
|
import { FleetConfigService } from './fleet-config-service.js';
|
|
22
|
-
import {
|
|
22
|
+
import { mergeTopology } from './topology-model.js';
|
|
23
|
+
import { TopologyDraftStore } from './topology-draft-store.js';
|
|
24
|
+
import { TopologyPromoteService } from './topology-promote.js';
|
|
23
25
|
import { doctor } from '../doctor.js';
|
|
24
26
|
import { TerminalBridgeManager } from './terminal/bridge.js';
|
|
25
27
|
import { acquireWebServerLock } from './lock.js';
|
|
@@ -148,11 +150,16 @@ export async function startWebConsole(options) {
|
|
|
148
150
|
configPath: options.configPath,
|
|
149
151
|
preflight: path => doctor({ configPath: path, yamlMode: 'strict' }),
|
|
150
152
|
});
|
|
153
|
+
const topologyDrafts = new TopologyDraftStore({ dir: webDir });
|
|
154
|
+
const readTopology = async () => mergeTopology(loadConfig(options.configPath), await query.list(), topologyDrafts.read());
|
|
155
|
+
const topologyPromote = new TopologyPromoteService({
|
|
156
|
+
drafts: topologyDrafts, configuration, topology: readTopology,
|
|
157
|
+
});
|
|
151
158
|
let server;
|
|
152
159
|
try {
|
|
153
160
|
server = await buildWebServer({
|
|
154
161
|
query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
|
|
155
|
-
topology:
|
|
162
|
+
topology: readTopology, topologyDrafts, topologyPromote,
|
|
156
163
|
terminalUpgrade: terminalAvailable
|
|
157
164
|
? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
|
|
158
165
|
: undefined,
|
package/dist/web/server.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ import { AuditSink } from './audit.js';
|
|
|
11
11
|
import { WebAuth } from './auth.js';
|
|
12
12
|
import { FleetEventBus } from './events.js';
|
|
13
13
|
import type { FleetConfigService } from './fleet-config-service.js';
|
|
14
|
-
import type {
|
|
14
|
+
import type { MergedTopology } from './topology-model.js';
|
|
15
|
+
import type { TopologyDraftStore } from './topology-draft-store.js';
|
|
16
|
+
import type { TopologyPromoteService } from './topology-promote.js';
|
|
15
17
|
import type { RoleRemovalService } from '../application/role-removal-service.js';
|
|
16
18
|
export interface WebServices {
|
|
17
19
|
query: FleetQueryService;
|
|
@@ -24,7 +26,9 @@ export interface WebServices {
|
|
|
24
26
|
events?: FleetEventBus;
|
|
25
27
|
watchdogs?: WatchdogQueryService;
|
|
26
28
|
configuration?: FleetConfigService;
|
|
27
|
-
topology?: () => Promise<
|
|
29
|
+
topology?: () => Promise<MergedTopology>;
|
|
30
|
+
topologyDrafts?: TopologyDraftStore;
|
|
31
|
+
topologyPromote?: TopologyPromoteService;
|
|
28
32
|
removal?: RoleRemovalService;
|
|
29
33
|
terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
|
|
30
34
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -159,6 +159,46 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
159
159
|
throw new FleetError('capability_unavailable', 'fleet topology is unavailable');
|
|
160
160
|
return services.topology();
|
|
161
161
|
});
|
|
162
|
+
const drafts = () => {
|
|
163
|
+
if (!services.topologyDrafts)
|
|
164
|
+
throw new FleetError('capability_unavailable', 'topology sketching is unavailable');
|
|
165
|
+
return services.topologyDrafts;
|
|
166
|
+
};
|
|
167
|
+
app.get('/api/v1/topology/draft', async (request) => {
|
|
168
|
+
auth.authenticate(request);
|
|
169
|
+
return drafts().read();
|
|
170
|
+
});
|
|
171
|
+
app.put('/api/v1/topology/draft', async (request) => {
|
|
172
|
+
const session = auth.authenticate(request, true);
|
|
173
|
+
const body = request.body;
|
|
174
|
+
const result = await drafts().write(String(body?.revision ?? ''), body?.draft);
|
|
175
|
+
events.publish('topology.draft.changed', { revision: result.revision });
|
|
176
|
+
await audit.record({
|
|
177
|
+
requestId: request.id, browser: session.id, action: 'topology.draft.save', result: 'succeeded',
|
|
178
|
+
});
|
|
179
|
+
return result;
|
|
180
|
+
});
|
|
181
|
+
const promotion = () => {
|
|
182
|
+
if (!services.topologyPromote)
|
|
183
|
+
throw new FleetError('capability_unavailable', 'adding sketches to the fleet is unavailable');
|
|
184
|
+
return services.topologyPromote;
|
|
185
|
+
};
|
|
186
|
+
app.post('/api/v1/topology/promote/preview', async (request) => {
|
|
187
|
+
auth.authenticate(request, true);
|
|
188
|
+
return promotion().preview(promoteRequest(request.body));
|
|
189
|
+
});
|
|
190
|
+
// Writes configuration only. Nothing here starts a process: `Launch` is a
|
|
191
|
+
// separate, explicit action, so adding to the fleet can never launch by surprise.
|
|
192
|
+
app.post('/api/v1/topology/promote', async (request) => {
|
|
193
|
+
const session = auth.authenticate(request, true);
|
|
194
|
+
const result = await promotion().promote(promoteRequest(request.body));
|
|
195
|
+
events.publish('configuration.changed', { revision: result.newRevision });
|
|
196
|
+
events.publish('topology.draft.changed', { revision: result.draftRevision });
|
|
197
|
+
await audit.record({
|
|
198
|
+
requestId: request.id, browser: session.id, action: 'topology.promote', result: 'succeeded',
|
|
199
|
+
});
|
|
200
|
+
return result;
|
|
201
|
+
});
|
|
162
202
|
app.get('/api/v1/roles/:id', async (request) => {
|
|
163
203
|
auth.authenticate(request);
|
|
164
204
|
return services.query.detail(request.params.id);
|
|
@@ -454,6 +494,16 @@ function clearAuthCookies(reply) {
|
|
|
454
494
|
'ofs_device=; HttpOnly; SameSite=Strict; Path=/api; Max-Age=0',
|
|
455
495
|
]);
|
|
456
496
|
}
|
|
497
|
+
function promoteRequest(body) {
|
|
498
|
+
const value = (body ?? {});
|
|
499
|
+
if (!Array.isArray(value.ids) || value.ids.some(id => typeof id !== 'string'))
|
|
500
|
+
throw new FleetError('invalid_request', 'ids must be a list of sketch ids');
|
|
501
|
+
return {
|
|
502
|
+
ids: value.ids,
|
|
503
|
+
configRevision: String(value.configRevision ?? ''),
|
|
504
|
+
draftRevision: typeof value.draftRevision === 'string' ? value.draftRevision : undefined,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
457
507
|
function cryptoRandomId() { return randomBytes(12).toString('hex'); }
|
|
458
508
|
function escapeHtml(value) {
|
|
459
509
|
return value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Problem } from '../application/types.js';
|
|
2
|
+
import type { TopologyNodeKind } from './topology.js';
|
|
3
|
+
/**
|
|
4
|
+
* Durable storage for *sketches*: nodes the owner has drawn but not yet added to
|
|
5
|
+
* the fleet, and where every node sits on the canvas.
|
|
6
|
+
*
|
|
7
|
+
* This file is deliberately invisible to `loadConfig`. An incomplete agent in
|
|
8
|
+
* `roles:` would be started by `ours-fleet up`, and an incomplete watchdog or
|
|
9
|
+
* loop is a hard `ConfigError` that makes the entire fleet unloadable — so a
|
|
10
|
+
* draft cannot live in fleet.yaml at all. Being outside the config also makes
|
|
11
|
+
* "visible but not launchable" true at the daemon level rather than in the UI.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here is authoritative and nothing here is a secret: the schema admits
|
|
14
|
+
* only presentational coordinates and a small allowlist of plain draft fields,
|
|
15
|
+
* so `env:`/`vars:` values can never reach it.
|
|
16
|
+
*/
|
|
17
|
+
export declare const TOPOLOGY_DRAFT_VERSION = 1;
|
|
18
|
+
export type DraftFieldValue = string | number | boolean;
|
|
19
|
+
export interface DraftPosition {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
}
|
|
23
|
+
export interface DraftNode {
|
|
24
|
+
id: string;
|
|
25
|
+
kind: TopologyNodeKind;
|
|
26
|
+
fields: Record<string, DraftFieldValue>;
|
|
27
|
+
}
|
|
28
|
+
export interface DraftEdge {
|
|
29
|
+
kind: 'oversees' | 'watches' | 'targets';
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
}
|
|
33
|
+
export interface DraftTutorial {
|
|
34
|
+
step: number;
|
|
35
|
+
dismissed: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface TopologyDraft {
|
|
38
|
+
version: number;
|
|
39
|
+
positions: Record<string, DraftPosition>;
|
|
40
|
+
drafts: {
|
|
41
|
+
nodes: DraftNode[];
|
|
42
|
+
edges: DraftEdge[];
|
|
43
|
+
};
|
|
44
|
+
tutorial: DraftTutorial;
|
|
45
|
+
}
|
|
46
|
+
export interface TopologyDraftRead {
|
|
47
|
+
draft: TopologyDraft;
|
|
48
|
+
revision: string;
|
|
49
|
+
/** Set when the stored file was unusable and an empty draft is being served. */
|
|
50
|
+
problem?: Problem;
|
|
51
|
+
/** False when the file was written by a newer console and must not be clobbered. */
|
|
52
|
+
writable: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface TopologyDraftWriteResult {
|
|
55
|
+
draft: TopologyDraft;
|
|
56
|
+
revision: string;
|
|
57
|
+
}
|
|
58
|
+
export declare const emptyDraft: () => TopologyDraft;
|
|
59
|
+
export interface TopologyDraftStoreOptions {
|
|
60
|
+
dir?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare class TopologyDraftStore {
|
|
63
|
+
readonly path: string;
|
|
64
|
+
private readonly dir;
|
|
65
|
+
constructor(options?: TopologyDraftStoreOptions);
|
|
66
|
+
/**
|
|
67
|
+
* Never throws. A missing, unreadable, oversized, malformed or future-version
|
|
68
|
+
* sidecar degrades to an empty draft plus a `problem` the console can show as
|
|
69
|
+
* a banner — losing sketches is bad, but blocking the console on them is worse.
|
|
70
|
+
*/
|
|
71
|
+
read(): TopologyDraftRead;
|
|
72
|
+
/**
|
|
73
|
+
* Revision-guarded, atomic, 0600. Strict on the way in: an out-of-bounds
|
|
74
|
+
* coordinate, an unknown field or an unparseable id is refused with a reason
|
|
75
|
+
* rather than silently dropped, because the caller is the console's own
|
|
76
|
+
* editor and a silent drop would look like data loss.
|
|
77
|
+
*/
|
|
78
|
+
write(baseRevision: string, next: unknown): Promise<TopologyDraftWriteResult>;
|
|
79
|
+
private readRaw;
|
|
80
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
5
|
+
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { FleetError } from '../application/errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* Durable storage for *sketches*: nodes the owner has drawn but not yet added to
|
|
9
|
+
* the fleet, and where every node sits on the canvas.
|
|
10
|
+
*
|
|
11
|
+
* This file is deliberately invisible to `loadConfig`. An incomplete agent in
|
|
12
|
+
* `roles:` would be started by `ours-fleet up`, and an incomplete watchdog or
|
|
13
|
+
* loop is a hard `ConfigError` that makes the entire fleet unloadable — so a
|
|
14
|
+
* draft cannot live in fleet.yaml at all. Being outside the config also makes
|
|
15
|
+
* "visible but not launchable" true at the daemon level rather than in the UI.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is authoritative and nothing here is a secret: the schema admits
|
|
18
|
+
* only presentational coordinates and a small allowlist of plain draft fields,
|
|
19
|
+
* so `env:`/`vars:` values can never reach it.
|
|
20
|
+
*/
|
|
21
|
+
export const TOPOLOGY_DRAFT_VERSION = 1;
|
|
22
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
23
|
+
const MAX_NODES = 500;
|
|
24
|
+
const MAX_EDGES = 2000;
|
|
25
|
+
const MAX_POSITIONS = 2000;
|
|
26
|
+
const MAX_COORDINATE = 100_000;
|
|
27
|
+
const MAX_FIELDS_PER_NODE = 32;
|
|
28
|
+
const MAX_FIELD_LENGTH = 16 * 1024;
|
|
29
|
+
const MAX_TUTORIAL_STEP = 32;
|
|
30
|
+
const NODE_ID_RE = /^(agent|watchdog|loop):[A-Za-z0-9_-]{1,64}$/;
|
|
31
|
+
const NODE_KINDS = ['agent', 'watchdog', 'loop'];
|
|
32
|
+
/** `spawned` is runtime provenance, never a drawn edge. */
|
|
33
|
+
const EDGE_KINDS = ['oversees', 'watches', 'targets'];
|
|
34
|
+
/**
|
|
35
|
+
* Draft fields the console may persist. An allowlist rather than a denylist so
|
|
36
|
+
* secret-bearing keys (`env`, `vars`, `harness_options`) are excluded by
|
|
37
|
+
* construction; extend it deliberately when the inspector grows a field.
|
|
38
|
+
*
|
|
39
|
+
* Every entry is a plain scalar that promotion can write straight into a role,
|
|
40
|
+
* watchdog or loop mapping. Nested settings (`permissions:`, `harness_options:`,
|
|
41
|
+
* `isolation:`) are deliberately absent: they are edited through the reviewed
|
|
42
|
+
* configuration editor once the node is real, not sketched.
|
|
43
|
+
*/
|
|
44
|
+
const FIELD_KEYS = [
|
|
45
|
+
'mission', 'bio', 'persona', 'coordinator', 'prompt',
|
|
46
|
+
'interval', 'initial_delay', 'jitter', 'enabled',
|
|
47
|
+
'harness', 'session', 'model', 'identity', 'cwd',
|
|
48
|
+
];
|
|
49
|
+
export const emptyDraft = () => ({
|
|
50
|
+
version: TOPOLOGY_DRAFT_VERSION,
|
|
51
|
+
positions: {},
|
|
52
|
+
drafts: { nodes: [], edges: [] },
|
|
53
|
+
tutorial: { step: 0, dismissed: false },
|
|
54
|
+
});
|
|
55
|
+
export class TopologyDraftStore {
|
|
56
|
+
path;
|
|
57
|
+
dir;
|
|
58
|
+
constructor(options = {}) {
|
|
59
|
+
this.dir = options.dir ?? join(stateRoot(), 'web');
|
|
60
|
+
this.path = join(this.dir, 'topology.json');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Never throws. A missing, unreadable, oversized, malformed or future-version
|
|
64
|
+
* sidecar degrades to an empty draft plus a `problem` the console can show as
|
|
65
|
+
* a banner — losing sketches is bad, but blocking the console on them is worse.
|
|
66
|
+
*/
|
|
67
|
+
read() {
|
|
68
|
+
const raw = this.readRaw();
|
|
69
|
+
const revision = digest(raw.text);
|
|
70
|
+
if (raw.problem)
|
|
71
|
+
return { draft: emptyDraft(), revision, problem: raw.problem, writable: true };
|
|
72
|
+
if (raw.text === '')
|
|
73
|
+
return { draft: emptyDraft(), revision, writable: true };
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw.text);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
draft: emptyDraft(), revision, writable: true,
|
|
81
|
+
problem: problem('draft_corrupt', `topology drafts are unreadable and were ignored: ${error.message}`),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const version = parsed?.version;
|
|
85
|
+
if (typeof version === 'number' && version > TOPOLOGY_DRAFT_VERSION)
|
|
86
|
+
return {
|
|
87
|
+
draft: emptyDraft(), revision, writable: false,
|
|
88
|
+
problem: problem('draft_version_unsupported', `topology drafts were written by a newer console (version ${version}); update ours-fleet to edit them`),
|
|
89
|
+
};
|
|
90
|
+
return { draft: coerceDraft(parsed), revision, writable: true };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Revision-guarded, atomic, 0600. Strict on the way in: an out-of-bounds
|
|
94
|
+
* coordinate, an unknown field or an unparseable id is refused with a reason
|
|
95
|
+
* rather than silently dropped, because the caller is the console's own
|
|
96
|
+
* editor and a silent drop would look like data loss.
|
|
97
|
+
*/
|
|
98
|
+
async write(baseRevision, next) {
|
|
99
|
+
const draft = validateDraft(next);
|
|
100
|
+
const text = `${JSON.stringify(draft, null, 2)}\n`;
|
|
101
|
+
if (Buffer.byteLength(text) > MAX_FILE_BYTES)
|
|
102
|
+
throw new FleetError('invalid_request', `topology drafts exceed ${MAX_FILE_BYTES} bytes`);
|
|
103
|
+
return withFileLock(`${this.path}.lock`, () => {
|
|
104
|
+
const current = this.read();
|
|
105
|
+
if (!current.writable)
|
|
106
|
+
throw new FleetError('incompatible_version', current.problem?.detail ?? 'topology drafts are not writable');
|
|
107
|
+
if (!baseRevision || baseRevision !== current.revision)
|
|
108
|
+
throw new FleetError('stale_state', 'topology drafts changed since they were opened; reload before saving');
|
|
109
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
110
|
+
chmodSync(this.dir, 0o700);
|
|
111
|
+
replaceFileAtomically(this.path, text, 0o600);
|
|
112
|
+
return { draft, revision: digest(text) };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
readRaw() {
|
|
116
|
+
if (!existsSync(this.path))
|
|
117
|
+
return { text: '' };
|
|
118
|
+
try {
|
|
119
|
+
const stat = lstatSync(this.path);
|
|
120
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
121
|
+
return { text: '', problem: problem('draft_unreadable', 'topology drafts must be a regular non-symlink file') };
|
|
122
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
123
|
+
return { text: '', problem: problem('draft_unreadable', `topology drafts exceed ${MAX_FILE_BYTES} bytes and were ignored`) };
|
|
124
|
+
const uid = process.getuid?.();
|
|
125
|
+
if (uid !== undefined && stat.uid !== uid)
|
|
126
|
+
return { text: '', problem: problem('draft_unreadable', 'topology drafts are not owned by the current user') };
|
|
127
|
+
return { text: readFileSync(this.path, 'utf8') };
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return { text: '', problem: problem('draft_unreadable', `topology drafts could not be read: ${error.message}`) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const problem = (code, detail) => ({ code, severity: 'warning', detail, source: 'topology.json' });
|
|
135
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex');
|
|
136
|
+
/* ------------------------------------------------------------------ *
|
|
137
|
+
* Lenient read coercion — drop what does not fit, keep the rest.
|
|
138
|
+
* ------------------------------------------------------------------ */
|
|
139
|
+
function coerceDraft(value) {
|
|
140
|
+
const draft = emptyDraft();
|
|
141
|
+
if (!isRecord(value))
|
|
142
|
+
return draft;
|
|
143
|
+
const positions = value.positions;
|
|
144
|
+
if (isRecord(positions))
|
|
145
|
+
for (const [id, raw] of Object.entries(positions).slice(0, MAX_POSITIONS)) {
|
|
146
|
+
if (!NODE_ID_RE.test(id) || !isRecord(raw))
|
|
147
|
+
continue;
|
|
148
|
+
if (!isCoordinate(raw.x) || !isCoordinate(raw.y))
|
|
149
|
+
continue;
|
|
150
|
+
draft.positions[id] = { x: raw.x, y: raw.y };
|
|
151
|
+
}
|
|
152
|
+
const drafts = isRecord(value.drafts) ? value.drafts : {};
|
|
153
|
+
const seenNodes = new Set();
|
|
154
|
+
if (Array.isArray(drafts.nodes))
|
|
155
|
+
for (const raw of drafts.nodes) {
|
|
156
|
+
if (draft.drafts.nodes.length >= MAX_NODES)
|
|
157
|
+
break;
|
|
158
|
+
const node = coerceNode(raw);
|
|
159
|
+
if (!node || seenNodes.has(node.id))
|
|
160
|
+
continue;
|
|
161
|
+
seenNodes.add(node.id);
|
|
162
|
+
draft.drafts.nodes.push(node);
|
|
163
|
+
}
|
|
164
|
+
const seenEdges = new Set();
|
|
165
|
+
if (Array.isArray(drafts.edges))
|
|
166
|
+
for (const raw of drafts.edges) {
|
|
167
|
+
if (draft.drafts.edges.length >= MAX_EDGES)
|
|
168
|
+
break;
|
|
169
|
+
const edge = coerceEdge(raw);
|
|
170
|
+
if (!edge)
|
|
171
|
+
continue;
|
|
172
|
+
const key = `${edge.kind}:${edge.from}:${edge.to}`;
|
|
173
|
+
if (seenEdges.has(key))
|
|
174
|
+
continue;
|
|
175
|
+
seenEdges.add(key);
|
|
176
|
+
draft.drafts.edges.push(edge);
|
|
177
|
+
}
|
|
178
|
+
if (isRecord(value.tutorial)) {
|
|
179
|
+
const { step, dismissed } = value.tutorial;
|
|
180
|
+
if (typeof step === 'number' && Number.isInteger(step) && step >= 0 && step <= MAX_TUTORIAL_STEP)
|
|
181
|
+
draft.tutorial.step = step;
|
|
182
|
+
draft.tutorial.dismissed = dismissed === true;
|
|
183
|
+
}
|
|
184
|
+
return draft;
|
|
185
|
+
}
|
|
186
|
+
function coerceNode(value) {
|
|
187
|
+
if (!isRecord(value))
|
|
188
|
+
return undefined;
|
|
189
|
+
const { id, kind } = value;
|
|
190
|
+
if (typeof id !== 'string' || !NODE_ID_RE.test(id))
|
|
191
|
+
return undefined;
|
|
192
|
+
if (typeof kind !== 'string' || !NODE_KINDS.includes(kind))
|
|
193
|
+
return undefined;
|
|
194
|
+
if (!id.startsWith(`${kind}:`))
|
|
195
|
+
return undefined;
|
|
196
|
+
const fields = {};
|
|
197
|
+
if (isRecord(value.fields))
|
|
198
|
+
for (const [key, raw] of Object.entries(value.fields)) {
|
|
199
|
+
if (Object.keys(fields).length >= MAX_FIELDS_PER_NODE)
|
|
200
|
+
break;
|
|
201
|
+
if (!FIELD_KEYS.includes(key) || !isFieldValue(raw))
|
|
202
|
+
continue;
|
|
203
|
+
fields[key] = raw;
|
|
204
|
+
}
|
|
205
|
+
return { id, kind: kind, fields };
|
|
206
|
+
}
|
|
207
|
+
function coerceEdge(value) {
|
|
208
|
+
if (!isRecord(value))
|
|
209
|
+
return undefined;
|
|
210
|
+
const { kind, from, to } = value;
|
|
211
|
+
if (typeof kind !== 'string' || !EDGE_KINDS.includes(kind))
|
|
212
|
+
return undefined;
|
|
213
|
+
if (typeof from !== 'string' || !NODE_ID_RE.test(from))
|
|
214
|
+
return undefined;
|
|
215
|
+
if (typeof to !== 'string' || !NODE_ID_RE.test(to) || from === to)
|
|
216
|
+
return undefined;
|
|
217
|
+
return { kind: kind, from, to };
|
|
218
|
+
}
|
|
219
|
+
/* ------------------------------------------------------------------ *
|
|
220
|
+
* Strict write validation — refuse with a reason.
|
|
221
|
+
* ------------------------------------------------------------------ */
|
|
222
|
+
function validateDraft(value) {
|
|
223
|
+
if (!isRecord(value))
|
|
224
|
+
throw invalid('topology draft must be a JSON object');
|
|
225
|
+
const version = value.version ?? TOPOLOGY_DRAFT_VERSION;
|
|
226
|
+
if (version !== TOPOLOGY_DRAFT_VERSION)
|
|
227
|
+
throw invalid(`unsupported topology draft version ${String(version)}`);
|
|
228
|
+
const draft = emptyDraft();
|
|
229
|
+
const positions = value.positions ?? {};
|
|
230
|
+
if (!isRecord(positions))
|
|
231
|
+
throw invalid('positions must be an object');
|
|
232
|
+
const positionIds = Object.keys(positions);
|
|
233
|
+
if (positionIds.length > MAX_POSITIONS)
|
|
234
|
+
throw invalid(`at most ${MAX_POSITIONS} node positions are supported`);
|
|
235
|
+
for (const id of positionIds) {
|
|
236
|
+
if (!NODE_ID_RE.test(id))
|
|
237
|
+
throw invalid(`invalid node id in positions: ${id}`);
|
|
238
|
+
const raw = positions[id];
|
|
239
|
+
if (!isRecord(raw) || !isCoordinate(raw.x) || !isCoordinate(raw.y))
|
|
240
|
+
throw invalid(`position for ${id} must be finite x/y within ±${MAX_COORDINATE}`);
|
|
241
|
+
draft.positions[id] = { x: raw.x, y: raw.y };
|
|
242
|
+
}
|
|
243
|
+
const drafts = value.drafts ?? {};
|
|
244
|
+
if (!isRecord(drafts))
|
|
245
|
+
throw invalid('drafts must be an object');
|
|
246
|
+
const nodes = drafts.nodes ?? [];
|
|
247
|
+
const edges = drafts.edges ?? [];
|
|
248
|
+
if (!Array.isArray(nodes))
|
|
249
|
+
throw invalid('drafts.nodes must be an array');
|
|
250
|
+
if (!Array.isArray(edges))
|
|
251
|
+
throw invalid('drafts.edges must be an array');
|
|
252
|
+
if (nodes.length > MAX_NODES)
|
|
253
|
+
throw invalid(`at most ${MAX_NODES} draft nodes are supported`);
|
|
254
|
+
if (edges.length > MAX_EDGES)
|
|
255
|
+
throw invalid(`at most ${MAX_EDGES} draft edges are supported`);
|
|
256
|
+
const seenNodes = new Set();
|
|
257
|
+
for (const raw of nodes) {
|
|
258
|
+
const node = validateNode(raw);
|
|
259
|
+
if (seenNodes.has(node.id))
|
|
260
|
+
throw invalid(`duplicate draft node ${node.id}`);
|
|
261
|
+
seenNodes.add(node.id);
|
|
262
|
+
draft.drafts.nodes.push(node);
|
|
263
|
+
}
|
|
264
|
+
const seenEdges = new Set();
|
|
265
|
+
for (const raw of edges) {
|
|
266
|
+
const edge = validateEdge(raw);
|
|
267
|
+
const key = `${edge.kind}:${edge.from}:${edge.to}`;
|
|
268
|
+
if (seenEdges.has(key))
|
|
269
|
+
continue;
|
|
270
|
+
seenEdges.add(key);
|
|
271
|
+
draft.drafts.edges.push(edge);
|
|
272
|
+
}
|
|
273
|
+
const tutorial = value.tutorial ?? {};
|
|
274
|
+
if (!isRecord(tutorial))
|
|
275
|
+
throw invalid('tutorial must be an object');
|
|
276
|
+
const step = tutorial.step ?? 0;
|
|
277
|
+
if (typeof step !== 'number' || !Number.isInteger(step) || step < 0 || step > MAX_TUTORIAL_STEP)
|
|
278
|
+
throw invalid(`tutorial.step must be an integer between 0 and ${MAX_TUTORIAL_STEP}`);
|
|
279
|
+
draft.tutorial = { step, dismissed: tutorial.dismissed === true };
|
|
280
|
+
return draft;
|
|
281
|
+
}
|
|
282
|
+
function validateNode(value) {
|
|
283
|
+
if (!isRecord(value))
|
|
284
|
+
throw invalid('each draft node must be an object');
|
|
285
|
+
const { id, kind } = value;
|
|
286
|
+
if (typeof id !== 'string' || !NODE_ID_RE.test(id))
|
|
287
|
+
throw invalid(`draft node id must look like agent:<Name>: ${JSON.stringify(id)}`);
|
|
288
|
+
if (typeof kind !== 'string' || !NODE_KINDS.includes(kind))
|
|
289
|
+
throw invalid(`draft node ${id} has an unknown kind ${JSON.stringify(kind)}`);
|
|
290
|
+
if (!id.startsWith(`${kind}:`))
|
|
291
|
+
throw invalid(`draft node ${id} does not match kind ${kind}`);
|
|
292
|
+
const rawFields = value.fields ?? {};
|
|
293
|
+
if (!isRecord(rawFields))
|
|
294
|
+
throw invalid(`draft node ${id} fields must be an object`);
|
|
295
|
+
const keys = Object.keys(rawFields);
|
|
296
|
+
if (keys.length > MAX_FIELDS_PER_NODE)
|
|
297
|
+
throw invalid(`draft node ${id} has more than ${MAX_FIELDS_PER_NODE} fields`);
|
|
298
|
+
const fields = {};
|
|
299
|
+
for (const key of keys) {
|
|
300
|
+
if (!FIELD_KEYS.includes(key))
|
|
301
|
+
throw invalid(`draft node ${id} may not store field ${JSON.stringify(key)}`);
|
|
302
|
+
const raw = rawFields[key];
|
|
303
|
+
if (!isFieldValue(raw))
|
|
304
|
+
throw invalid(`draft node ${id} field ${key} must be a string under ${MAX_FIELD_LENGTH} characters, a finite number, or a boolean`);
|
|
305
|
+
fields[key] = raw;
|
|
306
|
+
}
|
|
307
|
+
return { id, kind: kind, fields };
|
|
308
|
+
}
|
|
309
|
+
function validateEdge(value) {
|
|
310
|
+
if (!isRecord(value))
|
|
311
|
+
throw invalid('each draft edge must be an object');
|
|
312
|
+
const { kind, from, to } = value;
|
|
313
|
+
if (typeof kind !== 'string' || !EDGE_KINDS.includes(kind))
|
|
314
|
+
throw invalid(`draft edge has an unknown kind ${JSON.stringify(kind)}`);
|
|
315
|
+
if (typeof from !== 'string' || !NODE_ID_RE.test(from))
|
|
316
|
+
throw invalid(`invalid draft edge source ${JSON.stringify(from)}`);
|
|
317
|
+
if (typeof to !== 'string' || !NODE_ID_RE.test(to))
|
|
318
|
+
throw invalid(`invalid draft edge target ${JSON.stringify(to)}`);
|
|
319
|
+
if (from === to)
|
|
320
|
+
throw invalid(`draft edge ${from} cannot point at itself`);
|
|
321
|
+
return { kind: kind, from, to };
|
|
322
|
+
}
|
|
323
|
+
const invalid = (message) => new FleetError('invalid_request', message);
|
|
324
|
+
function isRecord(value) {
|
|
325
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
326
|
+
}
|
|
327
|
+
function isCoordinate(value) {
|
|
328
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
329
|
+
&& value >= -MAX_COORDINATE && value <= MAX_COORDINATE;
|
|
330
|
+
}
|
|
331
|
+
function isFieldValue(value) {
|
|
332
|
+
if (typeof value === 'boolean')
|
|
333
|
+
return true;
|
|
334
|
+
if (typeof value === 'number')
|
|
335
|
+
return Number.isFinite(value);
|
|
336
|
+
return typeof value === 'string' && value.length <= MAX_FIELD_LENGTH;
|
|
337
|
+
}
|