G V-8 (TS), A-17, test: the TypeScript writer is generic over the contract's bodies and names its events; findings-asked is mesh-conditions' event

The TypeScript writer holds no FindingBody of its own and works on the wire form (checkFinding, views reader findings laid out by
viewFindingBody); both writers name findings-asked as the seat mesh-conditions' event. The view-waits test waits on the raise,
not on a count of yields.

Builds: design 47 G §686, §640, §687, A-3, A-13, A-17
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 21:45:07 +01:00
parent 43960cc925
commit 58bbc26219
6 changed files with 165 additions and 130 deletions
+46
View File
@@ -482,6 +482,52 @@ export function viewWaitsFinding(part: string, s: Status, at: Date): Finding {
};
}
/** The kinds a views reader's findings written through the part's writer are of, and the one that is urgent (design 47 G §687). */
const TOO_LARGE_KIND = "view-too-large";
/** A name as a key's part (design 47 G §73): a character outside letters, digits, `-`, `/` and `_` is `=` and its two lowercase hex digits per byte. */
export function escapeName(name: string): string {
let out = "";
for (const b of new TextEncoder().encode(name)) {
const c = String.fromCharCode(b);
out += /[A-Za-z0-9\-/_]/.test(c) ? c : `=${b.toString(16).padStart(2, "0")}`;
}
return out;
}
/**
* A views reader's finding laid out in its wire form by mesh-sdk (design 47 G §687): `kind` the finding's, `scope` module,
* `id` the finding's id escaped as a key's name is, `severity` warning but urgent for view-too-large, `source` views,
* `subject` the part's module on its node, `summary` and `headline` the finding's texts. The Go side's `views.WriterFindings`.
*/
export function viewFindingBody(f: Finding, module: string, node: string): Record<string, unknown> {
return {
kind: f.kind,
scope: "module",
id: escapeName(f.id),
severity: f.kind === TOO_LARGE_KIND ? "urgent" : "warning",
source: "views",
subject: { scope: "module", id: module, ...(node ? { machine: node } : {}) },
summary: f.summary,
...(f.headline ? { headline: f.headline } : {}),
...(f.at ? { at: f.at } : {}),
};
}
/** The part of a Writer a views reader's findings use, whatever the contract's body types. */
export interface WireWriter {
write(finding: any): Promise<void>;
clear(scope: string, id: string, kind: string, why: string): Promise<void>;
}
/** A views reader's findings written through the part's writer, as kinds of the reader's own (design 47 G §639, §687). */
export function writerFindings(writer: WireWriter, module: string, node: string): { put(f: Finding): Promise<void>; delete(f: Finding): Promise<void> } {
return {
put: (f) => writer.write(viewFindingBody(f, module, node)),
delete: (f) => writer.clear("module", escapeName(f.id), f.kind, "the part no longer observes it"),
};
}
/** A Follower's `said` that keeps its status at statusKey in the reader's state STATUS_STATE. */
export function keepStatus(st: State<Status>): (s: Status) => Promise<void> {
return async (s) => {
+80 -111
View File
@@ -1,74 +1,44 @@
// The finding writer, TypeScript side (design 47 G §461, §485, §503): the same acts as the Go `views` writer, with the
// same bodies (§439) and rules, for the observers written in TypeScript. It keeps an observer's findings in its own state
// `findings` and says them as events (§383, §384). It names no seat and no module: the observer hands in the subjects of
// `finding-written`, `finding-cleared`, `findings-stated` and `findings-asked`, which the seat's generated contract makes.
// same rules, for the observers written in TypeScript, generic over the contract's body types (plain structures) and
// working on their wire form, with no layout of a finding of its own (design 47 G A-3, §640). It keeps an observer's findings in its own state
// `findings` and says them as events (§383, §384). It names the four events itself (design 47 G A-13, A-17): it says
// `finding-written`, `finding-cleared` and `findings-stated` by their names, which the runtime publishes on the subject the
// module's membership names for each, and hears `findings-asked` as the seat mesh-conditions' event; a module passes none.
// Its acts, `write`, `clear` and a statement, run one at a time in the order they are asked, on one chain of promises.
import type { State } from "../state/index.js";
/** The event `findings-asked` is always the seat mesh-conditions' (design 47 G A-17), named with that seat. */
export const FINDINGS_ASKED_EVENT = "mesh-conditions.findings-asked";
/** The events the writer says, by their names (design 47 G §383, A-13). */
export const FINDING_WRITTEN_EVENT = "finding-written";
export const FINDING_CLEARED_EVENT = "finding-cleared";
export const FINDINGS_STATED_EVENT = "findings-stated";
/** How long an event the bus did not acknowledge is sent again before it is logged as an error (§383). */
export const PUBLISH_RETRY_MS = 60 * 1000;
/** How often a failing `observe` is called again until a set is said (§485). */
export const OBSERVE_RETRY_MS = 60 * 1000;
/** What a finding is about. */
export interface FindingSubject {
scope: string;
id: string;
machine?: string;
also?: string[];
}
/** One observation, as it was said. */
export interface FindingEvidence {
at: string;
said: string;
}
/** One answer the operator may give from a notification. */
export interface FindingAction {
label: string;
verb: string;
machine?: string;
arguments?: Record<string, string>;
level?: "acknowledge" | "approve";
}
/**
* One finding as an observer says it: the layout whole of §439's list. It is the body of `finding-written`, of each entry
* of `findings-stated`, and the value of its key `<scope>.<id>.<kind>` in the state `findings`.
* What the writer needs of a contract's finding body (design 47 G A-3, §640): the fields it reads and sets by their
* contract names. The body types are the seat generator's, plain structures; a body type lacking one of these fields
* does not compile as the writer's `F`, and a value lacking its `subject` is refused at its first write.
*/
export interface FindingBody {
export interface FindingFields {
kind: string;
scope: string;
id: string;
subject: object;
at?: string;
since?: string;
severity: "urgent" | "warning";
summary: string;
headline?: string;
explanation?: string;
subject: FindingSubject;
evidence?: FindingEvidence[];
source: string;
observer?: string;
owner?: string;
"waiting-on"?: string;
actions?: FindingAction[];
needs?: string;
resolver?: string;
ref?: string;
run?: string;
}
/** The body of `finding-cleared`. */
export interface FindingCleared {
kind: string;
scope: string;
id: string;
at: string;
why: string;
}
/** A body in its wire form (design 47 G A-3). */
type Wire = Record<string, unknown>;
/** A key the observer cannot judge yet since its start: its `since` and its bound (a duration such as `5m`). */
export interface PendingKey {
@@ -77,27 +47,9 @@ export interface PendingKey {
bound: string;
}
/** The body of `findings-stated`. */
export interface FindingsStated {
at: string;
asked?: string;
/** The kinds this statement covers (design 47 G §580); none means every kind. */
kinds?: string[];
findings: FindingBody[];
pending: PendingKey[];
}
/** The body of `findings-asked`. */
export interface FindingsAsked {
ask: string;
at: string;
by: string;
module?: string;
}
/** What the observer's own truth says now. */
export interface Observed {
findings: FindingBody[];
/** What the observer's own truth says now: its findings, in the contract's finding type, and its pending keys. */
export interface Observed<F> {
findings: F[];
pending: PendingKey[];
}
@@ -109,8 +61,9 @@ const SEAT_FORM = /^[a-z][a-z0-9-]*$/;
const AUTHOR_FORM = /^forge:[A-Za-z0-9._-]+$/;
/** A finding's `owner` or `waiting-on` is a session (who@node), a seat's name or a commit's author (design 47 G §678). */
function formOfWho(field: string, v: string | undefined): void {
if (!v || SESSION_FORM.test(v) || SEAT_FORM.test(v) || AUTHOR_FORM.test(v)) return;
function formOfWho(field: string, v: unknown): void {
if (v === undefined || v === null || v === "") return;
if (typeof v === "string" && (SESSION_FORM.test(v) || SEAT_FORM.test(v) || AUTHOR_FORM.test(v))) return;
throw new Error(`the finding's ${field} "${v}" is none of a session (who@node), a seat's name or a commit's author (forge:<account>)`);
}
@@ -119,21 +72,36 @@ export function findingKey(scope: string, id: string, kind: string): string {
return `${scope}.${id}.${kind}`;
}
export interface WriterOptions {
/**
* Refuse a finding the keeper could not make a condition of, naming the field of the first rule it breaks: the key's three
* parts (each given, the kind with no dot, since the kind is the key's last part), the subject, and the forms of `owner`
* and `waiting-on` (design 47 G §678, §689, A-3). It judges the finding's wire form: the contract's generated type is one.
*/
export function checkFinding(finding: object): void {
const m = finding as Wire;
const given = (f: string): boolean => typeof m[f] === "string" && (m[f] as string).trim() !== "";
if (!given("scope")) throw new Error("the finding's scope is not given");
if (!given("id")) throw new Error("the finding's id is not given");
if (!given("kind")) throw new Error("the finding's kind is not given");
if ((m.kind as string).includes(".")) throw new Error(`the finding's kind "${m.kind}" holds a dot, and the kind is the last part of its key`);
if (!m.subject || typeof m.subject !== "object") throw new Error("the finding's subject is not given, and the contract's finding names it");
formOfWho("owner", m.owner);
formOfWho("waiting-on", m["waiting-on"]);
}
export interface WriterOptions<F extends FindingFields, C extends object, S extends object, A extends object> {
/** The observer's module name: it fills the `observer` of a finding and tells an ask for another module. */
module: string;
/** The observer's own state `findings`. */
state: State<FindingBody>;
state: State<F>;
/** The finding kinds this writer owns (design 47 G §580): a write or clear of another kind is refused, naming it, and a statement carries them as `kinds` and deletes only keys of them. None: every kind. The views reader's kinds are always its own besides. */
kinds?: string[];
/** The subjects of the four events. */
subjects: { written: string; cleared: string; stated: string; asked: string };
/** The findings the observer observes now and its pending keys. It is called inside the writer's one chain: it must not call `write` or `clear`. */
observe(): Promise<Observed>;
/** Say one message and resolve when the bus acknowledged it; `id` is the bus's de-duplication id. */
publish(subject: string, body: unknown, id: string): Promise<void>;
/** Make a consumer of `subject` that delivers only the messages published after it is made; resolves to its stop. */
listen(subject: string, handle: (body: unknown) => void): Promise<() => void>;
observe(): Promise<Observed<F>>;
/** Say one event, by its name, and resolve when the bus acknowledged it; `id` is the bus's de-duplication id. */
publish(subject: string, body: F | C | S, id: string): Promise<void>;
/** Make a consumer of the event `subject` names that delivers only the messages published after it is made; resolves to its stop. */
listen(subject: string, handle: (body: A) => void): Promise<() => void>;
/** Put a set larger than one bus message in the observer's sent store; resolves to what to publish instead. */
send?(label: string, body: unknown): Promise<unknown>;
log?(line: string): void;
@@ -142,9 +110,9 @@ export interface WriterOptions {
wait?(ms: number): Promise<boolean>;
}
/** The finding writer of an observer. */
export class Writer {
private readonly o: WriterOptions;
/** The finding writer of an observer: `F`, `C`, `S` and `A` are the contract's bodies of `finding-written`, `finding-cleared`, `findings-stated` and `findings-asked`. */
export class Writer<F extends FindingFields, C extends object, S extends object, A extends object> {
private readonly o: WriterOptions<F, C, S, A>;
private chain: Promise<unknown> = Promise.resolve();
private seq = 0;
private stop?: () => void;
@@ -154,7 +122,7 @@ export class Writer {
private readonly closing: Promise<void>;
private close_!: () => void;
constructor(o: WriterOptions) {
constructor(o: WriterOptions<F, C, S, A>) {
this.o = o;
this.closing = new Promise((r) => (this.close_ = r));
}
@@ -206,7 +174,7 @@ export class Writer {
try {
await this.listen();
} catch (e) {
this.log(`error: the finding writer could not make its consumer of ${this.o.subjects.asked} again: ${e}`);
this.log(`error: the finding writer could not make its consumer of ${FINDINGS_ASKED_EVENT} again: ${e}`);
}
this.request("");
}
@@ -220,13 +188,13 @@ export class Writer {
}
private async listen(): Promise<void> {
const stop = await this.o.listen(this.o.subjects.asked, (body) => this.heard(body));
const stop = await this.o.listen(FINDINGS_ASKED_EVENT, (body) => this.heard(body));
this.stop?.();
this.stop = stop;
}
private heard(body: unknown): void {
const a = body as FindingsAsked | null;
const a = body as Wire | null;
if (!a || typeof a.ask !== "string") {
this.log("an ask for findings could not be read");
return;
@@ -263,12 +231,12 @@ export class Writer {
}
/** Keep the finding in its key and say `finding-written` only when what it says changed; the key's `since` stays. */
write(f: FindingBody): Promise<void> {
write(f: F): Promise<void> {
return this.act(async () => {
const [full, old] = await this.complete(f);
await this.o.state.put(key(full), full);
await this.o.state.put(key(full), full as unknown as F);
if (old && unsaid(old) === unsaid(full)) return;
await this.say(this.o.subjects.written, "finding-written", key(full), full);
await this.say(FINDING_WRITTEN_EVENT, "finding-written", key(full), full as unknown as F);
});
}
@@ -278,8 +246,8 @@ export class Writer {
if (!this.owns(kind)) throw new Error(`the finding kind "${kind}" is not one this writer owns (${this.o.kinds})`);
const k = findingKey(scope, id, kind);
await this.o.state.delete(k);
const body: FindingCleared = { kind, scope, id, at: this.stamp(this.now()), why };
await this.say(this.o.subjects.cleared, "finding-cleared", k, body);
const body = { kind, scope, id, at: this.stamp(this.now()), why };
await this.say(FINDING_CLEARED_EVENT, "finding-cleared", k, body as unknown as C);
});
}
@@ -289,10 +257,10 @@ export class Writer {
const obs = await this.o.observe();
const keep = new Set<string>();
const kinds = this.o.kinds?.length ? [...new Set([...this.o.kinds, ...VIEW_KINDS])].sort() : undefined;
const body: FindingsStated = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), ...(kinds ? { kinds } : {}), findings: [], pending: [] };
const body = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), ...(kinds ? { kinds } : {}), findings: [] as Wire[], pending: [] as PendingKey[] };
for (const f of obs.findings) {
const [full] = await this.complete(f);
await this.o.state.put(key(full), full);
await this.o.state.put(key(full), full as unknown as F);
keep.add(key(full));
body.findings.push(full);
}
@@ -307,38 +275,38 @@ export class Writer {
const e = await this.o.state.get(k);
if (e) {
keep.add(k);
body.findings.push(e.value);
body.findings.push(e.value as unknown as Wire);
}
}
if (!keep.has(k) && this.owns(kind)) await this.o.state.delete(k);
}
let out: unknown = body;
let out = body as unknown as S;
if (this.o.send) {
try {
out = await this.o.send(`findings-stated.${this.o.module}`, body);
out = (await this.o.send(`findings-stated.${this.o.module}`, body)) as S;
} catch (e) {
this.log(`error: the findings-stated of ${this.o.module} could not be sent, the next statement covers it: ${e}`);
return;
}
}
await this.say(this.o.subjects.stated, "findings-stated", this.o.module, out);
await this.say(FINDINGS_STATED_EVENT, "findings-stated", this.o.module, out);
});
}
private async complete(f: FindingBody): Promise<[FindingBody, FindingBody | null]> {
if (!f.kind || !f.scope || !f.id) throw new Error(`a finding needs its kind, scope and id: ${f.scope}.${f.id}.${f.kind}`);
if (!this.owns(f.kind)) throw new Error(`the finding kind "${f.kind}" is not one this writer owns (${this.o.kinds})`);
formOfWho("owner", f.owner);
formOfWho("waiting-on", f["waiting-on"]);
private async complete(finding: F): Promise<[Wire, Wire | null]> {
const f: Wire = { ...(finding as object) };
checkFinding(f);
const kind = f.kind as string;
if (!this.owns(kind)) throw new Error(`the finding kind "${kind}" is not one this writer owns (${this.o.kinds})`);
const e = await this.o.state.get(key(f));
const old = e ? e.value : null;
const at = f.at ?? this.stamp(this.now());
const since = old?.since ?? f.since ?? at;
const old = e ? (e.value as unknown as Wire) : null;
const at = typeof f.at === "string" && f.at ? f.at : this.stamp(this.now());
const since = (typeof old?.since === "string" && old.since) || (typeof f.since === "string" && f.since) || at;
return [{ ...f, observer: f.observer || this.o.module, at, since }, old];
}
/** Publish awaiting the acknowledgement, again for a minute when unacknowledged, then log the error naming what it said. */
private async say(subject: string, event: string, what: string, body: unknown): Promise<void> {
private async say(subject: string, event: string, what: string, body: F | C | S): Promise<void> {
const id = `${this.o.module}-${event}-${this.now().getTime()}-${++this.seq}`;
const start = this.now().getTime();
for (let delay = 1000; ; delay = Math.min(delay * 2, 10000)) {
@@ -355,12 +323,13 @@ export class Writer {
}
}
function key(f: FindingBody): string {
return findingKey(f.scope, f.id, f.kind);
function key(f: object): string {
const m = f as Wire;
return findingKey(m.scope as string, m.id as string, m.kind as string);
}
/** A finding as compared to tell whether what it says changed: not the time it was last seen, nor what saw it. */
function unsaid(f: FindingBody): string {
function unsaid(f: Wire): string {
const { at: _at, run: _run, ...rest } = f;
return canonical(rest);
}