omp-conductor 0.15.11 → 0.15.13
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/REFERENCE.md +107 -60
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/restart.ts +81 -54
- package/src/commands/setup.ts +61 -11
- package/src/commands/stop.ts +45 -22
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +588 -37
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +419 -45
- package/src/escalate.ts +8 -0
- package/src/failure-class.ts +37 -0
- package/src/fleet.ts +49 -2
- package/src/gitops.ts +157 -0
- package/src/lifecycle.ts +113 -2
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +115 -13
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +108 -5
- package/src/orchestrator.ts +18 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +373 -28
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +665 -70
- package/src/setup-install.ts +275 -28
- package/src/setup-wizard.ts +339 -126
- package/src/setup.ts +25 -0
- package/src/stop-provenance.ts +66 -0
- package/src/store.ts +194 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +182 -0
- package/src/upgrade.ts +110 -32
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +27 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +24 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/setup-wizard.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
SYSTEMD_UNIT_DIR,
|
|
41
41
|
tickCwdForProject,
|
|
42
42
|
writeHostRuntime,
|
|
43
|
+
type HostRuntimePlan,
|
|
43
44
|
type SetupSmokeResult,
|
|
44
45
|
} from "./setup-host.ts";
|
|
45
46
|
import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts";
|
|
@@ -70,9 +71,11 @@ import {
|
|
|
70
71
|
detectTelegram,
|
|
71
72
|
formatGates,
|
|
72
73
|
orchestratorBriefPath,
|
|
74
|
+
planAgainstLabels,
|
|
73
75
|
planLabels,
|
|
74
76
|
summariseAmend,
|
|
75
77
|
summarisePlan,
|
|
78
|
+
wantedLabels,
|
|
76
79
|
writeOrchestratorBrief,
|
|
77
80
|
type AmendAreaId,
|
|
78
81
|
type LabelPlan,
|
|
@@ -445,6 +448,27 @@ async function askPolicyPreconditions(
|
|
|
445
448
|
return { merge, release };
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The release questions a delegated release authority unlocks, in the order the
|
|
453
|
+
* interview asks them after the grant: one tool gate per release shape, then
|
|
454
|
+
* the release-policy conditions.
|
|
455
|
+
*
|
|
456
|
+
* The authority preview counts and names them from this list — and from
|
|
457
|
+
* `RELEASE_SHAPES` directly — so the prompt stays honest with the questions
|
|
458
|
+
* that follow: a shape added to `RELEASE_SHAPES` or a condition added here moves
|
|
459
|
+
* the number the preview quotes at the same time the later questions appear. A
|
|
460
|
+
* hard-coded count is the drift this exists to prevent.
|
|
461
|
+
*/
|
|
462
|
+
export function releaseUnlockPlan(): { gates: readonly string[]; policy: readonly string[] } {
|
|
463
|
+
return {
|
|
464
|
+
// `RELEASE_SHAPES`, not a copy that can rot: these are exactly the per-shape
|
|
465
|
+
// gates `askReleaseGrants` asks (seven, not five (#500)).
|
|
466
|
+
gates: [...RELEASE_SHAPES],
|
|
467
|
+
// The release half of `askPolicyPreconditions`, in the order it asks them.
|
|
468
|
+
policy: ["requirements", "required checks", "artefacts", "environments"],
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
448
472
|
/**
|
|
449
473
|
* Who merges, who releases and who promotes. Three confirms rather than one
|
|
450
474
|
* four-way list: these are independent grants — delegating merges is routine,
|
|
@@ -467,12 +491,16 @@ async function askAuthority(
|
|
|
467
491
|
"re-checked against the base branch first. Default: humans merge" +
|
|
468
492
|
`${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
469
493
|
);
|
|
494
|
+
const unlock = releaseUnlockPlan();
|
|
495
|
+
const unlockCount = unlock.gates.length + unlock.policy.length;
|
|
470
496
|
const release = await askYesNo(
|
|
471
497
|
ui,
|
|
472
498
|
"Release authority",
|
|
473
499
|
"Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
|
|
474
500
|
"procedure you write into its brief — and its brief forbids cutting one before you have. " +
|
|
475
|
-
|
|
501
|
+
`Delegating unlocks ${unlockCount} later decisions: the ${unlock.gates.length} release-tool ` +
|
|
502
|
+
`gates (${unlock.gates.join(", ")}) and the release-policy conditions (${unlock.policy.join(", ")}). ` +
|
|
503
|
+
"Declining means humans release and none are asked. Default: humans release" +
|
|
476
504
|
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
477
505
|
);
|
|
478
506
|
const promotion = await askYesNo(
|
|
@@ -1479,6 +1507,32 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
1479
1507
|
await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes),
|
|
1480
1508
|
};
|
|
1481
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* The full interview's areas, in the exact order `collectAnswers` walks them.
|
|
1512
|
+
*
|
|
1513
|
+
* This array is the single source of both the questions and their progress
|
|
1514
|
+
* marker: the "m" in every `Area n of m` line is this list's `.length`, never a
|
|
1515
|
+
* separate constant, so an area a branch predicate prunes takes its row and its
|
|
1516
|
+
* slot in the denominator with it — the count cannot drift from the iterable.
|
|
1517
|
+
*
|
|
1518
|
+
* The order is load-bearing and each step documents its own rationale: the
|
|
1519
|
+
* graph sits straight after the repos because the answer is derived per repo;
|
|
1520
|
+
* the worker model sits after the policy block because the boundary is an
|
|
1521
|
+
* authority-shaped decision, not a runtime knob; the brief is last because its
|
|
1522
|
+
* write question needs the real path the rest of the answers derive.
|
|
1523
|
+
*/
|
|
1524
|
+
const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
|
|
1525
|
+
{ label: "tracker & repos", asker: askTrackerAndRepos },
|
|
1526
|
+
{ label: "code graph", asker: askGraph },
|
|
1527
|
+
{ label: "caps", asker: askCaps },
|
|
1528
|
+
{ label: "authority & release grants", asker: askAuthorityArea },
|
|
1529
|
+
{ label: "merge & release preconditions", asker: askPolicy },
|
|
1530
|
+
{ label: "worker model", asker: askWorkerModel },
|
|
1531
|
+
{ label: "escalation & triage", asker: askEscalation },
|
|
1532
|
+
{ label: "reporting", asker: askReporting },
|
|
1533
|
+
{ label: "orchestrator brief", asker: askBrief },
|
|
1534
|
+
];
|
|
1535
|
+
|
|
1482
1536
|
/** The two ways to answer the first question a configured project gets. Labels,
|
|
1483
1537
|
* because the harness's select resolves to the label it displayed. */
|
|
1484
1538
|
const AMEND_ONE = "Change one area";
|
|
@@ -1487,6 +1541,31 @@ const REINTERVIEW = "Walk every question again";
|
|
|
1487
1541
|
* brand-new neighbour rather than amending the one that is already there. */
|
|
1488
1542
|
const ADD_PROJECT = "Add another project";
|
|
1489
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* The consent gate's four choices, in the order the contract names them: the
|
|
1546
|
+
* old yes/no "apply?" is now a menu whose decline loops back instead of
|
|
1547
|
+
* dropping the interview (#417). The plan is recomputed from the answers
|
|
1548
|
+
* (including the predictive queue preview) before every row is offered again.
|
|
1549
|
+
*/
|
|
1550
|
+
const REVIEW_APPLY = "Apply";
|
|
1551
|
+
const REVIEW_EDIT = "Edit an area";
|
|
1552
|
+
const REVIEW_REVIEW = "Review the plan";
|
|
1553
|
+
const REVIEW_EXIT = "Exit";
|
|
1554
|
+
/** A bare Enter lands on Review — the one row with no effect, so neither
|
|
1555
|
+
* applying nor discarding can happen by accident. */
|
|
1556
|
+
const REVIEW_CHOICES: { label: string; description: string }[] = [
|
|
1557
|
+
{
|
|
1558
|
+
label: REVIEW_APPLY,
|
|
1559
|
+
description: "run the apply sequence once: labels, config, briefs, staged host files, paused-daemon smoke",
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
label: REVIEW_EDIT,
|
|
1563
|
+
description: "re-ask one interview area; the plan re-derives from the new answers",
|
|
1564
|
+
},
|
|
1565
|
+
{ label: REVIEW_REVIEW, description: "show the current plan again — nothing applies" },
|
|
1566
|
+
{ label: REVIEW_EXIT, description: "discard these answers; nothing is written or changed" },
|
|
1567
|
+
];
|
|
1568
|
+
|
|
1490
1569
|
/** Outcome of the first chooser on a re-run. */
|
|
1491
1570
|
type AmendChoice =
|
|
1492
1571
|
| { kind: "area"; area: AmendAreaId }
|
|
@@ -1594,19 +1673,14 @@ async function collectAnswers(
|
|
|
1594
1673
|
mirrorRoot: scoped.mirrorRoot,
|
|
1595
1674
|
};
|
|
1596
1675
|
}
|
|
1597
|
-
|
|
1598
|
-
//
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
a = await askEscalation(ui, a, probes);
|
|
1606
|
-
a = await askReporting(ui, a, probes);
|
|
1607
|
-
// Asked last, and asked with the real path in the question — which needs the
|
|
1608
|
-
// rest of the answers to derive.
|
|
1609
|
-
return await askBrief(ui, a, probes);
|
|
1676
|
+
// Walk the ordered area list once; the marker's denominator is the list's
|
|
1677
|
+
// length, so an area a branch prunes drops its row and its slot together.
|
|
1678
|
+
const areas = INTERVIEW_AREAS;
|
|
1679
|
+
for (const [index, area] of areas.entries()) {
|
|
1680
|
+
ui.notify(`Area ${index + 1} of ${areas.length}: ${area.label}`, "info");
|
|
1681
|
+
a = await area.asker(ui, a, probes, discovered);
|
|
1682
|
+
}
|
|
1683
|
+
return a;
|
|
1610
1684
|
}
|
|
1611
1685
|
|
|
1612
1686
|
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
@@ -1639,6 +1713,24 @@ export interface CollectedSetup {
|
|
|
1639
1713
|
added?: boolean;
|
|
1640
1714
|
}
|
|
1641
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* The consent gate's re-derivable view of the *current* answers: everything the
|
|
1718
|
+
* plan shows and the apply tail writes. The review loop rebuilds it after every
|
|
1719
|
+
* edit — config, project, runtime plan, caps, and the predictive queue preview
|
|
1720
|
+
* all re-derive from the answers, so the operator can only approve a diff that
|
|
1721
|
+
* matches them (#417).
|
|
1722
|
+
*/
|
|
1723
|
+
interface DerivedPlan {
|
|
1724
|
+
nextConfig: ConductorConfig;
|
|
1725
|
+
project: ProjectConfig;
|
|
1726
|
+
runtime: HostRuntimePlan;
|
|
1727
|
+
/** Re-derived from the *cached* label listing: one `gh` read per run. */
|
|
1728
|
+
labels: LabelPlan[];
|
|
1729
|
+
toCreate: string[];
|
|
1730
|
+
totalWorkers: number;
|
|
1731
|
+
overcommit: string | undefined;
|
|
1732
|
+
queuePreview: string[];
|
|
1733
|
+
}
|
|
1642
1734
|
|
|
1643
1735
|
export async function ensureSetupArm(
|
|
1644
1736
|
projectName: string,
|
|
@@ -1895,7 +1987,10 @@ export async function setup(
|
|
|
1895
1987
|
ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
1896
1988
|
return;
|
|
1897
1989
|
}
|
|
1898
|
-
|
|
1990
|
+
// `answers` is mutable below: the review loop re-asks one area at a time and
|
|
1991
|
+
// replaces only the fields that area owns, so everything else carries across.
|
|
1992
|
+
const { amend, added } = collected;
|
|
1993
|
+
let answers = collected.answers;
|
|
1899
1994
|
|
|
1900
1995
|
const scopes = await apply.scopes();
|
|
1901
1996
|
if (!scopes.ok) {
|
|
@@ -1911,127 +2006,240 @@ export async function setup(
|
|
|
1911
2006
|
// consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those
|
|
1912
2007
|
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1913
2008
|
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1914
|
-
|
|
1915
|
-
|
|
2009
|
+
// Everything below the interview is *derived* from `answers`, so an edit in
|
|
2010
|
+
// the review loop re-derives it all — including the predictive queue preview,
|
|
2011
|
+
// which is part of the diff the operator approves (#417). The one-time reads
|
|
2012
|
+
// (scope check, label listing, Telegram state, the prose draft) run once and
|
|
2013
|
+
// are cached: a decline followed by a dozen edits must not re-probe GitHub or
|
|
2014
|
+
// the daemon on every pass.
|
|
1916
2015
|
const telegram = detectTelegram();
|
|
1917
|
-
let
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
`Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
|
|
1923
|
-
"error",
|
|
1924
|
-
);
|
|
1925
|
-
return;
|
|
1926
|
-
}
|
|
1927
|
-
const project = findProject(nextConfig, answers.projectName);
|
|
1928
|
-
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
1929
|
-
const overcommit = workerOvercommit(totalWorkers);
|
|
1930
|
-
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
1931
|
-
if (
|
|
1932
|
-
project.escalation.orchestrator === "external" &&
|
|
1933
|
-
!answers.writeOrchestratorBrief &&
|
|
1934
|
-
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
1935
|
-
) {
|
|
1936
|
-
ui.notify(
|
|
1937
|
-
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
1938
|
-
`Run setup again and approve the brief write.`,
|
|
1939
|
-
"error",
|
|
1940
|
-
);
|
|
1941
|
-
return;
|
|
2016
|
+
let prose: ProbedProse = {};
|
|
2017
|
+
let proseDrafted = false;
|
|
2018
|
+
if (answers.writeOrchestratorBrief) {
|
|
2019
|
+
prose = await probes.prose(ui, answers);
|
|
2020
|
+
proseDrafted = true;
|
|
1942
2021
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
2022
|
+
// The single label listing, cached for the whole review loop. The plan
|
|
2023
|
+
// re-derives existence against this set after an edit, so a new queue label
|
|
2024
|
+
// or routing key is planned on the same tracker read the first pass used —
|
|
2025
|
+
// a label that in fact already exists is skipped idempotently at apply.
|
|
2026
|
+
const labelListing = await apply.labels(answers.trackerRepo, answers);
|
|
2027
|
+
const knownLabelNames = new Set(labelListing.filter((l) => l.exists).map((l) => l.name));
|
|
2028
|
+
|
|
2029
|
+
/** Re-builds everything the plan shows and apply writes from the answers as
|
|
2030
|
+
* they stand right now. Returns `undefined` when a guard aborted (the error
|
|
2031
|
+
* is already shown) — either way, nothing has been written. */
|
|
2032
|
+
const derive = async (): Promise<DerivedPlan | undefined> => {
|
|
2033
|
+
let nextConfig: ConductorConfig;
|
|
2034
|
+
try {
|
|
2035
|
+
nextConfig = buildConfig(answers, existing);
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
ui.notify(
|
|
2038
|
+
`Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
|
|
2039
|
+
"error",
|
|
2040
|
+
);
|
|
2041
|
+
return undefined;
|
|
2042
|
+
}
|
|
2043
|
+
const project = findProject(nextConfig, answers.projectName);
|
|
2044
|
+
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
2045
|
+
const overcommit = workerOvercommit(totalWorkers);
|
|
2046
|
+
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
2047
|
+
if (
|
|
2048
|
+
project.escalation.orchestrator === "external" &&
|
|
2049
|
+
!answers.writeOrchestratorBrief &&
|
|
2050
|
+
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
2051
|
+
) {
|
|
2052
|
+
ui.notify(
|
|
2053
|
+
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
2054
|
+
`Run setup again and approve the brief write.`,
|
|
2055
|
+
"error",
|
|
2056
|
+
);
|
|
2057
|
+
return undefined;
|
|
2058
|
+
}
|
|
2059
|
+
const runtime = planHostRuntime(
|
|
2060
|
+
project,
|
|
2061
|
+
resolveCaps(project, nextConfig.defaults),
|
|
2062
|
+
telegram.stateDir,
|
|
2063
|
+
undefined,
|
|
2064
|
+
totalWorkers,
|
|
1958
2065
|
);
|
|
1959
|
-
|
|
1960
|
-
|
|
2066
|
+
let queuePreview: string[];
|
|
2067
|
+
try {
|
|
2068
|
+
// The predictive diff: the same routing code the loop runs, re-run on
|
|
2069
|
+
// every pass back so the dry run can only ever describe the answers it
|
|
2070
|
+
// was just asked about (#417).
|
|
2071
|
+
queuePreview = formatPreview(await apply.preview(project));
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2074
|
+
ui.notify(
|
|
2075
|
+
`Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
|
|
2076
|
+
"error",
|
|
2077
|
+
);
|
|
2078
|
+
return undefined;
|
|
2079
|
+
}
|
|
2080
|
+
const labels = planAgainstLabels(wantedLabels(answers), knownLabelNames);
|
|
2081
|
+
return {
|
|
2082
|
+
nextConfig,
|
|
2083
|
+
project,
|
|
2084
|
+
runtime,
|
|
2085
|
+
labels,
|
|
2086
|
+
toCreate: labels.filter((l) => !l.exists).map((l) => l.name),
|
|
2087
|
+
totalWorkers,
|
|
2088
|
+
overcommit,
|
|
2089
|
+
queuePreview,
|
|
2090
|
+
};
|
|
2091
|
+
};
|
|
1961
2092
|
|
|
1962
|
-
|
|
2093
|
+
/** The plan screen, exactly as the consent gate shows it after every re-derive:
|
|
2094
|
+
* the delta first when there is one, then the whole plan — the confirm has to
|
|
2095
|
+
* name every mutation it makes, and a delta names none of them. */
|
|
2096
|
+
const planBlock = (d: DerivedPlan): string =>
|
|
1963
2097
|
[
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
|
|
1967
|
-
summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
|
|
2098
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, d.nextConfig.defaults)]),
|
|
2099
|
+
summarisePlan(answers, scopes, d.labels, telegram, d.nextConfig.defaults),
|
|
1968
2100
|
"",
|
|
1969
|
-
formatHostRuntimePlan(runtime),
|
|
2101
|
+
formatHostRuntimePlan(d.runtime),
|
|
1970
2102
|
"",
|
|
1971
|
-
...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
|
|
2103
|
+
...(d.overcommit === undefined ? [] : [`WARNING: ${d.overcommit}`, ""]),
|
|
1972
2104
|
"Dry run against the PROPOSED config:",
|
|
1973
|
-
...queuePreview,
|
|
2105
|
+
...d.queuePreview,
|
|
1974
2106
|
"",
|
|
1975
2107
|
"Nothing has been changed yet.",
|
|
1976
|
-
].join("\n")
|
|
1977
|
-
"info",
|
|
1978
|
-
);
|
|
2108
|
+
].join("\n");
|
|
1979
2109
|
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
// — do not apply — so it returns through the existing "left untouched" path
|
|
1984
|
-
// instead of raising a `Cancelled` nothing outside `collectSetup` catches.
|
|
1985
|
-
const go = await ui.confirm(
|
|
1986
|
-
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
2110
|
+
/** The consent recap, unchanged from the confirm it replaces: picking "Apply"
|
|
2111
|
+
* is an agreement to exactly the mutations named here. */
|
|
2112
|
+
const consentRecap = (d: DerivedPlan): string =>
|
|
1987
2113
|
[
|
|
1988
|
-
toCreate.length > 0
|
|
1989
|
-
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|
|
2114
|
+
d.toCreate.length > 0
|
|
2115
|
+
? `Creates ${d.toCreate.length} label(s) in ${answers.trackerRepo}: ${d.toCreate.join(", ")}.`
|
|
1990
2116
|
: "Creates no labels.",
|
|
1991
2117
|
`Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
|
|
1992
|
-
runtime.service.action === "keep"
|
|
1993
|
-
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
1994
|
-
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
1995
|
-
runtime.herdrUnit === undefined
|
|
2118
|
+
d.runtime.service.action === "keep"
|
|
2119
|
+
? `Keeps the staged systemd unit at ${d.runtime.service.path}.`
|
|
2120
|
+
: `${d.runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${d.runtime.service.path}.`,
|
|
2121
|
+
d.runtime.herdrUnit === undefined
|
|
2122
|
+
? ""
|
|
2123
|
+
: d.runtime.herdrUnit.action === "keep"
|
|
2124
|
+
? `Keeps the staged Herdr session unit at ${d.runtime.herdrUnit.path}.`
|
|
2125
|
+
: `${d.runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${d.runtime.herdrUnit.path} (account's login shell for panes).`,
|
|
2126
|
+
d.runtime.tick === undefined
|
|
1996
2127
|
? ""
|
|
1997
|
-
: runtime.
|
|
1998
|
-
? `Keeps the
|
|
1999
|
-
: `${runtime.
|
|
2000
|
-
runtime.
|
|
2128
|
+
: d.runtime.tick.action === "keep"
|
|
2129
|
+
? `Keeps the external heartbeat config at ${d.runtime.tick.path}.`
|
|
2130
|
+
: `${d.runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${d.runtime.tick.path}.`,
|
|
2131
|
+
d.runtime.briefLink === undefined
|
|
2001
2132
|
? ""
|
|
2002
|
-
: runtime.
|
|
2003
|
-
? `
|
|
2004
|
-
:
|
|
2005
|
-
|
|
2006
|
-
|
|
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}.`,
|
|
2133
|
+
: d.runtime.briefLink.action === "skip"
|
|
2134
|
+
? `Leaves the existing file at ${d.runtime.briefLink.path} alone (an operator's file, not overwritten).`
|
|
2135
|
+
: d.runtime.briefLink.action === "keep"
|
|
2136
|
+
? `Keeps the brief link at ${d.runtime.briefLink.path}.`
|
|
2137
|
+
: `${d.runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${d.runtime.briefLink.path} -> ${d.runtime.briefLink.target}.`,
|
|
2010
2138
|
answers.writeOrchestratorBrief
|
|
2011
2139
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
2012
2140
|
: "",
|
|
2013
|
-
project.escalation.orchestrator === "external"
|
|
2141
|
+
d.project.escalation.orchestrator === "external"
|
|
2014
2142
|
? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
|
|
2015
2143
|
: "Dispatch resumes after the smoke succeeds.",
|
|
2016
2144
|
"Issues are only claimed after every setup gate succeeds.",
|
|
2017
2145
|
]
|
|
2018
2146
|
.filter((s) => s.length > 0)
|
|
2019
|
-
.join(" ")
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2147
|
+
.join(" ");
|
|
2148
|
+
|
|
2149
|
+
let plan = await derive();
|
|
2150
|
+
if (plan === undefined) return;
|
|
2151
|
+
ui.notify(planBlock(plan), "info");
|
|
2152
|
+
// -------- the consent gate (#417): a review loop, not a yes/no --------
|
|
2153
|
+
//
|
|
2154
|
+
// Declining the old "Apply this setup?" confirm dropped every collected
|
|
2155
|
+
// answer on the floor. Now the decline is a loop: edit a named area (through
|
|
2156
|
+
// the same INTERVIEW_AREAS list the interview walks, so the menu's area names
|
|
2157
|
+
// cannot drift from the questions), re-review the resulting plan, or exit
|
|
2158
|
+
// explicitly — and every pass back re-derives the whole plan including the
|
|
2159
|
+
// predictive diff. Nothing is touched before the loop ends on "Apply", which
|
|
2160
|
+
// runs the one-writer sequence below exactly once. A dismissed menu (Ctrl-C /
|
|
2161
|
+
// EOF) is an explicit exit: the old confirm treated dismissal as decline, and
|
|
2162
|
+
// that is the path whose dropped answers must be seen to be dropped.
|
|
2163
|
+
for (;;) {
|
|
2164
|
+
ui.notify(consentRecap(plan), "info");
|
|
2165
|
+
const choice = await ui.select(
|
|
2166
|
+
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
2167
|
+
REVIEW_CHOICES,
|
|
2168
|
+
// Bare Enter lands on Review — the one row with no effect.
|
|
2169
|
+
{ initialIndex: 2 },
|
|
2170
|
+
);
|
|
2171
|
+
if (choice === undefined) {
|
|
2172
|
+
ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (choice === REVIEW_APPLY) break;
|
|
2176
|
+
if (choice === REVIEW_EXIT) {
|
|
2177
|
+
ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info");
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
if (choice === REVIEW_REVIEW) {
|
|
2181
|
+
ui.notify(planBlock(plan), "info");
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (choice !== REVIEW_EDIT) {
|
|
2185
|
+
// An option this wizard never offered; do not guess. Mirrors
|
|
2186
|
+
// chooseAmendArea's refusal: nothing was changed.
|
|
2187
|
+
ui.notify(`Unrecognised choice "${choice}" — nothing was changed.`, "warning");
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// Edit: re-enter through the interview's own area list, never a parallel
|
|
2192
|
+
// vocabulary — the review menu's names are the interview's names (#416/#417).
|
|
2193
|
+
const pickedArea = await ui.select(
|
|
2194
|
+
"Edit which area?",
|
|
2195
|
+
INTERVIEW_AREAS.map((area) => ({ label: area.label })),
|
|
2196
|
+
{ initialIndex: 0 },
|
|
2197
|
+
);
|
|
2198
|
+
if (pickedArea === undefined) {
|
|
2199
|
+
ui.notify("Editing cancelled — the answers stand as they were.", "info");
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
const area = INTERVIEW_AREAS.find((a) => a.label === pickedArea);
|
|
2203
|
+
if (area === undefined) {
|
|
2204
|
+
// Guessing an area here would ask the wrong questions and carry the rest
|
|
2205
|
+
// through as if it had been reviewed.
|
|
2206
|
+
ui.notify(`Unrecognised choice "${pickedArea}" — nothing was changed.`, "warning");
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
let edited: SetupAnswers;
|
|
2210
|
+
try {
|
|
2211
|
+
edited = await area.asker(ui, answers, probes);
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
if (!(err instanceof Cancelled)) throw err;
|
|
2214
|
+
ui.notify(`Editing "${area.label}" was cancelled — the answers stand as they were.`, "info");
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
answers = edited;
|
|
2218
|
+
// The prose draft is the one probe an edit can legitimately make necessary:
|
|
2219
|
+
// flipping the brief on after a first pass had it off (the interview ran
|
|
2220
|
+
// once, not per loop). Still one draft per run.
|
|
2221
|
+
if (answers.writeOrchestratorBrief && !proseDrafted) {
|
|
2222
|
+
prose = await probes.prose(ui, answers);
|
|
2223
|
+
proseDrafted = true;
|
|
2224
|
+
}
|
|
2225
|
+
const redrafted = await derive();
|
|
2226
|
+
if (redrafted === undefined) return;
|
|
2227
|
+
plan = redrafted;
|
|
2228
|
+
ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info");
|
|
2229
|
+
ui.notify(planBlock(plan), "info");
|
|
2024
2230
|
}
|
|
2025
2231
|
|
|
2026
2232
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
2027
|
-
// partially applied setup unable to claim work.
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2233
|
+
// partially applied setup unable to claim work. Everything below reads the
|
|
2234
|
+
// last re-derivation: the loop above can only break here on a final "Apply",
|
|
2235
|
+
// so this one-writer sequence runs exactly once per setup run.
|
|
2236
|
+
prepareConductor(plan.project.name);
|
|
2237
|
+
const created = await apply.createLabels(answers.trackerRepo, plan.labels);
|
|
2238
|
+
saveConfig(plan.nextConfig);
|
|
2031
2239
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
2032
|
-
const runtimeFiles = writeHostRuntime(runtime);
|
|
2240
|
+
const runtimeFiles = writeHostRuntime(plan.runtime);
|
|
2033
2241
|
for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
|
|
2034
|
-
const smoke = await apply.smoke(project.name);
|
|
2242
|
+
const smoke = await apply.smoke(plan.project.name);
|
|
2035
2243
|
let smokeLine =
|
|
2036
2244
|
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
2037
2245
|
`stored status for ${smoke.status.project}`;
|
|
@@ -2045,9 +2253,9 @@ export async function setup(
|
|
|
2045
2253
|
`Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
|
|
2046
2254
|
"Dispatch remains paused. Let those workers finish.",
|
|
2047
2255
|
"Then run `omp-conductor restart --now`.",
|
|
2048
|
-
project.escalation.orchestrator === "external"
|
|
2049
|
-
? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${project.name}\`.`
|
|
2050
|
-
: `Then run \`omp-conductor resume --project ${project.name}\`.`,
|
|
2256
|
+
plan.project.escalation.orchestrator === "external"
|
|
2257
|
+
? `Run \`omp-conductor arm --project ${plan.project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${plan.project.name}\`.`
|
|
2258
|
+
: `Then run \`omp-conductor resume --project ${plan.project.name}\`.`,
|
|
2051
2259
|
].join("\n"),
|
|
2052
2260
|
"warning",
|
|
2053
2261
|
);
|
|
@@ -2056,8 +2264,8 @@ export async function setup(
|
|
|
2056
2264
|
// the first project, so only the clone+seed are outstanding — and nothing
|
|
2057
2265
|
// above names them. Offer regardless of live workers: cloning seeds and
|
|
2058
2266
|
// enables the timer, which does not touch the running daemon.
|
|
2059
|
-
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2060
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2267
|
+
await offerCodeGraph(plan.project, ui, apply.graphInstall);
|
|
2268
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2061
2269
|
return;
|
|
2062
2270
|
}
|
|
2063
2271
|
// No live workers: still prefer an explicit reload when this run *added* a
|
|
@@ -2074,7 +2282,7 @@ export async function setup(
|
|
|
2074
2282
|
"info",
|
|
2075
2283
|
);
|
|
2076
2284
|
} else {
|
|
2077
|
-
const restarted = await apply.restart({ project: project.name });
|
|
2285
|
+
const restarted = await apply.restart({ project: plan.project.name });
|
|
2078
2286
|
restartVia = restarted.via;
|
|
2079
2287
|
smokeLine =
|
|
2080
2288
|
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
@@ -2083,25 +2291,25 @@ export async function setup(
|
|
|
2083
2291
|
}
|
|
2084
2292
|
|
|
2085
2293
|
let armLine = "embedded orchestrator — no heartbeat arm marker";
|
|
2086
|
-
if (project.escalation.orchestrator === "external") {
|
|
2294
|
+
if (plan.project.escalation.orchestrator === "external") {
|
|
2087
2295
|
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
2088
2296
|
try {
|
|
2089
|
-
armLine = await apply.arm(project.name);
|
|
2297
|
+
armLine = await apply.arm(plan.project.name);
|
|
2090
2298
|
} catch (err) {
|
|
2091
2299
|
ui.notify(
|
|
2092
2300
|
[
|
|
2093
2301
|
"Setup files passed the paused daemon smoke, but the fleet remains held.",
|
|
2094
2302
|
err instanceof Error ? err.message : String(err),
|
|
2095
|
-
`Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
|
|
2303
|
+
`Start the external orchestrator in ${plan.project.workspaceRoot}, then run \`omp-conductor arm --project ${plan.project.name}\`.`,
|
|
2096
2304
|
"After the arm proof succeeds, run `omp-conductor resume`.",
|
|
2097
2305
|
].join("\n"),
|
|
2098
2306
|
"warning",
|
|
2099
2307
|
);
|
|
2100
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2308
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2101
2309
|
return;
|
|
2102
2310
|
}
|
|
2103
2311
|
}
|
|
2104
|
-
apply.resume(project.name);
|
|
2312
|
+
apply.resume(plan.project.name);
|
|
2105
2313
|
|
|
2106
2314
|
ui.notify(
|
|
2107
2315
|
[
|
|
@@ -2132,32 +2340,37 @@ export async function setup(
|
|
|
2132
2340
|
// configured before the install was executed would have been told the unit
|
|
2133
2341
|
// matched and never offered the install, which is precisely the population that
|
|
2134
2342
|
// has never installed one.
|
|
2135
|
-
if (runtime.installedAction === "keep") {
|
|
2136
|
-
ui.notify(`The unit systemd reads (${runtime.installedPath}) already matches this config.`, "info");
|
|
2343
|
+
if (plan.runtime.installedAction === "keep") {
|
|
2344
|
+
ui.notify(`The unit systemd reads (${plan.runtime.installedPath}) already matches this config.`, "info");
|
|
2137
2345
|
} else if (platform() !== "linux") {
|
|
2138
2346
|
// No systemctl to run. The staged file is real and is what an operator copies
|
|
2139
2347
|
// to the box that will run it, so say where it is rather than offer an install
|
|
2140
2348
|
// that could only refuse.
|
|
2141
2349
|
ui.notify(
|
|
2142
|
-
`Staged the unit at ${runtime.service.path}. systemd install is Linux-only — copy it to the fleet host and run \`omp-conductor setup host\` there.`,
|
|
2350
|
+
`Staged the unit at ${plan.runtime.service.path}. systemd install is Linux-only — copy it to the fleet host and run \`omp-conductor setup host\` there.`,
|
|
2143
2351
|
"info",
|
|
2144
2352
|
);
|
|
2145
2353
|
} else {
|
|
2146
2354
|
const install = await ui.confirm(
|
|
2147
2355
|
"Install and start the supervised daemon now?",
|
|
2148
|
-
`${runtime.installedAction === "create" ? "Installs" : "Updates"} ${runtime.installedPath} from ` +
|
|
2149
|
-
`${runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
|
|
2356
|
+
`${plan.runtime.installedAction === "create" ? "Installs" : "Updates"} ${plan.runtime.installedPath} from ` +
|
|
2357
|
+
`${plan.runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
|
|
2150
2358
|
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
2151
2359
|
);
|
|
2152
2360
|
if (install === true)
|
|
2153
|
-
await apply.hostInstall(
|
|
2361
|
+
await apply.hostInstall(
|
|
2362
|
+
plan.project,
|
|
2363
|
+
resolveCaps(plan.project, plan.nextConfig.defaults),
|
|
2364
|
+
telegram.stateDir,
|
|
2365
|
+
ui,
|
|
2366
|
+
);
|
|
2154
2367
|
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
2155
2368
|
}
|
|
2156
2369
|
|
|
2157
|
-
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2370
|
+
await offerCodeGraph(plan.project, ui, apply.graphInstall);
|
|
2158
2371
|
|
|
2159
2372
|
// Always print: first install, amend, and add-a-project all need the operator
|
|
2160
2373
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|
|
2161
2374
|
// tick + config but cannot start a herdr agent (#319).
|
|
2162
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2375
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2163
2376
|
}
|