diff --git a/README.md b/README.md index c0a96bf1..266d5b3a 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ caller the runtime proved in the call's `_meta`, never an argument, and `callers holder's list per verb (`parseVerbCallers`, `configuredVerbCallers`: `*` any proved caller, `operator` any person); `views.Follower` keeps a first view of an owner's state from its pages and its events, asking again only on a gap, and `views.statusTools` serves its read and ask-again tools (`_first_views`, `_first_view_again`) -over the reader's state `first-views`. Checked by `test/stdio.test.ts` and `test/sdk.test.ts`. +over the reader's state `first-views`. `waitFor(view)` and `holds(view)` say the revision view a part waits for and +holds; past five minutes `raiseWaits` is told and `viewWaitsFinding` says the part's `view-waits` finding. Checked by `test/stdio.test.ts` and `test/sdk.test.ts`. ### `seats` and `channel` — publishing on a seat, the channel's benches (Go) A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says diff --git a/src/views/index.ts b/src/views/index.ts index ec82c609..a489b23b 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -22,6 +22,15 @@ export const STATUS_STATE = "first-views"; /** How long a reader asks before it raises its condition naming the owner and the tool (ADR 0615 §6), in ms. */ export const BOUND_MS = 2 * 60 * 1000; +/** + * How long a part waits for a view before it writes its finding view-waits (design 47 *A part with no view says it + * waits*), in ms: the Go side's `views.WaitsBound`, so an owner down for a restart raises nothing. + */ +export const WAITS_BOUND_MS = 5 * 60 * 1000; + +/** The kind of a part's finding of a view it has waited for past WAITS_BOUND_MS: the Go side's `views.ViewWaitsKind`. */ +export const VIEW_WAITS_KIND = "view-waits"; + /** The key of a reader's status of its first view of `owner`'s `state`. */ export function statusKey(owner: string, state: string): string { return `${owner}.${state}`; @@ -79,6 +88,15 @@ export interface Status { behind?: string; why?: string; error?: string; + /** + * The revision of the revision view the part holds from this first view, as its seat's client names it (design 47 *A + * part with no view says it waits*); `waits-for` the one it waits for and acts on nothing new until it holds, since + * `waiting-since`; `waits-raised` says its view-waits finding is written (past WAITS_BOUND_MS). + */ + view?: string; + "waits-for"?: string; + "waiting-since"?: string; + "waits-raised"?: boolean; } /** How a part makes a follower: the owner and the tool it asks (for the status and the log), and the hooks. */ @@ -91,6 +109,9 @@ export interface FollowerOptions { /** The reader's condition of this first view: raised past the bound, cleared by the answer. */ readonly raise?: (s: Status) => void | Promise; readonly clear?: (s: Status) => void | Promise; + /** The part's view-waits finding: written past WAITS_BOUND_MS of a waitFor, cleared when it holds the view. */ + readonly raiseWaits?: (s: Status) => void | Promise; + readonly clearWaits?: (s: Status) => void | Promise; /** Told of each key the picture changes, after the change is held. */ readonly changed?: (key: string, value: unknown, deleted: boolean, at: string) => void | Promise; /** Told of the status each time an ask starts, is answered or fails, and of each event taken: where a reader keeps it. */ @@ -103,6 +124,8 @@ export interface FollowerOptions { readonly wait?: number; /** Waits ms between two asks; a test hands one that does not sleep. */ readonly pause?: (ms: number) => Promise; + /** Waits out WAITS_BOUND_MS of a waitFor; a test hands one it ends itself. Absent: a timer that holds no process open. */ + readonly waitsBound?: (ms: number) => Promise; } const ms = (t: string | undefined): number => (t ? Date.parse(t) || 0 : 0); @@ -194,6 +217,53 @@ export class Follower { return ""; } + /** + * Say the part waits for `view`, a revision of the revision view its seat's client names, and acts on nothing new + * until it holds it (holds): the status says what and since when, and if it still waits for it past WAITS_BOUND_MS, + * raiseWaits is told (the part writes view-waits, viewWaitsFinding). Waiting for the view it waits for already changes + * nothing, not even since when. Resolves once the status is said; the bound runs on by itself. + */ + async waitFor(view: string): Promise { + if (!view || this.st["waits-for"] === view) return; + this.st["waits-for"] = view; + this.st["waiting-since"] = this.now(); + this.st["waits-raised"] = false; + this.log(`views: ${this.o.tool} waits for ${this.o.owner}'s view ${view}, from ${this.st["waiting-since"]}`); + await this.say(); + void this.boundWaits(view); + } + + private async boundWaits(view: string): Promise { + const bound = this.o.waitsBound ?? ((wait: number) => new Promise((resolve) => setTimeout(resolve, wait).unref?.())); + await bound(WAITS_BOUND_MS); + if (this.closed || this.st["waits-for"] !== view || this.st["waits-raised"]) return; + this.st["waits-raised"] = true; + this.log(`views: ${this.o.tool} has waited for ${this.o.owner}'s view ${view} since ${this.st["waiting-since"]}`); + try { + await this.o.raiseWaits?.(this.status()); + } catch (err) { + this.log(`views: the view-waits of ${this.o.tool} is not written: ${err instanceof Error ? err.message : String(err)}`); + } + await this.say(); + } + + /** Say the part holds `view` now: it waits no more, and a raised view-waits is cleared (clearWaits). */ + async holds(view: string): Promise { + const raised = !!this.st["waits-raised"]; + this.st.view = view; + delete this.st["waits-for"]; + delete this.st["waiting-since"]; + delete this.st["waits-raised"]; + if (raised) { + try { + await this.o.clearWaits?.(this.status()); + } catch (err) { + this.log(`views: the view-waits of ${this.o.tool} is not cleared: ${err instanceof Error ? err.message : String(err)}`); + } + } + await this.say(); + } + /** Ask the first view until it is answered (ADR 0615 §6); resolves once answered, or once the follower is closed. */ start(): Promise { return this.askUntil("start", false); @@ -378,6 +448,40 @@ export class Follower { } } +/** + * What a views part says is wrong with a state it follows, in plain fields, as the Go side's `views.Finding`: the writer + * the part gives encodes it as the condition keeper reads it, which is the seat mesh-conditions' contract, not this + * package's (novox/hq ADR 0634 §4). + */ +export interface Finding { + readonly observer: string; + readonly kind: string; + readonly id: string; + readonly summary: string; + readonly headline: string; + readonly explanation: string; + readonly at: string; +} + +/** + * A part's finding of the view it has waited for since `s["waiting-since"]` (design 47), as the Go side's + * `views.ViewWaitsFinding`: it names the part, the owner and the revision, and clears when the part holds it. + */ +export function viewWaitsFinding(part: string, s: Status, at: Date): Finding { + const since = s["waiting-since"] ? new Date(s["waiting-since"]).toISOString().replace(/\.\d{3}Z$/, "Z") : ""; + return { + observer: part, + kind: VIEW_WAITS_KIND, + id: `${part}.${s.owner}.${s.state}`, + summary: `${part} waits for ${s.owner}'s view ${s["waits-for"] ?? ""} since ${since} and acts on nothing new until it holds it; it holds ${s.view || "none"}`, + headline: `${part} waits for a view of ${s.owner}`, + explanation: + `${part} keeps running what it runs and takes up nothing new until ${s.owner}'s view ${s["waits-for"] ?? ""} reaches it. Look at ${s.owner}: is it running, and has it ` + + `said that revision? ${part}'s first views tool says what it holds and waits for.`, + at: at.toISOString(), + }; +} + /** A Follower's `said` that keeps its status at statusKey in the reader's state STATUS_STATE. */ export function keepStatus(st: State): (s: Status) => Promise { return async (s) => { @@ -413,7 +517,8 @@ export function statusTools( `${reader}'s first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, the tool it asks, the ` + "revision its picture holds now (events taken in included), the highest revision it heard and why it is behind it, if it " + "is, when it asked and was answered, how many asks (by hand among them) and whether its condition (unanswered past two " + - "minutes) is raised.", + "minutes) is raised; and the revision of the revision view it holds, the one it waits for and acts on nothing new until it " + + "holds, since when, and whether its view-waits finding (past five minutes) is written.", input: { type: "object", properties: {} }, run: async () => { const out: Status[] = []; diff --git a/test/sdk.test.ts b/test/sdk.test.ts index e3083623..65de1cef 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -664,7 +664,7 @@ test("the provider loop is named for what it is, and its old name stays an alias }); import { allows, refusal, parseCallers, parseVerbCallers, parseConfiguredVerbCallers, moduleOf, UNPROVEN } from "../dist/callers/index.js"; -import { Follower, statusTools, keepStatus, statusKey, type Answer, type Status } from "../dist/views/index.js"; +import { Follower, statusTools, keepStatus, statusKey, viewWaitsFinding, type Answer, type Status } from "../dist/views/index.js"; test("callers and verb callers judge a proved caller by the Go side's rules (ADR 0459 §2, ADR 0627 §2)", () => { const rows: [string, string[], string, boolean][] = [ @@ -784,4 +784,47 @@ test("a first view follows its owner's events, asks again on a gap and by hand, // Another created is the state made again: a gap too. f.close(); assert.equal((await f.apply({ key: "a", revision: 1, created: "2026-11-09T11:00:00.000Z", value: 9 })).gap, true); + + // Design 47 *A part with no view says it waits*: the status says the view held, the one waited for and since when; + // past its bound the part's view-waits is raised once, and holding the view clears it. + const ends: (() => void)[] = []; + const bounds: number[] = []; + const wRaised: Status[] = []; + const wCleared: Status[] = []; + const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; + const w = new Follower({ + owner: "o", tool: "o.revision", state: "catalog", ask, now: () => new Date(created), said: keepStatus(st), + raiseWaits: (s) => void wRaised.push(s), clearWaits: (s) => void wCleared.push(s), + waitsBound: (ms) => (bounds.push(ms), new Promise((resolve) => ends.push(resolve))), + }); + await w.holds("trunk.11"); + await w.waitFor("trunk.12"); + await w.waitFor("trunk.12"); // the same view again: nothing new, no second bound + assert.deepEqual(bounds, [5 * 60 * 1000], "one five-minute bound"); + s = w.status(); + assert.equal(s.view, "trunk.11"); + assert.equal(s["waits-for"], "trunk.12"); + assert.equal(s["waiting-since"], created); + assert.equal(s["waits-raised"], false); + assert.equal(kept.get(statusKey("o", "catalog"))!["waits-for"], "trunk.12", "the read tool's state says it"); + ends.shift()!(); + await settle(); + assert.equal(wRaised.length, 1, "past its bound view-waits is raised"); + assert.equal(wRaised[0]["waits-for"], "trunk.12"); + const fd = viewWaitsFinding("gitea", w.status(), new Date(created)); + assert.equal(fd.kind, "view-waits"); + assert.equal(fd.id, "gitea.o.catalog"); + assert.match(fd.summary, /^gitea waits for o's view trunk\.12 since 2026-11-09T10:00:00Z and acts on nothing new until it holds it; it holds trunk\.11$/); + await w.holds("trunk.12"); + s = w.status(); + assert.equal(s.view, "trunk.12"); + assert.equal(s["waits-for"], undefined); + assert.equal(s["waits-raised"], undefined); + assert.equal(wCleared.length, 1, "holding the view clears it"); + await w.waitFor("trunk.13"); + await w.holds("trunk.13"); + ends.shift()!(); // the bound of a view now held raises nothing + await settle(); + assert.equal(wRaised.length, 1, "a view held before its bound raises nothing"); + assert.equal(wCleared.length, 1, "nothing raised, nothing cleared"); });