omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/setup-install.ts
CHANGED
|
@@ -18,7 +18,17 @@
|
|
|
18
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, pauseSourceToken, 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,
|
|
@@ -34,18 +44,21 @@ import {
|
|
|
34
44
|
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
35
45
|
import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
|
|
36
46
|
import {
|
|
47
|
+
agentRenameVerdict,
|
|
37
48
|
checkEscalation,
|
|
49
|
+
DEFAULT_AGENT_RENAME_DEPS,
|
|
38
50
|
planHostRuntime,
|
|
39
51
|
totalConfiguredWorkers,
|
|
40
52
|
writeHostRuntime,
|
|
41
53
|
STAGED_SERVICE_NAME,
|
|
42
54
|
SYSTEMD_UNIT_DIR,
|
|
55
|
+
RECOVER_SCRIPT_INSTALL_PATH,
|
|
56
|
+
type AgentRenameDeps,
|
|
43
57
|
type EscalationDeps,
|
|
44
58
|
type ServiceRuntime,
|
|
45
59
|
} from "./setup-host.ts";
|
|
46
|
-
import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
47
60
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
48
|
-
import type { Caps, ProjectConfig } from "./types.ts";
|
|
61
|
+
import type { Caps, ProjectConfig, Store } from "./types.ts";
|
|
49
62
|
|
|
50
63
|
/**
|
|
51
64
|
* Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
|
|
@@ -64,12 +77,46 @@ export interface InstallDeps {
|
|
|
64
77
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
65
78
|
platform?: () => string;
|
|
66
79
|
unitDir?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Where the recovery playbook installs. Injectable so the idempotency gate
|
|
82
|
+
* is testable hermetically; the real install uses
|
|
83
|
+
* {@link RECOVER_SCRIPT_INSTALL_PATH}.
|
|
84
|
+
*/
|
|
85
|
+
recoverScriptInstallPath?: string;
|
|
67
86
|
/**
|
|
68
87
|
* The runtime the plan derives from. Injectable so tests pin herdr
|
|
69
88
|
* present/absent deterministically; the default ({@link defaultServiceRuntime})
|
|
70
89
|
* detects herdr from the host's own PATH.
|
|
71
90
|
*/
|
|
72
91
|
runtime?: ServiceRuntime;
|
|
92
|
+
/**
|
|
93
|
+
* Whether the host configures more than one project, which scopes the
|
|
94
|
+
* recovery unit's RECOVER_PROJECT (host-global: never one project's name on
|
|
95
|
+
* a multi-project host, #510/#530). Injectable so tests pin the shape
|
|
96
|
+
* hermetically; the default ({@link hostMultiProject}) reads the config.
|
|
97
|
+
*/
|
|
98
|
+
multiProject?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* The pause/drain fence around the restart (#512): which projects to drain,
|
|
101
|
+
* how long to wait, and the store used to attribute a run the restart kills.
|
|
102
|
+
* Injectable so tests pin the drain hermetically; the defaults read the real
|
|
103
|
+
* daemon, sentinel and store.
|
|
104
|
+
*/
|
|
105
|
+
drain?: {
|
|
106
|
+
deps?: DrainDeps;
|
|
107
|
+
/** How long the drain waits for live workers before giving up and staying
|
|
108
|
+
* paused; absent waits indefinitely, the `upgrade` posture. */
|
|
109
|
+
timeoutMs?: number;
|
|
110
|
+
/** The store consulted for runs still live at restart time, so they are
|
|
111
|
+
* attributed before the session host restarts under them. */
|
|
112
|
+
drainStore?: Pick<Store, "liveRuns" | "updateRun">;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* The live herdr CLI seam for the post-install agent rename (#541).
|
|
116
|
+
* Injectable so tests pin the verdict without touching the host's herdr; the
|
|
117
|
+
* default is the same `herdr --session` CLI recover.sh and the tick use.
|
|
118
|
+
*/
|
|
119
|
+
agentRename?: AgentRenameDeps;
|
|
73
120
|
}
|
|
74
121
|
|
|
75
122
|
/**
|
|
@@ -100,11 +147,21 @@ function sameFile(a: string, b: string): boolean {
|
|
|
100
147
|
}
|
|
101
148
|
|
|
102
149
|
/**
|
|
103
|
-
* Install the supervised daemon unit: re-render, stage, then run the
|
|
104
|
-
*
|
|
150
|
+
* Install the supervised daemon unit: re-render, stage, then run the plan's
|
|
151
|
+
* steps in order. The steps are the plan's own {@link HostRuntimePlan.steps} —
|
|
152
|
+
* the same single list its {@link installCommands} display renders from — so
|
|
153
|
+
* what is printed and what is executed can never diverge.
|
|
105
154
|
*/
|
|
106
155
|
export async function runHostInstall(
|
|
107
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The project whose per-project tail to write, or `undefined` for a
|
|
158
|
+
* host-global install: the units are host-global (same staged paths, same
|
|
159
|
+
* systemd destinations, one shared daemon), so no project still installs
|
|
160
|
+
* and starts them. The per-project tail (tick config, brief link) is the
|
|
161
|
+
* only part that needs a name — with none, it is skipped with a printed
|
|
162
|
+
* note (#530).
|
|
163
|
+
*/
|
|
164
|
+
project: ProjectConfig | undefined,
|
|
108
165
|
caps: Caps,
|
|
109
166
|
telegramStateDir: string,
|
|
110
167
|
ui: WizardUi,
|
|
@@ -116,40 +173,106 @@ export async function runHostInstall(
|
|
|
116
173
|
return { kind: "refused", reason: verdict.message };
|
|
117
174
|
}
|
|
118
175
|
|
|
119
|
-
const plan = planHostRuntime(project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps));
|
|
120
|
-
const { wrote, warnings } = writeHostRuntime(plan);
|
|
121
|
-
for (const warning of warnings) ui.notify(warning, "warning");
|
|
122
176
|
const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
177
|
+
// The plan is computed eagerly — `planHostRuntime` renders every byte and
|
|
178
|
+
// compares it against disk, so the prompt can print exactly what would be
|
|
179
|
+
// written and the currentInstall gate below can decide a re-run restarts
|
|
180
|
+
// nothing. But rendering is read-only: no file is written here. Staging
|
|
181
|
+
// happens only after consent (#510), so the prompt's "Nothing has been run
|
|
182
|
+
// yet" is true of the staged tree when it is printed, and a declined run
|
|
183
|
+
// leaves every staged file byte-identical.
|
|
184
|
+
const plan = planHostRuntime(
|
|
185
|
+
project,
|
|
186
|
+
caps,
|
|
187
|
+
telegramStateDir,
|
|
188
|
+
deps.runtime,
|
|
189
|
+
hostInstallWorkers(caps),
|
|
190
|
+
unitDir,
|
|
191
|
+
deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
|
|
192
|
+
deps.multiProject ?? hostMultiProject(),
|
|
193
|
+
);
|
|
194
|
+
// No pane-shell key is planned (unusable login shell, unparseable config):
|
|
195
|
+
// the operator hears why before the consent prompt, not after an install
|
|
196
|
+
// that quietly had nothing to say about the shell.
|
|
197
|
+
if (plan.herdrConfigWarning !== undefined) ui.notify(plan.herdrConfigWarning, "warning");
|
|
198
|
+
if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
|
|
199
|
+
// A host-global install (no --project): the per-project tail was
|
|
200
|
+
// deliberately not written, and the operator must be told which files and
|
|
201
|
+
// how to get them — never a silent skip.
|
|
202
|
+
if (plan.noProject !== undefined) {
|
|
203
|
+
ui.notify(
|
|
204
|
+
`No project named: ${plan.noProject.skipped.join(", ")} were not written. ${plan.noProject.how}.`,
|
|
205
|
+
"info",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
123
208
|
const installed = join(unitDir, STAGED_SERVICE_NAME);
|
|
124
209
|
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
125
210
|
|
|
126
211
|
const blocked = linuxOnly(deps);
|
|
127
212
|
if (blocked !== undefined) {
|
|
213
|
+
// A non-Linux host cannot run systemd, so staging IS the deliverable: the
|
|
214
|
+
// rendered files are written for the operator to copy to the box that will
|
|
215
|
+
// run them, and only the systemctl half is refused. There is no consent
|
|
216
|
+
// prompt on this path — nothing privileged to approve — so the write lands
|
|
217
|
+
// here, not in the (never reached) post-consent hook.
|
|
218
|
+
const { wrote, warnings } = writeHostRuntime(plan);
|
|
219
|
+
for (const warning of warnings) ui.notify(warning, "warning");
|
|
128
220
|
ui.notify(`${blocked} ${plan.service.path}`, "warning");
|
|
129
221
|
return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
|
|
130
222
|
}
|
|
131
223
|
|
|
224
|
+
// A no-op re-run: every unit the steps would install is already current on
|
|
225
|
+
// disk, so installing them again would only touch mtimes and restart the
|
|
226
|
+
// units for nothing. Report success without running a single privileged step.
|
|
227
|
+
// Nothing is staged here either: a re-run with nothing to install has no
|
|
228
|
+
// reason to touch the staged tree.
|
|
229
|
+
if (plan.currentInstall) {
|
|
230
|
+
ui.notify("The supervised daemon and recovery unit are already current — nothing to install, nothing restarted.", "info");
|
|
231
|
+
return { kind: "installed", wrote: [] };
|
|
232
|
+
}
|
|
233
|
+
|
|
132
234
|
// argv, not shell: `installCommands` renders `sudo …` strings for humans to
|
|
133
235
|
// read, and re-parsing those into an argv is how a path with a space becomes
|
|
134
|
-
// two arguments. The
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
236
|
+
// two arguments. The plan carries the exact argv once, in
|
|
237
|
+
// {@link HostRuntimePlan.steps}; we execute that same list, so the printed
|
|
238
|
+
// plan and the run are the same steps (#509).
|
|
239
|
+
//
|
|
240
|
+
// The fence `upgrade` already has (#512): pause new issue claims, drain live
|
|
241
|
+
// workers to idle, restart the units, then restore the dispatch state this
|
|
242
|
+
// began in. The consent prompt below names how many workers will be drained
|
|
243
|
+
// so the operator can choose the moment; it authorises the whole batch,
|
|
244
|
+
// drain included, in one confirm (the `beforeRun` hook).
|
|
245
|
+
const drainDeps = hostInstallDrainDeps(ui, deps);
|
|
246
|
+
// Host-wide by default (#389): the daemon this restarts serves every
|
|
247
|
+
// configured project, so the drain covers all of them — narrowing to the
|
|
248
|
+
// named project would restart the shared session host under the other
|
|
249
|
+
// projects' live workers without ever counting them.
|
|
250
|
+
const fenceScope = resolveScope(drainDeps, "setup host");
|
|
251
|
+
const liveWorkers = fenceScope.selectors.reduce((n, s) => n + drainDeps.snapshot(s).liveWorkers, 0);
|
|
252
|
+
let fence: { scope: UpgradeScope; initialPaused: boolean } | undefined;
|
|
253
|
+
// What the post-consent staging actually wrote, surfaced by the outcomes that
|
|
254
|
+
// report it. Empty on a decline — the hook never ran.
|
|
255
|
+
let wrote: readonly string[] = [];
|
|
256
|
+
const beforeRun = async (): Promise<void> => {
|
|
257
|
+
// Consent has been given; the plan's files may now land. Staging here —
|
|
258
|
+
// post-confirm, pre-step, under the same confirm that authorised the batch
|
|
259
|
+
// — is what makes the prompt's "Nothing has been run yet" true of the
|
|
260
|
+
// staged tree when it is printed: a declined prompt never reaches this
|
|
261
|
+
// hook, so nothing is staged and a re-run still plans an install (#510).
|
|
262
|
+
const staged = writeHostRuntime(plan);
|
|
263
|
+
wrote = staged.wrote;
|
|
264
|
+
for (const warning of staged.warnings) ui.notify(warning, "warning");
|
|
265
|
+
fence = await pauseAndDrain(drainDeps, "setup host", {
|
|
266
|
+
...(deps.drain?.timeoutMs === undefined ? {} : { timeoutMs: deps.drain.timeoutMs }),
|
|
267
|
+
});
|
|
268
|
+
// Any run still live when the session host is about to restart is killed by
|
|
269
|
+
// it; record the actor on its row so the classifier reads an administrative
|
|
270
|
+
// kill rather than charging a failure for an operator's restart (#512). The
|
|
271
|
+
// fence drained to idle, so in the healthy path this records nothing.
|
|
272
|
+
attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const outcome = await runPrivileged(plan.steps, ui, {
|
|
153
276
|
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
154
277
|
title: "Install and start the supervised session?",
|
|
155
278
|
preamble: [
|
|
@@ -159,18 +282,126 @@ export async function runHostInstall(
|
|
|
159
282
|
: [
|
|
160
283
|
`Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
|
|
161
284
|
"run it instead of dash, then enables and restarts it.",
|
|
285
|
+
...(plan.herdrConfigTarget === undefined
|
|
286
|
+
? []
|
|
287
|
+
: [
|
|
288
|
+
`And merges [terminal] default_shell into ${plan.herdrConfigTarget} as you, before that restart,`,
|
|
289
|
+
"so the session server reads the pinned shell the moment it comes up.",
|
|
290
|
+
]),
|
|
162
291
|
]),
|
|
163
292
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
293
|
+
"",
|
|
294
|
+
liveWorkers === 0
|
|
295
|
+
? "No workers are live — nothing to drain before the restart."
|
|
296
|
+
: `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.`,
|
|
164
297
|
],
|
|
298
|
+
beforeRun,
|
|
165
299
|
});
|
|
166
300
|
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
167
301
|
if (outcome.kind === "failed") {
|
|
302
|
+
// Restore the preceding dispatch state: a restart that failed is still a
|
|
303
|
+
// finished operator action whose units may or may not be up, so the fleet
|
|
304
|
+
// must not sit paused for it. A throw from the drain itself (a timeout or
|
|
305
|
+
// a superseded fence) never reaches here — it propagates and leaves
|
|
306
|
+
// dispatch paused, fail-closed, exactly as upgrade does.
|
|
307
|
+
if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
|
|
168
308
|
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
169
309
|
}
|
|
310
|
+
if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
|
|
170
311
|
ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
|
|
312
|
+
// #541: the tick plan may have restamped agentName away from the shared
|
|
313
|
+
// default — the exact moment the live pane is stuck under the old name and
|
|
314
|
+
// every tick declines with the rename hint. Perform the rename now that the
|
|
315
|
+
// herdr session has (re)started, safely: exactly one pane carrying the old
|
|
316
|
+
// identity is renamed; zero, several, or an unreadable agent list prints the
|
|
317
|
+
// exact command and says why it was not run. Never guesses a pane.
|
|
318
|
+
if (plan.agentRename !== undefined) {
|
|
319
|
+
const renameDeps = deps.agentRename ?? DEFAULT_AGENT_RENAME_DEPS;
|
|
320
|
+
const verdict = agentRenameVerdict(
|
|
321
|
+
renameDeps.list(plan.agentRename.session),
|
|
322
|
+
plan.agentRename.from,
|
|
323
|
+
plan.agentRename.to,
|
|
324
|
+
plan.agentRename.session,
|
|
325
|
+
);
|
|
326
|
+
if (verdict.kind === "rename") {
|
|
327
|
+
const ran = renameDeps.rename(plan.agentRename.session, verdict.pane, plan.agentRename.to);
|
|
328
|
+
if (ran.ok) {
|
|
329
|
+
ui.notify(`Renamed the herdr agent in pane ${verdict.pane} to ${plan.agentRename.to}.`, "info");
|
|
330
|
+
} else {
|
|
331
|
+
ui.notify(
|
|
332
|
+
`The herdr agent rename did not go through (${ran.problem ?? "unknown"}): ${verdict.command} — the pane still carries the old name.`,
|
|
333
|
+
"warning",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
ui.notify(`${verdict.reason}; run it by hand: ${verdict.command}`, "warning");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
171
340
|
return { kind: "installed", wrote };
|
|
172
341
|
}
|
|
173
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The pause/drain fence's default accessors, overridable per test through
|
|
345
|
+
* {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps}
|
|
346
|
+
* wiring, except the pause names `setup host` as the actor and the drain
|
|
347
|
+
* reports progress through the same UI surface.
|
|
348
|
+
*/
|
|
349
|
+
function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
|
|
350
|
+
if (deps.drain?.deps !== undefined) return deps.drain.deps;
|
|
351
|
+
return {
|
|
352
|
+
snapshot: statusSnapshot,
|
|
353
|
+
layers: fleetLayers,
|
|
354
|
+
projectNames: () => loadConfig().projects.map((p) => p.name),
|
|
355
|
+
daemonIdentity: () => {
|
|
356
|
+
const daemon = livingDaemon();
|
|
357
|
+
return daemon === undefined
|
|
358
|
+
? { running: false }
|
|
359
|
+
: { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
|
|
360
|
+
},
|
|
361
|
+
pauseState: (project) => pauseInstance(project),
|
|
362
|
+
setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup host"), reason: "setup host, draining" }, project),
|
|
363
|
+
sleep: Bun.sleep,
|
|
364
|
+
log: (message) => ui.notify(message, "info"),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Attribute the runs the restart is about to kill: any run still live when the
|
|
370
|
+
* session host restarts underneath it is that restart's casualty, not a failed
|
|
371
|
+
* attempt, so its row records the actor. The classifier reads this as an
|
|
372
|
+
* administrative kill that costs no budget and requeues (#512). The fence
|
|
373
|
+
* drains to idle first, so in the healthy path this records nothing — it is
|
|
374
|
+
* the safety net for a claim that raced the drain or a worker the drain could
|
|
375
|
+
* not bring to idle.
|
|
376
|
+
*/
|
|
377
|
+
function attributeRestartKills(
|
|
378
|
+
scope: UpgradeScope,
|
|
379
|
+
project: string,
|
|
380
|
+
store: Pick<Store, "liveRuns" | "updateRun"> | undefined,
|
|
381
|
+
): void {
|
|
382
|
+
const attribute = (owner: Pick<Store, "liveRuns" | "updateRun">): void => {
|
|
383
|
+
for (const selector of scope.selectors) {
|
|
384
|
+
for (const run of owner.liveRuns(selector ?? project)) {
|
|
385
|
+
owner.updateRun(run.id, { lastError: "admin restart: setup host (draining)" });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
if (store !== undefined) {
|
|
390
|
+
attribute(store);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const path = dbPath();
|
|
394
|
+
// No store yet — a host that has never dispatched — means nothing to attribute
|
|
395
|
+
// and nothing a fresh open would do but create an empty database.
|
|
396
|
+
if (!existsSync(path)) return;
|
|
397
|
+
const owner = openStore(path);
|
|
398
|
+
try {
|
|
399
|
+
attribute(owner);
|
|
400
|
+
} finally {
|
|
401
|
+
owner.close();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
174
405
|
/** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
|
|
175
406
|
function hostInstallWorkers(caps: Caps): number {
|
|
176
407
|
try {
|
|
@@ -180,6 +411,22 @@ function hostInstallWorkers(caps: Caps): number {
|
|
|
180
411
|
}
|
|
181
412
|
}
|
|
182
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Whether this host configures more than one project. The recovery unit is
|
|
416
|
+
* host-global and must not encode one project's name when several are
|
|
417
|
+
* configured, so the plan scopes RECOVER_PROJECT by this (#510/#530). A
|
|
418
|
+
* loadable config is the truth; when it cannot be read (a not-yet-configured
|
|
419
|
+
* box), there is no evidence of several projects and the named case stays
|
|
420
|
+
* unambiguous — the same fallback shape as {@link hostInstallWorkers}.
|
|
421
|
+
*/
|
|
422
|
+
function hostMultiProject(): boolean {
|
|
423
|
+
try {
|
|
424
|
+
return loadConfig().projects.length > 1;
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
183
430
|
export interface GraphInstallOptions extends InstallDeps {
|
|
184
431
|
/**
|
|
185
432
|
* Skip the seeding step. The timer is still installed and enabled, and the
|