Skip to content

Commit df15f70

Browse files
Merge branch 'main' into fix/miner-resolve-local-store-db-path-8336
2 parents 888deb2 + 9285881 commit df15f70

8 files changed

Lines changed: 450 additions & 34 deletions

File tree

.github/workflows/cache-cleanup.yml

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,48 +9,61 @@ name: Clean up closed-PR caches
99
# waiting on GitHub's passive 7-day-unused eviction. Confirmed live before adding this workflow: repo
1010
# cache usage sat at ~10.7GB of the 10GB budget, with closed-PR-scoped node_modules caches alone
1111
# accounting for the large majority of that -- crowding out the much smaller, much more useful
12-
# Turborepo/tsbuildinfo caches for LRU survival. This deletes a PR's own cache entries the moment it
13-
# closes (merged or not) instead of waiting on eviction.
12+
# Turborepo/tsbuildinfo caches for LRU survival.
1413
#
15-
# pull_request_target, not pull_request: this job only ever calls the GitHub API using
16-
# github.event.pull_request.number -- a trusted value GitHub itself populates, never anything read
17-
# from the PR's own code or checked out from it -- so the classic pull_request_target risk (running
18-
# fork-controlled code with base-repo credentials) doesn't apply here. It has to be
19-
# pull_request_target specifically because a fork-triggered plain `pull_request` run is always capped
20-
# to a read-only token regardless of the permissions block below, and deleting a cache needs
21-
# actions: write.
14+
# BATCHED, not per-event (queue-pressure fix, 2026-07-24): the original pull_request_target:[closed]
15+
# trigger meant one queued runner per closed PR -- at this repo's one-shot-gate volume that was dozens
16+
# of runs a day competing with real CI for the org's concurrent-runner cap during exactly the bursts
17+
# when CI is most backed up. A cache entry lingering a few hours costs nothing (the 10GB budget has
18+
# ample headroom between sweeps; LRU only bites near the cap), so one 6-hourly sweep that deletes every
19+
# closed-PR-scoped entry replaces N per-close runs with 4/day flat, independent of PR volume.
2220

2321
on:
24-
pull_request_target:
25-
types: [closed]
22+
schedule:
23+
- cron: "40 */6 * * *"
24+
workflow_dispatch:
2625

2726
permissions:
2827
actions: write
28+
pull-requests: read
2929

3030
concurrency:
31-
group: cache-cleanup-${{ github.event.pull_request.number }}
32-
cancel-in-progress: true
31+
group: cache-cleanup-sweep
32+
cancel-in-progress: false
3333

