omp-conductor 0.19.4 → 0.19.6

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.
@@ -42,7 +42,7 @@ import { randomUUID } from "node:crypto";
42
42
  import { createServer, type Server, type Socket } from "node:net";
43
43
  import { join } from "node:path";
44
44
 
45
- import { resolvePolicy, resolveReleaseGrants, resolveReview } from "../config.ts";
45
+ import { resolvePolicy, resolveReleaseGrants, resolveReview, resolveSharedInstallAuthority } from "../config.ts";
46
46
  import { wakeDispatch } from "../wake.ts";
47
47
  // The mediated release is the drain's closing gesture (#791): a successful
48
48
  // release ends this project's bounded release window, so the privileged half
@@ -60,6 +60,7 @@ import {
60
60
  import { releaseRefusal } from "../release-policy.ts";
61
61
  import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
62
62
  import { LIVE_STATES } from "../store.ts";
63
+ import { DENIED_RELEASE_GRANTS } from "../types.ts";
63
64
  import type {
64
65
  FileLane,
65
66
  IssueComment,
@@ -71,6 +72,7 @@ import type {
71
72
  ReleaseRequirement,
72
73
  ReleaseShape,
73
74
  RepoTarget,
75
+ ResolvedGrants,
74
76
  ReviewReason,
75
77
  RunRecord,
76
78
  RunState,
@@ -172,6 +174,9 @@ export interface ReleaseExecution {
172
174
  export interface InstallExecution {
173
175
  /** The published semver to pin all three surfaces to, exactly as npm has it. */
174
176
  version: string;
177
+ /** Shared host authority and scope, carried into the detached report. */
178
+ holder: ResolvedGrants["install"];
179
+ projects: readonly string[];
175
180
  }
176
181
 
177
182
  /**
@@ -222,6 +227,11 @@ export interface VerbDeps {
222
227
  * unreadable, and cannot pick up an operator's edit either.
223
228
  */
224
229
  project: () => ProjectConfig;
230
+ /**
231
+ * Every project served by the shared daemon, re-read from the same config.
232
+ * Host-global mutations use this set rather than the calling socket's project.
233
+ */
234
+ projects: () => readonly ProjectConfig[];
225
235
  store: Store;
226
236
  tracker: Tracker;
227
237
  actions: VerbActions;
@@ -632,7 +642,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
632
642
  case "conductor_release":
633
643
  return releaseVerb(deps, project, channel, args, refuse, allow);
634
644
  case "conductor_install":
635
- return installVerb(deps, project, channel, args, refuse, allow);
645
+ return installVerb(deps, channel, args, refuse, allow);
636
646
  case "conductor_pr_review":
637
647
  return prReviewVerb(deps, project, channel, args, refuse, allow);
638
648
  case "conductor_pr_review_clear":
@@ -1660,31 +1670,50 @@ async function releaseVerb(
1660
1670
 
1661
1671
 
1662
1672
  /**
1663
- * The fleet-installs-itself request (#486).
1664
- *
1665
- * Gated like a release act: the caller must be an orchestrator (spec), and the
1666
- * `install` shape must be granted to the orchestrator (release policy) — the
1667
- * package floor's "nobody patches the running conductor" is the deny default
1668
- * an operator deliberately moves, never something this verb works around.
1669
- *
1670
- * Everything past the gate is the privileged half's job: `actions.install`
1671
- * refussses a version npm does not expose and starts the detached transient
1672
- * unit. This verb records the request durably (the journal the post-restart
1673
- * tick verifies against) and tells the caller where the evidence will land.
1673
+ * The install is host-global: one package tree and daemon serve every project.
1674
+ * Authority is therefore the consensus holder across the configured project
1675
+ * set, never whichever project happened to invoke the verb (#1018). Mixed
1676
+ * holders refuse before version lookup or transient-unit creation.
1674
1677
  */
1675
1678
  async function installVerb(
1676
1679
  deps: VerbDeps,
1677
- project: ProjectConfig,
1678
1680
  channel: VerbChannel,
1679
1681
  args: Record<string, unknown>,
1680
1682
  refuse: Refuse,
1681
1683
  allow: Allow,
1682
1684
  ): Promise<Verdict> {
1683
- const grants = resolveReleaseGrants(project);
1684
- const granted = releaseRefusal(grants, channel.role, "install");
1685
+ let projects: readonly ProjectConfig[];
1686
+ try {
1687
+ projects = deps.projects();
1688
+ } catch (err) {
1689
+ const why = err instanceof Error ? err.message : String(err);
1690
+ return refuse("config-unreadable", `refused: shared install authority could not be read (${why}).`);
1691
+ }
1692
+ if (projects.length === 0) {
1693
+ return refuse("config-unreadable", "refused: shared install authority has no configured projects.");
1694
+ }
1695
+ const authority = resolveSharedInstallAuthority(projects);
1696
+ const holderDetail = authority.entries.map((entry) => `${entry.project}=${entry.holder}`).join(", ");
1697
+ if (authority.holder === undefined) {
1698
+ return refuse(
1699
+ "release-not-granted",
1700
+ `refused: conductor_install is host-global, but configured install holders conflict (${holderDetail}). ` +
1701
+ "No dispatch pause or transient install unit was created.",
1702
+ );
1703
+ }
1704
+ const globalHolder = authority.holder;
1705
+ const granted = releaseRefusal(
1706
+ { ...DENIED_RELEASE_GRANTS, install: globalHolder },
1707
+ channel.role,
1708
+ "install",
1709
+ );
1685
1710
  if (granted !== undefined) {
1686
- return refuse("release-not-granted", `refused: ${granted.reason}`);
1711
+ return refuse(
1712
+ "release-not-granted",
1713
+ `refused: ${granted.reason} Shared-host holders: ${holderDetail}.`,
1714
+ );
1687
1715
  }
1716
+ const affectedProjects = authority.entries.map((entry) => entry.project);
1688
1717
 
1689
1718
  const version = args["version"];
1690
1719
  if (typeof version !== "string" || version.trim().length === 0) {
@@ -1695,16 +1724,21 @@ async function installVerb(
1695
1724
  );
1696
1725
  }
1697
1726
 
1698
- const outcome = await deps.actions.install({ version: version.trim() });
1727
+ const outcome = await deps.actions.install({
1728
+ version: version.trim(),
1729
+ holder: globalHolder,
1730
+ projects: affectedProjects,
1731
+ });
1699
1732
  if (!outcome.ok) {
1700
1733
  return refuse("action-failed", `refused: the install request failed:\n${outcome.stderr}`);
1701
1734
  }
1702
1735
  const unit = outcome.detail;
1703
1736
  return allow(
1704
- `install requested: omp-conductor@${version} will be installed by a detached unit` +
1737
+ `install requested by host-global holder ${globalHolder}: omp-conductor@${version} will be installed ` +
1738
+ `for projects ${affectedProjects.join(", ")} by a detached unit` +
1705
1739
  `${unit === undefined ? "" : ` (${unit})`} that survives the restart. ` +
1706
1740
  "The first tick after the fleet restarts verifies version, /healthz, ticks, pane and doctor, " +
1707
- "and the outcome is reported through the durable outbox.",
1741
+ "and the outcome is reported through the durable outbox.",
1708
1742
  );
1709
1743
  }
1710
1744
 
@@ -61,6 +61,7 @@ PROG=omp-conductor-recover
61
61
  SYSTEMCTL=${SYSTEMCTL_BIN:-systemctl}
62
62
  JOURNALCTL=${JOURNALCTL_BIN:-journalctl}
63
63
  CONDUCTOR=${OMP_CONDUCTOR_BIN:-omp-conductor}
64
+ BUN=${BUN_BIN:-bun}
64
65
 
65
66
  # The fleet's conductor home: where config.json, the sqlite store and the
66
67
  # upgrade's durable snapshots live. The unit renders OMP_CONDUCTOR_HOME and
@@ -84,6 +85,12 @@ JOURNAL_LINES=${RECOVER_JOURNAL_LINES:-200}
84
85
  VERIFY_POLL_S=${RECOVER_VERIFY_POLL_S:-2}
85
86
  VERIFY_TRIES=${RECOVER_VERIFY_TRIES:-10}
86
87
  PROJECT=${RECOVER_PROJECT:-}
88
+ # Recovery action scope and durable-report ownership are deliberately separate.
89
+ # `RECOVER_PROJECT` is only safe on a single-project host; the report still
90
+ # needs one configured project to own its outbox row on a shared host.
91
+ REPORT_PROJECT=${RECOVER_REPORT_PROJECT:-$PROJECT}
92
+ AFFECTED_PROJECTS=${RECOVER_PROJECTS:-$PROJECT}
93
+ REPORT_ROUTING_NOTE=
87
94
  # The bound on the post-restore re-arm. `arm` with a claim-only proof performs
88
95
  # no Telegram send or wait, so it completes in milliseconds; the timeout exists
89
96
  # so a misbehaving conductor can never hang this bounded playbook (#613).
@@ -190,6 +197,40 @@ append_evidence() { # <evidence path> <text>
190
197
  printf '%s\n' "$2" >>"$1" 2>/dev/null || true
191
198
  }
192
199
 
200
+
201
+ # Resolve the host-global report owner from the live machine-written config.
202
+ # Bun is not an extra prerequisite: omp-conductor itself runs under Bun, and
203
+ # the recovery unit's canonical PATH already contains that executable. When a
204
+ # manual/degraded invocation cannot read the config, the script still attempts
205
+ # the legacy unscoped report and records that degradation in the evidence.
206
+ resolve_report_scope() {
207
+ if [[ -n $REPORT_PROJECT && -n $AFFECTED_PROJECTS ]]; then
208
+ return 0
209
+ fi
210
+ local lines first all
211
+ if lines=$(
212
+ "$BUN" -e '
213
+ const path = Bun.argv.at(-1);
214
+ const config = await Bun.file(path).json();
215
+ const names = Array.isArray(config.projects)
216
+ ? config.projects.map((project) => project?.name).filter((name) => typeof name === "string" && name.length > 0)
217
+ : [];
218
+ if (names.length === 0) process.exit(2);
219
+ console.log(names[0]);
220
+ console.log(names.join(","));
221
+ ' "$CONFIG_PATH" 2>/dev/null
222
+ ); then
223
+ first=${lines%%$'\n'*}
224
+ all=${lines#*$'\n'}
225
+ REPORT_PROJECT=${REPORT_PROJECT:-$first}
226
+ AFFECTED_PROJECTS=${AFFECTED_PROJECTS:-$all}
227
+ fi
228
+ if [[ -z $REPORT_PROJECT || -z $AFFECTED_PROJECTS ]]; then
229
+ AFFECTED_PROJECTS=${AFFECTED_PROJECTS:-unknown}
230
+ REPORT_ROUTING_NOTE="configured project resolution failed for $CONFIG_PATH; attempting the report without a project selector"
231
+ fi
232
+ return 0
233
+ }
193
234
  # --------------------------------------------------------------------------
194
235
  # escalation — always, through the durable outbox (#123, #288)
195
236
  # --------------------------------------------------------------------------
@@ -199,23 +240,27 @@ append_evidence() { # <evidence path> <text>
199
240
  # (the durable outbox) before anything is sent, and the daemon owns delivery
200
241
  # with bounded retries once it is back. This script only ever enqueues.
201
242
  escalation_text() { # <failed-unit or empty> <action> <outcome> <evidence>
243
+ local projects=${AFFECTED_PROJECTS:-unknown}
202
244
  if [[ ${1:-none} == none ]]; then
203
245
  printf '%s\n' \
204
- "fleet recovery (#485): a recovery trigger fired but no fleet unit was failing when it ran — the fleet got itself up or was started externally; evidence=$4"
246
+ "fleet recovery (#485): a recovery trigger fired but no fleet unit was failing when it ran — the fleet got itself up or was started externally; affected-projects=$projects; evidence=$4"
205
247
  return
206
248
  fi
207
249
  printf '%s\n' \
208
- "fleet recovery (#485): $1 entered failed state; recovery=${2:-none}; outcome=${3}; evidence=${4}; next: read the evidence file (and its doctor output) before touching the fleet"
250
+ "fleet recovery (#485): $1 entered failed state; affected-projects=$projects; recovery=${2:-none}; outcome=${3}; evidence=${4}; next: read the evidence file (and its doctor output) before touching the fleet"
209
251
  }
210
252
 
211
253
  escalate() { # <failed-unit or empty> <action-name> <outcome> <evidence-path>
212
254
  local unit=${1:-none} text args
213
- text=$(escalation_text "$unit" "$2" "$3" "$4")
214
- if [[ -n $PROJECT ]]; then
215
- args=("$CONDUCTOR" report --kind tier2 --project "$PROJECT" --text "$text")
216
- else
217
- args=("$CONDUCTOR" report --kind tier2 --text "$text")
255
+ resolve_report_scope
256
+ if [[ -n $REPORT_ROUTING_NOTE ]]; then
257
+ log "$REPORT_ROUTING_NOTE"
258
+ append_evidence "$4" "report routing: $REPORT_ROUTING_NOTE"
218
259
  fi
260
+ text=$(escalation_text "$unit" "$2" "$3" "$4")
261
+ args=("$CONDUCTOR" report --kind tier2)
262
+ [[ -n $REPORT_PROJECT ]] && args+=(--project "$REPORT_PROJECT")
263
+ args+=(--text "$text")
219
264
  say "escalation: ${args[*]}"
220
265
  if dry_run; then
221
266
  return 0
@@ -365,6 +410,9 @@ run_recovery() {
365
410
  done
366
411
 
367
412
  if dry_run; then
413
+ resolve_report_scope
414
+ say "plan: report owner — ${REPORT_PROJECT:-unscoped}; affected projects — $AFFECTED_PROJECTS"
415
+ [[ -n $REPORT_ROUTING_NOTE ]] && say "plan: report routing degraded — $REPORT_ROUTING_NOTE"
368
416
  say "dry run: nothing will be written or executed"
369
417
  [[ -z $failed ]] && say "plan: no unit failed — escalate only"
370
418
  if [[ -n $failed ]]; then
@@ -50,6 +50,7 @@ fi
50
50
 
51
51
  tmp=$(mktemp -d "${TMPDIR:-/tmp}/omp-recover-test.XXXXXX")
52
52
  trap 'rm -rf "$tmp"' EXIT
53
+ bun_bin=$(command -v bun)
53
54
  mkdir -p "$tmp/bin"
54
55
 
55
56
  # --------------------------------------------------------------------------
@@ -156,6 +157,18 @@ write_project_config() { # <case-dir> <proof>
156
157
  printf '{\n "version": 2,\n "projects": [\n { "name": "demo", "arm": { "proof": "%s" } }\n ]\n}\n' "$2" >"$1/state/config.json"
157
158
  }
158
159
 
160
+ write_multi_project_config() { # <case-dir>
161
+ cat >"$1/state/config.json" <<'JSON'
162
+ {
163
+ "version": 2,
164
+ "projects": [
165
+ { "name": "alpha" },
166
+ { "name": "beta" }
167
+ ]
168
+ }
169
+ JSON
170
+ }
171
+
159
172
  run_recover() { # <case-dir> [env assignments…]
160
173
  local d="$1"
161
174
  shift
@@ -182,7 +195,9 @@ run_recover() { # <case-dir> [env assignments…]
182
195
  # marks the named unit active, which is what a real peer restart does to a
183
196
  # healthy peer), so tests wanting daemon-side healing pass HEAL_ON_START=<unit>
184
197
  # and the stub's `start` flips that unit back to active.
185
- run_with_heal() { # <case-dir> <heal-on-start>
198
+ run_with_heal() { # <case-dir> <heal-on-start> [env assignments…]
199
+ local d="$1" heal="$2"
200
+ shift 2
186
201
  (
187
202
  cd "$here" || exit 1
188
203
  env \
@@ -190,14 +205,15 @@ run_with_heal() { # <case-dir> <heal-on-start>
190
205
  SYSTEMCTL_BIN="$tmp/bin/systemctl" \
191
206
  JOURNALCTL_BIN="$tmp/bin/journalctl" \
192
207
  OMP_CONDUCTOR_BIN="$tmp/bin/omp-conductor" \
193
- RECOVER_STATE_DIR="$1/state" \
208
+ RECOVER_STATE_DIR="$d/state" \
194
209
  RECOVER_PROJECT=demo \
195
210
  RECOVER_VERIFY_POLL_S=0 \
196
211
  RECOVER_VERIFY_TRIES=3 \
197
- STUB_CALLS="$1/calls" \
198
- STUB_UNITS="$1/units" \
199
- STUB_HEAL_ON_START="$2" \
200
- bash "$recover" >"$1/out" 2>"$1/stderr"
212
+ STUB_CALLS="$d/calls" \
213
+ STUB_UNITS="$d/units" \
214
+ STUB_HEAL_ON_START="$heal" \
215
+ "$@" \
216
+ bash "$recover" >"$d/out" 2>"$d/stderr"
201
217
  )
202
218
  echo $?
203
219
  }
@@ -412,6 +428,83 @@ check 'a trigger with nothing failed escalates (fleet came back)' \
412
428
  check 'a trigger with nothing failed exits 0' '0' "$code"
413
429
  check 'a trigger with nothing failed clears the old counter' '0' "$(marker "$d")"
414
430
 
431
+ # ---------------------------------------------------------------------------
432
+ # case — a host-global recovery on a multi-project config still has one
433
+ # deterministic outbox owner, while its body names every affected project.
434
+ # ---------------------------------------------------------------------------
435
+
436
+ d=$(newcase multi-project-report)
437
+ write_multi_project_config "$d"
438
+ unit_state "$d" omp-conductor.service active
439
+ unit_state "$d" herdr-fleet.service active
440
+ code=$(run_recover "$d" \
441
+ RECOVER_PROJECT= \
442
+ RECOVER_REPORT_PROJECT= \
443
+ RECOVER_PROJECTS= \
444
+ BUN_BIN="$bun_bin")
445
+ report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
446
+ check 'multi-project recovery exits after queuing its report' '0' "$code"
447
+ check 'multi-project recovery uses the first configured outbox owner' \
448
+ '--project alpha' "$report_call"
449
+ check 'multi-project recovery names every affected project' \
450
+ 'affected-projects=alpha,beta' "$report_call"
451
+ check 'multi-project recovery queues exactly one report' \
452
+ '1' "$(grep -c '^omp-conductor report' "$d/calls")"
453
+
454
+ d=$(newcase multi-project-recovery-action)
455
+ write_multi_project_config "$d"
456
+ unit_state "$d" omp-conductor.service active
457
+ unit_state "$d" herdr-fleet.service failed
458
+ code=$(run_with_heal "$d" herdr-fleet.service \
459
+ RECOVER_PROJECT= \
460
+ RECOVER_REPORT_PROJECT= \
461
+ RECOVER_PROJECTS= \
462
+ BUN_BIN="$bun_bin")
463
+ report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
464
+ check 'verified multi-project recovery action exits cleanly' '0' "$code"
465
+ check 'verified multi-project recovery action queues one scoped report' \
466
+ '1' "$(grep -c '^omp-conductor report' "$d/calls")"
467
+ check 'verified multi-project recovery action uses configured owner' \
468
+ '--project alpha' "$report_call"
469
+ check 'verified multi-project recovery action names every project' \
470
+ 'affected-projects=alpha,beta' "$report_call"
471
+
472
+ d=$(newcase multi-project-dry-run)
473
+ write_multi_project_config "$d"
474
+ unit_state "$d" omp-conductor.service active
475
+ unit_state "$d" herdr-fleet.service failed
476
+ code=$(run_recover "$d" \
477
+ RECOVER_PROJECT= \
478
+ RECOVER_REPORT_PROJECT= \
479
+ RECOVER_PROJECTS= \
480
+ RECOVER_DRY_RUN=1 \
481
+ BUN_BIN="$bun_bin")
482
+ check 'multi-project dry run exits cleanly' '0' "$code"
483
+ check 'multi-project dry run names its report owner' \
484
+ 'plan: report owner — alpha' "$(cat "$d/out")"
485
+ check 'multi-project dry run names every affected project' \
486
+ 'affected projects — alpha,beta' "$(cat "$d/out")"
487
+
488
+ d=$(newcase report-routing-fallback)
489
+ unit_state "$d" omp-conductor.service active
490
+ unit_state "$d" herdr-fleet.service active
491
+ code=$(run_recover "$d" \
492
+ RECOVER_PROJECT= \
493
+ RECOVER_REPORT_PROJECT= \
494
+ RECOVER_PROJECTS= \
495
+ BUN_BIN="$bun_bin")
496
+ report_call=$(grep '^omp-conductor report' "$d/calls" | head -n 1)
497
+ check 'unreadable config routing still attempts a durable report' \
498
+ 'omp-conductor report --kind tier2 --text' "$report_call"
499
+ if [[ $report_call == *"--project "* ]]; then
500
+ fail "routing fallback does not guess an unconfigured project — $report_call"
501
+ else
502
+ pass 'routing fallback does not guess an unconfigured project'
503
+ fi
504
+ check 'routing fallback remains a successful recovered run' '0' "$code"
505
+ check 'routing fallback is preserved in durable evidence' \
506
+ 'attempting the report without a project selector' "$(cat "$d/state/recovery"/evidence-*)"
507
+
415
508
  # ---------------------------------------------------------------------------
416
509
  # case DRY-RUN — the operator's rehearsal mode prints decisions and mutates
417
510
  # nothing, not even the recovery dir or the fixture call log.