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.
@@ -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
+ }