T-3: the finding writer in src/views (TypeScript twin), transport handed in
Builds: design 47 G §383, §384, §390, §439, §461, §485, §503 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -563,3 +563,4 @@ export function statusTools(
|
||||
},
|
||||
];
|
||||
}
|
||||
export * from "./writer.js";
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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.
|
||||
// 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";
|
||||
|
||||
/** 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`.
|
||||
*/
|
||||
export interface FindingBody {
|
||||
kind: string;
|
||||
scope: string;
|
||||
id: string;
|
||||
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 key the observer cannot judge yet since its start: its `since` and its bound (a duration such as `5m`). */
|
||||
export interface PendingKey {
|
||||
key: string;
|
||||
since: string;
|
||||
bound: string;
|
||||
}
|
||||
|
||||
/** The body of `findings-stated`. */
|
||||
export interface FindingsStated {
|
||||
at: string;
|
||||
asked?: 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[];
|
||||
pending: PendingKey[];
|
||||
}
|
||||
|
||||
/** The key of a finding in the state `findings`: `<scope>.<id>.<kind>`. */
|
||||
export function findingKey(scope: string, id: string, kind: string): string {
|
||||
return `${scope}.${id}.${kind}`;
|
||||
}
|
||||
|
||||
export interface WriterOptions {
|
||||
/** 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>;
|
||||
/** 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>;
|
||||
/** 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;
|
||||
now?(): Date;
|
||||
/** Pause for `ms`; resolve false when the writer closed. */
|
||||
wait?(ms: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The finding writer of an observer. */
|
||||
export class Writer {
|
||||
private readonly o: WriterOptions;
|
||||
private chain: Promise<unknown> = Promise.resolve();
|
||||
private seq = 0;
|
||||
private stop?: () => void;
|
||||
private asks: string[] = [];
|
||||
private busy = false;
|
||||
private closed = false;
|
||||
private readonly closing: Promise<void>;
|
||||
private close_!: () => void;
|
||||
|
||||
constructor(o: WriterOptions) {
|
||||
this.o = o;
|
||||
this.closing = new Promise((r) => (this.close_ = r));
|
||||
}
|
||||
|
||||
private log(line: string): void {
|
||||
this.o.log?.(line);
|
||||
}
|
||||
private now(): Date {
|
||||
return this.o.now ? this.o.now() : new Date();
|
||||
}
|
||||
private stamp(d: Date): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
private async pause(ms: number): Promise<boolean> {
|
||||
if (this.o.wait) return this.o.wait(ms);
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => resolve(!this.closed), ms);
|
||||
void this.closing.then(() => {
|
||||
clearTimeout(t);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Run `act` after every act asked before it has ended. */
|
||||
private act<T>(act: () => Promise<T>): Promise<T> {
|
||||
const run = this.chain.then(act, act);
|
||||
this.chain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the consumer of `findings-asked`, then state the findings now; later each ask for this module (or for none) is
|
||||
* answered by a statement. Resolves once the consumer exists; the start statement, retried every minute until `observe`
|
||||
* answers, runs on.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.listen();
|
||||
this.request("");
|
||||
}
|
||||
|
||||
/** The bus connection was made again: make the consumer again, from the newest ask, and state the findings again (§485). */
|
||||
async reconnected(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
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.request("");
|
||||
}
|
||||
|
||||
/** End the consumer and the statements. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.stop?.();
|
||||
this.stop = undefined;
|
||||
this.close_();
|
||||
}
|
||||
|
||||
private async listen(): Promise<void> {
|
||||
const stop = await this.o.listen(this.o.subjects.asked, (body) => this.heard(body));
|
||||
this.stop?.();
|
||||
this.stop = stop;
|
||||
}
|
||||
|
||||
private heard(body: unknown): void {
|
||||
const a = body as FindingsAsked | null;
|
||||
if (!a || typeof a.ask !== "string") {
|
||||
this.log("an ask for findings could not be read");
|
||||
return;
|
||||
}
|
||||
if (a.module && a.module !== this.o.module) return;
|
||||
this.request(a.ask);
|
||||
}
|
||||
|
||||
private request(ask: string): void {
|
||||
this.asks.push(ask);
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
while (this.asks.length > 0 && !this.closed) {
|
||||
const asked = this.asks[this.asks.length - 1];
|
||||
this.asks = [];
|
||||
for (;;) {
|
||||
try {
|
||||
await this.state(asked);
|
||||
break;
|
||||
} catch (e) {
|
||||
this.log(`the findings could not be stated, no set said, trying again in ${OBSERVE_RETRY_MS / 1000}s: ${e}`);
|
||||
if (!(await this.pause(OBSERVE_RETRY_MS))) {
|
||||
this.busy = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
return this.act(async () => {
|
||||
const [full, old] = await this.complete(f);
|
||||
await this.o.state.put(key(full), full);
|
||||
if (old && unsaid(old) === unsaid(full)) return;
|
||||
await this.say(this.o.subjects.written, "finding-written", key(full), full);
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete the key and say `finding-cleared`, also of a key that does not exist, so a keeper holding it clears it. */
|
||||
clear(scope: string, id: string, kind: string, why: string): Promise<void> {
|
||||
return this.act(async () => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Judge every finding anew, write each key, delete each key neither stated nor pending, and say `findings-stated`. */
|
||||
state(asked = ""): Promise<void> {
|
||||
return this.act(async () => {
|
||||
const obs = await this.o.observe();
|
||||
const keep = new Set<string>();
|
||||
const body: FindingsStated = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), findings: [], pending: [] };
|
||||
for (const f of obs.findings) {
|
||||
const [full] = await this.complete(f);
|
||||
await this.o.state.put(key(full), full);
|
||||
keep.add(key(full));
|
||||
body.findings.push(full);
|
||||
}
|
||||
for (const p of obs.pending) {
|
||||
keep.add(p.key);
|
||||
body.pending.push(p);
|
||||
}
|
||||
for (const k of await this.o.state.keys()) {
|
||||
if (!keep.has(k)) await this.o.state.delete(k);
|
||||
}
|
||||
let out: unknown = body;
|
||||
if (this.o.send) {
|
||||
try {
|
||||
out = await this.o.send(`findings-stated.${this.o.module}`, body);
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
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;
|
||||
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> {
|
||||
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)) {
|
||||
try {
|
||||
await this.o.publish(subject, body, id);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (this.now().getTime() - start >= PUBLISH_RETRY_MS || this.closed || !(await this.pause(delay))) {
|
||||
this.log(`error: ${event} for ${what} was not acknowledged by the bus, the next statement covers it: ${e}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function key(f: FindingBody): string {
|
||||
return findingKey(f.scope, f.id, f.kind);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const { at: _at, run: _run, ...rest } = f;
|
||||
return canonical(rest);
|
||||
}
|
||||
|
||||
/** JSON with every object's keys in order, so two equal findings compare equal whatever order they were built in. */
|
||||
function canonical(v: unknown): string {
|
||||
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
return `{${Object.keys(o)
|
||||
.filter((k) => o[k] !== undefined)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
Reference in New Issue
Block a user