omp-conductor 0.16.1 → 0.16.2

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
@@ -211,6 +211,14 @@ export interface HostRuntimePlan {
211
211
  * the installed unit matched.
212
212
  */
213
213
  installedAction: PlannedWrite<string>["action"];
214
+ /**
215
+ * The installed destinations whose live bytes differ from this version's
216
+ * render — exactly the files the privileged steps would rewrite. `upgrade`
217
+ * names them after a release that re-rendered a template, and
218
+ * {@link currentInstall} is `true` iff this list is empty, so the two can
219
+ * never disagree (#598).
220
+ */
221
+ drift: readonly string[];
214
222
  /**
215
223
  * True when every file the privileged install steps would write is already
216
224
  * at its destination with the current bytes. `runHostInstall` uses it to
@@ -1285,18 +1293,27 @@ export function planHostRuntime(
1285
1293
  // nothing to restart. The herdr unit is absent on a host without herdr, and
1286
1294
  // an absent unit that would not be provisioned is nothing to do.
1287
1295
  const installedAction = actionFor(installedPath, serviceContent);
1296
+ // The same comparison as a destination list, so `upgrade` can name the
1297
+ // drifted files and `currentInstall` can never disagree with that list: a
1298
+ // plan is current exactly when no installed destination differs from the
1299
+ // render.
1300
+ const drift: string[] = [];
1301
+ const noteDrift = (path: string, content: string | undefined): void => {
1302
+ if (content !== undefined && actionFor(path, content) !== "keep") drift.push(path);
1303
+ };
1304
+ noteDrift(installedPath, serviceContent);
1305
+ noteDrift(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent);
1306
+ noteDrift(recoverScriptInstallPath, recoverScriptContent);
1307
+ if (herdrUnit !== undefined) noteDrift(installedHerdr, herdrUnit.content);
1308
+ // The pane-shell file is a destination like the units: a plan with all
1309
+ // units current but the config merge still pending must not report
1310
+ // "nothing to install" and skip the very write it exists to make.
1311
+ if (herdrConfig !== undefined) noteDrift(herdrConfigPath, herdrConfig.content);
1312
+ // Same for the herdr-conductor config.env: a pending env merge is pending
1313
+ // work, not an already-current install.
1314
+ if (herdrEnv !== undefined) noteDrift(herdrEnvTarget, herdrEnv.content);
1288
1315
  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");
1316
+ drift.length === 0;
1300
1317
  return {
1301
1318
  service,
1302
1319
  // The herdr unit and pane-shell config stand and fall together: no herdr, no
@@ -1349,6 +1366,7 @@ export function planHostRuntime(
1349
1366
  cliSource: runtime.cli === undefined ? "plugin" : "global",
1350
1367
  installedPath,
1351
1368
  installedAction,
1369
+ drift,
1352
1370
  currentInstall,
1353
1371
  };
1354
1372
  }
@@ -2005,7 +2005,7 @@ export async function setup(
2005
2005
  areaArg?: AmendAreaId,
2006
2006
  probes: SetupProbes = DEFAULT_PROBES,
2007
2007
  apply: SetupApplyDeps = DEFAULT_APPLY,
2008
- ): Promise<void> {
2008
+ ): Promise<boolean> {
2009
2009
  const path = configPath();
2010
2010
  // A config that exists but does not parse is a fault to report, never
2011
2011
  // something to quietly replace: overwriting it would delete every project it
@@ -2021,7 +2021,7 @@ export async function setup(
2021
2021
  } catch (err) {
2022
2022
  if (!(err instanceof Cancelled)) throw err;
2023
2023
  ui.notify("Setup cancelled — nothing was changed.", "info");
2024
- return;
2024
+ return false;
2025
2025
  }
2026
2026
  // `answers` is mutable below: the review loop re-asks one area at a time and
2027
2027
  // replaces only the fields that area owns, so everything else carries across.
@@ -2035,7 +2035,7 @@ export async function setup(
2035
2035
  `Run \`gh auth refresh -s repo,project\`, then run setup again.`,
2036
2036
  "error",
2037
2037
  );
2038
- return;
2038
+ return false;
2039
2039
  }
2040
2040
 
2041
2041
  // Drafted here — after the interview, before the plan — because the plan's single
@@ -2183,7 +2183,7 @@ export async function setup(
2183
2183
  .join(" ");
2184
2184
 
2185
2185
  let plan = await derive();
2186
- if (plan === undefined) return;
2186
+ if (plan === undefined) return false;
2187
2187
  ui.notify(planBlock(plan), "info");
2188
2188
  // -------- the consent gate (#417): a review loop, not a yes/no --------
2189
2189
  //
@@ -2206,12 +2206,12 @@ export async function setup(
2206
2206
  );
2207
2207
  if (choice === undefined) {
2208
2208
  ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
2209
- return;
2209
+ return false;
2210
2210
  }
2211
2211
  if (choice === REVIEW_APPLY) break;
2212
2212
  if (choice === REVIEW_EXIT) {
2213
2213
  ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info");
2214
- return;
2214
+ return false;
2215
2215
  }
2216
2216
  if (choice === REVIEW_REVIEW) {
2217
2217
  ui.notify(planBlock(plan), "info");
@@ -2259,7 +2259,7 @@ export async function setup(
2259
2259
  proseDrafted = true;
2260
2260
  }
2261
2261
  const redrafted = await derive();
2262
- if (redrafted === undefined) return;
2262
+ if (redrafted === undefined) return false;
2263
2263
  plan = redrafted;
2264
2264
  ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info");
2265
2265
  ui.notify(planBlock(plan), "info");
@@ -2302,7 +2302,7 @@ export async function setup(
2302
2302
  // enables the timer, which does not touch the running daemon.
2303
2303
  await offerCodeGraph(plan.project, ui, apply.graphInstall);
2304
2304
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2305
- return;
2305
+ return false;
2306
2306
  }
2307
2307
  // No live workers: still prefer an explicit reload when this run *added* a
2308
2308
  // neighbour — auto-restart would bounce every other project's heartbeat for
@@ -2342,7 +2342,7 @@ export async function setup(
2342
2342
  "warning",
2343
2343
  );
2344
2344
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2345
- return;
2345
+ return false;
2346
2346
  }
2347
2347
  }
2348
2348
  apply.resume(plan.project.name);
@@ -2409,4 +2409,5 @@ export async function setup(
2409
2409
  // to create/verify the herdr pane. Added projects especially — the CLI wrote
2410
2410
  // tick + config but cannot start a herdr agent (#319).
2411
2411
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2412
+ return true;
2412
2413
  }
@@ -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,
@@ -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
  }