omp-conductor 0.15.10 → 0.15.12
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 +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -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 +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- 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 +150 -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 +100 -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 +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -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 +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
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,58 @@ 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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
449
|
+
* The release questions a delegated release authority unlocks, in the order the
|
|
450
|
+
* interview asks them after the grant: one tool gate per release shape, then
|
|
451
|
+
* the release-policy conditions.
|
|
452
|
+
*
|
|
453
|
+
* The authority preview counts and names them from this list — and from
|
|
454
|
+
* `RELEASE_SHAPES` directly — so the prompt stays honest with the questions
|
|
455
|
+
* that follow: a shape added to `RELEASE_SHAPES` or a condition added here moves
|
|
456
|
+
* the number the preview quotes at the same time the later questions appear. A
|
|
457
|
+
* hard-coded count is the drift this exists to prevent.
|
|
458
|
+
*/
|
|
459
|
+
export function releaseUnlockPlan(): { gates: readonly string[]; policy: readonly string[] } {
|
|
460
|
+
return {
|
|
461
|
+
// `RELEASE_SHAPES`, not a copy that can rot: these are exactly the per-shape
|
|
462
|
+
// gates `askReleaseGrants` asks (seven, not five (#500)).
|
|
463
|
+
gates: [...RELEASE_SHAPES],
|
|
464
|
+
// The release half of `askPolicyPreconditions`, in the order it asks them.
|
|
465
|
+
policy: ["requirements", "required checks", "artefacts", "environments"],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Who merges, who releases and who promotes. Three confirms rather than one
|
|
471
|
+
* four-way list: these are independent grants — delegating merges is routine,
|
|
472
|
+
* delegating releases and promotion is not — and a menu of combinations frames
|
|
473
|
+
* them as equally ordinary choices, which is exactly the framing a release or
|
|
474
|
+
* promotion grant must not get.
|
|
435
475
|
*
|
|
436
476
|
* Neither confirm can start on "yes", so a re-run that Enters through the
|
|
437
477
|
* wizard revokes rather than renews. That is the safe direction, and the
|
|
@@ -448,15 +488,31 @@ async function askAuthority(
|
|
|
448
488
|
"re-checked against the base branch first. Default: humans merge" +
|
|
449
489
|
`${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
450
490
|
);
|
|
491
|
+
const unlock = releaseUnlockPlan();
|
|
492
|
+
const unlockCount = unlock.gates.length + unlock.policy.length;
|
|
451
493
|
const release = await askYesNo(
|
|
452
494
|
ui,
|
|
453
495
|
"Release authority",
|
|
454
496
|
"Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
|
|
455
497
|
"procedure you write into its brief — and its brief forbids cutting one before you have. " +
|
|
456
|
-
|
|
498
|
+
`Delegating unlocks ${unlockCount} later decisions: the ${unlock.gates.length} release-tool ` +
|
|
499
|
+
`gates (${unlock.gates.join(", ")}) and the release-policy conditions (${unlock.policy.join(", ")}). ` +
|
|
500
|
+
"Declining means humans release and none are asked. Default: humans release" +
|
|
457
501
|
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
458
502
|
);
|
|
459
|
-
|
|
503
|
+
const promotion = await askYesNo(
|
|
504
|
+
ui,
|
|
505
|
+
"Promotion authority",
|
|
506
|
+
"Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
|
|
507
|
+
"issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
|
|
508
|
+
"Default: the operator promotes" +
|
|
509
|
+
`${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
510
|
+
);
|
|
511
|
+
return {
|
|
512
|
+
merge: merge ? "orchestrator" : "human",
|
|
513
|
+
release: release ? "orchestrator" : "human",
|
|
514
|
+
promotion: promotion ? "orchestrator" : "human",
|
|
515
|
+
};
|
|
460
516
|
}
|
|
461
517
|
|
|
462
518
|
/**
|
|
@@ -478,6 +534,14 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
478
534
|
deploy:
|
|
479
535
|
"deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
|
|
480
536
|
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
537
|
+
// #486: the last leg of the loop. Releasing publishes; installing replaces the
|
|
538
|
+
// running conductor itself, detached from the session, and only the first
|
|
539
|
+
// tick after the restart proves it. Fund as deliberate as `deploy` is — the
|
|
540
|
+
// package floor's "nobody patches the running conductor" is this grant's
|
|
541
|
+
// deny default.
|
|
542
|
+
install:
|
|
543
|
+
"install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
|
|
544
|
+
"and Herdr plugin pinned to one published release, executed detached from the fleet",
|
|
481
545
|
};
|
|
482
546
|
|
|
483
547
|
/**
|
|
@@ -1137,11 +1201,25 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1137
1201
|
return next;
|
|
1138
1202
|
};
|
|
1139
1203
|
|
|
1140
|
-
/** The
|
|
1204
|
+
/** The three ownership questions, then the mechanical gate one shape at a time:
|
|
1141
1205
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1142
1206
|
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
1143
1207
|
const authority = await askAuthority(ui, a.authority);
|
|
1144
|
-
|
|
1208
|
+
// Declining release authority makes the five per-shape grants moot — they
|
|
1209
|
+
// exist to scope an authority that was just refused, and asking them after
|
|
1210
|
+
// "humans release" teaches the operator that answers do not matter (#368).
|
|
1211
|
+
//
|
|
1212
|
+
// Humans-only authority clears any stale per-shape grant to the fail-closed
|
|
1213
|
+
// baseline rather than offering an individual-tool path: `releaseVerb`
|
|
1214
|
+
// refuses before a shape grant is ever read (see `verbs/server`), so a shape
|
|
1215
|
+
// granted under humans-only authority is a dead prompt that says the opposite
|
|
1216
|
+
// of what the operator answered. Clearing keeps the tick tripwire, which
|
|
1217
|
+
// reads the grant map independently of authority, from enforcing a shape the
|
|
1218
|
+
// operator just revoked.
|
|
1219
|
+
const releaseGrants =
|
|
1220
|
+
authority.release === "human"
|
|
1221
|
+
? { ...DENIED_RELEASE_GRANTS }
|
|
1222
|
+
: await askReleaseGrants(ui, a.releaseGrants);
|
|
1145
1223
|
// Asked here because it is the same decision one layer down: the grants say
|
|
1146
1224
|
// who may act, and these say where that permission stops. Amending authority
|
|
1147
1225
|
// therefore re-asks the boundary, which is the point — a grant widened without
|
|
@@ -1151,8 +1229,13 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
|
1151
1229
|
};
|
|
1152
1230
|
|
|
1153
1231
|
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
1154
|
-
* who may act, then under what conditions
|
|
1155
|
-
|
|
1232
|
+
* who may act, then under what conditions. The release half is only asked
|
|
1233
|
+
* when the orchestrator cuts releases — with humans releasing it has no gates
|
|
1234
|
+
* to configure, so it would be a dead prompt (#368). */
|
|
1235
|
+
const askPolicy: AreaAsker = async (ui, a) => ({
|
|
1236
|
+
...a,
|
|
1237
|
+
policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
|
|
1238
|
+
});
|
|
1156
1239
|
|
|
1157
1240
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
1158
1241
|
const askEscalation: AreaAsker = async (ui, a) => {
|
|
@@ -1421,6 +1504,32 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
1421
1504
|
await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes),
|
|
1422
1505
|
};
|
|
1423
1506
|
|
|
1507
|
+
/**
|
|
1508
|
+
* The full interview's areas, in the exact order `collectAnswers` walks them.
|
|
1509
|
+
*
|
|
1510
|
+
* This array is the single source of both the questions and their progress
|
|
1511
|
+
* marker: the "m" in every `Area n of m` line is this list's `.length`, never a
|
|
1512
|
+
* separate constant, so an area a branch predicate prunes takes its row and its
|
|
1513
|
+
* slot in the denominator with it — the count cannot drift from the iterable.
|
|
1514
|
+
*
|
|
1515
|
+
* The order is load-bearing and each step documents its own rationale: the
|
|
1516
|
+
* graph sits straight after the repos because the answer is derived per repo;
|
|
1517
|
+
* the worker model sits after the policy block because the boundary is an
|
|
1518
|
+
* authority-shaped decision, not a runtime knob; the brief is last because its
|
|
1519
|
+
* write question needs the real path the rest of the answers derive.
|
|
1520
|
+
*/
|
|
1521
|
+
const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
|
|
1522
|
+
{ label: "tracker & repos", asker: askTrackerAndRepos },
|
|
1523
|
+
{ label: "code graph", asker: askGraph },
|
|
1524
|
+
{ label: "caps", asker: askCaps },
|
|
1525
|
+
{ label: "authority & release grants", asker: askAuthorityArea },
|
|
1526
|
+
{ label: "merge & release preconditions", asker: askPolicy },
|
|
1527
|
+
{ label: "worker model", asker: askWorkerModel },
|
|
1528
|
+
{ label: "escalation & triage", asker: askEscalation },
|
|
1529
|
+
{ label: "reporting", asker: askReporting },
|
|
1530
|
+
{ label: "orchestrator brief", asker: askBrief },
|
|
1531
|
+
];
|
|
1532
|
+
|
|
1424
1533
|
/** The two ways to answer the first question a configured project gets. Labels,
|
|
1425
1534
|
* because the harness's select resolves to the label it displayed. */
|
|
1426
1535
|
const AMEND_ONE = "Change one area";
|
|
@@ -1444,7 +1553,7 @@ type AmendChoice =
|
|
|
1444
1553
|
* add-a-project path. Only asked when a project is already configured — a first
|
|
1445
1554
|
* run has nothing to amend and is never shown this.
|
|
1446
1555
|
*/
|
|
1447
|
-
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
|
|
1556
|
+
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
|
|
1448
1557
|
const mode = await ui.select(
|
|
1449
1558
|
`"${prior.name}" is already configured — what would you like to do?`,
|
|
1450
1559
|
[
|
|
@@ -1473,7 +1582,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
|
|
|
1473
1582
|
return { kind: "reinterview" };
|
|
1474
1583
|
}
|
|
1475
1584
|
|
|
1476
|
-
const choices = amendChoices(prior);
|
|
1585
|
+
const choices = amendChoices(prior, defaults);
|
|
1477
1586
|
const picked = await ui.select(
|
|
1478
1587
|
"Which area? Each row shows what it says now",
|
|
1479
1588
|
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
@@ -1536,19 +1645,14 @@ async function collectAnswers(
|
|
|
1536
1645
|
mirrorRoot: scoped.mirrorRoot,
|
|
1537
1646
|
};
|
|
1538
1647
|
}
|
|
1539
|
-
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
a = await askEscalation(ui, a, probes);
|
|
1548
|
-
a = await askReporting(ui, a, probes);
|
|
1549
|
-
// Asked last, and asked with the real path in the question — which needs the
|
|
1550
|
-
// rest of the answers to derive.
|
|
1551
|
-
return await askBrief(ui, a, probes);
|
|
1648
|
+
// Walk the ordered area list once; the marker's denominator is the list's
|
|
1649
|
+
// length, so an area a branch prunes drops its row and its slot together.
|
|
1650
|
+
const areas = INTERVIEW_AREAS;
|
|
1651
|
+
for (const [index, area] of areas.entries()) {
|
|
1652
|
+
ui.notify(`Area ${index + 1} of ${areas.length}: ${area.label}`, "info");
|
|
1653
|
+
a = await area.asker(ui, a, probes, discovered);
|
|
1654
|
+
}
|
|
1655
|
+
return a;
|
|
1552
1656
|
}
|
|
1553
1657
|
|
|
1554
1658
|
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
@@ -1661,7 +1765,7 @@ export async function collectSetup(
|
|
|
1661
1765
|
};
|
|
1662
1766
|
}
|
|
1663
1767
|
|
|
1664
|
-
const choice = await chooseAmendArea(ui, prior);
|
|
1768
|
+
const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
|
|
1665
1769
|
if (choice.kind === "add-project") {
|
|
1666
1770
|
// Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
|
|
1667
1771
|
// here: the operator just chose to add, so the name comes from the prompt.
|
|
@@ -1743,11 +1847,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
|
|
|
1743
1847
|
|
|
1744
1848
|
|
|
1745
1849
|
|
|
1850
|
+
/**
|
|
1851
|
+
* The side-effecting apply tail of {@link setup}, as one injectable object — the
|
|
1852
|
+
* wizard has `collectSetup` fully covered but none of its apply tail today. The
|
|
1853
|
+
* default is the real implementations; a test stubs whichever of gh/daemon/root
|
|
1854
|
+
* it proves, and drives the real tail (including the real `runGraphInstall`
|
|
1855
|
+
* with injected `unitDir`/`privileged`/probe) through the same seam, so a
|
|
1856
|
+
* regression cannot hide behind a rewritten status line.
|
|
1857
|
+
*/
|
|
1858
|
+
export interface SetupApplyDeps {
|
|
1859
|
+
scopes: () => Promise<ScopeCheck>;
|
|
1860
|
+
labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
|
|
1861
|
+
preview: (project: ProjectConfig) => Promise<QueuePreview>;
|
|
1862
|
+
createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
|
|
1863
|
+
smoke: (project: string) => Promise<SetupSmokeResult>;
|
|
1864
|
+
restart: (o: { project?: string }) => Promise<RestartResult>;
|
|
1865
|
+
arm: (project: string) => Promise<string>;
|
|
1866
|
+
resume: (project: string) => void;
|
|
1867
|
+
hostInstall: (
|
|
1868
|
+
project: ProjectConfig,
|
|
1869
|
+
caps: Caps,
|
|
1870
|
+
telegramStateDir: string,
|
|
1871
|
+
ui: WizardUi,
|
|
1872
|
+
) => Promise<InstallOutcome>;
|
|
1873
|
+
graphInstall: (
|
|
1874
|
+
project: ProjectConfig,
|
|
1875
|
+
ui: WizardUi,
|
|
1876
|
+
options?: GraphInstallOptions,
|
|
1877
|
+
) => Promise<InstallOutcome>;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
export const DEFAULT_APPLY: SetupApplyDeps = {
|
|
1881
|
+
scopes: checkTokenScopes,
|
|
1882
|
+
labels: planLabels,
|
|
1883
|
+
preview: previewProject,
|
|
1884
|
+
createLabels: createMissingLabels,
|
|
1885
|
+
smoke: runSetupSmoke,
|
|
1886
|
+
restart: (o) => restartDaemon(o),
|
|
1887
|
+
arm: (project) => ensureSetupArm(project),
|
|
1888
|
+
resume: (project) => setPaused(false, undefined, project),
|
|
1889
|
+
hostInstall: (project, caps, telegramStateDir, ui) =>
|
|
1890
|
+
runHostInstall(project, caps, telegramStateDir, ui),
|
|
1891
|
+
graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* The post-apply code-graph offer. Shared by the tail and the live-worker early
|
|
1896
|
+
* return, so neither path can silently leave the graph install unreferenced.
|
|
1897
|
+
* `graphInstall` is injectable: the tail and the CLI both call the real
|
|
1898
|
+
* {@link runGraphInstall}, and a test drives apply through this same seam.
|
|
1899
|
+
*/
|
|
1900
|
+
async function offerCodeGraph(
|
|
1901
|
+
project: ProjectConfig,
|
|
1902
|
+
ui: WizardUi,
|
|
1903
|
+
graphInstall: SetupApplyDeps["graphInstall"],
|
|
1904
|
+
): Promise<void> {
|
|
1905
|
+
if (graphRepos(project).length === 0) return;
|
|
1906
|
+
const wantsGraph = await ui.confirm(
|
|
1907
|
+
"Install the code-graph indexes now?",
|
|
1908
|
+
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
1909
|
+
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
1910
|
+
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
1911
|
+
);
|
|
1912
|
+
if (wantsGraph === true) await graphInstall(project, ui);
|
|
1913
|
+
else
|
|
1914
|
+
ui.notify(
|
|
1915
|
+
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
1916
|
+
"info",
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1746
1920
|
export async function setup(
|
|
1747
1921
|
ui: WizardUi,
|
|
1748
1922
|
projectArg: string | undefined,
|
|
1749
1923
|
areaArg?: AmendAreaId,
|
|
1750
1924
|
probes: SetupProbes = DEFAULT_PROBES,
|
|
1925
|
+
apply: SetupApplyDeps = DEFAULT_APPLY,
|
|
1751
1926
|
): Promise<void> {
|
|
1752
1927
|
const path = configPath();
|
|
1753
1928
|
// A config that exists but does not parse is a fault to report, never
|
|
@@ -1768,7 +1943,7 @@ export async function setup(
|
|
|
1768
1943
|
}
|
|
1769
1944
|
const { answers, amend, added } = collected;
|
|
1770
1945
|
|
|
1771
|
-
const scopes = await
|
|
1946
|
+
const scopes = await apply.scopes();
|
|
1772
1947
|
if (!scopes.ok) {
|
|
1773
1948
|
ui.notify(
|
|
1774
1949
|
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
@@ -1783,7 +1958,7 @@ export async function setup(
|
|
|
1783
1958
|
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1784
1959
|
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1785
1960
|
const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
|
|
1786
|
-
const labels = await
|
|
1961
|
+
const labels = await apply.labels(answers.trackerRepo, answers);
|
|
1787
1962
|
const telegram = detectTelegram();
|
|
1788
1963
|
let nextConfig;
|
|
1789
1964
|
try {
|
|
@@ -1820,7 +1995,7 @@ export async function setup(
|
|
|
1820
1995
|
);
|
|
1821
1996
|
let queuePreview: string[];
|
|
1822
1997
|
try {
|
|
1823
|
-
queuePreview = formatPreview(await
|
|
1998
|
+
queuePreview = formatPreview(await apply.preview(project));
|
|
1824
1999
|
} catch (err) {
|
|
1825
2000
|
const message = err instanceof Error ? err.message : String(err);
|
|
1826
2001
|
ui.notify(
|
|
@@ -1834,8 +2009,8 @@ export async function setup(
|
|
|
1834
2009
|
[
|
|
1835
2010
|
// The delta first when there is one, then the whole plan: the confirm has
|
|
1836
2011
|
// 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),
|
|
2012
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
|
|
2013
|
+
summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
|
|
1839
2014
|
"",
|
|
1840
2015
|
formatHostRuntimePlan(runtime),
|
|
1841
2016
|
"",
|
|
@@ -1863,11 +2038,23 @@ export async function setup(
|
|
|
1863
2038
|
runtime.service.action === "keep"
|
|
1864
2039
|
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
1865
2040
|
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
2041
|
+
runtime.herdrUnit === undefined
|
|
2042
|
+
? ""
|
|
2043
|
+
: runtime.herdrUnit.action === "keep"
|
|
2044
|
+
? `Keeps the staged Herdr session unit at ${runtime.herdrUnit.path}.`
|
|
2045
|
+
: `${runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${runtime.herdrUnit.path} (account's login shell for panes).`,
|
|
1866
2046
|
runtime.tick === undefined
|
|
1867
2047
|
? ""
|
|
1868
2048
|
: runtime.tick.action === "keep"
|
|
1869
2049
|
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
1870
2050
|
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
2051
|
+
runtime.briefLink === undefined
|
|
2052
|
+
? ""
|
|
2053
|
+
: runtime.briefLink.action === "skip"
|
|
2054
|
+
? `Leaves the existing file at ${runtime.briefLink.path} alone (an operator's file, not overwritten).`
|
|
2055
|
+
: runtime.briefLink.action === "keep"
|
|
2056
|
+
? `Keeps the brief link at ${runtime.briefLink.path}.`
|
|
2057
|
+
: `${runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${runtime.briefLink.path} -> ${runtime.briefLink.target}.`,
|
|
1871
2058
|
answers.writeOrchestratorBrief
|
|
1872
2059
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
1873
2060
|
: "",
|
|
@@ -1887,11 +2074,12 @@ export async function setup(
|
|
|
1887
2074
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
1888
2075
|
// partially applied setup unable to claim work.
|
|
1889
2076
|
prepareConductor(project.name);
|
|
1890
|
-
const created = await
|
|
2077
|
+
const created = await apply.createLabels(answers.trackerRepo, labels);
|
|
1891
2078
|
saveConfig(nextConfig);
|
|
1892
2079
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
1893
2080
|
const runtimeFiles = writeHostRuntime(runtime);
|
|
1894
|
-
const
|
|
2081
|
+
for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
|
|
2082
|
+
const smoke = await apply.smoke(project.name);
|
|
1895
2083
|
let smokeLine =
|
|
1896
2084
|
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
1897
2085
|
`stored status for ${smoke.status.project}`;
|
|
@@ -1911,6 +2099,12 @@ export async function setup(
|
|
|
1911
2099
|
].join("\n"),
|
|
1912
2100
|
"warning",
|
|
1913
2101
|
);
|
|
2102
|
+
// The early return must not swallow the graph offer. This host is the
|
|
2103
|
+
// live reproduction: the shared reindex units were already installed by
|
|
2104
|
+
// the first project, so only the clone+seed are outstanding — and nothing
|
|
2105
|
+
// above names them. Offer regardless of live workers: cloning seeds and
|
|
2106
|
+
// enables the timer, which does not touch the running daemon.
|
|
2107
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
1914
2108
|
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1915
2109
|
return;
|
|
1916
2110
|
}
|
|
@@ -1928,7 +2122,7 @@ export async function setup(
|
|
|
1928
2122
|
"info",
|
|
1929
2123
|
);
|
|
1930
2124
|
} else {
|
|
1931
|
-
const restarted = await
|
|
2125
|
+
const restarted = await apply.restart({ project: project.name });
|
|
1932
2126
|
restartVia = restarted.via;
|
|
1933
2127
|
smokeLine =
|
|
1934
2128
|
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
@@ -1940,7 +2134,7 @@ export async function setup(
|
|
|
1940
2134
|
if (project.escalation.orchestrator === "external") {
|
|
1941
2135
|
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1942
2136
|
try {
|
|
1943
|
-
armLine = await
|
|
2137
|
+
armLine = await apply.arm(project.name);
|
|
1944
2138
|
} catch (err) {
|
|
1945
2139
|
ui.notify(
|
|
1946
2140
|
[
|
|
@@ -1955,7 +2149,7 @@ export async function setup(
|
|
|
1955
2149
|
return;
|
|
1956
2150
|
}
|
|
1957
2151
|
}
|
|
1958
|
-
|
|
2152
|
+
apply.resume(project.name);
|
|
1959
2153
|
|
|
1960
2154
|
ui.notify(
|
|
1961
2155
|
[
|
|
@@ -1964,9 +2158,9 @@ export async function setup(
|
|
|
1964
2158
|
briefPath === undefined
|
|
1965
2159
|
? "Kept the existing orchestrator brief."
|
|
1966
2160
|
: `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
|
|
1967
|
-
runtimeFiles.length === 0
|
|
2161
|
+
runtimeFiles.wrote.length === 0
|
|
1968
2162
|
? "Host runtime files were already current."
|
|
1969
|
-
: `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
|
|
2163
|
+
: `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
|
|
1970
2164
|
`Smoke passed: ${smokeLine}.`,
|
|
1971
2165
|
`Heartbeat: ${armLine}.`,
|
|
1972
2166
|
"",
|
|
@@ -2004,24 +2198,11 @@ export async function setup(
|
|
|
2004
2198
|
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
2005
2199
|
);
|
|
2006
2200
|
if (install === true)
|
|
2007
|
-
await
|
|
2201
|
+
await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
|
|
2008
2202
|
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
2009
2203
|
}
|
|
2010
2204
|
|
|
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
|
-
}
|
|
2205
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2025
2206
|
|
|
2026
2207
|
// Always print: first install, amend, and add-a-project all need the operator
|
|
2027
2208
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|