omp-conductor 0.15.10 → 0.15.11
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 +273 -2544
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +193 -60
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/setup-install.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* call in here; `privileged.ts` stays the primitive underneath.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
19
|
import { platform } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { stateDir } from "./config.ts";
|
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
STAGED_SERVICE_NAME,
|
|
42
42
|
SYSTEMD_UNIT_DIR,
|
|
43
43
|
type EscalationDeps,
|
|
44
|
+
type ServiceRuntime,
|
|
44
45
|
} from "./setup-host.ts";
|
|
46
|
+
import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
45
47
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
46
48
|
import type { Caps, ProjectConfig } from "./types.ts";
|
|
47
49
|
|
|
@@ -62,6 +64,12 @@ export interface InstallDeps {
|
|
|
62
64
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
63
65
|
platform?: () => string;
|
|
64
66
|
unitDir?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The runtime the plan derives from. Injectable so tests pin herdr
|
|
69
|
+
* present/absent deterministically; the default ({@link defaultServiceRuntime})
|
|
70
|
+
* detects herdr from the host's own PATH.
|
|
71
|
+
*/
|
|
72
|
+
runtime?: ServiceRuntime;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
@@ -75,6 +83,22 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
75
83
|
: "systemd install is Linux-only; staged files are at";
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether two files hold identical bytes. The "keep" side of setup-host.ts's
|
|
88
|
+
* `actionFor`: a unit systemd already reads that matches what would be written
|
|
89
|
+
* needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
|
|
90
|
+
* read error is a mismatch, never silently "matches", because the whole point
|
|
91
|
+
* is to make the plan only claim a unit install is redundant when it can see
|
|
92
|
+
* the installed copy.
|
|
93
|
+
*/
|
|
94
|
+
function sameFile(a: string, b: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(a, "utf8") === readFileSync(b, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
/**
|
|
79
103
|
* Install the supervised daemon unit: re-render, stage, then run the four steps
|
|
80
104
|
* `planHostRuntime` used to only print.
|
|
@@ -92,10 +116,12 @@ export async function runHostInstall(
|
|
|
92
116
|
return { kind: "refused", reason: verdict.message };
|
|
93
117
|
}
|
|
94
118
|
|
|
95
|
-
const plan = planHostRuntime(project, caps, telegramStateDir,
|
|
96
|
-
const wrote = writeHostRuntime(plan);
|
|
119
|
+
const plan = planHostRuntime(project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps));
|
|
120
|
+
const { wrote, warnings } = writeHostRuntime(plan);
|
|
121
|
+
for (const warning of warnings) ui.notify(warning, "warning");
|
|
97
122
|
const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
98
123
|
const installed = join(unitDir, STAGED_SERVICE_NAME);
|
|
124
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
99
125
|
|
|
100
126
|
const blocked = linuxOnly(deps);
|
|
101
127
|
if (blocked !== undefined) {
|
|
@@ -111,13 +137,29 @@ export async function runHostInstall(
|
|
|
111
137
|
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
112
138
|
{ title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
|
|
113
139
|
{ title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
|
|
140
|
+
// The herdr session server, provisioned alongside the daemon (#456). Only
|
|
141
|
+
// when herdr is installed and the plan therefore staged a unit.
|
|
142
|
+
...(plan.herdrUnit === undefined
|
|
143
|
+
? []
|
|
144
|
+
: [
|
|
145
|
+
{ title: `install ${DEFAULT_HERDR_UNIT}`, argv: ["install", "-m", "0644", plan.herdrUnit.path, installedHerdr] },
|
|
146
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
147
|
+
{ title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
|
|
148
|
+
{ title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
|
|
149
|
+
]),
|
|
114
150
|
];
|
|
115
151
|
|
|
116
152
|
const outcome = await runPrivileged(steps, ui, {
|
|
117
153
|
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
118
|
-
title: "Install and start the supervised
|
|
154
|
+
title: "Install and start the supervised session?",
|
|
119
155
|
preamble: [
|
|
120
156
|
`Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
|
|
157
|
+
...(plan.herdrUnit === undefined
|
|
158
|
+
? []
|
|
159
|
+
: [
|
|
160
|
+
`Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
|
|
161
|
+
"run it instead of dash, then enables and restarts it.",
|
|
162
|
+
]),
|
|
121
163
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
122
164
|
],
|
|
123
165
|
});
|
|
@@ -219,6 +261,15 @@ export async function runGraphInstall(
|
|
|
219
261
|
const blocked = linuxOnly(options);
|
|
220
262
|
const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
|
|
221
263
|
const from = unitPaths(stateDir());
|
|
264
|
+
// The units systemd already reads, compared to what staging just wrote — the
|
|
265
|
+
// same distinction setup-host.ts draws between `installedAction` and
|
|
266
|
+
// `service.action` (read the installed file, compare to the fresh content,
|
|
267
|
+
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
268
|
+
// stale unit is the older bug, so when they differ the install runs. Only
|
|
269
|
+
// when both units already match does the plan omit them — then just the
|
|
270
|
+
// outstanding clone and seed remain.
|
|
271
|
+
const unitsCurrent =
|
|
272
|
+
blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
|
|
222
273
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
223
274
|
// one preview and one confirm, and a second confirm here also invented a
|
|
224
275
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -230,9 +281,13 @@ export async function runGraphInstall(
|
|
|
230
281
|
const install: PrivilegedStep[] =
|
|
231
282
|
blocked === undefined
|
|
232
283
|
? [
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
...(unitsCurrent
|
|
285
|
+
? []
|
|
286
|
+
: [
|
|
287
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
|
|
288
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
289
|
+
{ title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
|
|
290
|
+
]),
|
|
236
291
|
...seed,
|
|
237
292
|
]
|
|
238
293
|
: [];
|
|
@@ -242,19 +297,19 @@ export async function runGraphInstall(
|
|
|
242
297
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
243
298
|
}
|
|
244
299
|
|
|
245
|
-
if (blocked !== undefined) {
|
|
246
|
-
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
247
|
-
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
300
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
251
301
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
252
|
-
title: "Clone, install and enable the code-graph timer?",
|
|
302
|
+
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
253
303
|
preamble: [
|
|
254
304
|
`Staged: ${staged.written.join(", ")}.`,
|
|
255
305
|
...(clones.length === 0
|
|
256
306
|
? ["Every indexed clone already exists."]
|
|
257
|
-
: [
|
|
307
|
+
: [
|
|
308
|
+
`${clones.length} clone(s) run as you; ${
|
|
309
|
+
unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root."
|
|
310
|
+
}`,
|
|
311
|
+
]),
|
|
312
|
+
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
258
313
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
259
314
|
],
|
|
260
315
|
});
|
package/src/setup-wizard.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type QueuePreview,
|
|
31
31
|
} from "./daemon.ts";
|
|
32
32
|
import { armTicks, telegramStateDir } from "./fleet.ts";
|
|
33
|
-
import { restartDaemon } from "./lifecycle.ts";
|
|
33
|
+
import { restartDaemon, type RestartResult } from "./lifecycle.ts";
|
|
34
34
|
import { defaultGraphRoot, graphRepos } from "./graph.ts";
|
|
35
35
|
import {
|
|
36
36
|
formatHostRuntimePlan,
|
|
@@ -40,8 +40,9 @@ import {
|
|
|
40
40
|
SYSTEMD_UNIT_DIR,
|
|
41
41
|
tickCwdForProject,
|
|
42
42
|
writeHostRuntime,
|
|
43
|
+
type SetupSmokeResult,
|
|
43
44
|
} from "./setup-host.ts";
|
|
44
|
-
import { runGraphInstall, runHostInstall } from "./setup-install.ts";
|
|
45
|
+
import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts";
|
|
45
46
|
import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
|
|
46
47
|
import {
|
|
47
48
|
discoverFacts,
|
|
@@ -74,7 +75,9 @@ import {
|
|
|
74
75
|
summarisePlan,
|
|
75
76
|
writeOrchestratorBrief,
|
|
76
77
|
type AmendAreaId,
|
|
78
|
+
type LabelPlan,
|
|
77
79
|
type OperatorJudgment,
|
|
80
|
+
type ScopeCheck,
|
|
78
81
|
type SetupAnswers,
|
|
79
82
|
type ProbedProse,
|
|
80
83
|
} from "./setup.ts";
|
|
@@ -82,11 +85,13 @@ import {
|
|
|
82
85
|
BASE_FRESHNESS,
|
|
83
86
|
BEHIND_BASE_ACTIONS,
|
|
84
87
|
DEFAULT_CAPS,
|
|
88
|
+
DENIED_RELEASE_GRANTS,
|
|
85
89
|
DRAFT_POLICIES,
|
|
86
90
|
RELEASE_REQUIREMENTS,
|
|
87
91
|
INTERRUPT_CATEGORIES,
|
|
88
92
|
RELEASE_SHAPES,
|
|
89
93
|
WEEKDAYS,
|
|
94
|
+
type AuthorityHolder,
|
|
90
95
|
type Caps,
|
|
91
96
|
type ConductorConfig,
|
|
92
97
|
type OrchestratorMode,
|
|
@@ -374,8 +379,18 @@ async function askReleaseRequirements(
|
|
|
374
379
|
* them out of POLICY.md is that they are config: a condition an operator can
|
|
375
380
|
* only reach by opening `config.json` is one that stays at its default while
|
|
376
381
|
* their prose says something else, which is the drift this key ended.
|
|
382
|
+
*
|
|
383
|
+
* The merge half is always asked — it gates work that lands whatever the
|
|
384
|
+
* release authority is. The release half only matters when the orchestrator
|
|
385
|
+
* actually cuts releases: with `release: human` a release verb is refused
|
|
386
|
+
* before these fields are read (see `verbs/server`), so asking them would be a
|
|
387
|
+
* dead question. They are skipped, keeping the seed's value.
|
|
377
388
|
*/
|
|
378
|
-
async function askPolicyPreconditions(
|
|
389
|
+
async function askPolicyPreconditions(
|
|
390
|
+
ui: WizardUi,
|
|
391
|
+
prior: ProjectPolicy,
|
|
392
|
+
releaseAuthority: AuthorityHolder,
|
|
393
|
+
): Promise<ProjectPolicy> {
|
|
379
394
|
const merge = {
|
|
380
395
|
requiredChecks: await askNameList(
|
|
381
396
|
ui,
|
|
@@ -405,33 +420,37 @@ async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promi
|
|
|
405
420
|
),
|
|
406
421
|
};
|
|
407
422
|
|
|
408
|
-
const release =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
const release =
|
|
424
|
+
releaseAuthority === "orchestrator"
|
|
425
|
+
? {
|
|
426
|
+
requires: await askReleaseRequirements(ui, prior.release.requires),
|
|
427
|
+
requiredChecks: await askNameList(
|
|
428
|
+
ui,
|
|
429
|
+
`Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
|
|
430
|
+
prior.release.requiredChecks,
|
|
431
|
+
),
|
|
432
|
+
artefacts: await askNameList(
|
|
433
|
+
ui,
|
|
434
|
+
`Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
|
|
435
|
+
prior.release.artefacts,
|
|
436
|
+
),
|
|
437
|
+
environments: await askNameList(
|
|
438
|
+
ui,
|
|
439
|
+
`Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
|
|
440
|
+
prior.release.environments,
|
|
441
|
+
),
|
|
442
|
+
}
|
|
443
|
+
: prior.release;
|
|
426
444
|
|
|
427
445
|
return { merge, release };
|
|
428
446
|
}
|
|
429
447
|
|
|
430
448
|
/**
|
|
431
|
-
* Who merges and who
|
|
432
|
-
* these are independent grants — delegating merges is routine,
|
|
433
|
-
* releases is not — and a menu of
|
|
434
|
-
* ordinary choices, which is exactly the framing a release
|
|
449
|
+
* Who merges, who releases and who promotes. Three confirms rather than one
|
|
450
|
+
* four-way list: these are independent grants — delegating merges is routine,
|
|
451
|
+
* delegating releases and promotion is not — and a menu of combinations frames
|
|
452
|
+
* them as equally ordinary choices, which is exactly the framing a release or
|
|
453
|
+
* promotion grant must not get.
|
|
435
454
|
*
|
|
436
455
|
* Neither confirm can start on "yes", so a re-run that Enters through the
|
|
437
456
|
* wizard revokes rather than renews. That is the safe direction, and the
|
|
@@ -456,7 +475,19 @@ async function askAuthority(
|
|
|
456
475
|
"Default: humans release" +
|
|
457
476
|
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
458
477
|
);
|
|
459
|
-
|
|
478
|
+
const promotion = await askYesNo(
|
|
479
|
+
ui,
|
|
480
|
+
"Promotion authority",
|
|
481
|
+
"Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
|
|
482
|
+
"issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
|
|
483
|
+
"Default: the operator promotes" +
|
|
484
|
+
`${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
485
|
+
);
|
|
486
|
+
return {
|
|
487
|
+
merge: merge ? "orchestrator" : "human",
|
|
488
|
+
release: release ? "orchestrator" : "human",
|
|
489
|
+
promotion: promotion ? "orchestrator" : "human",
|
|
490
|
+
};
|
|
460
491
|
}
|
|
461
492
|
|
|
462
493
|
/**
|
|
@@ -478,6 +509,14 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
478
509
|
deploy:
|
|
479
510
|
"deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
|
|
480
511
|
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
512
|
+
// #486: the last leg of the loop. Releasing publishes; installing replaces the
|
|
513
|
+
// running conductor itself, detached from the session, and only the first
|
|
514
|
+
// tick after the restart proves it. Fund as deliberate as `deploy` is — the
|
|
515
|
+
// package floor's "nobody patches the running conductor" is this grant's
|
|
516
|
+
// deny default.
|
|
517
|
+
install:
|
|
518
|
+
"install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
|
|
519
|
+
"and Herdr plugin pinned to one published release, executed detached from the fleet",
|
|
481
520
|
};
|
|
482
521
|
|
|
483
522
|
/**
|
|
@@ -1137,11 +1176,25 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1137
1176
|
return next;
|
|
1138
1177
|
};
|
|
1139
1178
|
|
|
1140
|
-
/** The
|
|
1179
|
+
/** The three ownership questions, then the mechanical gate one shape at a time:
|
|
1141
1180
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1142
1181
|
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
1143
1182
|
const authority = await askAuthority(ui, a.authority);
|
|
1144
|
-
|
|
1183
|
+
// Declining release authority makes the five per-shape grants moot — they
|
|
1184
|
+
// exist to scope an authority that was just refused, and asking them after
|
|
1185
|
+
// "humans release" teaches the operator that answers do not matter (#368).
|
|
1186
|
+
//
|
|
1187
|
+
// Humans-only authority clears any stale per-shape grant to the fail-closed
|
|
1188
|
+
// baseline rather than offering an individual-tool path: `releaseVerb`
|
|
1189
|
+
// refuses before a shape grant is ever read (see `verbs/server`), so a shape
|
|
1190
|
+
// granted under humans-only authority is a dead prompt that says the opposite
|
|
1191
|
+
// of what the operator answered. Clearing keeps the tick tripwire, which
|
|
1192
|
+
// reads the grant map independently of authority, from enforcing a shape the
|
|
1193
|
+
// operator just revoked.
|
|
1194
|
+
const releaseGrants =
|
|
1195
|
+
authority.release === "human"
|
|
1196
|
+
? { ...DENIED_RELEASE_GRANTS }
|
|
1197
|
+
: await askReleaseGrants(ui, a.releaseGrants);
|
|
1145
1198
|
// Asked here because it is the same decision one layer down: the grants say
|
|
1146
1199
|
// who may act, and these say where that permission stops. Amending authority
|
|
1147
1200
|
// therefore re-asks the boundary, which is the point — a grant widened without
|
|
@@ -1151,8 +1204,13 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
|
1151
1204
|
};
|
|
1152
1205
|
|
|
1153
1206
|
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
1154
|
-
* who may act, then under what conditions
|
|
1155
|
-
|
|
1207
|
+
* who may act, then under what conditions. The release half is only asked
|
|
1208
|
+
* when the orchestrator cuts releases — with humans releasing it has no gates
|
|
1209
|
+
* to configure, so it would be a dead prompt (#368). */
|
|
1210
|
+
const askPolicy: AreaAsker = async (ui, a) => ({
|
|
1211
|
+
...a,
|
|
1212
|
+
policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
|
|
1213
|
+
});
|
|
1156
1214
|
|
|
1157
1215
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
1158
1216
|
const askEscalation: AreaAsker = async (ui, a) => {
|
|
@@ -1444,7 +1502,7 @@ type AmendChoice =
|
|
|
1444
1502
|
* add-a-project path. Only asked when a project is already configured — a first
|
|
1445
1503
|
* run has nothing to amend and is never shown this.
|
|
1446
1504
|
*/
|
|
1447
|
-
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
|
|
1505
|
+
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
|
|
1448
1506
|
const mode = await ui.select(
|
|
1449
1507
|
`"${prior.name}" is already configured — what would you like to do?`,
|
|
1450
1508
|
[
|
|
@@ -1473,7 +1531,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
|
|
|
1473
1531
|
return { kind: "reinterview" };
|
|
1474
1532
|
}
|
|
1475
1533
|
|
|
1476
|
-
const choices = amendChoices(prior);
|
|
1534
|
+
const choices = amendChoices(prior, defaults);
|
|
1477
1535
|
const picked = await ui.select(
|
|
1478
1536
|
"Which area? Each row shows what it says now",
|
|
1479
1537
|
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
@@ -1661,7 +1719,7 @@ export async function collectSetup(
|
|
|
1661
1719
|
};
|
|
1662
1720
|
}
|
|
1663
1721
|
|
|
1664
|
-
const choice = await chooseAmendArea(ui, prior);
|
|
1722
|
+
const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
|
|
1665
1723
|
if (choice.kind === "add-project") {
|
|
1666
1724
|
// Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
|
|
1667
1725
|
// here: the operator just chose to add, so the name comes from the prompt.
|
|
@@ -1743,11 +1801,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
|
|
|
1743
1801
|
|
|
1744
1802
|
|
|
1745
1803
|
|
|
1804
|
+
/**
|
|
1805
|
+
* The side-effecting apply tail of {@link setup}, as one injectable object — the
|
|
1806
|
+
* wizard has `collectSetup` fully covered but none of its apply tail today. The
|
|
1807
|
+
* default is the real implementations; a test stubs whichever of gh/daemon/root
|
|
1808
|
+
* it proves, and drives the real tail (including the real `runGraphInstall`
|
|
1809
|
+
* with injected `unitDir`/`privileged`/probe) through the same seam, so a
|
|
1810
|
+
* regression cannot hide behind a rewritten status line.
|
|
1811
|
+
*/
|
|
1812
|
+
export interface SetupApplyDeps {
|
|
1813
|
+
scopes: () => Promise<ScopeCheck>;
|
|
1814
|
+
labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
|
|
1815
|
+
preview: (project: ProjectConfig) => Promise<QueuePreview>;
|
|
1816
|
+
createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
|
|
1817
|
+
smoke: (project: string) => Promise<SetupSmokeResult>;
|
|
1818
|
+
restart: (o: { project?: string }) => Promise<RestartResult>;
|
|
1819
|
+
arm: (project: string) => Promise<string>;
|
|
1820
|
+
resume: (project: string) => void;
|
|
1821
|
+
hostInstall: (
|
|
1822
|
+
project: ProjectConfig,
|
|
1823
|
+
caps: Caps,
|
|
1824
|
+
telegramStateDir: string,
|
|
1825
|
+
ui: WizardUi,
|
|
1826
|
+
) => Promise<InstallOutcome>;
|
|
1827
|
+
graphInstall: (
|
|
1828
|
+
project: ProjectConfig,
|
|
1829
|
+
ui: WizardUi,
|
|
1830
|
+
options?: GraphInstallOptions,
|
|
1831
|
+
) => Promise<InstallOutcome>;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
export const DEFAULT_APPLY: SetupApplyDeps = {
|
|
1835
|
+
scopes: checkTokenScopes,
|
|
1836
|
+
labels: planLabels,
|
|
1837
|
+
preview: previewProject,
|
|
1838
|
+
createLabels: createMissingLabels,
|
|
1839
|
+
smoke: runSetupSmoke,
|
|
1840
|
+
restart: (o) => restartDaemon(o),
|
|
1841
|
+
arm: (project) => ensureSetupArm(project),
|
|
1842
|
+
resume: (project) => setPaused(false, undefined, project),
|
|
1843
|
+
hostInstall: (project, caps, telegramStateDir, ui) =>
|
|
1844
|
+
runHostInstall(project, caps, telegramStateDir, ui),
|
|
1845
|
+
graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The post-apply code-graph offer. Shared by the tail and the live-worker early
|
|
1850
|
+
* return, so neither path can silently leave the graph install unreferenced.
|
|
1851
|
+
* `graphInstall` is injectable: the tail and the CLI both call the real
|
|
1852
|
+
* {@link runGraphInstall}, and a test drives apply through this same seam.
|
|
1853
|
+
*/
|
|
1854
|
+
async function offerCodeGraph(
|
|
1855
|
+
project: ProjectConfig,
|
|
1856
|
+
ui: WizardUi,
|
|
1857
|
+
graphInstall: SetupApplyDeps["graphInstall"],
|
|
1858
|
+
): Promise<void> {
|
|
1859
|
+
if (graphRepos(project).length === 0) return;
|
|
1860
|
+
const wantsGraph = await ui.confirm(
|
|
1861
|
+
"Install the code-graph indexes now?",
|
|
1862
|
+
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
1863
|
+
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
1864
|
+
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
1865
|
+
);
|
|
1866
|
+
if (wantsGraph === true) await graphInstall(project, ui);
|
|
1867
|
+
else
|
|
1868
|
+
ui.notify(
|
|
1869
|
+
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
1870
|
+
"info",
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1746
1874
|
export async function setup(
|
|
1747
1875
|
ui: WizardUi,
|
|
1748
1876
|
projectArg: string | undefined,
|
|
1749
1877
|
areaArg?: AmendAreaId,
|
|
1750
1878
|
probes: SetupProbes = DEFAULT_PROBES,
|
|
1879
|
+
apply: SetupApplyDeps = DEFAULT_APPLY,
|
|
1751
1880
|
): Promise<void> {
|
|
1752
1881
|
const path = configPath();
|
|
1753
1882
|
// A config that exists but does not parse is a fault to report, never
|
|
@@ -1768,7 +1897,7 @@ export async function setup(
|
|
|
1768
1897
|
}
|
|
1769
1898
|
const { answers, amend, added } = collected;
|
|
1770
1899
|
|
|
1771
|
-
const scopes = await
|
|
1900
|
+
const scopes = await apply.scopes();
|
|
1772
1901
|
if (!scopes.ok) {
|
|
1773
1902
|
ui.notify(
|
|
1774
1903
|
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
@@ -1783,7 +1912,7 @@ export async function setup(
|
|
|
1783
1912
|
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1784
1913
|
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1785
1914
|
const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
|
|
1786
|
-
const labels = await
|
|
1915
|
+
const labels = await apply.labels(answers.trackerRepo, answers);
|
|
1787
1916
|
const telegram = detectTelegram();
|
|
1788
1917
|
let nextConfig;
|
|
1789
1918
|
try {
|
|
@@ -1820,7 +1949,7 @@ export async function setup(
|
|
|
1820
1949
|
);
|
|
1821
1950
|
let queuePreview: string[];
|
|
1822
1951
|
try {
|
|
1823
|
-
queuePreview = formatPreview(await
|
|
1952
|
+
queuePreview = formatPreview(await apply.preview(project));
|
|
1824
1953
|
} catch (err) {
|
|
1825
1954
|
const message = err instanceof Error ? err.message : String(err);
|
|
1826
1955
|
ui.notify(
|
|
@@ -1834,8 +1963,8 @@ export async function setup(
|
|
|
1834
1963
|
[
|
|
1835
1964
|
// The delta first when there is one, then the whole plan: the confirm has
|
|
1836
1965
|
// to name every mutation it authorises, and a delta names none of them.
|
|
1837
|
-
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
|
|
1838
|
-
summarisePlan(answers, scopes, labels, telegram),
|
|
1966
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
|
|
1967
|
+
summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
|
|
1839
1968
|
"",
|
|
1840
1969
|
formatHostRuntimePlan(runtime),
|
|
1841
1970
|
"",
|
|
@@ -1863,11 +1992,21 @@ export async function setup(
|
|
|
1863
1992
|
runtime.service.action === "keep"
|
|
1864
1993
|
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
1865
1994
|
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
1995
|
+
runtime.herdrUnit === undefined
|
|
1996
|
+
? ""
|
|
1997
|
+
: runtime.herdrUnit.action === "keep"
|
|
1998
|
+
? `Keeps the staged Herdr session unit at ${runtime.herdrUnit.path}.`
|
|
1999
|
+
: `${runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${runtime.herdrUnit.path} (account's login shell for panes).`,
|
|
1866
2000
|
runtime.tick === undefined
|
|
1867
2001
|
? ""
|
|
1868
2002
|
: runtime.tick.action === "keep"
|
|
1869
2003
|
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
1870
2004
|
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
2005
|
+
runtime.briefLink.action === "skip"
|
|
2006
|
+
? `Leaves the existing file at ${runtime.briefLink.path} alone (an operator's file, not overwritten).`
|
|
2007
|
+
: runtime.briefLink.action === "keep"
|
|
2008
|
+
? `Keeps the brief link at ${runtime.briefLink.path}.`
|
|
2009
|
+
: `${runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${runtime.briefLink.path} -> ${runtime.briefLink.target}.`,
|
|
1871
2010
|
answers.writeOrchestratorBrief
|
|
1872
2011
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
1873
2012
|
: "",
|
|
@@ -1887,11 +2026,12 @@ export async function setup(
|
|
|
1887
2026
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
1888
2027
|
// partially applied setup unable to claim work.
|
|
1889
2028
|
prepareConductor(project.name);
|
|
1890
|
-
const created = await
|
|
2029
|
+
const created = await apply.createLabels(answers.trackerRepo, labels);
|
|
1891
2030
|
saveConfig(nextConfig);
|
|
1892
2031
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
1893
2032
|
const runtimeFiles = writeHostRuntime(runtime);
|
|
1894
|
-
const
|
|
2033
|
+
for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
|
|
2034
|
+
const smoke = await apply.smoke(project.name);
|
|
1895
2035
|
let smokeLine =
|
|
1896
2036
|
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
1897
2037
|
`stored status for ${smoke.status.project}`;
|
|
@@ -1911,6 +2051,12 @@ export async function setup(
|
|
|
1911
2051
|
].join("\n"),
|
|
1912
2052
|
"warning",
|
|
1913
2053
|
);
|
|
2054
|
+
// The early return must not swallow the graph offer. This host is the
|
|
2055
|
+
// live reproduction: the shared reindex units were already installed by
|
|
2056
|
+
// the first project, so only the clone+seed are outstanding — and nothing
|
|
2057
|
+
// above names them. Offer regardless of live workers: cloning seeds and
|
|
2058
|
+
// enables the timer, which does not touch the running daemon.
|
|
2059
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
1914
2060
|
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1915
2061
|
return;
|
|
1916
2062
|
}
|
|
@@ -1928,7 +2074,7 @@ export async function setup(
|
|
|
1928
2074
|
"info",
|
|
1929
2075
|
);
|
|
1930
2076
|
} else {
|
|
1931
|
-
const restarted = await
|
|
2077
|
+
const restarted = await apply.restart({ project: project.name });
|
|
1932
2078
|
restartVia = restarted.via;
|
|
1933
2079
|
smokeLine =
|
|
1934
2080
|
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
@@ -1940,7 +2086,7 @@ export async function setup(
|
|
|
1940
2086
|
if (project.escalation.orchestrator === "external") {
|
|
1941
2087
|
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1942
2088
|
try {
|
|
1943
|
-
armLine = await
|
|
2089
|
+
armLine = await apply.arm(project.name);
|
|
1944
2090
|
} catch (err) {
|
|
1945
2091
|
ui.notify(
|
|
1946
2092
|
[
|
|
@@ -1955,7 +2101,7 @@ export async function setup(
|
|
|
1955
2101
|
return;
|
|
1956
2102
|
}
|
|
1957
2103
|
}
|
|
1958
|
-
|
|
2104
|
+
apply.resume(project.name);
|
|
1959
2105
|
|
|
1960
2106
|
ui.notify(
|
|
1961
2107
|
[
|
|
@@ -1964,9 +2110,9 @@ export async function setup(
|
|
|
1964
2110
|
briefPath === undefined
|
|
1965
2111
|
? "Kept the existing orchestrator brief."
|
|
1966
2112
|
: `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
|
|
1967
|
-
runtimeFiles.length === 0
|
|
2113
|
+
runtimeFiles.wrote.length === 0
|
|
1968
2114
|
? "Host runtime files were already current."
|
|
1969
|
-
: `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
|
|
2115
|
+
: `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
|
|
1970
2116
|
`Smoke passed: ${smokeLine}.`,
|
|
1971
2117
|
`Heartbeat: ${armLine}.`,
|
|
1972
2118
|
"",
|
|
@@ -2004,24 +2150,11 @@ export async function setup(
|
|
|
2004
2150
|
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
2005
2151
|
);
|
|
2006
2152
|
if (install === true)
|
|
2007
|
-
await
|
|
2153
|
+
await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
|
|
2008
2154
|
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
2009
2155
|
}
|
|
2010
2156
|
|
|
2011
|
-
|
|
2012
|
-
const wantsGraph = await ui.confirm(
|
|
2013
|
-
"Install the code-graph indexes now?",
|
|
2014
|
-
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
2015
|
-
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
2016
|
-
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
2017
|
-
);
|
|
2018
|
-
if (wantsGraph === true) await runGraphInstall(project, ui);
|
|
2019
|
-
else
|
|
2020
|
-
ui.notify(
|
|
2021
|
-
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
2022
|
-
"info",
|
|
2023
|
-
);
|
|
2024
|
-
}
|
|
2157
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2025
2158
|
|
|
2026
2159
|
// Always print: first install, amend, and add-a-project all need the operator
|
|
2027
2160
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|