mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(ship-queue): screens read from the run, train watch/forget/prune --stale, verify --post-merge, ready --dispatch-ci, --stack-tests Operating merge trains on 2026-10-26 showed the gaps this closes. A dispatched screen's verdict is read from the run's gate-binding and report-verdict artifacts (lib/ship-queue-screen.ts) and recorded in the READY receipt: a RED screen refuses ready under either choice and is never re-dispatched at the same merge SHA (QUEUE_SCREEN_RED); a run that concluded success with no slice is NOT-RUN (QUEUE_SCREEN_NOT_RUN), never followed, and counted as unscreened by train build. When refs/pull/<n>/merge lags or is missing the screen is planned on a scratch merge pushed as screen/<pr>-<head12> (#3465); QUEUE_SCREEN_STALE_REF means a real conflict only. train watch --train is the resumable follow command; train prune --stale settles a merging/green train whose PRs all merged or closed elsewhere; train forget removes one record with an exact lease on refs/notes/gstack-trains; verify --post-merge <sha> watches main's push workflows until they settle; ready --dispatch-ci <wf,…> dispatches the free workflows on a conflicting PR, waits, and clears the matching requires-remote pregate lanes with the run URLs; train build --dry-run --stack-tests runs each PR's new test files on its integrated prefix before anything is dispatched (QUEUE_TRAIN_STACK_RED). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Regenerate gstack-doctor-components.sh for the four new QUEUE_* result codes The committed Bash render was four case lines behind its generator, so test/doctor-components.test.ts failed atc75ec3a. Output of bun run gen:skill-docs; no hand edits. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Restore the exact stderr line count in the POLICY_SOURCE_UNAVAILABLE test RH-15: toBeGreaterThan(0) after a toMatch is vacuous; the refusal is one deterministic line, so toBe(1) holds and is restored. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship-policy): a lagging merge ref is a scratch merge, not QUEUE_SCREEN_STALE_REF Three passages still described the pre-scratch-merge refusal; they now match screenMerge: a missing or lagging refs/pull/<n>/merge gets a screen/<pr>-<head12> scratch merge, and QUEUE_SCREEN_STALE_REF is a real conflict with the base only. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * stack-tests: drop the dead detail clause and assert the worktree is gone r.detail === 'pass' can never hold when r.ok is false; the dry-run test now checks git worktree list instead of a state-dir name that was never written. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ready --dispatch-ci: fail closed on fork or unreadable PRs, surface gh errors, keep screen: after READY dispatchCi now uses prInfo (a fork head or an unreadable PR is refused instead of dispatched), reads gh's error text when the workflow run call fails, and calls runsAt once. The screen: line is inserted after the READY line instead of at index 1, which with --dispatch-ci put it before READY and after the first CI_DISPATCH line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * verify --post-merge: honour commit statuses in the settled and red verdicts settled and red were computed from check runs only while ci.green also counted commit statuses, so a pending status was called settled and a failed status was reported as 'red: ' with no name. Pending statuses now keep the verdict pending; a red status with no failed check run is named as such. The fixture gains a statuses knob to drive both. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * train watch: reject a malformed --free-timeout as usage instead of taking the default Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * train forget: restore the local notes ref on a rejected lease and refuse every in-flight state forgetNotes removed the note locally before the leased push, so a rejected lease left a local notes ref without the note that the next plain persist pushed without a lease. It now snapshots the ref and restores it on rejection, reporting failed removals separately. forget refused only merging/gate-pending/free-pending; built, pushed, free-green, dispatch-unknown and dispatch-absent are in flight too and are refused the same way. CHANGELOG and ship-policy say so. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * train build --dry-run: never persist or push the read screen verdict admitSlots recorded a verdict that differed from the receipt and pushed refs/notes/gstack-trains before the dryRun branch printed 'nothing written, pushed or dispatched'. It now takes dryRun and skips the persist, matching trainReady's guard. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * stack-tests: only bun test files count as new tests, passed as paths TEST_FILE matched every .ts under test/, so a PR whose only added file was a helper or fixture ran 'bun test test/helpers/x.ts', which exits 1 (the filter matches no test file) and was reported QUEUE_TRAIN_STACK_RED. A bare positional is also a substring filter that picks up unrelated files. The rule is now bun's own (.test, _test_, .spec, _spec_ in the name) and each file is passed as ./<path>, which bun treats as the exact file. The fixture PR 3 now adds a helper and is still 'no new test files'. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * screen: page the run's artifact list before picking the newest binding One page of 100 artifacts can miss gate-binding/report-verdict on a broad or rerun screen (three artifacts per slice plus reruns), which read as 'bound no verdict' and bought a fresh screen. newestArtifact now follows page= until a short page. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * stack-tests: match bun's _test and _spec suffixes too Bun discovers a file whose name ends in .test, _test, .spec or _spec before the extension. The previous rule missed the underscore forms (b_test.ts, e_spec.ts), so a PR adding only such files got no stack check, and it matched names bun ignores. PR 1 in the fixture now adds test/pr-1_test.ts to prove the underscore form is run. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: ship-policy and troubleshooting match the shipped train commands Step 14.5 documentation audit: the stack-tests naming rule is bun's own (.test, _test, .spec, _spec); prune --stale deletes every merged or closed PR's screen/<pr>-* branch; verify --post-merge takes the full SHA and counts commit statuses; train watch --free-timeout and ready --dispatch-ci --timeout defaults; PENDING/UNKNOWN/INCOMPLETE screen verdicts; QUEUE_SCREEN_STALE_REF's Meaning no longer contradicts its Fix; one sentence each for the new QUEUE_NOT_READY, QUEUE_TRAIN_UNTRUSTED and QUEUE_TRAIN_PENDING triggers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: prune --stale scope, post-merge commit statuses, STALE_REF vs POLICY_SOURCE_UNAVAILABLE Three details the documentation audit found wrong against the code; the entry stays under [Unreleased] (stamp-at-merge). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: RED screens are not seated; QUEUE_PROOF_MISSING and POLICY_SOURCE_UNAVAILABLE name the train-command causes Step 14.5 re-audit: ship-policy.md says train build does not seat a RED screen and its NEXT names the build without that PR; troubleshooting.md lists train forget, train watch, verify --screen and the scratch-branch push under QUEUE_PROOF_MISSING, and the origin/<base> fetch under POLICY_SOURCE_UNAVAILABLE. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(host-conformance): delete the named scope.md in the C3 broken-link case A test repair the remote gate requires, not a review finding. The case deleted the first .md readdirSync listed under gstack-qa/sections, and on the remote-gate VM's ext4 that was a file setup --status does not count, so the case was deterministically red there (#3479) while green locally and on Actions. The same one-line change ships in #3471 (fd468eb); this copy carries no CHANGELOG line so the two merge cleanly. Refs #3479 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.263.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
277 lines
18 KiB
TypeScript
277 lines
18 KiB
TypeScript
/**
|
|
* Merge-train records (docs/ship-policy.md#run-a-merge-train): versioned
|
|
* `train.json`, one attempt record per dispatch, the bisect ledger, READY
|
|
* receipts and the queue freeze, under `$GSTACK_STATE_ROOT/ship-queue/`
|
|
* (lib/state-root.ts resolves the root). Every record is mirrored to origin
|
|
* as a git note under `refs/notes/gstack-trains`, each on a blob named for
|
|
* the record, so a lost machine rebuilds its state with `train status`. A
|
|
* record is written locally before its note, and the note before any remote
|
|
* effect that depends on it.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { redactFindingSpans } from './redact-engine';
|
|
import { git, sha256, writeJsonAtomic } from './ship-queue-gate';
|
|
import { resolveStateRoot } from './state-root';
|
|
|
|
export const TRAIN_SCHEMA = 1;
|
|
export const NOTES_REF = 'refs/notes/gstack-trains';
|
|
const NOTES_REMOTE_REF = 'refs/notes/gstack-trains-origin';
|
|
|
|
export type TrainState =
|
|
| 'built' | 'pushed' | 'free-pending' | 'free-red' | 'free-green' | 'dispatch-unknown' | 'dispatch-absent' | 'gate-pending'
|
|
| 'green' | 'red' | 'incomplete' | 'attributed' | 'red-on-main' | 'unattributed' | 'merging' | 'merged' | 'superseded' | 'stale';
|
|
/** States after which a train never dispatches again; prune deletes their branches (`stale`: every PR merged or closed outside the train, `train prune --stale`). */
|
|
export const TERMINAL_STATES: readonly TrainState[] = ['free-red', 'attributed', 'red-on-main', 'merged', 'superseded', 'stale'];
|
|
|
|
/** What the screen run itself said (lib/ship-queue-screen.ts), recorded when it was read; absent while the run is pending or unread. */
|
|
export interface ScreenVerdictRecord { outcome: 'GREEN' | 'RED' | 'NOT_RUN' | 'INCOMPLETE'; run: number; red: string[]; flaky: string[]; detail: string; read_at: string }
|
|
export interface ScreenChoice {
|
|
choice: 'screen' | 'no-screen'; policy: 'narrow' | 'broad' | null; nonce: string | null; run: number | null; merge_sha: string | null;
|
|
/** The scratch branch `ready --screen` pushed when GitHub's merge ref lagged the head (docs/ship-policy.md#run-a-merge-train). */
|
|
branch?: string;
|
|
verdict?: ScreenVerdictRecord;
|
|
}
|
|
export interface TrainSlot { pr: string; head: string; ready_at: string; screen: ScreenChoice }
|
|
export interface TrainPrefix { index: number; commit: string; tree: string; pr: string | null }
|
|
export interface FreeRun { run: number | null; url: string | null; head_sha: string | null; conclusion: string | null; verdict: 'green' | 'red' | 'missing' | 'stale' | 'cancelled' | 'pending' }
|
|
export interface TrainRecord {
|
|
schema: 1; kind: 'train'; nonce: string; key: string; repo_key: string; repo: string; base: { ref: string; sha: string }; policy: string;
|
|
slots: TrainSlot[]; held: string[]; prefixes: TrainPrefix[]; head: string; tree: string; branch: string;
|
|
urgent: boolean; repair: boolean; explicit_order?: boolean; state: TrainState; free: FreeRun | null; attempts: string[];
|
|
merged: Array<{ pr: string; sha: string; tree: string; at: string }>; superseded_by: string | null;
|
|
effects: Array<{ at: string; what: string }>; error: string | null; created_at: string; updated_at: string;
|
|
}
|
|
export type AttemptPurpose = 'gate' | 'rebuild' | 'infra-redispatch' | 'bisect';
|
|
export type GateOutcome = 'PENDING' | 'GREEN' | 'RED' | 'INCOMPLETE';
|
|
export interface CasePanel {
|
|
case: string; kind: string; status: string; red_class: string | null; cost_usd: number | null;
|
|
/** Rule 16: a PASS that failed once and passed on its one rerun, with where each ran. */
|
|
flaky?: { first: string; rerun: string };
|
|
}
|
|
export interface AttemptRecord {
|
|
schema: 1; kind: 'train-attempt'; train: string; id: string; purpose: AttemptPurpose; nonce: string; workflow: string; ref: string;
|
|
inputs: Record<string, string>; state: 'dispatching' | 'unknown' | 'absent' | 'dispatched' | 'completed'; run: { id: number; url: string } | null;
|
|
outcome: GateOutcome | null; detail: string | null; red_cases: string[]; panels: CasePanel[]; infra_eligible: boolean;
|
|
cost_usd: number | null; prefixes: number[]; created_at: string; updated_at: string;
|
|
}
|
|
export type PrefixResult = 'red' | 'green';
|
|
export interface BisectRecord {
|
|
schema: 1; kind: 'train-bisect'; train: string; cases: string[]; spend_cap: number; spent_usd: number;
|
|
prefixes: Array<{ index: number; sha: string; results: Record<string, PrefixResult>; attempts: string[] }>;
|
|
outcome: null | { kind: 'attributed' | 'red-on-main' | 'unattributed'; all_green: boolean; prs: string[]; patterns: Record<string, string>; at: string };
|
|
updated_at: string;
|
|
}
|
|
export interface FreezeRecord {
|
|
schema: 1; kind: 'freeze'; repo_key: string; train: string; merged: string[]; unmerged: Array<{ pr: string; head: string }>;
|
|
main: { sha: string; tree: string }; reason: string; backstop: { nonce: string; run: number | null; state: string } | null;
|
|
repair: { pr: string; head: string; train: string | null } | null; created_at: string; cleared_at: string | null; cleared_by: string | null; updated_at: string;
|
|
}
|
|
export interface ReadyReceipt {
|
|
schema: 1; kind: 'ready'; repo_key: string; pr: string; head: string; base_sha: string; ready_at: string; screen: ScreenChoice; updated_at: string;
|
|
}
|
|
export type AnyRecord = TrainRecord | AttemptRecord | BisectRecord | FreezeRecord | ReadyReceipt;
|
|
|
|
/** Clock seam for fill-time and record tests (`GSTACK_SHIP_QUEUE_NOW`, ISO-8601). */
|
|
export function nowIso(): string {
|
|
const fixed = process.env.GSTACK_SHIP_QUEUE_NOW;
|
|
return fixed && !Number.isNaN(Date.parse(fixed)) ? new Date(fixed).toISOString() : new Date().toISOString();
|
|
}
|
|
|
|
/** An unexpected failure's cause, with secrets withheld, for the record and the error line. */
|
|
export function redactCause(message: string): string {
|
|
const text = message.replace(/\s+/g, ' ').slice(0, 400);
|
|
return redactFindingSpans(text) ?? '(cause withheld: it could not be redacted safely)';
|
|
}
|
|
|
|
export function queueRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
return path.join(resolveStateRoot(env), 'ship-queue');
|
|
}
|
|
export const trainDir = (nonce: string) => path.join(queueRoot(), 'trains', nonce);
|
|
export const trainFile = (nonce: string) => path.join(trainDir(nonce), 'train.json');
|
|
const attemptPath = (nonce: string, id: string) => path.join(trainDir(nonce), 'attempts', `${id}.json`);
|
|
const bisectPath = (nonce: string) => path.join(trainDir(nonce), 'bisect.json');
|
|
const freezePath = (repoKey: string) => path.join(queueRoot(), 'freeze', `${repoKey}.json`);
|
|
export const readyPath = (repoKey: string, pr: string, head: string) => path.join(queueRoot(), 'ready', repoKey, `${pr}-${head}.json`);
|
|
|
|
/** Hosts that serve GitHub repositories at /<owner>/<repo> (git.capy.ai is Capy's GitHub proxy, set through url.insteadOf). */
|
|
const GITHUB_HOSTS = ['github.com', 'www.github.com', 'ssh.github.com', 'git.capy.ai'];
|
|
|
|
/** Before 1.91.187.0 the key hashed origin's URL with only .git, git@github.com: and trailing slashes normalized. */
|
|
const legacyNorm = (url: string) => url.replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\/+$/, '');
|
|
|
|
/** A remote URL as https://github.com/<owner>/<repo> when it names a GitHub repository in any form (scp, ssh, http(s), credentials, proxy host); otherwise the legacy form. */
|
|
export function normalizeRemoteUrl(url: string): string {
|
|
const m = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/*$/i.exec(url.trim());
|
|
if (m && GITHUB_HOSTS.includes(m[1]!.toLowerCase())) return `https://github.com/${m[2]!.toLowerCase()}/${m[3]!.toLowerCase()}`;
|
|
return legacyNorm(url.trim().replace(/^([a-z+]+:\/\/)[^@/]+@/i, '$1'));
|
|
}
|
|
|
|
/**
|
|
* The repository's identity, keyed so records of different repositories
|
|
* never mix: a hash of origin's URL normalized to owner/repo, so lanes whose
|
|
* origin names the same GitHub repository in another form share one key.
|
|
* `keys` adds the keys earlier versions wrote for this URL and for the
|
|
* repository's common URL forms; readers accept all of them, writers use `key`.
|
|
*/
|
|
export function repoIdentity(repoRoot: string): { key: string; url: string; keys: string[] } {
|
|
const raw = git(repoRoot, ['config', '--get', 'remote.origin.url']).out.trim() || repoRoot;
|
|
const url = normalizeRemoteUrl(raw);
|
|
const key = sha256(url).slice(0, 16);
|
|
const gh = /^https:\/\/github\.com\/(.+)$/.exec(url)?.[1];
|
|
const forms = gh ? GITHUB_HOSTS.flatMap(h => ['https', 'http', 'ssh://git@'].map(s => `${s.includes('://') ? s : `${s}://`}${h}/${gh}`)) : [];
|
|
const keys = [...new Set([key, ...[raw, ...forms].map(u => sha256(legacyNorm(u)).slice(0, 16))])];
|
|
return { key, url, keys };
|
|
}
|
|
|
|
function readJson<T>(file: string, kind: string): T | null {
|
|
try { const r = JSON.parse(fs.readFileSync(file, 'utf8')); return r?.schema === TRAIN_SCHEMA && r.kind === kind ? r as T : null; } catch { return null; }
|
|
}
|
|
|
|
export const loadTrain = (nonce: string) => (/^[A-Za-z0-9._-]{1,128}$/.test(nonce) ? readJson<TrainRecord>(trainFile(nonce), 'train') : null);
|
|
export const loadAttempt = (nonce: string, id: string) => (/^[A-Za-z0-9._-]{1,64}$/.test(id) ? readJson<AttemptRecord>(attemptPath(nonce, id), 'train-attempt') : null);
|
|
export const loadBisect = (nonce: string) => readJson<BisectRecord>(bisectPath(nonce), 'train-bisect');
|
|
/** A reader's repo keys: the current key first, then the legacy keys (repoIdentity().keys). */
|
|
type RepoKeys = string | readonly string[];
|
|
const keyList = (k: RepoKeys): readonly string[] => (typeof k === 'string' ? [k] : k);
|
|
const firstOf = <T>(k: RepoKeys, read: (key: string) => T | null): T | null => { for (const key of keyList(k)) { const r = read(key); if (r) return r; } return null; };
|
|
|
|
export const loadFreeze = (repoKeys: RepoKeys) => firstOf(repoKeys, key => readJson<FreezeRecord>(freezePath(key), 'freeze'));
|
|
export const loadReady = (repoKeys: RepoKeys, pr: string, head: string) => firstOf(repoKeys, key => readJson<ReadyReceipt>(readyPath(key, pr, head), 'ready'));
|
|
export const activeFreeze = (repoKeys: RepoKeys): FreezeRecord | null => firstOf(repoKeys, key => { const f = readJson<FreezeRecord>(freezePath(key), 'freeze'); return f && !f.cleared_at ? f : null; });
|
|
|
|
export function attemptsOf(train: TrainRecord): AttemptRecord[] {
|
|
return train.attempts.map(id => loadAttempt(train.nonce, id)).filter((a): a is AttemptRecord => !!a);
|
|
}
|
|
|
|
/** Every train of this repository, newest first. */
|
|
export function listTrains(repoKeys: RepoKeys): TrainRecord[] {
|
|
const dir = path.join(queueRoot(), 'trains');
|
|
if (!fs.existsSync(dir)) return [];
|
|
const keys = keyList(repoKeys);
|
|
return fs.readdirSync(dir).map(n => loadTrain(n)).filter((t): t is TrainRecord => !!t && keys.includes(t.repo_key))
|
|
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.nonce.localeCompare(a.nonce));
|
|
}
|
|
|
|
export function recordPath(r: AnyRecord): string {
|
|
if (r.kind === 'train') return trainFile(r.nonce);
|
|
if (r.kind === 'train-attempt') return attemptPath(r.train, r.id);
|
|
if (r.kind === 'train-bisect') return bisectPath(r.train);
|
|
if (r.kind === 'freeze') return freezePath(r.repo_key);
|
|
return readyPath(r.repo_key, r.pr, r.head);
|
|
}
|
|
|
|
export function noteName(r: AnyRecord): string {
|
|
if (r.kind === 'train') return `gstack-trains:train:${r.nonce}`;
|
|
if (r.kind === 'train-attempt') return `gstack-trains:attempt:${r.train}:${r.id}`;
|
|
if (r.kind === 'train-bisect') return `gstack-trains:bisect:${r.train}`;
|
|
if (r.kind === 'freeze') return `gstack-trains:freeze:${r.repo_key}`;
|
|
return `gstack-trains:ready:${r.repo_key}:${r.pr}:${r.head}`;
|
|
}
|
|
|
|
const NOTES_IDENTITY = { GIT_AUTHOR_NAME: 'gstack-ship-queue', GIT_AUTHOR_EMAIL: 'ship-queue@gstack.local', GIT_COMMITTER_NAME: 'gstack-ship-queue', GIT_COMMITTER_EMAIL: 'ship-queue@gstack.local' };
|
|
const notesEnv = () => ({ ...process.env, ...NOTES_IDENTITY });
|
|
|
|
/** Fetch origin's notes and fold them into the local notes ref (the local side wins a same-record conflict). */
|
|
export function fetchNotes(repoRoot: string, remote = 'origin'): { ok: boolean; detail: string } {
|
|
const f = git(repoRoot, ['fetch', '--no-tags', '-q', remote, `+${NOTES_REF}:${NOTES_REMOTE_REF}`]);
|
|
if (!f.ok) {
|
|
const absent = /couldn't find remote ref|no such ref/i.test(f.err);
|
|
return { ok: absent, detail: absent ? `${remote} has no ${NOTES_REF} yet` : `git fetch ${NOTES_REF} failed: ${f.err.slice(0, 160)}` };
|
|
}
|
|
const local = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REF]).out;
|
|
const fetched = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REMOTE_REF]).out;
|
|
if (!local) { git(repoRoot, ['update-ref', NOTES_REF, fetched]); return { ok: true, detail: 'notes fetched' }; }
|
|
if (local === fetched || git(repoRoot, ['merge-base', '--is-ancestor', fetched, local]).ok) return { ok: true, detail: 'notes current' };
|
|
const m = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'merge', '-q', '-s', 'ours', NOTES_REMOTE_REF], notesEnv());
|
|
return m.ok ? { ok: true, detail: 'notes merged' } : { ok: false, detail: `git notes merge failed: ${m.err.slice(0, 160)}` };
|
|
}
|
|
|
|
function addNote(repoRoot: string, r: AnyRecord): boolean {
|
|
const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, noteName(r));
|
|
if (!blob.ok) return false;
|
|
return git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'add', '-f', '-F', '-', blob.out], notesEnv(), JSON.stringify(r)).ok;
|
|
}
|
|
|
|
/**
|
|
* Write records locally, then as notes, then push the notes ref (fetch,
|
|
* merge and retry on a race). A failed push leaves the local records and
|
|
* notes for the next write to carry, and says so.
|
|
*/
|
|
export function persist(repoRoot: string, records: AnyRecord[], opts: { push?: boolean; remote?: string } = {}): { pushed: boolean; detail: string } {
|
|
for (const r of records) writeJsonAtomic(recordPath(r), r);
|
|
if (!records.every(r => addNote(repoRoot, r))) return { pushed: false, detail: 'could not write the notes locally' };
|
|
if (opts.push === false) return { pushed: false, detail: 'notes written locally (not pushed)' };
|
|
const remote = opts.remote ?? 'origin';
|
|
let last = '';
|
|
for (let i = 0; i < 3; i++) {
|
|
const p = git(repoRoot, ['push', '-q', remote, `${NOTES_REF}:${NOTES_REF}`]);
|
|
if (p.ok) return { pushed: true, detail: `notes pushed to ${remote} ${NOTES_REF}` };
|
|
last = p.err.slice(0, 160);
|
|
const f = fetchNotes(repoRoot, remote);
|
|
if (!f.ok) return { pushed: false, detail: f.detail };
|
|
}
|
|
return { pushed: false, detail: `git push ${NOTES_REF} failed: ${last}` };
|
|
}
|
|
|
|
function newerOrMissing(r: AnyRecord): boolean {
|
|
try {
|
|
const cur = JSON.parse(fs.readFileSync(recordPath(r), 'utf8'));
|
|
return !(typeof cur.updated_at === 'string' && cur.updated_at >= r.updated_at);
|
|
} catch { return true; }
|
|
}
|
|
|
|
function parseNote(text: string): AnyRecord | null {
|
|
try {
|
|
const r = JSON.parse(text);
|
|
const ids = /^[A-Za-z0-9._-]{1,128}$/;
|
|
if (r?.schema !== TRAIN_SCHEMA || typeof r.updated_at !== 'string') return null;
|
|
if (r.kind === 'train' && ids.test(r.nonce)) return r;
|
|
if ((r.kind === 'train-attempt' && ids.test(r.train) && ids.test(r.id)) || (r.kind === 'train-bisect' && ids.test(r.train))) return r;
|
|
if (r.kind === 'freeze' && ids.test(r.repo_key)) return r;
|
|
if (r.kind === 'ready' && ids.test(r.repo_key) && /^\d+$/.test(r.pr) && /^[0-9a-f]{40,64}$/.test(r.head)) return r;
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Remove records from the notes ref (`train forget`): each named note is
|
|
* removed locally, then the ref is pushed under an exact lease on the remote
|
|
* tip this call fetched, so a concurrent writer's push is never overwritten
|
|
* (a rejected lease returns `pushed: false` and nothing is deleted locally).
|
|
* Names are note names (noteName); a name with no note is reported missing.
|
|
*/
|
|
export function forgetNotes(repoRoot: string, names: string[], remote = 'origin'): { removed: string[]; missing: string[]; failed: string[]; pushed: boolean; lease: string | null; detail: string } {
|
|
const f = fetchNotes(repoRoot, remote);
|
|
if (!f.ok) return { removed: [], missing: [], failed: [], pushed: false, lease: null, detail: f.detail };
|
|
const lease = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REMOTE_REF]).out || null;
|
|
const before = git(repoRoot, ['rev-parse', '--verify', '--quiet', NOTES_REF]).out || null;
|
|
const listed = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'list']).out.split('\n').map(l => l.split(' ')[1]).filter(Boolean);
|
|
const removed: string[] = [];
|
|
const missing: string[] = [];
|
|
const failed: string[] = [];
|
|
const restore = () => { if (before) git(repoRoot, ['update-ref', NOTES_REF, before]); };
|
|
for (const name of names) {
|
|
const blob = git(repoRoot, ['hash-object', '--stdin'], undefined, name).out;
|
|
if (!listed.includes(blob)) { missing.push(name); continue; }
|
|
if (git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'remove', blob], notesEnv()).ok) removed.push(name); else failed.push(name);
|
|
}
|
|
if (failed.length) { restore(); return { removed: [], missing, failed, pushed: false, lease, detail: `git notes remove failed for ${failed.join(', ')}; the local notes ref was restored` }; }
|
|
if (!removed.length) return { removed, missing, failed, pushed: false, lease, detail: 'no note to remove' };
|
|
const p = git(repoRoot, ['push', '-q', lease ? `--force-with-lease=${NOTES_REF}:${lease}` : `--force-with-lease=${NOTES_REF}`, remote, `${NOTES_REF}:${NOTES_REF}`]);
|
|
if (!p.ok) restore();
|
|
return { removed, missing, failed, pushed: p.ok, lease, detail: p.ok ? `notes pushed to ${remote} ${NOTES_REF} (lease ${lease?.slice(0, 12) ?? 'new ref'})` : `git push ${NOTES_REF} rejected: ${p.err.replace(/\s+/g, ' ').trim().slice(0, 160)}; the local notes ref was restored` };
|
|
}
|
|
|
|
/** Rebuild local records from the notes ref: a note whose record is missing locally, or newer than the local copy, is written. */
|
|
export function rebuildFromNotes(repoRoot: string, remote = 'origin'): { restored: number; read: number; detail: string } {
|
|
const f = fetchNotes(repoRoot, remote);
|
|
const list = git(repoRoot, ['notes', `--ref=${NOTES_REF}`, 'list']);
|
|
if (!list.ok || !list.out.trim()) return { restored: 0, read: 0, detail: f.detail };
|
|
const records = list.out.split('\n').map(l => l.split(' ')[0]!).filter(Boolean)
|
|
.map(blob => parseNote(git(repoRoot, ['cat-file', 'blob', blob]).out)).filter((r): r is AnyRecord => !!r);
|
|
const fresh = records.filter(newerOrMissing);
|
|
for (const r of fresh) writeJsonAtomic(recordPath(r), r);
|
|
return { restored: fresh.length, read: records.length, detail: f.detail };
|
|
}
|