Files

283 lines
14 KiB
TypeScript

// **The forge module judges a head by the failed steps it heard, never by a call** (novox/hq design 47 §39, §475, §487, §548,
// §135): the seat git's `merge-pull` refuses a head at whose build-input fingerprint any run failed a `required` step, from
// the module's own state `failed-steps`, kept from the runner's `step-ended`, so a head merges while the runner is down.
//
// Two key forms live in the state:
// <module>.<fingerprint>.<step> a failed `required` step (the contract's FailedStep): the run that failed it and every
// commit it was seen at; cleared by a later passing `rerun` of the same step.
// seen.<commit>.<module> the build-input fingerprints the runs named for that commit and module, written from
// every `step-ended` that carries a pipeline's commit and fingerprint, whatever its outcome
// (§487, §548), so a head whose runs all ended before another commit of the same content
// failed is still refused.
//
// An entry is deleted once none of its commits is a watched branch's head or the head an open pull request names (§135),
// judged on each `branch-moved` and each pull request's close (Heads, below).
import type { State } from "@novox/mesh-sdk/state";
import { askRuntime } from "@novox/mesh-sdk/runtime";
import {
failedStepsByModuleFingerprintStepKey as failedKey, type FailedStep, type PullClosedEvent, type PullMergedEvent, type PullUpdatedEvent,
} from "@novox/mesh-delivery-contracts-git";
import type { StepEndedEvent } from "@novox/mesh-delivery-contracts-mesh-pipeline-runner";
/** The key prefix of the commits' fingerprints (§487). */
export const SEEN = "seen.";
/** The bucket's fill against its caps as the bundle's state call `mesh/state.info` answers it (design 47 G §623). */
export interface StateFill {
bytes: number;
keys: number;
max_bytes: number;
}
/** The fill of the state `failed-steps`, asked of the runtime (§623). */
export const askFill = (): Promise<StateFill> => askRuntime("mesh/state.info", { state: "failed-steps" }) as Promise<StateFill>;
/** How long a `seen` key is kept after its `step-ended`, and how many go on one event (§581). */
export const SEEN_DAYS = 30;
export const SEEN_MOST_PER_EVENT = 100;
/** What a `seen` key holds: a commit's fingerprints at one module. */
export interface Seen {
v: 1;
commit: string;
module: string;
fingerprints: string[];
at: string;
}
/** The step a head is refused for: the run, the module and the step the refusal names. */
export interface Judged {
run: string;
module: string;
step: string;
fingerprint: string;
}
const SHA = /^[0-9a-f]{40}$/;
/** A part of a key as the bus keeps keys: letters, digits and `-_=/`. */
const safe = (s: string): string => s.replace(/[^A-Za-z0-9_=/-]/g, "_");
export const seenKey = (commit: string, module: string): string => `${SEEN}${commit}.${safe(module)}`;
export const failedStepKey = (module: string, fingerprint: string, step: string): string => failedKey(safe(module), safe(fingerprint), safe(step));
export class FailedSteps {
/** Every act runs after the one before it, so a step-ended and a head's loss never interleave their reads and writes. */
private chain: Promise<unknown> = Promise.resolve();
constructor(private readonly st: State<unknown>, private readonly log: (line: string) => void = () => {},
/** Whether a commit is now a watched branch's head or an open pull request's head. */
private readonly current: (commit: string) => boolean = () => false,
/** The state's fill against its caps: the bundle's state call (§623). */
private readonly fillOf: () => Promise<StateFill> = askFill) {}
private turn<T>(act: () => Promise<T>): Promise<T> {
const r = this.chain.then(act, act);
this.chain = r.catch(() => {});
return r;
}
private async all(): Promise<{ failed: Map<string, FailedStep>; seen: Map<string, Seen> }> {
const failed = new Map<string, FailedStep>(), seen = new Map<string, Seen>();
for (const key of await this.st.keys()) {
const e = await this.st.get(key);
if (!e?.value) continue;
if (key.startsWith(SEEN)) seen.set(key, e.value as Seen);
else failed.set(key, e.value as FailedStep);
}
return { failed, seen };
}
/**
* One `step-ended` (§39, §475, §487, §548). A `step-ended` that carries no pipeline's commit and fingerprint (an ask of another
* kind, §602) writes nothing. Its commit's fingerprint is kept; a failed `required` step is kept with every commit seen at its
* module and fingerprint; a later step-ended at a kept failure's module and fingerprint adds its commit; a passing `rerun` of
* a kept failure's step clears it, and nothing else does.
*/
stepEnded(ev: StepEndedEvent): Promise<void> {
// The `seen` keys past their 30 days are judged on each step end, with no timer (§581).
return this.record(ev).then(() => this.expireSeen(new Date(Date.parse(String(ev?.at ?? "")) || Date.now())).then(() => {}));
}
private record(ev: StepEndedEvent): Promise<void> {
const r = ev?.result;
const fingerprint = r?.fingerprint || ev?.fingerprint || "";
if (!r || !SHA.test(String(ev.commit ?? "")) || !fingerprint || !ev.module) return Promise.resolve();
return this.turn(async () => {
const at = ev.at ?? new Date().toISOString();
const sk = seenKey(ev.commit, ev.module);
const held = (await this.st.get(sk))?.value as Seen | undefined;
if (!held?.fingerprints.includes(fingerprint)) {
await this.st.put(sk, { v: 1, commit: ev.commit, module: ev.module, fingerprints: [...(held?.fingerprints ?? []), fingerprint], at } satisfies Seen);
} else if (held.at !== at) {
await this.st.put(sk, { ...held, at });
}
const key = failedStepKey(ev.module, fingerprint, r.step);
const all = await this.all();
// A step-ended at a kept failure's module and fingerprint, of any outcome, adds its commit to every failure there (§475).
for (const [k, f] of all.failed) {
if (f.module === ev.module && f.fingerprint === fingerprint && !f.commits.includes(ev.commit)) {
const next = { ...f, commits: [...f.commits, ev.commit].sort() };
all.failed.set(k, next);
await this.st.put(k, next);
}
}
const kept = all.failed.get(key);
if (r.outcome === "failed" && r.status === "required") {
if (kept) return;
const commits = new Set<string>([ev.commit]);
for (const s of all.seen.values()) if (s.module === ev.module && s.fingerprints.includes(fingerprint)) commits.add(s.commit);
await this.st.put(key, { v: 1, module: ev.module, fingerprint, step: r.step, run: ev.pipeline, ...(r.artifact ? { artifact: r.artifact } : {}),
commits: [...commits].sort(), ...(r.why ? { said: r.why } : {}), at } satisfies FailedStep);
return;
}
// The runner's rerun is granted to the operator and the healers alone (its definition's callers), so a step-ended that says
// it is a rerun was asked by one of them; a plain pass, or any other outcome, clears nothing (§39).
if (r.rerun === true && r.outcome === "passed" && kept) {
await this.st.delete(key);
this.log(`${ev.module} ${r.step} at ${fingerprint} passed again by ${r.by ?? "a rerun"}: its failure is cleared`);
}
});
}
/** The failed `required` step that refuses this head, or null: a head with a `seen` key is judged by it, one with none merges (§487). */
judge(head: string): Promise<Judged | null> {
return this.turn(async () => {
const { failed, seen } = await this.all();
for (const [, s] of [...seen].sort(([a], [b]) => (a < b ? -1 : 1))) {
if (s.commit !== head) continue;
for (const fingerprint of s.fingerprints) {
const hit = [...failed.values()].filter((f) => f.module === s.module && f.fingerprint === fingerprint).sort((a, b) => (a.step < b.step ? -1 : 1))[0];
if (hit) return { run: hit.run, module: hit.module, step: hit.step, fingerprint };
}
}
return null;
});
}
/**
* Commits that are no longer a watched branch's head nor an open pull request's head (§135): their `seen` keys go, they leave the
* failures' commits, and a failure none of whose commits is a head any longer is deleted.
*/
lost(commits: readonly string[]): Promise<void> {
const gone = new Set(commits.filter((c) => !this.current(c)));
if (gone.size === 0) return Promise.resolve();
return this.turn(async () => {
const { failed, seen } = await this.all();
for (const [k, s] of seen) if (gone.has(s.commit)) await this.st.delete(k);
for (const [k, f] of failed) {
if (!f.commits.some((c) => gone.has(c))) continue;
const rest = f.commits.filter((c) => !gone.has(c));
if (rest.some((c) => this.current(c))) await this.st.put(k, { ...f, commits: rest });
else await this.st.delete(k);
}
});
}
/** What is held, for `git_failed_steps`: the failures, the `seen` keys (of one commit when asked), and the store's fill. */
held(commit?: string): Promise<{ failed: FailedStep[]; seen: Seen[]; fill: { keys: number; bytes: number; cap: number } }> {
return this.turn(async () => {
const { failed, seen } = await this.all();
const info = await this.fillOf();
return { failed: [...failed.values()].filter((f) => !commit || f.commits.includes(commit)).sort((a, b) => (a.run < b.run ? -1 : 1)),
seen: [...seen.values()].filter((s) => !commit || s.commit === commit), fill: { keys: info.keys, bytes: info.bytes, cap: info.max_bytes } };
});
}
/**
* Deletes the `seen` keys whose `at` is more than 30 days before `now`, the oldest first, at most `most` of them (§581): run on
* each `step-ended`, at the start, and by the tool `git_seen_expire`. A key with no readable `at` is kept. Answers the keys deleted.
*/
expireSeen(now: Date = new Date(), most = SEEN_MOST_PER_EVENT): Promise<string[]> {
return this.turn(async () => {
const { seen } = await this.all();
const limit = now.getTime() - SEEN_DAYS * 24 * 3600 * 1000;
const old = [...seen].filter(([, s]) => Date.parse(s.at) < limit).sort(([, a], [, b]) => Date.parse(a.at) - Date.parse(b.at)).slice(0, most);
for (const [k] of old) await this.st.delete(k);
if (old.length) this.log(`${old.length} seen key(s) past ${SEEN_DAYS} days deleted`);
return old.map(([k]) => k);
});
}
/** Drops one held key, a failure by its key or a `seen` key; false when it is not there. */
drop(key: string): Promise<boolean> {
return this.turn(async () => {
if (!(await this.st.get(key))) return false;
await this.st.delete(key);
return true;
});
}
}
/**
* The commits that are now a watched branch's head or the head an open pull request names (§39, §135). A watched branch is a
* branch a registration names for its origin; a head is read from the forge at the start and when a registration changes, and
* kept by `branch-moved` (its head read again from the forge, never from the event) and a pull request's updates and closes.
*/
export class Heads {
private watched = new Set<string>();
private branches = new Map<string, string>();
private pulls = new Map<string, string>();
has(commit: string): boolean {
for (const h of this.branches.values()) if (h === commit) return true;
for (const h of this.pulls.values()) if (h === commit) return true;
return false;
}
/** The heads the forge holds now of the watched branches (`<repository>@<branch>`) and the open pull requests (`<repository>#<number>`):
* replaces both pictures and answers the commits that were heads and are not any more. */
replace(watched: Set<string>, branches: Map<string, string>, pulls: Map<string, string>): string[] {
const before = new Set([...this.branches.values(), ...this.pulls.values()]);
this.watched = watched;
this.branches = branches;
this.pulls = pulls;
return [...before].filter((c) => !this.has(c));
}
/** A watched branch's new head; the commit it replaces when that is no head any more. A branch no registration watches moves nothing. */
branchMoved(repository: string, branch: string, head: string): string[] {
if (!this.watched.has(`${repository}@${branch}`)) return [];
const old = this.branches.get(`${repository}@${branch}`);
this.branches.set(`${repository}@${branch}`, head);
return old && old !== head && !this.has(old) ? [old] : [];
}
pullUpdated(ev: PullUpdatedEvent): string[] {
const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k);
this.pulls.set(k, ev.head);
return old && old !== ev.head && !this.has(old) ? [old] : [];
}
pullEnded(ev: PullClosedEvent | PullMergedEvent): string[] {
const k = `${ev.repository}#${ev.number}`, old = this.pulls.get(k);
this.pulls.delete(k);
return old && !this.has(old) ? [old] : [];
}
watches(repository: string, branch: string): boolean {
return this.watched.has(`${repository}@${branch}`);
}
}
/** What the forge holds now: the heads of the watched branches (`<repository>@<branch>`) and of the open pull requests (`<repository>#<number>`). */
export interface HeadsSource {
branchHead(owner: string, repo: string, branch: string): Promise<string>;
openPulls(): Promise<{ owner: string; repo: string; number: number; head_sha: string }[]>;
}
/** Reads the heads the picture is made of: each watched branch's head (one a forge cannot name is said and left out) and every open pull request's. */
export async function readHeads(forge: HeadsSource, watched: ReadonlyMap<string, ReadonlySet<string>>, log: (line: string) => void = () => {}) {
const names = new Set<string>(), branches = new Map<string, string>(), pulls = new Map<string, string>();
for (const [repository, list] of watched) {
const [owner, repo] = repository.split("/");
for (const branch of list) {
names.add(`${repository}@${branch}`);
try {
branches.set(`${repository}@${branch}`, await forge.branchHead(owner, repo, branch));
} catch (err) {
log(`the head of ${repository}'s watched branch ${branch} could not be read: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
for (const p of await forge.openPulls()) pulls.set(`${p.owner}/${p.repo}#${p.number}`, p.head_sha);
return { names, branches, pulls };
}