omp-conductor 0.17.1 → 0.18.1
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 +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/setup-install.ts
CHANGED
|
@@ -30,12 +30,17 @@ import {
|
|
|
30
30
|
type UpgradeScope,
|
|
31
31
|
} from "./upgrade.ts";
|
|
32
32
|
import {
|
|
33
|
+
graphConflictMessage,
|
|
33
34
|
graphRepos,
|
|
34
35
|
formatGraphSetup,
|
|
36
|
+
legacyReindexFiles,
|
|
37
|
+
legacyReindexNote,
|
|
35
38
|
mcpEntry,
|
|
39
|
+
planGraphSetup,
|
|
36
40
|
reindexScriptPath,
|
|
41
|
+
reindexUnitName,
|
|
37
42
|
resolvePrereqs,
|
|
38
|
-
|
|
43
|
+
stagingConflict,
|
|
39
44
|
unitPaths,
|
|
40
45
|
writeGraphSetup,
|
|
41
46
|
type GraphPrereqs,
|
|
@@ -47,6 +52,8 @@ import {
|
|
|
47
52
|
agentRenameVerdict,
|
|
48
53
|
checkEscalation,
|
|
49
54
|
DEFAULT_AGENT_RENAME_DEPS,
|
|
55
|
+
defaultIdentityProbes,
|
|
56
|
+
defaultServiceRuntime,
|
|
50
57
|
planHostRuntime,
|
|
51
58
|
totalConfiguredWorkers,
|
|
52
59
|
writeHostRuntime,
|
|
@@ -56,6 +63,7 @@ import {
|
|
|
56
63
|
type AgentRenameDeps,
|
|
57
64
|
type EscalationDeps,
|
|
58
65
|
type ServiceRuntime,
|
|
66
|
+
type WorkerIdentityProbes,
|
|
59
67
|
} from "./setup-host.ts";
|
|
60
68
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
61
69
|
import type { Caps, ProjectConfig, Store } from "./types.ts";
|
|
@@ -76,6 +84,14 @@ export interface InstallDeps {
|
|
|
76
84
|
escalation?: EscalationDeps;
|
|
77
85
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
78
86
|
platform?: () => string;
|
|
87
|
+
/**
|
|
88
|
+
* The worker identity plan's probes (#798). `undefined` means the real
|
|
89
|
+
* host's read-only facts — production: every `setup host` installs and
|
|
90
|
+
* grants the dedicated worker account. `null` disables the identity plan
|
|
91
|
+
* entirely (the historical step list, for tests pinning it), and an object
|
|
92
|
+
* is used as given (tests pinning a specific host state).
|
|
93
|
+
*/
|
|
94
|
+
workerIdentityProbes?: WorkerIdentityProbes | null;
|
|
79
95
|
unitDir?: string;
|
|
80
96
|
/**
|
|
81
97
|
* Where the recovery playbook installs. Injectable so the idempotency gate
|
|
@@ -131,16 +147,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
/**
|
|
134
|
-
* Whether
|
|
135
|
-
* `actionFor`: a unit systemd already reads that matches what
|
|
136
|
-
* needs no reinstall. Reads can fail (permissions, vanished
|
|
137
|
-
* read error is a mismatch, never silently "matches", because
|
|
138
|
-
* is to make the plan only claim a unit install is redundant
|
|
139
|
-
* the installed copy.
|
|
150
|
+
* Whether a file holds exactly the rendered bytes. The "keep" side of
|
|
151
|
+
* setup-host.ts's `actionFor`: a unit systemd already reads that matches what
|
|
152
|
+
* would be written needs no reinstall. Reads can fail (permissions, vanished
|
|
153
|
+
* mid-flight) — a read error is a mismatch, never silently "matches", because
|
|
154
|
+
* the whole point is to make the plan only claim a unit install is redundant
|
|
155
|
+
* when it can see the installed copy. Comparing against the rendered content
|
|
156
|
+
* rather than the staged file keeps the comparison read-only: staging now
|
|
157
|
+
* happens only after consent (#720), so nothing may be written to compare.
|
|
140
158
|
*/
|
|
141
|
-
function
|
|
159
|
+
function sameContent(path: string, content: string): boolean {
|
|
142
160
|
try {
|
|
143
|
-
return readFileSync(
|
|
161
|
+
return readFileSync(path, "utf8") === content;
|
|
144
162
|
} catch {
|
|
145
163
|
return false;
|
|
146
164
|
}
|
|
@@ -181,6 +199,13 @@ export async function runHostInstall(
|
|
|
181
199
|
// happens only after consent (#510), so the prompt's "Nothing has been run
|
|
182
200
|
// yet" is true of the staged tree when it is printed, and a declined run
|
|
183
201
|
// leaves every staged file byte-identical.
|
|
202
|
+
// The worker identity plan rides the same consent (#798): production probes
|
|
203
|
+
// the real host (account present? grants current?); a test may pass its own
|
|
204
|
+
// probes, or `null` to plan the historical step list only.
|
|
205
|
+
const identityProbes: WorkerIdentityProbes | null =
|
|
206
|
+
deps.workerIdentityProbes === null
|
|
207
|
+
? null
|
|
208
|
+
: deps.workerIdentityProbes ?? defaultIdentityProbes(deps.runtime ?? defaultServiceRuntime(telegramStateDir));
|
|
184
209
|
const plan = planHostRuntime(
|
|
185
210
|
project,
|
|
186
211
|
caps,
|
|
@@ -190,6 +215,7 @@ export async function runHostInstall(
|
|
|
190
215
|
unitDir,
|
|
191
216
|
deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
|
|
192
217
|
deps.multiProject ?? hostMultiProject(),
|
|
218
|
+
identityProbes,
|
|
193
219
|
);
|
|
194
220
|
// No pane-shell key is planned (unusable login shell, unparseable config):
|
|
195
221
|
// the operator hears why before the consent prompt, not after an install
|
|
@@ -291,6 +317,15 @@ export async function runHostInstall(
|
|
|
291
317
|
]),
|
|
292
318
|
]),
|
|
293
319
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
320
|
+
...(plan.workerIdentity === undefined
|
|
321
|
+
? []
|
|
322
|
+
: [
|
|
323
|
+
`Establishes the dedicated worker identity ${plan.workerIdentity.account}: every worker session runs under it, ` +
|
|
324
|
+
"never under the daemon's account, and it is granted search/read access to exactly the fleet paths a worker needs.",
|
|
325
|
+
...(plan.workerIdentity.current
|
|
326
|
+
? ["The worker identity is already in place — nothing to change."]
|
|
327
|
+
: plan.workerIdentity.pending),
|
|
328
|
+
]),
|
|
294
329
|
"",
|
|
295
330
|
liveWorkers === 0
|
|
296
331
|
? "No workers are live — nothing to drain before the restart."
|
|
@@ -450,9 +485,10 @@ export interface GraphInstallOptions extends InstallDeps {
|
|
|
450
485
|
* Staging and enabling alone installs a service that fails on every run: the
|
|
451
486
|
* generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
|
|
452
487
|
* its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
|
|
453
|
-
* than a graph. That is why
|
|
454
|
-
*
|
|
455
|
-
* seed and verify — one preview, one confirm
|
|
488
|
+
* than a graph. That is why staging was never allowed to call the install a
|
|
489
|
+
* finished job. So: prerequisites, then a conflict check, then clones, then
|
|
490
|
+
* install, then seed and verify — one preview, one confirm, and nothing
|
|
491
|
+
* staged before the confirm (#720).
|
|
456
492
|
*/
|
|
457
493
|
export async function runGraphInstall(
|
|
458
494
|
project: ProjectConfig,
|
|
@@ -494,7 +530,38 @@ export async function runGraphInstall(
|
|
|
494
530
|
return { kind: "refused", reason: missing };
|
|
495
531
|
}
|
|
496
532
|
|
|
497
|
-
const
|
|
533
|
+
const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
534
|
+
// The plan is computed eagerly — `planGraphSetup` renders every byte, so the
|
|
535
|
+
// prompt can print exactly what would be written and the unitsCurrent gate
|
|
536
|
+
// below can decide a re-run installs nothing. But rendering is read-only: no
|
|
537
|
+
// file is written here. Staging happens only after consent (#720), so the
|
|
538
|
+
// prompt's "stages once you confirm" is true of the staged tree when it is
|
|
539
|
+
// printed, and a declined run leaves every staged file byte-identical.
|
|
540
|
+
const plan = planGraphSetup(project, unitDir);
|
|
541
|
+
// Never overwrite another project's staged artefact: the stems derive from
|
|
542
|
+
// the project name, and two names can fold to one stem ("My Project" vs
|
|
543
|
+
// "my_project"), so an existing file's generated-for marker is checked
|
|
544
|
+
// before the consent prompt — a collision is refused before anything is
|
|
545
|
+
// asked, let alone written (#720).
|
|
546
|
+
const conflict = stagingConflict(project, [
|
|
547
|
+
plan.script.path,
|
|
548
|
+
plan.service.path,
|
|
549
|
+
plan.timer.path,
|
|
550
|
+
...Object.values(unitPaths(project, unitDir)),
|
|
551
|
+
]);
|
|
552
|
+
if (conflict !== undefined) {
|
|
553
|
+
const message = graphConflictMessage(conflict);
|
|
554
|
+
ui.notify(message, "error");
|
|
555
|
+
return { kind: "refused", reason: message };
|
|
556
|
+
}
|
|
557
|
+
// A host that ran the pre-#720 project-less version keeps its old files:
|
|
558
|
+
// their timer refreshes whichever project generated it last, forever. Name
|
|
559
|
+
// the remediation before the consent prompt — deleting files outside this
|
|
560
|
+
// command's own names is exactly how the overwrite happened in the first
|
|
561
|
+
// place, so it is host action, not install action.
|
|
562
|
+
const legacy = legacyReindexFiles();
|
|
563
|
+
if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
|
|
564
|
+
|
|
498
565
|
const absent = repos.filter((r) => !existsSync(r.graphProject));
|
|
499
566
|
|
|
500
567
|
// 2. Missing clones, as the operator. A root-owned index-only clone under the
|
|
@@ -507,17 +574,18 @@ export async function runGraphInstall(
|
|
|
507
574
|
}));
|
|
508
575
|
|
|
509
576
|
const blocked = linuxOnly(options);
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
// The units systemd already reads, compared to
|
|
577
|
+
const stem = reindexUnitName(project);
|
|
578
|
+
const { service, timer } = unitPaths(project, unitDir);
|
|
579
|
+
// The units systemd already reads, compared to the rendered content — the
|
|
513
580
|
// same distinction setup-host.ts draws between `installedAction` and
|
|
514
581
|
// `service.action` (read the installed file, compare to the fresh content,
|
|
515
582
|
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
516
583
|
// stale unit is the older bug, so when they differ the install runs. Only
|
|
517
584
|
// when both units already match does the plan omit them — then just the
|
|
518
|
-
// outstanding clone and seed remain.
|
|
585
|
+
// outstanding clone and seed remain. Nothing is written to compare: staging
|
|
586
|
+
// is post-consent now, so the comparison is against the rendered bytes.
|
|
519
587
|
const unitsCurrent =
|
|
520
|
-
blocked === undefined &&
|
|
588
|
+
blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
|
|
521
589
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
522
590
|
// one preview and one confirm, and a second confirm here also invented a
|
|
523
591
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -525,32 +593,52 @@ export async function runGraphInstall(
|
|
|
525
593
|
const seed: PrivilegedStep[] =
|
|
526
594
|
options.noSeed === true
|
|
527
595
|
? []
|
|
528
|
-
: [{ title: `seed the indexes (runs ${
|
|
596
|
+
: [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
|
|
529
597
|
const install: PrivilegedStep[] =
|
|
530
598
|
blocked === undefined
|
|
531
599
|
? [
|
|
532
600
|
...(unitsCurrent
|
|
533
601
|
? []
|
|
534
602
|
: [
|
|
535
|
-
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644",
|
|
603
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
|
|
536
604
|
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
537
|
-
{ title: `enable ${
|
|
605
|
+
{ title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
|
|
538
606
|
]),
|
|
539
607
|
...seed,
|
|
540
608
|
]
|
|
541
609
|
: [];
|
|
542
610
|
|
|
543
611
|
if (blocked !== undefined) {
|
|
612
|
+
// A non-Linux host cannot run systemd, so staging IS the deliverable: the
|
|
613
|
+
// rendered files are written for the operator to copy to the box that will
|
|
614
|
+
// run them, and only the systemctl half is refused. There is no consent
|
|
615
|
+
// prompt on this path — nothing privileged to approve — so the write lands
|
|
616
|
+
// here, not in the (never reached) post-consent hook (#720, as #510).
|
|
617
|
+
const staged = writeGraphSetup(project, unitDir);
|
|
544
618
|
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
545
619
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
546
620
|
}
|
|
547
621
|
|
|
622
|
+
// What the post-consent staging actually wrote, surfaced by the outcomes
|
|
623
|
+
// that report it. Empty on a decline — the hook never ran.
|
|
624
|
+
let wrote: readonly string[] = [];
|
|
625
|
+
const beforeRun = async (): Promise<void> => {
|
|
626
|
+
// Consent has been given; this project's files may now land. Staging here —
|
|
627
|
+
// post-confirm, pre-step, under the same confirm that authorised the batch
|
|
628
|
+
// — is what makes the prompt's plan true of the staged tree when it is
|
|
629
|
+
// printed: a declined prompt never reaches this hook, so nothing is staged
|
|
630
|
+
// and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
|
|
631
|
+
// backstop throws here with nothing written if a collision raced the
|
|
632
|
+
// pre-consent check.
|
|
633
|
+
wrote = writeGraphSetup(project, unitDir).written;
|
|
634
|
+
};
|
|
635
|
+
|
|
548
636
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
549
637
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
550
638
|
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
551
639
|
answerKey: `install-code-graph.${project.name}`,
|
|
552
640
|
preamble: [
|
|
553
|
-
`
|
|
641
|
+
`Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
|
|
554
642
|
...(clones.length === 0
|
|
555
643
|
? ["Every indexed clone already exists."]
|
|
556
644
|
: [
|
|
@@ -561,8 +649,9 @@ export async function runGraphInstall(
|
|
|
561
649
|
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
562
650
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
563
651
|
],
|
|
652
|
+
beforeRun,
|
|
564
653
|
});
|
|
565
|
-
if (outcome.kind === "declined") return { kind: "declined", wrote
|
|
654
|
+
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
566
655
|
if (outcome.kind === "failed") {
|
|
567
656
|
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
568
657
|
}
|
|
@@ -571,10 +660,10 @@ export async function runGraphInstall(
|
|
|
571
660
|
// answer: an unseeded graph is not usable until the timer first fires.
|
|
572
661
|
if (options.noSeed === true) {
|
|
573
662
|
ui.notify(
|
|
574
|
-
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${
|
|
663
|
+
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
|
|
575
664
|
"warning",
|
|
576
665
|
);
|
|
577
|
-
return { kind: "installed", wrote
|
|
666
|
+
return { kind: "installed", wrote };
|
|
578
667
|
}
|
|
579
668
|
|
|
580
669
|
const health = await (options.probe ?? probeCodeGraph)(project);
|
|
@@ -583,13 +672,13 @@ export async function runGraphInstall(
|
|
|
583
672
|
// Staged but not trusted. Reporting success here is how an operator learns
|
|
584
673
|
// months later that no worker ever read an index.
|
|
585
674
|
ui.notify(
|
|
586
|
-
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "",
|
|
675
|
+
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
|
|
587
676
|
"error",
|
|
588
677
|
);
|
|
589
678
|
return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
|
|
590
679
|
}
|
|
591
680
|
ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
|
|
592
|
-
return { kind: "installed", wrote
|
|
681
|
+
return { kind: "installed", wrote };
|
|
593
682
|
}
|
|
594
683
|
|
|
595
684
|
/** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
|
|
@@ -618,7 +707,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
|
|
|
618
707
|
return graphRepos(project);
|
|
619
708
|
}
|
|
620
709
|
|
|
621
|
-
/** Where
|
|
622
|
-
export function reindexScriptLocation(): string {
|
|
623
|
-
return reindexScriptPath();
|
|
710
|
+
/** Where this project's reindex script lands, for the wizard's tail to name. */
|
|
711
|
+
export function reindexScriptLocation(project: ProjectConfig): string {
|
|
712
|
+
return reindexScriptPath(project);
|
|
624
713
|
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -93,6 +93,7 @@ import {
|
|
|
93
93
|
POLICY_BRIEF_NAME,
|
|
94
94
|
RELEASE_REQUIREMENT_CHOICES,
|
|
95
95
|
REPORT_SCOPE_CHOICES,
|
|
96
|
+
REVIEW_STRICTNESS_CHOICES,
|
|
96
97
|
SETUP_DEFAULTS,
|
|
97
98
|
amendChoices,
|
|
98
99
|
answersFromProject,
|
|
@@ -125,11 +126,16 @@ import {
|
|
|
125
126
|
BASE_FRESHNESS,
|
|
126
127
|
BEHIND_BASE_ACTIONS,
|
|
127
128
|
DEFAULT_CAPS,
|
|
129
|
+
DEFAULT_REVIEW_MAX_ROUNDS,
|
|
130
|
+
DEFAULT_REVIEW_STRICTNESS,
|
|
128
131
|
DENIED_RELEASE_GRANTS,
|
|
129
132
|
DRAFT_POLICIES,
|
|
130
133
|
RELEASE_REQUIREMENTS,
|
|
131
134
|
INTERRUPT_CATEGORIES,
|
|
132
135
|
RELEASE_SHAPES,
|
|
136
|
+
REVIEW_MAX_ROUNDS_MAX,
|
|
137
|
+
REVIEW_MAX_ROUNDS_MIN,
|
|
138
|
+
REVIEW_STRICTNESS,
|
|
133
139
|
WEEKDAYS,
|
|
134
140
|
type AuthorityHolder,
|
|
135
141
|
type Caps,
|
|
@@ -142,6 +148,7 @@ import {
|
|
|
142
148
|
type ReleaseRequirement,
|
|
143
149
|
type ReportScopeChoice,
|
|
144
150
|
type ResolvedGrants,
|
|
151
|
+
type ReviewPolicy,
|
|
145
152
|
} from "./types.ts";
|
|
146
153
|
import { withProgress } from "./ui/progress.ts";
|
|
147
154
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
@@ -972,6 +979,7 @@ function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAns
|
|
|
972
979
|
inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
|
|
973
980
|
blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
|
|
974
981
|
failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
|
|
982
|
+
backlog: labels.get(seed.stateLabels.backlog.toLowerCase()) ?? seed.stateLabels.backlog,
|
|
975
983
|
},
|
|
976
984
|
targetRepos,
|
|
977
985
|
policy: {
|
|
@@ -1087,19 +1095,28 @@ const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
|
|
|
1087
1095
|
|
|
1088
1096
|
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
1089
1097
|
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
1090
|
-
// its reputation.
|
|
1098
|
+
// its reputation. The park label is in the same confirm, with its ownership
|
|
1099
|
+
// stated — it is not conductor-written, but it has to exist for the operator
|
|
1100
|
+
// to have the gesture (#507).
|
|
1091
1101
|
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
1092
1102
|
const customiseStates = await askYesNo(
|
|
1093
1103
|
ui,
|
|
1094
1104
|
"customise-state-labels",
|
|
1095
1105
|
"State labels",
|
|
1096
1106
|
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
1097
|
-
`"${stateLabels.failed}" so the tracker alone shows live state.
|
|
1107
|
+
`"${stateLabels.failed}" so the tracker alone shows live state; "${stateLabels.backlog}" is ` +
|
|
1108
|
+
`yours — the conductor never writes it, but an issue carrying it is never claimed. Rename them?`,
|
|
1098
1109
|
);
|
|
1099
1110
|
if (customiseStates) {
|
|
1100
1111
|
stateLabels.inProgress = await ask(ui, "state-label-in-progress", "Label for a run in progress", stateLabels.inProgress);
|
|
1101
1112
|
stateLabels.blocked = await ask(ui, "state-label-blocked", "Label for a run parked on a human", stateLabels.blocked);
|
|
1102
1113
|
stateLabels.failed = await ask(ui, "state-label-failed", "Label for a run that gave up", stateLabels.failed);
|
|
1114
|
+
stateLabels.backlog = await ask(
|
|
1115
|
+
ui,
|
|
1116
|
+
"state-label-backlog",
|
|
1117
|
+
"Label the operator applies to park an issue (the conductor never writes it)",
|
|
1118
|
+
stateLabels.backlog,
|
|
1119
|
+
);
|
|
1103
1120
|
}
|
|
1104
1121
|
|
|
1105
1122
|
const routingLabelPrefix = await ask(
|
|
@@ -1379,6 +1396,73 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
|
1379
1396
|
* and `claim-only` means anything that can invoke it can start dispatch once
|
|
1380
1397
|
* the live claim and poller pass.
|
|
1381
1398
|
*/
|
|
1399
|
+
/**
|
|
1400
|
+
* How many review rounds one PR lifecycle may be returned at most — the hard
|
|
1401
|
+
* bound against endless polishing (#678). Validated in the dialog against the
|
|
1402
|
+
* same integer range the loader validates against, so an out-of-range answer
|
|
1403
|
+
* is re-asked rather than written for the daemon to reject. The consequence of
|
|
1404
|
+
* the ceiling is stated in the title: at it, the PR is left open and escalated
|
|
1405
|
+
* once, never returned again.
|
|
1406
|
+
*/
|
|
1407
|
+
async function askReviewRounds(ui: WizardUi, current: number): Promise<number> {
|
|
1408
|
+
// Bounded like askValid above: a dialog that cannot be escaped is worse than
|
|
1409
|
+
// one that gives up and leaves the config alone. The `--answers` path
|
|
1410
|
+
// matters here specifically — answersUi is a map, not a consumer, so every
|
|
1411
|
+
// retry reads the same value and an out-of-range file used to recurse
|
|
1412
|
+
// forever instead of failing closed (#678).
|
|
1413
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1414
|
+
const raw = await ask(
|
|
1415
|
+
ui,
|
|
1416
|
+
"review-max-rounds",
|
|
1417
|
+
`Review rounds per PR lifecycle — an integer ${REVIEW_MAX_ROUNDS_MIN} to ${REVIEW_MAX_ROUNDS_MAX}; ` +
|
|
1418
|
+
`at the ceiling the PR is left open and escalated once`,
|
|
1419
|
+
String(current),
|
|
1420
|
+
);
|
|
1421
|
+
const value = Number(raw);
|
|
1422
|
+
if (Number.isInteger(value) && value >= REVIEW_MAX_ROUNDS_MIN && value <= REVIEW_MAX_ROUNDS_MAX) {
|
|
1423
|
+
return value;
|
|
1424
|
+
}
|
|
1425
|
+
ui.notify(
|
|
1426
|
+
`Review rounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX} ` +
|
|
1427
|
+
`— shown the current value again.`,
|
|
1428
|
+
"warning",
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
throw new Cancelled();
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* How green PRs are reviewed (#678), asked with the merge preconditions and
|
|
1436
|
+
* the arming proof: they are the same kind of declared policy — a typed,
|
|
1437
|
+
* mechanical boundary on what may happen to a PR — so they ride the same area.
|
|
1438
|
+
*
|
|
1439
|
+
* The three levels and their bars are shown before the select, with the
|
|
1440
|
+
* recommended level marked, so an operator chooses between named thresholds
|
|
1441
|
+
* rather than between three words. The select cursor opens on the current
|
|
1442
|
+
* answer (the configured level on a re-run, the recommended default on a
|
|
1443
|
+
* first run); the rounds are a validated integer within the same range the
|
|
1444
|
+
* loader enforces.
|
|
1445
|
+
*/
|
|
1446
|
+
async function askReviewPolicy(ui: WizardUi, a: SetupAnswers): Promise<ReviewPolicy> {
|
|
1447
|
+
ui.notify(
|
|
1448
|
+
REVIEW_STRICTNESS.map((level) => `${level}${level === DEFAULT_REVIEW_STRICTNESS ? " (recommended)" : ""} — ${REVIEW_STRICTNESS_CHOICES[level]}`).join(
|
|
1449
|
+
"\n",
|
|
1450
|
+
),
|
|
1451
|
+
"info",
|
|
1452
|
+
);
|
|
1453
|
+
return {
|
|
1454
|
+
strictness: await askLiteral(
|
|
1455
|
+
ui,
|
|
1456
|
+
"review-strictness",
|
|
1457
|
+
"Review strictness for green PRs",
|
|
1458
|
+
REVIEW_STRICTNESS,
|
|
1459
|
+
REVIEW_STRICTNESS_CHOICES,
|
|
1460
|
+
a.review.strictness,
|
|
1461
|
+
),
|
|
1462
|
+
maxRounds: await askReviewRounds(ui, a.review.maxRounds),
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1382
1466
|
const askPolicy: AreaAsker = async (ui, a) => ({
|
|
1383
1467
|
...a,
|
|
1384
1468
|
policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
|
|
@@ -1390,6 +1474,7 @@ const askPolicy: AreaAsker = async (ui, a) => ({
|
|
|
1390
1474
|
ARM_PROOF_CHOICES,
|
|
1391
1475
|
a.armProof,
|
|
1392
1476
|
),
|
|
1477
|
+
review: await askReviewPolicy(ui, a),
|
|
1393
1478
|
});
|
|
1394
1479
|
|
|
1395
1480
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
@@ -2441,6 +2526,7 @@ function captureInventory(
|
|
|
2441
2526
|
};
|
|
2442
2527
|
captureIfWritten(plan.runtime.service);
|
|
2443
2528
|
captureIfWritten(plan.runtime.herdrUnit);
|
|
2529
|
+
captureIfWritten(plan.runtime.harnessMount);
|
|
2444
2530
|
captureIfWritten(plan.runtime.herdrConfig);
|
|
2445
2531
|
captureIfWritten(plan.runtime.herdrEnv);
|
|
2446
2532
|
captureIfWritten(plan.runtime.recoverUnit);
|