omp-conductor 0.15.10 → 0.15.12
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 +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/setup-install.ts
CHANGED
|
@@ -15,10 +15,20 @@
|
|
|
15
15
|
* call in here; `privileged.ts` stays the primitive underneath.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
19
|
import { platform } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
-
import { stateDir } from "./config.ts";
|
|
21
|
+
import { loadConfig, stateDir } from "./config.ts";
|
|
22
|
+
import { pauseInstance, setPaused, statusSnapshot } from "./daemon.ts";
|
|
23
|
+
import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
24
|
+
import { livingDaemon } from "./lifecycle.ts";
|
|
25
|
+
import { dbPath, openStore } from "./store.ts";
|
|
26
|
+
import {
|
|
27
|
+
resolveScope,
|
|
28
|
+
pauseAndDrain,
|
|
29
|
+
type DrainDeps,
|
|
30
|
+
type UpgradeScope,
|
|
31
|
+
} from "./upgrade.ts";
|
|
22
32
|
import {
|
|
23
33
|
graphRepos,
|
|
24
34
|
formatGraphSetup,
|
|
@@ -40,10 +50,12 @@ import {
|
|
|
40
50
|
writeHostRuntime,
|
|
41
51
|
STAGED_SERVICE_NAME,
|
|
42
52
|
SYSTEMD_UNIT_DIR,
|
|
53
|
+
RECOVER_SCRIPT_INSTALL_PATH,
|
|
43
54
|
type EscalationDeps,
|
|
55
|
+
type ServiceRuntime,
|
|
44
56
|
} from "./setup-host.ts";
|
|
45
57
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
46
|
-
import type { Caps, ProjectConfig } from "./types.ts";
|
|
58
|
+
import type { Caps, ProjectConfig, Store } from "./types.ts";
|
|
47
59
|
|
|
48
60
|
/**
|
|
49
61
|
* Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
|
|
@@ -62,6 +74,40 @@ export interface InstallDeps {
|
|
|
62
74
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
63
75
|
platform?: () => string;
|
|
64
76
|
unitDir?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Where the recovery playbook installs. Injectable so the idempotency gate
|
|
79
|
+
* is testable hermetically; the real install uses
|
|
80
|
+
* {@link RECOVER_SCRIPT_INSTALL_PATH}.
|
|
81
|
+
*/
|
|
82
|
+
recoverScriptInstallPath?: string;
|
|
83
|
+
/**
|
|
84
|
+
* The runtime the plan derives from. Injectable so tests pin herdr
|
|
85
|
+
* present/absent deterministically; the default ({@link defaultServiceRuntime})
|
|
86
|
+
* detects herdr from the host's own PATH.
|
|
87
|
+
*/
|
|
88
|
+
runtime?: ServiceRuntime;
|
|
89
|
+
/**
|
|
90
|
+
* Whether the host configures more than one project, which scopes the
|
|
91
|
+
* recovery unit's RECOVER_PROJECT (host-global: never one project's name on
|
|
92
|
+
* a multi-project host, #510/#530). Injectable so tests pin the shape
|
|
93
|
+
* hermetically; the default ({@link hostMultiProject}) reads the config.
|
|
94
|
+
*/
|
|
95
|
+
multiProject?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The pause/drain fence around the restart (#512): which projects to drain,
|
|
98
|
+
* how long to wait, and the store used to attribute a run the restart kills.
|
|
99
|
+
* Injectable so tests pin the drain hermetically; the defaults read the real
|
|
100
|
+
* daemon, sentinel and store.
|
|
101
|
+
*/
|
|
102
|
+
drain?: {
|
|
103
|
+
deps?: DrainDeps;
|
|
104
|
+
/** How long the drain waits for live workers before giving up and staying
|
|
105
|
+
* paused; absent waits indefinitely, the `upgrade` posture. */
|
|
106
|
+
timeoutMs?: number;
|
|
107
|
+
/** The store consulted for runs still live at restart time, so they are
|
|
108
|
+
* attributed before the session host restarts under them. */
|
|
109
|
+
drainStore?: Pick<Store, "liveRuns" | "updateRun">;
|
|
110
|
+
};
|
|
65
111
|
}
|
|
66
112
|
|
|
67
113
|
/**
|
|
@@ -76,11 +122,37 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
76
122
|
}
|
|
77
123
|
|
|
78
124
|
/**
|
|
79
|
-
*
|
|
80
|
-
* `
|
|
125
|
+
* Whether two files hold identical bytes. The "keep" side of setup-host.ts's
|
|
126
|
+
* `actionFor`: a unit systemd already reads that matches what would be written
|
|
127
|
+
* needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
|
|
128
|
+
* read error is a mismatch, never silently "matches", because the whole point
|
|
129
|
+
* is to make the plan only claim a unit install is redundant when it can see
|
|
130
|
+
* the installed copy.
|
|
131
|
+
*/
|
|
132
|
+
function sameFile(a: string, b: string): boolean {
|
|
133
|
+
try {
|
|
134
|
+
return readFileSync(a, "utf8") === readFileSync(b, "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Install the supervised daemon unit: re-render, stage, then run the plan's
|
|
142
|
+
* steps in order. The steps are the plan's own {@link HostRuntimePlan.steps} —
|
|
143
|
+
* the same single list its {@link installCommands} display renders from — so
|
|
144
|
+
* what is printed and what is executed can never diverge.
|
|
81
145
|
*/
|
|
82
146
|
export async function runHostInstall(
|
|
83
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The project whose per-project tail to write, or `undefined` for a
|
|
149
|
+
* host-global install: the units are host-global (same staged paths, same
|
|
150
|
+
* systemd destinations, one shared daemon), so no project still installs
|
|
151
|
+
* and starts them. The per-project tail (tick config, brief link) is the
|
|
152
|
+
* only part that needs a name — with none, it is skipped with a printed
|
|
153
|
+
* note (#530).
|
|
154
|
+
*/
|
|
155
|
+
project: ProjectConfig | undefined,
|
|
84
156
|
caps: Caps,
|
|
85
157
|
telegramStateDir: string,
|
|
86
158
|
ui: WizardUi,
|
|
@@ -92,43 +164,206 @@ export async function runHostInstall(
|
|
|
92
164
|
return { kind: "refused", reason: verdict.message };
|
|
93
165
|
}
|
|
94
166
|
|
|
95
|
-
const plan = planHostRuntime(project, caps, telegramStateDir, undefined, hostInstallWorkers(caps));
|
|
96
|
-
const wrote = writeHostRuntime(plan);
|
|
97
167
|
const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
168
|
+
// The plan is computed eagerly — `planHostRuntime` renders every byte and
|
|
169
|
+
// compares it against disk, so the prompt can print exactly what would be
|
|
170
|
+
// written and the currentInstall gate below can decide a re-run restarts
|
|
171
|
+
// nothing. But rendering is read-only: no file is written here. Staging
|
|
172
|
+
// happens only after consent (#510), so the prompt's "Nothing has been run
|
|
173
|
+
// yet" is true of the staged tree when it is printed, and a declined run
|
|
174
|
+
// leaves every staged file byte-identical.
|
|
175
|
+
const plan = planHostRuntime(
|
|
176
|
+
project,
|
|
177
|
+
caps,
|
|
178
|
+
telegramStateDir,
|
|
179
|
+
deps.runtime,
|
|
180
|
+
hostInstallWorkers(caps),
|
|
181
|
+
unitDir,
|
|
182
|
+
deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
|
|
183
|
+
deps.multiProject ?? hostMultiProject(),
|
|
184
|
+
);
|
|
185
|
+
// No pane-shell key is planned (unusable login shell, unparseable config):
|
|
186
|
+
// the operator hears why before the consent prompt, not after an install
|
|
187
|
+
// that quietly had nothing to say about the shell.
|
|
188
|
+
if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
|
|
189
|
+
// A host-global install (no --project): the per-project tail was
|
|
190
|
+
// deliberately not written, and the operator must be told which files and
|
|
191
|
+
// how to get them — never a silent skip.
|
|
192
|
+
if (plan.noProject !== undefined) {
|
|
193
|
+
ui.notify(
|
|
194
|
+
`No project named: ${plan.noProject.skipped.join(", ")} were not written. ${plan.noProject.how}.`,
|
|
195
|
+
"info",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
98
198
|
const installed = join(unitDir, STAGED_SERVICE_NAME);
|
|
199
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
99
200
|
|
|
100
201
|
const blocked = linuxOnly(deps);
|
|
101
202
|
if (blocked !== undefined) {
|
|
203
|
+
// A non-Linux host cannot run systemd, so staging IS the deliverable: the
|
|
204
|
+
// rendered files are written for the operator to copy to the box that will
|
|
205
|
+
// run them, and only the systemctl half is refused. There is no consent
|
|
206
|
+
// prompt on this path — nothing privileged to approve — so the write lands
|
|
207
|
+
// here, not in the (never reached) post-consent hook.
|
|
208
|
+
const { wrote, warnings } = writeHostRuntime(plan);
|
|
209
|
+
for (const warning of warnings) ui.notify(warning, "warning");
|
|
102
210
|
ui.notify(`${blocked} ${plan.service.path}`, "warning");
|
|
103
211
|
return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
|
|
104
212
|
}
|
|
105
213
|
|
|
214
|
+
// A no-op re-run: every unit the steps would install is already current on
|
|
215
|
+
// disk, so installing them again would only touch mtimes and restart the
|
|
216
|
+
// units for nothing. Report success without running a single privileged step.
|
|
217
|
+
// Nothing is staged here either: a re-run with nothing to install has no
|
|
218
|
+
// reason to touch the staged tree.
|
|
219
|
+
if (plan.currentInstall) {
|
|
220
|
+
ui.notify("The supervised daemon and recovery unit are already current — nothing to install, nothing restarted.", "info");
|
|
221
|
+
return { kind: "installed", wrote: [] };
|
|
222
|
+
}
|
|
223
|
+
|
|
106
224
|
// argv, not shell: `installCommands` renders `sudo …` strings for humans to
|
|
107
225
|
// read, and re-parsing those into an argv is how a path with a space becomes
|
|
108
|
-
// two arguments. The
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
226
|
+
// two arguments. The plan carries the exact argv once, in
|
|
227
|
+
// {@link HostRuntimePlan.steps}; we execute that same list, so the printed
|
|
228
|
+
// plan and the run are the same steps (#509).
|
|
229
|
+
//
|
|
230
|
+
// The fence `upgrade` already has (#512): pause new issue claims, drain live
|
|
231
|
+
// workers to idle, restart the units, then restore the dispatch state this
|
|
232
|
+
// began in. The consent prompt below names how many workers will be drained
|
|
233
|
+
// so the operator can choose the moment; it authorises the whole batch,
|
|
234
|
+
// drain included, in one confirm (the `beforeRun` hook).
|
|
235
|
+
const drainDeps = hostInstallDrainDeps(ui, deps);
|
|
236
|
+
// Host-wide by default (#389): the daemon this restarts serves every
|
|
237
|
+
// configured project, so the drain covers all of them — narrowing to the
|
|
238
|
+
// named project would restart the shared session host under the other
|
|
239
|
+
// projects' live workers without ever counting them.
|
|
240
|
+
const fenceScope = resolveScope(drainDeps, "setup host");
|
|
241
|
+
const liveWorkers = fenceScope.selectors.reduce((n, s) => n + drainDeps.snapshot(s).liveWorkers, 0);
|
|
242
|
+
let fence: { scope: UpgradeScope; initialPaused: boolean } | undefined;
|
|
243
|
+
// What the post-consent staging actually wrote, surfaced by the outcomes that
|
|
244
|
+
// report it. Empty on a decline — the hook never ran.
|
|
245
|
+
let wrote: readonly string[] = [];
|
|
246
|
+
const beforeRun = async (): Promise<void> => {
|
|
247
|
+
// Consent has been given; the plan's files may now land. Staging here —
|
|
248
|
+
// post-confirm, pre-step, under the same confirm that authorised the batch
|
|
249
|
+
// — is what makes the prompt's "Nothing has been run yet" true of the
|
|
250
|
+
// staged tree when it is printed: a declined prompt never reaches this
|
|
251
|
+
// hook, so nothing is staged and a re-run still plans an install (#510).
|
|
252
|
+
const staged = writeHostRuntime(plan);
|
|
253
|
+
wrote = staged.wrote;
|
|
254
|
+
for (const warning of staged.warnings) ui.notify(warning, "warning");
|
|
255
|
+
fence = await pauseAndDrain(drainDeps, "setup host", {
|
|
256
|
+
...(deps.drain?.timeoutMs === undefined ? {} : { timeoutMs: deps.drain.timeoutMs }),
|
|
257
|
+
});
|
|
258
|
+
// Any run still live when the session host is about to restart is killed by
|
|
259
|
+
// it; record the actor on its row so the classifier reads an administrative
|
|
260
|
+
// kill rather than charging a failure for an operator's restart (#512). The
|
|
261
|
+
// fence drained to idle, so in the healthy path this records nothing.
|
|
262
|
+
attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const outcome = await runPrivileged(plan.steps, ui, {
|
|
117
266
|
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
118
|
-
title: "Install and start the supervised
|
|
267
|
+
title: "Install and start the supervised session?",
|
|
119
268
|
preamble: [
|
|
120
269
|
`Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
|
|
270
|
+
...(plan.herdrUnit === undefined
|
|
271
|
+
? []
|
|
272
|
+
: [
|
|
273
|
+
`Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
|
|
274
|
+
"run it instead of dash, then enables and restarts it.",
|
|
275
|
+
...(plan.herdrConfigTarget === undefined
|
|
276
|
+
? []
|
|
277
|
+
: [
|
|
278
|
+
`And merges [terminal] default_shell into ${plan.herdrConfigTarget} as you, before that restart,`,
|
|
279
|
+
"so the session server reads the pinned shell the moment it comes up.",
|
|
280
|
+
]),
|
|
281
|
+
]),
|
|
121
282
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
283
|
+
"",
|
|
284
|
+
liveWorkers === 0
|
|
285
|
+
? "No workers are live — nothing to drain before the restart."
|
|
286
|
+
: `Pauses new claims and drains ${liveWorkers} live worker(s) to idle first — this can take minutes per worker — then restarts the units and resumes dispatch.`,
|
|
122
287
|
],
|
|
288
|
+
beforeRun,
|
|
123
289
|
});
|
|
124
290
|
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
125
291
|
if (outcome.kind === "failed") {
|
|
292
|
+
// Restore the preceding dispatch state: a restart that failed is still a
|
|
293
|
+
// finished operator action whose units may or may not be up, so the fleet
|
|
294
|
+
// must not sit paused for it. A throw from the drain itself (a timeout or
|
|
295
|
+
// a superseded fence) never reaches here — it propagates and leaves
|
|
296
|
+
// dispatch paused, fail-closed, exactly as upgrade does.
|
|
297
|
+
if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
|
|
126
298
|
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
127
299
|
}
|
|
300
|
+
if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
|
|
128
301
|
ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
|
|
129
302
|
return { kind: "installed", wrote };
|
|
130
303
|
}
|
|
131
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The pause/drain fence's default accessors, overridable per test through
|
|
307
|
+
* {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps}
|
|
308
|
+
* wiring, except the pause names `setup host` as the actor and the drain
|
|
309
|
+
* reports progress through the same UI surface.
|
|
310
|
+
*/
|
|
311
|
+
function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
|
|
312
|
+
if (deps.drain?.deps !== undefined) return deps.drain.deps;
|
|
313
|
+
return {
|
|
314
|
+
snapshot: statusSnapshot,
|
|
315
|
+
layers: fleetLayers,
|
|
316
|
+
projectNames: () => loadConfig().projects.map((p) => p.name),
|
|
317
|
+
daemonIdentity: () => {
|
|
318
|
+
const daemon = livingDaemon();
|
|
319
|
+
return daemon === undefined
|
|
320
|
+
? { running: false }
|
|
321
|
+
: { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
|
|
322
|
+
},
|
|
323
|
+
pauseState: (project) => pauseInstance(project),
|
|
324
|
+
setPaused: (v, project) => setPaused(v, { source: "setup host", reason: "setup host, draining" }, project),
|
|
325
|
+
sleep: Bun.sleep,
|
|
326
|
+
log: (message) => ui.notify(message, "info"),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Attribute the runs the restart is about to kill: any run still live when the
|
|
332
|
+
* session host restarts underneath it is that restart's casualty, not a failed
|
|
333
|
+
* attempt, so its row records the actor. The classifier reads this as an
|
|
334
|
+
* administrative kill that costs no budget and requeues (#512). The fence
|
|
335
|
+
* drains to idle first, so in the healthy path this records nothing — it is
|
|
336
|
+
* the safety net for a claim that raced the drain or a worker the drain could
|
|
337
|
+
* not bring to idle.
|
|
338
|
+
*/
|
|
339
|
+
function attributeRestartKills(
|
|
340
|
+
scope: UpgradeScope,
|
|
341
|
+
project: string,
|
|
342
|
+
store: Pick<Store, "liveRuns" | "updateRun"> | undefined,
|
|
343
|
+
): void {
|
|
344
|
+
const attribute = (owner: Pick<Store, "liveRuns" | "updateRun">): void => {
|
|
345
|
+
for (const selector of scope.selectors) {
|
|
346
|
+
for (const run of owner.liveRuns(selector ?? project)) {
|
|
347
|
+
owner.updateRun(run.id, { lastError: "admin restart: setup host (draining)" });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
if (store !== undefined) {
|
|
352
|
+
attribute(store);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const path = dbPath();
|
|
356
|
+
// No store yet — a host that has never dispatched — means nothing to attribute
|
|
357
|
+
// and nothing a fresh open would do but create an empty database.
|
|
358
|
+
if (!existsSync(path)) return;
|
|
359
|
+
const owner = openStore(path);
|
|
360
|
+
try {
|
|
361
|
+
attribute(owner);
|
|
362
|
+
} finally {
|
|
363
|
+
owner.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
132
367
|
/** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
|
|
133
368
|
function hostInstallWorkers(caps: Caps): number {
|
|
134
369
|
try {
|
|
@@ -138,6 +373,22 @@ function hostInstallWorkers(caps: Caps): number {
|
|
|
138
373
|
}
|
|
139
374
|
}
|
|
140
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Whether this host configures more than one project. The recovery unit is
|
|
378
|
+
* host-global and must not encode one project's name when several are
|
|
379
|
+
* configured, so the plan scopes RECOVER_PROJECT by this (#510/#530). A
|
|
380
|
+
* loadable config is the truth; when it cannot be read (a not-yet-configured
|
|
381
|
+
* box), there is no evidence of several projects and the named case stays
|
|
382
|
+
* unambiguous — the same fallback shape as {@link hostInstallWorkers}.
|
|
383
|
+
*/
|
|
384
|
+
function hostMultiProject(): boolean {
|
|
385
|
+
try {
|
|
386
|
+
return loadConfig().projects.length > 1;
|
|
387
|
+
} catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
141
392
|
export interface GraphInstallOptions extends InstallDeps {
|
|
142
393
|
/**
|
|
143
394
|
* Skip the seeding step. The timer is still installed and enabled, and the
|
|
@@ -219,6 +470,15 @@ export async function runGraphInstall(
|
|
|
219
470
|
const blocked = linuxOnly(options);
|
|
220
471
|
const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
|
|
221
472
|
const from = unitPaths(stateDir());
|
|
473
|
+
// The units systemd already reads, compared to what staging just wrote — the
|
|
474
|
+
// same distinction setup-host.ts draws between `installedAction` and
|
|
475
|
+
// `service.action` (read the installed file, compare to the fresh content,
|
|
476
|
+
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
477
|
+
// stale unit is the older bug, so when they differ the install runs. Only
|
|
478
|
+
// when both units already match does the plan omit them — then just the
|
|
479
|
+
// outstanding clone and seed remain.
|
|
480
|
+
const unitsCurrent =
|
|
481
|
+
blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
|
|
222
482
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
223
483
|
// one preview and one confirm, and a second confirm here also invented a
|
|
224
484
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -230,9 +490,13 @@ export async function runGraphInstall(
|
|
|
230
490
|
const install: PrivilegedStep[] =
|
|
231
491
|
blocked === undefined
|
|
232
492
|
? [
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
493
|
+
...(unitsCurrent
|
|
494
|
+
? []
|
|
495
|
+
: [
|
|
496
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
|
|
497
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
498
|
+
{ title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
|
|
499
|
+
]),
|
|
236
500
|
...seed,
|
|
237
501
|
]
|
|
238
502
|
: [];
|
|
@@ -242,19 +506,19 @@ export async function runGraphInstall(
|
|
|
242
506
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
243
507
|
}
|
|
244
508
|
|
|
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
509
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
251
510
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
252
|
-
title: "Clone, install and enable the code-graph timer?",
|
|
511
|
+
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
253
512
|
preamble: [
|
|
254
513
|
`Staged: ${staged.written.join(", ")}.`,
|
|
255
514
|
...(clones.length === 0
|
|
256
515
|
? ["Every indexed clone already exists."]
|
|
257
|
-
: [
|
|
516
|
+
: [
|
|
517
|
+
`${clones.length} clone(s) run as you; ${
|
|
518
|
+
unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root."
|
|
519
|
+
}`,
|
|
520
|
+
]),
|
|
521
|
+
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
258
522
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
259
523
|
],
|
|
260
524
|
});
|