CAT-RD2: gitea says an unanswered first view in its findings and records the re-read through mesh-hand-acts
This commit is contained in:
+11
-3
@@ -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<ViewAnswer>,
|
||||
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<unknown>("findings").put(f.key, f.value);
|
||||
},
|
||||
clear: async () => {
|
||||
await state<unknown>("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key);
|
||||
} });
|
||||
void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on<ViewEvent>(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body)));
|
||||
modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" };
|
||||
void modules.start();
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
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\)/);
|
||||
});
|
||||
|
||||
+39
-2
@@ -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<void>;
|
||||
clear?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
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<string, unknown>) => Promise<unknown>): 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<string, unknown> } {
|
||||
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(),
|
||||
} };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user