T-2: W1.4 (TypeScript): a TypeScript part says the revision view it holds and waits for, and view-waits past five minutes

The forge module reads the catalog half from TypeScript (W12.7) and must wait and say so as the Go readers do (design 47,
A part with no view says it waits): views' Follower gains waitFor and holds, raiseWaits and clearWaits, the status fields
view, waits-for, waiting-since and waits-raised, WAITS_BOUND_MS (five minutes) and viewWaitsFinding, by the Go side's
rules (go/views follow.go, findings.go). Rows in the first-view test of test/sdk.test.ts.

Builds: design 47 G §196, §200

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 10:51:07 +01:00
parent 0d546d37f3
commit 2411fb4e12
3 changed files with 152 additions and 3 deletions
+2 -1
View File
@@ -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 (`<module>_first_views`, `<module>_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
+106 -1
View File
@@ -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<void>;
readonly clear?: (s: Status) => void | Promise<void>;
/** 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<void>;
readonly clearWaits?: (s: Status) => void | Promise<void>;
/** Told of each key the picture changes, after the change is held. */
readonly changed?: (key: string, value: unknown, deleted: boolean, at: string) => void | Promise<void>;
/** 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<void>;
/** 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<void>;
}
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<void> {
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<void> {
const bound = this.o.waitsBound ?? ((wait: number) => new Promise<void>((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<void> {
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<void> {
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<Status>): (s: Status) => Promise<void> {
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[] = [];
+44 -1
View File
@@ -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<void>((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");
});