@codyswann/lisa 4.25.3 → 4.26.1
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/dist/cli/check-learnings-budget-cmd.d.ts +2 -0
- package/dist/cli/check-learnings-budget-cmd.d.ts.map +1 -1
- package/dist/cli/check-learnings-budget-cmd.js +17 -6
- package/dist/cli/check-learnings-budget-cmd.js.map +1 -1
- package/dist/cli/gate-commands.d.ts +3 -1
- package/dist/cli/gate-commands.d.ts.map +1 -1
- package/dist/cli/gate-commands.js +5 -2
- package/dist/cli/gate-commands.js.map +1 -1
- package/dist/core/configured-learnings-path.d.ts +16 -0
- package/dist/core/configured-learnings-path.d.ts.map +1 -0
- package/dist/core/configured-learnings-path.js +42 -0
- package/dist/core/configured-learnings-path.js.map +1 -0
- package/dist/core/learnings-budget-check.d.ts +13 -3
- package/dist/core/learnings-budget-check.d.ts.map +1 -1
- package/dist/core/learnings-budget-check.js +41 -10
- package/dist/core/learnings-budget-check.js.map +1 -1
- package/dist/core/learnings-overflow-path.d.ts +11 -0
- package/dist/core/learnings-overflow-path.d.ts.map +1 -0
- package/dist/core/learnings-overflow-path.js +19 -0
- package/dist/core/learnings-overflow-path.js.map +1 -0
- package/dist/core/learnings-overflow.d.ts +1 -11
- package/dist/core/learnings-overflow.d.ts.map +1 -1
- package/dist/core/learnings-overflow.js +2 -17
- package/dist/core/learnings-overflow.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +12 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +4 -4
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +7 -7
- package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +4 -38
- package/dist/core/project-config.js.map +1 -1
- package/dist/core/safe-relative-markdown-path.d.ts +10 -0
- package/dist/core/safe-relative-markdown-path.d.ts.map +1 -0
- package/dist/core/safe-relative-markdown-path.js +42 -0
- package/dist/core/safe-relative-markdown-path.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +9 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-learnings-budget.ts +96 -22
- package/scripts/generate-nightly-e2e-guard-certificate.mjs +5 -0
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +338 -39
|
@@ -96,6 +96,42 @@
|
|
|
96
96
|
* label, because manufacturing a bypass surface in a repository that never
|
|
97
97
|
* adopted §6 is a new hole rather than a closed one.
|
|
98
98
|
*
|
|
99
|
+
* ## The newest run is not the newest CONCLUSIVE run (contract 1.8.0, rows 41-42)
|
|
100
|
+
*
|
|
101
|
+
* Measured on 2026-08-29 in a consuming repository: two runs of the same native
|
|
102
|
+
* suite, seven seconds apart. The first concluded `success` with all eight jobs
|
|
103
|
+
* green — the whole suite executed. The second concluded `cancelled` with one
|
|
104
|
+
* cancelled preflight job and nothing else, because the suite's `concurrency`
|
|
105
|
+
* group keeps only ONE pending run and displaces the rest. The second run tested
|
|
106
|
+
* nothing, but it was the newest completed run, so the gate scored it and
|
|
107
|
+
* reported `⚪ UNKNOWN [cancelled]` over a suite that had genuinely passed. The
|
|
108
|
+
* gate is a required check, so that blocked every merge for three hours.
|
|
109
|
+
*
|
|
110
|
+
* Refusing to score a cancellation green stays exactly as it was (rows 6-8).
|
|
111
|
+
* The defect is one layer upstream: SELECTION. `observe` now walks candidates
|
|
112
|
+
* newest-first and stops at the first that produced evidence.
|
|
113
|
+
*
|
|
114
|
+
* "Produced evidence" is decided from the run's JOBS, not from a status
|
|
115
|
+
* allowlist, because `cancelled` is overloaded across at least three causes —
|
|
116
|
+
* a duplicate displaced by the concurrency group (tested nothing), a job killed
|
|
117
|
+
* at its own `timeout-minutes` ceiling (tested most of the suite), and an
|
|
118
|
+
* operator cancel (anywhere in between) — and only the first may ever be walked
|
|
119
|
+
* past. See `runProducedEvidence`.
|
|
120
|
+
*
|
|
121
|
+
* Bounded twice over: the walk stops at the first run with evidence, and it
|
|
122
|
+
* never leaves the arm's own freshness window. Without the second bound a stale
|
|
123
|
+
* green could hold the gate open indefinitely, which is the opposite failure and
|
|
124
|
+
* a worse one. When nothing conclusive is found inside the window it falls back
|
|
125
|
+
* to the newest run, so `stale_run` / `indecisive_conclusion` / `no_run` still
|
|
126
|
+
* fire and the gate still blocks. That fallback is what makes this change unable
|
|
127
|
+
* to invent a pass: it may only ever promote an older FRESH, CONCLUSIVE run over
|
|
128
|
+
* a run that tested nothing.
|
|
129
|
+
*
|
|
130
|
+
* And it SAYS SO. The original failure was silent — a real verdict was replaced
|
|
131
|
+
* with no trace — so every finding carries a `selection` record naming the run
|
|
132
|
+
* that was scored and every run walked past with the reason. A gate that quietly
|
|
133
|
+
* changes which run it scores is the same class of defect one layer down.
|
|
134
|
+
*
|
|
99
135
|
* ## Inherited from three implementations, with one path closed
|
|
100
136
|
*
|
|
101
137
|
* `DECISIVE_CONCLUSIONS` comes from acmeorgb's `check-nightly-e2e.mjs` and is
|
|
@@ -118,7 +154,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
|
118
154
|
* rather than running a contract neither half agrees on. See §8 of
|
|
119
155
|
* `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
|
|
120
156
|
*/
|
|
121
|
-
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.
|
|
157
|
+
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.8.0";
|
|
122
158
|
|
|
123
159
|
/**
|
|
124
160
|
* The conclusions that constitute a verdict about the code.
|
|
@@ -152,6 +188,19 @@ export const GREEN_CONCLUSION = "success";
|
|
|
152
188
|
*/
|
|
153
189
|
export const COUNTED_EVENTS = Object.freeze(["schedule", "workflow_dispatch"]);
|
|
154
190
|
|
|
191
|
+
/**
|
|
192
|
+
* How many completed runs per event the selection walk (rows 41-42) may read.
|
|
193
|
+
*
|
|
194
|
+
* Bounded on purpose. The walk exists to step over runs that tested nothing, and
|
|
195
|
+
* every candidate it examines costs a job read on the merge-gate path; an
|
|
196
|
+
* unbounded walk would turn a long chain of displaced duplicates into an
|
|
197
|
+
* unbounded number of requests. Ten is ample — the measured displacement chain
|
|
198
|
+
* that motivated this was two runs, seven seconds apart — and in the ordinary
|
|
199
|
+
* case where the newest run is conclusive the walk still reads exactly one run's
|
|
200
|
+
* jobs, the same as before. The freshness window bounds it a second time.
|
|
201
|
+
*/
|
|
202
|
+
export const RUN_CANDIDATE_PAGE_SIZE = 10;
|
|
203
|
+
|
|
155
204
|
/** Suite states. `unknown` is "no readable verdict", which is not a pass. */
|
|
156
205
|
export const SUITE_STATES = Object.freeze({
|
|
157
206
|
pass: "pass",
|
|
@@ -814,6 +863,67 @@ export function stateForConclusion(conclusion) {
|
|
|
814
863
|
return SUITE_STATES.unknown;
|
|
815
864
|
}
|
|
816
865
|
|
|
866
|
+
/**
|
|
867
|
+
* Whether a run produced EVIDENCE about the code — rows 41-42's discriminator.
|
|
868
|
+
*
|
|
869
|
+
* This decides only whether a run is worth SCORING, never what its verdict is.
|
|
870
|
+
* A run with evidence goes to `assessSuite` and is judged there exactly as
|
|
871
|
+
* before; a run without evidence is stepped over so the next-older candidate can
|
|
872
|
+
* be judged instead.
|
|
873
|
+
*
|
|
874
|
+
* Evidence is read from the run's JOBS rather than from a status allowlist,
|
|
875
|
+
* because `cancelled` is overloaded across at least three distinct causes — a
|
|
876
|
+
* duplicate displaced by the suite's `concurrency` group (tested nothing), a job
|
|
877
|
+
* killed at its own `timeout-minutes` ceiling (tested most of the suite), and an
|
|
878
|
+
* operator cancel (anywhere in between). A status-only rule cannot tell them
|
|
879
|
+
* apart, and only the first may ever be walked past.
|
|
880
|
+
*
|
|
881
|
+
* Four cases, and three of them answer "scoreable":
|
|
882
|
+
*
|
|
883
|
+
* 1. A DECISIVE run conclusion is evidence whatever it says — including
|
|
884
|
+
* `failure`. Nothing here may skip over a red run to find an older green.
|
|
885
|
+
* 2. An indecisive conclusion whose jobs contain at least one decisive outcome
|
|
886
|
+
* is PARTIAL evidence. Scoreable, and it will block, because a suite killed
|
|
887
|
+
* part-way through reached no verdict. Deliberately not skipped: walking past
|
|
888
|
+
* it to an older green would RELAX the gate, which is the failure mode a fix
|
|
889
|
+
* here must not introduce.
|
|
890
|
+
* 3. An UNREAD job list — empty, or a list that would not load, whether it
|
|
891
|
+
* failed on the first page or part-way through — is scoreable. Fail-closed:
|
|
892
|
+
* a completed run always has at least one job, so an empty list is an unread
|
|
893
|
+
* one, and "we could not check" is never grounds to skip a run. A PARTIAL
|
|
894
|
+
* read belongs here rather than in case 4 for exactly the same reason: 100
|
|
895
|
+
* indecisive jobs and an unreadable page 2 do not show that the run tested
|
|
896
|
+
* nothing, and skipping it would hide a failure on the page that would not
|
|
897
|
+
* load behind an older green.
|
|
898
|
+
* 4. An indecisive conclusion whose job list was read IN FULL and holds no
|
|
899
|
+
* decisive outcome tested nothing. This is the only case that is skipped.
|
|
900
|
+
*
|
|
901
|
+
* @param {object} run - An Actions run
|
|
902
|
+
* @param {ReadonlyArray<object>|null|undefined} jobs - That run's jobs, as read
|
|
903
|
+
* @param {boolean} [complete] - Whether that job list was read to its end; a partial read is never grounds to skip
|
|
904
|
+
* @returns {boolean} True when the run is worth scoring
|
|
905
|
+
*/
|
|
906
|
+
export function runProducedEvidence(run, jobs, complete = true) {
|
|
907
|
+
if (DECISIVE_CONCLUSIONS.has(run?.conclusion)) return true;
|
|
908
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return true;
|
|
909
|
+
if (complete !== true) return true;
|
|
910
|
+
return jobs.some(job => DECISIVE_CONCLUSIONS.has(job?.conclusion));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Counts how many of a run's jobs reached a verdict.
|
|
915
|
+
*
|
|
916
|
+
* Carried onto the `selection` record so the report can say "0 of 1 job(s)
|
|
917
|
+
* reached a verdict" rather than the unfalsifiable "it tested nothing".
|
|
918
|
+
*
|
|
919
|
+
* @param {ReadonlyArray<object>|null|undefined} jobs - A run's jobs
|
|
920
|
+
* @returns {number} How many carry a decisive conclusion
|
|
921
|
+
*/
|
|
922
|
+
export function countDecisiveJobs(jobs) {
|
|
923
|
+
if (!Array.isArray(jobs)) return 0;
|
|
924
|
+
return jobs.filter(job => DECISIVE_CONCLUSIONS.has(job?.conclusion)).length;
|
|
925
|
+
}
|
|
926
|
+
|
|
817
927
|
/**
|
|
818
928
|
* Reads the scope markers a run published, from its artifact NAMES.
|
|
819
929
|
*
|
|
@@ -969,7 +1079,16 @@ export function assessSuiteScope(suite, scope) {
|
|
|
969
1079
|
* @returns {{label: string, state: string, reason: string, conclusion: string|null, url: string|null, createdAt: string|null, event: string|null}} The finding
|
|
970
1080
|
*/
|
|
971
1081
|
export function assessSuite(suite, observation, context) {
|
|
972
|
-
|
|
1082
|
+
// Rows 41-42. Attached to EVERY return path, because the defect being fixed
|
|
1083
|
+
// was a gate silently changing which run it scored — a selection nobody can
|
|
1084
|
+
// see is the same class of defect one layer down. Attached only when the
|
|
1085
|
+
// observation carried one, so a caller that never went through `observe`
|
|
1086
|
+
// produces byte-identical findings.
|
|
1087
|
+
const base = {
|
|
1088
|
+
label: suite.label,
|
|
1089
|
+
workflow: suite.workflow,
|
|
1090
|
+
...(observation.selection ? { selection: observation.selection } : {}),
|
|
1091
|
+
};
|
|
973
1092
|
const blank = { conclusion: null, url: null, createdAt: null, event: null };
|
|
974
1093
|
// An observation with no `scope` at all is the same fact as an artifacts list
|
|
975
1094
|
// that would not load — "nobody asked" and "the answer did not come back" are
|
|
@@ -993,6 +1112,27 @@ export function assessSuite(suite, observation, context) {
|
|
|
993
1112
|
* @returns {object} The finding
|
|
994
1113
|
*/
|
|
995
1114
|
const green = (seen, reason) => {
|
|
1115
|
+
// Row 26's rule, applied to the half of it a job list cannot show you.
|
|
1116
|
+
// Every green below is argued from the jobs that were READ, so a list the
|
|
1117
|
+
// walk could not finish reading cannot support one: a `success` run whose
|
|
1118
|
+
// page 2 would not load looks, from page 1, exactly like a `success` run
|
|
1119
|
+
// with nothing behind it — and the failing shard hides on the page that
|
|
1120
|
+
// 404'd. The empty-list case already landed on `incomplete_run` for this
|
|
1121
|
+
// reason; a PARTIAL read is the same "we could not check", and it must not
|
|
1122
|
+
// render as "it is fine" either.
|
|
1123
|
+
//
|
|
1124
|
+
// Placed inside `green` on purpose, so it can only ever turn a PASS into
|
|
1125
|
+
// `unknown`. A decisive run or job conclusion returns before reaching here,
|
|
1126
|
+
// which keeps `failure` conclusive and unswallowable — an unreadable page
|
|
1127
|
+
// is never allowed to soften a red into an inconclusive.
|
|
1128
|
+
if (observation.jobsComplete === false) {
|
|
1129
|
+
return {
|
|
1130
|
+
...base,
|
|
1131
|
+
...seen,
|
|
1132
|
+
state: SUITE_STATES.unknown,
|
|
1133
|
+
reason: INCOMPLETE_EVIDENCE_REASON,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
996
1136
|
const disqualifier = assessSuiteScope(suite, scope);
|
|
997
1137
|
if (disqualifier) {
|
|
998
1138
|
return {
|
|
@@ -1544,14 +1684,15 @@ const REASON_TEXT = Object.freeze({
|
|
|
1544
1684
|
"the workflow file this gate watches does not exist any more. Someone renamed or deleted the suite out from under the gate — fix the `suites` table or restore the workflow.",
|
|
1545
1685
|
no_run: "no completed run on this branch at all.",
|
|
1546
1686
|
stale_run: "no completed run inside the freshness window.",
|
|
1547
|
-
wrong_branch: "the
|
|
1548
|
-
stale_sha:
|
|
1687
|
+
wrong_branch: "the run this gate scored is on a different branch.",
|
|
1688
|
+
stale_sha:
|
|
1689
|
+
"the run this gate scored is for a different commit than the one required.",
|
|
1549
1690
|
indecisive_conclusion:
|
|
1550
|
-
"the
|
|
1691
|
+
"the run this gate scored reached no verdict about the code (cancelled / skipped / neutral). That is not a green — cancelling a run must never be a one-click way to clear a merge gate. The `↳` line beneath names the run that was scored and every run walked past for having tested nothing, so a displaced duplicate is no longer mistaken for last night's verdict.",
|
|
1551
1692
|
job_not_found:
|
|
1552
1693
|
"the run completed without ever producing the job this gate reads. The job was renamed, which silently disarms the gate.",
|
|
1553
1694
|
pattern_matched_nothing:
|
|
1554
|
-
"the job pattern matched zero jobs in the
|
|
1695
|
+
"the job pattern matched zero jobs in the run this gate scored. Zero matches is the signature of a renamed job.",
|
|
1555
1696
|
[INCOMPLETE_EVIDENCE_REASON]:
|
|
1556
1697
|
"the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
|
|
1557
1698
|
[FILTERED_RUN_REASON]:
|
|
@@ -1610,6 +1751,53 @@ export function formatFinding(finding) {
|
|
|
1610
1751
|
return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${scopeDetail}${link}${grace}`;
|
|
1611
1752
|
}
|
|
1612
1753
|
|
|
1754
|
+
/**
|
|
1755
|
+
* Renders which run a finding was scored on, and every run walked past.
|
|
1756
|
+
*
|
|
1757
|
+
* Rows 41-42, and it prints on EVERY finding that has a selection — greens
|
|
1758
|
+
* included. The defect this closes was silent: a run that tested nothing
|
|
1759
|
+
* replaced the verdict of a run that tested everything, with no trace, and three
|
|
1760
|
+
* hours went into debugging a suite that was fine. A gate that quietly changes
|
|
1761
|
+
* which run it scores is the same defect one layer down, so the selection is not
|
|
1762
|
+
* conditional on anything having gone wrong.
|
|
1763
|
+
*
|
|
1764
|
+
* A scored run that reached NO verdict also names its cause, from the job
|
|
1765
|
+
* counts the walk already read. `cancelled` is overloaded across a displaced
|
|
1766
|
+
* duplicate, a job killed at its own `timeout-minutes` ceiling, and an operator
|
|
1767
|
+
* cancel; "0 of 1 job(s) reached a verdict" and "7 of 8" are different enough
|
|
1768
|
+
* that a reader can tell "it never started" from "it ran out of time" without
|
|
1769
|
+
* opening the run. That costs nothing — the counts come from jobs already
|
|
1770
|
+
* fetched, and no workflow job configuration is read to produce them.
|
|
1771
|
+
*
|
|
1772
|
+
* A DECISIVE conclusion gets no such suffix. `success` and `failure` already say
|
|
1773
|
+
* what happened, and appending job arithmetic to every green is noise.
|
|
1774
|
+
*
|
|
1775
|
+
* @param {object|null|undefined} selection - The finding's selection record
|
|
1776
|
+
* @returns {string|null} One trailing line, or null when there is nothing to say
|
|
1777
|
+
*/
|
|
1778
|
+
export function formatSelection(selection) {
|
|
1779
|
+
if (!selection) return null;
|
|
1780
|
+
const conclusion = selection.conclusion ?? null;
|
|
1781
|
+
const counted =
|
|
1782
|
+
!DECISIVE_CONCLUSIONS.has(conclusion) &&
|
|
1783
|
+
typeof selection.decisiveJobs === "number" &&
|
|
1784
|
+
typeof selection.totalJobs === "number";
|
|
1785
|
+
const cause = counted
|
|
1786
|
+
? ` — ${selection.decisiveJobs} of ${selection.totalJobs} job(s) reached a verdict, so it ${selection.decisiveJobs === 0 ? "tested nothing" : "ran but did not finish"}`
|
|
1787
|
+
: "";
|
|
1788
|
+
const scored = `↳ scored run ${selection.runId ?? "?"} (${conclusion ?? "no conclusion"}${cause}, ${selection.createdAt ?? "unknown time"})`;
|
|
1789
|
+
const fallback = selection.fellBack
|
|
1790
|
+
? " (no conclusive run in the freshness window — fell back to the newest)"
|
|
1791
|
+
: "";
|
|
1792
|
+
const skipped = (selection.skipped ?? [])
|
|
1793
|
+
.map(
|
|
1794
|
+
entry =>
|
|
1795
|
+
`${entry.runId ?? "?"} [${entry.conclusion ?? "no conclusion"} — ${entry.decisiveJobs} of ${entry.totalJobs} job(s) reached a verdict, so it tested nothing]`
|
|
1796
|
+
)
|
|
1797
|
+
.join(", ");
|
|
1798
|
+
return `${scored}${fallback}${skipped ? `; skipped ${skipped}` : ""}`;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1613
1801
|
/**
|
|
1614
1802
|
* Renders the full report.
|
|
1615
1803
|
*
|
|
@@ -1619,7 +1807,13 @@ export function formatFinding(finding) {
|
|
|
1619
1807
|
*/
|
|
1620
1808
|
export function formatReport(verdict, context) {
|
|
1621
1809
|
const lines = ["## 🌙 Nightly E2E Health", ""];
|
|
1622
|
-
lines.push(
|
|
1810
|
+
lines.push(
|
|
1811
|
+
...verdict.findings.flatMap(finding => {
|
|
1812
|
+
const line = `- ${formatFinding(finding)}`;
|
|
1813
|
+
const selection = formatSelection(finding.selection);
|
|
1814
|
+
return selection === null ? [line] : [line, ` - ${selection}`];
|
|
1815
|
+
})
|
|
1816
|
+
);
|
|
1623
1817
|
lines.push("");
|
|
1624
1818
|
|
|
1625
1819
|
// A limit the caller asked for and did not get must be visible, or the gate
|
|
@@ -2084,7 +2278,11 @@ function issueBody(finding, context) {
|
|
|
2084
2278
|
`| Workflow | \`${finding.workflow ?? "—"}\` |`,
|
|
2085
2279
|
`| Branch | \`${context.branch}\` |`,
|
|
2086
2280
|
`| Blocks merges | ${blockingCell} |`,
|
|
2087
|
-
|
|
2281
|
+
// "Scored", not "Newest": since the selection walk landed, the run this
|
|
2282
|
+
// row describes is the newest CONCLUSIVE one, which may be older than the
|
|
2283
|
+
// newest candidate. Labelling it "Newest" would reintroduce the exact
|
|
2284
|
+
// confusion the walk exists to remove.
|
|
2285
|
+
`| Scored run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
|
|
2088
2286
|
`| Why it is not green | ${detail || finding.reason} |`,
|
|
2089
2287
|
`| Last checked | ${context.now.toISOString()} |`,
|
|
2090
2288
|
"",
|
|
@@ -2449,29 +2647,37 @@ export async function apiGet(api, path, wait = sleep) {
|
|
|
2449
2647
|
}
|
|
2450
2648
|
|
|
2451
2649
|
/**
|
|
2452
|
-
*
|
|
2650
|
+
* The newest completed runs of one workflow on one branch for one event.
|
|
2651
|
+
*
|
|
2652
|
+
* Reads a bounded PAGE rather than a single run, which is rows 41-42's whole
|
|
2653
|
+
* mechanism. `per_page: "1"` made "the newest run" and "the newest run that
|
|
2654
|
+
* tested anything" the same query; they are not, and a displaced duplicate that
|
|
2655
|
+
* executed nothing is routinely the newer of the two.
|
|
2453
2656
|
*
|
|
2454
2657
|
* @param {object} api - API coordinates
|
|
2455
2658
|
* @param {string} file - Workflow file name
|
|
2456
2659
|
* @param {string} branch - Branch to read
|
|
2457
2660
|
* @param {string} event - Run event
|
|
2458
2661
|
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
2459
|
-
* @returns {Promise<{
|
|
2662
|
+
* @returns {Promise<{runs: ReadonlyArray<object>, missing: boolean}>} The candidates, or a 404 marker
|
|
2460
2663
|
*/
|
|
2461
|
-
export async function
|
|
2664
|
+
export async function fetchRunCandidates(api, file, branch, event, wait) {
|
|
2462
2665
|
const query = new URLSearchParams({
|
|
2463
2666
|
branch,
|
|
2464
2667
|
status: "completed",
|
|
2465
2668
|
event,
|
|
2466
|
-
per_page:
|
|
2669
|
+
per_page: String(RUN_CANDIDATE_PAGE_SIZE),
|
|
2467
2670
|
});
|
|
2468
2671
|
const result = await apiGet(
|
|
2469
2672
|
api,
|
|
2470
2673
|
`/repos/${api.repo}/actions/workflows/${encodeURIComponent(file)}/runs?${query}`,
|
|
2471
2674
|
wait
|
|
2472
2675
|
);
|
|
2473
|
-
if (result === null) return {
|
|
2474
|
-
return {
|
|
2676
|
+
if (result === null) return { runs: Object.freeze([]), missing: true };
|
|
2677
|
+
return {
|
|
2678
|
+
runs: Object.freeze(result.body.workflow_runs ?? []),
|
|
2679
|
+
missing: false,
|
|
2680
|
+
};
|
|
2475
2681
|
}
|
|
2476
2682
|
|
|
2477
2683
|
/**
|
|
@@ -2482,10 +2688,18 @@ export async function fetchNewestRun(api, file, branch, event, wait) {
|
|
|
2482
2688
|
* Hitting the page cap while still unread is raised rather than silently
|
|
2483
2689
|
* truncated.
|
|
2484
2690
|
*
|
|
2691
|
+
* `complete` says whether the walk reached the end of the list, and it is the
|
|
2692
|
+
* half a caller cannot reconstruct from the jobs alone. A list cut short by a
|
|
2693
|
+
* mid-walk 404 is indistinguishable, by inspection, from a short list that was
|
|
2694
|
+
* read in full — and the two must not be treated alike: 100 indecisive jobs
|
|
2695
|
+
* followed by an unreadable page 2 is *not* proof that the run tested nothing,
|
|
2696
|
+
* because the failing shard may be on the page that would not load. Callers
|
|
2697
|
+
* that decide anything from the ABSENCE of a job outcome must consult it.
|
|
2698
|
+
*
|
|
2485
2699
|
* @param {object} api - API coordinates
|
|
2486
2700
|
* @param {number|string} runId - The run
|
|
2487
2701
|
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
2488
|
-
* @returns {Promise<ReadonlyArray<object
|
|
2702
|
+
* @returns {Promise<{jobs: ReadonlyArray<object>, complete: boolean}>} All jobs read, and whether the list was read to its end
|
|
2489
2703
|
*/
|
|
2490
2704
|
export async function fetchAllJobs(api, runId, wait) {
|
|
2491
2705
|
const jobs = [];
|
|
@@ -2496,13 +2710,15 @@ export async function fetchAllJobs(api, runId, wait) {
|
|
|
2496
2710
|
wait
|
|
2497
2711
|
);
|
|
2498
2712
|
// A 404 means the run's job list stopped being readable mid-walk. Return
|
|
2499
|
-
// what was read; falling through to the page-cap throw
|
|
2500
|
-
// pagination limit that had nothing to do with it AND
|
|
2501
|
-
// already collected.
|
|
2502
|
-
if (result === null)
|
|
2713
|
+
// what was read, flagged INCOMPLETE; falling through to the page-cap throw
|
|
2714
|
+
// below would blame a pagination limit that had nothing to do with it AND
|
|
2715
|
+
// discard every job already collected.
|
|
2716
|
+
if (result === null)
|
|
2717
|
+
return Object.freeze({ jobs: Object.freeze(jobs), complete: false });
|
|
2503
2718
|
const batch = result.body.jobs ?? [];
|
|
2504
2719
|
jobs.push(...batch);
|
|
2505
|
-
if (batch.length < 100)
|
|
2720
|
+
if (batch.length < 100)
|
|
2721
|
+
return Object.freeze({ jobs: Object.freeze(jobs), complete: true });
|
|
2506
2722
|
}
|
|
2507
2723
|
throw new GateApiError(
|
|
2508
2724
|
`Run ${runId} reports more jobs than \`api_max_pages\` (${api.maxPages}) allows this gate to read. A truncated job list can hide the failing shard, so this is RED rather than a partial read.`
|
|
@@ -2759,58 +2975,139 @@ export async function fetchRunArtifacts(api, runId, wait) {
|
|
|
2759
2975
|
return null;
|
|
2760
2976
|
}
|
|
2761
2977
|
|
|
2978
|
+
/**
|
|
2979
|
+
* Selects the run one suite will be scored on, and records what it walked past.
|
|
2980
|
+
*
|
|
2981
|
+
* Rows 41-42. Candidates arrive newest-first; the walk stops at the first that
|
|
2982
|
+
* produced evidence (`runProducedEvidence`) and never leaves the arm's own
|
|
2983
|
+
* freshness window. Both bounds are load-bearing. Without the first the gate
|
|
2984
|
+
* would keep reading runs it has no reason to; without the second a stale green
|
|
2985
|
+
* could hold the gate open indefinitely, which is the opposite failure and a
|
|
2986
|
+
* worse one.
|
|
2987
|
+
*
|
|
2988
|
+
* When nothing conclusive is found inside the window it falls back to the NEWEST
|
|
2989
|
+
* candidate — the run the gate scored before this walk existed — so `stale_run`,
|
|
2990
|
+
* `indecisive_conclusion` and the rest still fire and the gate still blocks.
|
|
2991
|
+
* That fallback is what makes selection unable to invent a pass: it may only
|
|
2992
|
+
* ever promote an older fresh, conclusive run over a run that tested nothing.
|
|
2993
|
+
*
|
|
2994
|
+
* @param {object} api - API coordinates
|
|
2995
|
+
* @param {ReadonlyArray<object>} candidates - Completed runs, newest first
|
|
2996
|
+
* @param {number} freshnessHours - This arm's freshness window
|
|
2997
|
+
* @param {Date} now - Evaluation instant
|
|
2998
|
+
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
2999
|
+
* @returns {Promise<{run: object, jobs: ReadonlyArray<object>, jobsComplete: boolean, selection: object}>} The scored run, its jobs, whether that job list was read to its end, and the audit
|
|
3000
|
+
*/
|
|
3001
|
+
async function selectScoredRun(api, candidates, freshnessHours, now, wait) {
|
|
3002
|
+
const jobsById = new Map();
|
|
3003
|
+
const walkedPast = [];
|
|
3004
|
+
let chosen = null;
|
|
3005
|
+
for (const candidate of candidates) {
|
|
3006
|
+
if (!isFresh(candidate, freshnessHours, now)) break;
|
|
3007
|
+
const read = await fetchAllJobs(api, candidate.id, wait);
|
|
3008
|
+
const { jobs, complete } = read;
|
|
3009
|
+
jobsById.set(candidate.id, read);
|
|
3010
|
+
if (runProducedEvidence(candidate, jobs, complete)) {
|
|
3011
|
+
chosen = candidate;
|
|
3012
|
+
break;
|
|
3013
|
+
}
|
|
3014
|
+
walkedPast.push({
|
|
3015
|
+
runId: candidate.id ?? null,
|
|
3016
|
+
conclusion: candidate.conclusion ?? null,
|
|
3017
|
+
createdAt: candidate.created_at ?? null,
|
|
3018
|
+
decisiveJobs: countDecisiveJobs(jobs),
|
|
3019
|
+
totalJobs: jobs.length,
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
const run = chosen ?? candidates[0];
|
|
3023
|
+
const read = jobsById.get(run.id) ?? (await fetchAllJobs(api, run.id, wait));
|
|
3024
|
+
const { jobs, complete: jobsComplete } = read;
|
|
3025
|
+
// A run cannot be both scored and skipped. When the walk falls back onto a
|
|
3026
|
+
// candidate it had already stepped over — the single-candidate case — the
|
|
3027
|
+
// honest report is "this was scored because nothing better existed", not a
|
|
3028
|
+
// line contradicting itself.
|
|
3029
|
+
const skipped = walkedPast.filter(entry => entry.runId !== run.id);
|
|
3030
|
+
return {
|
|
3031
|
+
run,
|
|
3032
|
+
jobs,
|
|
3033
|
+
jobsComplete,
|
|
3034
|
+
selection: Object.freeze({
|
|
3035
|
+
runId: run.id ?? null,
|
|
3036
|
+
conclusion: run.conclusion ?? null,
|
|
3037
|
+
createdAt: run.created_at ?? null,
|
|
3038
|
+
// Carried for the SCORED run too, not only the skipped ones, so an
|
|
3039
|
+
// inconclusive verdict can name its cause. See `formatSelection`.
|
|
3040
|
+
decisiveJobs: countDecisiveJobs(jobs),
|
|
3041
|
+
totalJobs: jobs.length,
|
|
3042
|
+
fellBack: chosen === null && walkedPast.length > 0,
|
|
3043
|
+
skipped: Object.freeze(skipped.map(entry => Object.freeze(entry))),
|
|
3044
|
+
}),
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
|
|
2762
3048
|
/**
|
|
2763
3049
|
* Observes every suite.
|
|
2764
3050
|
*
|
|
2765
3051
|
* @param {object} api - API coordinates
|
|
2766
3052
|
* @param {ReadonlyArray<object>} suites - Validated suites
|
|
2767
3053
|
* @param {string} branch - Branch to read
|
|
3054
|
+
* @param {{freshnessHours: number, now: Date}} context - Evaluation context, bounding the selection walk
|
|
2768
3055
|
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
2769
3056
|
* @returns {Promise<ReadonlyArray<object>>} One observation per suite
|
|
2770
3057
|
*/
|
|
2771
|
-
export async function observe(api, suites, branch, wait) {
|
|
3058
|
+
export async function observe(api, suites, branch, context, wait) {
|
|
2772
3059
|
return await Promise.all(
|
|
2773
3060
|
suites.map(async suite => {
|
|
2774
3061
|
const perEvent = await Promise.all(
|
|
2775
3062
|
COUNTED_EVENTS.map(event =>
|
|
2776
|
-
|
|
3063
|
+
fetchRunCandidates(api, suite.workflow, branch, event, wait)
|
|
2777
3064
|
)
|
|
2778
3065
|
);
|
|
2779
3066
|
if (perEvent.every(result => result.missing)) {
|
|
2780
3067
|
return { workflowMissing: true, run: null, jobs: [] };
|
|
2781
3068
|
}
|
|
2782
|
-
// Newest by created_at, so a fresh dispatch supersedes an older
|
|
2783
|
-
// schedule. ISO-8601 UTC compares correctly as strings.
|
|
2784
|
-
const
|
|
2785
|
-
.
|
|
3069
|
+
// Newest FIRST by created_at, so a fresh dispatch supersedes an older
|
|
3070
|
+
// failed schedule. ISO-8601 UTC compares correctly as strings.
|
|
3071
|
+
const candidates = perEvent
|
|
3072
|
+
.flatMap(result => result.runs)
|
|
2786
3073
|
.filter(
|
|
2787
3074
|
candidate => candidate && typeof candidate.created_at === "string"
|
|
2788
3075
|
)
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3076
|
+
// Equal timestamps compare 0 rather than falling through to -1, which
|
|
3077
|
+
// would make the sort unstable and let two runs created in the same
|
|
3078
|
+
// second swap places between reads of the same history.
|
|
3079
|
+
.sort((left, right) =>
|
|
3080
|
+
left.created_at === right.created_at
|
|
3081
|
+
? 0
|
|
3082
|
+
: left.created_at < right.created_at
|
|
3083
|
+
? 1
|
|
3084
|
+
: -1
|
|
2795
3085
|
);
|
|
2796
3086
|
// Jobs are read for EVERY match mode, `run` included. A run-scoped suite
|
|
2797
3087
|
// needs them to prove the run was complete (row 26) — a `success` run
|
|
2798
3088
|
// that skipped half its jobs is not evidence about the half it skipped.
|
|
2799
|
-
if (
|
|
2800
|
-
return { workflowMissing: false, run, jobs: [] };
|
|
3089
|
+
if (candidates.length === 0) {
|
|
3090
|
+
return { workflowMissing: false, run: null, jobs: [] };
|
|
2801
3091
|
}
|
|
3092
|
+
const { run, jobs, jobsComplete, selection } = await selectScoredRun(
|
|
3093
|
+
api,
|
|
3094
|
+
candidates,
|
|
3095
|
+
suite.freshness_hours ?? context.freshnessHours,
|
|
3096
|
+
context.now,
|
|
3097
|
+
wait
|
|
3098
|
+
);
|
|
2802
3099
|
// Artifacts are read for every suite, not only the ones declaring
|
|
2803
3100
|
// `min_flows`: a run that recorded ITSELF as filtered disqualifies with no
|
|
2804
3101
|
// declaration at all (row 36), and a gate that only looked when asked to
|
|
2805
|
-
// would miss exactly the repos that have not adopted the field yet.
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
fetchRunArtifacts(api, run.id, wait),
|
|
2809
|
-
]);
|
|
3102
|
+
// would miss exactly the repos that have not adopted the field yet. Read
|
|
3103
|
+
// for the SCORED run, which is the run every other row also judges.
|
|
3104
|
+
const artifacts = await fetchRunArtifacts(api, run.id, wait);
|
|
2810
3105
|
return {
|
|
2811
3106
|
workflowMissing: false,
|
|
2812
3107
|
run,
|
|
2813
3108
|
jobs,
|
|
3109
|
+
jobsComplete,
|
|
3110
|
+
selection,
|
|
2814
3111
|
scope: readSuiteScope(artifacts),
|
|
2815
3112
|
};
|
|
2816
3113
|
})
|
|
@@ -3426,6 +3723,7 @@ export async function runGate(env, wait) {
|
|
|
3426
3723
|
settings.api,
|
|
3427
3724
|
settings.suites,
|
|
3428
3725
|
settings.branch,
|
|
3726
|
+
{ freshnessHours: settings.freshnessHours, now },
|
|
3429
3727
|
wait
|
|
3430
3728
|
);
|
|
3431
3729
|
const findings = settings.suites.map((suite, index) => {
|
|
@@ -3520,6 +3818,7 @@ export async function runReport(env, wait) {
|
|
|
3520
3818
|
settings.api,
|
|
3521
3819
|
settings.suites,
|
|
3522
3820
|
settings.branch,
|
|
3821
|
+
{ freshnessHours: settings.freshnessHours, now },
|
|
3523
3822
|
wait
|
|
3524
3823
|
);
|
|
3525
3824
|
const findings = settings.suites.map((suite, index) => {
|