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:
@@ -10,9 +10,13 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// FindingsAskedEvent is the event `findings-asked`, always the seat mesh-conditions' event, named with that seat (design 47 G
|
||||
// A-17); a stdio module's writer subscribes to it by this name, and no module supplies it.
|
||||
const FindingsAskedEvent = "mesh-conditions.findings-asked"
|
||||
|
||||
// NewStdioWriter makes the finding writer of a stdio module (design 47 G A-16, §461, §485, §503): it fills `Publish` from
|
||||
// the module's own event publish (stdio.Emit, answered once the bus acknowledged it) and `Listen` from its subscription
|
||||
// (stdio.SubscribeNew, only the asks published after it is made), so no module writes that adapter. A `Publish` or `Listen`
|
||||
// (stdio.SubscribeNew of FindingsAskedEvent, only the asks published after it is made), so no module writes that adapter. A `Publish` or `Listen`
|
||||
// the config already holds is kept. The runtime publishes and subscribes by the event's name on the subject the module's
|
||||
// membership names for it, so each of the writer's four subjects (FindingSubjects) is told by its event.
|
||||
func NewStdioWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*Writer[F, C, S, A], error) {
|
||||
@@ -32,11 +36,8 @@ func newStdioWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A],
|
||||
}
|
||||
}
|
||||
if cfg.Listen == nil {
|
||||
cfg.Listen = func(_ context.Context, subject string, handle func([]byte)) (func(), error) {
|
||||
event, err := eventOfSubject(subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Listen = func(_ context.Context, _ string, handle func([]byte)) (func(), error) {
|
||||
event := FindingsAskedEvent
|
||||
var stopped atomic.Bool
|
||||
// the runtime keeps a subscription until the process ends: a stopped listener hands on nothing
|
||||
if err := subscribe(event, func(env stdio.Envelope) error {
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestStdioWriterPublishesAndListensThroughTheModulesOwnPlumbing(t *testing.T
|
||||
}
|
||||
var heard []string
|
||||
stop, err := w.cfg.Listen(context.Background(), w.subj.Asked, func(b []byte) { heard = append(heard, string(b)) })
|
||||
if err != nil || len(subscribed) != 1 || subscribed[0] != "findings-asked" {
|
||||
if err != nil || len(subscribed) != 1 || subscribed[0] != "mesh-conditions.findings-asked" {
|
||||
t.Fatalf("the listen: %v %v", subscribed, err)
|
||||
}
|
||||
_ = deliver(stdio.Envelope{Body: json.RawMessage(`{"ask":"x"}`)})
|
||||
|
||||
@@ -310,8 +310,9 @@ func TestViews(t *testing.T) {
|
||||
defer func() { waitBound = kept }()
|
||||
var raised, cleared, said []Status
|
||||
var mu sync.Mutex
|
||||
raisedCh := make(chan struct{}, 4) // told after the part's raise, so the test reads it only once it is made
|
||||
f := &Follower{Owner: "o", Tool: "o.revision", State: BranchState("catalog", "trunk"), Now: now,
|
||||
RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock() },
|
||||
RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock(); raisedCh <- struct{}{} },
|
||||
ClearWaits: func(s Status) { mu.Lock(); cleared = append(cleared, s); mu.Unlock() },
|
||||
Said: func(s Status) { mu.Lock(); said = append(said, s); mu.Unlock() }}
|
||||
f.Holds("trunk.11")
|
||||
@@ -321,8 +322,10 @@ func TestViews(t *testing.T) {
|
||||
t.Fatalf("status = %+v", s)
|
||||
}
|
||||
ended <- true
|
||||
for i := 0; i < 1000 && !f.Status().WaitsRaised; i++ {
|
||||
runtime.Gosched()
|
||||
select {
|
||||
case <-raisedCh:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the bound ended and view-waits was not raised")
|
||||
}
|
||||
mu.Lock()
|
||||
if len(raised) != 1 || raised[0].WaitsFor != "trunk.12" {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+25
-9
@@ -861,7 +861,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
const w = new Writer({
|
||||
module: "m",
|
||||
state: st,
|
||||
subjects: { written: "w", cleared: "c", stated: "s", asked: "a" },
|
||||
|
||||
observe: async () => {
|
||||
if (++observes <= 2) throw new Error("not yet");
|
||||
return { findings: [{ kind: "k", scope: "module", id: "a", severity: "warning", summary: "now", source: "p", subject: { scope: "module", id: "a" } }], pending: [{ key: "module.wait.k", since: "2026-11-11T11:00:00Z", bound: "5m" }] };
|
||||
@@ -870,7 +870,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
if (refuse > 0) { refuse--; throw new Error("no ack"); }
|
||||
sent.push({ subject, body });
|
||||
},
|
||||
listen: async (_s, h) => { consumers++; ask = h; return () => {}; },
|
||||
listen: async (s, h) => { assert.equal(s, "mesh-conditions.findings-asked"); consumers++; ask = h; return () => {}; },
|
||||
log: (l) => logs.push(l),
|
||||
now: () => new Date(clock),
|
||||
wait: async (ms) => { waits.push(ms); clock += ms; return true; },
|
||||
@@ -881,11 +881,11 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
clock += 3600_000;
|
||||
await w.write({ ...f, run: "other" }); // nothing it says changed
|
||||
await w.write({ ...f, summary: "two" });
|
||||
assert.deepEqual(sent.map((s) => s.subject), ["w", "w"]);
|
||||
assert.deepEqual(sent.map((s) => s.subject), ["finding-written", "finding-written"]);
|
||||
assert.equal(sent[1].body.since, first);
|
||||
assert.equal(sent[1].body.observer, "m");
|
||||
await w.clear("module", "never", "k", "held by the keeper");
|
||||
assert.equal(sent[2].subject, "c");
|
||||
assert.equal(sent[2].subject, "finding-cleared");
|
||||
assert.equal(sent[2].body.why, "held by the keeper");
|
||||
// an unacknowledged event is sent again for a minute, then logged naming the finding
|
||||
refuse = 1000;
|
||||
@@ -900,7 +900,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
await w.start();
|
||||
for (let i = 0; i < 50 && sent.length < 4; i++) await new Promise((r) => setImmediate(r));
|
||||
const stated = sent[3];
|
||||
assert.equal(stated.subject, "s");
|
||||
assert.equal(stated.subject, "findings-stated");
|
||||
assert.deepEqual(waits.slice(0, 2), [OBSERVE_RETRY_MS, OBSERVE_RETRY_MS]);
|
||||
assert.equal(stated.body.findings.length, 1);
|
||||
assert.equal(stated.body.findings[0].since, first);
|
||||
@@ -921,7 +921,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const w2 = new Writer({
|
||||
module: "m", state: st, subjects: { written: "w", cleared: "c", stated: "s", asked: "a" },
|
||||
module: "m", state: st,
|
||||
observe: async () => { await gate; return { findings: [], pending: [] }; },
|
||||
publish: async () => {}, listen: async () => () => {},
|
||||
});
|
||||
@@ -935,7 +935,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe
|
||||
});
|
||||
|
||||
test("the finding writer owns its kinds, keeps a views reader's findings in its statement and refuses an owner of no form", async () => {
|
||||
const { Writer, VIEW_KINDS } = await import("../dist/views/index.js");
|
||||
const { Writer, VIEW_KINDS, checkFinding, escapeName, viewFindingBody, writerFindings, viewWaitsFinding } = await import("../dist/views/index.js");
|
||||
const store = new Map<string, any>();
|
||||
const st: any = {
|
||||
get: async (k: string) => (store.has(k) ? { key: k, value: store.get(k), revision: 1 } : null),
|
||||
@@ -945,7 +945,7 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its
|
||||
};
|
||||
const sent: { subject: string; body: any }[] = [];
|
||||
const w = new Writer({
|
||||
module: "m", state: st, kinds: ["k"], subjects: { written: "w", cleared: "c", stated: "s", asked: "a" },
|
||||
module: "m", state: st, kinds: ["k"],
|
||||
observe: async () => ({ findings: [], pending: [] }),
|
||||
publish: async (subject, body) => void sent.push({ subject, body }),
|
||||
listen: async () => () => {},
|
||||
@@ -954,6 +954,10 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its
|
||||
await assert.rejects(w.write({ ...f, kind: "other" }), /"other"/);
|
||||
await assert.rejects(w.clear("module", "a", "other", "why"), /"other"/);
|
||||
await assert.rejects(w.write({ ...f, owner: "Not A Form" }), /owner/);
|
||||
// a body whose wire form lacks a field the contract names fails at its first write, naming it (design 47 G A-3)
|
||||
const { subject: _s, ...noSubject } = f;
|
||||
await assert.rejects(w.write(noSubject as any), /subject/);
|
||||
assert.throws(() => checkFinding({ ...f, kind: "a.b" }), /dot/);
|
||||
await assert.rejects(w.write({ ...f, "waiting-on": "x y" }), /waiting-on/);
|
||||
await w.write({ ...f, owner: "me@node-1" });
|
||||
await w.write({ ...f, kind: VIEW_KINDS[0], id: "m.o.s" });
|
||||
@@ -961,7 +965,19 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its
|
||||
await w.state();
|
||||
assert.deepEqual([...store.keys()].sort(), ["module.m.o.s.view-waits", "module.x.other"].sort());
|
||||
const stated = sent.at(-1)!;
|
||||
assert.equal(stated.subject, "s");
|
||||
assert.equal(stated.subject, "findings-stated");
|
||||
assert.ok(stated.body.kinds.includes("k") && stated.body.kinds.includes("view-waits"));
|
||||
assert.equal(stated.body.findings.length, 1);
|
||||
// a views reader's findings are laid out by the writer from the part's plain ones (design 47 G §687)
|
||||
assert.equal(escapeName("a b.c"), "a=20b=2ec");
|
||||
const vf = viewWaitsFinding("m", { owner: "o", state: "s", "waiting-since": "2026-11-11T12:00:00Z", "waits-for": "r7", view: "" } as any, new Date("2026-11-11T12:06:00Z"));
|
||||
const laid = viewFindingBody(vf, "m", "n1");
|
||||
assert.deepEqual([laid.kind, laid.scope, laid.id, laid.severity, laid.source], ["view-waits", "module", "m=2eo=2es", "warning", "views"]);
|
||||
assert.deepEqual(laid.subject, { scope: "module", id: "m", machine: "n1" });
|
||||
assert.equal(viewFindingBody({ ...vf, kind: "view-too-large" }, "m", "n1").severity, "urgent");
|
||||
const vw = writerFindings(w, "m", "n1");
|
||||
await vw.put(vf);
|
||||
assert.equal(store.has("module.m=2eo=2es.view-waits"), true);
|
||||
await vw.delete(vf);
|
||||
assert.equal(store.has("module.m=2eo=2es.view-waits"), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user