diff --git a/modules/gitea/failedsteps.ts b/modules/gitea/failedsteps.ts index bbdb5f89c..c7edd8eea 100644 --- a/modules/gitea/failedsteps.ts +++ b/modules/gitea/failedsteps.ts @@ -24,6 +24,10 @@ export const SEEN = "seen."; /** The template state's cap (design 47 *The runner's kept data*): the fill is shown against it. */ export const CAP_BYTES = 64 * 1024 * 1024; +/** 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; @@ -79,6 +83,11 @@ export class FailedSteps { * a kept failure's step clears it, and nothing else does. */ stepEnded(ev: StepEndedEvent): Promise { + // 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 { const r = ev?.result; const fingerprint = r?.fingerprint || ev?.fp || ""; if (!r || !SHA.test(String(ev.commit ?? "")) || !fingerprint || !ev.module) return Promise.resolve(); @@ -163,6 +172,21 @@ export class FailedSteps { }); } + /** + * 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 { + 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 { return this.turn(async () => { diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 0f61601bd..d26359575 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -402,6 +402,9 @@ const heldHeads = new Heads(); let reloadHeads: () => Promise = async () => {}; const failedSteps = new FailedSteps(state("failed-steps"), (line) => console.log(`[gitea] ${line}`), (commit) => heldHeads.has(commit)); +// The `seen` keys past their 30 days are deleted at the start, as on each step-ended (design 47 §581). +void failedSteps.expireSeen().catch((err) => console.log(`[gitea] the seen keys past 30 days could not be deleted at the start: ${err instanceof Error ? err.message : String(err)}`)); + // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are // served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the // announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456). diff --git a/modules/gitea/test/failedsteps.test.ts b/modules/gitea/test/failedsteps.test.ts index d6883b617..e6e499887 100644 --- a/modules/gitea/test/failedsteps.test.ts +++ b/modules/gitea/test/failedsteps.test.ts @@ -96,3 +96,25 @@ test("git_failed_steps shows what is held and its fill, and git_failed_steps_dro assert.deepEqual([done.dropped, acts.length, acts[0].verb], [true, 1, "git_failed_steps_drop"]); assert.equal(await f.judge(C2), null); }); + +test("a seen key past 30 days is deleted at the next step-ended, a younger one kept, at most 100 oldest first, and git_seen_expire runs the same now (§581)", async () => { + const { failedStepsTools } = await import("../tools/failedsteps.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const { st, kv } = memory() as any; + const f = new FailedSteps(st as any); + // The step-ended is at 2026-11-11T12:00:00Z, so 30 days before it is 2026-10-12. + const now = new Date("2026-11-11T12:00:00Z"); + const old = (n: number) => new Date(Date.parse("2026-10-01T00:00:00Z") - n * 60000).toISOString(); + await st.put(seenKey(C1, "old"), { v: 1, commit: C1, module: "old", fingerprints: ["x"], at: old(0) }); + await st.put(seenKey(C2, "young"), { v: 1, commit: C2, module: "young", fingerprints: ["x"], at: "2026-10-20T00:00:00Z" }); + await f.stepEnded(ended(C3, "passed")); + assert.equal(kv.has(seenKey(C1, "old")), false, "past 30 days: deleted at the next step-ended"); + assert.equal(kv.has(seenKey(C2, "young")), true, "younger: kept"); + assert.equal(kv.has(seenKey(C3, "nats")), true, "the step's own key is new"); + for (let i = 0; i < 101; i++) await st.put(seenKey(C1, `m${String(i).padStart(3, "0")}`), { v: 1, commit: C1, module: `m${i}`, fingerprints: ["x"], at: old(i) }); + assert.equal((await f.expireSeen(now)).length, 100, "at most 100 for each event"); + const acts: any[] = []; + const tool = failedStepsTools(f, async (a) => { acts.push(a); }).find((t) => t.name === "git_seen_expire")!; + const done: any = await runAs({ "mesh/caller": "operator" }, {}, () => tool.run({ why: "stale" })); + assert.deepEqual([done.deleted.length, acts[0].verb], [1, "git_seen_expire"]); +}); diff --git a/modules/gitea/tools/failedsteps.ts b/modules/gitea/tools/failedsteps.ts index 430aaaa61..c13ba2067 100644 --- a/modules/gitea/tools/failedsteps.ts +++ b/modules/gitea/tools/failedsteps.ts @@ -40,5 +40,23 @@ export function failedStepsTools(kept: FailedSteps, acts: (act: HandAct) => Prom return { dropped: await kept.drop(key), key, why, by }; }, }, + { + name: "git_seen_expire", + description: "Deletes the seen keys of the state failed-steps that are more than 30 days old, the oldest first, at most 100: the removal the forge module does on each step-ended, now, with why. " + + "Written in the hand-act log first (design 47 §581).", + input: { type: "object", required: ["why"], properties: { why: { type: "string", description: "why it is run now, in plain words" } } }, + run: async (args: Record) => { + const why = typeof args.why === "string" ? args.why.trim() : ""; + if (!why) throw new Error("git_seen_expire says why"); + const by = callerOf(args); + try { + await acts({ what: "deleted the seen keys of the forge module's failed-steps past 30 days", why: `${why} (${by})`, cause: "seen-expired", verb: "git_seen_expire" }); + } catch (err) { + throw new Error(`the hand act was not recorded, so nothing was deleted: ${err instanceof Error ? err.message : String(err)}`); + } + const deleted = await kept.expireSeen(); + return { deleted, why, by }; + }, + }, ]; }