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-host.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, type Stats } from "node:fs";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { homedir, userInfo } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
6
6
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FLEET_AGENT_NAME,
|
|
10
|
+
DEFAULT_HERDR_SESSION,
|
|
11
|
+
DEFAULT_HERDR_UNIT,
|
|
12
|
+
resolveHerdrSessionWithBridge,
|
|
13
|
+
} from "./fleet.ts";
|
|
8
14
|
import {
|
|
9
15
|
DEFAULT_PORT,
|
|
10
16
|
healthCheck,
|
|
@@ -21,12 +27,41 @@ import {
|
|
|
21
27
|
tickConfigMatchesProject,
|
|
22
28
|
type TickConfig,
|
|
23
29
|
} from "./orchestrator-tick.ts";
|
|
30
|
+
import { formatStep, type PrivilegedStep } from "./privileged.ts";
|
|
24
31
|
import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
|
|
25
32
|
|
|
26
33
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
27
34
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
28
35
|
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The one recovery unit every fleet unit's `OnFailure=` points at (#485).
|
|
39
|
+
*
|
|
40
|
+
* A plain (non-template) unit on purpose: both fleet units name the same
|
|
41
|
+
* oneshot, and the playbook it runs determines which unit failed from live
|
|
42
|
+
* systemd state rather than trusting an instance name it cannot verify. The
|
|
43
|
+
* unit itself never carries `OnFailure=` — the one hard bound on
|
|
44
|
+
* recovery-triggering-recovery.
|
|
45
|
+
*/
|
|
46
|
+
export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
|
|
47
|
+
|
|
48
|
+
/** The playbook file shipped in the package's `systemd/` directory. */
|
|
49
|
+
const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where `setup host` installs the recovery playbook so the unit's ExecStart is
|
|
53
|
+
* a stable absolute path that survives package upgrades. /usr/local/sbin is
|
|
54
|
+
* root-executed operational tooling, on PATH for root on every target host.
|
|
55
|
+
*/
|
|
56
|
+
export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
|
|
60
|
+
* from the session cwd, so the fleet pane's cwd needs a link at this name
|
|
61
|
+
* resolving to the composed {@link ORCHESTRATOR_BRIEF_NAME}.
|
|
62
|
+
*/
|
|
63
|
+
export const AGENTS_BRIEF_NAME = "AGENTS.md";
|
|
64
|
+
|
|
30
65
|
export type PlannedWrite<T> = {
|
|
31
66
|
path: string;
|
|
32
67
|
action: "create" | "update" | "keep";
|
|
@@ -34,9 +69,103 @@ export type PlannedWrite<T> = {
|
|
|
34
69
|
value: T;
|
|
35
70
|
};
|
|
36
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The `AGENTS.md` symlink setup places in a project's fleet cwd so the pane's
|
|
74
|
+
* session auto-loads the composed brief instead of staying brief-less.
|
|
75
|
+
*
|
|
76
|
+
* {@link action} is what the plan will *do*, compared against what sits at
|
|
77
|
+
* {@link path}:
|
|
78
|
+
* - `create` — no entry yet, link it;
|
|
79
|
+
* - `update` — a symlink occupies {@link path} but points elsewhere, replace it;
|
|
80
|
+
* - `keep` — an already-correct symlink, leave untouched (idempotent re-run);
|
|
81
|
+
* - `skip` — a regular file occupies {@link path}, never overwrite an
|
|
82
|
+
* operator's file (see {@link skippedReason}).
|
|
83
|
+
*/
|
|
84
|
+
export interface BriefLinkPlan {
|
|
85
|
+
/** `AGENTS.md` inside the project's fleet cwd ({@link tickCwdForProject}). */
|
|
86
|
+
path: string;
|
|
87
|
+
/** Relative target inside the symlink — what the link resolves to. */
|
|
88
|
+
target: string;
|
|
89
|
+
action: "create" | "update" | "keep" | "skip";
|
|
90
|
+
/** Why {@link action} is `skip`; present only then. */
|
|
91
|
+
skippedReason?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why a host-global install (no `--project`) wrote no per-project tail, and
|
|
96
|
+
* what the operator does about it.
|
|
97
|
+
*/
|
|
98
|
+
export interface HostRuntimeNoProject {
|
|
99
|
+
/** The project-scoped files the host-global install deliberately left unwritten. */
|
|
100
|
+
skipped: readonly string[];
|
|
101
|
+
/** The command that writes them: `setup host` naming one project. */
|
|
102
|
+
how: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
37
105
|
export interface HostRuntimePlan {
|
|
38
106
|
service: PlannedWrite<string>;
|
|
107
|
+
/**
|
|
108
|
+
* The staged Herdr session-server unit. Absent when Herdr is not installed
|
|
109
|
+
* ({@link ServiceRuntime.herdr} is `undefined`) — a host driving Herdr some
|
|
110
|
+
* other way must stay supported. `upgrade` and `start` treat
|
|
111
|
+
* {@link DEFAULT_HERDR_UNIT} as a first-class dependency, and this is the
|
|
112
|
+
* rendering that provisions it (see {@link renderHerdrUnit}).
|
|
113
|
+
*/
|
|
114
|
+
herdrUnit?: PlannedWrite<string>;
|
|
115
|
+
/**
|
|
116
|
+
* The fleet recovery oneshot (#485): staged always, because the daemon unit
|
|
117
|
+
* (which every fleet has) carries `OnFailure=` to it. Rendered by
|
|
118
|
+
* {@link renderRecoverUnit}.
|
|
119
|
+
*/
|
|
120
|
+
recoverUnit: PlannedWrite<string>;
|
|
121
|
+
/**
|
|
122
|
+
* The recovery playbook body, staged from the shipped package file and
|
|
123
|
+
* installed to {@link RECOVER_SCRIPT_INSTALL_PATH} so the unit's ExecStart
|
|
124
|
+
* is a stable absolute path.
|
|
125
|
+
*/
|
|
126
|
+
recoverScript: PlannedWrite<string>;
|
|
127
|
+
/**
|
|
128
|
+
* `[terminal] default_shell` staged into the session's herdr config, the
|
|
129
|
+
* belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
|
|
130
|
+
* Present exactly when {@link herdrUnit} is, unless the login shell is not a
|
|
131
|
+
* usable path ({@link usableHerdrShell}) or the rendered config would not
|
|
132
|
+
* parse — either one leaves {@link herdrConfigProblem} set and writes
|
|
133
|
+
* nothing.
|
|
134
|
+
*
|
|
135
|
+
* This is the **staged** copy, under the state directory like every other
|
|
136
|
+
* file the plan writes: herdr's live config is a file conductor does not own,
|
|
137
|
+
* so it is only replaced post-consent by the install step naming
|
|
138
|
+
* {@link herdrConfigTarget} (#513).
|
|
139
|
+
*/
|
|
140
|
+
herdrConfig?: PlannedWrite<string>;
|
|
141
|
+
/** The live herdr config {@link herdrConfig} merges into, post-consent. */
|
|
142
|
+
herdrConfigTarget?: string;
|
|
143
|
+
/** Why no pane-shell key is planned (unusable login shell, …). */
|
|
144
|
+
herdrConfigProblem?: string;
|
|
39
145
|
tick?: PlannedWrite<TickConfig>;
|
|
146
|
+
/** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
|
|
147
|
+
briefLink?: BriefLinkPlan;
|
|
148
|
+
/**
|
|
149
|
+
* Set when an install ran with no project named (a host-global install):
|
|
150
|
+
* the per-project tail was deliberately not written. Names the files and
|
|
151
|
+
* how to write them, so the operator is told what is missing and how to get
|
|
152
|
+
* it rather than silently installing host-global units that leave a project
|
|
153
|
+
* brief-less and tick-less.
|
|
154
|
+
*/
|
|
155
|
+
noProject?: HostRuntimeNoProject;
|
|
156
|
+
/**
|
|
157
|
+
* The exact privileged steps this plan will run, in order — recovery first,
|
|
158
|
+
* so the daemon unit that follows names a recovery unit systemd already has.
|
|
159
|
+
* This is the single list: {@link installCommands} and `runHostInstall` are
|
|
160
|
+
* both derived from it, so a step can never appear in the printed plan and
|
|
161
|
+
* be absent from the run (#509).
|
|
162
|
+
*/
|
|
163
|
+
steps: readonly PrivilegedStep[];
|
|
164
|
+
/**
|
|
165
|
+
* The same steps as `sudo …` shell lines, for humans to read. A projection
|
|
166
|
+
* of {@link steps} rather than a parallel list, so nothing executes a step
|
|
167
|
+
* the printed plan does not show.
|
|
168
|
+
*/
|
|
40
169
|
installCommands: readonly string[];
|
|
41
170
|
cliSource: "global" | "plugin";
|
|
42
171
|
/** Absolute path of the unit systemd actually reads. */
|
|
@@ -54,6 +183,12 @@ export interface HostRuntimePlan {
|
|
|
54
183
|
* the installed unit matched.
|
|
55
184
|
*/
|
|
56
185
|
installedAction: PlannedWrite<string>["action"];
|
|
186
|
+
/**
|
|
187
|
+
* True when every file the privileged install steps would write is already
|
|
188
|
+
* at its destination with the current bytes. `runHostInstall` uses it to
|
|
189
|
+
* make a no-op re-run of `setup host` neither restage nor restart anything.
|
|
190
|
+
*/
|
|
191
|
+
currentInstall: boolean;
|
|
57
192
|
}
|
|
58
193
|
|
|
59
194
|
export interface ServiceRuntime {
|
|
@@ -65,6 +200,21 @@ export interface ServiceRuntime {
|
|
|
65
200
|
packageCli: string;
|
|
66
201
|
conductorHome: string;
|
|
67
202
|
telegramStateDir: string;
|
|
203
|
+
/**
|
|
204
|
+
* The account's login shell. Rendered into the Herdr session unit as
|
|
205
|
+
* `Environment=SHELL=` (and into the herdr config as `[terminal]
|
|
206
|
+
* default_shell`), so every pane in the fleet session runs this shell instead
|
|
207
|
+
* of the `/bin/sh` Herdr falls back to when `$SHELL` is unset (the fleet's
|
|
208
|
+
* dash-pane symptom, #456). A systemd service has no `$SHELL`, so this is
|
|
209
|
+
* load-bearing, not decoration.
|
|
210
|
+
*/
|
|
211
|
+
shell: string;
|
|
212
|
+
/**
|
|
213
|
+
* Absolute path of the `herdr` binary on this host, or `undefined` when Herdr
|
|
214
|
+
* is not installed. When absent, setup stages no Herdr unit and no pane-shell
|
|
215
|
+
* config — a host driving Herdr some other way stays supported (#456).
|
|
216
|
+
*/
|
|
217
|
+
herdr?: string;
|
|
68
218
|
/**
|
|
69
219
|
* Non-default Herdr session to pin into the unit (#394). Omitted when the
|
|
70
220
|
* host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
|
|
@@ -93,10 +243,6 @@ function systemdPath(value: string): string {
|
|
|
93
243
|
return value.replaceAll("%", "%%");
|
|
94
244
|
}
|
|
95
245
|
|
|
96
|
-
function shellQuote(value: string): string {
|
|
97
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
246
|
function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
|
|
101
247
|
if (!existsSync(path)) return "create";
|
|
102
248
|
try {
|
|
@@ -252,6 +398,8 @@ function defaultServiceRuntime(
|
|
|
252
398
|
const home = homedir();
|
|
253
399
|
const bun = process.execPath;
|
|
254
400
|
const globalCli = Bun.which("omp-conductor");
|
|
401
|
+
const herdr = Bun.which("herdr");
|
|
402
|
+
const loginShell = userInfo().shell;
|
|
255
403
|
const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
|
|
256
404
|
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
|
257
405
|
);
|
|
@@ -264,6 +412,10 @@ function defaultServiceRuntime(
|
|
|
264
412
|
packageCli: join(import.meta.dir, "cli.ts"),
|
|
265
413
|
conductorHome: dirname(configPath()),
|
|
266
414
|
telegramStateDir,
|
|
415
|
+
shell: loginShell ?? "/bin/sh",
|
|
416
|
+
// Only when Herdr is actually installed: a host running Herdr some other
|
|
417
|
+
// way (or none yet) gets no Herdr unit rendered, ever.
|
|
418
|
+
...(herdr === null ? {} : { herdr }),
|
|
267
419
|
// Only pin a non-default session. Default hosts keep a unit with no
|
|
268
420
|
// HERDR_SESSION line so setup/upgrade do not invent drift (#394).
|
|
269
421
|
...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
|
|
@@ -289,6 +441,10 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
289
441
|
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
290
442
|
"After=network-online.target",
|
|
291
443
|
"Wants=network-online.target",
|
|
444
|
+
// #485: a crash-looped daemon used to sit in `failed` indefinitely, in
|
|
445
|
+
// silence — no unit was watching it. The recovery oneshot runs outside
|
|
446
|
+
// this unit's cgroup, collects evidence durably, and pages tier-2.
|
|
447
|
+
`OnFailure=${RECOVER_SERVICE_NAME}`,
|
|
292
448
|
"",
|
|
293
449
|
"[Service]",
|
|
294
450
|
"Type=simple",
|
|
@@ -304,7 +460,12 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
304
460
|
: [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
|
|
305
461
|
`WorkingDirectory=${systemdPath(stateDir())}`,
|
|
306
462
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
307
|
-
|
|
463
|
+
// #546: restart on any exit — clean, signalled or crashed — except an
|
|
464
|
+
// explicit `systemctl stop`, which systemd records as intentional and does
|
|
465
|
+
// not undo. A stray SIGTERM (a worker's `bun test`) then costs seconds of
|
|
466
|
+
// downtime instead of leaving the fleet down until a human. A crash loop
|
|
467
|
+
// still trips the start-limit burst and reaches `OnFailure=` above.
|
|
468
|
+
"Restart=always",
|
|
308
469
|
"SuccessExitStatus=0 143",
|
|
309
470
|
"MemoryAccounting=yes",
|
|
310
471
|
`MemoryMax=${memoryMax}`,
|
|
@@ -315,6 +476,161 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
315
476
|
].join("\n");
|
|
316
477
|
}
|
|
317
478
|
|
|
479
|
+
/**
|
|
480
|
+
* The Herdr session-server unit, in the same shape as the daemon unit.
|
|
481
|
+
*
|
|
482
|
+
* The fleet's 24/7 guarantee (`upgrade`'s restart + pane-recovery leg, `start`,
|
|
483
|
+
* the recovery plugin) treats {@link DEFAULT_HERDR_UNIT} as a first-class
|
|
484
|
+
* dependency, yet nothing rendered it — every operator hand-wrote it from a bare
|
|
485
|
+
* `ExecStart=` line. This provisions it, and it is where the dash-pane bug dies:
|
|
486
|
+
* a systemd service has no `$SHELL`, so without `Environment=SHELL=` Herdr
|
|
487
|
+
* resolved every pane as `/bin/sh`→dash with no readline and no `.bashrc`. The
|
|
488
|
+
* account's login shell is pinned here, and again in the herdr config
|
|
489
|
+
* ({@link renderHerdrConfig}) as belt and braces.
|
|
490
|
+
*/
|
|
491
|
+
export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
492
|
+
if (runtime.herdr === undefined) throw new Error("cannot render a herdr unit without a herdr binary");
|
|
493
|
+
const session = runtime.herdrSession ?? DEFAULT_HERDR_SESSION;
|
|
494
|
+
const configPath = join(runtime.home, ".config", "herdr", "config.toml");
|
|
495
|
+
return [
|
|
496
|
+
"[Unit]",
|
|
497
|
+
"Description=herdr fleet session server",
|
|
498
|
+
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
499
|
+
"After=network-online.target",
|
|
500
|
+
"Wants=network-online.target",
|
|
501
|
+
// #485: the fleet's two units are each other's only plausible watcher and
|
|
502
|
+
// neither watched the other. The recovery oneshot is the watcher, and it
|
|
503
|
+
// must fire for the orchestrator host too — a dead herdr means no tick, so
|
|
504
|
+
// nobody else would ever report it.
|
|
505
|
+
`OnFailure=${RECOVER_SERVICE_NAME}`,
|
|
506
|
+
"",
|
|
507
|
+
"[Service]",
|
|
508
|
+
"Type=simple",
|
|
509
|
+
`User=${runtime.username}`,
|
|
510
|
+
`Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
|
|
511
|
+
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
512
|
+
`Environment=${systemdQuote(`HERDR_CONFIG_PATH=${configPath}`)}`,
|
|
513
|
+
`Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
|
|
514
|
+
`WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
|
|
515
|
+
`ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
|
|
516
|
+
// #546: same exposure as the daemon — restart on any exit except an
|
|
517
|
+
// explicit `systemctl stop`; a crash loop still trips the start limit.
|
|
518
|
+
"Restart=always",
|
|
519
|
+
"RestartSec=5",
|
|
520
|
+
"",
|
|
521
|
+
"[Install]",
|
|
522
|
+
"WantedBy=multi-user.target",
|
|
523
|
+
"",
|
|
524
|
+
].join("\n");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The fleet recovery unit (#485): a oneshot every fleet unit's `OnFailure=`
|
|
529
|
+
* names.
|
|
530
|
+
*
|
|
531
|
+
* It runs as root, outside the fleet units' cgroups and process trees, so the
|
|
532
|
+
* restart it performs can never take it down, and it carries an explicit PATH —
|
|
533
|
+
* on a typical host the non-interactive ssh PATH lacks `omp`, `herdr` and
|
|
534
|
+
* `omp-conductor`, and the playbook needs all three.
|
|
535
|
+
*
|
|
536
|
+
* The bounds are part of the unit text so `doctor` drift sees them:
|
|
537
|
+
* `StartLimitIntervalSec`/`StartLimitBurst` cap failed recovery starts at the
|
|
538
|
+
* systemd layer (the playbook keeps its own durable consecutive-attempt
|
|
539
|
+
* counter, agreeing on the same window), and there is deliberately **no**
|
|
540
|
+
* `OnFailure=` of its own and no `[Install]` — nothing activates recovery
|
|
541
|
+
* except the two fleet units' failure, so recovery can never re-trigger
|
|
542
|
+
* recovery.
|
|
543
|
+
*/
|
|
544
|
+
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
|
|
545
|
+
// `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
|
|
546
|
+
// enqueues. It is a host-global unit installed once for the whole box, so a
|
|
547
|
+
// static per-project value is only unambiguous on a single-project host — a
|
|
548
|
+
// multi-project host (or a no-project, host-global install) leaves it unset
|
|
549
|
+
// and reports without a `--project` rather than guessing one (#510/#530).
|
|
550
|
+
return [
|
|
551
|
+
"[Unit]",
|
|
552
|
+
"Description=omp-conductor fleet recovery (OnFailure handler)",
|
|
553
|
+
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
554
|
+
// The oneshot bound: a recovery that fails twice in the window stops
|
|
555
|
+
// trying, and the playbook's durable counter uses the same window.
|
|
556
|
+
"StartLimitIntervalSec=1800",
|
|
557
|
+
"StartLimitBurst=2",
|
|
558
|
+
"",
|
|
559
|
+
"[Service]",
|
|
560
|
+
"Type=oneshot",
|
|
561
|
+
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
562
|
+
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
563
|
+
`Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
|
|
564
|
+
...(projectName === undefined ? [] : [`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`]),
|
|
565
|
+
`ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
566
|
+
"",
|
|
567
|
+
].join("\n");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Whether a login-shell value may be pinned into herdr's config at all.
|
|
572
|
+
*
|
|
573
|
+
* `userInfo().shell` degrades to the literal `unknown` where the passwd entry
|
|
574
|
+
* cannot be read (the same root as #511's drift check), and `unknown` is not a
|
|
575
|
+
* path — a pane told to exec it dies on start, which is a stopped fleet. Only
|
|
576
|
+
* an absolute path that exists on this host qualifies; anything else must mean
|
|
577
|
+
* the caller writes **no** key and lets herdr's own `$SHELL → /bin/sh →
|
|
578
|
+
* passwd` fallback apply.
|
|
579
|
+
*/
|
|
580
|
+
export function usableHerdrShell(shell: string): boolean {
|
|
581
|
+
if (!shell.startsWith("/")) return false;
|
|
582
|
+
try {
|
|
583
|
+
return existsSync(shell);
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Idempotently put `[terminal] default_shell` into a herdr config, preserving
|
|
591
|
+
* everything else verbatim.
|
|
592
|
+
*
|
|
593
|
+
* Belt-and-braces behind the unit's `Environment=SHELL=`: a pane comes up as the
|
|
594
|
+
* account's login shell even if an operator later edits the unit and drops the
|
|
595
|
+
* `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
|
|
596
|
+
* so without this the only guarantee would be the unit's environment.
|
|
597
|
+
*
|
|
598
|
+
* A shell that is not a usable executable path ({@link usableHerdrShell})
|
|
599
|
+
* writes **no** key at all — the file passes through byte-identical, because
|
|
600
|
+
* "pin the shell" must never mean "write the word `unknown`".
|
|
601
|
+
*/
|
|
602
|
+
export function renderHerdrConfig(shell: string, existing?: string): string {
|
|
603
|
+
const text = existing ?? "";
|
|
604
|
+
if (!usableHerdrShell(shell)) return text;
|
|
605
|
+
|
|
606
|
+
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
607
|
+
const line = `default_shell = ${quoted}`;
|
|
608
|
+
|
|
609
|
+
// One anchored, comment-aware match, used for both the guard and the replace
|
|
610
|
+
// so they can never disagree: a *real* key starts its line (after whitespace),
|
|
611
|
+
// while `# default_shell = …` starts with a comment and is not a key. The
|
|
612
|
+
// old guard was unanchored, so it matched a commented line the anchored
|
|
613
|
+
// replace did not — a config whose only mention was a comment took the
|
|
614
|
+
// "replace" branch, replaced nothing, and silently wrote nothing at all.
|
|
615
|
+
// Any existing real key keeps its position and gets the new value in place —
|
|
616
|
+
// even when the value already matches, so a re-run stays idempotent instead
|
|
617
|
+
// of duplicating the key.
|
|
618
|
+
const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
|
|
619
|
+
if (keyLine.test(text)) {
|
|
620
|
+
return text.replace(keyLine, `$1${quoted}`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const lines = text.split("\n");
|
|
624
|
+
const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
|
|
625
|
+
if (table === -1) {
|
|
626
|
+
const body = text.replace(/\s+$/, "");
|
|
627
|
+
return `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
|
|
628
|
+
}
|
|
629
|
+
// The table exists but has no default_shell; drop the key right under it.
|
|
630
|
+
lines.splice(table + 1, 0, line);
|
|
631
|
+
return lines.join("\n");
|
|
632
|
+
}
|
|
633
|
+
|
|
318
634
|
function tickSearchRoots(project: ProjectConfig): string[] {
|
|
319
635
|
const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
|
|
320
636
|
return roots.filter((root, index) => roots.indexOf(root) === index);
|
|
@@ -373,6 +689,47 @@ export function tickCwdForProject(project: ProjectConfig): string {
|
|
|
373
689
|
}
|
|
374
690
|
}
|
|
375
691
|
|
|
692
|
+
/**
|
|
693
|
+
* Where this project's `AGENTS.md` link should point and what setup will do
|
|
694
|
+
* about it. The link lives in the **fleet cwd** — the directory the pane runs
|
|
695
|
+
* in — pointing at the composed brief under `workspaceRoot`, by a relative
|
|
696
|
+
* target so the tree stays movable.
|
|
697
|
+
*
|
|
698
|
+
* A fleet whose tick config predates per-project roots keeps its cwd at the
|
|
699
|
+
* state dir, so the link is `<stateDir>/AGENTS.md -> worktrees/ORCHESTRATOR.md`;
|
|
700
|
+
* a brand-new project links `AGENTS.md -> ORCHESTRATOR.md` in its own cwd.
|
|
701
|
+
*
|
|
702
|
+
* Never a clobber: a symlink already at the brief is left alone, a stale
|
|
703
|
+
* symlink is replaceable, and a *regular file* is an operator's own brief —
|
|
704
|
+
* reported and left untouched.
|
|
705
|
+
*/
|
|
706
|
+
function planBriefLink(project: ProjectConfig): BriefLinkPlan {
|
|
707
|
+
const path = join(tickCwdForProject(project), AGENTS_BRIEF_NAME);
|
|
708
|
+
const target = relative(dirname(path), join(project.workspaceRoot, ORCHESTRATOR_BRIEF_NAME));
|
|
709
|
+
let st;
|
|
710
|
+
try {
|
|
711
|
+
st = lstatSync(path);
|
|
712
|
+
} catch {
|
|
713
|
+
// ENOENT (no link yet) and any unreadable path (no permission on a parent)
|
|
714
|
+
// both collapse to a best-effort create; a refused filesystem surfaces as
|
|
715
|
+
// a warning at write time.
|
|
716
|
+
return { path, target, action: "create" };
|
|
717
|
+
}
|
|
718
|
+
if (st.isSymbolicLink()) {
|
|
719
|
+
return {
|
|
720
|
+
path,
|
|
721
|
+
target,
|
|
722
|
+
action: readlinkSync(path, "utf8") === target ? "keep" : "update",
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
path,
|
|
727
|
+
target,
|
|
728
|
+
action: "skip",
|
|
729
|
+
skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
376
733
|
function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
|
|
377
734
|
const found = findProjectTick(project);
|
|
378
735
|
const existing = found === undefined ? undefined : { path: found.path, config: found.config };
|
|
@@ -405,7 +762,14 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
405
762
|
}
|
|
406
763
|
|
|
407
764
|
export function planHostRuntime(
|
|
408
|
-
|
|
765
|
+
/**
|
|
766
|
+
* The project the per-project tail (tick config, brief link, the recovery
|
|
767
|
+
* unit's RECOVER_PROJECT) is planned for. `undefined` plans the host-global
|
|
768
|
+
* units only — the same staged paths and systemd destinations, one shared
|
|
769
|
+
* daemon — and a `noProject` note naming the per-project files it left for
|
|
770
|
+
* a named run (#530).
|
|
771
|
+
*/
|
|
772
|
+
project: ProjectConfig | undefined,
|
|
409
773
|
_caps: Caps,
|
|
410
774
|
telegramStateDir: string,
|
|
411
775
|
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
@@ -413,6 +777,17 @@ export function planHostRuntime(
|
|
|
413
777
|
// Defaulting through loadConfig() would throw in install tests that stage
|
|
414
778
|
// files before a config exists, and would hide a missing total at the call site.
|
|
415
779
|
totalWorkers: number = 1,
|
|
780
|
+
// The directory the privileged steps install units into. `runHostInstall`
|
|
781
|
+
// injects a test-hermetic directory so the executed argv is assertable; the
|
|
782
|
+
// real install always resolves to `/etc/systemd/system`.
|
|
783
|
+
unitDir: string = SYSTEMD_UNIT_DIR,
|
|
784
|
+
// Where the recovery playbook installs; injectable like {@link unitDir} so
|
|
785
|
+
// the idempotency gate is testable without touching the real `/usr/local/sbin`.
|
|
786
|
+
recoverScriptInstallPath: string = RECOVER_SCRIPT_INSTALL_PATH,
|
|
787
|
+
// True when the host configures more than one project. The recovery unit is
|
|
788
|
+
// host-global, so on a multi-project host it must not encode one project's
|
|
789
|
+
// name — and a no-project install never does (#510/#530).
|
|
790
|
+
multiProject: boolean = false,
|
|
416
791
|
): HostRuntimePlan {
|
|
417
792
|
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
418
793
|
const serviceContent = renderDaemonService(runtime, totalWorkers);
|
|
@@ -422,21 +797,201 @@ export function planHostRuntime(
|
|
|
422
797
|
content: serviceContent,
|
|
423
798
|
value: serviceContent,
|
|
424
799
|
};
|
|
425
|
-
const
|
|
800
|
+
const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
|
|
801
|
+
const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
|
|
802
|
+
// The pane-shell config is staged like every other file; the live herdr
|
|
803
|
+
// config — a file conductor does not own — is only written by the install
|
|
804
|
+
// step, after consent (#513). A declined `setup host` must leave herdr's
|
|
805
|
+
// config byte-for-byte untouched, and the merge must land before the unit
|
|
806
|
+
// restart that accompanies it.
|
|
807
|
+
const herdrConfigStagedPath = join(stateDir(), "herdr-config.toml");
|
|
808
|
+
let herdrUnit: PlannedWrite<string> | undefined;
|
|
809
|
+
let herdrConfig: PlannedWrite<string> | undefined;
|
|
810
|
+
let herdrConfigTarget: string | undefined;
|
|
811
|
+
let herdrConfigProblem: string | undefined;
|
|
812
|
+
// The recovery playbook ships in this package's systemd/ dir; the same
|
|
813
|
+
// bytes go into the staged copy, so version control is the single source.
|
|
814
|
+
const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
|
|
815
|
+
let recoverScriptContent: string;
|
|
816
|
+
try {
|
|
817
|
+
recoverScriptContent = readFileSync(join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE), "utf8");
|
|
818
|
+
} catch (err) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`cannot read the recovery playbook at ${join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE)} — it ships with this package, and setup cannot stage it without it: ${err instanceof Error ? err.message : String(err)}`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (runtime.herdr !== undefined) {
|
|
824
|
+
const unitContent = renderHerdrUnit(runtime);
|
|
825
|
+
herdrUnit = {
|
|
826
|
+
path: herdrUnitPath,
|
|
827
|
+
action: actionFor(herdrUnitPath, unitContent),
|
|
828
|
+
content: unitContent,
|
|
829
|
+
value: unitContent,
|
|
830
|
+
};
|
|
831
|
+
// Belt and braces: read the session's herdr config so `[terminal]
|
|
832
|
+
// default_shell` merges into whatever the operator already has, preserving
|
|
833
|
+
// it verbatim. A missing or unreadable config starts from scratch.
|
|
834
|
+
let existing: string | undefined;
|
|
835
|
+
try {
|
|
836
|
+
existing = readFileSync(herdrConfigPath, "utf8");
|
|
837
|
+
} catch {
|
|
838
|
+
existing = undefined;
|
|
839
|
+
}
|
|
840
|
+
// An unresolvable login shell (userInfo() renders the literal "unknown")
|
|
841
|
+
// plans nothing and says why: never a placeholder, and never a value that
|
|
842
|
+
// is not an executable path — herdr's own `$SHELL → /bin/sh → passwd`
|
|
843
|
+
// fallback applies instead.
|
|
844
|
+
if (!usableHerdrShell(runtime.shell)) {
|
|
845
|
+
herdrConfigProblem =
|
|
846
|
+
`login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
|
|
847
|
+
"no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
|
|
848
|
+
} else {
|
|
849
|
+
const configContent = renderHerdrConfig(runtime.shell, existing);
|
|
850
|
+
// The rendered file is parse-checked before anything may replace herdr's
|
|
851
|
+
// live config. An input that is already broken (a duplicated key, say)
|
|
852
|
+
// renders broken — the replace touches every real key, so two keys stay
|
|
853
|
+
// two keys — and writing that file is exactly the stopped-fleet outcome
|
|
854
|
+
// this must prevent. A parse failure aborts the whole plan here, before
|
|
855
|
+
// a single file has been staged.
|
|
856
|
+
if (actionFor(herdrConfigPath, configContent) !== "keep") {
|
|
857
|
+
try {
|
|
858
|
+
Bun.TOML.parse(configContent);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
throw new Error(
|
|
861
|
+
`refusing to write herdr's config at ${herdrConfigPath}: it would not parse ` +
|
|
862
|
+
`(${err instanceof Error ? err.message : String(err)}). Nothing has been staged — ` +
|
|
863
|
+
"fix the file by hand (herdr's `config check` names the line), then re-run setup host.",
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
herdrConfig = {
|
|
868
|
+
path: herdrConfigStagedPath,
|
|
869
|
+
action: actionFor(herdrConfigStagedPath, configContent),
|
|
870
|
+
content: configContent,
|
|
871
|
+
value: configContent,
|
|
872
|
+
};
|
|
873
|
+
herdrConfigTarget = herdrConfigPath;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const installedPath = join(unitDir, STAGED_SERVICE_NAME);
|
|
877
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
878
|
+
const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
|
|
879
|
+
// The recovery unit is host-global: one shared unit, installed once. A
|
|
880
|
+
// static RECOVER_PROJECT is only legitimate when there is exactly one
|
|
881
|
+
// project to be unambiguous about — a multi-project host (or a no-project
|
|
882
|
+
// install) leaves it unset so the escalation reports without attributing a
|
|
883
|
+
// sibling's crash to one project (#510/#530).
|
|
884
|
+
const recoverProject = project === undefined || multiProject ? undefined : project.name;
|
|
885
|
+
const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
|
|
886
|
+
const recoverUnit: PlannedWrite<string> = {
|
|
887
|
+
path: recoverUnitPath,
|
|
888
|
+
action: actionFor(recoverUnitPath, recoverUnitContent),
|
|
889
|
+
content: recoverUnitContent,
|
|
890
|
+
value: recoverUnitContent,
|
|
891
|
+
};
|
|
892
|
+
const recoverScript: PlannedWrite<string> = {
|
|
893
|
+
path: recoverScriptPath,
|
|
894
|
+
action: actionFor(recoverScriptPath, recoverScriptContent),
|
|
895
|
+
content: recoverScriptContent,
|
|
896
|
+
value: recoverScriptContent,
|
|
897
|
+
};
|
|
898
|
+
// Recovery first: the daemon unit that follows names it in OnFailure=, so
|
|
899
|
+
// the restart below must never point at a unit systemd cannot load. This is
|
|
900
|
+
// the single list `runHostInstall` executes and {@link installCommands}
|
|
901
|
+
// renders from, so no step can be in one and missing from the other (#509).
|
|
902
|
+
const installSteps: PrivilegedStep[] = [
|
|
903
|
+
{
|
|
904
|
+
title: "install the recovery playbook",
|
|
905
|
+
argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
title: `install ${RECOVER_SERVICE_NAME}`,
|
|
909
|
+
argv: ["install", "-m", "0644", recoverUnitPath, join(unitDir, RECOVER_SERVICE_NAME)],
|
|
910
|
+
},
|
|
911
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
912
|
+
{
|
|
913
|
+
title: `install ${STAGED_SERVICE_NAME}`,
|
|
914
|
+
argv: ["install", "-m", "0644", servicePath, installedPath],
|
|
915
|
+
},
|
|
916
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
917
|
+
{ title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
|
|
918
|
+
{ title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
|
|
919
|
+
// The herdr session server, provisioned alongside the daemon (#456). Only
|
|
920
|
+
// when herdr is installed and the plan therefore staged a unit.
|
|
921
|
+
...(herdrUnit === undefined
|
|
922
|
+
? []
|
|
923
|
+
: [
|
|
924
|
+
{
|
|
925
|
+
title: `install ${DEFAULT_HERDR_UNIT}`,
|
|
926
|
+
argv: ["install", "-m", "0644", herdrUnitPath, installedHerdr],
|
|
927
|
+
},
|
|
928
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
929
|
+
{ title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
|
|
930
|
+
// The pane-shell merge lands *before* the restart it accompanies, so
|
|
931
|
+
// the session server comes up with the pinned shell, not the stale
|
|
932
|
+
// config. It runs without sudo on purpose: the file is the fleet
|
|
933
|
+
// account's own (the escalation guard guarantees setup runs as that
|
|
934
|
+
// account), and a root-owned copy would break herdr's next rewrite.
|
|
935
|
+
// Nothing writes herdr's live config until this consent-gated step.
|
|
936
|
+
...(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep"
|
|
937
|
+
? []
|
|
938
|
+
: [
|
|
939
|
+
{
|
|
940
|
+
title: `write the pane shell into ${herdrConfigPath}`,
|
|
941
|
+
argv: ["install", "-m", "0644", herdrConfigStagedPath, herdrConfigPath],
|
|
942
|
+
unprivileged: true,
|
|
943
|
+
},
|
|
944
|
+
]),
|
|
945
|
+
{ title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
|
|
946
|
+
]),
|
|
947
|
+
];
|
|
948
|
+
// Everything the privileged steps install is already at its destination with
|
|
949
|
+
// the current bytes, so a re-run of `setup host` has nothing to install and
|
|
950
|
+
// nothing to restart. The herdr unit is absent on a host without herdr, and
|
|
951
|
+
// an absent unit that would not be provisioned is nothing to do.
|
|
952
|
+
const installedAction = actionFor(installedPath, serviceContent);
|
|
953
|
+
const currentInstall =
|
|
954
|
+
installedAction === "keep" &&
|
|
955
|
+
actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
|
|
956
|
+
actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
|
|
957
|
+
(herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
|
|
958
|
+
// The pane-shell file is a destination like the units: a re-run with all
|
|
959
|
+
// units current but the config merge still pending must not report
|
|
960
|
+
// "nothing to install" and skip the very write the plan exists to make.
|
|
961
|
+
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep");
|
|
426
962
|
return {
|
|
427
963
|
service,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
964
|
+
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
965
|
+
// session to supervise, nothing for a pane shell to belong to. (The pane
|
|
966
|
+
// shell is omitted too when the login shell is unusable or the rendered
|
|
967
|
+
// config would not parse — those plans carry {@link herdrConfigProblem}.)
|
|
968
|
+
...(herdrUnit === undefined ? {} : { herdrUnit }),
|
|
969
|
+
...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
|
|
970
|
+
...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
|
|
971
|
+
recoverUnit,
|
|
972
|
+
recoverScript,
|
|
973
|
+
...(project === undefined
|
|
974
|
+
? {
|
|
975
|
+
// Host-global install: the per-project tail entities are not written,
|
|
976
|
+
// and the plan says exactly which files and how to write them.
|
|
977
|
+
noProject: {
|
|
978
|
+
skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME],
|
|
979
|
+
how: "re-run `omp-conductor setup host <NAME>` (or --project NAME) to write them for one project",
|
|
980
|
+
},
|
|
981
|
+
}
|
|
982
|
+
: {
|
|
983
|
+
briefLink: planBriefLink(project),
|
|
984
|
+
...(project.escalation.orchestrator === "external"
|
|
985
|
+
? { tick: planTick(project, telegramStateDir) }
|
|
986
|
+
: {}),
|
|
987
|
+
}),
|
|
988
|
+
steps: installSteps,
|
|
989
|
+
// For humans to read; the executed form is {@link steps}, in argv.
|
|
990
|
+
installCommands: installSteps.map((s) => formatStep(s)),
|
|
437
991
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
438
992
|
installedPath,
|
|
439
|
-
installedAction
|
|
993
|
+
installedAction,
|
|
994
|
+
currentInstall,
|
|
440
995
|
};
|
|
441
996
|
}
|
|
442
997
|
|
|
@@ -445,6 +1000,16 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
445
1000
|
"host runtime",
|
|
446
1001
|
` service ${plan.service.action} ${plan.service.path}`,
|
|
447
1002
|
` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
|
|
1003
|
+
` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
|
|
1004
|
+
` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
1005
|
+
...(plan.herdrUnit === undefined
|
|
1006
|
+
? [" herdr session skipped — herdr not installed on this host"]
|
|
1007
|
+
: [
|
|
1008
|
+
` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
|
|
1009
|
+
...(plan.herdrConfig === undefined
|
|
1010
|
+
? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
|
|
1011
|
+
: [` pane shell ${plan.herdrConfig.action} ${plan.herdrConfig.path} ([terminal] default_shell)`]),
|
|
1012
|
+
]),
|
|
448
1013
|
];
|
|
449
1014
|
if (plan.tick !== undefined) {
|
|
450
1015
|
lines.push(
|
|
@@ -456,6 +1021,19 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
456
1021
|
} else {
|
|
457
1022
|
lines.push(" heartbeat embedded orchestrator — no external tick config");
|
|
458
1023
|
}
|
|
1024
|
+
if (plan.noProject !== undefined) {
|
|
1025
|
+
lines.push(
|
|
1026
|
+
` per-project skipped (no project named): ${plan.noProject.skipped.join(", ")}`,
|
|
1027
|
+
` ${plan.noProject.how}`,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
if (plan.briefLink !== undefined) {
|
|
1031
|
+
lines.push(
|
|
1032
|
+
plan.briefLink.action === "skip"
|
|
1033
|
+
? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
|
|
1034
|
+
: ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
459
1037
|
lines.push(" install staged only; the final result prints the systemd install commands");
|
|
460
1038
|
return lines.join("\n");
|
|
461
1039
|
}
|
|
@@ -474,17 +1052,77 @@ function atomicWrite(path: string, content: string, mode: number): void {
|
|
|
474
1052
|
}
|
|
475
1053
|
}
|
|
476
1054
|
|
|
477
|
-
|
|
478
|
-
|
|
1055
|
+
/** What {@link writeHostRuntime} wrote, plus anything it could not. */
|
|
1056
|
+
export interface HostRuntimeWrite {
|
|
1057
|
+
/** Paths that were (re)written. */
|
|
1058
|
+
wrote: string[];
|
|
1059
|
+
/** Best-effort failures the caller should surface; setup continues regardless. */
|
|
1060
|
+
warnings: string[];
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
1064
|
+
const wrote: string[] = [];
|
|
1065
|
+
const warnings: string[] = [];
|
|
479
1066
|
if (plan.service.action !== "keep") {
|
|
480
1067
|
atomicWrite(plan.service.path, plan.service.content, 0o644);
|
|
481
|
-
|
|
1068
|
+
wrote.push(plan.service.path);
|
|
1069
|
+
}
|
|
1070
|
+
if (plan.herdrUnit !== undefined && plan.herdrUnit.action !== "keep") {
|
|
1071
|
+
atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
|
|
1072
|
+
wrote.push(plan.herdrUnit.path);
|
|
1073
|
+
}
|
|
1074
|
+
if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
|
|
1075
|
+
atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
|
|
1076
|
+
wrote.push(plan.herdrConfig.path);
|
|
1077
|
+
}
|
|
1078
|
+
if (plan.recoverUnit.action !== "keep") {
|
|
1079
|
+
atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
|
|
1080
|
+
wrote.push(plan.recoverUnit.path);
|
|
1081
|
+
}
|
|
1082
|
+
if (plan.recoverScript.action !== "keep") {
|
|
1083
|
+
atomicWrite(plan.recoverScript.path, plan.recoverScript.content, 0o755);
|
|
1084
|
+
wrote.push(plan.recoverScript.path);
|
|
482
1085
|
}
|
|
483
1086
|
if (plan.tick !== undefined && plan.tick.action !== "keep") {
|
|
484
1087
|
atomicWrite(plan.tick.path, plan.tick.content, 0o600);
|
|
485
|
-
|
|
1088
|
+
wrote.push(plan.tick.path);
|
|
1089
|
+
}
|
|
1090
|
+
// `skip` is a regular file the plan already told the operator would be left
|
|
1091
|
+
// alone; `keep` is an already-correct link. Only create/update touch disk —
|
|
1092
|
+
// and never on the plan's word: the entry can change across the consent gap
|
|
1093
|
+
// (an operator may drop a regular file where a stale symlink was planned for
|
|
1094
|
+
// replacement). Re-lstat at write time and act on *current* state, so a file
|
|
1095
|
+
// that appears mid-flight is preserved, never unlinked by a stale plan.
|
|
1096
|
+
// A host-global plan (no project) has no briefLink to write at all.
|
|
1097
|
+
if (plan.briefLink !== undefined && (plan.briefLink.action === "create" || plan.briefLink.action === "update")) {
|
|
1098
|
+
const { path, target } = plan.briefLink;
|
|
1099
|
+
try {
|
|
1100
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1101
|
+
let st: Stats | undefined;
|
|
1102
|
+
try {
|
|
1103
|
+
st = lstatSync(path);
|
|
1104
|
+
} catch {
|
|
1105
|
+
st = undefined; // ENOENT — nothing there yet; also best-effort if a parent refuses.
|
|
1106
|
+
}
|
|
1107
|
+
if (st !== undefined && !st.isSymbolicLink()) {
|
|
1108
|
+
// A regular file occupies the path (present at plan time, appeared, or
|
|
1109
|
+
// swapped in since). Never clobber an operator's file: warn and carry on.
|
|
1110
|
+
warnings.push(`${path} is a regular file, not ours to overwrite; AGENTS.md brief link skipped`);
|
|
1111
|
+
} else if (st !== undefined && readlinkSync(path, "utf8") === target) {
|
|
1112
|
+
// Already the correct link — leave it untouched, however the plan read it.
|
|
1113
|
+
} else {
|
|
1114
|
+
// Missing, or a stale symlink: (re)link it. Only a *current* symlink is
|
|
1115
|
+
// removed; an operator's file that appears mid-flight survives the write.
|
|
1116
|
+
if (st !== undefined) rmSync(path, { force: true });
|
|
1117
|
+
symlinkSync(target, path);
|
|
1118
|
+
wrote.push(path);
|
|
1119
|
+
}
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
// Best effort: a filesystem that refuses symlinks must not fail setup.
|
|
1122
|
+
warnings.push(`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1123
|
+
}
|
|
486
1124
|
}
|
|
487
|
-
return
|
|
1125
|
+
return { wrote, warnings };
|
|
488
1126
|
}
|
|
489
1127
|
|
|
490
1128
|
export interface SetupSmokeResult {
|