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-host.ts
CHANGED
|
@@ -23,10 +23,13 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
legacyArmedMarkerPath,
|
|
25
25
|
readTickConfig,
|
|
26
|
+
parseHerdrAgents,
|
|
26
27
|
TICK_CONFIG_FILE,
|
|
27
28
|
tickConfigMatchesProject,
|
|
29
|
+
type HerdrAgentList,
|
|
28
30
|
type TickConfig,
|
|
29
31
|
} from "./orchestrator-tick.ts";
|
|
32
|
+
import { formatStep, type PrivilegedStep } from "./privileged.ts";
|
|
30
33
|
import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
|
|
31
34
|
|
|
32
35
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
@@ -90,6 +93,17 @@ export interface BriefLinkPlan {
|
|
|
90
93
|
skippedReason?: string;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Why a host-global install (no `--project`) wrote no per-project tail, and
|
|
98
|
+
* what the operator does about it.
|
|
99
|
+
*/
|
|
100
|
+
export interface HostRuntimeNoProject {
|
|
101
|
+
/** The project-scoped files the host-global install deliberately left unwritten. */
|
|
102
|
+
skipped: readonly string[];
|
|
103
|
+
/** The command that writes them: `setup host` naming one project. */
|
|
104
|
+
how: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
export interface HostRuntimePlan {
|
|
94
108
|
service: PlannedWrite<string>;
|
|
95
109
|
/**
|
|
@@ -115,12 +129,71 @@ export interface HostRuntimePlan {
|
|
|
115
129
|
/**
|
|
116
130
|
* `[terminal] default_shell` staged into the session's herdr config, the
|
|
117
131
|
* belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
|
|
118
|
-
* Present exactly when {@link herdrUnit} is
|
|
132
|
+
* Present exactly when {@link herdrUnit} is, unless the login shell is not a
|
|
133
|
+
* usable path ({@link usableHerdrShell}) or the rendered config would not
|
|
134
|
+
* parse — either one leaves {@link herdrConfigProblem} set and writes
|
|
135
|
+
* nothing.
|
|
136
|
+
*
|
|
137
|
+
* This is the **staged** copy, under the state directory like every other
|
|
138
|
+
* file the plan writes: herdr's live config is a file conductor does not own,
|
|
139
|
+
* so it is only replaced post-consent by the install step naming
|
|
140
|
+
* {@link herdrConfigTarget} (#513).
|
|
119
141
|
*/
|
|
120
142
|
herdrConfig?: PlannedWrite<string>;
|
|
143
|
+
/** The live herdr config {@link herdrConfig} merges into, post-consent. */
|
|
144
|
+
herdrConfigTarget?: string;
|
|
145
|
+
/** Why no pane-shell key is planned (unusable login shell, …). */
|
|
146
|
+
herdrConfigProblem?: string;
|
|
147
|
+
/**
|
|
148
|
+
* A loud note when the pane-shell merge kept an explicit
|
|
149
|
+
* `resume_agents_on_restore = true` — {@link renderHerdrConfig} preserves
|
|
150
|
+
* that value on purpose (it may be a desktop host), and the plan says so
|
|
151
|
+
* rather than silently overriding a deliberate choice.
|
|
152
|
+
*/
|
|
153
|
+
herdrConfigWarning?: string;
|
|
154
|
+
/**
|
|
155
|
+
* The herdr-conductor plugin's `config.env`, staged like the pane-shell
|
|
156
|
+
* config and merged into the live plugin config dir post-consent: the file
|
|
157
|
+
* recover.sh sources (`herdr/bin/recover.sh`) — TARGET_SESSION, FLEET_CWDS
|
|
158
|
+
* (derived from every configured project), TELEGRAM_ENV, ACCESS_JSON.
|
|
159
|
+
* Present exactly when {@link herdrUnit} is; conductor-owned keys are
|
|
160
|
+
* patched in place, operator keys survive untouched.
|
|
161
|
+
*/
|
|
162
|
+
herdrEnv?: PlannedWrite<string>;
|
|
163
|
+
/** The live plugin config.env {@link herdrEnv} merges into, post-consent. */
|
|
164
|
+
herdrEnvTarget?: string;
|
|
165
|
+
/**
|
|
166
|
+
* When {@link planTick} rewrote a shared-default `agentName`, the live herdr
|
|
167
|
+
* pane still carries the old identity — the ticking decline that names the
|
|
168
|
+
* fix (`herdr agent rename <pane> <name>`). Setup performs the rename after
|
|
169
|
+
* the install when the session is reachable and exactly one pane matches;
|
|
170
|
+
* anything else surfaces the exact command (never guesses a pane).
|
|
171
|
+
*/
|
|
172
|
+
agentRename?: { session: string; from: string; to: string };
|
|
121
173
|
tick?: PlannedWrite<TickConfig>;
|
|
122
174
|
/** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
|
|
123
|
-
briefLink
|
|
175
|
+
briefLink?: BriefLinkPlan;
|
|
176
|
+
/**
|
|
177
|
+
* Set when an install ran with no project named (a host-global install):
|
|
178
|
+
* the per-project tail was deliberately not written. Names the files and
|
|
179
|
+
* how to write them, so the operator is told what is missing and how to get
|
|
180
|
+
* it rather than silently installing host-global units that leave a project
|
|
181
|
+
* brief-less and tick-less.
|
|
182
|
+
*/
|
|
183
|
+
noProject?: HostRuntimeNoProject;
|
|
184
|
+
/**
|
|
185
|
+
* The exact privileged steps this plan will run, in order — recovery first,
|
|
186
|
+
* so the daemon unit that follows names a recovery unit systemd already has.
|
|
187
|
+
* This is the single list: {@link installCommands} and `runHostInstall` are
|
|
188
|
+
* both derived from it, so a step can never appear in the printed plan and
|
|
189
|
+
* be absent from the run (#509).
|
|
190
|
+
*/
|
|
191
|
+
steps: readonly PrivilegedStep[];
|
|
192
|
+
/**
|
|
193
|
+
* The same steps as `sudo …` shell lines, for humans to read. A projection
|
|
194
|
+
* of {@link steps} rather than a parallel list, so nothing executes a step
|
|
195
|
+
* the printed plan does not show.
|
|
196
|
+
*/
|
|
124
197
|
installCommands: readonly string[];
|
|
125
198
|
cliSource: "global" | "plugin";
|
|
126
199
|
/** Absolute path of the unit systemd actually reads. */
|
|
@@ -138,6 +211,12 @@ export interface HostRuntimePlan {
|
|
|
138
211
|
* the installed unit matched.
|
|
139
212
|
*/
|
|
140
213
|
installedAction: PlannedWrite<string>["action"];
|
|
214
|
+
/**
|
|
215
|
+
* True when every file the privileged install steps would write is already
|
|
216
|
+
* at its destination with the current bytes. `runHostInstall` uses it to
|
|
217
|
+
* make a no-op re-run of `setup host` neither restage nor restart anything.
|
|
218
|
+
*/
|
|
219
|
+
currentInstall: boolean;
|
|
141
220
|
}
|
|
142
221
|
|
|
143
222
|
export interface ServiceRuntime {
|
|
@@ -192,10 +271,6 @@ function systemdPath(value: string): string {
|
|
|
192
271
|
return value.replaceAll("%", "%%");
|
|
193
272
|
}
|
|
194
273
|
|
|
195
|
-
function shellQuote(value: string): string {
|
|
196
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
274
|
function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
|
|
200
275
|
if (!existsSync(path)) return "create";
|
|
201
276
|
try {
|
|
@@ -413,7 +488,12 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
413
488
|
: [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
|
|
414
489
|
`WorkingDirectory=${systemdPath(stateDir())}`,
|
|
415
490
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
416
|
-
|
|
491
|
+
// #546: restart on any exit — clean, signalled or crashed — except an
|
|
492
|
+
// explicit `systemctl stop`, which systemd records as intentional and does
|
|
493
|
+
// not undo. A stray SIGTERM (a worker's `bun test`) then costs seconds of
|
|
494
|
+
// downtime instead of leaving the fleet down until a human. A crash loop
|
|
495
|
+
// still trips the start-limit burst and reaches `OnFailure=` above.
|
|
496
|
+
"Restart=always",
|
|
417
497
|
"SuccessExitStatus=0 143",
|
|
418
498
|
"MemoryAccounting=yes",
|
|
419
499
|
`MemoryMax=${memoryMax}`,
|
|
@@ -461,7 +541,9 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
|
461
541
|
`Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
|
|
462
542
|
`WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
|
|
463
543
|
`ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
|
|
464
|
-
|
|
544
|
+
// #546: same exposure as the daemon — restart on any exit except an
|
|
545
|
+
// explicit `systemctl stop`; a crash loop still trips the start limit.
|
|
546
|
+
"Restart=always",
|
|
465
547
|
"RestartSec=5",
|
|
466
548
|
"",
|
|
467
549
|
"[Install]",
|
|
@@ -487,7 +569,12 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
|
487
569
|
* except the two fleet units' failure, so recovery can never re-trigger
|
|
488
570
|
* recovery.
|
|
489
571
|
*/
|
|
490
|
-
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string): string {
|
|
572
|
+
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
|
|
573
|
+
// `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
|
|
574
|
+
// enqueues. It is a host-global unit installed once for the whole box, so a
|
|
575
|
+
// static per-project value is only unambiguous on a single-project host — a
|
|
576
|
+
// multi-project host (or a no-project, host-global install) leaves it unset
|
|
577
|
+
// and reports without a `--project` rather than guessing one (#510/#530).
|
|
491
578
|
return [
|
|
492
579
|
"[Unit]",
|
|
493
580
|
"Description=omp-conductor fleet recovery (OnFailure handler)",
|
|
@@ -502,12 +589,31 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
|
|
|
502
589
|
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
503
590
|
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
504
591
|
`Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
|
|
505
|
-
`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}
|
|
592
|
+
...(projectName === undefined ? [] : [`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`]),
|
|
506
593
|
`ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
507
594
|
"",
|
|
508
595
|
].join("\n");
|
|
509
596
|
}
|
|
510
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Whether a login-shell value may be pinned into herdr's config at all.
|
|
600
|
+
*
|
|
601
|
+
* `userInfo().shell` degrades to the literal `unknown` where the passwd entry
|
|
602
|
+
* cannot be read (the same root as #511's drift check), and `unknown` is not a
|
|
603
|
+
* path — a pane told to exec it dies on start, which is a stopped fleet. Only
|
|
604
|
+
* an absolute path that exists on this host qualifies; anything else must mean
|
|
605
|
+
* the caller writes **no** key and lets herdr's own `$SHELL → /bin/sh →
|
|
606
|
+
* passwd` fallback apply.
|
|
607
|
+
*/
|
|
608
|
+
export function usableHerdrShell(shell: string): boolean {
|
|
609
|
+
if (!shell.startsWith("/")) return false;
|
|
610
|
+
try {
|
|
611
|
+
return existsSync(shell);
|
|
612
|
+
} catch {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
511
617
|
/**
|
|
512
618
|
* Idempotently put `[terminal] default_shell` into a herdr config, preserving
|
|
513
619
|
* everything else verbatim.
|
|
@@ -516,28 +622,280 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
|
|
|
516
622
|
* account's login shell even if an operator later edits the unit and drops the
|
|
517
623
|
* `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
|
|
518
624
|
* so without this the only guarantee would be the unit's environment.
|
|
625
|
+
*
|
|
626
|
+
* A shell that is not a usable executable path ({@link usableHerdrShell})
|
|
627
|
+
* writes **no** key at all — the file passes through byte-identical, because
|
|
628
|
+
* "pin the shell" must never mean "write the word `unknown`".
|
|
519
629
|
*/
|
|
520
|
-
export
|
|
521
|
-
|
|
630
|
+
export interface HerdrConfigRender {
|
|
631
|
+
content: string;
|
|
632
|
+
/**
|
|
633
|
+
* Set when the merged config kept an explicit `resume_agents_on_restore =
|
|
634
|
+
* true` — preserved on purpose (it may be a deliberate desktop config), but
|
|
635
|
+
* loudly flagged because the headless shape herdr's own README calls
|
|
636
|
+
* recoverable requires `false`. When absent, the value is `false` (written
|
|
637
|
+
* or already present) and nothing needs saying.
|
|
638
|
+
*/
|
|
639
|
+
warning?: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function renderHerdrConfig(shell: string, existing?: string): HerdrConfigRender {
|
|
522
643
|
const text = existing ?? "";
|
|
644
|
+
// Unusable login shell → byte-identical pass-through, exactly as before: the
|
|
645
|
+
// "pin the shell" guarantee is all-or-nothing, and the fleet docs'
|
|
646
|
+
// `resume_agents_on_restore` key rides in on the same config merge, so an
|
|
647
|
+
// operator whose passwd entry cannot be resolved still gets nothing written.
|
|
648
|
+
if (!usableHerdrShell(shell)) return { content: text };
|
|
649
|
+
|
|
650
|
+
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
523
651
|
const line = `default_shell = ${quoted}`;
|
|
524
652
|
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
653
|
+
// One anchored, comment-aware match, used for both the guard and the replace
|
|
654
|
+
// so they can never disagree: a *real* key starts its line (after whitespace),
|
|
655
|
+
// while `# default_shell = …` starts with a comment and is not a key. The
|
|
656
|
+
// old guard was unanchored, so it matched a commented line the anchored
|
|
657
|
+
// replace did not — a config whose only mention was a comment took the
|
|
658
|
+
// "replace" branch, replaced nothing, and silently wrote nothing at all.
|
|
659
|
+
// Any existing key keeps its position and gets the new value in place —
|
|
660
|
+
// even when the value already matches, so a re-run stays idempotent instead
|
|
661
|
+
// of duplicating the key.
|
|
662
|
+
const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
|
|
663
|
+
let content: string;
|
|
664
|
+
if (keyLine.test(text)) {
|
|
665
|
+
content = text.replace(keyLine, `$1${quoted}`);
|
|
666
|
+
} else {
|
|
667
|
+
const lines = text.split("\n");
|
|
668
|
+
const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
|
|
669
|
+
if (table === -1) {
|
|
670
|
+
const body = text.replace(/\s+$/, "");
|
|
671
|
+
content = `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
|
|
672
|
+
} else {
|
|
673
|
+
// The table exists but has no default_shell; drop the key right under it.
|
|
674
|
+
lines.splice(table + 1, 0, line);
|
|
675
|
+
content = lines.join("\n");
|
|
676
|
+
}
|
|
530
677
|
}
|
|
531
678
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
679
|
+
// [session] resume_agents_on_restore = false, the same anchored, comment-aware
|
|
680
|
+
// merge as default_shell: a real key is kept at its position, a commented-out
|
|
681
|
+
// mention is not a key. The one difference is explicit `true` (the value an
|
|
682
|
+
// operator pinned on a desktop host): it is preserved deliberately, and the
|
|
683
|
+
// caller warns about it rather than silently flipping a deliberate choice.
|
|
684
|
+
const resumeLine = "resume_agents_on_restore = false";
|
|
685
|
+
const resumeKey = /^[ \t]*resume_agents_on_restore[ \t]*=\s*([^#\n]*)/m;
|
|
686
|
+
const resumeMatch = resumeKey.exec(content);
|
|
687
|
+
const resumeValue = resumeMatch?.[1]?.trim().split(/\s+/)[0];
|
|
688
|
+
if (resumeMatch !== null) {
|
|
689
|
+
if (resumeValue === "false") {
|
|
690
|
+
content = content.replace(/^([ \t]*resume_agents_on_restore[ \t]*=\s*)[^#\n]*/m, `$1false`);
|
|
691
|
+
} else if (resumeValue !== undefined && resumeValue.length > 0) {
|
|
692
|
+
return {
|
|
693
|
+
content,
|
|
694
|
+
warning:
|
|
695
|
+
`explicit resume_agents_on_restore = ${resumeValue} kept as-is (could be a deliberate desktop config); ` +
|
|
696
|
+
"a headless fleet host leaves no live terminal for restored panes unless it is false",
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
const lines = content.split("\n");
|
|
701
|
+
const sessionTable = lines.findIndex((l) => /^\[session\]\s*$/.test(l));
|
|
702
|
+
if (sessionTable === -1) {
|
|
703
|
+
const body = content.replace(/\s+$/, "");
|
|
704
|
+
content = `${body.length > 0 ? body + "\n" : ""}[session]\n${resumeLine}\n`;
|
|
705
|
+
} else {
|
|
706
|
+
// The table exists but has no resume key; drop the key right under it.
|
|
707
|
+
lines.splice(sessionTable + 1, 0, resumeLine);
|
|
708
|
+
content = lines.join("\n");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return { content };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The values `setup host` owns in the herdr-conductor plugin's `config.env`.
|
|
716
|
+
*
|
|
717
|
+
* recover.sh sources the file (`herdr/bin/recover.sh`), and conductor writes
|
|
718
|
+
* exactly the keys that file reads to resolve a fleet. `FLEET_CWDS` is the
|
|
719
|
+
* multi-fleet form of the legacy single `FLEET_CWD`, so the renderer always
|
|
720
|
+
* writes the former; the legacy key is an operator key and survives untouched.
|
|
721
|
+
*/
|
|
722
|
+
export interface HerdrConductorEnvValues {
|
|
723
|
+
/** The Herdr session that owns the fleet: TARGET_SESSION. */
|
|
724
|
+
targetSession: string;
|
|
725
|
+
/** Every configured project's fleet cwd, in config order (FLEET_CWDS). */
|
|
726
|
+
fleetCwds: readonly string[];
|
|
727
|
+
/** Absolute path of omp-telegram's `.env` (TELEGRAM_ENV). */
|
|
728
|
+
telegramEnv: string;
|
|
729
|
+
/** Absolute path of omp-telegram's `access.json` (ACCESS_JSON). */
|
|
730
|
+
accessJson: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** The keys `setup` patches; every other line in the file is operator-owned. */
|
|
734
|
+
const CONDUCTOR_ENV_KEYS = ["TARGET_SESSION", "FLEET_CWDS", "TELEGRAM_ENV", "ACCESS_JSON"] as const;
|
|
735
|
+
|
|
736
|
+
const ENV_VALUE_ESCAPE = /[\\"$]/g;
|
|
737
|
+
|
|
738
|
+
/** One `KEY=value` line, with the value shell-quoted so recover.sh's `.`
|
|
739
|
+
* sourcing reads paths with spaces or `$` literally. */
|
|
740
|
+
function envLine(key: string, value: string): string {
|
|
741
|
+
return `${key}="${value.replace(ENV_VALUE_ESCAPE, "\\$&")}"`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Render herdr-conductor's `config.env` — create it when missing; when present
|
|
746
|
+
* patch only conductor-owned keys and pass every other line through verbatim
|
|
747
|
+
* (an operator's `RECOVER_RECHECK_SECONDS`, `BOOTSTRAP_RESUME`, AGENT_NAME,
|
|
748
|
+
* FLEET_CWD … must survive a re-run).
|
|
749
|
+
*
|
|
750
|
+
* A conductor-owned key that already exists keeps its position and gets the new
|
|
751
|
+
* value; a missing one is appended at the end. The anchored `^[ \t]*KEY=` guard
|
|
752
|
+
* mirrors the TOML merge's comment-awareness: a line starting with `#` is a
|
|
753
|
+
* comment, not a key, so a commented draft is neither patched nor mistaken for
|
|
754
|
+
* an existing real key (which would block the append).
|
|
755
|
+
*/
|
|
756
|
+
export function renderHerdrConductorEnv(values: HerdrConductorEnvValues, existing?: string): string {
|
|
757
|
+
const lines = (existing ?? "").split("\n");
|
|
758
|
+
// The split of a file ending in a newline leaves one empty trailing element
|
|
759
|
+
// (and a fresh file has exactly one empty element, nothing more); both are
|
|
760
|
+
// the join's terminator, not file content — the single join below re-adds it.
|
|
761
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
762
|
+
const headers: Record<(typeof CONDUCTOR_ENV_KEYS)[number], string> = {
|
|
763
|
+
TARGET_SESSION: values.targetSession,
|
|
764
|
+
FLEET_CWDS: values.fleetCwds.join(":"),
|
|
765
|
+
TELEGRAM_ENV: values.telegramEnv,
|
|
766
|
+
ACCESS_JSON: values.accessJson,
|
|
767
|
+
};
|
|
768
|
+
const seen = new Set<string>();
|
|
769
|
+
for (const index of lines.keys()) {
|
|
770
|
+
const line = lines[index]!;
|
|
771
|
+
const match = /^([ \t]*)([A-Za-z_][A-Za-z0-9_]*)([ \t]*=[ \t]*).*$/.exec(line);
|
|
772
|
+
if (match === null) continue;
|
|
773
|
+
const key = match[2] as (typeof CONDUCTOR_ENV_KEYS)[number];
|
|
774
|
+
if (!(key in headers)) continue;
|
|
775
|
+
seen.add(key);
|
|
776
|
+
lines[index] = `${match[1]}${envLine(key, headers[key])}`;
|
|
777
|
+
}
|
|
778
|
+
for (const key of CONDUCTOR_ENV_KEYS) {
|
|
779
|
+
if (!seen.has(key)) lines.push(envLine(key, headers[key]));
|
|
780
|
+
}
|
|
781
|
+
return `${lines.join("\n")}\n`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Decide whether the fleet pane may be renamed, and if so what to run.
|
|
786
|
+
*
|
|
787
|
+
* The contract is written for the one-identity case and refuses to guess:
|
|
788
|
+
* - exactly one pane carries `from` (the restamped-away identity) → rename it;
|
|
789
|
+
* - zero, several, or an unreadable agent list → surface the exact command
|
|
790
|
+
* and the reason; never invent a pane.
|
|
791
|
+
*
|
|
792
|
+
* `command` is always the one-line remedy an operator can paste; on ambiguity
|
|
793
|
+
* it carries the generic pane placeholder because no specific pane may be
|
|
794
|
+
* guessed. It is the same shape the tick's decline path prints (`herdr agent
|
|
795
|
+
* rename <pane> <name>`), born from the session so the same command works from
|
|
796
|
+
* outside herdr too.
|
|
797
|
+
*/
|
|
798
|
+
export type AgentRenameVerdict =
|
|
799
|
+
| { kind: "rename"; pane: string; command: string }
|
|
800
|
+
| { kind: "flag"; command: string; reason: string };
|
|
801
|
+
|
|
802
|
+
export function agentRenameVerdict(
|
|
803
|
+
list: HerdrAgentList,
|
|
804
|
+
from: string,
|
|
805
|
+
to: string,
|
|
806
|
+
session: string,
|
|
807
|
+
): AgentRenameVerdict {
|
|
808
|
+
const commandFor = (pane: string): string => `herdr --session ${session} agent rename ${pane} ${to}`;
|
|
809
|
+
if (list.kind !== "ok") {
|
|
810
|
+
return {
|
|
811
|
+
kind: "flag",
|
|
812
|
+
command: commandFor("<pane>"),
|
|
813
|
+
reason: `herdr agent list is unreadable (${list.problem}) — cannot verify which pane to rename`,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const matches = list.agents.filter((a) => a.name === from);
|
|
817
|
+
if (matches.length === 1) {
|
|
818
|
+
return { kind: "rename", pane: matches[0]!.paneId, command: commandFor(matches[0]!.paneId) };
|
|
819
|
+
}
|
|
820
|
+
if (matches.length === 0) {
|
|
821
|
+
return {
|
|
822
|
+
kind: "flag",
|
|
823
|
+
command: commandFor("<pane>"),
|
|
824
|
+
reason: `no live pane is registered as agent "${from}" — nothing was renamed (an unregistered fleet pane must be named by hand)`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
kind: "flag",
|
|
829
|
+
command: commandFor("<pane>"),
|
|
830
|
+
reason: `${matches.length} panes (${matches.map((a) => a.paneId).join(", ")}) are registered as "${from}" — refusing to guess which is the fleet pane`,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The live herdr CLI seam setup-host shells out for the one-shot rename. */
|
|
835
|
+
export interface AgentRenameDeps {
|
|
836
|
+
/** `herdr --session <s> agent list`'s one JSON line. */
|
|
837
|
+
list(session: string): HerdrAgentList;
|
|
838
|
+
/** `herdr --session <s> agent rename <pane> <name>`. */
|
|
839
|
+
rename(session: string, pane: string, name: string): { ok: boolean; problem?: string };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The production wiring: the same CLI `recover.sh` and the tick both use. */
|
|
843
|
+
export const DEFAULT_AGENT_RENAME_DEPS: AgentRenameDeps = {
|
|
844
|
+
list: (session) => {
|
|
845
|
+
const bin = Bun.which("herdr") ?? "herdr";
|
|
846
|
+
try {
|
|
847
|
+
const run = spawnSync(bin, ["--session", session, "agent", "list"], {
|
|
848
|
+
encoding: "utf8",
|
|
849
|
+
timeout: 3000,
|
|
850
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
851
|
+
});
|
|
852
|
+
if (run.error !== undefined) return { kind: "unavailable", problem: run.error.message };
|
|
853
|
+
if (run.status !== 0) return { kind: "unavailable", problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
|
|
854
|
+
return parseHerdrAgents(run.stdout ?? "");
|
|
855
|
+
} catch (err) {
|
|
856
|
+
return { kind: "unavailable", problem: err instanceof Error ? err.message : String(err) };
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
rename: (session, pane, name) => {
|
|
860
|
+
const bin = Bun.which("herdr") ?? "herdr";
|
|
861
|
+
try {
|
|
862
|
+
const run = spawnSync(bin, ["--session", session, "agent", "rename", pane, name], {
|
|
863
|
+
encoding: "utf8",
|
|
864
|
+
timeout: 3000,
|
|
865
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
866
|
+
});
|
|
867
|
+
if (run.error !== undefined) return { ok: false, problem: run.error.message };
|
|
868
|
+
if (run.status !== 0) {
|
|
869
|
+
return { ok: false, problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
|
|
870
|
+
}
|
|
871
|
+
return { ok: true };
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return { ok: false, problem: err instanceof Error ? err.message : String(err) };
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/** The directory `herdr plugin config-dir herdr-conductor` prints. */
|
|
879
|
+
export function herdrConductorPluginConfigDir(home: string): string {
|
|
880
|
+
return join(home, ".config", "herdr", "plugins", "config", "herdr-conductor");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The fleet cwd of every configured project, in config order — the FLEET_CWDS
|
|
885
|
+
* value recover.sh walks to find each fleet's `.conductor-tick.json`.
|
|
886
|
+
*
|
|
887
|
+
* Tries the real config first; when it cannot be read (a not-yet-configured
|
|
888
|
+
* box, install tests staging before a config exists), the named project's own
|
|
889
|
+
* cwd stands in — the same guarded fallback shape as `hostInstallWorkers` and
|
|
890
|
+
* `hostMultiProject`. Multi-project hosts get the full list when the config
|
|
891
|
+
* loads, which is the case the recovery path exists for.
|
|
892
|
+
*/
|
|
893
|
+
export function configuredFleetCwds(project: ProjectConfig | undefined): string[] {
|
|
894
|
+
try {
|
|
895
|
+
return loadConfig().projects.map((p) => tickCwdForProject(p));
|
|
896
|
+
} catch {
|
|
897
|
+
return project === undefined ? [] : [tickCwdForProject(project)];
|
|
537
898
|
}
|
|
538
|
-
// The table exists but has no default_shell; drop the key right under it.
|
|
539
|
-
lines.splice(table + 1, 0, line);
|
|
540
|
-
return lines.join("\n");
|
|
541
899
|
}
|
|
542
900
|
|
|
543
901
|
function tickSearchRoots(project: ProjectConfig): string[] {
|
|
@@ -639,7 +997,13 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
|
|
|
639
997
|
};
|
|
640
998
|
}
|
|
641
999
|
|
|
642
|
-
function planTick(project: ProjectConfig, telegramStateDir: string):
|
|
1000
|
+
function planTick(project: ProjectConfig, telegramStateDir: string): {
|
|
1001
|
+
write: PlannedWrite<TickConfig>;
|
|
1002
|
+
/** Set when the config was restamped from the shared default: the live herdr
|
|
1003
|
+
* pane still carries that old identity, and the plan must rename it
|
|
1004
|
+
* (or flag the exact command) after the tick lands. */
|
|
1005
|
+
previousAgentName?: string;
|
|
1006
|
+
} {
|
|
643
1007
|
const found = findProjectTick(project);
|
|
644
1008
|
const existing = found === undefined ? undefined : { path: found.path, config: found.config };
|
|
645
1009
|
|
|
@@ -667,11 +1031,30 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
667
1031
|
: existing.config.agentName,
|
|
668
1032
|
};
|
|
669
1033
|
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
670
|
-
return {
|
|
1034
|
+
return {
|
|
1035
|
+
write: { path, action: actionFor(path, content), content, value: config },
|
|
1036
|
+
// The rename only fires for a *re*-stamp: the old agentName was the shared
|
|
1037
|
+
// default (or absent, which every reader resolves to the same default), and
|
|
1038
|
+
// the new one is the project's own name. An already-project-stamped config
|
|
1039
|
+
// is not a collision and needs no rename. A brand-new config (no existing
|
|
1040
|
+
// tick at all) is not a restamp either: there was no previous identity for
|
|
1041
|
+
// a pane to be stuck with.
|
|
1042
|
+
...(existing !== undefined &&
|
|
1043
|
+
(existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME)
|
|
1044
|
+
? { previousAgentName: DEFAULT_FLEET_AGENT_NAME }
|
|
1045
|
+
: {}),
|
|
1046
|
+
};
|
|
671
1047
|
}
|
|
672
1048
|
|
|
673
1049
|
export function planHostRuntime(
|
|
674
|
-
|
|
1050
|
+
/**
|
|
1051
|
+
* The project the per-project tail (tick config, brief link, the recovery
|
|
1052
|
+
* unit's RECOVER_PROJECT) is planned for. `undefined` plans the host-global
|
|
1053
|
+
* units only — the same staged paths and systemd destinations, one shared
|
|
1054
|
+
* daemon — and a `noProject` note naming the per-project files it left for
|
|
1055
|
+
* a named run (#530).
|
|
1056
|
+
*/
|
|
1057
|
+
project: ProjectConfig | undefined,
|
|
675
1058
|
_caps: Caps,
|
|
676
1059
|
telegramStateDir: string,
|
|
677
1060
|
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
@@ -679,6 +1062,17 @@ export function planHostRuntime(
|
|
|
679
1062
|
// Defaulting through loadConfig() would throw in install tests that stage
|
|
680
1063
|
// files before a config exists, and would hide a missing total at the call site.
|
|
681
1064
|
totalWorkers: number = 1,
|
|
1065
|
+
// The directory the privileged steps install units into. `runHostInstall`
|
|
1066
|
+
// injects a test-hermetic directory so the executed argv is assertable; the
|
|
1067
|
+
// real install always resolves to `/etc/systemd/system`.
|
|
1068
|
+
unitDir: string = SYSTEMD_UNIT_DIR,
|
|
1069
|
+
// Where the recovery playbook installs; injectable like {@link unitDir} so
|
|
1070
|
+
// the idempotency gate is testable without touching the real `/usr/local/sbin`.
|
|
1071
|
+
recoverScriptInstallPath: string = RECOVER_SCRIPT_INSTALL_PATH,
|
|
1072
|
+
// True when the host configures more than one project. The recovery unit is
|
|
1073
|
+
// host-global, so on a multi-project host it must not encode one project's
|
|
1074
|
+
// name — and a no-project install never does (#510/#530).
|
|
1075
|
+
multiProject: boolean = false,
|
|
682
1076
|
): HostRuntimePlan {
|
|
683
1077
|
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
684
1078
|
const serviceContent = renderDaemonService(runtime, totalWorkers);
|
|
@@ -690,8 +1084,20 @@ export function planHostRuntime(
|
|
|
690
1084
|
};
|
|
691
1085
|
const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
|
|
692
1086
|
const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
|
|
1087
|
+
// The pane-shell config is staged like every other file; the live herdr
|
|
1088
|
+
// config — a file conductor does not own — is only written by the install
|
|
1089
|
+
// step, after consent (#513). A declined `setup host` must leave herdr's
|
|
1090
|
+
// config byte-for-byte untouched, and the merge must land before the unit
|
|
1091
|
+
// restart that accompanies it.
|
|
1092
|
+
const herdrConfigStagedPath = join(stateDir(), "herdr-config.toml");
|
|
1093
|
+
const herdrEnvStagedPath = join(stateDir(), "herdr-conductor.env");
|
|
1094
|
+
const herdrEnvTarget = join(herdrConductorPluginConfigDir(runtime.home), "config.env");
|
|
693
1095
|
let herdrUnit: PlannedWrite<string> | undefined;
|
|
694
1096
|
let herdrConfig: PlannedWrite<string> | undefined;
|
|
1097
|
+
let herdrConfigTarget: string | undefined;
|
|
1098
|
+
let herdrConfigProblem: string | undefined;
|
|
1099
|
+
let herdrConfigWarning: string | undefined;
|
|
1100
|
+
let herdrEnv: PlannedWrite<string> | undefined;
|
|
695
1101
|
// The recovery playbook ships in this package's systemd/ dir; the same
|
|
696
1102
|
// bytes go into the staged copy, so version control is the single source.
|
|
697
1103
|
const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
|
|
@@ -720,18 +1126,85 @@ export function planHostRuntime(
|
|
|
720
1126
|
} catch {
|
|
721
1127
|
existing = undefined;
|
|
722
1128
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1129
|
+
// An unresolvable login shell (userInfo() renders the literal "unknown")
|
|
1130
|
+
// plans nothing and says why: never a placeholder, and never a value that
|
|
1131
|
+
// is not an executable path — herdr's own `$SHELL → /bin/sh → passwd`
|
|
1132
|
+
// fallback applies instead.
|
|
1133
|
+
if (!usableHerdrShell(runtime.shell)) {
|
|
1134
|
+
herdrConfigProblem =
|
|
1135
|
+
`login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
|
|
1136
|
+
"no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
|
|
1137
|
+
} else {
|
|
1138
|
+
const rendered = renderHerdrConfig(runtime.shell, existing);
|
|
1139
|
+
const configContent = rendered.content;
|
|
1140
|
+
if (rendered.warning !== undefined) herdrConfigWarning = rendered.warning;
|
|
1141
|
+
// The rendered file is parse-checked before anything may replace herdr's
|
|
1142
|
+
// live config. An input that is already broken (a duplicated key, say)
|
|
1143
|
+
// renders broken — the replace touches every real key, so two keys stay
|
|
1144
|
+
// two keys — and writing that file is exactly the stopped-fleet outcome
|
|
1145
|
+
// this must prevent. A parse failure aborts the whole plan here, before
|
|
1146
|
+
// a single file has been staged.
|
|
1147
|
+
if (actionFor(herdrConfigPath, configContent) !== "keep") {
|
|
1148
|
+
try {
|
|
1149
|
+
Bun.TOML.parse(configContent);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
throw new Error(
|
|
1152
|
+
`refusing to write herdr's config at ${herdrConfigPath}: it would not parse ` +
|
|
1153
|
+
`(${err instanceof Error ? err.message : String(err)}). Nothing has been staged — ` +
|
|
1154
|
+
"fix the file by hand (herdr's `config check` names the line), then re-run setup host.",
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
herdrConfig = {
|
|
1159
|
+
path: herdrConfigStagedPath,
|
|
1160
|
+
action: actionFor(herdrConfigStagedPath, configContent),
|
|
1161
|
+
content: configContent,
|
|
1162
|
+
value: configContent,
|
|
1163
|
+
};
|
|
1164
|
+
herdrConfigTarget = herdrConfigPath;
|
|
1165
|
+
}
|
|
1166
|
+
// The herdr-conductor plugin's config.env (#541): recover.sh sources
|
|
1167
|
+
// `$HERDR_PLUGIN_CONFIG_DIR/config.env` and shrinks to legacy /root paths
|
|
1168
|
+
// when it is missing — on a host whose fleet lives elsewhere the recovery
|
|
1169
|
+
// still runs, only the page silently goes nowhere. Setup owns
|
|
1170
|
+
// TARGET_SESSION/FLEET_CWDS/TELEGRAM_ENV/ACCESS_JSON; the renderer patches
|
|
1171
|
+
// only those keys, so an operator's BOOTSTRAP_RESUME or
|
|
1172
|
+
// RECOVER_RECHECK_SECONDS survive a re-run.
|
|
1173
|
+
let existingEnv: string | undefined;
|
|
1174
|
+
try {
|
|
1175
|
+
existingEnv = readFileSync(herdrEnvTarget, "utf8");
|
|
1176
|
+
} catch {
|
|
1177
|
+
existingEnv = undefined;
|
|
1178
|
+
}
|
|
1179
|
+
const envContent = renderHerdrConductorEnv(
|
|
1180
|
+
{
|
|
1181
|
+
targetSession: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
|
|
1182
|
+
// Derived from every configured project's tick cwd, not from the
|
|
1183
|
+
// host-global unit the install runs for: FLEET_CWDS is what recover.sh
|
|
1184
|
+
// walks to find each fleet's `.conductor-tick.json`.
|
|
1185
|
+
fleetCwds: configuredFleetCwds(project),
|
|
1186
|
+
telegramEnv: join(telegramStateDir, ".env"),
|
|
1187
|
+
accessJson: join(telegramStateDir, "access.json"),
|
|
1188
|
+
},
|
|
1189
|
+
existingEnv,
|
|
1190
|
+
);
|
|
1191
|
+
herdrEnv = {
|
|
1192
|
+
path: herdrEnvStagedPath,
|
|
1193
|
+
action: actionFor(herdrEnvStagedPath, envContent),
|
|
1194
|
+
content: envContent,
|
|
1195
|
+
value: envContent,
|
|
729
1196
|
};
|
|
730
1197
|
}
|
|
731
|
-
const installedPath = join(
|
|
732
|
-
const installedHerdr = join(
|
|
1198
|
+
const installedPath = join(unitDir, STAGED_SERVICE_NAME);
|
|
1199
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
733
1200
|
const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
|
|
734
|
-
|
|
1201
|
+
// The recovery unit is host-global: one shared unit, installed once. A
|
|
1202
|
+
// static RECOVER_PROJECT is only legitimate when there is exactly one
|
|
1203
|
+
// project to be unambiguous about — a multi-project host (or a no-project
|
|
1204
|
+
// install) leaves it unset so the escalation reports without attributing a
|
|
1205
|
+
// sibling's crash to one project (#510/#530).
|
|
1206
|
+
const recoverProject = project === undefined || multiProject ? undefined : project.name;
|
|
1207
|
+
const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
|
|
735
1208
|
const recoverUnit: PlannedWrite<string> = {
|
|
736
1209
|
path: recoverUnitPath,
|
|
737
1210
|
action: actionFor(recoverUnitPath, recoverUnitContent),
|
|
@@ -744,39 +1217,139 @@ export function planHostRuntime(
|
|
|
744
1217
|
content: recoverScriptContent,
|
|
745
1218
|
value: recoverScriptContent,
|
|
746
1219
|
};
|
|
1220
|
+
// Recovery first: the daemon unit that follows names it in OnFailure=, so
|
|
1221
|
+
// the restart below must never point at a unit systemd cannot load. This is
|
|
1222
|
+
// the single list `runHostInstall` executes and {@link installCommands}
|
|
1223
|
+
// renders from, so no step can be in one and missing from the other (#509).
|
|
1224
|
+
const installSteps: PrivilegedStep[] = [
|
|
1225
|
+
{
|
|
1226
|
+
title: "install the recovery playbook",
|
|
1227
|
+
argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
title: `install ${RECOVER_SERVICE_NAME}`,
|
|
1231
|
+
argv: ["install", "-m", "0644", recoverUnitPath, join(unitDir, RECOVER_SERVICE_NAME)],
|
|
1232
|
+
},
|
|
1233
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
1234
|
+
{
|
|
1235
|
+
title: `install ${STAGED_SERVICE_NAME}`,
|
|
1236
|
+
argv: ["install", "-m", "0644", servicePath, installedPath],
|
|
1237
|
+
},
|
|
1238
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
1239
|
+
{ title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
|
|
1240
|
+
{ title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
|
|
1241
|
+
// The herdr session server, provisioned alongside the daemon (#456). Only
|
|
1242
|
+
// when herdr is installed and the plan therefore staged a unit.
|
|
1243
|
+
...(herdrUnit === undefined
|
|
1244
|
+
? []
|
|
1245
|
+
: [
|
|
1246
|
+
{
|
|
1247
|
+
title: `install ${DEFAULT_HERDR_UNIT}`,
|
|
1248
|
+
argv: ["install", "-m", "0644", herdrUnitPath, installedHerdr],
|
|
1249
|
+
},
|
|
1250
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
1251
|
+
{ title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
|
|
1252
|
+
// The pane-shell merge lands *before* the restart it accompanies, so
|
|
1253
|
+
// the session server comes up with the pinned shell, not the stale
|
|
1254
|
+
// config. It runs without sudo on purpose: the file is the fleet
|
|
1255
|
+
// account's own (the escalation guard guarantees setup runs as that
|
|
1256
|
+
// account), and a root-owned copy would break herdr's next rewrite.
|
|
1257
|
+
// Nothing writes herdr's live config until this consent-gated step.
|
|
1258
|
+
...(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep"
|
|
1259
|
+
? []
|
|
1260
|
+
: [
|
|
1261
|
+
{
|
|
1262
|
+
title: `write the pane shell into ${herdrConfigPath}`,
|
|
1263
|
+
argv: ["install", "-m", "0644", herdrConfigStagedPath, herdrConfigPath],
|
|
1264
|
+
unprivileged: true,
|
|
1265
|
+
},
|
|
1266
|
+
]),
|
|
1267
|
+
// The herdr-conductor config.env (#541) rides the same consent-gated,
|
|
1268
|
+
// unprivileged, pre-restart lane: recover.sh sources it on every
|
|
1269
|
+
// startup, so the session server must come up already pointing at
|
|
1270
|
+
// this host's fleet cwds and telegram files.
|
|
1271
|
+
...(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep"
|
|
1272
|
+
? []
|
|
1273
|
+
: [
|
|
1274
|
+
{
|
|
1275
|
+
title: `write the herdr-conductor config.env into ${herdrEnvTarget}`,
|
|
1276
|
+
argv: ["install", "-m", "0644", herdrEnvStagedPath, herdrEnvTarget],
|
|
1277
|
+
unprivileged: true,
|
|
1278
|
+
},
|
|
1279
|
+
]),
|
|
1280
|
+
{ title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
|
|
1281
|
+
]),
|
|
1282
|
+
];
|
|
1283
|
+
// Everything the privileged steps install is already at its destination with
|
|
1284
|
+
// the current bytes, so a re-run of `setup host` has nothing to install and
|
|
1285
|
+
// nothing to restart. The herdr unit is absent on a host without herdr, and
|
|
1286
|
+
// an absent unit that would not be provisioned is nothing to do.
|
|
1287
|
+
const installedAction = actionFor(installedPath, serviceContent);
|
|
1288
|
+
const currentInstall =
|
|
1289
|
+
installedAction === "keep" &&
|
|
1290
|
+
actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
|
|
1291
|
+
actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
|
|
1292
|
+
(herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
|
|
1293
|
+
// The pane-shell file is a destination like the units: a re-run with all
|
|
1294
|
+
// units current but the config merge still pending must not report
|
|
1295
|
+
// "nothing to install" and skip the very write the plan exists to make.
|
|
1296
|
+
(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep") &&
|
|
1297
|
+
// Same for the herdr-conductor config.env: pending env merge is pending
|
|
1298
|
+
// work, not an already-current install.
|
|
1299
|
+
(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep");
|
|
747
1300
|
return {
|
|
748
1301
|
service,
|
|
749
1302
|
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
750
|
-
// session to supervise, nothing for a pane shell to belong to.
|
|
751
|
-
|
|
1303
|
+
// session to supervise, nothing for a pane shell to belong to. (The pane
|
|
1304
|
+
// shell is omitted too when the login shell is unusable or the rendered
|
|
1305
|
+
// config would not parse — those plans carry {@link herdrConfigProblem}.)
|
|
1306
|
+
...(herdrUnit === undefined ? {} : { herdrUnit }),
|
|
1307
|
+
...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
|
|
1308
|
+
...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
|
|
1309
|
+
...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
|
|
1310
|
+
...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
|
|
752
1311
|
recoverUnit,
|
|
753
1312
|
recoverScript,
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1313
|
+
...(project === undefined
|
|
1314
|
+
? {
|
|
1315
|
+
// Host-global install: the per-project tail entities are not written,
|
|
1316
|
+
// and the plan says exactly which files and how to write them.
|
|
1317
|
+
noProject: {
|
|
1318
|
+
skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME],
|
|
1319
|
+
how: "re-run `omp-conductor setup host <NAME>` (or --project NAME) to write them for one project",
|
|
1320
|
+
},
|
|
1321
|
+
}
|
|
1322
|
+
: {
|
|
1323
|
+
briefLink: planBriefLink(project),
|
|
1324
|
+
...(project.escalation.orchestrator === "external"
|
|
1325
|
+
? (() => {
|
|
1326
|
+
const planned = planTick(project, telegramStateDir);
|
|
1327
|
+
return {
|
|
1328
|
+
...(planned.previousAgentName === undefined || runtime.herdr === undefined
|
|
1329
|
+
? {}
|
|
1330
|
+
: {
|
|
1331
|
+
// The restamp that made the tick's agentName no longer
|
|
1332
|
+
// the shared default: the live pane (if any) still
|
|
1333
|
+
// carries the old name, and setup will rename it after
|
|
1334
|
+
// the install — or print the exact command.
|
|
1335
|
+
agentRename: {
|
|
1336
|
+
session: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
|
|
1337
|
+
from: planned.previousAgentName,
|
|
1338
|
+
to: project.name,
|
|
1339
|
+
},
|
|
1340
|
+
}),
|
|
1341
|
+
tick: planned.write,
|
|
1342
|
+
};
|
|
1343
|
+
})()
|
|
1344
|
+
: {}),
|
|
1345
|
+
}),
|
|
1346
|
+
steps: installSteps,
|
|
1347
|
+
// For humans to read; the executed form is {@link steps}, in argv.
|
|
1348
|
+
installCommands: installSteps.map((s) => formatStep(s)),
|
|
777
1349
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
778
1350
|
installedPath,
|
|
779
|
-
installedAction
|
|
1351
|
+
installedAction,
|
|
1352
|
+
currentInstall,
|
|
780
1353
|
};
|
|
781
1354
|
}
|
|
782
1355
|
|
|
@@ -791,7 +1364,16 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
791
1364
|
? [" herdr session skipped — herdr not installed on this host"]
|
|
792
1365
|
: [
|
|
793
1366
|
` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
|
|
794
|
-
|
|
1367
|
+
...(plan.herdrConfig === undefined
|
|
1368
|
+
? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
|
|
1369
|
+
: [` pane shell ${plan.herdrConfig.action} ${plan.herdrConfig.path} ([terminal] default_shell)`]),
|
|
1370
|
+
...(plan.herdrConfigWarning === undefined ? [] : [` pane resume warn — ${plan.herdrConfigWarning}`]),
|
|
1371
|
+
...(plan.herdrEnv === undefined
|
|
1372
|
+
? []
|
|
1373
|
+
: [` herdr env ${plan.herdrEnv.action} ${plan.herdrEnv.path} -> ${plan.herdrEnvTarget}`]),
|
|
1374
|
+
...(plan.agentRename === undefined
|
|
1375
|
+
? []
|
|
1376
|
+
: [` agent rename ${plan.agentRename.from} -> ${plan.agentRename.to} in session ${plan.agentRename.session}`]),
|
|
795
1377
|
]),
|
|
796
1378
|
];
|
|
797
1379
|
if (plan.tick !== undefined) {
|
|
@@ -804,11 +1386,19 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
804
1386
|
} else {
|
|
805
1387
|
lines.push(" heartbeat embedded orchestrator — no external tick config");
|
|
806
1388
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1389
|
+
if (plan.noProject !== undefined) {
|
|
1390
|
+
lines.push(
|
|
1391
|
+
` per-project skipped (no project named): ${plan.noProject.skipped.join(", ")}`,
|
|
1392
|
+
` ${plan.noProject.how}`,
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
if (plan.briefLink !== undefined) {
|
|
1396
|
+
lines.push(
|
|
1397
|
+
plan.briefLink.action === "skip"
|
|
1398
|
+
? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
|
|
1399
|
+
: ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
812
1402
|
lines.push(" install staged only; the final result prints the systemd install commands");
|
|
813
1403
|
return lines.join("\n");
|
|
814
1404
|
}
|
|
@@ -850,6 +1440,10 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
|
850
1440
|
atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
|
|
851
1441
|
wrote.push(plan.herdrConfig.path);
|
|
852
1442
|
}
|
|
1443
|
+
if (plan.herdrEnv !== undefined && plan.herdrEnv.action !== "keep") {
|
|
1444
|
+
atomicWrite(plan.herdrEnv.path, plan.herdrEnv.content, 0o644);
|
|
1445
|
+
wrote.push(plan.herdrEnv.path);
|
|
1446
|
+
}
|
|
853
1447
|
if (plan.recoverUnit.action !== "keep") {
|
|
854
1448
|
atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
|
|
855
1449
|
wrote.push(plan.recoverUnit.path);
|
|
@@ -868,7 +1462,8 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
|
868
1462
|
// (an operator may drop a regular file where a stale symlink was planned for
|
|
869
1463
|
// replacement). Re-lstat at write time and act on *current* state, so a file
|
|
870
1464
|
// that appears mid-flight is preserved, never unlinked by a stale plan.
|
|
871
|
-
|
|
1465
|
+
// A host-global plan (no project) has no briefLink to write at all.
|
|
1466
|
+
if (plan.briefLink !== undefined && (plan.briefLink.action === "create" || plan.briefLink.action === "update")) {
|
|
872
1467
|
const { path, target } = plan.briefLink;
|
|
873
1468
|
try {
|
|
874
1469
|
mkdirSync(dirname(path), { recursive: true });
|