omp-conductor 0.13.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 +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- 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 +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- 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 +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- 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/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
package/src/setup-host.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { homedir, userInfo } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { configPath, stateDir } from "./config.ts";
|
|
5
|
+
import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
5
6
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
6
7
|
import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
|
|
7
8
|
import {
|
|
@@ -14,11 +15,13 @@ import {
|
|
|
14
15
|
type StopResult,
|
|
15
16
|
} from "./lifecycle.ts";
|
|
16
17
|
import {
|
|
18
|
+
legacyArmedMarkerPath,
|
|
17
19
|
readTickConfig,
|
|
18
20
|
TICK_CONFIG_FILE,
|
|
21
|
+
tickConfigMatchesProject,
|
|
19
22
|
type TickConfig,
|
|
20
23
|
} from "./orchestrator-tick.ts";
|
|
21
|
-
import type { Caps, ProjectConfig } from "./types.ts";
|
|
24
|
+
import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
|
|
22
25
|
|
|
23
26
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
24
27
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
@@ -36,6 +39,21 @@ export interface HostRuntimePlan {
|
|
|
36
39
|
tick?: PlannedWrite<TickConfig>;
|
|
37
40
|
installCommands: readonly string[];
|
|
38
41
|
cliSource: "global" | "plugin";
|
|
42
|
+
/** Absolute path of the unit systemd actually reads. */
|
|
43
|
+
installedPath: string;
|
|
44
|
+
/**
|
|
45
|
+
* What installing would do to the unit **systemd reads**, which is a different
|
|
46
|
+
* question from {@link HostRuntimePlan.service}'s action — that one compares the
|
|
47
|
+
* staged copy under the state directory.
|
|
48
|
+
*
|
|
49
|
+
* The distinction is load-bearing: staging has always happened during setup,
|
|
50
|
+
* so on any fleet configured before the install was executed the staged file is
|
|
51
|
+
* already current (`service.action === "keep"`) while `/etc/systemd/system`
|
|
52
|
+
* holds nothing at all. Gating the install offer on the staged action therefore
|
|
53
|
+
* skipped exactly the fleets that had never installed the unit, and told them
|
|
54
|
+
* the installed unit matched.
|
|
55
|
+
*/
|
|
56
|
+
installedAction: PlannedWrite<string>["action"];
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
export interface ServiceRuntime {
|
|
@@ -66,6 +84,143 @@ function actionFor(path: string, content: string): PlannedWrite<string>["action"
|
|
|
66
84
|
return "update";
|
|
67
85
|
}
|
|
68
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Which account this host's fleet belongs to, and how we know.
|
|
89
|
+
*
|
|
90
|
+
* Two sources, in order of authority: the installed unit's own `User=` is what
|
|
91
|
+
* systemd will actually run as, and the owner of `$OMP_CONDUCTOR_HOME` is what
|
|
92
|
+
* owns the config, the store and the state directory. Either one disagreeing
|
|
93
|
+
* with the invoking account means staging would write the wrong identity.
|
|
94
|
+
*/
|
|
95
|
+
export interface FleetAccount {
|
|
96
|
+
name: string | undefined;
|
|
97
|
+
source: "installed unit" | "$OMP_CONDUCTOR_HOME owner" | "nothing on this host";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface EscalationDeps {
|
|
101
|
+
invokingUser(): string;
|
|
102
|
+
/** `$SUDO_USER`, which `sudo` sets and `sudo -i` / `su -` clear. */
|
|
103
|
+
sudoUser(): string | undefined;
|
|
104
|
+
/** `User=` from the installed unit, or `undefined` when there is no unit. */
|
|
105
|
+
unitUser(): string | undefined;
|
|
106
|
+
/** Owner of `$OMP_CONDUCTOR_HOME`, or `undefined` when it does not exist. */
|
|
107
|
+
homeOwner(): string | undefined;
|
|
108
|
+
conductorHome(): string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** uid → account name. `id -un` because no Node builtin maps a foreign uid. */
|
|
112
|
+
function accountName(uid: number): string {
|
|
113
|
+
const ran = spawnSync("id", ["-un", String(uid)], { encoding: "utf8" });
|
|
114
|
+
const name = ran.status === 0 ? (ran.stdout ?? "").trim() : "";
|
|
115
|
+
return name === "" ? `uid ${uid}` : name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function installedUnitUser(): string | undefined {
|
|
119
|
+
try {
|
|
120
|
+
const unit = readFileSync(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME), "utf8");
|
|
121
|
+
const match = /^User=(.*)$/m.exec(unit);
|
|
122
|
+
if (match === null) return undefined;
|
|
123
|
+
// Unquoted in what we generate, but systemd accepts quotes and a
|
|
124
|
+
// hand-edited unit is exactly the case this guard has to read correctly.
|
|
125
|
+
const value = match[1]?.trim().replace(/^"(.*)"$/, "$1") ?? "";
|
|
126
|
+
return value === "" ? undefined : value;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const DEFAULT_ESCALATION_DEPS: EscalationDeps = {
|
|
133
|
+
invokingUser: () => userInfo().username,
|
|
134
|
+
sudoUser: () => process.env["SUDO_USER"],
|
|
135
|
+
unitUser: installedUnitUser,
|
|
136
|
+
homeOwner: () => {
|
|
137
|
+
try {
|
|
138
|
+
return accountName(statSync(dirname(configPath())).uid);
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
conductorHome: () => dirname(configPath()),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The account this host's fleet runs as, from whichever source knows. */
|
|
147
|
+
export function resolveFleetAccount(deps: EscalationDeps = DEFAULT_ESCALATION_DEPS): FleetAccount {
|
|
148
|
+
const fromUnit = deps.unitUser();
|
|
149
|
+
if (fromUnit !== undefined) return { name: fromUnit, source: "installed unit" };
|
|
150
|
+
const fromHome = deps.homeOwner();
|
|
151
|
+
if (fromHome !== undefined) return { name: fromHome, source: "$OMP_CONDUCTOR_HOME owner" };
|
|
152
|
+
return { name: undefined, source: "nothing on this host" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type EscalationVerdict = { kind: "ok" } | { kind: "refuse"; message: string };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Refuses an *escalated* invocation of any setup path — and only that.
|
|
159
|
+
*
|
|
160
|
+
* The distinction is the whole point. Staging derives the unit's `User=` and
|
|
161
|
+
* `HOME=` from the invoking account ({@link defaultServiceRuntime}), so a
|
|
162
|
+
* `sudo omp-conductor setup host` writes `User=root` + `HOME=/root` into a unit
|
|
163
|
+
* for a fleet that runs as somebody else, and the config it loads, the state
|
|
164
|
+
* directory it writes and the indexes it points at all resolve as root too.
|
|
165
|
+
* Nothing about that announces itself: the unit starts, the timer goes green,
|
|
166
|
+
* and the fleet reads none of it. So it is refused rather than accommodated.
|
|
167
|
+
*
|
|
168
|
+
* `uid 0` is *not* the test. A fleet that legitimately runs as root — root owns
|
|
169
|
+
* `$OMP_CONDUCTOR_HOME` and the unit says `User=root` — is the case this verb
|
|
170
|
+
* exists for on single-tenant boxes, and refusing there would break exactly the
|
|
171
|
+
* hosts it targets. Two checks instead:
|
|
172
|
+
*
|
|
173
|
+
* - `$SUDO_USER` is set, which is `sudo` announcing the escalation itself;
|
|
174
|
+
* - the invoking account disagrees with the fleet account, which is what
|
|
175
|
+
* catches `sudo -i` and `su -` — both clear `$SUDO_USER`, so the
|
|
176
|
+
* environment check alone is not sufficient.
|
|
177
|
+
*/
|
|
178
|
+
export function checkEscalation(
|
|
179
|
+
verb: string,
|
|
180
|
+
deps: EscalationDeps = DEFAULT_ESCALATION_DEPS,
|
|
181
|
+
): EscalationVerdict {
|
|
182
|
+
const invoking = deps.invokingUser();
|
|
183
|
+
const sudoUser = deps.sudoUser();
|
|
184
|
+
const fleet = resolveFleetAccount(deps);
|
|
185
|
+
|
|
186
|
+
if (sudoUser !== undefined && sudoUser !== "") {
|
|
187
|
+
return {
|
|
188
|
+
kind: "refuse",
|
|
189
|
+
message: refusal(verb, invoking, sudoUser, fleet, deps.conductorHome()),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (fleet.name !== undefined && fleet.name !== invoking) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "refuse",
|
|
195
|
+
message: refusal(verb, invoking, undefined, fleet, deps.conductorHome()),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { kind: "ok" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function refusal(
|
|
202
|
+
verb: string,
|
|
203
|
+
invoking: string,
|
|
204
|
+
sudoUser: string | undefined,
|
|
205
|
+
fleet: FleetAccount,
|
|
206
|
+
home: string,
|
|
207
|
+
): string {
|
|
208
|
+
const fleetName = fleet.name ?? sudoUser ?? "the fleet's own account";
|
|
209
|
+
return [
|
|
210
|
+
`omp-conductor: run ${verb} as the account the fleet runs as, not escalated.`,
|
|
211
|
+
sudoUser === undefined
|
|
212
|
+
? `This is running as "${invoking}", but the fleet runs as "${fleetName}" (${fleet.source}).`
|
|
213
|
+
: `This is running as "${invoking}" under sudo from "${sudoUser}"` +
|
|
214
|
+
(fleet.name === undefined ? "." : `, and the fleet runs as "${fleet.name}" (${fleet.source}).`),
|
|
215
|
+
`As "${invoking}" the config, ${home}, ~/.cache and the unit's own User= all resolve`,
|
|
216
|
+
`as "${invoking}" instead, and the result is a unit that starts and a fleet that reads`,
|
|
217
|
+
"none of it. Nothing has been written.",
|
|
218
|
+
"",
|
|
219
|
+
`Run it as "${fleetName}". Only the individual install steps need root, and`,
|
|
220
|
+
`${verb} runs those for you with sudo after showing you each one.`,
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
69
224
|
|
|
70
225
|
function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
71
226
|
const home = homedir();
|
|
@@ -86,16 +241,19 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
|
86
241
|
};
|
|
87
242
|
}
|
|
88
243
|
|
|
89
|
-
export function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
244
|
+
export function totalConfiguredWorkers(cfg: ConductorConfig = loadConfig()): number {
|
|
245
|
+
return cfg.projects.reduce(
|
|
246
|
+
(total, project) => total + resolveCaps(project, cfg.defaults).maxConcurrentWorkers,
|
|
247
|
+
0,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: number): string {
|
|
94
252
|
const command =
|
|
95
253
|
runtime.cli === undefined
|
|
96
|
-
? [runtime.bun, runtime.packageCli, "daemon", "--
|
|
97
|
-
: [runtime.cli, "daemon", "--
|
|
98
|
-
const memoryMax =
|
|
254
|
+
? [runtime.bun, runtime.packageCli, "daemon", "--port", String(DEFAULT_PORT)]
|
|
255
|
+
: [runtime.cli, "daemon", "--port", String(DEFAULT_PORT)];
|
|
256
|
+
const memoryMax = totalWorkers <= 1 ? "3G" : "5G";
|
|
99
257
|
return [
|
|
100
258
|
"[Unit]",
|
|
101
259
|
"Description=omp-conductor dispatch daemon",
|
|
@@ -128,6 +286,19 @@ function tickSearchRoots(project: ProjectConfig): string[] {
|
|
|
128
286
|
return roots.filter((root, index) => roots.indexOf(root) === index);
|
|
129
287
|
}
|
|
130
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The tick config for one project's fleet cwd.
|
|
291
|
+
*
|
|
292
|
+
* Everything per-project here used to be shared, and that was two bugs: one
|
|
293
|
+
* `<stateDir>/armed` marker meant arming project A armed B as well, and one
|
|
294
|
+
* `agentName` meant every pane claimed the identity `fleet`, so herdr recovery
|
|
295
|
+
* and tick ownership could not tell two fleets apart.
|
|
296
|
+
*
|
|
297
|
+
* An existing config keeps an explicit `armedFile`/`agentName` only when it
|
|
298
|
+
* differs from those shared defaults. A value equal to a shared default cannot
|
|
299
|
+
* have been a deliberate per-project choice — it *is* the collision — so it is
|
|
300
|
+
* rewritten; anything else is operator intent and survives untouched.
|
|
301
|
+
*/
|
|
131
302
|
function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
|
|
132
303
|
let existing: { path: string; config: TickConfig } | undefined;
|
|
133
304
|
for (const root of tickSearchRoots(project)) {
|
|
@@ -135,24 +306,37 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
135
306
|
if (result.kind === "invalid") {
|
|
136
307
|
throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
|
|
137
308
|
}
|
|
138
|
-
|
|
309
|
+
// A config stamped for another project is that project's file: the search
|
|
310
|
+
// roots overlap, and restamping it here would hand this project's identity
|
|
311
|
+
// to the other fleet's cwd.
|
|
312
|
+
if (result.kind === "ok" && tickConfigMatchesProject(result.config, project.name)) {
|
|
139
313
|
existing = { path: result.path, config: result.config };
|
|
140
314
|
break;
|
|
141
315
|
}
|
|
142
316
|
}
|
|
143
317
|
|
|
318
|
+
const armedFile = join(stateDir(), `armed-${project.name}`);
|
|
144
319
|
const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
|
|
145
320
|
const config: TickConfig = existing === undefined
|
|
146
321
|
? {
|
|
147
322
|
intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
|
|
148
|
-
|
|
323
|
+
project: project.name,
|
|
324
|
+
armedFile,
|
|
149
325
|
accessFile: join(telegramStateDir, "access.json"),
|
|
150
|
-
agentName:
|
|
326
|
+
agentName: project.name,
|
|
151
327
|
}
|
|
152
328
|
: {
|
|
153
329
|
...existing.config,
|
|
154
|
-
|
|
330
|
+
project: project.name,
|
|
331
|
+
armedFile:
|
|
332
|
+
existing.config.armedFile === undefined || existing.config.armedFile === legacyArmedMarkerPath()
|
|
333
|
+
? armedFile
|
|
334
|
+
: existing.config.armedFile,
|
|
155
335
|
accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
|
|
336
|
+
agentName:
|
|
337
|
+
existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME
|
|
338
|
+
? project.name
|
|
339
|
+
: existing.config.agentName,
|
|
156
340
|
};
|
|
157
341
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
158
342
|
return { path, action: actionFor(path, content), content, value: config };
|
|
@@ -160,12 +344,16 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
160
344
|
|
|
161
345
|
export function planHostRuntime(
|
|
162
346
|
project: ProjectConfig,
|
|
163
|
-
|
|
347
|
+
_caps: Caps,
|
|
164
348
|
telegramStateDir: string,
|
|
165
349
|
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
350
|
+
// Callers that know the fleet pass the sum of resolved maxConcurrentWorkers.
|
|
351
|
+
// Defaulting through loadConfig() would throw in install tests that stage
|
|
352
|
+
// files before a config exists, and would hide a missing total at the call site.
|
|
353
|
+
totalWorkers: number = 1,
|
|
166
354
|
): HostRuntimePlan {
|
|
167
355
|
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
168
|
-
const serviceContent = renderDaemonService(
|
|
356
|
+
const serviceContent = renderDaemonService(runtime, totalWorkers);
|
|
169
357
|
const service: PlannedWrite<string> = {
|
|
170
358
|
path: servicePath,
|
|
171
359
|
action: actionFor(servicePath, serviceContent),
|
|
@@ -185,6 +373,8 @@ export function planHostRuntime(
|
|
|
185
373
|
`sudo systemctl restart ${STAGED_SERVICE_NAME}`,
|
|
186
374
|
],
|
|
187
375
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
376
|
+
installedPath,
|
|
377
|
+
installedAction: actionFor(installedPath, serviceContent),
|
|
188
378
|
};
|
|
189
379
|
}
|
|
190
380
|
|
|
@@ -242,7 +432,7 @@ export interface SetupSmokeResult {
|
|
|
242
432
|
}
|
|
243
433
|
|
|
244
434
|
export interface SetupSmokeDeps {
|
|
245
|
-
paused(): boolean;
|
|
435
|
+
paused(project: string): boolean;
|
|
246
436
|
runOnce(project: string): Promise<void>;
|
|
247
437
|
living(): DaemonRecord | undefined;
|
|
248
438
|
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
@@ -265,7 +455,7 @@ export async function runSetupSmoke(
|
|
|
265
455
|
project: string,
|
|
266
456
|
deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
|
|
267
457
|
): Promise<SetupSmokeResult> {
|
|
268
|
-
if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
|
|
458
|
+
if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
|
|
269
459
|
await deps.runOnce(project);
|
|
270
460
|
const existing = deps.living();
|
|
271
461
|
if (existing !== undefined) {
|
|
@@ -283,8 +473,3 @@ export async function runSetupSmoke(
|
|
|
283
473
|
await deps.stop();
|
|
284
474
|
}
|
|
285
475
|
}
|
|
286
|
-
|
|
287
|
-
/** Absolute path of the unit systemd actually reads. */
|
|
288
|
-
export function installedUnitPath(): string {
|
|
289
|
-
return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
|
|
290
|
-
}
|
|
@@ -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
|
+
}
|