From beba03613c1bb71978836b18b5f69e0403194beb Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:24:11 +0100 Subject: [PATCH] CAT-RD2: gitea says an unanswered first view in its findings and records the re-read through mesh-hand-acts --- modules/gitea/index.ts | 14 ++++++++--- modules/gitea/test/views.test.ts | 22 +++++++++++++---- modules/gitea/views.ts | 41 ++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 4554e9120..545120e5a 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -51,7 +51,7 @@ import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check import { state } from "@novox/mesh-sdk/state"; import { on } from "@novox/mesh-sdk/events"; import { broker } from "@novox/mesh-sdk/messaging"; -import { ViewFollower, viewTools, type ViewAnswer, type ViewEvent } from "./views.js"; +import { ViewFollower, unansweredFinding, viewTools, type ViewAnswer, type ViewEvent } from "./views.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; @@ -514,7 +514,7 @@ const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaA let modulesView: { follower: ViewFollower; owner: string; tool: string; state: string } | undefined; // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). -registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView), ...(gitea ? [commitStatusTool({ +registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView, (act) => broker().request("seat:mesh-hand-acts.hand-act", act)), ...(gitea ? [commitStatusTool({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gates, }), mergeTool(gitea, { gates })] : [])]); @@ -635,7 +635,15 @@ if (gitea) { // ADR 0615: asked once of the indexer's read tool, then kept by its events; the indexer's state itself is not read. const modules = new ViewFollower({ owner: INDEXER_OWNER, state: "modules", tool: INDEXER_MODULES_TOOL, ask: (after) => broker().request(INDEXER_MODULES_TOOL, after ? { values: true, after } : { values: true }) as Promise, - take: (change) => w.take(change as ModulesChange) }); + take: (change) => w.take(change as ModulesChange), + // Unanswered past its bound is a finding of the module's own, which the condition keeper raises (ADR 0615 §6). + raise: async (asked, error) => { + const f = unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, asked, error, new Date()); + await state("findings").put(f.key, f.value); + }, + clear: async () => { + await state("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key); + } }); void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body))); modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" }; void modules.start(); diff --git a/modules/gitea/test/views.test.ts b/modules/gitea/test/views.test.ts index a03c1c3c4..3f53160c9 100644 --- a/modules/gitea/test/views.test.ts +++ b/modules/gitea/test/views.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { ViewFollower, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts"; +import { ViewFollower, findingKey, unansweredFinding, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts"; // ADR 0615: the module asks the indexer's read tool once, then keeps its picture of `modules` from the indexer's views // events; a gap asks once more; an owner that is down is asked again and never read instead. @@ -16,12 +16,14 @@ test("a follower takes the first view, then the events, and asks again only on a const said: string[] = []; let asks = 0; let down = 2; + const raised: string[][] = []; const f = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { if (down-- > 0) throw new Error("no responders"); asks++; return answer(9, { a: { repository: "novox/a" }, b: { repository: "novox/b" } }); }, - take: (c) => { changes.push(c); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() }); + take: (c) => { changes.push(c); }, raise: (_a, e) => { raised.push(["raised", e]); }, clear: () => { raised.push(["cleared", ""]); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() }); await f.start(); assert.equal(f.ready, true); assert.equal(f.values.size, 2); + assert.deepEqual(raised.map((r) => r.slice(0, 2)), [["raised", "no responders"], ["cleared", ""]]); assert.ok(said.some((l) => l.includes("no first view of o's modules") && l.includes("o.read")), said.join("|")); await f.hear(ev(8, "a", { repository: "old" })); // already in the picture await f.hear(ev(10, "c", { repository: "novox/c" })); @@ -40,11 +42,21 @@ test("a follower takes the first view, then the events, and asks again only on a test("the reader's two tools say where the first view stands and ask it again by hand", async () => { let asks = 0; + const acts: Record[] = []; const follower = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { asks++; return answer(5, { a: 1 }); }, take: () => {}, sleep: async () => {} }); - const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" })); + const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async (act) => { acts.push(act); }); assert.deepEqual(await first.run({}), { views: [{ owner: "o", tool: "o.read", state: "modules", revision: 0, created: "", keys: 0, answered: false, asks: 0, "by-hand": 0 }] }); const after = (await again.run({})) as { views: { revision: number; "by-hand": number; asks: number }[] }; - assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks], [5, 1, 1]); - assert.deepEqual(await viewTools(() => undefined)[0].run({}), { views: [] }); + assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks, acts.length], [5, 1, 1, 1]); + await assert.rejects(viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async () => { throw new Error("no holder"); })[1].run({}), /not recorded/); + assert.deepEqual(await viewTools(() => undefined, async () => {})[0].run({}), { views: [] }); +}); + +test("the finding of a first view unanswered is the one mesh-sdk writes: key, kind, subject and the words", () => { + const f = unansweredFinding("gitea", "mesh-delivery-indexer", "modules", "mesh-delivery-indexer.indexer_read_modules", new Date("2026-11-04T12:00:00Z"), "no responders", new Date("2026-11-04T12:03:00Z")); + assert.equal(f.key, "module.gitea.mesh-delivery-indexer.modules.first-view-unanswered"); + assert.equal(findingKey("module", "a.b", "k"), "module.a.b.k"); + assert.equal(f.value.severity, "warning"); + assert.match(String(f.value.summary), /gitea has no first view of mesh-delivery-indexer's modules: .* \(no responders\)/); }); diff --git a/modules/gitea/views.ts b/modules/gitea/views.ts index 9872e4b20..dbcc7d6a4 100644 --- a/modules/gitea/views.ts +++ b/modules/gitea/views.ts @@ -55,6 +55,9 @@ export interface ViewFollowerOptions { now?: () => number; /** How long it asks before it says so; two minutes. */ boundMs?: number; + /** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */ + raise?: (asked: Date, error: string) => void | Promise; + clear?: () => void | Promise; } export class ViewFollower { @@ -100,11 +103,15 @@ export class ViewFollower { for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) { try { await this.askOnce(); - if (said) this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`); + if (said) { + this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`); + await this.o.clear?.(); + } return; } catch (err) { if (!said && now() - first >= (this.o.boundMs ?? 120_000)) { said = true; + await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err)); this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` + `(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`); } @@ -178,7 +185,8 @@ export function status(f: ViewFollower, owner: string, tool: string, state: stri } /** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */ -export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined): Tool[] { +export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined, + record: (act: Record) => Promise): Tool[] { return [ { name: "gitea_first_views", @@ -196,9 +204,38 @@ export function viewTools(view: () => { follower: ViewFollower; owner: string; t run: async () => { const v = view(); if (!v) throw new Error("the module follows no first view yet"); + // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing. + try { + await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" }); + } catch (err) { + throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`); + } await v.follower.again("the operator asked"); return { views: [status(v.follower, v.owner, v.tool, v.state)] }; }, }, ]; } + +/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ +export function findingKey(scope: string, id: string, kind: string): string { + return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); +} + +export const UNANSWERED_KIND = "first-view-unanswered"; + +/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the + * condition keeper raises the condition from the module's state `findings`. */ +export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date): + { key: string; value: Record } { + const id = `${reader}.${owner}.${state}`; + const key = findingKey("module", id, UNANSWERED_KIND); + const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`; + return { key, value: { + v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary, + headline: `${reader} waits for ${owner}`, + explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` + + `Look at ${owner}'s runtime of its own: is it running and connected?`, + resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(), + } }; +}