omp-conductor 0.16.1 → 0.17.0

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.
@@ -0,0 +1,32 @@
1
+ /** Whether the CLI owns an interactive terminal rather than a pipe or plain-UI session. */
2
+ export function interactiveUi(): boolean {
3
+ return Boolean(
4
+ process.stdin.isTTY &&
5
+ process.stdout.isTTY &&
6
+ !process.env.OMP_CONDUCTOR_PLAIN_UI,
7
+ );
8
+ }
9
+
10
+ /** Run one long, silent operation with safe Clack progress on an interactive TTY. */
11
+ export async function withProgress<T>(
12
+ message: string,
13
+ success: string,
14
+ operation: () => Promise<T>,
15
+ options: { plainMessage?: boolean } = {},
16
+ ): Promise<T> {
17
+ if (!interactiveUi()) {
18
+ if (options.plainMessage) process.stdout.write(`${message}\n`);
19
+ return operation();
20
+ }
21
+
22
+ const { log } = await import("@clack/prompts");
23
+ log.step(message);
24
+ try {
25
+ const result = await operation();
26
+ log.success(success);
27
+ return result;
28
+ } catch (err) {
29
+ log.error(`${message} failed`);
30
+ throw err;
31
+ }
32
+ }
@@ -0,0 +1,11 @@
1
+ import { styleText } from "node:util";
2
+
3
+ function styled(format: Parameters<typeof styleText>[0], text: string): string {
4
+ return process.stdout.isTTY ? styleText(format, text) : text;
5
+ }
6
+
7
+ export const heading = (text: string): string => styled(["bold", "cyan"], text);
8
+ export const ok = (text: string): string => styled("green", text);
9
+ export const warn = (text: string): string => styled("yellow", text);
10
+ export const fail = (text: string): string => styled("red", text);
11
+ export const dim = (text: string): string => styled("dim", text);
@@ -382,6 +382,19 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
382
382
  }
383
383
  }
384
384
 
385
+ /**
386
+ * Doctor findings that only a privileged `setup host` can clear — the systemd
387
+ * units (and the recovery pair they name), and the herdr pane-shell key. A
388
+ * release that re-renders any of those templates fails them on every host
389
+ * whose `setup host` has not been re-run, so they are not rollback-worthy
390
+ * regression evidence: the upgrade is deliberately unable to perform the
391
+ * privileged install, and rolling back over drift it cannot clear would
392
+ * strand the fleet on the previous release with the same outstanding `setup
393
+ * host` (#598). The verifier escalates them instead — the doctor check's
394
+ * detail and the verify report name the drift and its fix.
395
+ */
396
+ const PRIVILEGED_INSTALL_DRIFT = new Set(["systemd-unit", "systemd-recovery", "herdr-resume"]);
397
+
385
398
  /**
386
399
  * The independent checks the returning process runs. Each compares a live
387
400
  * fact with the journal's record of the world the install began in:
@@ -392,7 +405,9 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
392
405
  * same predicate the in-process upgrade used, so a fleet that was armed
393
406
  * stays armed and a live pane stays live;
394
407
  * - `health` — the daemon that came back answers `/healthz`;
395
- * - `doctor` — no failing finding.
408
+ * - `doctor` — no failing finding beyond the privileged-install drift
409
+ * ({@link PRIVILEGED_INSTALL_DRIFT}) only `setup host` can clear, which is
410
+ * escalated rather than rolled back.
396
411
  */
