@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.
@@ -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 source = serialize(restored);
36
- const candidate = this.validateCandidate(source);
37
- const [redactedCurrent, redactedNext] = [redactModel(current), redactModel(restored)];
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: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
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 = serialize(restored);
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: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
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
- function serialize(model) {
114
- return stringify(model, { lineWidth: 0, sortMapEntries: false });
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
- function exactDiff(before, after) {
177
- if (before === after)
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
- return ['--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
180
- ...before.trimEnd().split('\n').map(line => `-${line}`),
181
- ...after.trimEnd().split('\n').map(line => `+${line}`), ''].join('\n');
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) : [];
@@ -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 { deriveTopology } from './topology.js';
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: async () => deriveTopology(loadConfig(options.configPath), await query.list()),
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,
@@ -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 { TopologySnapshot } from './topology.js';
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<TopologySnapshot>;
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
  }
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ }