omp-conductor 0.14.0 → 0.15.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 +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two executed install paths: the supervised daemon unit, and the code-graph
|
|
3
|
+
* timer.
|
|
4
|
+
*
|
|
5
|
+
* Both used to end at a printed list the operator retyped — `setup-host.ts`
|
|
6
|
+
* composed the exact `sudo` lines and showed them, and `graph-setup --write`
|
|
7
|
+
* staged its files and said it "never runs `systemctl` itself". Retyping is not
|
|
8
|
+
* a safety property: it is the same commands with a chance of a typo, and it is
|
|
9
|
+
* why a fleet sits half-installed. These run them, behind one confirm each.
|
|
10
|
+
*
|
|
11
|
+
* Why this module exists rather than the code living where its pieces do:
|
|
12
|
+
* `graph-health.ts` already imports `graph.ts`, so orchestration that verifies
|
|
13
|
+
* with `probeCodeGraph()` cannot sit in `graph.ts` without a cycle — and `cli.ts`
|
|
14
|
+
* is argument parsing, not sequencing. Both the CLI verbs and the wizard's tail
|
|
15
|
+
* call in here; `privileged.ts` stays the primitive underneath.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { platform } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { stateDir } from "./config.ts";
|
|
22
|
+
import {
|
|
23
|
+
graphRepos,
|
|
24
|
+
formatGraphSetup,
|
|
25
|
+
mcpEntry,
|
|
26
|
+
reindexScriptPath,
|
|
27
|
+
resolvePrereqs,
|
|
28
|
+
REINDEX_UNIT,
|
|
29
|
+
unitPaths,
|
|
30
|
+
writeGraphSetup,
|
|
31
|
+
type GraphPrereqs,
|
|
32
|
+
type GraphRepo,
|
|
33
|
+
} from "./graph.ts";
|
|
34
|
+
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
35
|
+
import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
|
|
36
|
+
import {
|
|
37
|
+
checkEscalation,
|
|
38
|
+
planHostRuntime,
|
|
39
|
+
totalConfiguredWorkers,
|
|
40
|
+
writeHostRuntime,
|
|
41
|
+
STAGED_SERVICE_NAME,
|
|
42
|
+
SYSTEMD_UNIT_DIR,
|
|
43
|
+
type EscalationDeps,
|
|
44
|
+
} from "./setup-host.ts";
|
|
45
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
46
|
+
import type { Caps, ProjectConfig } from "./types.ts";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
|
|
50
|
+
* the files are real and correct, only the `systemctl` half is impossible.
|
|
51
|
+
*/
|
|
52
|
+
export type InstallOutcome =
|
|
53
|
+
| { kind: "installed"; wrote: readonly string[] }
|
|
54
|
+
| { kind: "staged"; wrote: readonly string[]; reason: string }
|
|
55
|
+
| { kind: "declined"; wrote: readonly string[] }
|
|
56
|
+
| { kind: "refused"; reason: string }
|
|
57
|
+
| { kind: "failed"; reason: string };
|
|
58
|
+
|
|
59
|
+
export interface InstallDeps {
|
|
60
|
+
privileged?: PrivilegedDeps;
|
|
61
|
+
escalation?: EscalationDeps;
|
|
62
|
+
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
63
|
+
platform?: () => string;
|
|
64
|
+
unitDir?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `systemctl` exists only on Linux, and staging is still worth doing everywhere:
|
|
69
|
+
* a macOS operator reading the plan wants the rendered unit on disk to copy to
|
|
70
|
+
* the box that will run it. So this is checked *after* the files are written.
|
|
71
|
+
*/
|
|
72
|
+
function linuxOnly(deps: InstallDeps): string | undefined {
|
|
73
|
+
return (deps.platform ?? platform)() === "linux"
|
|
74
|
+
? undefined
|
|
75
|
+
: "systemd install is Linux-only; staged files are at";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Install the supervised daemon unit: re-render, stage, then run the four steps
|
|
80
|
+
* `planHostRuntime` used to only print.
|
|
81
|
+
*/
|
|
82
|
+
export async function runHostInstall(
|
|
83
|
+
project: ProjectConfig,
|
|
84
|
+
caps: Caps,
|
|
85
|
+
telegramStateDir: string,
|
|
86
|
+
ui: WizardUi,
|
|
87
|
+
deps: InstallDeps = {},
|
|
88
|
+
): Promise<InstallOutcome> {
|
|
89
|
+
const verdict = checkEscalation("setup host", deps.escalation);
|
|
90
|
+
if (verdict.kind === "refuse") {
|
|
91
|
+
ui.notify(verdict.message, "error");
|
|
92
|
+
return { kind: "refused", reason: verdict.message };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const plan = planHostRuntime(project, caps, telegramStateDir, undefined, hostInstallWorkers(caps));
|
|
96
|
+
const wrote = writeHostRuntime(plan);
|
|
97
|
+
const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
98
|
+
const installed = join(unitDir, STAGED_SERVICE_NAME);
|
|
99
|
+
|
|
100
|
+
const blocked = linuxOnly(deps);
|
|
101
|
+
if (blocked !== undefined) {
|
|
102
|
+
ui.notify(`${blocked} ${plan.service.path}`, "warning");
|
|
103
|
+
return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// argv, not shell: `installCommands` renders `sudo …` strings for humans to
|
|
107
|
+
// read, and re-parsing those into an argv is how a path with a space becomes
|
|
108
|
+
// two arguments. The steps are built from the same values instead.
|
|
109
|
+
const steps: PrivilegedStep[] = [
|
|
110
|
+
{ title: `install ${STAGED_SERVICE_NAME}`, argv: ["install", "-m", "0644", plan.service.path, installed] },
|
|
111
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
112
|
+
{ title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
|
|
113
|
+
{ title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const outcome = await runPrivileged(steps, ui, {
|
|
117
|
+
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
118
|
+
title: "Install and start the supervised daemon?",
|
|
119
|
+
preamble: [
|
|
120
|
+
`Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
|
|
121
|
+
"The unit runs as the account that staged it; nothing here changes that.",
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
125
|
+
if (outcome.kind === "failed") {
|
|
126
|
+
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
127
|
+
}
|
|
128
|
+
ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
|
|
129
|
+
return { kind: "installed", wrote };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
|
|
133
|
+
function hostInstallWorkers(caps: Caps): number {
|
|
134
|
+
try {
|
|
135
|
+
return totalConfiguredWorkers();
|
|
136
|
+
} catch {
|
|
137
|
+
return caps.maxConcurrentWorkers;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface GraphInstallOptions extends InstallDeps {
|
|
142
|
+
/**
|
|
143
|
+
* Skip the seeding step. The timer is still installed and enabled, and the
|
|
144
|
+
* graph is plainly unusable until its first scheduled run finishes. Never
|
|
145
|
+
* skips the prerequisite or clone steps: those are what make the installed
|
|
146
|
+
* unit runnable at all.
|
|
147
|
+
*/
|
|
148
|
+
noSeed?: boolean;
|
|
149
|
+
/** Print the plan and change nothing — today's `graph-setup` behaviour. */
|
|
150
|
+
print?: boolean;
|
|
151
|
+
/** Injected so the prerequisite gate is testable without touching PATH. */
|
|
152
|
+
prereqs?: GraphPrereqs;
|
|
153
|
+
/** Injected so verification is deterministic without a live indexer. */
|
|
154
|
+
probe?: (project: ProjectConfig) => Promise<CodeGraphHealth>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The code-graph install, end to end.
|
|
159
|
+
*
|
|
160
|
+
* Staging and enabling alone installs a service that fails on every run: the
|
|
161
|
+
* generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
|
|
162
|
+
* its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
|
|
163
|
+
* than a graph. That is why `writeGraphSetup` already refused to call the old
|
|
164
|
+
* install a finished job. So: prerequisites, then clones, then install, then
|
|
165
|
+
* seed and verify — one preview, one confirm.
|
|
166
|
+
*/
|
|
167
|
+
export async function runGraphInstall(
|
|
168
|
+
project: ProjectConfig,
|
|
169
|
+
ui: WizardUi,
|
|
170
|
+
options: GraphInstallOptions = {},
|
|
171
|
+
): Promise<InstallOutcome> {
|
|
172
|
+
const repos = graphRepos(project);
|
|
173
|
+
if (repos.length === 0) {
|
|
174
|
+
const reason = `no repo in ${project.name} has graphProject — nothing to install`;
|
|
175
|
+
ui.notify(reason, "error");
|
|
176
|
+
return { kind: "refused", reason };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const prereqs = options.prereqs ?? resolvePrereqs();
|
|
180
|
+
|
|
181
|
+
// `--print` is today's `graph-setup`: read-only, writes nothing, runs nothing.
|
|
182
|
+
// It comes first — before the escalation guard and before the prerequisite
|
|
183
|
+
// refusal — because the host that most needs the plan is exactly the fresh one
|
|
184
|
+
// missing the indexer, and refusing there would withhold the remediation the
|
|
185
|
+
// operator ran the command to get. `formatGraphSetup` is that renderer, and it
|
|
186
|
+
// already reports the prerequisites as step 0.
|
|
187
|
+
if (options.print === true) {
|
|
188
|
+
ui.notify(formatGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR, prereqs), "info");
|
|
189
|
+
return { kind: "staged", wrote: [], reason: "print-only" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const verdict = checkEscalation("setup graph", options.escalation);
|
|
193
|
+
if (verdict.kind === "refuse") {
|
|
194
|
+
ui.notify(verdict.message, "error");
|
|
195
|
+
return { kind: "refused", reason: verdict.message };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 1. Prerequisites. With no indexer on PATH every timer run fails, and with no
|
|
199
|
+
// MCP entry no worker can read what it indexed — so this stops before
|
|
200
|
+
// installing anything rather than enabling a timer that cannot work.
|
|
201
|
+
const missing = prerequisiteProblem(prereqs);
|
|
202
|
+
if (missing !== undefined) {
|
|
203
|
+
ui.notify([missing, "", mcpEntry(prereqs)].join("\n"), "error");
|
|
204
|
+
return { kind: "refused", reason: missing };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const staged = writeGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR);
|
|
208
|
+
const absent = repos.filter((r) => !existsSync(r.graphProject));
|
|
209
|
+
|
|
210
|
+
// 2. Missing clones, as the operator. A root-owned index-only clone under the
|
|
211
|
+
// fleet user's cache is exactly the failure the escalation guard exists to
|
|
212
|
+
// prevent — but it belongs in the same plan under the same confirm.
|
|
213
|
+
const clones: PrivilegedStep[] = absent.map((r) => ({
|
|
214
|
+
title: `clone ${r.name} for indexing (as you, not root)`,
|
|
215
|
+
argv: ["git", "clone", "--single-branch", "--branch", r.defaultBranch, r.cloneUrl, r.graphProject],
|
|
216
|
+
unprivileged: true,
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
const blocked = linuxOnly(options);
|
|
220
|
+
const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
|
|
221
|
+
const from = unitPaths(stateDir());
|
|
222
|
+
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
223
|
+
// one preview and one confirm, and a second confirm here also invented a
|
|
224
|
+
// third outcome — a declined seed — that neither the caller nor the
|
|
225
|
+
// verification below could interpret.
|
|
226
|
+
const seed: PrivilegedStep[] =
|
|
227
|
+
options.noSeed === true
|
|
228
|
+
? []
|
|
229
|
+
: [{ title: `seed the indexes (runs ${REINDEX_UNIT}.service once, minutes per repo)`, argv: ["systemctl", "start", `${REINDEX_UNIT}.service`] }];
|
|
230
|
+
const install: PrivilegedStep[] =
|
|
231
|
+
blocked === undefined
|
|
232
|
+
? [
|
|
233
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
|
|
234
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
235
|
+
{ title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
|
|
236
|
+
...seed,
|
|
237
|
+
]
|
|
238
|
+
: [];
|
|
239
|
+
|
|
240
|
+
if (blocked !== undefined) {
|
|
241
|
+
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
242
|
+
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (blocked !== undefined) {
|
|
246
|
+
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
247
|
+
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
251
|
+
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
252
|
+
title: "Clone, install and enable the code-graph timer?",
|
|
253
|
+
preamble: [
|
|
254
|
+
`Staged: ${staged.written.join(", ")}.`,
|
|
255
|
+
...(clones.length === 0
|
|
256
|
+
? ["Every indexed clone already exists."]
|
|
257
|
+
: [`${clones.length} clone(s) run as you; the unit install needs root.`]),
|
|
258
|
+
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
if (outcome.kind === "declined") return { kind: "declined", wrote: staged.written };
|
|
262
|
+
if (outcome.kind === "failed") {
|
|
263
|
+
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Verify. `--no-seed` has nothing to verify yet, and saying so is the honest
|
|
267
|
+
// answer: an unseeded graph is not usable until the timer first fires.
|
|
268
|
+
if (options.noSeed === true) {
|
|
269
|
+
ui.notify(
|
|
270
|
+
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${REINDEX_UNIT}.timer first fires.`,
|
|
271
|
+
"warning",
|
|
272
|
+
);
|
|
273
|
+
return { kind: "installed", wrote: staged.written };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const health = await (options.probe ?? probeCodeGraph)(project);
|
|
277
|
+
const unhealthy = unverifiedRepos(health);
|
|
278
|
+
if (unhealthy.length > 0) {
|
|
279
|
+
// Staged but not trusted. Reporting success here is how an operator learns
|
|
280
|
+
// months later that no worker ever read an index.
|
|
281
|
+
ui.notify(
|
|
282
|
+
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", staged.next].join("\n"),
|
|
283
|
+
"error",
|
|
284
|
+
);
|
|
285
|
+
return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
|
|
286
|
+
}
|
|
287
|
+
ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
|
|
288
|
+
return { kind: "installed", wrote: staged.written };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
|
|
292
|
+
function prerequisiteProblem(prereqs: GraphPrereqs): string | undefined {
|
|
293
|
+
if (prereqs.indexer === null) {
|
|
294
|
+
return "codebase-memory-mcp is not on PATH — every timer run would fail, so nothing was installed.";
|
|
295
|
+
}
|
|
296
|
+
if (!prereqs.mounted) {
|
|
297
|
+
return "no codebase-memory MCP entry — no worker could read the indexes, so nothing was installed.";
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Repos whose index the probe could not vouch for: a missing clone, or an
|
|
304
|
+
* indexed project path that never appeared in `list_projects`. Either one means
|
|
305
|
+
* staged-but-not-trusted, which must not be reported as success.
|
|
306
|
+
*/
|
|
307
|
+
function unverifiedRepos(health: CodeGraphHealth): string[] {
|
|
308
|
+
if (!health.configured) return [];
|
|
309
|
+
return health.repos.filter((r) => r.clone !== "present" || r.index !== "present").map((r) => r.name);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The repos `setup graph` would act on, for a caller deciding whether to offer it. */
|
|
313
|
+
export function graphInstallable(project: ProjectConfig): GraphRepo[] {
|
|
314
|
+
return graphRepos(project);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Where the reindex script lands, for the wizard's tail to name. */
|
|
318
|
+
export function reindexScriptLocation(): string {
|
|
319
|
+
return reindexScriptPath();
|
|
320
|
+
}
|