@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { FleetError } from '../application/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turn sketches into configuration.
|
|
4
|
+
*
|
|
5
|
+
* Promotion is the middle step of sketch -> promote -> launch, and it writes
|
|
6
|
+
* configuration ONLY. It never provisions an identity, never registers a
|
|
7
|
+
* supervisor service and never starts a process: `Launch` is a separate, explicit
|
|
8
|
+
* action. That separation is what makes "visible but not launchable" true at the
|
|
9
|
+
* daemon level rather than as a UI rule.
|
|
10
|
+
*
|
|
11
|
+
* No YAML is written here. The mutation is expressed against the configuration
|
|
12
|
+
* *model* and handed to `FleetConfigService`, so the revision guard, the real
|
|
13
|
+
* loader validating a candidate file, the reviewed diff, the timestamped backup
|
|
14
|
+
* and the atomic 0600 replace are all impossible to bypass.
|
|
15
|
+
*/
|
|
16
|
+
/** Loop interval when the sketch did not choose one (`resolveLoops` requires it). */
|
|
17
|
+
const DEFAULT_LOOP_INTERVAL = '10m';
|
|
18
|
+
/** Scalar draft fields that are valid keys of a role mapping. */
|
|
19
|
+
const ROLE_FIELDS = ['mission', 'bio', 'persona', 'coordinator', 'harness', 'session', 'model', 'identity', 'cwd'];
|
|
20
|
+
/** Scalar draft fields that are valid keys of a watchdog mapping. */
|
|
21
|
+
const WATCHDOG_FIELDS = ['coordinator', 'interval', 'enabled'];
|
|
22
|
+
/** Scalar draft fields that are valid keys of a loop mapping. */
|
|
23
|
+
const LOOP_FIELDS = ['prompt', 'interval', 'initial_delay', 'jitter'];
|
|
24
|
+
export class TopologyPromoteService {
|
|
25
|
+
options;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
async preview(request) {
|
|
30
|
+
const { model, promoted } = await this.build(request);
|
|
31
|
+
const preview = await this.options.configuration.preview(request.configRevision, model);
|
|
32
|
+
return { ...preview, promoted };
|
|
33
|
+
}
|
|
34
|
+
async promote(request) {
|
|
35
|
+
const { model, promoted } = await this.build(request);
|
|
36
|
+
const written = await this.options.configuration.write(request.configRevision, model);
|
|
37
|
+
// The configuration is real now. Clearing the sketches is best effort: if the
|
|
38
|
+
// sidecar moved under us the drafts simply resurface as shadowed-by-config,
|
|
39
|
+
// which the merged model already reports, rather than losing the write.
|
|
40
|
+
const cleared = await this.clearDrafts(promoted, request.draftRevision);
|
|
41
|
+
return { ...written, promoted, draftsCleared: cleared.ok, draftRevision: cleared.revision };
|
|
42
|
+
}
|
|
43
|
+
async clearDrafts(promoted, draftRevision) {
|
|
44
|
+
const current = this.options.drafts.read();
|
|
45
|
+
if (draftRevision !== undefined && draftRevision !== current.revision)
|
|
46
|
+
return { ok: false, revision: current.revision };
|
|
47
|
+
const promotedIds = new Set(promoted);
|
|
48
|
+
const next = {
|
|
49
|
+
...current.draft,
|
|
50
|
+
drafts: {
|
|
51
|
+
nodes: current.draft.drafts.nodes.filter(node => !promotedIds.has(node.id)),
|
|
52
|
+
/*
|
|
53
|
+
* A drawn edge is owned by its SOURCE — it becomes the source's `watch:`
|
|
54
|
+
* or `roles:` list — so it is materialised, and therefore redundant, only
|
|
55
|
+
* once the source itself is written. Clearing it because the TARGET was
|
|
56
|
+
* promoted silently rewrites the survivor's meaning: a watchdog scoped to
|
|
57
|
+
* one agent loses its only scope edge, reads as standalone, and is then
|
|
58
|
+
* written with no `watch:` key at all — that is, watching everything.
|
|
59
|
+
*/
|
|
60
|
+
edges: current.draft.drafts.edges.filter(edge => !promotedIds.has(edge.from)),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
const written = await this.options.drafts.write(current.revision, next);
|
|
65
|
+
return { ok: true, revision: written.revision };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { ok: false, revision: current.revision };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Build the configuration model that adding `ids` produces. */
|
|
72
|
+
async build(request) {
|
|
73
|
+
if (!Array.isArray(request.ids) || request.ids.length === 0)
|
|
74
|
+
throw new FleetError('invalid_request', 'name at least one sketch to add to the fleet');
|
|
75
|
+
const merged = await this.options.topology();
|
|
76
|
+
const read = this.options.configuration.read();
|
|
77
|
+
if (read.revision !== request.configRevision)
|
|
78
|
+
throw new FleetError('stale_state', 'fleet.yaml changed since it was opened; reload before adding to the fleet');
|
|
79
|
+
const selected = request.ids.map(id => resolve(merged, id));
|
|
80
|
+
const model = structuredClone(read.model);
|
|
81
|
+
// Agents first: a watchdog's `watch:` list and a loop's `roles:` list may only
|
|
82
|
+
// name roles that exist, including ones being added in this same write.
|
|
83
|
+
for (const node of [...selected].sort(byKind))
|
|
84
|
+
addNode(model, node, merged);
|
|
85
|
+
return { model, promoted: selected.map(node => node.id) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const KIND_ORDER = { agent: 0, watchdog: 1, loop: 2 };
|
|
89
|
+
const byKind = (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
90
|
+
function resolve(merged, id) {
|
|
91
|
+
const node = merged.nodes.find(candidate => candidate.id === id);
|
|
92
|
+
if (!node)
|
|
93
|
+
throw new FleetError('invalid_request', `there is no sketch called ${id}`);
|
|
94
|
+
if (node.origin !== 'draft')
|
|
95
|
+
throw new FleetError('conflict', `${node.label} is already part of the fleet configuration`);
|
|
96
|
+
if (!node.valid || !node.complete) {
|
|
97
|
+
const why = node.missing.map(item => `${item.field}: ${item.fix}`).join(' ');
|
|
98
|
+
throw new FleetError('invalid_request', `${node.label} is not ready to add — ${why}`);
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
function addNode(model, node, merged) {
|
|
103
|
+
const block = section(model, node.kind === 'agent' ? 'roles' : node.kind === 'watchdog' ? 'watchdogs' : 'loops');
|
|
104
|
+
if (block[node.label] !== undefined)
|
|
105
|
+
throw new FleetError('conflict', `${node.label} already exists in the configuration`);
|
|
106
|
+
block[node.label] = node.kind === 'agent' ? agentEntry(node)
|
|
107
|
+
: node.kind === 'watchdog' ? watchdogEntry(node, merged)
|
|
108
|
+
: loopEntry(node, merged, model);
|
|
109
|
+
}
|
|
110
|
+
function agentEntry(node) {
|
|
111
|
+
return pick(node, ROLE_FIELDS);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* D4, and the owner requirement it encodes: a watchdog drawn on its own carries
|
|
115
|
+
* NO `watch:` key, which the config layer reads as "every configured role" —
|
|
116
|
+
* so an agent added tomorrow is covered with no edit at all. A watchdog created
|
|
117
|
+
* from a specific agent carries that agent explicitly and stays scoped to it.
|
|
118
|
+
*/
|
|
119
|
+
function watchdogEntry(node, merged) {
|
|
120
|
+
const scoped = linked(merged, node.id, 'watches');
|
|
121
|
+
return { ...pick(node, WATCHDOG_FIELDS), ...(scoped.length ? { watch: scoped } : {}) };
|
|
122
|
+
}
|
|
123
|
+
function loopEntry(node, merged, model) {
|
|
124
|
+
const roles = linked(merged, node.id, 'targets');
|
|
125
|
+
const entry = {
|
|
126
|
+
roles, ...pick(node, LOOP_FIELDS),
|
|
127
|
+
};
|
|
128
|
+
entry.interval ??= DEFAULT_LOOP_INTERVAL;
|
|
129
|
+
// An enabled loop hard-requires `session: acp` on every target. Adding one to a
|
|
130
|
+
// tmux agent would make the whole fleet unloadable, so it arrives switched off
|
|
131
|
+
// and badged instead — the owner turns it on after switching the agent to ACP.
|
|
132
|
+
if (node.enabled === false || !roles.every(role => sessionOf(model, role) === 'acp'))
|
|
133
|
+
entry.enabled = false;
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
/** Names of the agents this node points at, from drawn edges and derived ones alike. */
|
|
137
|
+
function linked(merged, id, kind) {
|
|
138
|
+
return [...new Set(merged.edges
|
|
139
|
+
.filter(edge => edge.kind === kind && edge.from === id && !edge.implicit && !edge.dangling)
|
|
140
|
+
.map(edge => edge.to.slice('agent:'.length)))].sort();
|
|
141
|
+
}
|
|
142
|
+
function sessionOf(model, role) {
|
|
143
|
+
const roles = model.roles;
|
|
144
|
+
const defaults = model.defaults;
|
|
145
|
+
return String(roles?.[role]?.session ?? defaults?.session ?? 'tmux');
|
|
146
|
+
}
|
|
147
|
+
function section(model, key) {
|
|
148
|
+
const existing = model[key];
|
|
149
|
+
if (existing && typeof existing === 'object' && !Array.isArray(existing))
|
|
150
|
+
return existing;
|
|
151
|
+
const created = {};
|
|
152
|
+
model[key] = created;
|
|
153
|
+
return created;
|
|
154
|
+
}
|
|
155
|
+
/** Copy only the sketched fields that are real keys of the target mapping. */
|
|
156
|
+
function pick(node, keys) {
|
|
157
|
+
const fields = node.fields ?? {};
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const key of keys) {
|
|
160
|
+
const value = fields[key];
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
continue;
|
|
163
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
164
|
+
continue;
|
|
165
|
+
out[key] = value;
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type YamlModel = Record<string, unknown>;
|
|
2
|
+
/** Formatting of the source document, so a rewrite does not reflow it. */
|
|
3
|
+
export interface DocumentFormatting {
|
|
4
|
+
indent: number;
|
|
5
|
+
indentSeq: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Apply `model` to `source`, returning the new document text.
|
|
9
|
+
*
|
|
10
|
+
* Guarantees:
|
|
11
|
+
* - an unchanged model returns `source` byte for byte;
|
|
12
|
+
* - regions outside a change keep their bytes whenever the change is spliceable;
|
|
13
|
+
* - the result parses back to exactly `model`, verified before returning, so a
|
|
14
|
+
* planning bug can never silently write something else to disk.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderModelOntoSource(source: string, model: YamlModel): string;
|
|
17
|
+
/**
|
|
18
|
+
* Replace every `defaults.env` / `roles.*.env` value — and every `vars:` entry
|
|
19
|
+
* those values interpolate — with `marker`.
|
|
20
|
+
*
|
|
21
|
+
* Splices the scalars out of the original bytes rather than re-rendering, so the
|
|
22
|
+
* only difference from `source` is the secrets themselves. Redaction must not be
|
|
23
|
+
* able to introduce (or conceal) a formatting change in a review diff.
|
|
24
|
+
*/
|
|
25
|
+
export declare function redactSourceSecrets(source: string, marker: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort detection of the document's block indentation, used only when a
|
|
28
|
+
* change is not spliceable and the document has to be re-rendered. Detection
|
|
29
|
+
* failure falls back to the library defaults; correctness never depends on it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function detectFormatting(source: string): DocumentFormatting;
|