omp-conductor 0.15.10 → 0.15.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
package/src/verbs/actions.ts
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
|
|
18
|
+
import { livingDaemon } from "../lifecycle.ts";
|
|
19
|
+
import { stateDir } from "../config.ts";
|
|
18
20
|
import { ensureMirror } from "../worktree.ts";
|
|
21
|
+
import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
|
|
22
|
+
import { classifyUpgrade } from "../upgrade-verify.ts";
|
|
19
23
|
import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
|
|
20
|
-
import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
24
|
+
import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* What this daemon can actually cut.
|
|
@@ -779,5 +783,73 @@ export function githubVerbActions(
|
|
|
779
783
|
}
|
|
780
784
|
: failed(pushed.result, pushed.argv);
|
|
781
785
|
},
|
|
786
|
+
|
|
787
|
+
// The fleet-installs-itself request (#486). Two gates, then a spawn:
|
|
788
|
+
//
|
|
789
|
+
// 1. `npm view` must actually expose the version with a full gitHead —
|
|
790
|
+
// "install it" may only name what npm can serve. Refused with npm's
|
|
791
|
+
// own detail so the orchestrator learns why, not that something was
|
|
792
|
+
// wrong.
|
|
793
|
+
// 2. A live daemon must exist to verify the install on its first tick
|
|
794
|
+
// after the restart. A stopped daemon can never come back to do that,
|
|
795
|
+
// so the request is refused and the operator is pointed at the plain
|
|
796
|
+
// `upgrade` command, whose own process is its witness.
|
|
797
|
+
install: async (execution: InstallExecution): Promise<ActionOutcome> => {
|
|
798
|
+
if (livingDaemon() === undefined) {
|
|
799
|
+
return {
|
|
800
|
+
ok: false,
|
|
801
|
+
stderr:
|
|
802
|
+
"the dispatch daemon is not running, so nobody can verify this install after the restart " +
|
|
803
|
+
"it performs. Run `omp-conductor upgrade` from a shell instead.",
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
// One install at a time: the journal's newest request is either closed or
|
|
807
|
+
// in flight, and a second request while one is in flight would race it
|
|
808
|
+
// for the same restarts. The refusal names the state so the caller knows
|
|
809
|
+
// to wait for the verifier's outcome instead of retrying blind.
|
|
810
|
+
const pending = classifyUpgrade(readUpgradeJournal(stateDir()));
|
|
811
|
+
if (pending.kind !== "none" && pending.kind !== "closed") {
|
|
812
|
+
return {
|
|
813
|
+
ok: false,
|
|
814
|
+
stderr:
|
|
815
|
+
`an install is already in flight (journal verdict: ${pending.kind}); ` +
|
|
816
|
+
"the fleet verifies or rolls it back before accepting another request.",
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const looked = await run(
|
|
820
|
+
["npm", "view", `omp-conductor@${execution.version}`, "version", "gitHead", "--json"],
|
|
821
|
+
{ env: env() },
|
|
822
|
+
);
|
|
823
|
+
if (!looked.ok) {
|
|
824
|
+
const detail = looked.stderr.trim() || looked.stdout.trim() || "the npm view command failed";
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
stderr:
|
|
828
|
+
`npm does not expose omp-conductor@${execution.version}: ${detail}. ` +
|
|
829
|
+
"Only a published release with a full gitHead can be installed; the request was not started.",
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const metadata = objectFromJson(looked.stdout);
|
|
833
|
+
const published = metadata === undefined ? undefined : String(metadata["version"] ?? "");
|
|
834
|
+
const gitHead = metadata === undefined ? undefined : String(metadata["gitHead"] ?? "");
|
|
835
|
+
if (published !== execution.version || !/^[0-9a-f]{40}$/i.test(gitHead ?? "")) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
stderr:
|
|
839
|
+
`npm does not expose omp-conductor@${execution.version} with a full gitHead ` +
|
|
840
|
+
`(${published === undefined ? "unreadable metadata" : `saw ${published}`}); the request was not started.`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const launched = await launchTransientUnit(
|
|
844
|
+
async (argv) => {
|
|
845
|
+
const ran = await run([...argv] as string[], { env: env() });
|
|
846
|
+
return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr };
|
|
847
|
+
},
|
|
848
|
+
env(),
|
|
849
|
+
{ kind: "install", version: execution.version },
|
|
850
|
+
);
|
|
851
|
+
if (!launched.ok) return { ok: false, stderr: launched.stderr };
|
|
852
|
+
return { ok: true, detail: launched.unit };
|
|
853
|
+
},
|
|
782
854
|
};
|
|
783
855
|
}
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -288,15 +288,40 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
288
288
|
`release authority is the orchestrator's, and this is a ${role} session. ` +
|
|
289
289
|
"A worker session holds no release grant whatever the config says.",
|
|
290
290
|
},
|
|
291
|
+
conductor_install: {
|
|
292
|
+
name: "conductor_install",
|
|
293
|
+
mutating: true,
|
|
294
|
+
allowedRoles: ["orchestrator"],
|
|
295
|
+
description:
|
|
296
|
+
"Request the fleet upgrade itself to a published omp-conductor release (#486). The install " +
|
|
297
|
+
"shape gates it like a release shape and stays with the human by default; with the grant, " +
|
|
298
|
+
"the daemon validates the version against npm and starts a detached transient unit that " +
|
|
299
|
+
"pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session " +
|
|
300
|
+
"and the daemon. The unit never declares its own success: the first tick after the restart " +
|
|
301
|
+
"verifies version, /healthz, ticks, pane and doctor, and reports through the durable outbox.",
|
|
302
|
+
args: {
|
|
303
|
+
version: {
|
|
304
|
+
type: "string",
|
|
305
|
+
required: true,
|
|
306
|
+
description:
|
|
307
|
+
"The exact published semver to install (e.g. 0.15.11). The request is refused when npm " +
|
|
308
|
+
"does not expose it with a full gitHead.",
|
|
309
|
+
},
|
|
310
|
+
rationale: RATIONALE_ARG,
|
|
311
|
+
},
|
|
312
|
+
roleRefusalText: (role) =>
|
|
313
|
+
`install authority is the orchestrator's, and this is a ${role} session. ` +
|
|
314
|
+
"A worker session holds no install grant whatever the config says.",
|
|
315
|
+
},
|
|
291
316
|
conductor_pr_status: {
|
|
292
317
|
name: "conductor_pr_status",
|
|
293
318
|
mutating: false,
|
|
294
319
|
allowedRoles: ["worker", "orchestrator"],
|
|
295
320
|
description:
|
|
296
|
-
"Read one pull request's live state and current head
|
|
297
|
-
"
|
|
298
|
-
"
|
|
299
|
-
"A worker may omit prUrl and gets its own run's.",
|
|
321
|
+
"Read one pull request's live state and current head — open, merged, or closed — and the checks " +
|
|
322
|
+
"available for it (checks are unavailable for a merged or closed PR). Optionally compare that head " +
|
|
323
|
+
"with one the caller already observed. This is the daemon's own merge-gate verdict, not a summary of it. " +
|
|
324
|
+
"A poll, not a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.",
|
|
300
325
|
args: {
|
|
301
326
|
prUrl: {
|
|
302
327
|
type: "string",
|
package/src/verbs/server.ts
CHANGED
|
@@ -139,6 +139,12 @@ export interface ReleaseExecution {
|
|
|
139
139
|
environment?: string;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** What one install request asks the privileged half to do, after policy said yes. */
|
|
143
|
+
export interface InstallExecution {
|
|
144
|
+
/** The published semver to pin all three surfaces to, exactly as npm has it. */
|
|
145
|
+
version: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
142
148
|
/**
|
|
143
149
|
* The privileged half, as an interface so every check above it is testable
|
|
144
150
|
* without a repository, a credential or a network.
|
|
@@ -163,6 +169,13 @@ export interface VerbActions {
|
|
|
163
169
|
mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
|
|
164
170
|
setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
|
|
165
171
|
release(execution: ReleaseExecution): Promise<ActionOutcome>;
|
|
172
|
+
/**
|
|
173
|
+
* Request the fleet upgrade itself to `version`: refuse when npm does not
|
|
174
|
+
* expose it, then start the detached transient unit that performs it and
|
|
175
|
+
* survives the restarts. The implementation owns the npm read and the
|
|
176
|
+
* systemd-run spawn (#486) — the server only gates and records.
|
|
177
|
+
*/
|
|
178
|
+
install(execution: InstallExecution): Promise<ActionOutcome>;
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
export interface VerbDeps {
|
|
@@ -272,6 +285,19 @@ export interface ReleaseFacts {
|
|
|
272
285
|
unsettledRuns: number;
|
|
273
286
|
/** Of those, the ones with a pull request still open. */
|
|
274
287
|
openPrs: number;
|
|
288
|
+
/**
|
|
289
|
+
* Of those, the ones with a live worker (claimed/running), as opposed to
|
|
290
|
+
* finished pushes. Absent when the caller could not distinguish (the plain
|
|
291
|
+
* refusal wording is used then).
|
|
292
|
+
*/
|
|
293
|
+
liveWorkers?: number;
|
|
294
|
+
/**
|
|
295
|
+
* Of those, finished runs whose PR the tracker could not confirm merged yet
|
|
296
|
+
* — the settle sweep either has not run or could not read GitHub. Distinct
|
|
297
|
+
* from {@link liveWorkers} because only a live worker means the operator
|
|
298
|
+
* must wait on a process; these clear by themselves on the next sweep.
|
|
299
|
+
*/
|
|
300
|
+
unconfirmedMerges?: number;
|
|
275
301
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
276
302
|
queueDepth: number | undefined;
|
|
277
303
|
/** Current live-head workflow verdict for the released routed repository. */
|
|
@@ -286,7 +312,26 @@ export function releaseRequirementRefusal(
|
|
|
286
312
|
): string | undefined {
|
|
287
313
|
for (const requirement of requires) {
|
|
288
314
|
if (requirement === "runs-settled" && facts.unsettledRuns > 0) {
|
|
289
|
-
|
|
315
|
+
// The same blocker reads differently depending on WHY the row is still
|
|
316
|
+
// active: a live worker is the operator's wait, an unconfirmed merge or
|
|
317
|
+
// an unrecorded sweep clears by itself on the next settle pass. The
|
|
318
|
+
// gate has the split; the message must hand it to the reader.
|
|
319
|
+
const live = facts.liveWorkers ?? 0;
|
|
320
|
+
const unconfirmed = facts.unconfirmedMerges ?? 0;
|
|
321
|
+
// openPrs counts verifiably-open PRs *and* unread ones (fail-closed for
|
|
322
|
+
// no-open-prs), so the verifiably-open sub-bucket is the difference.
|
|
323
|
+
const open = Math.max(0, (facts.openPrs ?? 0) - unconfirmed);
|
|
324
|
+
// Everything not named above: closed unmerged PRs and rows with no PR.
|
|
325
|
+
const remaining = facts.unsettledRuns - live - unconfirmed - open;
|
|
326
|
+
const parts: string[] = [];
|
|
327
|
+
if (live > 0) parts.push(`${live} worker(s) still running`);
|
|
328
|
+
if (open > 0) parts.push(`${open} pull request(s) still open`);
|
|
329
|
+
if (unconfirmed > 0) parts.push(`${unconfirmed} run(s) whose PR merge the settle sweep has not yet recorded`);
|
|
330
|
+
if (remaining > 0) parts.push(`${remaining} run(s) with no merged PR (closed or none yet)`);
|
|
331
|
+
return (
|
|
332
|
+
`policy.release.requires includes runs-settled and ${facts.unsettledRuns} run(s) have not settled` +
|
|
333
|
+
(parts.length === 0 ? "" : ` — ${parts.join("; ")}`)
|
|
334
|
+
);
|
|
290
335
|
}
|
|
291
336
|
if (requirement === "no-open-prs" && facts.openPrs > 0) {
|
|
292
337
|
return `policy.release.requires includes no-open-prs and ${facts.openPrs} pull request(s) are still open`;
|
|
@@ -462,7 +507,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
462
507
|
verb === "conductor_pr_update" ||
|
|
463
508
|
verb === "conductor_label");
|
|
464
509
|
const stopped =
|
|
465
|
-
spec.mutating && verb !== "conductor_release" && !orchestratorCompletion
|
|
510
|
+
spec.mutating && verb !== "conductor_release" && verb !== "conductor_install" && !orchestratorCompletion
|
|
466
511
|
? deps.fleetStop()
|
|
467
512
|
: undefined;
|
|
468
513
|
if (stopped !== undefined) {
|
|
@@ -473,7 +518,7 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
473
518
|
"fleet-paused",
|
|
474
519
|
`refused: ${stopped}. The pause refuses new claims and work-starting mutations. ` +
|
|
475
520
|
"Completion verbs for runs admitted before the pause (conductor_pr_merge, conductor_pr_update_branch, " +
|
|
476
|
-
"conductor_pr_update, conductor_label) and
|
|
521
|
+
"conductor_pr_update, conductor_label), conductor_release and conductor_install remain available.",
|
|
477
522
|
);
|
|
478
523
|
}
|
|
479
524
|
}
|
|
@@ -493,6 +538,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
493
538
|
return labelVerb(deps, project, channel, args, refuse, allow);
|
|
494
539
|
case "conductor_release":
|
|
495
540
|
return releaseVerb(deps, project, channel, args, refuse, allow);
|
|
541
|
+
case "conductor_install":
|
|
542
|
+
return installVerb(deps, project, channel, args, refuse, allow);
|
|
496
543
|
case "conductor_pr_status":
|
|
497
544
|
return prStatusVerb(deps, project, channel, args, refuse, allow);
|
|
498
545
|
}
|
|
@@ -641,9 +688,11 @@ async function prCreateVerb(
|
|
|
641
688
|
export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
642
689
|
|
|
643
690
|
function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | undefined {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
691
|
+
// Every attempt that recorded this PR, newest first — not `recentRuns`'
|
|
692
|
+
// newest-attempt-per-issue view. The row a PR belongs to is the newest
|
|
693
|
+
// attempt that owns it, and a requeued continuation that never inherited
|
|
694
|
+
// the URL must not hide its predecessor's ownership (#434).
|
|
695
|
+
return deps.store.runsForProjectPr(project, prUrl, deps.now() - PR_LOOKUP_WINDOW_MS)[0];
|
|
647
696
|
}
|
|
648
697
|
|
|
649
698
|
/**
|
|
@@ -1043,9 +1092,69 @@ async function releaseVerb(
|
|
|
1043
1092
|
const health = wantsBase
|
|
1044
1093
|
? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
|
|
1045
1094
|
: undefined;
|
|
1095
|
+
|
|
1096
|
+
// `runs-settled` (and `no-open-prs`) read the PR fact, not the row (#482):
|
|
1097
|
+
// a `pushed-green` row whose PR has actually merged is settled work, and a
|
|
1098
|
+
// merged PR is not an open one. The settle sweep records the same fact on
|
|
1099
|
+
// its next tick; the gate must not wait an extra tick the operator reached
|
|
1100
|
+
// this release by holding. Fail-closed stays fail-closed: a row whose PR
|
|
1101
|
+
// state cannot be read is still unsettled, and an unknown PR is still
|
|
1102
|
+
// treated as open.
|
|
1103
|
+
const wantsRunFacts =
|
|
1104
|
+
policy.release.requires.includes("runs-settled") ||
|
|
1105
|
+
policy.release.requires.includes("no-open-prs");
|
|
1106
|
+
let unsettledRuns = active.length;
|
|
1107
|
+
let openPrs = active.filter((r) => r.prUrl !== undefined).length;
|
|
1108
|
+
let liveWorkers: number | undefined;
|
|
1109
|
+
let unconfirmedMerges: number | undefined;
|
|
1110
|
+
if (wantsRunFacts) {
|
|
1111
|
+
let unsettled = 0;
|
|
1112
|
+
let open = 0;
|
|
1113
|
+
let live = 0;
|
|
1114
|
+
let unconfirmed = 0;
|
|
1115
|
+
for (const run of active) {
|
|
1116
|
+
if (run.state === "claimed" || run.state === "running") {
|
|
1117
|
+
unsettled += 1;
|
|
1118
|
+
live += 1;
|
|
1119
|
+
// A live worker's PR is not yet confirmable; count it as open, the
|
|
1120
|
+
// same fail-closed answer the old row-count gave it.
|
|
1121
|
+
if (run.prUrl !== undefined) open += 1;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
// pushed-pending / pushed-green: the worker is finished; the PR decides.
|
|
1125
|
+
if (run.prUrl === undefined) {
|
|
1126
|
+
unsettled += 1; // nothing to read; the sweep cannot settle it either
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
let pr: PrState | undefined;
|
|
1130
|
+
try {
|
|
1131
|
+
pr = await deps.tracker.prState(run.prUrl);
|
|
1132
|
+
} catch {
|
|
1133
|
+
pr = undefined;
|
|
1134
|
+
}
|
|
1135
|
+
if (pr === "merged") continue; // settled in fact; not an open PR
|
|
1136
|
+
unsettled += 1;
|
|
1137
|
+
if (pr === "open") open += 1;
|
|
1138
|
+
else if (pr === undefined) {
|
|
1139
|
+
// Cannot confirm the merge (or its absence): fail closed, and count
|
|
1140
|
+
// the row as an unconfirmed merge. Also open — an unread PR may be
|
|
1141
|
+
// open — so `no-open-prs` keeps its fail-closed refusal.
|
|
1142
|
+
open += 1;
|
|
1143
|
+
unconfirmed += 1;
|
|
1144
|
+
}
|
|
1145
|
+
// "closed": the row still owes a failure write from the sweep; blocks
|
|
1146
|
+
// exactly as it did before, the message bucket covers it.
|
|
1147
|
+
}
|
|
1148
|
+
unsettledRuns = unsettled;
|
|
1149
|
+
openPrs = open;
|
|
1150
|
+
liveWorkers = live;
|
|
1151
|
+
unconfirmedMerges = unconfirmed;
|
|
1152
|
+
}
|
|
1046
1153
|
const unmet = releaseRequirementRefusal(policy.release.requires, {
|
|
1047
|
-
unsettledRuns
|
|
1048
|
-
openPrs
|
|
1154
|
+
unsettledRuns,
|
|
1155
|
+
openPrs,
|
|
1156
|
+
...(liveWorkers === undefined ? {} : { liveWorkers }),
|
|
1157
|
+
...(unconfirmedMerges === undefined ? {} : { unconfirmedMerges }),
|
|
1049
1158
|
queueDepth,
|
|
1050
1159
|
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
1051
1160
|
...(health?.verdict === "red" && health.detail !== undefined
|
|
@@ -1152,14 +1261,66 @@ async function releaseVerb(
|
|
|
1152
1261
|
}
|
|
1153
1262
|
|
|
1154
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* The fleet-installs-itself request (#486).
|
|
1266
|
+
*
|
|
1267
|
+
* Gated like a release act: the caller must be an orchestrator (spec), and the
|
|
1268
|
+
* `install` shape must be granted to the orchestrator (release policy) — the
|
|
1269
|
+
* package floor's "nobody patches the running conductor" is the deny default
|
|
1270
|
+
* an operator deliberately moves, never something this verb works around.
|
|
1271
|
+
*
|
|
1272
|
+
* Everything past the gate is the privileged half's job: `actions.install`
|
|
1273
|
+
* refussses a version npm does not expose and starts the detached transient
|
|
1274
|
+
* unit. This verb records the request durably (the journal the post-restart
|
|
1275
|
+
* tick verifies against) and tells the caller where the evidence will land.
|
|
1276
|
+
*/
|
|
1277
|
+
async function installVerb(
|
|
1278
|
+
deps: VerbDeps,
|
|
1279
|
+
project: ProjectConfig,
|
|
1280
|
+
channel: VerbChannel,
|
|
1281
|
+
args: Record<string, unknown>,
|
|
1282
|
+
refuse: Refuse,
|
|
1283
|
+
allow: Allow,
|
|
1284
|
+
): Promise<Verdict> {
|
|
1285
|
+
const grants = resolveReleaseGrants(project);
|
|
1286
|
+
const granted = releaseRefusal(grants, channel.role, "install");
|
|
1287
|
+
if (granted !== undefined) {
|
|
1288
|
+
return refuse("release-not-granted", `refused: ${granted.reason}`);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const version = args["version"];
|
|
1292
|
+
if (typeof version !== "string" || version.trim().length === 0) {
|
|
1293
|
+
return refuse(
|
|
1294
|
+
"malformed-argument",
|
|
1295
|
+
`refused: conductor_install needs a version, got ${JSON.stringify(version)}. ` +
|
|
1296
|
+
"Name the exact published semver, e.g. 0.15.11.",
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const outcome = await deps.actions.install({ version: version.trim() });
|
|
1301
|
+
if (!outcome.ok) {
|
|
1302
|
+
return refuse("action-failed", `refused: the install request failed:\n${outcome.stderr}`);
|
|
1303
|
+
}
|
|
1304
|
+
const unit = outcome.detail;
|
|
1305
|
+
return allow(
|
|
1306
|
+
`install requested: omp-conductor@${version} will be installed by a detached unit` +
|
|
1307
|
+
`${unit === undefined ? "" : ` (${unit})`} that survives the restart. ` +
|
|
1308
|
+
"The first tick after the fleet restarts verifies version, /healthz, ticks, pane and doctor, " +
|
|
1309
|
+
"and the outcome is reported through the durable outbox.",
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1155
1313
|
/**
|
|
1156
1314
|
* The one read verb, and the reason a worker reports a PR's real state rather
|
|
1157
1315
|
* than its own recollection.
|
|
1158
1316
|
*
|
|
1159
|
-
* Reuses `Tracker.verifyPr
|
|
1160
|
-
* verdict rather than a second opinion assembled here — including
|
|
1161
|
-
* moved-head case, whose reason already names both shas. A worker that
|
|
1162
|
-
* this has read a fact; a worker that says "should be green" has not.
|
|
1317
|
+
* Reuses `Tracker.verifyPr` in read mode, so the answer is literally the merge
|
|
1318
|
+
* gate's own verdict rather than a second opinion assembled here — including
|
|
1319
|
+
* the moved-head case, whose reason already names both shas. A worker that
|
|
1320
|
+
* quotes this has read a fact; a worker that says "should be green" has not.
|
|
1321
|
+
* Read mode also reports a merged or closed PR as a fact (`ok: true`) instead
|
|
1322
|
+
* of the merge gate's `expected OPEN` refusal, so an orchestrator can confirm
|
|
1323
|
+
* release evidence on a just-merged PR (#389).
|
|
1163
1324
|
*/
|
|
1164
1325
|
async function prStatusVerb(
|
|
1165
1326
|
deps: VerbDeps,
|
|
@@ -1190,17 +1351,16 @@ async function prStatusVerb(
|
|
|
1190
1351
|
prUrl = own.prUrl;
|
|
1191
1352
|
issue = channel.issue;
|
|
1192
1353
|
} else {
|
|
1354
|
+
// A read resolves any syntactically valid PR URL — nothing to gate. A
|
|
1355
|
+
// release-preparation PR the workflow opened (never a run row, and not
|
|
1356
|
+
// necessarily in `routing.repos`) still has to be confirmable before the
|
|
1357
|
+
// orchestrator cuts a release (#389). `runForPr` only decides which issue
|
|
1358
|
+
// to record on the ledger; `verifyPr`'s `PR_URL` check rejects a URL that
|
|
1359
|
+
// is not a pull request.
|
|
1193
1360
|
if (typeof asked !== "string") {
|
|
1194
1361
|
return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
|
|
1195
1362
|
}
|
|
1196
1363
|
const target = runForPr(deps, project.name, asked);
|
|
1197
|
-
if (target === undefined && !prInProjectRouting(project, asked)) {
|
|
1198
|
-
return refuse(
|
|
1199
|
-
"pr-not-this-run",
|
|
1200
|
-
`refused: ${asked} is not a pull request in ${project.name}'s routed repositories ` +
|
|
1201
|
-
`(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1204
1364
|
prUrl = asked;
|
|
1205
1365
|
issue = target?.issue;
|
|
1206
1366
|
}
|
|
@@ -1209,7 +1369,10 @@ async function prStatusVerb(
|
|
|
1209
1369
|
const expectedHead = typeof askedHead === "string" ? askedHead : undefined;
|
|
1210
1370
|
let verification: PrVerification | undefined;
|
|
1211
1371
|
try {
|
|
1212
|
-
|
|
1372
|
+
// Read mode: a merged or closed PR is a fact to report, not the merge
|
|
1373
|
+
// gate's `expected OPEN` refusal (#389). The mutating verbs never pass
|
|
1374
|
+
// this, so their open-PR gate is untouched.
|
|
1375
|
+
verification = await deps.tracker.verifyPr(prUrl, expectedHead, { read: true });
|
|
1213
1376
|
} catch (err) {
|
|
1214
1377
|
verification = undefined;
|
|
1215
1378
|
deps.log(`verb pr_status could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
package/src/worker.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
|
|
14
|
-
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
15
|
-
import type { Caps,
|
|
14
|
+
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
15
|
+
import type { Caps, ResolvedGrants, RunState } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
/** Structured evidence fields from the worker's final report. */
|
|
18
18
|
const PR_URL_PATTERN = /^pr:\s*(https:\/\/github\.com\/\S+\/pull\/\d+)\s*$/im;
|
|
@@ -86,7 +86,7 @@ export interface WorkerOpts {
|
|
|
86
86
|
*/
|
|
87
87
|
releaseGrants?: ResolvedGrants;
|
|
88
88
|
/** Durable audit sink for rejected release/deploy calls. */
|
|
89
|
-
onReleaseBlocked?: (shape:
|
|
89
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
90
90
|
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
91
91
|
onSpawn?: (pid: number) => void;
|
|
92
92
|
/** Control socket for that child, beside the run's own session directory. */
|