@ours.network/fleet 0.15.7 → 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.
@@ -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;
@@ -0,0 +1,220 @@
1
+ import { deriveTopology, } from './topology.js';
2
+ export const IMPLICIT_WATCH_LABEL = 'watches all agents (default)';
3
+ const AGENT_MISSION = {
4
+ field: 'mission',
5
+ why: 'An agent without a mission has nothing to do and reads as noise on the canvas.',
6
+ fix: 'Write one sentence about this agent\'s job.',
7
+ };
8
+ const WATCHDOG_COORDINATOR = {
9
+ field: 'coordinator',
10
+ why: 'A watchdog reports to exactly one coordinator; the fleet refuses to load without it.',
11
+ fix: 'Name the agent this watchdog reports to.',
12
+ };
13
+ const LOOP_PROMPT = {
14
+ field: 'prompt',
15
+ why: 'An interval delivers a prompt on a schedule; there is nothing to deliver yet.',
16
+ fix: 'Write the prompt this interval should send.',
17
+ };
18
+ const LOOP_TARGET = {
19
+ field: 'roles',
20
+ why: 'An interval needs at least one agent to deliver its prompt to.',
21
+ fix: 'Connect this interval to an agent.',
22
+ };
23
+ /**
24
+ * Merge configuration and drafts into the graph the console renders.
25
+ *
26
+ * Drift is surfaced, never silently repaired: a draft whose name is now taken
27
+ * by real configuration is reported as a problem rather than shadowing it, a
28
+ * draft edge to a vanished node is kept and marked dangling, and a position for
29
+ * a node that no longer exists is simply dropped.
30
+ */
31
+ export function mergeTopology(config, roles, draft) {
32
+ const snapshot = deriveTopology(config, roles);
33
+ const problems = draft.problem ? [draft.problem] : [];
34
+ const configIds = new Set(snapshot.nodes.map(node => node.id));
35
+ const shadowed = draft.draft.drafts.nodes.filter(node => configIds.has(node.id));
36
+ if (shadowed.length)
37
+ problems.push({
38
+ code: 'draft_conflicts_with_config',
39
+ severity: 'warning',
40
+ source: 'topology.json',
41
+ detail: `${shadowed.map(node => node.id).join(', ')} now exist${shadowed.length === 1 ? 's' : ''} in the fleet configuration; rename the draft to keep sketching it`,
42
+ });
43
+ const draftNodes = draft.draft.drafts.nodes.filter(node => !configIds.has(node.id));
44
+ const draftEdges = draft.draft.drafts.edges;
45
+ const nodes = [
46
+ ...snapshot.nodes.map(node => configNode(node, config)),
47
+ ...draftNodes.map(node => draftNode(node, draftEdges, draftNodes, config)),
48
+ ];
49
+ const known = new Map(nodes.map(node => [node.id, node]));
50
+ const implicitConfigWatchdogs = new Set(config.watchdogs.filter(watchdog => !watchdog.watchExplicit).map(watchdog => `watchdog:${watchdog.name}`));
51
+ const edges = [
52
+ ...snapshot.edges.map(edge => annotate(edge, 'config', edge.kind === 'watches' && implicitConfigWatchdogs.has(edge.from), known)),
53
+ ...draftEdges.map(edge => annotate({
54
+ id: `${edge.kind}:${edge.from}:${edge.to}`, kind: edge.kind,
55
+ from: edge.from, to: edge.to, label: edgeLabel(edge.kind),
56
+ }, 'draft', false, known)),
57
+ ...implicitDraftWatchEdges(draftNodes, draftEdges, nodes, known),
58
+ ];
59
+ for (const node of nodes) {
60
+ const position = draft.draft.positions[node.id];
61
+ if (position)
62
+ node.position = position;
63
+ }
64
+ return {
65
+ nodes,
66
+ edges,
67
+ unknownLineage: snapshot.unknownLineage,
68
+ problems,
69
+ draftRevision: draft.revision,
70
+ draftWritable: draft.writable,
71
+ };
72
+ }
73
+ /* ------------------------------------------------------------------ *
74
+ * Configured nodes
75
+ * ------------------------------------------------------------------ */
76
+ function configNode(node, config) {
77
+ const name = node.id.slice(node.kind.length + 1);
78
+ const missing = [];
79
+ let enabled;
80
+ if (node.kind === 'agent') {
81
+ const role = config.roles.find(candidate => candidate.name === name);
82
+ // A role reachable only through its on-disk state directory is running
83
+ // configuration we cannot inspect; do not invent a gap for it.
84
+ if (role && !nonBlank(role.mission))
85
+ missing.push(AGENT_MISSION);
86
+ }
87
+ else if (node.kind === 'watchdog') {
88
+ enabled = config.watchdogs.find(candidate => candidate.name === name)?.enabled;
89
+ }
90
+ else {
91
+ enabled = config.loops.find(candidate => candidate.name === name)?.enabled;
92
+ }
93
+ const complete = missing.length === 0;
94
+ return { ...node, origin: 'config', valid: true, complete, launchable: complete, missing, enabled };
95
+ }
96
+ /* ------------------------------------------------------------------ *
97
+ * Draft nodes
98
+ * ------------------------------------------------------------------ */
99
+ function draftNode(node, edges, draftNodes, config) {
100
+ const name = node.id.slice(node.kind.length + 1);
101
+ const missing = [];
102
+ const collision = nameConflict(node.kind, name, config);
103
+ if (collision)
104
+ missing.push({
105
+ field: 'name',
106
+ why: `The fleet already uses the name "${name}" for ${collision}.`,
107
+ fix: 'Rename this draft before adding it to the fleet.',
108
+ });
109
+ const draftIds = new Set(draftNodes.map(other => other.id));
110
+ const outgoing = edges.filter(edge => edge.from === node.id);
111
+ const stillDraft = (kind) => outgoing
112
+ .filter(edge => edge.kind === kind && draftIds.has(edge.to))
113
+ .map(edge => edge.to.slice('agent:'.length));
114
+ if (node.kind === 'agent') {
115
+ if (!nonBlank(node.fields.mission))
116
+ missing.push(AGENT_MISSION);
117
+ }
118
+ else if (node.kind === 'watchdog') {
119
+ if (!nonBlank(node.fields.coordinator))
120
+ missing.push(WATCHDOG_COORDINATOR);
121
+ pushPendingTargets(missing, 'watch', stillDraft('watches'), 'A watchdog may only watch agents that are already in the fleet.');
122
+ }
123
+ else {
124
+ if (!outgoing.some(edge => edge.kind === 'targets'))
125
+ missing.push(LOOP_TARGET);
126
+ if (!nonBlank(node.fields.prompt))
127
+ missing.push(LOOP_PROMPT);
128
+ pushPendingTargets(missing, 'roles', stillDraft('targets'), 'An interval may only deliver to agents that are already in the fleet.');
129
+ }
130
+ return {
131
+ id: node.id,
132
+ kind: node.kind,
133
+ label: name,
134
+ status: 'draft',
135
+ detail: draftDetail(node),
136
+ origin: 'draft',
137
+ valid: !collision,
138
+ complete: missing.length === 0,
139
+ launchable: false, // a draft is in no file the supervisor reads
140
+ missing,
141
+ fields: node.fields,
142
+ enabled: node.fields.enabled === undefined ? undefined : node.fields.enabled !== false,
143
+ };
144
+ }
145
+ function pushPendingTargets(missing, field, pending, why) {
146
+ if (!pending.length)
147
+ return;
148
+ missing.push({
149
+ field, why,
150
+ fix: `Add ${pending.join(', ')} to the fleet first, or remove the connection.`,
151
+ });
152
+ }
153
+ function draftDetail(node) {
154
+ const value = node.kind === 'agent' ? node.fields.mission
155
+ : node.kind === 'watchdog' ? node.fields.coordinator && `Reports to ${node.fields.coordinator}`
156
+ : node.fields.prompt;
157
+ return nonBlank(value) ? String(value) : undefined;
158
+ }
159
+ /* ------------------------------------------------------------------ *
160
+ * Edges
161
+ * ------------------------------------------------------------------ */
162
+ function annotate(edge, origin, implicit, known) {
163
+ return {
164
+ ...edge,
165
+ label: implicit ? IMPLICIT_WATCH_LABEL : edge.label,
166
+ origin,
167
+ implicit,
168
+ dangling: !known.has(edge.from) || !known.has(edge.to),
169
+ };
170
+ }
171
+ /**
172
+ * A draft watchdog nobody scoped to a single agent is a *standalone* watchdog:
173
+ * promoting it omits `watch:`, which the config layer reads as "every role".
174
+ * Draw that so the owner sees the coverage before adding it, and so an agent
175
+ * added later is visibly covered with no edit at all.
176
+ */
177
+ function implicitDraftWatchEdges(draftNodes, draftEdges, nodes, known) {
178
+ const persistentAgents = nodes.filter(node => node.kind === 'agent' && node.lifetime !== 'temporary');
179
+ return draftNodes
180
+ .filter(node => node.kind === 'watchdog'
181
+ && !draftEdges.some(edge => edge.kind === 'watches' && edge.from === node.id))
182
+ .flatMap(watchdog => persistentAgents.map(agent => annotate({
183
+ id: `watches:${watchdog.id}:${agent.id}`, kind: 'watches',
184
+ from: watchdog.id, to: agent.id, label: IMPLICIT_WATCH_LABEL,
185
+ }, 'draft', true, known)));
186
+ }
187
+ const edgeLabel = (kind) => (kind === 'watches' ? 'watches' : kind === 'targets' ? 'delivers to' : 'oversees');
188
+ /* ------------------------------------------------------------------ *
189
+ * Shared
190
+ * ------------------------------------------------------------------ */
191
+ /**
192
+ * What already owns `name`, or undefined when a draft of this kind may use it.
193
+ *
194
+ * Deliberately mirrors the collisions the config layer actually rejects and no
195
+ * others: a watchdog name may not equal a role name, and a watchdog identity
196
+ * (`Watchdog-<name>` by default) may not equal a role name, a role identity or
197
+ * another watchdog identity. Loop names share no namespace with anything, so a
198
+ * loop is only ever blocked by another loop of the same name.
199
+ */
200
+ function nameConflict(kind, name, config) {
201
+ if (kind === 'loop')
202
+ return undefined;
203
+ if (kind === 'agent') {
204
+ // Adding a role named N breaks any watchdog already using N as its identity.
205
+ if (config.watchdogs.some(watchdog => watchdog.name === name))
206
+ return 'a watchdog';
207
+ const identity = config.watchdogs.find(watchdog => watchdog.identity === name);
208
+ return identity ? `watchdog ${identity.name}'s identity` : undefined;
209
+ }
210
+ if (config.roles.some(role => role.name === name))
211
+ return 'a role';
212
+ const identity = `Watchdog-${name}`;
213
+ if (config.roles.some(role => role.name === identity))
214
+ return `a role, which this watchdog's identity "${identity}" would collide with`;
215
+ if (config.roles.some(role => role.identity === identity))
216
+ return `a role identity, which this watchdog's identity "${identity}" would collide with`;
217
+ const other = config.watchdogs.find(watchdog => watchdog.identity === identity);
218
+ return other ? `watchdog ${other.name}'s identity "${identity}"` : undefined;
219
+ }
220
+ const nonBlank = (value) => typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null;
@@ -0,0 +1,31 @@
1
+ import type { ConfigPreviewResult, ConfigWriteResult, FleetConfigService } from './fleet-config-service.js';
2
+ import type { TopologyDraftStore } from './topology-draft-store.js';
3
+ import type { MergedTopology } from './topology-model.js';
4
+ export interface PromoteRequest {
5
+ ids: string[];
6
+ configRevision: string;
7
+ draftRevision?: string;
8
+ }
9
+ export interface PromotePreview extends ConfigPreviewResult {
10
+ promoted: string[];
11
+ }
12
+ export interface PromoteResult extends ConfigWriteResult {
13
+ promoted: string[];
14
+ /** False when the config landed but the sketches could not be cleared. */
15
+ draftsCleared: boolean;
16
+ draftRevision: string;
17
+ }
18
+ export interface TopologyPromoteOptions {
19
+ drafts: TopologyDraftStore;
20
+ configuration: FleetConfigService;
21
+ topology(): Promise<MergedTopology>;
22
+ }
23
+ export declare class TopologyPromoteService {
24
+ private readonly options;
25
+ constructor(options: TopologyPromoteOptions);
26
+ preview(request: PromoteRequest): Promise<PromotePreview>;
27
+ promote(request: PromoteRequest): Promise<PromoteResult>;
28
+ private clearDrafts;
29
+ /** Build the configuration model that adding `ids` produces. */
30
+ private build;
31
+ }