GIT17: the forge module deletes seen keys past 30 days on each step-ended and at its start; git_seen_expire runs it now

Builds: design 47 G §581, §585, §487 (TypeScript tests not run here: no installed dependencies)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 05:06:03 +01:00
parent 2d5358b8b9
commit 00be4c5063
4 changed files with 67 additions and 0 deletions
+24
View File
@@ -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<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?.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<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 () => {
+3
View File
@@ -402,6 +402,9 @@ const heldHeads = new Heads();
let reloadHeads: () => Promise<void> = async () => {};
const failedSteps = new FailedSteps(state<unknown>("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).
+22
View File
@@ -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"]);
});
+18
View File
@@ -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<string, unknown>) => {
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 };
},
},
];
}