@ours.network/fleet 0.15.7 → 0.17.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.
Files changed (36) hide show
  1. package/README.md +29 -3
  2. package/dist/application/role-creation-service.d.ts +2 -2
  3. package/dist/config.d.ts +4 -2
  4. package/dist/config.js +3 -2
  5. package/dist/docs.d.ts +1 -1
  6. package/dist/docs.js +24 -4
  7. package/dist/monitor.d.ts +4 -3
  8. package/dist/monitor.js +11 -2
  9. package/dist/owner-channel/channel.js +4 -1
  10. package/dist/runner.js +20 -5
  11. package/dist/session/acp.d.ts +23 -0
  12. package/dist/session/acp.js +183 -10
  13. package/dist/session/arbiter.d.ts +5 -0
  14. package/dist/session/arbiter.js +8 -0
  15. package/dist/session/conversation-normalizer.d.ts +6 -0
  16. package/dist/session/conversation-normalizer.js +4 -3
  17. package/dist/session/conversation-types.d.ts +9 -2
  18. package/dist/session/types.d.ts +13 -1
  19. package/dist/web/fleet-config-service.js +51 -15
  20. package/dist/web/runtime.js +9 -2
  21. package/dist/web/server.d.ts +6 -2
  22. package/dist/web/server.js +50 -0
  23. package/dist/web/topology-draft-store.d.ts +80 -0
  24. package/dist/web/topology-draft-store.js +337 -0
  25. package/dist/web/topology-model.d.ts +62 -0
  26. package/dist/web/topology-model.js +220 -0
  27. package/dist/web/topology-promote.d.ts +31 -0
  28. package/dist/web/topology-promote.js +168 -0
  29. package/dist/web/yaml-document-edit.d.ts +31 -0
  30. package/dist/web/yaml-document-edit.js +401 -0
  31. package/dist/web-app/assets/{TerminalView-BCbgag6j.js → TerminalView-B3rnVWbo.js} +1 -1
  32. package/dist/web-app/assets/index-CliHATFt.js +10 -0
  33. package/dist/web-app/assets/{index-COg4Azq1.css → index-DuC-xnX4.css} +1 -1
  34. package/dist/web-app/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web-app/assets/index-BstfCP_F.js +0 -10
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import type { FleetConfig } from '../config.js';
2
+ import type { Problem } from '../application/types.js';
3
+ import { type RuntimeRoleItem, type TopologyEdge, type TopologyNode } from './topology.js';
4
+ import type { DraftFieldValue, DraftPosition, TopologyDraftRead } from './topology-draft-store.js';
5
+ /**
6
+ * The console's read model: authoritative configuration overlaid with the
7
+ * sketches and canvas positions from the draft sidecar.
8
+ *
9
+ * `deriveTopology` stays a pure function of resolved config plus the runtime
10
+ * role list — this layer calls it and annotates the result, so the derivation
11
+ * and its tests keep their meaning. Everything drafts add is additive.
12
+ */
13
+ export type NodeOrigin = 'draft' | 'config';
14
+ /** One actionable reason a node is not yet ready, phrased for the owner. */
15
+ export interface MissingRequirement {
16
+ field: string;
17
+ why: string;
18
+ fix: string;
19
+ }
20
+ export interface MergedTopologyNode extends TopologyNode {
21
+ origin: NodeOrigin;
22
+ /** Would `loadConfig` accept this node's name today. */
23
+ valid: boolean;
24
+ /** Meets the bar for being added to the fleet. */
25
+ complete: boolean;
26
+ /**
27
+ * Whether a process may be started for this node. A draft is never launchable,
28
+ * and not because the UI hides a button: a draft is in no file the supervisor
29
+ * or `ours-fleet up` reads.
30
+ */
31
+ launchable: boolean;
32
+ missing: MissingRequirement[];
33
+ position?: DraftPosition;
34
+ enabled?: boolean;
35
+ /** Sketched values, on draft nodes only — what promotion writes. */
36
+ fields?: Record<string, DraftFieldValue>;
37
+ }
38
+ export interface MergedTopologyEdge extends TopologyEdge {
39
+ origin: NodeOrigin;
40
+ /** A watchdog with no `watch:` list watches every agent, including later ones. */
41
+ implicit: boolean;
42
+ /** An endpoint that no longer exists — kept visible rather than swallowed. */
43
+ dangling: boolean;
44
+ }
45
+ export interface MergedTopology {
46
+ nodes: MergedTopologyNode[];
47
+ edges: MergedTopologyEdge[];
48
+ unknownLineage: string[];
49
+ problems: Problem[];
50
+ draftRevision: string;
51
+ draftWritable: boolean;
52
+ }
53
+ export declare const IMPLICIT_WATCH_LABEL = "watches all agents (default)";
54
+ /**
55
+ * Merge configuration and drafts into the graph the console renders.
56
+ *
57
+ * Drift is surfaced, never silently repaired: a draft whose name is now taken
58
+ * by real configuration is reported as a problem rather than shadowing it, a
59
+ * draft edge to a vanished node is kept and marked dangling, and a position for
60
+ * a node that no longer exists is simply dropped.
61
+ */
62
+ export declare function mergeTopology(config: FleetConfig, roles: RuntimeRoleItem[], draft: TopologyDraftRead): MergedTopology;