T-1, T-2: W1.11: the TypeScript side holds a seat as the Go side does
A TypeScript holder, the forge module first, must judge a verb's caller by the runtime's proof, say its seat's events and answer its first views' read and ask-again tools (design 47, The forge module's tools come from mesh-sdk's TypeScript side; G-F1 option A). The stdio session now hands each tool the caller and call id the runtime wrote in _meta (callers.callerOf, the Go rules for Callers and VerbCallers), binds the runtime for seatPublish over mesh/seat.publish and config, and views gains the Follower and its two status tools over the state first-views. @novox/mesh-sdk 0.1.19. Builds: design 47 G §196, §200, §336 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -115,6 +115,16 @@ runtime's (`node-tools/internal/launch`, mesh-tools).
|
||||
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
||||
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
||||
|
||||
### `runtime`, `callers` and `views` — holding a seat from TypeScript
|
||||
The TypeScript side of what the Go side's `stdio` and `views` give a seat's holder, with the same rules (design 47
|
||||
*The forge module's tools come from mesh-sdk's TypeScript side*): `runtime.seatPublish` says an event or submits an
|
||||
accept on a seat (`mesh/seat.publish`) and `runtime.config` reads a config value; `callers.callerOf(args)` is the
|
||||
caller the runtime proved in the call's `_meta`, never an argument, and `callers.refusal` judges it against a
|
||||
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`.
|
||||
|
||||
### `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
|
||||
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@novox/mesh-sdk",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"description": "The stable spine a Novox Mesh module's own code builds against.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -12,7 +12,10 @@
|
||||
"./events": "./dist/events/index.js",
|
||||
"./primitives": "./dist/primitives/index.js",
|
||||
"./stdio": "./dist/stdio/index.js",
|
||||
"./state": "./dist/state/index.js"
|
||||
"./state": "./dist/state/index.js",
|
||||
"./callers": "./dist/callers/index.js",
|
||||
"./runtime": "./dist/runtime/index.js",
|
||||
"./views": "./dist/views/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Who called a tool, as the runtime proved it, and who may call a verb (novox/hq issue 365, ADR 0459 §2, ADR 0627 §2;
|
||||
// design 51 *Who may call which verb*). The TypeScript side of the Go `stdio` package's caller.go and callers.go, with
|
||||
// the same rules, so a holder in either language judges a caller alike.
|
||||
//
|
||||
// The runtime names the caller in a `tools/call`'s `_meta`, which a caller cannot write: the bus user that published
|
||||
// the call, taken from the subject it arrived on. It is handed to the tool here, never read from the arguments: a
|
||||
// `caller` argument names nobody.
|
||||
|
||||
import { config } from "../runtime/index.js";
|
||||
|
||||
/** The caller of a call the runtime named no caller for, said as a word so a record shows it was not proven. */
|
||||
export const UNPROVEN = "unproven";
|
||||
|
||||
/** Where in a `tools/call`'s `_meta` the runtime names the caller (the Go side's `stdio.MetaCaller`). */
|
||||
export const META_CALLER = "mesh/caller";
|
||||
|
||||
/** Where in a `tools/call`'s `_meta` the runtime names the id of the call in flight (`buscalls.MetaCall`). */
|
||||
export const META_CALL = "mesh/call";
|
||||
|
||||
// A bus user's name: `<node>.<module>`, `person.<name>`, `controller`; anything else is no caller.
|
||||
const busUser = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
|
||||
// A call's id: one subject-safe token.
|
||||
const callID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
|
||||
interface InFlight {
|
||||
readonly caller: string;
|
||||
readonly call: string;
|
||||
}
|
||||
|
||||
// The caller and the call's id of every call while its tool runs, by the arguments object that call was handed.
|
||||
const calls = new WeakMap<object, InFlight>();
|
||||
|
||||
/** The caller a `tools/call`'s `_meta` names, or UNPROVEN. */
|
||||
export function callerIn(meta: unknown): string {
|
||||
const s = meta && typeof meta === "object" ? (meta as Record<string, unknown>)[META_CALLER] : undefined;
|
||||
return typeof s === "string" && busUser.test(s) ? s : UNPROVEN;
|
||||
}
|
||||
|
||||
/** The call's id a `tools/call`'s `_meta` names, or empty. */
|
||||
export function callIn(meta: unknown): string {
|
||||
const s = meta && typeof meta === "object" ? (meta as Record<string, unknown>)[META_CALL] : undefined;
|
||||
return typeof s === "string" && callID.test(s) ? s : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `run` with the caller and the call's id `meta` names held for `args` while it runs: the stdio session does this
|
||||
* for every `tools/call`.
|
||||
*/
|
||||
export async function runAs<T>(meta: unknown, args: object, run: () => Promise<T>): Promise<T> {
|
||||
calls.set(args, { caller: callerIn(meta), call: callIn(meta) });
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
calls.delete(args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Who called the tool that was handed `args`, as the runtime proved it, or UNPROVEN. Valid while the tool runs. */
|
||||
export function callerOf(args: object | undefined | null): string {
|
||||
return (args && calls.get(args)?.caller) || UNPROVEN;
|
||||
}
|
||||
|
||||
/** The id of the call that was handed `args`, as the runtime named it, or empty. Valid while the tool runs. */
|
||||
export function callIdOf(args: object | undefined | null): string {
|
||||
return (args && calls.get(args)?.call) || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's trusted list of who may call it (ADR 0459 §2): an entry is a module (on any machine), exactly
|
||||
* `<machine>.<module>`, or exactly `person.<name>`. A wildcard is no entry and an unproven caller never matches.
|
||||
*/
|
||||
export type Callers = readonly string[];
|
||||
|
||||
/** A callers file, `{"callers": [...]}`, and nothing else. */
|
||||
export function parseCallers(raw: string): Callers {
|
||||
let f: unknown;
|
||||
try {
|
||||
f = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`the callers setting is not a list of callers: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
if (!f || typeof f !== "object" || Array.isArray(f)) throw new Error("the callers setting is not a list of callers: not an object");
|
||||
const extra = Object.keys(f).filter((k) => k !== "callers");
|
||||
if (extra.length) throw new Error(`the callers setting is not a list of callers: unknown field "${extra[0]}"`);
|
||||
const list = (f as { callers?: unknown }).callers ?? [];
|
||||
if (!Array.isArray(list) || list.some((c) => typeof c !== "string")) {
|
||||
throw new Error("the callers setting is not a list of callers: callers is not a list of words");
|
||||
}
|
||||
return list as string[];
|
||||
}
|
||||
|
||||
/** Whether `caller`, as the runtime proved it, is one the list names. */
|
||||
export function allows(callers: Callers, caller: string): boolean {
|
||||
if (!caller || caller === UNPROVEN) return false;
|
||||
for (const entry of callers) {
|
||||
if (!entry || /[*> ]/.test(entry)) continue;
|
||||
if (entry === caller) return true;
|
||||
if (!entry.includes(".") && !caller.startsWith("person.")) {
|
||||
const at = caller.indexOf(".");
|
||||
if (at >= 0 && caller.slice(at + 1) === entry) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A holder's trusted list per verb (ADR 0627 §2): for each verb it serves, the callers that may call it. */
|
||||
export type VerbCallers = Readonly<Record<string, readonly string[]>>;
|
||||
|
||||
/** The entry of a VerbCallers list that lets every proved caller call the verb. */
|
||||
export const ANY_CALLER = "*";
|
||||
/** The entry of a VerbCallers list that lets every operator's session (`person.<name>`) call the verb. */
|
||||
export const ANY_PERSON = "operator";
|
||||
|
||||
/** A holder's config value of verb to callers; empty is `defaults`, and a verb the holder does not serve is refused. */
|
||||
export function parseVerbCallers(raw: string, verbs: readonly string[], defaults: VerbCallers): VerbCallers {
|
||||
if (raw.trim() === "") return defaults;
|
||||
let c: unknown;
|
||||
try {
|
||||
c = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`callers is not a JSON object of verb to callers: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
if (!c || typeof c !== "object" || Array.isArray(c)) throw new Error("callers is not a JSON object of verb to callers");
|
||||
for (const [verb, list] of Object.entries(c)) {
|
||||
if (!verbs.includes(verb)) throw new Error(`callers names the verb "${verb}", which the holder does not serve`);
|
||||
if (!Array.isArray(list) || list.some((e) => typeof e !== "string")) {
|
||||
throw new Error(`callers is not a JSON object of verb to callers: ${verb} is not a list of words`);
|
||||
}
|
||||
}
|
||||
return c as VerbCallers;
|
||||
}
|
||||
|
||||
/** Undefined when `caller`, as the runtime proved it, may call `verb`; else the refusal naming both. */
|
||||
export function refusal(c: VerbCallers, verb: string, caller: string): Error | undefined {
|
||||
if (!caller || caller === UNPROVEN) {
|
||||
return new Error(`${verb}: refused: the request carries no caller the tool runner proved`);
|
||||
}
|
||||
const person = caller.startsWith("person.");
|
||||
for (const entry of c[verb] ?? []) {
|
||||
if (entry === ANY_CALLER) return undefined;
|
||||
if (entry === ANY_PERSON) {
|
||||
if (person) return undefined;
|
||||
continue;
|
||||
}
|
||||
if (allows([entry], caller)) return undefined;
|
||||
}
|
||||
return new Error(`${verb}: refused for ${caller}: not on the holder's caller list for ${verb}`);
|
||||
}
|
||||
|
||||
/** The module of a proved module caller, which the runtime names `<machine>.<module>`. */
|
||||
export function moduleOf(proved: string): string {
|
||||
const at = proved.indexOf(".");
|
||||
return at >= 0 ? proved.slice(at + 1) : proved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The holder's list as mesh-config resolved its config value `key` for this machine: an object of verb to callers, or
|
||||
* that object written as a string; unset or empty is `defaults`. A value that cannot be read is an error, never the
|
||||
* defaults, so a broken list is said, not widened.
|
||||
*/
|
||||
export async function configuredVerbCallers(key: string, verbs: readonly string[], defaults: VerbCallers): Promise<VerbCallers> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await config(key);
|
||||
} catch (err) {
|
||||
throw new Error(`the config value ${key} cannot be read: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return parseConfiguredVerbCallers(raw, verbs, defaults);
|
||||
}
|
||||
|
||||
/** configuredVerbCallers' reading of a value as the runtime answered it. */
|
||||
export function parseConfiguredVerbCallers(raw: unknown, verbs: readonly string[], defaults: VerbCallers): VerbCallers {
|
||||
if (raw === undefined || raw === null) return defaults;
|
||||
if (typeof raw === "string") return parseVerbCallers(raw, verbs, defaults);
|
||||
return parseVerbCallers(JSON.stringify(raw), verbs, defaults);
|
||||
}
|
||||
@@ -13,3 +13,6 @@ export * as messaging from "./messaging/index.js";
|
||||
export * as events from "./events/index.js";
|
||||
export * as primitives from "./primitives/index.js";
|
||||
export * as state from "./state/index.js";
|
||||
export * as runtime from "./runtime/index.js";
|
||||
export * as callers from "./callers/index.js";
|
||||
export * as views from "./views/index.js";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// The runtime that launched this bundle, as one channel of requests (novox/hq ADR 0193, ADR 0198): what a module's code
|
||||
// asks of its node's tool runner beyond emitting, subscribing and its state, each named by the runtime's method
|
||||
// (`mesh/seat.publish`, `mesh/provision.get`). The stdio session binds it when the runtime launched the process; a
|
||||
// bundle never names a transport.
|
||||
|
||||
/** What the runtime is asked through: the stdio session provides it. */
|
||||
export type RuntimeAsk = (method: string, params: unknown) => Promise<unknown>;
|
||||
|
||||
let bound: RuntimeAsk | undefined;
|
||||
|
||||
/** Bind the channel. The stdio session calls this when the runtime launched the process; a test binds its own. */
|
||||
export function useRuntime(ask: RuntimeAsk | undefined): void {
|
||||
bound = ask;
|
||||
}
|
||||
|
||||
/** Ask the runtime one of its methods; refused with the reason when no runtime launched this bundle. */
|
||||
export async function askRuntime(method: string, params: unknown): Promise<unknown> {
|
||||
if (!bound) {
|
||||
throw new Error(`no runtime to ask ${method} of: it is reached through the runtime that launched this bundle (novox/hq ADR 0198)`);
|
||||
}
|
||||
return bound(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an accept, or say an event, on a seat (novox/hq ADR 0259 §3): `subject` whole, as the seat's contract names
|
||||
* it (`mesh.seat.<seat>.event.<event>`), de-duplicated by `id` (the same id twice is one message; empty is the
|
||||
* runtime's to make). The runtime does it as this module, only as far as its membership lists, and refuses the rest
|
||||
* with the reason: the Go side's `stdio.SeatPublish`.
|
||||
*/
|
||||
export async function seatPublish(subject: string, body: unknown, id = ""): Promise<void> {
|
||||
await askRuntime("mesh/seat.publish", { subject, body, id });
|
||||
}
|
||||
|
||||
/** This module's config value `key`, as mesh-config resolved it for this machine (the Go side's `stdio.Config`). */
|
||||
export async function config(key: string): Promise<unknown> {
|
||||
return askRuntime("mesh/provision.get", { kind: "config", key });
|
||||
}
|
||||
+9
-1
@@ -16,6 +16,8 @@ import type { ToolDefinition } from "../contracts/index.js";
|
||||
import { collectTools } from "../tools/index.js";
|
||||
import { useBroker, type Broker, type Envelope } from "../messaging/index.js";
|
||||
import { useStateChannel, type StateChange } from "../state/index.js";
|
||||
import { useRuntime } from "../runtime/index.js";
|
||||
import { runAs } from "../callers/index.js";
|
||||
|
||||
/** The protocol version this speaks; the runtime says the same. */
|
||||
export const PROTOCOL = "2025-03-26";
|
||||
@@ -123,6 +125,8 @@ function startSession(): Session {
|
||||
close: async () => {},
|
||||
};
|
||||
useBroker(() => viaRuntime);
|
||||
// Every other method of the runtime a module's code asks (a seat's publish, its config): the same channel.
|
||||
useRuntime(ask);
|
||||
|
||||
let name = "bundle";
|
||||
let byName = new Map<string, ToolDefinition>();
|
||||
@@ -158,7 +162,11 @@ function startSession(): Session {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await tool.run((request.params?.arguments as Record<string, unknown> | undefined) ?? {});
|
||||
// The caller the runtime proved, in `_meta`, is held for these arguments while the tool runs (callerOf), never
|
||||
// read from the arguments (novox/hq issue 365).
|
||||
const given = request.params?.arguments;
|
||||
const args = (given && typeof given === "object" && !Array.isArray(given) ? given : {}) as Record<string, unknown>;
|
||||
const result = await runAs(request.params?._meta, args, () => tool.run(args));
|
||||
say({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: JSON.stringify(result ?? null) }] } });
|
||||
} catch (err) {
|
||||
// The tool failed; said as a tool error, not a protocol one, because the call was well-formed.
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
// A reader's first view of an owner's state, and its read and ask-again tools (novox/hq ADR 0615, ADR 0621; design 25
|
||||
// § 12.2; design 47 *The forge module's tools come from mesh-sdk's TypeScript side*): the TypeScript side of the Go
|
||||
// `views` package's Follower and StatusTools, with the same rules, so a TypeScript part answers `<module>_first_views`
|
||||
// and `<module>_first_view_again` as the Go parts do and keeps no follower of its own.
|
||||
//
|
||||
// A follower asks the owner's first view at its start (every page), then keeps its picture from the owner's events
|
||||
// alone, asking once more only on a gap in revisions (said in its log), never on a timer. Started while the owner is
|
||||
// down it keeps asking, waiting between asks, and past its bound raises its condition; the answer clears it. It names
|
||||
// no seat and no owner: the part hands it how to ask (a seat's verb through the seat's TypeScript contract, or an
|
||||
// owner's tool) and the events it hears, each in the form below.
|
||||
|
||||
import type { ToolDefinition } from "../contracts/index.js";
|
||||
import type { State } from "../state/index.js";
|
||||
import { callerOf } from "../callers/index.js";
|
||||
|
||||
/** The version of every value this writes and the only one it reads. */
|
||||
export const VERSION = 1;
|
||||
|
||||
/** The state in which a reader keeps each first view's status, as the Go side's `views.StatusState`. */
|
||||
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;
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a first view: the state's values by key, from `after` on, at the revision of the state the owner said up
|
||||
* to before it read them; `next` is the key to ask after for the rest, empty when the answer is whole. A reader keeps
|
||||
* the first page's revision.
|
||||
*/
|
||||
export interface Answer {
|
||||
readonly v?: number;
|
||||
readonly owner?: string;
|
||||
readonly state?: string;
|
||||
readonly revision: number;
|
||||
/** When the owner's state was made on the bus (RFC 3339): another one is a state made again. */
|
||||
readonly created: string;
|
||||
readonly values: Readonly<Record<string, unknown>>;
|
||||
readonly next?: string;
|
||||
readonly at?: string;
|
||||
/** When the owner's bus wrote each key (RFC 3339); a key whose time the owner does not say is absent. */
|
||||
readonly written?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** One change of one key of the owner's state, at the revision it was written at. */
|
||||
export interface ViewEvent {
|
||||
readonly v?: number;
|
||||
readonly owner?: string;
|
||||
readonly state?: string;
|
||||
readonly key: string;
|
||||
readonly revision: number;
|
||||
readonly created: string;
|
||||
readonly deleted?: boolean;
|
||||
readonly value?: unknown;
|
||||
readonly at?: string;
|
||||
/** The value was too large to carry: the reader asks its first view once. */
|
||||
readonly gap?: boolean;
|
||||
}
|
||||
|
||||
/** What a reader's read tool says of one first view: the Go side's `views.Status`, field for field. */
|
||||
export interface Status {
|
||||
owner: string;
|
||||
tool: string;
|
||||
state: string;
|
||||
revision: number;
|
||||
created?: string;
|
||||
keys: number;
|
||||
asked?: string;
|
||||
answered?: string;
|
||||
asks: number;
|
||||
"by-hand": number;
|
||||
raised: boolean;
|
||||
heard?: number;
|
||||
"heard-created"?: string;
|
||||
behind?: string;
|
||||
why?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** How a part makes a follower: the owner and the tool it asks (for the status and the log), and the hooks. */
|
||||
export interface FollowerOptions {
|
||||
readonly owner: string;
|
||||
readonly tool: string;
|
||||
readonly state: string;
|
||||
/** Ask the owner for the page after `after` (empty: the first). */
|
||||
readonly ask: (after: string) => Promise<Answer>;
|
||||
/** 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>;
|
||||
/** 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. */
|
||||
readonly said?: (s: Status) => void | Promise<void>;
|
||||
readonly log?: (line: string) => void;
|
||||
readonly now?: () => Date;
|
||||
/** How long it asks before it raises, in ms; BOUND_MS when absent. */
|
||||
readonly bound?: number;
|
||||
/** The first wait between two asks that failed, in ms, doubling to a minute; five seconds when absent. */
|
||||
readonly wait?: number;
|
||||
/** Waits ms between two asks; a test hands one that does not sleep. */
|
||||
readonly pause?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const ms = (t: string | undefined): number => (t ? Date.parse(t) || 0 : 0);
|
||||
|
||||
/** A reader's picture of one state of an owner, kept from its first view and its events. */
|
||||
export class Follower {
|
||||
private readonly o: FollowerOptions;
|
||||
private values = new Map<string, unknown>();
|
||||
private st: Status;
|
||||
private asking = false;
|
||||
private catching = false;
|
||||
private during: ViewEvent[] = [];
|
||||
private heardRev = 0;
|
||||
private heardCreated = "";
|
||||
private answered = false;
|
||||
private closed = false;
|
||||
private markReady!: () => void;
|
||||
/** Resolves once a first view was answered. */
|
||||
readonly ready: Promise<void>;
|
||||
|
||||
constructor(o: FollowerOptions) {
|
||||
this.o = o;
|
||||
this.st = { owner: o.owner, tool: o.tool, state: o.state, revision: 0, keys: 0, asks: 0, "by-hand": 0, raised: false };
|
||||
this.ready = new Promise((resolve) => (this.markReady = resolve));
|
||||
}
|
||||
|
||||
get owner(): string {
|
||||
return this.o.owner;
|
||||
}
|
||||
get state(): string {
|
||||
return this.o.state;
|
||||
}
|
||||
get tool(): string {
|
||||
return this.o.tool;
|
||||
}
|
||||
|
||||
private now(): string {
|
||||
return (this.o.now ? this.o.now() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
private log(line: string): void {
|
||||
this.o.log?.(line);
|
||||
}
|
||||
|
||||
private pause(wait: number): Promise<void> {
|
||||
return this.o.pause ? this.o.pause(wait) : new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
|
||||
/** The status now, with the keys held, the highest revision heard and why the picture is behind it. */
|
||||
status(): Status {
|
||||
const s: Status = { ...this.st, keys: this.values.size };
|
||||
if (this.heardRev) s.heard = this.heardRev;
|
||||
if (this.heardCreated) s["heard-created"] = this.heardCreated;
|
||||
const behind = this.behind();
|
||||
if (behind) s.behind = behind;
|
||||
else delete s.behind;
|
||||
return s;
|
||||
}
|
||||
|
||||
private async say(): Promise<void> {
|
||||
if (this.o.said) {
|
||||
try {
|
||||
await this.o.said(this.status());
|
||||
} catch (err) {
|
||||
this.log(`views: the status of the first view ${this.o.tool} is not kept: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A copy of the picture. */
|
||||
picture(): Map<string, unknown> {
|
||||
return new Map(this.values);
|
||||
}
|
||||
|
||||
/** One key of the picture. */
|
||||
get(key: string): unknown {
|
||||
return this.values.get(key);
|
||||
}
|
||||
|
||||
/** Why the picture is behind an event heard, or "" when it holds every revision heard. */
|
||||
behind(): string {
|
||||
if (!this.heardCreated) return "";
|
||||
if (ms(this.heardCreated) > ms(this.st.created)) {
|
||||
return `the state was made again at ${this.heardCreated}, the picture holds the one made at ${this.st.created ?? "none"}`;
|
||||
}
|
||||
if (ms(this.heardCreated) === ms(this.st.created) && this.heardRev > this.st.revision) {
|
||||
return `revision ${this.heardRev} heard, the picture holds ${this.st.revision}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** Ask the first view once more by hand (the re-read tool, recorded by its caller as a hand act). */
|
||||
again(why: string): Promise<void> {
|
||||
return this.askUntil(`by hand: ${why}`, true);
|
||||
}
|
||||
|
||||
/** Ask the first view once more for a gap the reader saw: counted among its asks, never among those by hand. */
|
||||
gap(why: string): Promise<void> {
|
||||
return this.askUntil(`a gap: ${why}`, false);
|
||||
}
|
||||
|
||||
/** Stop asking: a loop waiting between two asks ends at its next turn. */
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
private async askUntil(why: string, byHand: boolean): Promise<void> {
|
||||
if (this.asking) return; // one ask at a time; the one running brings the picture to its newest
|
||||
this.asking = true;
|
||||
this.during = [];
|
||||
this.st.why = why;
|
||||
if (byHand) this.st["by-hand"]++;
|
||||
const bound = this.o.bound ?? BOUND_MS;
|
||||
let wait = this.o.wait ?? 5000;
|
||||
const first = ms(this.now());
|
||||
try {
|
||||
while (!this.closed) {
|
||||
let failed: unknown;
|
||||
try {
|
||||
await this.askOnce();
|
||||
} catch (err) {
|
||||
failed = err ?? new Error("the ask failed");
|
||||
}
|
||||
if (failed === undefined) {
|
||||
// An event heard that the answer does not hold yet leaves the picture behind: asked once more, by itself.
|
||||
this.asking = false;
|
||||
const behind = this.behind();
|
||||
if (behind && !this.catching) {
|
||||
this.catching = true;
|
||||
this.log(`views: ${this.o.owner}'s ${this.o.state} is behind an event heard (${behind}): its first view ${this.o.tool} is asked once more`);
|
||||
void this.catchUp();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.st.error = failed instanceof Error ? failed.message : String(failed);
|
||||
const raise = !this.st.raised && ms(this.now()) - first >= bound;
|
||||
if (raise) this.st.raised = true;
|
||||
this.log(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is not answered (${this.st.error}): asked again in ${wait} ms`);
|
||||
if (raise) await this.o.raise?.(this.status());
|
||||
await this.say();
|
||||
await this.pause(wait);
|
||||
wait = Math.min(wait * 2, 60_000);
|
||||
}
|
||||
} finally {
|
||||
this.asking = false;
|
||||
}
|
||||
}
|
||||
|
||||
// catchUp asks once more, each time after a pause (doubling to a minute, so an owner that answers from before its
|
||||
// last write is not asked in a hot loop), until the picture holds every revision heard. One at a time: while it runs,
|
||||
// an answered ask starts no other.
|
||||
private async catchUp(): Promise<void> {
|
||||
let wait = this.o.wait ?? 5000;
|
||||
try {
|
||||
while (!this.closed) {
|
||||
await this.pause(wait);
|
||||
const behind = this.behind();
|
||||
if (!behind || this.closed) return;
|
||||
this.log(`views: ${this.o.owner}'s ${this.o.state} is still behind (${behind}): its first view ${this.o.tool} is asked once more`);
|
||||
await this.askUntil(`behind: ${behind}`, false);
|
||||
wait = Math.min(wait * 2, 60_000);
|
||||
}
|
||||
} finally {
|
||||
this.catching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// askOnce asks every page and, once whole, takes it as the picture.
|
||||
private async askOnce(): Promise<void> {
|
||||
this.st.asked = this.now();
|
||||
this.st.asks++;
|
||||
await this.say();
|
||||
let firstPage: Answer | undefined;
|
||||
const values = new Map<string, unknown>();
|
||||
const written = new Map<string, string>();
|
||||
let after = "";
|
||||
for (;;) {
|
||||
const a = await this.o.ask(after);
|
||||
if (a.v !== undefined && a.v !== VERSION) throw new Error(`views: unknown value version ${a.v}: this reader reads ${VERSION}`);
|
||||
firstPage ??= a;
|
||||
for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v);
|
||||
for (const [k, t] of Object.entries(a.written ?? {})) written.set(k, t);
|
||||
if (!a.next || a.next === after) break;
|
||||
after = a.next;
|
||||
}
|
||||
const old = this.values;
|
||||
this.values = values;
|
||||
const wasRaised = this.st.raised;
|
||||
this.st.revision = firstPage!.revision;
|
||||
this.st.created = firstPage!.created;
|
||||
this.st.answered = this.now();
|
||||
this.st.raised = false;
|
||||
delete this.st.error;
|
||||
// The events heard while it asked, after the answer's revision and in order, are taken again.
|
||||
const during = [...this.during].sort((x, y) => x.revision - y.revision);
|
||||
this.during = [];
|
||||
for (const ev of during) {
|
||||
if (ms(ev.created) === ms(this.st.created) && ev.revision === this.st.revision + 1 && !ev.gap) {
|
||||
if (ev.deleted) this.values.delete(ev.key);
|
||||
else this.values.set(ev.key, ev.value);
|
||||
if (ev.at) written.set(ev.key, ev.at);
|
||||
this.st.revision = ev.revision;
|
||||
}
|
||||
}
|
||||
if (!this.answered) {
|
||||
this.answered = true;
|
||||
this.markReady();
|
||||
}
|
||||
if (wasRaised) await this.o.clear?.(this.status());
|
||||
await this.say();
|
||||
if (this.o.changed) {
|
||||
for (const [k, v] of this.values) {
|
||||
if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.changed(k, v, false, written.get(k) ?? "");
|
||||
}
|
||||
for (const k of old.keys()) if (!this.values.has(k)) await this.o.changed(k, undefined, true, "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take one event into the picture and say whether it was a gap: another `created` (the owner's state made again), a
|
||||
* revision past the one after the picture's, or a value too large to carry. A revision at or below the picture's is
|
||||
* in it already and is passed over; so is any event before the first view is answered (the answer holds it).
|
||||
*/
|
||||
async apply(ev: ViewEvent): Promise<{ gap: boolean; why: string }> {
|
||||
if (ev.v !== undefined && ev.v !== VERSION) throw new Error(`views: unknown value version ${ev.v}: this reader reads ${VERSION}`);
|
||||
if (!ev.key || !ev.revision) throw new Error("views: an event names no key or revision");
|
||||
if (ms(ev.created) > ms(this.heardCreated)) {
|
||||
this.heardCreated = ev.created;
|
||||
this.heardRev = ev.revision;
|
||||
} else if (ms(ev.created) === ms(this.heardCreated) && ev.revision > this.heardRev) {
|
||||
this.heardRev = ev.revision;
|
||||
}
|
||||
if (this.asking) this.during.push(ev);
|
||||
if (!this.answered) {
|
||||
this.log(`views: ${this.o.owner}'s ${this.o.state} at revision ${ev.revision} was heard before its first view was answered: passed over, the answer holds it or is asked again`);
|
||||
return { gap: false, why: "" };
|
||||
}
|
||||
let why = "";
|
||||
if (ms(ev.created) !== ms(this.st.created)) {
|
||||
why = `${this.o.owner}'s ${this.o.state} was made again (or not marked when the first view was answered): created ${ev.created}, held ${this.st.created ?? "none"}`;
|
||||
} else if (ev.revision <= this.st.revision) {
|
||||
this.log(`views: ${this.o.owner}'s ${this.o.state} at revision ${ev.revision} is in the picture already (it holds ${this.st.revision}): passed over`);
|
||||
return { gap: false, why: "" };
|
||||
} else if (ev.revision > this.st.revision + 1) {
|
||||
why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.st.revision}: ${ev.revision - this.st.revision - 1} changes missed`;
|
||||
} else if (ev.gap) {
|
||||
why = `${this.o.owner}'s ${this.o.state}.${ev.key} at revision ${ev.revision} was too large to carry`;
|
||||
}
|
||||
if (why) return { gap: true, why };
|
||||
if (ev.deleted) this.values.delete(ev.key);
|
||||
else this.values.set(ev.key, ev.value);
|
||||
this.st.revision = ev.revision;
|
||||
await this.say(); // the revision the picture holds follows the events, not only the asks
|
||||
await this.o.changed?.(ev.key, ev.value, !!ev.deleted, ev.at ?? "");
|
||||
return { gap: false, why: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader's handler of one event: taken into the picture, and a gap asks the first view once more, said in the log
|
||||
* and not waited for (the event is taken; the ask brings the picture to its newest).
|
||||
*/
|
||||
async hear(ev: ViewEvent): Promise<void> {
|
||||
const { gap, why } = await this.apply(ev);
|
||||
if (gap) {
|
||||
this.log(`views: a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`);
|
||||
void this.gap(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) => {
|
||||
await st.put(statusKey(s.owner, s.state), s);
|
||||
};
|
||||
}
|
||||
|
||||
/** A hand act a part records before it does it (ADR 0565 clause 11): what is done, why, and its cause and verb. */
|
||||
export interface HandAct {
|
||||
readonly what: string;
|
||||
readonly why: string;
|
||||
readonly cause: string;
|
||||
readonly verb: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader's read tool of its first views (`read`) and its re-read tool (`again`), as the Go side's StatusTools: read
|
||||
* lists what each follower last said, kept in `st` (STATUS_STATE); again records a hand act through `acts` (none
|
||||
* given: refused, nothing asked), then asks that follower's first view again, naming the caller the runtime proved.
|
||||
*/
|
||||
export function statusTools(
|
||||
reader: string,
|
||||
read: string,
|
||||
again: string,
|
||||
followers: readonly Follower[],
|
||||
st: State<Status>,
|
||||
acts: ((act: HandAct) => Promise<void>) | undefined,
|
||||
): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: read,
|
||||
description:
|
||||
`${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.",
|
||||
input: { type: "object", properties: {} },
|
||||
run: async () => {
|
||||
const out: Status[] = [];
|
||||
for (const k of await st.keys()) {
|
||||
const e = await st.get(k);
|
||||
if (e?.value && typeof e.value === "object") out.push(e.value);
|
||||
}
|
||||
return { "first-views": out };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: again,
|
||||
description:
|
||||
`Ask ${reader}'s first view of an owner's state again now, by hand (a hand act, its caller and why kept in the status ` +
|
||||
`${read} reads): for a picture a person doubts. The part asks once and follows the owner's events after the answer.`,
|
||||
input: {
|
||||
type: "object",
|
||||
required: ["owner", "state", "why"],
|
||||
properties: {
|
||||
owner: { type: "string", description: "the owner, as the read tool names it" },
|
||||
state: { type: "string", description: "the owner's state, as the read tool names it" },
|
||||
why: { type: "string", description: "why it is asked again, in plain words" },
|
||||
},
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = typeof args.owner === "string" ? args.owner : "";
|
||||
const state = typeof args.state === "string" ? args.state : "";
|
||||
const why = typeof args.why === "string" ? args.why.trim() : "";
|
||||
if (!owner || !state || !why) throw new Error("again names the owner, the state and why");
|
||||
const f = followers.find((x) => x.owner === owner && x.state === state);
|
||||
if (!f) throw new Error(`${reader} holds no first view of ${owner}'s ${state}`);
|
||||
const by = callerOf(args);
|
||||
if (!acts) throw new Error("the hand act was not recorded, so nothing was asked again: no way to record a hand act is given");
|
||||
try {
|
||||
await acts({ what: `asked ${reader}'s first view of ${owner}'s ${state} again by hand`, why, cause: "first-view-again", verb: again });
|
||||
} catch (err) {
|
||||
throw new Error(`the hand act was not recorded, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
void f.again(`${why} (${by})`);
|
||||
return { asked: { owner, state, why, by }, read };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Vendored
+11
-2
@@ -1,8 +1,17 @@
|
||||
// A tools bundle written against the protocol: the module's own tool, a seat's verb, and one that fails.
|
||||
// A tools bundle written against the protocol: the module's own tool, a seat's verb, one that fails, and one that says
|
||||
// who called it as the runtime proved it and refuses a caller its verb's list does not name.
|
||||
import { serveStdio } from "../../dist/stdio/index.js";
|
||||
import { callerOf, callIdOf, refusal } from "../../dist/callers/index.js";
|
||||
|
||||
const verbs = { "node-lamp.on": ["mesh-delivery-forge-bridge"] };
|
||||
|
||||
await serveStdio("lamp", [
|
||||
{ name: "brightness", description: "set it", input: { level: { type: "number", description: "0 to 10" } }, run: async (a) => ({ level: a.level }) },
|
||||
{ name: "node-lamp.on", description: "the seat's verb", input: {}, run: async () => ({ on: true }) },
|
||||
{ name: "node-lamp.on", description: "the seat's verb", input: {}, run: async (a) => {
|
||||
const no = refusal(verbs, "node-lamp.on", callerOf(a));
|
||||
if (no) throw no;
|
||||
return { on: true };
|
||||
} },
|
||||
{ name: "fail", description: "it throws", input: {}, run: async () => { throw new Error("the bulb is gone"); } },
|
||||
{ name: "who", description: "who called", input: {}, run: async (a) => ({ caller: callerOf(a), call: callIdOf(a) }) },
|
||||
]);
|
||||
|
||||
Vendored
+4
-1
@@ -1,8 +1,11 @@
|
||||
// A launched bundle whose tool emits (novox/hq ADR 0193): the emit travels to the runtime over stdio.
|
||||
// A launched bundle whose tool emits (novox/hq ADR 0193): the emit travels to the runtime over stdio, and so does an
|
||||
// event it says on a seat (ADR 0259 §3).
|
||||
import { registerModuleTools } from "../../dist/tools/index.js";
|
||||
import { emit } from "../../dist/events/index.js";
|
||||
import { seatPublish } from "../../dist/runtime/index.js";
|
||||
import { serveRegisteredOverStdio } from "../../dist/stdio/index.js";
|
||||
registerModuleTools("chime", () => [
|
||||
{ name: "ring", description: "rings and says so", input: {}, run: async () => { await emit("rang", { times: 1 }); return { rang: true }; } },
|
||||
{ name: "peal", description: "says it on its seat", input: {}, run: async () => { await seatPublish("mesh.seat.bell.event.pealed.anchor", { times: 2 }, "p-1"); return { pealed: true }; } },
|
||||
]);
|
||||
await serveRegisteredOverStdio();
|
||||
|
||||
@@ -662,3 +662,126 @@ test("the provider loop is named for what it is, and its old name stays an alias
|
||||
// The events and tools are what the controller and the operator read on the bus: their names stay.
|
||||
assert.equal(loop.FAILING_EVENT, "provisioner.failing");
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
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][] = [
|
||||
["a module on any machine", ["mesh-delivery-forge-bridge"], "ace.mesh-delivery-forge-bridge", true],
|
||||
["exactly a machine's module", ["ace.mesh-delivery-forge-bridge"], "g14.mesh-delivery-forge-bridge", false],
|
||||
["exactly a person", ["person.jochen"], "person.jochen", true],
|
||||
["a module entry is not a person", ["jochen"], "person.jochen", false],
|
||||
["a wildcard is no entry", ["*", ">"], "ace.anything", false],
|
||||
["an unproven caller never matches", ["unproven"], UNPROVEN, false],
|
||||
];
|
||||
for (const [what, list, caller, want] of rows) assert.equal(allows(list, caller), want, what);
|
||||
assert.deepEqual(parseCallers('{"callers":["a","person.b"]}'), ["a", "person.b"]);
|
||||
assert.throws(() => parseCallers('{"callers":["a"],"more":1}'), /unknown field "more"/);
|
||||
|
||||
const verbs = parseVerbCallers('{"pull":["*"],"merge-pull":["mesh-delivery-forge-bridge"],"comment":["operator"]}', ["pull", "merge-pull", "comment"], {});
|
||||
assert.equal(refusal(verbs, "pull", "ace.anyone"), undefined);
|
||||
assert.match(String(refusal(verbs, "pull", UNPROVEN)), /carries no caller the tool runner proved/);
|
||||
assert.equal(refusal(verbs, "merge-pull", "novox.mesh-delivery-forge-bridge"), undefined);
|
||||
assert.match(String(refusal(verbs, "merge-pull", "person.jochen")), /refused for person.jochen: not on the holder's caller list for merge-pull/);
|
||||
assert.equal(refusal(verbs, "comment", "person.jochen"), undefined);
|
||||
assert.ok(refusal(verbs, "comment", "ace.mesh-delivery-forge-bridge"));
|
||||
assert.ok(refusal(verbs, "status", "ace.anyone"), "a verb the list does not name refuses everyone");
|
||||
assert.throws(() => parseVerbCallers('{"nope":["*"]}', ["pull"], {}), /names the verb "nope", which the holder does not serve/);
|
||||
const defaults = { pull: ["*"] };
|
||||
assert.equal(parseVerbCallers(" ", ["pull"], defaults), defaults);
|
||||
assert.equal(parseConfiguredVerbCallers(null, ["pull"], defaults), defaults);
|
||||
assert.deepEqual(parseConfiguredVerbCallers('{"pull":["a"]}', ["pull"], defaults), { pull: ["a"] });
|
||||
assert.deepEqual(parseConfiguredVerbCallers({ pull: ["b"] }, ["pull"], defaults), { pull: ["b"] });
|
||||
assert.throws(() => parseConfiguredVerbCallers("{", ["pull"], defaults), /not a JSON object/);
|
||||
assert.equal(moduleOf("ace.mesh-delivery-forge-bridge"), "mesh-delivery-forge-bridge");
|
||||
});
|
||||
|
||||
test("a first view follows its owner's events, asks again on a gap and by hand, and its two tools read and steer it (ADR 0615)", async () => {
|
||||
const created = "2026-11-09T10:00:00.000Z";
|
||||
let held: Record<string, unknown> = { a: 1, b: 2, c: 3 };
|
||||
let revision = 4;
|
||||
let down = 2;
|
||||
const asked: string[] = [];
|
||||
const raised: Status[] = [];
|
||||
const cleared: Status[] = [];
|
||||
const pauses: number[] = [];
|
||||
let clock = Date.parse(created);
|
||||
// The owner answers two keys a page, and is down for its first two asks.
|
||||
const ask = async (after: string): Promise<Answer> => {
|
||||
asked.push(after);
|
||||
if (down > 0) {
|
||||
down--;
|
||||
throw new Error("no responders");
|
||||
}
|
||||
const keys = Object.keys(held).sort().filter((k) => k > after);
|
||||
const page = keys.slice(0, 2);
|
||||
return { v: 1, revision, created, values: Object.fromEntries(page.map((k) => [k, held[k]])), next: keys.length > 2 ? page[1] : "" };
|
||||
};
|
||||
const kept = new Map<string, Status>();
|
||||
const st = {
|
||||
get: async (k: string) => (kept.has(k) ? { key: k, value: kept.get(k)!, revision: 1 } : null),
|
||||
put: async (k: string, v: Status) => (kept.set(k, structuredClone(v)), 1),
|
||||
delete: async (k: string) => void kept.delete(k),
|
||||
keys: async () => [...kept.keys()].sort(),
|
||||
watch: async () => {},
|
||||
};
|
||||
const f = new Follower({
|
||||
owner: "mesh-catalog", tool: "mesh-catalog.revision", state: "catalog",
|
||||
ask, said: keepStatus(st), raise: (s) => void raised.push(s), clear: (s) => void cleared.push(s),
|
||||
now: () => new Date(clock), bound: 1000, wait: 10,
|
||||
pause: async (ms) => { pauses.push(ms); clock += 1000; },
|
||||
});
|
||||
// An event before the first view is answered is passed over: the answer holds it.
|
||||
assert.deepEqual(await f.apply({ key: "a", revision: 4, created, value: 1 }), { gap: false, why: "" });
|
||||
await f.start();
|
||||
assert.deepEqual(pauses, [10, 20], "a failed ask waits, doubling");
|
||||
assert.equal(raised.length, 1, "past its bound it raises its condition");
|
||||
assert.equal(cleared.length, 1, "the answer clears it");
|
||||
assert.deepEqual(Object.fromEntries(f.picture()), { a: 1, b: 2, c: 3 }, "every page is taken");
|
||||
assert.deepEqual(asked.slice(-2), ["", "b"]);
|
||||
let s = kept.get(statusKey("mesh-catalog", "catalog"))!;
|
||||
assert.equal(s.revision, 4);
|
||||
assert.equal(s.asks, 3);
|
||||
assert.equal(s.raised, false);
|
||||
assert.equal(s.keys, 3);
|
||||
|
||||
// The next revision is taken; one at or below the picture's is passed over.
|
||||
assert.deepEqual(await f.apply({ key: "d", revision: 5, created, value: 4 }), { gap: false, why: "" });
|
||||
assert.deepEqual(await f.apply({ key: "a", revision: 3, created, value: 0 }), { gap: false, why: "" });
|
||||
assert.equal(f.get("d"), 4);
|
||||
assert.equal(f.get("a"), 1);
|
||||
assert.equal(kept.get(statusKey("mesh-catalog", "catalog"))!.revision, 5, "the kept status follows the events");
|
||||
|
||||
// A revision past the next one is a gap: hearing it asks the first view once more.
|
||||
held = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 };
|
||||
revision = 7;
|
||||
const before = asked.length;
|
||||
await f.hear({ key: "f", revision: 7, created, value: 6 });
|
||||
await f.ready;
|
||||
for (let i = 0; i < 10 && f.status().revision !== 7; i++) await Promise.resolve();
|
||||
assert.ok(asked.length > before, "a gap asks again");
|
||||
assert.equal(f.status().revision, 7);
|
||||
assert.equal(f.status().why?.startsWith("a gap:"), true);
|
||||
assert.equal(f.get("e"), 5);
|
||||
|
||||
// The two tools: read lists the kept status; again records the hand act first and asks by hand with the caller.
|
||||
const acts: unknown[] = [];
|
||||
const [read, again] = statusTools("gitea", "gitea_first_views", "gitea_first_view_again", [f], st, async (a) => void acts.push(a));
|
||||
const listed = (await read.run({})) as { "first-views": Status[] };
|
||||
assert.equal(listed["first-views"].length, 1);
|
||||
assert.equal(listed["first-views"][0].tool, "mesh-catalog.revision");
|
||||
await assert.rejects(again.run({ owner: "mesh-catalog", state: "catalog" }), /names the owner, the state and why/);
|
||||
await assert.rejects(again.run({ owner: "x", state: "catalog", why: "w" }), /holds no first view of x's catalog/);
|
||||
const [, refused] = statusTools("gitea", "r", "g", [f], st, undefined);
|
||||
await assert.rejects(refused.run({ owner: "mesh-catalog", state: "catalog", why: "w" }), /nothing was asked again/);
|
||||
const byHandBefore = f.status()["by-hand"];
|
||||
await again.run({ owner: "mesh-catalog", state: "catalog", why: "the picture is doubted" });
|
||||
for (let i = 0; i < 10 && f.status()["by-hand"] === byHandBefore; i++) await Promise.resolve();
|
||||
assert.equal(acts.length, 1);
|
||||
assert.equal(f.status()["by-hand"], byHandBefore + 1);
|
||||
assert.match(f.status().why ?? "", /^by hand: the picture is doubted \(unproven\)$/);
|
||||
// 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);
|
||||
});
|
||||
|
||||
+24
-1
@@ -26,7 +26,7 @@ test("a bundle on stdio lists its tools and answers a call, a seat's verb among
|
||||
assert.equal((await ask(1, "initialize", { protocolVersion: "2025-03-26", capabilities: {} })).result.serverInfo.name, "lamp");
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
||||
const listed = (await ask(2, "tools/list")).result.tools;
|
||||
assert.deepEqual(listed.map((t: any) => t.name), ["brightness", "node-lamp.on", "fail"]);
|
||||
assert.deepEqual(listed.map((t: any) => t.name), ["brightness", "node-lamp.on", "fail", "who"]);
|
||||
assert.deepEqual(listed[0].inputSchema, { type: "object", properties: { level: { type: "number", description: "0 to 10" } } });
|
||||
const called = (await ask(3, "tools/call", { name: "brightness", arguments: { level: 7 } })).result;
|
||||
assert.deepEqual(JSON.parse(called.content[0].text), { level: 7 });
|
||||
@@ -34,6 +34,21 @@ test("a bundle on stdio lists its tools and answers a call, a seat's verb among
|
||||
assert.equal(failed.isError, true);
|
||||
assert.equal(failed.content[0].text, "the bulb is gone");
|
||||
assert.equal((await ask(5, "tools/call", { name: "nothing" })).error.code, -32602);
|
||||
// The caller the runtime proved in `_meta` is handed to the tool, never one in the arguments (issue 365).
|
||||
const proved = { "mesh/caller": "ace.mesh-delivery-forge-bridge", "mesh/call": "c-17" };
|
||||
const who = (await ask(6, "tools/call", { name: "who", arguments: { caller: "person.mallory" }, _meta: proved })).result;
|
||||
assert.deepEqual(JSON.parse(who.content[0].text), { caller: "ace.mesh-delivery-forge-bridge", call: "c-17" });
|
||||
const unnamed = (await ask(7, "tools/call", { name: "who", arguments: { caller: "person.mallory" } })).result;
|
||||
assert.deepEqual(JSON.parse(unnamed.content[0].text), { caller: "unproven", call: "" });
|
||||
const malformed = (await ask(8, "tools/call", { name: "who", arguments: {}, _meta: { "mesh/caller": "a b", "mesh/call": "x y" } })).result;
|
||||
assert.deepEqual(JSON.parse(malformed.content[0].text), { caller: "unproven", call: "" });
|
||||
// A seat's verb judges its callers by the proof: the named module is answered, another and an unproven one refused.
|
||||
assert.deepEqual(JSON.parse((await ask(9, "tools/call", { name: "node-lamp.on", arguments: {}, _meta: proved })).result.content[0].text), { on: true });
|
||||
const other = (await ask(10, "tools/call", { name: "node-lamp.on", arguments: {}, _meta: { "mesh/caller": "ace.mallory" } })).result;
|
||||
assert.equal(other.isError, true);
|
||||
assert.equal(other.content[0].text, "node-lamp.on: refused for ace.mallory: not on the holder's caller list for node-lamp.on");
|
||||
const none = (await ask(11, "tools/call", { name: "node-lamp.on", arguments: {} })).result;
|
||||
assert.match(none.content[0].text, /carries no caller the tool runner proved/);
|
||||
} finally {
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
@@ -100,6 +115,14 @@ test("a launched tool's emit is asked of the runtime, and the call answers once
|
||||
send({ jsonrpc: "2.0", id: asked.id, result: {} });
|
||||
const answered = await until((m) => m.id === 2);
|
||||
assert.deepEqual(JSON.parse(answered.result.content[0].text), { rang: true });
|
||||
// A seat's event is asked of the runtime as a seat's publish, its subject whole and its id kept (ADR 0259 §3).
|
||||
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "peal", arguments: {} } });
|
||||
const said = await until((m) => m.method === "mesh/seat.publish");
|
||||
assert.ok(said, "the seat's event was not asked of the runtime");
|
||||
assert.deepEqual(said.params, { subject: "mesh.seat.bell.event.pealed.anchor", body: { times: 2 }, id: "p-1" });
|
||||
assert.equal(seen.find((m) => m.id === 3), undefined, "the call answered before its seat's event was published");
|
||||
send({ jsonrpc: "2.0", id: said.id, result: {} });
|
||||
assert.deepEqual(JSON.parse((await until((m) => m.id === 3)).result.content[0].text), { pealed: true });
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user