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/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: BriefLinkPlan;
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
- "Restart=on-failure",
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
- "Restart=on-failure",
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
- // Any existing `default_shell = …` gets the new value in place — even when the
526
- // value already matches, so a re-run stays idempotent instead of duplicating
527
- // the key.
528
- if (/\s*default_shell\s*=/.test(text)) {
529
- return text.replace(/^(\s*default_shell\s*=\s*).*$/m, `$1${quoted}`);
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
- project: ProjectConfig,
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
- const configContent = renderHerdrConfig(runtime.shell, existing);
724
- herdrConfig = {
725
- path: herdrConfigPath,
726
- action: actionFor(herdrConfigPath, configContent),
727
- content: configContent,
728
- value: configContent,
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(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
732
- const installedHerdr = join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT);
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
- const recoverUnitContent = renderRecoverUnit(runtime, project.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);
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
- ...(herdrUnit === undefined ? {} : { herdrUnit, herdrConfig }),
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
- briefLink: planBriefLink(project),
755
- ...(project.escalation.orchestrator === "external"
756
- ? { tick: planTick(project, telegramStateDir) }
757
- : {}),
758
- installCommands: [
759
- // Recovery first: the daemon unit that follows names it in OnFailure=,
760
- // so the restart below must never point at a unit systemd cannot load.
761
- `sudo install -m 0755 ${shellQuote(recoverScriptPath)} ${shellQuote(RECOVER_SCRIPT_INSTALL_PATH)}`,
762
- `sudo install -m 0644 ${shellQuote(recoverUnitPath)} ${shellQuote(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME))}`,
763
- "sudo systemctl daemon-reload",
764
- `sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
765
- "sudo systemctl daemon-reload",
766
- `sudo systemctl enable ${STAGED_SERVICE_NAME}`,
767
- `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
768
- ...(herdrUnit === undefined
769
- ? []
770
- : [
771
- `sudo install -m 0644 ${shellQuote(herdrUnitPath)} ${shellQuote(installedHerdr)}`,
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: actionFor(installedPath, serviceContent),
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
- ` pane shell ${plan.herdrConfig!.action} ${plan.herdrConfig!.path} ([terminal] default_shell)`,
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
- lines.push(
808
- plan.briefLink.action === "skip"
809
- ? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
810
- : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
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
- if (plan.briefLink.action === "create" || plan.briefLink.action === "update") {
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 });