omp-conductor 0.15.6 → 0.15.7
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 +99 -27
- package/package.json +1 -1
- package/schema/config.schema.json +39 -0
- package/src/briefs/orchestrator.md +34 -10
- package/src/briefs/policy.md +8 -3
- package/src/cli.ts +61 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/fleet.ts +14 -35
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +47 -0
- package/src/setup-discover.ts +425 -0
- package/src/setup-wizard.ts +106 -10
- package/src/setup.ts +25 -1
- package/src/store.ts +5 -1
- package/src/types.ts +34 -0
- package/src/verbs/actions.ts +407 -4
- package/src/verbs/protocol.ts +2 -2
- package/src/verbs/server.ts +141 -27
package/src/verbs/server.ts
CHANGED
|
@@ -120,7 +120,15 @@ export type VerbChannel =
|
|
|
120
120
|
role: "orchestrator";
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
export type ActionOutcome =
|
|
123
|
+
export type ActionOutcome =
|
|
124
|
+
| {
|
|
125
|
+
ok: true;
|
|
126
|
+
sha?: string;
|
|
127
|
+
detail?: string;
|
|
128
|
+
/** A reviewable version bump. New PRs return immediately; existing ones are re-checked and merged. */
|
|
129
|
+
review?: { prUrl: string; created: boolean };
|
|
130
|
+
}
|
|
131
|
+
| { ok: false; stderr: string };
|
|
124
132
|
|
|
125
133
|
/** What one release asks the privileged half to do, after policy said yes. */
|
|
126
134
|
export interface ReleaseExecution {
|
|
@@ -645,11 +653,15 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
|
|
|
645
653
|
* opened (workflow-generated release pins, etc.). Scope is the routed clone
|
|
646
654
|
* slugs — not "any PR a run once recorded".
|
|
647
655
|
*/
|
|
648
|
-
function
|
|
656
|
+
function repoForPr(project: ProjectConfig, prUrl: string): RepoTarget | undefined {
|
|
649
657
|
const parts = prUrlParts(prUrl);
|
|
650
|
-
if (parts === undefined) return
|
|
658
|
+
if (parts === undefined) return undefined;
|
|
651
659
|
const slug = `${parts.owner}/${parts.repo}`;
|
|
652
|
-
return Object.values(project.routing.repos).
|
|
660
|
+
return Object.values(project.routing.repos).find((repo) => repoSlugFor(repo) === slug);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
|
|
664
|
+
return repoForPr(project, prUrl) !== undefined;
|
|
653
665
|
}
|
|
654
666
|
|
|
655
667
|
/** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
|
|
@@ -747,11 +759,26 @@ async function prMergeVerb(
|
|
|
747
759
|
refuse: Refuse,
|
|
748
760
|
allow: Allow,
|
|
749
761
|
): Promise<Verdict> {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
762
|
+
|
|
763
|
+
const prUrl = String(args["prUrl"]);
|
|
764
|
+
const headSha = String(args["headSha"]);
|
|
765
|
+
const reason = String(args["reason"]);
|
|
766
|
+
const target = runForPr(deps, project.name, prUrl);
|
|
767
|
+
const recovery =
|
|
768
|
+
target === undefined
|
|
769
|
+
? project.recoveryMerges?.find((authorization) => authorization.prUrl === prUrl)
|
|
770
|
+
: undefined;
|
|
771
|
+
const routedRepo = repoForPr(project, prUrl);
|
|
772
|
+
const authorizedRecovery =
|
|
773
|
+
recovery !== undefined &&
|
|
774
|
+
recovery.headSha.toLowerCase() === headSha.toLowerCase() &&
|
|
775
|
+
recovery.reason === reason;
|
|
776
|
+
|
|
777
|
+
// Ordinary calls obey the standing holder. A recovery entry is itself the
|
|
778
|
+
// operator's one-PR grant to the orchestrator, so its exact URL/head/reason
|
|
779
|
+
// match replaces that broad holder check without widening any other merge.
|
|
753
780
|
const holder = project.authority.merge;
|
|
754
|
-
if (holder !== channel.role) {
|
|
781
|
+
if (holder !== channel.role && !authorizedRecovery) {
|
|
755
782
|
return refuse(
|
|
756
783
|
"authority-holder",
|
|
757
784
|
`refused: merge authority is the ${holder}'s for ${project.name}, and this is a ${channel.role} session. ` +
|
|
@@ -760,16 +787,27 @@ async function prMergeVerb(
|
|
|
760
787
|
: "Ask the operator which session is meant to hold it."),
|
|
761
788
|
);
|
|
762
789
|
}
|
|
763
|
-
|
|
764
|
-
const prUrl = String(args["prUrl"]);
|
|
765
|
-
const headSha = String(args["headSha"]);
|
|
766
|
-
const target = runForPr(deps, project.name, prUrl);
|
|
767
|
-
if (target === undefined) {
|
|
790
|
+
if (target === undefined && recovery === undefined) {
|
|
768
791
|
return refuse("pr-not-this-run", `refused: ${prUrl} is not a pull request any run in ${project.name} opened.`);
|
|
769
792
|
}
|
|
793
|
+
if (target === undefined && routedRepo === undefined) {
|
|
794
|
+
return refuse(
|
|
795
|
+
"pr-not-this-run",
|
|
796
|
+
`refused: ${prUrl} is not in ${project.name}'s routed repositories; a recovery authorization cannot widen project scope.`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (recovery !== undefined && !authorizedRecovery) {
|
|
800
|
+
return refuse(
|
|
801
|
+
"recovery-authorization-mismatch",
|
|
802
|
+
`refused: ${prUrl} has a recovery authorization, but not for this exact head and reason.`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const issue = target?.issue;
|
|
770
806
|
|
|
771
|
-
|
|
772
|
-
|
|
807
|
+
if (!authorizedRecovery) {
|
|
808
|
+
const paused = orchestratorPauseRefusal(deps, channel, target, refuse);
|
|
809
|
+
if (paused !== undefined) return paused;
|
|
810
|
+
}
|
|
773
811
|
|
|
774
812
|
// Taken before any network call, so two concurrent callers contend here
|
|
775
813
|
// rather than both spending a `gh` round trip and racing at the merge.
|
|
@@ -782,7 +820,7 @@ async function prMergeVerb(
|
|
|
782
820
|
`refused: a merge is already in flight for ${project.name}` +
|
|
783
821
|
(live === undefined ? "" : ` (${live.prUrl})`) +
|
|
784
822
|
". PRs land one at a time, each re-checked against its base: two agent merges at once is how they clobber each other.",
|
|
785
|
-
|
|
823
|
+
issue,
|
|
786
824
|
);
|
|
787
825
|
}
|
|
788
826
|
|
|
@@ -795,13 +833,13 @@ async function prMergeVerb(
|
|
|
795
833
|
verification = await deps.tracker.verifyPr(prUrl, headSha);
|
|
796
834
|
} catch (err) {
|
|
797
835
|
const why = err instanceof Error ? err.message : String(err);
|
|
798
|
-
return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`,
|
|
836
|
+
return refuse("head-unresolvable", `refused: the live head of ${prUrl} could not be read (${why}).`, issue);
|
|
799
837
|
}
|
|
800
838
|
if (verification === undefined) {
|
|
801
839
|
return refuse(
|
|
802
840
|
"head-unresolvable",
|
|
803
841
|
`refused: the live head of ${prUrl} could not be resolved. Refusing rather than merging what you last saw.`,
|
|
804
|
-
|
|
842
|
+
issue,
|
|
805
843
|
);
|
|
806
844
|
}
|
|
807
845
|
if (verification.status !== "green") {
|
|
@@ -812,11 +850,14 @@ async function prMergeVerb(
|
|
|
812
850
|
? `refused: ${verification.reason}. You asked to merge ${headSha}; that is not what is on the branch now. ` +
|
|
813
851
|
"Re-read the head, re-check the checks at it, and call again."
|
|
814
852
|
: `refused: ${prUrl} is not mergeable at ${headSha} — ${verification.reason}.`,
|
|
815
|
-
|
|
853
|
+
issue,
|
|
816
854
|
);
|
|
817
855
|
}
|
|
818
856
|
|
|
819
|
-
const repoTarget =
|
|
857
|
+
const repoTarget =
|
|
858
|
+
target === undefined
|
|
859
|
+
? routedRepo
|
|
860
|
+
: Object.values(project.routing.repos).find((repo) => repo.name === target.repo);
|
|
820
861
|
const chainDir = repoTarget?.migrations?.dir;
|
|
821
862
|
if (chainDir !== undefined && repoTarget !== undefined) {
|
|
822
863
|
// Checked against the base tip at merge time, not against a stale advisory:
|
|
@@ -826,7 +867,7 @@ async function prMergeVerb(
|
|
|
826
867
|
return refuse(
|
|
827
868
|
"chain-unreadable",
|
|
828
869
|
`refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
|
|
829
|
-
|
|
870
|
+
issue,
|
|
830
871
|
);
|
|
831
872
|
}
|
|
832
873
|
const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
|
|
@@ -835,7 +876,7 @@ async function prMergeVerb(
|
|
|
835
876
|
return refuse(
|
|
836
877
|
"chain-unreadable",
|
|
837
878
|
`refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
|
|
838
|
-
|
|
879
|
+
issue,
|
|
839
880
|
);
|
|
840
881
|
}
|
|
841
882
|
const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
|
|
@@ -843,7 +884,7 @@ async function prMergeVerb(
|
|
|
843
884
|
return refuse(
|
|
844
885
|
"chain-unreadable",
|
|
845
886
|
`refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
|
|
846
|
-
|
|
887
|
+
issue,
|
|
847
888
|
);
|
|
848
889
|
}
|
|
849
890
|
const violations = chainViolations({ base: base.entries, added, changed, deleted });
|
|
@@ -851,7 +892,7 @@ async function prMergeVerb(
|
|
|
851
892
|
return refuse(
|
|
852
893
|
"chain-conflict",
|
|
853
894
|
`refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
|
|
854
|
-
|
|
895
|
+
issue,
|
|
855
896
|
);
|
|
856
897
|
}
|
|
857
898
|
}
|
|
@@ -859,12 +900,14 @@ async function prMergeVerb(
|
|
|
859
900
|
|
|
860
901
|
const outcome = await deps.actions.mergePr(prUrl, headSha);
|
|
861
902
|
if (!outcome.ok) {
|
|
862
|
-
return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`,
|
|
903
|
+
return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, issue);
|
|
863
904
|
}
|
|
864
905
|
return allow(
|
|
865
|
-
`merged ${prUrl} at ${headSha} (${verification.reason})
|
|
906
|
+
`merged ${prUrl} at ${headSha} (${verification.reason})${
|
|
907
|
+
recovery === undefined ? "." : " under its exact operator recovery authorization."
|
|
908
|
+
}`,
|
|
866
909
|
outcome.sha ?? headSha,
|
|
867
|
-
|
|
910
|
+
issue,
|
|
868
911
|
);
|
|
869
912
|
} finally {
|
|
870
913
|
deps.store.releaseMergeLock(project.name, holderId);
|
|
@@ -1031,6 +1074,77 @@ async function releaseVerb(
|
|
|
1031
1074
|
if (!outcome.ok) {
|
|
1032
1075
|
return refuse("action-failed", `refused: the release command failed:\n${outcome.stderr}`);
|
|
1033
1076
|
}
|
|
1077
|
+
if (shape === "version-bump-pr") {
|
|
1078
|
+
if (outcome.review === undefined) {
|
|
1079
|
+
return allow(
|
|
1080
|
+
`release version for ${repo.name} is already prepared${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
|
|
1081
|
+
outcome.sha,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
if (outcome.sha === undefined) {
|
|
1085
|
+
return refuse("action-failed", "refused: version-bump-pr returned no exact pull-request head.");
|
|
1086
|
+
}
|
|
1087
|
+
if (outcome.review.created) {
|
|
1088
|
+
return allow(
|
|
1089
|
+
`opened release preparation ${outcome.review.prUrl} at ${outcome.sha}; wait for its checks, then call version-bump-pr again.`,
|
|
1090
|
+
outcome.sha,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const holderId = randomUUID();
|
|
1095
|
+
const lock = deps.store.acquireMergeLock(
|
|
1096
|
+
project.name,
|
|
1097
|
+
holderId,
|
|
1098
|
+
outcome.review.prUrl,
|
|
1099
|
+
deps.now(),
|
|
1100
|
+
MERGE_LOCK_STALE_MS,
|
|
1101
|
+
);
|
|
1102
|
+
if (lock === undefined) {
|
|
1103
|
+
const live = deps.store.mergeLock(project.name);
|
|
1104
|
+
return refuse(
|
|
1105
|
+
"merge-in-flight",
|
|
1106
|
+
`refused: a merge is already in flight for ${project.name}` +
|
|
1107
|
+
(live === undefined ? "" : ` (${live.prUrl})`) +
|
|
1108
|
+
". Release preparation waits for the project's single merge slot.",
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
let verification: PrVerification | undefined;
|
|
1113
|
+
try {
|
|
1114
|
+
verification = await deps.tracker.verifyPr(outcome.review.prUrl, outcome.sha);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1117
|
+
return refuse(
|
|
1118
|
+
"head-unresolvable",
|
|
1119
|
+
`refused: the live head of release preparation ${outcome.review.prUrl} could not be read (${why}).`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (verification === undefined) {
|
|
1123
|
+
return refuse(
|
|
1124
|
+
"head-unresolvable",
|
|
1125
|
+
`refused: the live head of release preparation ${outcome.review.prUrl} could not be resolved.`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
if (verification.status !== "green") {
|
|
1129
|
+
const stale = verification.status === "failed" && isHeadMismatch(verification.reason);
|
|
1130
|
+
return refuse(
|
|
1131
|
+
stale ? "head-stale" : "checks-not-green",
|
|
1132
|
+
`refused: release preparation ${outcome.review.prUrl} is not mergeable at ${outcome.sha} — ${verification.reason}.`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const merged = await deps.actions.mergePr(outcome.review.prUrl, outcome.sha);
|
|
1137
|
+
if (!merged.ok) {
|
|
1138
|
+
return refuse("action-failed", `refused: gh could not merge the release preparation:\n${merged.stderr}`);
|
|
1139
|
+
}
|
|
1140
|
+
return allow(
|
|
1141
|
+
`merged release preparation ${outcome.review.prUrl} at ${outcome.sha}; git-tag remains refused until the live version matches.`,
|
|
1142
|
+
merged.sha ?? outcome.sha,
|
|
1143
|
+
);
|
|
1144
|
+
} finally {
|
|
1145
|
+
deps.store.releaseMergeLock(project.name, holderId);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1034
1148
|
return allow(
|
|
1035
1149
|
`cut ${shape} for ${repo.name}${outcome.detail === undefined ? "" : ` — ${outcome.detail}`}.`,
|
|
1036
1150
|
outcome.sha,
|