omp-conductor 0.15.11 → 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/REFERENCE.md +98 -56
- 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/setup.ts +61 -11
- 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 +359 -7
- 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 +179 -42
- package/src/failure-class.ts +37 -0
- package/src/gitops.ts +157 -0
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +91 -13
- package/src/orchestrator-tick.ts +101 -5
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +212 -12
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +286 -60
- package/src/setup-install.ts +237 -28
- package/src/setup-wizard.ts +67 -19
- package/src/setup.ts +25 -0
- package/src/store.ts +13 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +71 -0
- package/src/upgrade.ts +90 -32
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/setup-host.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
tickConfigMatchesProject,
|
|
28
28
|
type TickConfig,
|
|
29
29
|
} from "./orchestrator-tick.ts";
|
|
30
|
+
import { formatStep, type PrivilegedStep } from "./privileged.ts";
|
|
30
31
|
import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
|
|
31
32
|
|
|
32
33
|
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
@@ -90,6 +91,17 @@ export interface BriefLinkPlan {
|
|
|
90
91
|
skippedReason?: string;
|
|
91
92
|
}
|
|
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
|
+
|
|
93
105
|
export interface HostRuntimePlan {
|
|
94
106
|
service: PlannedWrite<string>;
|
|
95
107
|
/**
|
|
@@ -115,12 +127,45 @@ export interface HostRuntimePlan {
|
|
|
115
127
|
/**
|
|
116
128
|
* `[terminal] default_shell` staged into the session's herdr config, the
|
|
117
129
|
* belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
|
|
118
|
-
* Present exactly when {@link herdrUnit} is
|
|
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).
|
|
119
139
|
*/
|
|
120
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;
|
|
121
145
|
tick?: PlannedWrite<TickConfig>;
|
|
122
146
|
/** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
|
|
123
|
-
briefLink
|
|
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
|
+
*/
|
|
124
169
|
installCommands: readonly string[];
|
|
125
170
|
cliSource: "global" | "plugin";
|
|
126
171
|
/** Absolute path of the unit systemd actually reads. */
|
|
@@ -138,6 +183,12 @@ export interface HostRuntimePlan {
|
|
|
138
183
|
* the installed unit matched.
|
|
139
184
|
*/
|
|
140
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;
|
|
141
192
|
}
|
|
142
193
|
|
|
143
194
|
export interface ServiceRuntime {
|
|
@@ -192,10 +243,6 @@ function systemdPath(value: string): string {
|
|
|
192
243
|
return value.replaceAll("%", "%%");
|
|
193
244
|
}
|
|
194
245
|
|
|
195
|
-
function shellQuote(value: string): string {
|
|
196
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
246
|
function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
|
|
200
247
|
if (!existsSync(path)) return "create";
|
|
201
248
|
try {
|
|
@@ -413,7 +460,12 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
413
460
|
: [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
|
|
414
461
|
`WorkingDirectory=${systemdPath(stateDir())}`,
|
|
415
462
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
416
|
-
|
|
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",
|
|
417
469
|
"SuccessExitStatus=0 143",
|
|
418
470
|
"MemoryAccounting=yes",
|
|
419
471
|
`MemoryMax=${memoryMax}`,
|
|
@@ -461,7 +513,9 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
|
461
513
|
`Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
|
|
462
514
|
`WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
|
|
463
515
|
`ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
|
|
464
|
-
|
|
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",
|
|
465
519
|
"RestartSec=5",
|
|
466
520
|
"",
|
|
467
521
|
"[Install]",
|
|
@@ -487,7 +541,12 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
|
487
541
|
* except the two fleet units' failure, so recovery can never re-trigger
|
|
488
542
|
* recovery.
|
|
489
543
|
*/
|
|
490
|
-
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string): string {
|
|
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).
|
|
491
550
|
return [
|
|
492
551
|
"[Unit]",
|
|
493
552
|
"Description=omp-conductor fleet recovery (OnFailure handler)",
|
|
@@ -502,12 +561,31 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
|
|
|
502
561
|
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
503
562
|
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
504
563
|
`Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
|
|
505
|
-
`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}
|
|
564
|
+
...(projectName === undefined ? [] : [`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`]),
|
|
506
565
|
`ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
507
566
|
"",
|
|
508
567
|
].join("\n");
|
|
509
568
|
}
|
|
510
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
|
+
|
|
511
589
|
/**
|
|
512
590
|
* Idempotently put `[terminal] default_shell` into a herdr config, preserving
|
|
513
591
|
* everything else verbatim.
|
|
@@ -516,17 +594,30 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
|
|
|
516
594
|
* account's login shell even if an operator later edits the unit and drops the
|
|
517
595
|
* `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
|
|
518
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`".
|
|
519
601
|
*/
|
|
520
602
|
export function renderHerdrConfig(shell: string, existing?: string): string {
|
|
521
|
-
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
522
603
|
const text = existing ?? "";
|
|
604
|
+
if (!usableHerdrShell(shell)) return text;
|
|
605
|
+
|
|
606
|
+
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
523
607
|
const line = `default_shell = ${quoted}`;
|
|
524
608
|
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
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}`);
|
|
530
621
|
}
|
|
531
622
|
|
|
532
623
|
const lines = text.split("\n");
|
|
@@ -671,7 +762,14 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
|
|
|
671
762
|
}
|
|
672
763
|
|
|
673
764
|
export function planHostRuntime(
|
|
674
|
-
|
|
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,
|
|
675
773
|
_caps: Caps,
|
|
676
774
|
telegramStateDir: string,
|
|
677
775
|
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
@@ -679,6 +777,17 @@ export function planHostRuntime(
|
|
|
679
777
|
// Defaulting through loadConfig() would throw in install tests that stage
|
|
680
778
|
// files before a config exists, and would hide a missing total at the call site.
|
|
681
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,
|
|
682
791
|
): HostRuntimePlan {
|
|
683
792
|
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
684
793
|
const serviceContent = renderDaemonService(runtime, totalWorkers);
|
|
@@ -690,8 +799,16 @@ export function planHostRuntime(
|
|
|
690
799
|
};
|
|
691
800
|
const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
|
|
692
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");
|
|
693
808
|
let herdrUnit: PlannedWrite<string> | undefined;
|
|
694
809
|
let herdrConfig: PlannedWrite<string> | undefined;
|
|
810
|
+
let herdrConfigTarget: string | undefined;
|
|
811
|
+
let herdrConfigProblem: string | undefined;
|
|
695
812
|
// The recovery playbook ships in this package's systemd/ dir; the same
|
|
696
813
|
// bytes go into the staged copy, so version control is the single source.
|
|
697
814
|
const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
|
|
@@ -720,18 +837,52 @@ export function planHostRuntime(
|
|
|
720
837
|
} catch {
|
|
721
838
|
existing = undefined;
|
|
722
839
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
+
}
|
|
730
875
|
}
|
|
731
|
-
const installedPath = join(
|
|
732
|
-
const installedHerdr = join(
|
|
876
|
+
const installedPath = join(unitDir, STAGED_SERVICE_NAME);
|
|
877
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
733
878
|
const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
|
|
734
|
-
|
|
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);
|
|
735
886
|
const recoverUnit: PlannedWrite<string> = {
|
|
736
887
|
path: recoverUnitPath,
|
|
737
888
|
action: actionFor(recoverUnitPath, recoverUnitContent),
|
|
@@ -744,39 +895,103 @@ export function planHostRuntime(
|
|
|
744
895
|
content: recoverScriptContent,
|
|
745
896
|
value: recoverScriptContent,
|
|
746
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");
|
|
747
962
|
return {
|
|
748
963
|
service,
|
|
749
964
|
// 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
|
-
|
|
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 }),
|
|
752
971
|
recoverUnit,
|
|
753
972
|
recoverScript,
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
"sudo systemctl daemon-reload",
|
|
773
|
-
`sudo systemctl enable ${DEFAULT_HERDR_UNIT}`,
|
|
774
|
-
`sudo systemctl restart ${DEFAULT_HERDR_UNIT}`,
|
|
775
|
-
]),
|
|
776
|
-
],
|
|
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)),
|
|
777
991
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
778
992
|
installedPath,
|
|
779
|
-
installedAction
|
|
993
|
+
installedAction,
|
|
994
|
+
currentInstall,
|
|
780
995
|
};
|
|
781
996
|
}
|
|
782
997
|
|
|
@@ -791,7 +1006,9 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
791
1006
|
? [" herdr session skipped — herdr not installed on this host"]
|
|
792
1007
|
: [
|
|
793
1008
|
` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
|
|
794
|
-
|
|
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)`]),
|
|
795
1012
|
]),
|
|
796
1013
|
];
|
|
797
1014
|
if (plan.tick !== undefined) {
|
|
@@ -804,11 +1021,19 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
804
1021
|
} else {
|
|
805
1022
|
lines.push(" heartbeat embedded orchestrator — no external tick config");
|
|
806
1023
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
+
}
|
|
812
1037
|
lines.push(" install staged only; the final result prints the systemd install commands");
|
|
813
1038
|
return lines.join("\n");
|
|
814
1039
|
}
|
|
@@ -868,7 +1093,8 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
|
868
1093
|
// (an operator may drop a regular file where a stale symlink was planned for
|
|
869
1094
|
// replacement). Re-lstat at write time and act on *current* state, so a file
|
|
870
1095
|
// that appears mid-flight is preserved, never unlinked by a stale plan.
|
|
871
|
-
|
|
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")) {
|
|
872
1098
|
const { path, target } = plan.briefLink;
|
|
873
1099
|
try {
|
|
874
1100
|
mkdirSync(dirname(path), { recursive: true });
|