3434
jobs:
3535
cleanup:
36-
name: Delete this PR's caches
36+
name: Delete closed PRs' caches
3737
runs-on: ubuntu-latest
38-
timeout-minutes: 5
38+
timeout-minutes: 10
3939
steps:
40-
# Scoped by ref, not by key prefix (e.g. "npm-fork-") -- ANY cache entry scoped to this PR's ref
41-
# (node_modules, Turborepo, tsbuildinfo, trusted or fork) is equally unreachable dead weight the
42-
# moment the PR closes, regardless of which of ci.yml's cache families wrote it.
43-
- name: Delete caches scoped to this PR
40+
# Scoped by ref, not by key prefix (e.g. "npm-fork-") -- ANY cache entry scoped to a closed PR's
41+
# ref (node_modules, Turborepo, tsbuildinfo, trusted or fork) is equally unreachable dead weight,
42+
# regardless of which of ci.yml's cache families wrote it. PR state is checked per unique PR
43+
# number (never inferred from cache age) so an idle-but-open PR -- maintainer branches routinely
44+
# sit for hours between pushes -- never loses a still-restorable cache.
45+
- name: Sweep caches scoped to closed PRs
4446
env:
4547
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46-
PR_REF: refs/pull/${{ github.event.pull_request.number }}/merge
4748
run: |
48-
ids=$(gh api "repos/${{ github.repository }}/actions/caches?per_page=100" --paginate --jq ".actions_caches[] | select(.ref == \"$PR_REF\") | .id")
49-
if [ -z "$ids" ]; then
50-
echo "No caches found for $PR_REF"
49+
set -euo pipefail
50+
gh api "repos/${{ github.repository }}/actions/caches?per_page=100" --paginate \
51+
--jq '.actions_caches[] | select(.ref // "" | test("^refs/pull/[0-9]+/merge$")) | "\(.id) \(.ref)"' > caches.txt || true
52+
if [ ! -s caches.txt ]; then
53+
echo "No PR-scoped caches found."
5154
exit 0
5255
fi
53-
for id in $ids; do
54-
echo "Deleting cache $id ($PR_REF)"
55-
gh api -X DELETE "repos/${{ github.repository }}/actions/caches/$id" || echo "::warning::Failed to delete cache $id (may already be gone)"
56-
done
56+
deleted=0
57+
while read -r pr; do
58+
state=$(gh api "repos/${{ github.repository }}/pulls/$pr" --jq .state 2>/dev/null || echo "unknown")
59+
if [ "$state" != "closed" ]; then
60+
continue
61+
fi
62+
while read -r id; do
63+
echo "Deleting cache $id (refs/pull/$pr/merge)"
64+
gh api -X DELETE "repos/${{ github.repository }}/actions/caches/$id" \
65+
|| echo "::warning::Failed to delete cache $id (may already be gone)"
66+
deleted=$((deleted + 1))
67+
done < <(awk -v ref="refs/pull/$pr/merge" '$2 == ref {print $1}' caches.txt)
68+
done < <(awk '{print $2}' caches.txt | sed -E 's#refs/pull/([0-9]+)/merge#\1#' | sort -un)
69+
echo "Swept $deleted cache entr(ies) across closed PRs."

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ jobs:
649649
run: npm run control-plane:test
650650
- name: Control-plane coverage
651651
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
652-
run: npm run control-plane:coverage || true
652+
run: npm run control-plane:coverage
653653
- name: Verify control-plane coverage report exists
654654
if: ${{ github.event_name == 'push' || needs.changes.outputs.controlPlane == 'true' }}
655655
run: |
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { readdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
import { docsNav } from "./docs-nav";
6+
7+
// REGRESSION (#8385): docsNav drives both the persistent /docs/* left rail and DocsPrevNext's
8+
// prev/next footer links, but it was maintained entirely by hand alongside docs.index.tsx's own
9+
// curated card list. Six published, cross-linked pages (miner-quickstart, loopover-commands,
10+
// ai-summaries, owner-checklist, self-hosting-docs-audit, self-hosting-unified-ams-orb) had real
11+
// content/docs/*.mdx files and index-page links but no sidebar entry, so a visitor landing on one
12+
// saw an unhighlighted rail with no route to any other group, and no page's prev/next ever reached
13+
// them. This is the drift guard: content/docs/ is the source of truth for what's published, so a new
14+
// .mdx that forgets its docsNav entry fails here instead of silently shipping unreachable.
15+
//
16+
// Filesystem-reading in a vitest test follows docs-source-server-isolation.test.ts's precedent --
17+
// process.cwd() is apps/loopover-ui because this file is only matched by that workspace's own
18+
// vitest.config.ts (`include: ["src/**/*.test.{ts,tsx}"]`); the root config takes `test/**` only.
19+
describe("docsNav covers every published docs page (#8385)", () => {
20+
const contentDir = join(process.cwd(), "content/docs");
21+
const publishedSlugs = readdirSync(contentDir)
22+
.filter((name) => name.endsWith(".mdx"))
23+
.map((name) => name.slice(0, -".mdx".length))
24+
.sort();
25+
26+
const navPaths = docsNav.flatMap((group) =>
27+
"items" in group
28+
? group.items.map((item) => item.to)
29+
: group.subgroups.flatMap((sub) => sub.items.map((item) => item.to)),
30+
);
31+
32+
it("reads a non-empty content/docs directory (guards against a silently-vacuous assertion)", () => {
33+
expect(publishedSlugs.length).toBeGreaterThan(40);
34+
});
35+
36+
it("has a sidebar entry for every published .mdx page", () => {
37+
const missing = publishedSlugs.filter((slug) => !navPaths.includes(`/docs/${slug}`));
38+
expect(missing).toEqual([]);
39+
});
40+
41+
it("has no sidebar entry pointing at a page that isn't published", () => {
42+
// "/docs" is the index route itself (docs.index.tsx), not a content/docs/*.mdx page.
43+
const dangling = navPaths
44+
.filter((to) => to !== "/docs")
45+
.filter((to) => !publishedSlugs.includes(to.replace("/docs/", "")));
46+
expect(dangling).toEqual([]);
47+
});
48+
49+
it("lists every page exactly once, so prev/next can't revisit a page", () => {
50+
const duplicates = navPaths.filter((to, index) => navPaths.indexOf(to) !== index);
51+
expect(duplicates).toEqual([]);
52+
});
53+
});

apps/loopover-ui/src/components/site/docs-nav.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const docsNav: DocsGroup[] = [
1818
{ to: "/docs", label: "Overview" },
1919
{ to: "/docs/beta-onboarding", label: "Beta onboarding" },
2020
{ to: "/docs/quickstart", label: "Quickstart" },
21+
{ to: "/docs/miner-quickstart", label: "Quickstart by lane" },
2122
{ to: "/docs/mcp-clients", label: "MCP client setup" },
2223
],
2324
},
@@ -43,6 +44,7 @@ export const docsNav: DocsGroup[] = [
4344
title: "Self-hosting: integrations",
4445
items: [
4546
{ to: "/docs/self-hosting-github-app", label: "GitHub App & Orb" },
47+
{ to: "/docs/self-hosting-unified-ams-orb", label: "Unified ORB + AMS" },
4648
{ to: "/docs/self-hosting-ai-providers", label: "AI providers" },
4749
{ to: "/docs/self-hosting-rees", label: "REES enrichment" },
4850
{ to: "/docs/self-hosting-rees-analyzers", label: "REES analyzers" },
@@ -63,6 +65,7 @@ export const docsNav: DocsGroup[] = [
6365
items: [
6466
{ to: "/docs/self-hosting-releases", label: "Releases & images" },
6567
{ to: "/docs/self-hosting-release-checklist", label: "Release checklist" },
68+
{ to: "/docs/self-hosting-docs-audit", label: "Self-host docs audit" },
6669
{ to: "/docs/self-hosting-security", label: "Security" },
6770
{ to: "/docs/federated-fleet-intelligence", label: "Federated fleet intelligence" },
6871
],
@@ -73,6 +76,7 @@ export const docsNav: DocsGroup[] = [
7376
{ to: "/docs/github-app", label: "GitHub App configuration" },
7477
{ to: "/docs/maintainer-workflow", label: "Maintainer workflow" },
7578
{ to: "/docs/maintainer-install-trust", label: "Maintainer install & trust" },
79+
{ to: "/docs/owner-checklist", label: "Onboarding checklist" },
7680
],
7781
},
7882
{
@@ -97,6 +101,7 @@ export const docsNav: DocsGroup[] = [
97101
title: "Core concepts",
98102
items: [
99103
{ to: "/docs/how-reviews-work", label: "How reviews work" },
104+
{ to: "/docs/loopover-commands", label: "@loopover commands" },
100105
{ to: "/docs/branch-analysis", label: "Branch analysis" },
101106
{ to: "/docs/scoreability", label: "Scoreability" },
102107
{ to: "/docs/upstream-drift", label: "Upstream drift" },
@@ -109,6 +114,7 @@ export const docsNav: DocsGroup[] = [
109114
items: [
110115
{ to: "/docs/tuning", label: "Tuning your reviews" },
111116
{ to: "/docs/privacy-security", label: "Privacy & security" },
117+
{ to: "/docs/ai-summaries", label: "AI summaries policy" },
112118
{ to: "/docs/troubleshooting", label: "Troubleshooting" },
113119
],
114120
},

packages/loopover-miner/lib/calibration-cli.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import type { LedgerEntry } from "./event-ledger.js";
2424
import { MINER_PR_OUTCOME_EVENT } from "./pr-outcome.js";
2525
import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js";
2626
import type { PredictionLedgerEntry } from "./prediction-ledger.js";
27-
import type { PredictedVerdictRecord, ObservedOutcomeRecord, CalibrationReport } from "./calibration-types.js";
27+
import type { PredictedVerdictRecord, ObservedOutcomeRecord, CalibrationReport, CalibrationRow } from "./calibration-types.js";
2828
import { reportCliFailure, describeCliError } from "./cli-error.js";
29+
import { runHistoricalReplayCalibrationCycle, type CalibrationSnapshotPayload } from "./calibration-run.js";
2930

3031
const CALIBRATION_USAGE =
31-
"Usage: loopover-miner calibration [--json] | calibration backtest-threshold --candidate <x> [--json] | calibration apply-min-rank --candidate <x> --approve [--json] | calibration revert-min-rank --approve [--json]";
32+
"Usage: loopover-miner calibration [--json] | calibration snapshot [--json] | calibration backtest-threshold --candidate <x> [--json] | calibration apply-min-rank --candidate <x> --approve [--json] | calibration revert-min-rank --approve [--json]";
3233

3334
export type CalibrationCliDeps = {
3435
readFileSync?: typeof readFileSync;
@@ -125,6 +126,93 @@ function renderReportText(report: CalibrationReport): void {
125126
}
126127
}
127128

129+
/** Map one {@link CalibrationRow} onto the engine's {@link PrOutcomeCalibrationInput} shape (#8317) —
130+
* field-for-field; `hold` is always present on the row so it always forwards. */
131+
export function prOutcomeFromCalibrationRow(row: CalibrationRow): {
132+
mergeConfirmed: number;
133+
mergeFalse: number;
134+
closeConfirmed: number;
135+
closeFalse: number;
136+
hold: number;
137+
} {
138+
return {
139+
mergeConfirmed: row.mergeConfirmed,
140+
mergeFalse: row.mergeFalse,
141+
closeConfirmed: row.closeConfirmed,
142+
closeFalse: row.closeFalse,
143+
hold: row.hold,
144+
};
145+
}
146+
147+
/** `calibration snapshot [--json]` (#8317): run the Phase 7 calibration runner once per project row from
148+
* {@link buildCalibrationReport}, persist a `calibration_snapshot` ledger event per project (with the AMS
149+
* backtest track record attached when any runs exist), and print the results. Mirrors
150+
* {@link runBacktestThreshold}'s open/run/persist/print/finally shape. Does NOT pass a Phase 7 config —
151+
* no `.loopover-ams.yml` calibration-section reader exists yet, so the engine defaults to loop-disabled
152+
* (honest, not a workaround). */
153+
function runCalibrationSnapshot(args: string[], env: Record<string, string | undefined>, deps: CalibrationCliDeps): number {
154+
const json = args.includes("--json");
155+
const unknown = args.find((token) => token !== "--json");
156+
if (unknown) {
157+
return reportCliFailure(json, `Unknown option: ${unknown}. ${CALIBRATION_USAGE}`, 1);
158+
}
159+
160+
let predictionStore;
161+
let eventLedger;
162+
try {
163+
predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env));
164+
eventLedger = initEventLedger(resolveEventLedgerDbPath(env));
165+
const predictionRows = predictionStore.readPredictions();
166+
const events = eventLedger.readEvents();
167+
const report = buildCalibrationReport(toPredictionRecords(predictionRows), toOutcomeRecords(events));
168+
const trackRecord = computeAmsBacktestTrackRecord(readAmsThresholdBacktestRuns(eventLedger));
169+
// #8317: only attach a real history; an empty track record stays null so consumers can tell "no runs yet"
170+
// from "runs exist with zero REGRESSED" without inventing a fabricated zero-run object at the CLI boundary.
171+
const backtestTrackRecord = trackRecord.totalRuns > 0 ? trackRecord : null;
172+
173+
if (!report.hasSignal) {
174+
const message = "calibration snapshot: no decided predictions yet (predictions need a realized merge/close outcome); nothing persisted.";
175+
console.log(json ? JSON.stringify({ snapshots: [], reason: "no_decided_predictions" }) : message);
176+
return 0;
177+
}
178+
179+
const snapshots: Array<{ repoFullName: string; snapshot: CalibrationSnapshotPayload }> = [];
180+
for (const row of report.rows) {
181+
const cycle = runHistoricalReplayCalibrationCycle(
182+
{
183+
prOutcome: prOutcomeFromCalibrationRow(row),
184+
repoFullName: row.project,
185+
backtestTrackRecord,
186+
...(deps.nowMs !== undefined ? { now: new Date(deps.nowMs).toISOString() } : {}),
187+
},
188+
{ eventLedger },
189+
);
190+
snapshots.push({ repoFullName: row.project, snapshot: cycle.snapshot });
191+
}
192+
193+
if (json) {
194+
console.log(JSON.stringify({ snapshots }, null, 2));
195+
} else {
196+
for (const entry of snapshots) {
197+
const accuracy =
198+
entry.snapshot.combinedAccuracy === null ? "n/a" : `${Math.round(entry.snapshot.combinedAccuracy * 100)}%`;
199+
console.log(
200+
`calibration snapshot ${entry.repoFullName}: enabled=${entry.snapshot.enabled} combined=${accuracy} ` +
201+
`delta=${entry.snapshot.deltaFromBaseline === null ? "n/a" : entry.snapshot.deltaFromBaseline.toFixed(3)} ` +
202+
`sources=${entry.snapshot.contributingSources.join(",") || "none"}`,
203+
);
204+
}
205+
console.log(`calibration snapshot: persisted ${snapshots.length} project snapshot(s).`);
206+
}
207+
return 0;
208+
} catch (error) {
209+
return reportCliFailure(json, describeCliError(error));
210+
} finally {
211+
predictionStore?.close();
212+
eventLedger?.close();
213+
}
214+
}
215+
128216
/** `calibration backtest-threshold --candidate <x>` (#8184): advisory replay of a candidate min-rank skip
129217
* threshold against the taken-opportunity corpus. Prints the shared comparison renderer's report and
130218
* persists the run event. Exit is nonzero ONLY on operational error -- never on verdict (the #8138
@@ -205,13 +293,14 @@ function runMinRankMutation(kind: "apply" | "revert", args: string[], env: Recor
205293
}
206294

207295
/**
208-
* Run `loopover-miner calibration [--json]` (or one of the #8184/#8187 subcommands -- see
296+
* Run `loopover-miner calibration [--json]` (or one of the #8184/#8187/#8317 subcommands -- see
209297
* CALIBRATION_USAGE). The bare form reads the prediction ledger + PR-outcome events, joins them into a
210298
* calibration report, and prints it (a JSON dump under `--json`, else a per-project text summary) along
211299
* with the corpus stats, the backtest track record (#8185), and any current backtest-cleared proposals
212300
* (#8186). Returns the process exit code: 0 on success, 1 on an unknown option.
213301
*/
214302
export function runCalibrationCli(args: string[] = [], env: Record<string, string | undefined> = process.env, deps: CalibrationCliDeps = {}): number {
303+
if (args[0] === "snapshot") return runCalibrationSnapshot(args.slice(1), env, deps);
215304
if (args[0] === "backtest-threshold") return runBacktestThreshold(args.slice(1), env, deps);
216305
if (args[0] === "apply-min-rank") return runMinRankMutation("apply", args.slice(1), env, deps);
217306
if (args[0] === "revert-min-rank") return runMinRankMutation("revert", args.slice(1), env, deps);

packages/loopover-miner/lib/calibration-run.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export interface RunCalibrationCycleInput {
124124
now?: string | Date | null;
125125
observedAt?: string | null;
126126
repoFullName?: string;
127+
/** #8317 / #8185: AMS backtest track record to embed on the persisted snapshot. Omit/null when none. */
128+
backtestTrackRecord?: SnapshotMeta["backtestTrackRecord"];
127129
}
128130

129131
export interface RunCalibrationCycleDeps extends ScoreCompositeOptions {
@@ -373,6 +375,7 @@ export function runHistoricalReplayCalibrationCycle(
373375
replayRunId: (built.historicalReplay as Record<string, unknown> | null)?.replayRunId as string | null ?? null,
374376
observedAt: input.observedAt ?? ((built.historicalReplay as Record<string, unknown> | null)?.observedAt as string | null) ?? null,
375377
sampleSize: built.sampleSize,
378+
...(input.backtestTrackRecord !== undefined ? { backtestTrackRecord: input.backtestTrackRecord } : {}),
376379
});
377380
const recorded = deps.eventLedger
378381
? recordCalibrationSnapshot(snapshot, { eventLedger: deps.eventLedger, repoFullName: input.repoFullName } as RecordCalibrationSnapshotOptions)

0 commit comments

Comments
 (0)