omp-conductor 0.15.12 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +81 -6
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +93 -54
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +66 -34
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +485 -577
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +418 -8
- package/src/escalate.ts +122 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +55 -377
- package/src/gitops.ts +86 -1
- package/src/lifecycle.ts +113 -2
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +63 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +14 -1
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +314 -113
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +119 -0
- package/src/store.ts +533 -11
- package/src/types.ts +298 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +27 -8
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +52 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +183 -10
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,12 @@ import {
|
|
|
70
71
|
detectTelegram,
|
|
71
72
|
formatGates,
|
|
72
73
|
orchestratorBriefPath,
|
|
74
|
+
parseOmpSettingsYaml,
|
|
75
|
+
planAgainstLabels,
|
|
73
76
|
planLabels,
|
|
74
77
|
summariseAmend,
|
|
75
78
|
summarisePlan,
|
|
79
|
+
wantedLabels,
|
|
76
80
|
writeOrchestratorBrief,
|
|
77
81
|
type AmendAreaId,
|
|
78
82
|
type LabelPlan,
|
|
@@ -1201,6 +1205,37 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1201
1205
|
return next;
|
|
1202
1206
|
};
|
|
1203
1207
|
|
|
1208
|
+
/** The free-form omp settings overlay (#537): one YAML answer, omp's own
|
|
1209
|
+
* vocabulary rather than a list this package can offer. The seed is the
|
|
1210
|
+
* current map rendered as JSON — a YAML 1.2 subset — so a re-run that Enters
|
|
1211
|
+
* re-affirms it on one line; a blank answer clears the overlay; anything that
|
|
1212
|
+
* is not a YAML mapping re-asks, because an answer the dispatcher would
|
|
1213
|
+
* silently drop is exactly the input this wizard exists to catch. */
|
|
1214
|
+
const askOmpSettings: AreaAsker = async (ui, a) => {
|
|
1215
|
+
const next: SetupAnswers = { ...a };
|
|
1216
|
+
const seed = a.ompSettings === undefined ? "" : JSON.stringify(a.ompSettings);
|
|
1217
|
+
const answered = await askValid(
|
|
1218
|
+
ui,
|
|
1219
|
+
"Omp settings overlay for workers (YAML, blank = none — omp's schema, not conductor's)",
|
|
1220
|
+
seed,
|
|
1221
|
+
(value) => {
|
|
1222
|
+
if (value.trim().length === 0) return undefined;
|
|
1223
|
+
const parsed = parseOmpSettingsYaml(value);
|
|
1224
|
+
return parsed.ok ? undefined : parsed.problem;
|
|
1225
|
+
},
|
|
1226
|
+
);
|
|
1227
|
+
if (answered.trim().length === 0) {
|
|
1228
|
+
delete next.ompSettings;
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
const parsed = parseOmpSettingsYaml(answered);
|
|
1232
|
+
// askValid already proved the shape on this exact string; a parse failure
|
|
1233
|
+
// here is an internal invariant, never a silently dropped overlay.
|
|
1234
|
+
if (!parsed.ok) throw new Error(`unreachable: askValid accepted unparseable omp settings: ${parsed.problem}`);
|
|
1235
|
+
next.ompSettings = parsed.value;
|
|
1236
|
+
return next;
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1204
1239
|
/** The three ownership questions, then the mechanical gate one shape at a time:
|
|
1205
1240
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1206
1241
|
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
@@ -1489,6 +1524,7 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
1489
1524
|
// The two per-worker knobs the full interview separates with the authority
|
|
1490
1525
|
// grants; an amend has no reason to put anything between them.
|
|
1491
1526
|
caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
|
|
1527
|
+
"omp-settings": askOmpSettings,
|
|
1492
1528
|
"code-graph": askGraph,
|
|
1493
1529
|
authority: askAuthorityArea,
|
|
1494
1530
|
policy: askPolicy,
|
|
@@ -1525,6 +1561,9 @@ const INTERVIEW_AREAS: readonly { label: string; asker: AreaAsker }[] = [
|
|
|
1525
1561
|
{ label: "authority & release grants", asker: askAuthorityArea },
|
|
1526
1562
|
{ label: "merge & release preconditions", asker: askPolicy },
|
|
1527
1563
|
{ label: "worker model", asker: askWorkerModel },
|
|
1564
|
+
// The omp settings overlay rides next to the worker model: both are
|
|
1565
|
+
// per-worker runtime knobs, and neither is a ceiling the caps block owns.
|
|
1566
|
+
{ label: "omp settings overlay", asker: askOmpSettings },
|
|
1528
1567
|
{ label: "escalation & triage", asker: askEscalation },
|
|
1529
1568
|
{ label: "reporting", asker: askReporting },
|
|
1530
1569
|
{ label: "orchestrator brief", asker: askBrief },
|
|
@@ -1538,6 +1577,31 @@ const REINTERVIEW = "Walk every question again";
|
|
|
1538
1577
|
* brand-new neighbour rather than amending the one that is already there. */
|
|
1539
1578
|
const ADD_PROJECT = "Add another project";
|
|
1540
1579
|
|
|
1580
|
+
/**
|
|
1581
|
+
* The consent gate's four choices, in the order the contract names them: the
|
|
1582
|
+
* old yes/no "apply?" is now a menu whose decline loops back instead of
|
|
1583
|
+
* dropping the interview (#417). The plan is recomputed from the answers
|
|
1584
|
+
* (including the predictive queue preview) before every row is offered again.
|
|
1585
|
+
*/
|
|
1586
|
+
const REVIEW_APPLY = "Apply";
|
|
1587
|
+
const REVIEW_EDIT = "Edit an area";
|
|
1588
|
+
const REVIEW_REVIEW = "Review the plan";
|
|
1589
|
+
const REVIEW_EXIT = "Exit";
|
|
1590
|
+
/** A bare Enter lands on Review — the one row with no effect, so neither
|
|
1591
|
+
* applying nor discarding can happen by accident. */
|
|
1592
|
+
const REVIEW_CHOICES: { label: string; description: string }[] = [
|
|
1593
|
+
{
|
|
1594
|
+
label: REVIEW_APPLY,
|
|
1595
|
+
description: "run the apply sequence once: labels, config, briefs, staged host files, paused-daemon smoke",
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
label: REVIEW_EDIT,
|
|
1599
|
+
description: "re-ask one interview area; the plan re-derives from the new answers",
|
|
1600
|
+
},
|
|
1601
|
+
{ label: REVIEW_REVIEW, description: "show the current plan again — nothing applies" },
|
|
1602
|
+
{ label: REVIEW_EXIT, description: "discard these answers; nothing is written or changed" },
|
|
1603
|
+
];
|
|
1604
|
+
|
|
1541
1605
|
/** Outcome of the first chooser on a re-run. */
|
|
1542
1606
|
type AmendChoice =
|
|
1543
1607
|
| { kind: "area"; area: AmendAreaId }
|
|
@@ -1685,6 +1749,24 @@ export interface CollectedSetup {
|
|
|
1685
1749
|
added?: boolean;
|
|
1686
1750
|
}
|
|
1687
1751
|
|
|
1752
|
+
/**
|
|
1753
|
+
* The consent gate's re-derivable view of the *current* answers: everything the
|
|
1754
|
+
* plan shows and the apply tail writes. The review loop rebuilds it after every
|
|
1755
|
+
* edit — config, project, runtime plan, caps, and the predictive queue preview
|
|
1756
|
+
* all re-derive from the answers, so the operator can only approve a diff that
|
|
1757
|
+
* matches them (#417).
|
|
1758
|
+
*/
|
|
1759
|
+
interface DerivedPlan {
|
|
1760
|
+
nextConfig: ConductorConfig;
|
|
1761
|
+
project: ProjectConfig;
|
|
1762
|
+
runtime: HostRuntimePlan;
|
|
1763
|
+
/** Re-derived from the *cached* label listing: one `gh` read per run. */
|
|
1764
|
+
labels: LabelPlan[];
|
|
1765
|
+
toCreate: string[];
|
|
1766
|
+
totalWorkers: number;
|
|
1767
|
+
overcommit: string | undefined;
|
|
1768
|
+
queuePreview: string[];
|
|
1769
|
+
}
|
|
1688
1770
|
|
|
1689
1771
|
export async function ensureSetupArm(
|
|
1690
1772
|
projectName: string,
|
|
@@ -1941,7 +2023,10 @@ export async function setup(
|
|
|
1941
2023
|
ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
1942
2024
|
return;
|
|
1943
2025
|
}
|
|
1944
|
-
|
|
2026
|
+
// `answers` is mutable below: the review loop re-asks one area at a time and
|
|
2027
|
+
// replaces only the fields that area owns, so everything else carries across.
|
|
2028
|
+
const { amend, added } = collected;
|
|
2029
|
+
let answers = collected.answers;
|
|
1945
2030
|
|
|
1946
2031
|
const scopes = await apply.scopes();
|
|
1947
2032
|
if (!scopes.ok) {
|
|
@@ -1957,129 +2042,240 @@ export async function setup(
|
|
|
1957
2042
|
// consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those
|
|
1958
2043
|
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1959
2044
|
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1960
|
-
|
|
1961
|
-
|
|
2045
|
+
// Everything below the interview is *derived* from `answers`, so an edit in
|
|
2046
|
+
// the review loop re-derives it all — including the predictive queue preview,
|
|
2047
|
+
// which is part of the diff the operator approves (#417). The one-time reads
|
|
2048
|
+
// (scope check, label listing, Telegram state, the prose draft) run once and
|
|
2049
|
+
// are cached: a decline followed by a dozen edits must not re-probe GitHub or
|
|
2050
|
+
// the daemon on every pass.
|
|
1962
2051
|
const telegram = detectTelegram();
|
|
1963
|
-
let
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
`Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
|
|
1969
|
-
"error",
|
|
1970
|
-
);
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
const project = findProject(nextConfig, answers.projectName);
|
|
1974
|
-
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
1975
|
-
const overcommit = workerOvercommit(totalWorkers);
|
|
1976
|
-
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
1977
|
-
if (
|
|
1978
|
-
project.escalation.orchestrator === "external" &&
|
|
1979
|
-
!answers.writeOrchestratorBrief &&
|
|
1980
|
-
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
1981
|
-
) {
|
|
1982
|
-
ui.notify(
|
|
1983
|
-
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
1984
|
-
`Run setup again and approve the brief write.`,
|
|
1985
|
-
"error",
|
|
1986
|
-
);
|
|
1987
|
-
return;
|
|
2052
|
+
let prose: ProbedProse = {};
|
|
2053
|
+
let proseDrafted = false;
|
|
2054
|
+
if (answers.writeOrchestratorBrief) {
|
|
2055
|
+
prose = await probes.prose(ui, answers);
|
|
2056
|
+
proseDrafted = true;
|
|
1988
2057
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2058
|
+
// The single label listing, cached for the whole review loop. The plan
|
|
2059
|
+
// re-derives existence against this set after an edit, so a new queue label
|
|
2060
|
+
// or routing key is planned on the same tracker read the first pass used —
|
|
2061
|
+
// a label that in fact already exists is skipped idempotently at apply.
|
|
2062
|
+
const labelListing = await apply.labels(answers.trackerRepo, answers);
|
|
2063
|
+
const knownLabelNames = new Set(labelListing.filter((l) => l.exists).map((l) => l.name));
|
|
2064
|
+
|
|
2065
|
+
/** Re-builds everything the plan shows and apply writes from the answers as
|
|
2066
|
+
* they stand right now. Returns `undefined` when a guard aborted (the error
|
|
2067
|
+
* is already shown) — either way, nothing has been written. */
|
|
2068
|
+
const derive = async (): Promise<DerivedPlan | undefined> => {
|
|
2069
|
+
let nextConfig: ConductorConfig;
|
|
2070
|
+
try {
|
|
2071
|
+
nextConfig = buildConfig(answers, existing);
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
ui.notify(
|
|
2074
|
+
`Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
|
|
2075
|
+
"error",
|
|
2076
|
+
);
|
|
2077
|
+
return undefined;
|
|
2078
|
+
}
|
|
2079
|
+
const project = findProject(nextConfig, answers.projectName);
|
|
2080
|
+
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
2081
|
+
const overcommit = workerOvercommit(totalWorkers);
|
|
2082
|
+
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
2083
|
+
if (
|
|
2084
|
+
project.escalation.orchestrator === "external" &&
|
|
2085
|
+
!answers.writeOrchestratorBrief &&
|
|
2086
|
+
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
2087
|
+
) {
|
|
2088
|
+
ui.notify(
|
|
2089
|
+
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
2090
|
+
`Run setup again and approve the brief write.`,
|
|
2091
|
+
"error",
|
|
2092
|
+
);
|
|
2093
|
+
return undefined;
|
|
2094
|
+
}
|
|
2095
|
+
const runtime = planHostRuntime(
|
|
2096
|
+
project,
|
|
2097
|
+
resolveCaps(project, nextConfig.defaults),
|
|
2098
|
+
telegram.stateDir,
|
|
2099
|
+
undefined,
|
|
2100
|
+
totalWorkers,
|
|
2004
2101
|
);
|
|
2005
|
-
|
|
2006
|
-
|
|
2102
|
+
let queuePreview: string[];
|
|
2103
|
+
try {
|
|
2104
|
+
// The predictive diff: the same routing code the loop runs, re-run on
|
|
2105
|
+
// every pass back so the dry run can only ever describe the answers it
|
|
2106
|
+
// was just asked about (#417).
|
|
2107
|
+
queuePreview = formatPreview(await apply.preview(project));
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2110
|
+
ui.notify(
|
|
2111
|
+
`Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
|
|
2112
|
+
"error",
|
|
2113
|
+
);
|
|
2114
|
+
return undefined;
|
|
2115
|
+
}
|
|
2116
|
+
const labels = planAgainstLabels(wantedLabels(answers), knownLabelNames);
|
|
2117
|
+
return {
|
|
2118
|
+
nextConfig,
|
|
2119
|
+
project,
|
|
2120
|
+
runtime,
|
|
2121
|
+
labels,
|
|
2122
|
+
toCreate: labels.filter((l) => !l.exists).map((l) => l.name),
|
|
2123
|
+
totalWorkers,
|
|
2124
|
+
overcommit,
|
|
2125
|
+
queuePreview,
|
|
2126
|
+
};
|
|
2127
|
+
};
|
|
2007
2128
|
|
|
2008
|
-
|
|
2129
|
+
/** The plan screen, exactly as the consent gate shows it after every re-derive:
|
|
2130
|
+
* the delta first when there is one, then the whole plan — the confirm has to
|
|
2131
|
+
* name every mutation it makes, and a delta names none of them. */
|
|
2132
|
+
const planBlock = (d: DerivedPlan): string =>
|
|
2009
2133
|
[
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
|
|
2013
|
-
summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
|
|
2134
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, d.nextConfig.defaults)]),
|
|
2135
|
+
summarisePlan(answers, scopes, d.labels, telegram, d.nextConfig.defaults),
|
|
2014
2136
|
"",
|
|
2015
|
-
formatHostRuntimePlan(runtime),
|
|
2137
|
+
formatHostRuntimePlan(d.runtime),
|
|
2016
2138
|
"",
|
|
2017
|
-
...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
|
|
2139
|
+
...(d.overcommit === undefined ? [] : [`WARNING: ${d.overcommit}`, ""]),
|
|
2018
2140
|
"Dry run against the PROPOSED config:",
|
|
2019
|
-
...queuePreview,
|
|
2141
|
+
...d.queuePreview,
|
|
2020
2142
|
"",
|
|
2021
2143
|
"Nothing has been changed yet.",
|
|
2022
|
-
].join("\n")
|
|
2023
|
-
"info",
|
|
2024
|
-
);
|
|
2144
|
+
].join("\n");
|
|
2025
2145
|
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
// — do not apply — so it returns through the existing "left untouched" path
|
|
2030
|
-
// instead of raising a `Cancelled` nothing outside `collectSetup` catches.
|
|
2031
|
-
const go = await ui.confirm(
|
|
2032
|
-
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
2146
|
+
/** The consent recap, unchanged from the confirm it replaces: picking "Apply"
|
|
2147
|
+
* is an agreement to exactly the mutations named here. */
|
|
2148
|
+
const consentRecap = (d: DerivedPlan): string =>
|
|
2033
2149
|
[
|
|
2034
|
-
toCreate.length > 0
|
|
2035
|
-
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|
|
2150
|
+
d.toCreate.length > 0
|
|
2151
|
+
? `Creates ${d.toCreate.length} label(s) in ${answers.trackerRepo}: ${d.toCreate.join(", ")}.`
|
|
2036
2152
|
: "Creates no labels.",
|
|
2037
2153
|
`Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
|
|
2038
|
-
runtime.service.action === "keep"
|
|
2039
|
-
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
2040
|
-
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
2041
|
-
runtime.herdrUnit === undefined
|
|
2154
|
+
d.runtime.service.action === "keep"
|
|
2155
|
+
? `Keeps the staged systemd unit at ${d.runtime.service.path}.`
|
|
2156
|
+
: `${d.runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${d.runtime.service.path}.`,
|
|
2157
|
+
d.runtime.herdrUnit === undefined
|
|
2042
2158
|
? ""
|
|
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).`,
|
|
2046
|
-
runtime.tick === undefined
|
|
2159
|
+
: d.runtime.herdrUnit.action === "keep"
|
|
2160
|
+
? `Keeps the staged Herdr session unit at ${d.runtime.herdrUnit.path}.`
|
|
2161
|
+
: `${d.runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${d.runtime.herdrUnit.path} (account's login shell for panes).`,
|
|
2162
|
+
d.runtime.tick === undefined
|
|
2047
2163
|
? ""
|
|
2048
|
-
: runtime.tick.action === "keep"
|
|
2049
|
-
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
2050
|
-
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
2051
|
-
runtime.briefLink === undefined
|
|
2164
|
+
: d.runtime.tick.action === "keep"
|
|
2165
|
+
? `Keeps the external heartbeat config at ${d.runtime.tick.path}.`
|
|
2166
|
+
: `${d.runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${d.runtime.tick.path}.`,
|
|
2167
|
+
d.runtime.briefLink === undefined
|
|
2052
2168
|
? ""
|
|
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}.`,
|
|
2169
|
+
: d.runtime.briefLink.action === "skip"
|
|
2170
|
+
? `Leaves the existing file at ${d.runtime.briefLink.path} alone (an operator's file, not overwritten).`
|
|
2171
|
+
: d.runtime.briefLink.action === "keep"
|
|
2172
|
+
? `Keeps the brief link at ${d.runtime.briefLink.path}.`
|
|
2173
|
+
: `${d.runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${d.runtime.briefLink.path} -> ${d.runtime.briefLink.target}.`,
|
|
2058
2174
|
answers.writeOrchestratorBrief
|
|
2059
2175
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
2060
2176
|
: "",
|
|
2061
|
-
project.escalation.orchestrator === "external"
|
|
2177
|
+
d.project.escalation.orchestrator === "external"
|
|
2062
2178
|
? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
|
|
2063
2179
|
: "Dispatch resumes after the smoke succeeds.",
|
|
2064
2180
|
"Issues are only claimed after every setup gate succeeds.",
|
|
2065
2181
|
]
|
|
2066
2182
|
.filter((s) => s.length > 0)
|
|
2067
|
-
.join(" ")
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2183
|
+
.join(" ");
|
|
2184
|
+
|
|
2185
|
+
let plan = await derive();
|
|
2186
|
+
if (plan === undefined) return;
|
|
2187
|
+
ui.notify(planBlock(plan), "info");
|
|
2188
|
+
// -------- the consent gate (#417): a review loop, not a yes/no --------
|
|
2189
|
+
//
|
|
2190
|
+
// Declining the old "Apply this setup?" confirm dropped every collected
|
|
2191
|
+
// answer on the floor. Now the decline is a loop: edit a named area (through
|
|
2192
|
+
// the same INTERVIEW_AREAS list the interview walks, so the menu's area names
|
|
2193
|
+
// cannot drift from the questions), re-review the resulting plan, or exit
|
|
2194
|
+
// explicitly — and every pass back re-derives the whole plan including the
|
|
2195
|
+
// predictive diff. Nothing is touched before the loop ends on "Apply", which
|
|
2196
|
+
// runs the one-writer sequence below exactly once. A dismissed menu (Ctrl-C /
|
|
2197
|
+
// EOF) is an explicit exit: the old confirm treated dismissal as decline, and
|
|
2198
|
+
// that is the path whose dropped answers must be seen to be dropped.
|
|
2199
|
+
for (;;) {
|
|
2200
|
+
ui.notify(consentRecap(plan), "info");
|
|
2201
|
+
const choice = await ui.select(
|
|
2202
|
+
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
2203
|
+
REVIEW_CHOICES,
|
|
2204
|
+
// Bare Enter lands on Review — the one row with no effect.
|
|
2205
|
+
{ initialIndex: 2 },
|
|
2206
|
+
);
|
|
2207
|
+
if (choice === undefined) {
|
|
2208
|
+
ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
if (choice === REVIEW_APPLY) break;
|
|
2212
|
+
if (choice === REVIEW_EXIT) {
|
|
2213
|
+
ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info");
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (choice === REVIEW_REVIEW) {
|
|
2217
|
+
ui.notify(planBlock(plan), "info");
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
if (choice !== REVIEW_EDIT) {
|
|
2221
|
+
// An option this wizard never offered; do not guess. Mirrors
|
|
2222
|
+
// chooseAmendArea's refusal: nothing was changed.
|
|
2223
|
+
ui.notify(`Unrecognised choice "${choice}" — nothing was changed.`, "warning");
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// Edit: re-enter through the interview's own area list, never a parallel
|
|
2228
|
+
// vocabulary — the review menu's names are the interview's names (#416/#417).
|
|
2229
|
+
const pickedArea = await ui.select(
|
|
2230
|
+
"Edit which area?",
|
|
2231
|
+
INTERVIEW_AREAS.map((area) => ({ label: area.label })),
|
|
2232
|
+
{ initialIndex: 0 },
|
|
2233
|
+
);
|
|
2234
|
+
if (pickedArea === undefined) {
|
|
2235
|
+
ui.notify("Editing cancelled — the answers stand as they were.", "info");
|
|
2236
|
+
continue;
|
|
2237
|
+
}
|
|
2238
|
+
const area = INTERVIEW_AREAS.find((a) => a.label === pickedArea);
|
|
2239
|
+
if (area === undefined) {
|
|
2240
|
+
// Guessing an area here would ask the wrong questions and carry the rest
|
|
2241
|
+
// through as if it had been reviewed.
|
|
2242
|
+
ui.notify(`Unrecognised choice "${pickedArea}" — nothing was changed.`, "warning");
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
let edited: SetupAnswers;
|
|
2246
|
+
try {
|
|
2247
|
+
edited = await area.asker(ui, answers, probes);
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
if (!(err instanceof Cancelled)) throw err;
|
|
2250
|
+
ui.notify(`Editing "${area.label}" was cancelled — the answers stand as they were.`, "info");
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
answers = edited;
|
|
2254
|
+
// The prose draft is the one probe an edit can legitimately make necessary:
|
|
2255
|
+
// flipping the brief on after a first pass had it off (the interview ran
|
|
2256
|
+
// once, not per loop). Still one draft per run.
|
|
2257
|
+
if (answers.writeOrchestratorBrief && !proseDrafted) {
|
|
2258
|
+
prose = await probes.prose(ui, answers);
|
|
2259
|
+
proseDrafted = true;
|
|
2260
|
+
}
|
|
2261
|
+
const redrafted = await derive();
|
|
2262
|
+
if (redrafted === undefined) return;
|
|
2263
|
+
plan = redrafted;
|
|
2264
|
+
ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info");
|
|
2265
|
+
ui.notify(planBlock(plan), "info");
|
|
2072
2266
|
}
|
|
2073
2267
|
|
|
2074
2268
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
2075
|
-
// partially applied setup unable to claim work.
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2269
|
+
// partially applied setup unable to claim work. Everything below reads the
|
|
2270
|
+
// last re-derivation: the loop above can only break here on a final "Apply",
|
|
2271
|
+
// so this one-writer sequence runs exactly once per setup run.
|
|
2272
|
+
prepareConductor(plan.project.name);
|
|
2273
|
+
const created = await apply.createLabels(answers.trackerRepo, plan.labels);
|
|
2274
|
+
saveConfig(plan.nextConfig);
|
|
2079
2275
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
2080
|
-
const runtimeFiles = writeHostRuntime(runtime);
|
|
2276
|
+
const runtimeFiles = writeHostRuntime(plan.runtime);
|
|
2081
2277
|
for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
|
|
2082
|
-
const smoke = await apply.smoke(project.name);
|
|
2278
|
+
const smoke = await apply.smoke(plan.project.name);
|
|
2083
2279
|
let smokeLine =
|
|
2084
2280
|
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
2085
2281
|
`stored status for ${smoke.status.project}`;
|
|
@@ -2093,9 +2289,9 @@ export async function setup(
|
|
|
2093
2289
|
`Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
|
|
2094
2290
|
"Dispatch remains paused. Let those workers finish.",
|
|
2095
2291
|
"Then run `omp-conductor restart --now`.",
|
|
2096
|
-
project.escalation.orchestrator === "external"
|
|
2097
|
-
? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${project.name}\`.`
|
|
2098
|
-
: `Then run \`omp-conductor resume --project ${project.name}\`.`,
|
|
2292
|
+
plan.project.escalation.orchestrator === "external"
|
|
2293
|
+
? `Run \`omp-conductor arm --project ${plan.project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${plan.project.name}\`.`
|
|
2294
|
+
: `Then run \`omp-conductor resume --project ${plan.project.name}\`.`,
|
|
2099
2295
|
].join("\n"),
|
|
2100
2296
|
"warning",
|
|
2101
2297
|
);
|
|
@@ -2104,8 +2300,8 @@ export async function setup(
|
|
|
2104
2300
|
// the first project, so only the clone+seed are outstanding — and nothing
|
|
2105
2301
|
// above names them. Offer regardless of live workers: cloning seeds and
|
|
2106
2302
|
// enables the timer, which does not touch the running daemon.
|
|
2107
|
-
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2108
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2303
|
+
await offerCodeGraph(plan.project, ui, apply.graphInstall);
|
|
2304
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2109
2305
|
return;
|
|
2110
2306
|
}
|
|
2111
2307
|
// No live workers: still prefer an explicit reload when this run *added* a
|
|
@@ -2122,7 +2318,7 @@ export async function setup(
|
|
|
2122
2318
|
"info",
|
|
2123
2319
|
);
|
|
2124
2320
|
} else {
|
|
2125
|
-
const restarted = await apply.restart({ project: project.name });
|
|
2321
|
+
const restarted = await apply.restart({ project: plan.project.name });
|
|
2126
2322
|
restartVia = restarted.via;
|
|
2127
2323
|
smokeLine =
|
|
2128
2324
|
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
@@ -2131,25 +2327,25 @@ export async function setup(
|
|
|
2131
2327
|
}
|
|
2132
2328
|
|
|
2133
2329
|
let armLine = "embedded orchestrator — no heartbeat arm marker";
|
|
2134
|
-
if (project.escalation.orchestrator === "external") {
|
|
2330
|
+
if (plan.project.escalation.orchestrator === "external") {
|
|
2135
2331
|
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
2136
2332
|
try {
|
|
2137
|
-
armLine = await apply.arm(project.name);
|
|
2333
|
+
armLine = await apply.arm(plan.project.name);
|
|
2138
2334
|
} catch (err) {
|
|
2139
2335
|
ui.notify(
|
|
2140
2336
|
[
|
|
2141
2337
|
"Setup files passed the paused daemon smoke, but the fleet remains held.",
|
|
2142
2338
|
err instanceof Error ? err.message : String(err),
|
|
2143
|
-
`Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
|
|
2339
|
+
`Start the external orchestrator in ${plan.project.workspaceRoot}, then run \`omp-conductor arm --project ${plan.project.name}\`.`,
|
|
2144
2340
|
"After the arm proof succeeds, run `omp-conductor resume`.",
|
|
2145
2341
|
].join("\n"),
|
|
2146
2342
|
"warning",
|
|
2147
2343
|
);
|
|
2148
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2344
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2149
2345
|
return;
|
|
2150
2346
|
}
|
|
2151
2347
|
}
|
|
2152
|
-
apply.resume(project.name);
|
|
2348
|
+
apply.resume(plan.project.name);
|
|
2153
2349
|
|
|
2154
2350
|
ui.notify(
|
|
2155
2351
|
[
|
|
@@ -2180,32 +2376,37 @@ export async function setup(
|
|
|
2180
2376
|
// configured before the install was executed would have been told the unit
|
|
2181
2377
|
// matched and never offered the install, which is precisely the population that
|
|
2182
2378
|
// has never installed one.
|
|
2183
|
-
if (runtime.installedAction === "keep") {
|
|
2184
|
-
ui.notify(`The unit systemd reads (${runtime.installedPath}) already matches this config.`, "info");
|
|
2379
|
+
if (plan.runtime.installedAction === "keep") {
|
|
2380
|
+
ui.notify(`The unit systemd reads (${plan.runtime.installedPath}) already matches this config.`, "info");
|
|
2185
2381
|
} else if (platform() !== "linux") {
|
|
2186
2382
|
// No systemctl to run. The staged file is real and is what an operator copies
|
|
2187
2383
|
// to the box that will run it, so say where it is rather than offer an install
|
|
2188
2384
|
// that could only refuse.
|
|
2189
2385
|
ui.notify(
|
|
2190
|
-
`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.`,
|
|
2386
|
+
`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.`,
|
|
2191
2387
|
"info",
|
|
2192
2388
|
);
|
|
2193
2389
|
} else {
|
|
2194
2390
|
const install = await ui.confirm(
|
|
2195
2391
|
"Install and start the supervised daemon now?",
|
|
2196
|
-
`${runtime.installedAction === "create" ? "Installs" : "Updates"} ${runtime.installedPath} from ` +
|
|
2197
|
-
`${runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
|
|
2392
|
+
`${plan.runtime.installedAction === "create" ? "Installs" : "Updates"} ${plan.runtime.installedPath} from ` +
|
|
2393
|
+
`${plan.runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
|
|
2198
2394
|
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
2199
2395
|
);
|
|
2200
2396
|
if (install === true)
|
|
2201
|
-
await apply.hostInstall(
|
|
2397
|
+
await apply.hostInstall(
|
|
2398
|
+
plan.project,
|
|
2399
|
+
resolveCaps(plan.project, plan.nextConfig.defaults),
|
|
2400
|
+
telegram.stateDir,
|
|
2401
|
+
ui,
|
|
2402
|
+
);
|
|
2202
2403
|
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
2203
2404
|
}
|
|
2204
2405
|
|
|
2205
|
-
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2406
|
+
await offerCodeGraph(plan.project, ui, apply.graphInstall);
|
|
2206
2407
|
|
|
2207
2408
|
// Always print: first install, amend, and add-a-project all need the operator
|
|
2208
2409
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|
|
2209
2410
|
// tick + config but cannot start a herdr agent (#319).
|
|
2210
|
-
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
2411
|
+
ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
|
|
2211
2412
|
}
|