omp-conductor 0.15.12 → 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.
@@ -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,
@@ -1538,6 +1541,31 @@ const REINTERVIEW = "Walk every question again";
1538
1541
  * brand-new neighbour rather than amending the one that is already there. */
1539
1542
  const ADD_PROJECT = "Add another project";
1540
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
+
1541
1569
  /** Outcome of the first chooser on a re-run. */
1542
1570
  type AmendChoice =
1543
1571
  | { kind: "area"; area: AmendAreaId }
@@ -1685,6 +1713,24 @@ export interface CollectedSetup {
1685
1713
  added?: boolean;
1686
1714
  }
1687
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
+ }
1688
1734
 
1689
1735
  export async function ensureSetupArm(
1690
1736
  projectName: string,
@@ -1941,7 +1987,10 @@ export async function setup(
1941
1987
  ui.notify("Setup cancelled — nothing was changed.", "info");
1942
1988
  return;
1943
1989
  }
1944
- const { answers, amend, added } = collected;
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;
1945
1994
 
1946
1995
  const scopes = await apply.scopes();
1947
1996
  if (!scopes.ok) {
@@ -1957,129 +2006,240 @@ export async function setup(
1957
2006
  // consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those
1958
2007
  // files will contain. Each draft carries its own preview and confirm; declining
1959
2008
  // one keeps the shipped stub, so an operator who wants none reaches the same plan.
1960
- const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
1961
- const labels = await apply.labels(answers.trackerRepo, answers);
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.
1962
2015
  const telegram = detectTelegram();
1963
- let nextConfig;
1964
- try {
1965
- nextConfig = buildConfig(answers, existing);
1966
- } catch (err) {
1967
- ui.notify(
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;
2016
+ let prose: ProbedProse = {};
2017
+ let proseDrafted = false;
2018
+ if (answers.writeOrchestratorBrief) {
2019
+ prose = await probes.prose(ui, answers);
2020
+ proseDrafted = true;
1988
2021
  }
1989
- const runtime = planHostRuntime(
1990
- project,
1991
- resolveCaps(project, nextConfig.defaults),
1992
- telegram.stateDir,
1993
- undefined,
1994
- totalWorkers,
1995
- );
1996
- let queuePreview: string[];
1997
- try {
1998
- queuePreview = formatPreview(await apply.preview(project));
1999
- } catch (err) {
2000
- const message = err instanceof Error ? err.message : String(err);
2001
- ui.notify(
2002
- `Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
2003
- "error",
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,
2004
2065
  );
2005
- return;
2006
- }
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
+ };
2007
2092
 
2008
- ui.notify(
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 =>
2009
2097
  [
2010
- // The delta first when there is one, then the whole plan: the confirm has
2011
- // to name every mutation it authorises, and a delta names none of them.
2012
- ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
2013
- 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),
2014
2100
  "",
2015
- formatHostRuntimePlan(runtime),
2101
+ formatHostRuntimePlan(d.runtime),
2016
2102
  "",
2017
- ...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
2103
+ ...(d.overcommit === undefined ? [] : [`WARNING: ${d.overcommit}`, ""]),
2018
2104
  "Dry run against the PROPOSED config:",
2019
- ...queuePreview,
2105
+ ...d.queuePreview,
2020
2106
  "",
2021
2107
  "Nothing has been changed yet.",
2022
- ].join("\n"),
2023
- "info",
2024
- );
2108
+ ].join("\n");
2025
2109
 
2026
- const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
2027
- // Deliberately `ui.confirm` rather than `askYesNo`: this is the one confirm
2028
- // after the interview, and dismissing it means the same thing as answering no
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}?`,
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 =>
2033
2113
  [
2034
- toCreate.length > 0
2035
- ? `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(", ")}.`
2036
2116
  : "Creates no labels.",
2037
2117
  `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
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
2042
2122
  ? ""
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
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
2047
2127
  ? ""
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
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
2052
2132
  ? ""
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}.`,
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}.`,
2058
2138
  answers.writeOrchestratorBrief
2059
2139
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
2060
2140
  : "",
2061
- project.escalation.orchestrator === "external"
2141
+ d.project.escalation.orchestrator === "external"
2062
2142
  ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
2063
2143
  : "Dispatch resumes after the smoke succeeds.",
2064
2144
  "Issues are only claimed after every setup gate succeeds.",
2065
2145
  ]
2066
2146
  .filter((s) => s.length > 0)
2067
- .join(" "),
2068
- );
2069
- if (!go) {
2070
- ui.notify("Left untouched no labels created, no config written, nothing armed.", "info");
2071
- return;
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");
2072
2230
  }
2073
2231
 
2074
2232
  // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
2075
- // partially applied setup unable to claim work.
2076
- prepareConductor(project.name);
2077
- const created = await apply.createLabels(answers.trackerRepo, labels);
2078
- saveConfig(nextConfig);
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);
2079
2239
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
2080
- const runtimeFiles = writeHostRuntime(runtime);
2240
+ const runtimeFiles = writeHostRuntime(plan.runtime);
2081
2241
  for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
2082
- const smoke = await apply.smoke(project.name);
2242
+ const smoke = await apply.smoke(plan.project.name);
2083
2243
  let smokeLine =
2084
2244
  `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
2085
2245
  `stored status for ${smoke.status.project}`;
@@ -2093,9 +2253,9 @@ export async function setup(
2093
2253
  `Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
2094
2254
  "Dispatch remains paused. Let those workers finish.",
2095
2255
  "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}\`.`,
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}\`.`,
2099
2259
  ].join("\n"),
2100
2260
  "warning",
2101
2261
  );
@@ -2104,8 +2264,8 @@ export async function setup(
2104
2264
  // the first project, so only the clone+seed are outstanding — and nothing
2105
2265
  // above names them. Offer regardless of live workers: cloning seeds and
2106
2266
  // enables the timer, which does not touch the running daemon.
2107
- await offerCodeGraph(project, ui, apply.graphInstall);
2108
- ui.notify(formatHerdrHandoff(project, nextConfig), "info");
2267
+ await offerCodeGraph(plan.project, ui, apply.graphInstall);
2268
+ ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2109
2269
  return;
2110
2270
  }
2111
2271
  // No live workers: still prefer an explicit reload when this run *added* a
@@ -2122,7 +2282,7 @@ export async function setup(
2122
2282
  "info",
2123
2283
  );
2124
2284
  } else {
2125
- const restarted = await apply.restart({ project: project.name });
2285
+ const restarted = await apply.restart({ project: plan.project.name });
2126
2286
  restartVia = restarted.via;
2127
2287
  smokeLine =
2128
2288
  `existing /healthz and stored status; restarted through ${restarted.via}; ` +
@@ -2131,25 +2291,25 @@ export async function setup(
2131
2291
  }
2132
2292
 
2133
2293
  let armLine = "embedded orchestrator — no heartbeat arm marker";
2134
- if (project.escalation.orchestrator === "external") {
2294
+ if (plan.project.escalation.orchestrator === "external") {
2135
2295
  ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
2136
2296
  try {
2137
- armLine = await apply.arm(project.name);
2297
+ armLine = await apply.arm(plan.project.name);
2138
2298
  } catch (err) {
2139
2299
  ui.notify(
2140
2300
  [
2141
2301
  "Setup files passed the paused daemon smoke, but the fleet remains held.",
2142
2302
  err instanceof Error ? err.message : String(err),
2143
- `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}\`.`,
2144
2304
  "After the arm proof succeeds, run `omp-conductor resume`.",
2145
2305
  ].join("\n"),
2146
2306
  "warning",
2147
2307
  );
2148
- ui.notify(formatHerdrHandoff(project, nextConfig), "info");
2308
+ ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2149
2309
  return;
2150
2310
  }
2151
2311
  }
2152
- apply.resume(project.name);
2312
+ apply.resume(plan.project.name);
2153
2313
 
2154
2314
  ui.notify(
2155
2315
  [
@@ -2180,32 +2340,37 @@ export async function setup(
2180
2340
  // configured before the install was executed would have been told the unit
2181
2341
  // matched and never offered the install, which is precisely the population that
2182
2342
  // has never installed one.
2183
- if (runtime.installedAction === "keep") {
2184
- 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");
2185
2345
  } else if (platform() !== "linux") {
2186
2346
  // No systemctl to run. The staged file is real and is what an operator copies
2187
2347
  // to the box that will run it, so say where it is rather than offer an install
2188
2348
  // that could only refuse.
2189
2349
  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.`,
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.`,
2191
2351
  "info",
2192
2352
  );
2193
2353
  } else {
2194
2354
  const install = await ui.confirm(
2195
2355
  "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 ` +
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 ` +
2198
2358
  "command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
2199
2359
  );
2200
2360
  if (install === true)
2201
- await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
2361
+ await apply.hostInstall(
2362
+ plan.project,
2363
+ resolveCaps(plan.project, plan.nextConfig.defaults),
2364
+ telegram.stateDir,
2365
+ ui,
2366
+ );
2202
2367
  else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
2203
2368
  }
2204
2369
 
2205
- await offerCodeGraph(project, ui, apply.graphInstall);
2370
+ await offerCodeGraph(plan.project, ui, apply.graphInstall);
2206
2371
 
2207
2372
  // Always print: first install, amend, and add-a-project all need the operator
2208
2373
  // to create/verify the herdr pane. Added projects especially — the CLI wrote
2209
2374
  // tick + config but cannot start a herdr agent (#319).
2210
- ui.notify(formatHerdrHandoff(project, nextConfig), "info");
2375
+ ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2211
2376
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Provenance for stop/restart requests (#378): who asked, from which project,
3
+ * for which daemon, naming every affected project and its live-run count.
4
+ *
5
+ * Shared by the `stop` and `restart` verbs so a second convention cannot
6
+ * drift beside the first. Explicit facts only — caller pid/uid and session
7
+ * role, scope, control path, timestamp, reason — and specifically never raw
8
+ * argv, environment values or credentials: the incident this exists for was
9
+ * a stop nobody could attribute, and a "just serialise the command line"
10
+ * implementation would leak tokens into the audit trail.
11
+ */
12
+
13
+ import { loadConfig } from "./config.ts";
14
+ import { openStore, dbPath } from "./store.ts";
15
+ import { SESSION_ROLE_ENV, type DaemonStopDraft } from "./types.ts";
16
+
17
+ export interface StopProvenanceSpec {
18
+ /** The operator-visible control path: "cli stop", "cli restart", … */
19
+ controlPath: string;
20
+ /** Non-secret reason; a generated default is fine, never credentials. */
21
+ reason: string;
22
+ /** "global" when the request covered every project ("--all"); otherwise "project". */
23
+ scope: "global" | "project";
24
+ /** The originating project, when project-scoped. */
25
+ project?: string;
26
+ }
27
+
28
+ /**
29
+ * Build the provenance draft for one stop/restart request.
30
+ *
31
+ * `affected` is every configured project with its live-run count at request
32
+ * time, not just the targeted one: the shared daemon serves all of them, and
33
+ * a project-scoped stop still stops the siblings — the record names them
34
+ * instead of silently interrupting them.
35
+ *
36
+ * Pure: nothing is written. The command decides when to persist (at request
37
+ * entry for drain-style restarts whose signal may never arrive, and at the
38
+ * lifecycle chokepoint immediately before signalling for every mediated stop).
39
+ */
40
+ export function buildStopProvenance(spec: StopProvenanceSpec): DaemonStopDraft {
41
+ const cfg = loadConfig();
42
+ const store = openStore(dbPath());
43
+ try {
44
+ const affected = cfg.projects.map((project) => ({
45
+ project: project.name,
46
+ live: store.liveRuns(project.name).length,
47
+ }));
48
+ return {
49
+ controlPath: spec.controlPath,
50
+ callerPid: process.pid,
51
+ ...(typeof process.getuid === "function" ? { callerUid: process.getuid() } : {}),
52
+ // The daemon stamps SESSION_ROLE_ENV on every session it spawns, so a
53
+ // stop that a worker session's tools trigger names itself as a worker;
54
+ // everything else — the operator's shell, a systemd unit, a script — is
55
+ // the orchestrator surface (same convention as the reporting CLI).
56
+ role: process.env[SESSION_ROLE_ENV] === "worker" ? "worker" : "orchestrator",
57
+ scope: spec.scope,
58
+ ...(spec.project === undefined ? {} : { project: spec.project }),
59
+ affected,
60
+ reason: spec.reason,
61
+ unattributed: false,
62
+ };
63
+ } finally {
64
+ store.close();
65
+ }
66
+ }