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/README.md +25 -0
- package/REFERENCE.md +3 -0
- package/package.json +2 -1
- package/src/briefs/orchestrator.md +21 -6
- package/src/briefs/policy.md +13 -5
- package/src/cli.ts +110 -381
- package/src/command-help.ts +220 -0
- package/src/command-manifest.ts +471 -0
- package/src/commands/complete.ts +93 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/setup.ts +68 -2
- package/src/setup-host.ts +29 -11
- package/src/setup-wizard.ts +10 -9
- package/src/upgrade-verify.ts +25 -3
- package/src/upgrade.ts +61 -2
- package/systemd/omp-conductor-recover.sh +1 -1
- package/systemd/recover-unit-test.sh +2 -2
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
|
-
|
|
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
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|
package/src/upgrade-verify.ts
CHANGED
|
@@ -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:
|
|
429
|
-
detail:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|