397
412
  export async function runUpgradeChecks(
398
413
  deps: UpgradeVerifyDeps,
@@ -423,10 +438,17 @@ export async function runUpgradeChecks(
423
438
 
424
439
  const doctor = await deps.doctor(deps.projectName);
425
440
  const failing = doctor.findings.filter((finding) => finding.status === "fail");
441
+ const drift = failing.filter((finding) => PRIVILEGED_INSTALL_DRIFT.has(finding.id));
442
+ const regression = failing.filter((finding) => !PRIVILEGED_INSTALL_DRIFT.has(finding.id));
426
443
  checks.push({
427
444
  name: "doctor",
428
- ok: failing.length === 0,
429
- detail: failing.length === 0 ? `status ${doctor.status}` : `${failing[0]!.id}: ${failing[0]!.summary}`,
445
+ ok: regression.length === 0,
446
+ detail:
447
+ regression.length > 0
448
+ ? `${regression[0]!.id}: ${regression[0]!.summary}`
449
+ : drift.length > 0
450
+ ? `status ${doctor.status}; ${drift.map((f) => f.id).join(", ")} — privileged-install drift, run \`omp-conductor setup host\` from the fleet account`
451
+ : `status ${doctor.status}`,
430
452
  });
431
453
  }
432
454
 
package/src/upgrade.ts CHANGED
@@ -14,9 +14,9 @@ import { livingDaemon, restartDaemon } from "./lifecycle.ts";
14
14
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
15
15
  import { renderBriefForProject } from "./setup.ts";
16
16
  import {
17
- STAGED_SERVICE_NAME,
18
17
  planHostRuntime,
19
- writeHostRuntime,
18
+ totalConfiguredWorkers,
19
+ type HostRuntimePlan,
20
20
  } from "./setup-host.ts";
21
21
  import {
22
22
  appendJournal,
@@ -95,6 +95,17 @@ export interface UpgradeDeps {
95
95
  sleep(ms: number): Promise<void>;
96
96
  env: NodeJS.ProcessEnv;
97
97
  log(message: string): void;
98
+ /**
99
+ * The host runtime this version's package renders, compared against what is
100
+ * actually installed: the daemon and herdr units, the recovery unit and its
101
+ * playbook at {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell
102
+ * config and the herdr-conductor `config.env`. `upgrade` prints
103
+ * {@link HostRuntimePlan.drift} after a release that re-rendered those
104
+ * files, so the operator is told that `setup host` is owed before a later
105
+ * `doctor` has to discover it. Read-only: the plan renders and compares,
106
+ * it never writes a host file.
107
+ */
108
+ hostRuntime(): HostRuntimePlan;
98
109
  /**
99
110
  * The durable journal sink for the detached fleet installer (#486). Wired
100
111
  * to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
@@ -135,6 +146,13 @@ export const DEFAULT_DEPS: UpgradeDeps = {
135
146
  restartDaemon: async () => {
136
147
  await restartDaemon({});
137
148
  },
149
+ // The bare host-global plan — the same render the advisory's `setup host`
150
+ // command would install: no per-project tail, the recovery unit encoding no
151
+ // one project's name, and FLEET_CWDS derived from every configured project.
152
+ hostRuntime: () => {
153
+ const cfg = loadConfig();
154
+ return planHostRuntime(undefined, cfg.defaults, telegramStateDir(), undefined, totalConfiguredWorkers(cfg));
155
+ },
138
156
  sleep: Bun.sleep,
139
157
  env: process.env,
140
158
  log: (message) => process.stdout.write(`${message}\n`),
@@ -821,6 +839,37 @@ export async function rollbackUpgrade(
821
839
  if (failures.length > 0) throw new Error(failures.join("; "));
822
840
  }
823
841
 
842
+ /**
843
+ * Name the host-runtime destinations this version's render no longer matches,
844
+ * after a successful upgrade — the installed units, the recovery playbook at
845
+ * {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell config and the
846
+ * herdr-conductor `config.env`. A release that re-renders any of those files
847
+ * silently invalidates the installed copies; telling the operator here — with
848
+ * the exact command — is what stops the drift from surviving until a later
849
+ * `doctor` finds it (#598).
850
+ *
851
+ * The plan *is* the comparison: {@link UpgradeDeps.hostRuntime} renders every
852
+ * destination against disk, so a current host prints nothing and a drifted one
853
+ * names exactly the files only a privileged `setup host` can refresh. This
854
+ * never mutates a host file and never fails the upgrade — an unreadable host
855
+ * state is a warning line, not a reason to roll a successful install back.
856
+ */
857
+ function logHostRuntimeDrift(deps: UpgradeDeps): void {
858
+ let plan: HostRuntimePlan;
859
+ try {
860
+ plan = deps.hostRuntime();
861
+ } catch (err) {
862
+ deps.log(
863
+ `host runtime: could not compare against this version's render — ${err instanceof Error ? err.message : String(err)}`,
864
+ );
865
+ return;
866
+ }
867
+ if (plan.drift.length === 0) return;
868
+ deps.log("host runtime:");
869
+ for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
870
+ deps.log("fix: run `omp-conductor setup host` from the fleet account to re-install the host units");
871
+ }
872
+
824
873
  export async function upgradeConductor(
825
874
  options: UpgradeOptions = {},
826
875
  overrides: Partial<UpgradeDeps> = {},
@@ -871,6 +920,11 @@ export async function upgradeConductor(
871
920
  detail: "all three surfaces and every brief were already pinned to the target release",
872
921
  });
873
922
  }
923
+ // Packages are current, but the host units they render may be behind: the
924
+ // same drift this advisory names after an install applies when a release
925
+ // re-rendered a template since the operator's last `setup host`. Silent on
926
+ // a host whose runtime matches (#598).
927
+ logHostRuntimeDrift(deps);
874
928
  return {
875
929
  previousVersion: surfaces.cliVersion,
876
930
  ...release,
@@ -1118,6 +1172,11 @@ export async function upgradeConductor(
1118
1172
  throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
1119
1173
  }
1120
1174
 
1175
+ // The install landed; name any host-runtime destination this version no
1176
+ // longer renders identically, so the operator knows `setup host` is owed
1177
+ // before a later `doctor` reports it as drift (#598).
1178
+ logHostRuntimeDrift(deps);
1179
+
1121
1180
  return {
1122
1181
  previousVersion: surfaces.cliVersion,
1123
1182
  ...release,
package/src/wizard-ui.ts CHANGED
@@ -24,6 +24,15 @@
24
24
  * (`Cancelled`). The terminal UI guarantees the readline interface is closed on
25
25
  * every exit path so the verb cannot hang the terminal.
26
26
  */
27
+ export interface PromptOptions {
28
+ /** Stable answer-file key. This is a permanent CLI API, not display copy. */
29
+ key: string;
30
+ }
31
+
32
+ export interface SelectOptions extends PromptOptions {
33
+ initialIndex?: number;
34
+ }
35
+
27
36
  export interface WizardUi {
28
37
  notify(message: string, type?: "info" | "warning" | "error"): void;
29
38
  /**
@@ -32,17 +41,17 @@ export interface WizardUi {
32
41
  * `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a
33
42
  * confirm used to record a silent "no" and carry on to the next question.
34
43
  */
35
- confirm(title: string, message: string): Promise<boolean | undefined>;
44
+ confirm(title: string, message: string, promptOptions: PromptOptions): Promise<boolean | undefined>;
36
45
  /** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
37
46
  * the placeholder. */
38
- input(title: string, placeholder?: string): Promise<string | undefined>;
47
+ input(title: string, placeholder: string | undefined, promptOptions: PromptOptions): Promise<string | undefined>;
39
48
  /** Single-choice list. On an interactive TTY the current option is rendered
40
49
  * inline and moved with ↑/↓ or j/k; on a pipe it stays the numbered wall.
41
50
  * Resolves the chosen option's label, or `undefined` when dismissed. */
42
51
  select(
43
52
  title: string,
44
53
  options: { label: string; description?: string }[],
45
- dialogOptions?: { initialIndex?: number },
54
+ dialogOptions: SelectOptions,
46
55
  ): Promise<string | undefined>;
47
56
  }
48
57
 
@@ -83,8 +92,8 @@ import type { Readable, Writable } from "node:stream";
83
92
  * rather than only through a spawned CLI; production callers pass nothing.
84
93
  */
85
94
  export interface TerminalUi extends WizardUi {
86
- /** Releases stdin. Idempotent; call it from a `finally`. */
87
- close(): void;
95
+ /** Releases stdin; interactive drivers may render the outcome message. Idempotent. */
96
+ close(message?: string): void;
88
97
  }
89
98
 
90
99
  export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
@@ -273,7 +273,7 @@ fresh_rollback_snapshot() {
273
273
  newest=$(ls -t "$BACKUP_ROOT"/config.json.pre-upgrade-* 2>/dev/null | head -n 1 || true)
274
274
  [[ -n $newest ]] || return 1
275
275
  now=$(epoch_now)
276
- ts=$(stat -c %Y "$newest" 2>/dev/null || true)
276
+ ts=$(stat -c %Y "$newest" 2>/dev/null || stat -f %m "$newest" 2>/dev/null || true)
277
277
  [[ -n $ts && $((now - ts)) -le $ROLLBACK_AGE_S ]] || return 1
278
278
  printf '%s\n' "$newest"
279
279
  }
@@ -126,14 +126,14 @@ unit_state() { # <case-dir> <unit> <state|absent>
126
126
  }
127
127
 
128
128
  # A pre-upgrade snapshot `upgrade` durably keeps (configBackupDir()):
129
- # config.json.pre-upgrade-<ts>. The test pass the age in seconds.
129
+ # config.json.pre-upgrade-<ts>. The test passes the age in seconds.
130
130
  write_snapshot() { # <case-dir> <age-seconds>
131
131
  local d="$1" age="${2:-0}" now
132
132
  now=$(date +%s)
133
133
  local snap="$d/state/backups/config/config.json.pre-upgrade-$now"
134
134
  printf '%s\n' '{"preUpgrade":true}' >"$snap"
135
135
  if (( age > 0 )); then
136
- touch -d "@$(( now - age ))" "$snap"
136
+ perl -e 'utime $ARGV[1], $ARGV[1], $ARGV[0] or die "utime: $!"' "$snap" "$(( now - age ))"
137
137
  fi
138
138
  printf '%s\n' "$snap"
139
139
  }