omp-conductor 0.18.2 → 0.19.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.
- package/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/unblock.ts
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { hasContinuationBudget, hasFailedAttemptBudget } from "./admission.ts";
|
|
39
39
|
import { projectLabels } from "./label-projection.ts";
|
|
40
40
|
import { LIVE_STATES } from "./store.ts";
|
|
41
|
+
import { wakeDispatch } from "./wake.ts";
|
|
41
42
|
import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
|
|
42
43
|
|
|
43
44
|
/** What one `unblock` did, and the state it found around it. */
|
|
@@ -54,6 +55,10 @@ export interface UnblockOutcome {
|
|
|
54
55
|
* to "will the dispatcher hold this issue as issue-active?" that #178
|
|
55
56
|
* found this verb guessing at. */
|
|
56
57
|
active: boolean;
|
|
58
|
+
/** What the resident-daemon wake did, when this unblock restored the queue
|
|
59
|
+
* label and therefore made work claimable now (#878). Absent when nothing
|
|
60
|
+
* was requeued: there would be nothing for a pass to find. */
|
|
61
|
+
woken?: string;
|
|
57
62
|
/** Set when a live worker (a claimed/running run) is on the issue. The
|
|
58
63
|
* dispatcher holds a worker-backed run as issue-active unconditionally,
|
|
59
64
|
* while worker-free pushed occupancy can bypass it (#175) — so this is
|
|
@@ -242,9 +247,19 @@ export async function unblockIssue(
|
|
|
242
247
|
if (stillPending > 0) labelSyncQueued = stillPending;
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
// An unblock that actually restored the queue label has made work claimable
|
|
251
|
+
// now, so the resident daemon is poked instead of leaving the issue for the
|
|
252
|
+
// next scheduled pass (#878). Only on a real restore: a withheld or skipped
|
|
253
|
+
// requeue changed nothing to dispatch, and a wake following a no-op is a claim
|
|
254
|
+
// of progress that did not happen. Best effort by construction — the label
|
|
255
|
+
// mutation already committed, so an unreachable daemon is a reported line,
|
|
256
|
+
// never an error here.
|
|
257
|
+
const woken = requeued !== undefined ? await wakeDispatch(project.name) : undefined;
|
|
258
|
+
|
|
245
259
|
return {
|
|
246
260
|
cleared,
|
|
247
261
|
...counts,
|
|
262
|
+
...(woken === undefined ? {} : { woken }),
|
|
248
263
|
...(live ? { live: true as const } : {}),
|
|
249
264
|
...(latest === undefined ? {} : { latest }),
|
|
250
265
|
...(held === undefined ? {} : { forced: true as const }),
|
|
@@ -418,5 +433,12 @@ export function formatUnblock(
|
|
|
418
433
|
lines.push(` queue "${project.queueLabel}" left untouched (--no-requeue)`);
|
|
419
434
|
}
|
|
420
435
|
|
|
436
|
+
// What happens next in time, not just in principle (#878): a restored label
|
|
437
|
+
// that woke the daemon claims on an immediate pass, and one that could not
|
|
438
|
+
// reach it waits for the scheduled pass. Both are honest; a report that says
|
|
439
|
+
// "eligible again" without saying which invites the reader to assume the
|
|
440
|
+
// faster one.
|
|
441
|
+
if (o.woken !== undefined) lines.push(` dispatch ${o.woken}`);
|
|
442
|
+
|
|
421
443
|
return lines.join("\n");
|
|
422
444
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded unit-template reconcile (#905).
|
|
3
|
+
*
|
|
4
|
+
* `upgrade` swaps the package that *renders* the host units, which silently
|
|
5
|
+
* invalidates the installed copies on every release that touches a template —
|
|
6
|
+
* and then hands the operator a chore: re-run `omp-conductor setup host`
|
|
7
|
+
* yourself. That advisory (#598) is worse than a chore, because `setup host`
|
|
8
|
+
* restarts `herdr-fleet.service` at the end and kills its own calling pane
|
|
9
|
+
* (#834). This module is the other half of that finding: the smallest thing
|
|
10
|
+
* that makes the installed units match the render, and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* Two properties make it safe to run inside the upgrade transaction:
|
|
13
|
+
*
|
|
14
|
+
* - **It is bounded to unit templates.** The staged copies of the daemon
|
|
15
|
+
* unit, the recovery unit and its playbook, the herdr session unit and the
|
|
16
|
+
* harness mount unit; then `install` for exactly the destinations whose
|
|
17
|
+
* live bytes differ, then one `daemon-reload`. No account creation, no
|
|
18
|
+
* ACLs, no `enable`, no `restart`, no herdr config merge, no tick config,
|
|
19
|
+
* no brief link — every one of those stays `setup host`'s, and a change
|
|
20
|
+
* that needs them is reported rather than attempted.
|
|
21
|
+
* - **It never overwrites an operator's edit.** The caller names the
|
|
22
|
+
* destinations that were *already* drifting before the new package landed;
|
|
23
|
+
* those are hand edits rather than drift this upgrade created, and they are
|
|
24
|
+
* skipped and reported. A host whose units already match is left untouched
|
|
25
|
+
* byte for byte, because the render-and-compare in
|
|
26
|
+
* {@link HostRuntimePlan.drift} is what decides.
|
|
27
|
+
*
|
|
28
|
+
* It lives in its own module, and runs as its own CLI verb, because the render
|
|
29
|
+
* is *code*: `upgradeConductor` replaces the package underneath itself, so
|
|
30
|
+
* every in-process render after the install phase is still the OLD version's
|
|
31
|
+
* templates. Only a freshly spawned process can render what the new release
|
|
32
|
+
* actually ships, which is why the transaction shells out to this verb instead
|
|
33
|
+
* of calling `writeHostRuntime` directly.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { writeFileSync } from "node:fs";
|
|
37
|
+
import { dirname, join } from "node:path";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
HARNESS_MOUNT_UNIT_NAME,
|
|
41
|
+
RECOVER_SCRIPT_INSTALL_PATH,
|
|
42
|
+
RECOVER_SERVICE_NAME,
|
|
43
|
+
STAGED_SERVICE_NAME,
|
|
44
|
+
type HostRuntimePlan,
|
|
45
|
+
} from "./setup-host.ts";
|
|
46
|
+
import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
47
|
+
import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
|
|
48
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
49
|
+
|
|
50
|
+
/** The confirm key, so a non-interactive caller can answer it up front. */
|
|
51
|
+
export const UNIT_RECONCILE_ANSWER_KEY = "reconcile-units";
|
|
52
|
+
|
|
53
|
+
/** One staged file and the destination it belongs at. */
|
|
54
|
+
export interface UnitDestination {
|
|
55
|
+
/** What this destination is, for the plan and the log. */
|
|
56
|
+
label: string;
|
|
57
|
+
/** The staged copy under the state directory — the render's own bytes. */
|
|
58
|
+
source: string;
|
|
59
|
+
/** The live path the system reads. */
|
|
60
|
+
installed: string;
|
|
61
|
+
/** `install -m` mode: units are data, the playbook is executed. */
|
|
62
|
+
mode: string;
|
|
63
|
+
/** The rendered content, so the staged copy can be written before install. */
|
|
64
|
+
content: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every destination this reconcile is allowed to touch, in dependency order:
|
|
69
|
+
* the recovery playbook and unit before the daemon unit that names it in
|
|
70
|
+
* `OnFailure=`, so systemd never reloads a unit referencing one it cannot
|
|
71
|
+
* load.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately absent, and not an oversight: the herdr pane-shell config and
|
|
74
|
+
* the herdr-conductor `config.env` (fleet-account files whose refresh implies
|
|
75
|
+
* a herdr restart), the worker identity, the tick config and the brief link.
|
|
76
|
+
* Those are `setup host`'s to own.
|
|
77
|
+
*/
|
|
78
|
+
export function unitDestinations(plan: HostRuntimePlan): UnitDestination[] {
|
|
79
|
+
const unitDir = dirname(plan.installedPath);
|
|
80
|
+
const destinations: UnitDestination[] = [
|
|
81
|
+
{
|
|
82
|
+
label: "recovery playbook",
|
|
83
|
+
source: plan.recoverScript.path,
|
|
84
|
+
installed: RECOVER_SCRIPT_INSTALL_PATH,
|
|
85
|
+
mode: "0755",
|
|
86
|
+
content: plan.recoverScript.content,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: RECOVER_SERVICE_NAME,
|
|
90
|
+
source: plan.recoverUnit.path,
|
|
91
|
+
installed: join(unitDir, RECOVER_SERVICE_NAME),
|
|
92
|
+
mode: "0644",
|
|
93
|
+
content: plan.recoverUnit.content,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: STAGED_SERVICE_NAME,
|
|
97
|
+
source: plan.service.path,
|
|
98
|
+
installed: plan.installedPath,
|
|
99
|
+
mode: "0644",
|
|
100
|
+
content: plan.service.content,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
if (plan.herdrUnit !== undefined) {
|
|
104
|
+
destinations.push({
|
|
105
|
+
label: DEFAULT_HERDR_UNIT,
|
|
106
|
+
source: plan.herdrUnit.path,
|
|
107
|
+
installed: join(unitDir, DEFAULT_HERDR_UNIT),
|
|
108
|
+
mode: "0644",
|
|
109
|
+
content: plan.herdrUnit.content,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return destinations;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface UnitReconcilePlan {
|
|
116
|
+
/** Destinations that will be refreshed, in execution order. */
|
|
117
|
+
refresh: UnitDestination[];
|
|
118
|
+
/**
|
|
119
|
+
* Host state this version retires, from {@link HostRuntimePlan.retire} (#895).
|
|
120
|
+
*
|
|
121
|
+
* It rides here rather than in a verb of its own because `upgrade` already
|
|
122
|
+
* spawns this reconcile as a child of the just-installed CLI — the only
|
|
123
|
+
* process that can render what the new release ships, and therefore the only
|
|
124
|
+
* one that knows what it stopped shipping. Retirement is NOT drift: a unit
|
|
125
|
+
* this version no longer renders cannot be compared against a render, so it
|
|
126
|
+
* is never protected by `--protect` and never counted as out of scope.
|
|
127
|
+
*/
|
|
128
|
+
retire: readonly string[];
|
|
129
|
+
/** Drifted destinations left alone because the caller protected them. */
|
|
130
|
+
protectedDrift: string[];
|
|
131
|
+
/** Drifted destinations outside this reconcile's remit, named so the caller
|
|
132
|
+
* can say precisely what still needs `setup host` — never a blanket
|
|
133
|
+
* "re-run setup host". */
|
|
134
|
+
outOfScope: string[];
|
|
135
|
+
/** The privileged batch: one `install` per refreshed destination, then one
|
|
136
|
+
* `daemon-reload`. Empty when nothing drifts. */
|
|
137
|
+
steps: PrivilegedStep[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What reconciling this plan would do. Pure: it renders no file and runs no
|
|
142
|
+
* command, so a caller can print it, test it, or decide there is nothing to do.
|
|
143
|
+
*
|
|
144
|
+
* `protect` names destinations whose installed bytes already differed from the
|
|
145
|
+
* *previous* version's render — operator hand edits, which this never
|
|
146
|
+
* overwrites.
|
|
147
|
+
*/
|
|
148
|
+
export function planUnitReconcile(
|
|
149
|
+
plan: HostRuntimePlan,
|
|
150
|
+
protect: readonly string[] = [],
|
|
151
|
+
/** `false` retires only — the posture `upgrade` uses when it could not read
|
|
152
|
+
* a drift baseline, so a refresh cannot tell an operator's edit from its
|
|
153
|
+
* own, but a retirement still must not be skipped (#895). */
|
|
154
|
+
refreshDestinations = true,
|
|
155
|
+
): UnitReconcilePlan {
|
|
156
|
+
const protectedSet = new Set(protect);
|
|
157
|
+
const drifted = new Set(plan.drift);
|
|
158
|
+
const known = unitDestinations(plan);
|
|
159
|
+
const refresh: UnitDestination[] = [];
|
|
160
|
+
const protectedDrift: string[] = [];
|
|
161
|
+
for (const destination of known) {
|
|
162
|
+
if (!refreshDestinations) continue;
|
|
163
|
+
if (!drifted.has(destination.installed)) continue;
|
|
164
|
+
if (protectedSet.has(destination.installed)) {
|
|
165
|
+
protectedDrift.push(destination.installed);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
refresh.push(destination);
|
|
169
|
+
}
|
|
170
|
+
const mine = new Set(known.map((d) => d.installed));
|
|
171
|
+
const outOfScope = refreshDestinations ? plan.drift.filter((path) => !mine.has(path)) : [];
|
|
172
|
+
const retireSteps = plan.retire?.steps ?? [];
|
|
173
|
+
const steps: PrivilegedStep[] =
|
|
174
|
+
refresh.length === 0 && retireSteps.length === 0
|
|
175
|
+
? []
|
|
176
|
+
: [
|
|
177
|
+
// Retirement first, and before any install: an obsolete unit is
|
|
178
|
+
// disabled and unmounted while the daemon this reconcile serves is
|
|
179
|
+
// still the one the caller drained for.
|
|
180
|
+
...retireSteps,
|
|
181
|
+
...refresh.map((destination) => ({
|
|
182
|
+
title: `install ${destination.label} (${destination.installed})`,
|
|
183
|
+
argv: ["install", "-m", destination.mode, destination.source, destination.installed],
|
|
184
|
+
})),
|
|
185
|
+
// One reload for the whole batch: systemd re-reads every unit file,
|
|
186
|
+
// so per-unit reloads would be the same work repeated. No `enable`
|
|
187
|
+
// and no `restart` — the units are already enabled, and restarting
|
|
188
|
+
// is the caller's decision, taken inside its own drain window.
|
|
189
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
190
|
+
];
|
|
191
|
+
const retire = plan.retire === undefined ? [] : [...plan.retire.units, ...plan.retire.staged];
|
|
192
|
+
return { refresh, retire, protectedDrift, outOfScope, steps };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type UnitReconcileOutcome =
|
|
196
|
+
/** Nothing drifted and nothing to retire: the host already matches this
|
|
197
|
+
* version. */
|
|
198
|
+
| { kind: "current"; protectedDrift: string[]; outOfScope: string[] }
|
|
199
|
+
/** The batch ran. `refreshed` are the destinations now matching the render,
|
|
200
|
+
* and `retired` the host state this version removed (#895). */
|
|
201
|
+
| { kind: "reconciled"; refreshed: string[]; retired: string[]; protectedDrift: string[]; outOfScope: string[] }
|
|
202
|
+
/** The confirm was declined; nothing was written or installed. */
|
|
203
|
+
| { kind: "declined"; pending: string[] }
|
|
204
|
+
/** A step failed. `failed` names it; nothing after it ran. */
|
|
205
|
+
| { kind: "failed"; failed: string; detail: string; remaining: string[] };
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Stage the rendered unit files and install the ones whose live bytes differ.
|
|
209
|
+
*
|
|
210
|
+
* The staged writes happen first and unprivileged (they land under the state
|
|
211
|
+
* directory the fleet account owns), and only for destinations that will
|
|
212
|
+
* actually be installed — so a declined confirm leaves the state directory as
|
|
213
|
+
* it was, and a current host is not rewritten just to prove it is current.
|
|
214
|
+
*/
|
|
215
|
+
export async function reconcileUnits(
|
|
216
|
+
plan: HostRuntimePlan,
|
|
217
|
+
ui: WizardUi,
|
|
218
|
+
options: {
|
|
219
|
+
protect?: readonly string[];
|
|
220
|
+
/** `false` retires only, refreshing nothing (#895). */
|
|
221
|
+
refresh?: boolean;
|
|
222
|
+
/** Pre-answered confirm for a non-interactive caller (the upgrade
|
|
223
|
+
* transaction, whose operator already consented to the whole upgrade). */
|
|
224
|
+
deps?: PrivilegedDeps;
|
|
225
|
+
/** Injected in tests; defaults to a real file write. */
|
|
226
|
+
write?: (path: string, content: string) => void;
|
|
227
|
+
} = {},
|
|
228
|
+
): Promise<UnitReconcileOutcome> {
|
|
229
|
+
const reconcile = planUnitReconcile(plan, options.protect ?? [], options.refresh ?? true);
|
|
230
|
+
if (reconcile.steps.length === 0) {
|
|
231
|
+
return { kind: "current", protectedDrift: reconcile.protectedDrift, outOfScope: reconcile.outOfScope };
|
|
232
|
+
}
|
|
233
|
+
const write = options.write ?? ((path: string, content: string) => writeFileSync(path, content));
|
|
234
|
+
const outcome = await runPrivileged(reconcile.steps, ui, {
|
|
235
|
+
...(options.deps === undefined ? {} : { deps: options.deps }),
|
|
236
|
+
title: "Install the re-rendered host units now?",
|
|
237
|
+
answerKey: UNIT_RECONCILE_ANSWER_KEY,
|
|
238
|
+
preamble: [
|
|
239
|
+
...(reconcile.refresh.length === 0
|
|
240
|
+
? []
|
|
241
|
+
: ["This release renders host unit files that differ from the installed copies."]),
|
|
242
|
+
...(reconcile.retire.length === 0
|
|
243
|
+
? []
|
|
244
|
+
: [`This release retires host state an earlier one installed: ${reconcile.retire.join(", ")}.`]),
|
|
245
|
+
"Units only: nothing is enabled, restarted, or re-provisioned here.",
|
|
246
|
+
],
|
|
247
|
+
// Staged copies are written under the same consent as the install that
|
|
248
|
+
// reads them, and after it — never before a declined confirm.
|
|
249
|
+
beforeRun: async () => {
|
|
250
|
+
for (const destination of reconcile.refresh) write(destination.source, destination.content);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (outcome.kind === "declined") {
|
|
254
|
+
return { kind: "declined", pending: [...reconcile.retire, ...reconcile.refresh.map((d) => d.installed)] };
|
|
255
|
+
}
|
|
256
|
+
if (outcome.kind === "failed") {
|
|
257
|
+
return {
|
|
258
|
+
kind: "failed",
|
|
259
|
+
failed: outcome.step.title,
|
|
260
|
+
detail: outcome.stderr.trim().split("\n")[0] ?? `exit ${outcome.exitCode}`,
|
|
261
|
+
remaining: outcome.remaining.map((step) => step.title),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
kind: "reconciled",
|
|
266
|
+
refreshed: reconcile.refresh.map((d) => d.installed),
|
|
267
|
+
retired: [...reconcile.retire],
|
|
268
|
+
protectedDrift: reconcile.protectedDrift,
|
|
269
|
+
outOfScope: reconcile.outOfScope,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** One line per outcome, for the CLI and the upgrade journal. */
|
|
274
|
+
export function formatUnitReconcile(outcome: UnitReconcileOutcome): string {
|
|
275
|
+
const tail = (protectedDrift: readonly string[], outOfScope: readonly string[]): string =>
|
|
276
|
+
[
|
|
277
|
+
protectedDrift.length === 0
|
|
278
|
+
? ""
|
|
279
|
+
: ` left alone (modified outside conductor): ${protectedDrift.join(", ")}.`,
|
|
280
|
+
outOfScope.length === 0
|
|
281
|
+
? ""
|
|
282
|
+
: ` still owed to \`omp-conductor setup host\`: ${outOfScope.join(", ")}.`,
|
|
283
|
+
].join("");
|
|
284
|
+
switch (outcome.kind) {
|
|
285
|
+
case "current":
|
|
286
|
+
return `host units: already match this version's render.${tail(outcome.protectedDrift, outcome.outOfScope)}`;
|
|
287
|
+
case "reconciled":
|
|
288
|
+
return (
|
|
289
|
+
(outcome.refreshed.length === 0
|
|
290
|
+
? "host units: nothing to refresh."
|
|
291
|
+
: `host units: reconciled ${outcome.refreshed.length} destination(s) — ${outcome.refreshed.join(", ")}.`) +
|
|
292
|
+
(outcome.retired.length === 0 ? "" : ` Retired: ${outcome.retired.join(", ")}.`) +
|
|
293
|
+
tail(outcome.protectedDrift, outcome.outOfScope)
|
|
294
|
+
);
|
|
295
|
+
case "declined":
|
|
296
|
+
return `host units: not reconciled (declined) — still drifted: ${outcome.pending.join(", ")}.`;
|
|
297
|
+
case "failed":
|
|
298
|
+
return (
|
|
299
|
+
`host units: reconcile failed at "${outcome.failed}" (${outcome.detail}); ` +
|
|
300
|
+
`${outcome.remaining.length} step(s) did not run.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/upgrade-verify.ts
CHANGED
|
@@ -39,13 +39,20 @@ export interface UpgradeCommandResult {
|
|
|
39
39
|
stderr: string;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export async function runCommand(
|
|
42
|
+
export async function runCommand(
|
|
43
|
+
command: string,
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
/** Where to run it. Absent means this process's cwd, which is what every
|
|
46
|
+
* step but the bootstrap's source checks wants (#908). */
|
|
47
|
+
cwd?: string,
|
|
48
|
+
): Promise<UpgradeCommandResult> {
|
|
43
49
|
try {
|
|
44
50
|
const child = Bun.spawn([command, ...args], {
|
|
45
51
|
stdin: "ignore",
|
|
46
52
|
stdout: "pipe",
|
|
47
53
|
stderr: "pipe",
|
|
48
54
|
env: process.env,
|
|
55
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
49
56
|
});
|
|
50
57
|
const stdout = new Response(child.stdout).text();
|
|
51
58
|
const stderr = new Response(child.stderr).text();
|