The spec is a sequence diagram of the a2030.md story: I lend a governed mower; the neighbor's harness shows the model only `about`, runs its code as a tenant, and the governor stops the tipsy tenant at a 2-cell fence. The test runs the actors through a causal tracer and compares the trace with the diagram, then checks the yard and what the model saw. The tracer comes from agoric-sdk PR #12895, extended to name participants made mid-sequence (governor, tenant) and to draw thrown errors as replies. Red: src/ doesn't exist yet (ERR_MODULE_NOT_FOUND for src/fence.ts). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
237 lines
7.9 KiB
TypeScript
237 lines
7.9 KiB
TypeScript
/**
|
|
* @file Helpers for sequence-diagram actor simulations.
|
|
*
|
|
* From agoric-sdk `packages/portfolio-contract/tools/` (Apache-2.0), with
|
|
* two additions: `declareMade` names participants that actors create
|
|
* mid-sequence, and a thrown error is drawn as a reply (`throws '...'`).
|
|
*
|
|
* @see https://github.com/Agoric/agoric-sdk/pull/12895
|
|
*/
|
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
|
export type SequenceArrow = Readonly<{
|
|
from: string;
|
|
kind: '->>' | '-->>';
|
|
to: string;
|
|
label: string;
|
|
}>;
|
|
|
|
export const formatBigInt = (value: bigint) =>
|
|
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_');
|
|
|
|
export type SequenceArgsRenderer = (args: readonly unknown[]) => string;
|
|
export type SequenceResultRenderer = (
|
|
result: unknown,
|
|
args: readonly unknown[],
|
|
) => string;
|
|
|
|
export type SequenceMethodViz = Readonly<
|
|
{
|
|
args?: SequenceArgsRenderer;
|
|
label?: SequenceArgsRenderer;
|
|
} & (
|
|
| { result?: SequenceResultRenderer; resultOnly?: never }
|
|
| { result?: never; resultOnly: SequenceResultRenderer }
|
|
)
|
|
>;
|
|
|
|
export type CausalSequenceViz = Readonly<Record<string, SequenceMethodViz>>;
|
|
|
|
export type SequenceParticipant = object | readonly object[];
|
|
|
|
/**
|
|
* Make an E-like causal tracer for sequence-diagram actor simulations.
|
|
* Inspired by Causeway, the message-oriented distributed debugger.
|
|
*
|
|
* Method visualization is currently selected by method name alone. If actors
|
|
* need different visualization for methods with the same name, this can be
|
|
* extended to account for the target participant.
|
|
*
|
|
* @see https://shiftleft.com/mirrors/www.hpl.hp.com/techreports/2009/HPL-2009-78.pdf
|
|
*/
|
|
export const makeCausalSequenceTracer = (
|
|
methodViz: CausalSequenceViz = harden({}),
|
|
) => {
|
|
const arrows: SequenceArrow[] = [];
|
|
const names = new WeakMap<object, string>();
|
|
const proxies = new WeakMap<object, object>();
|
|
const made = new Map<object, Map<string, string>>();
|
|
const activeActor = new AsyncLocalStorage<object>();
|
|
let starter: object | undefined;
|
|
|
|
const participantName = (participant: object) => {
|
|
const name = names.get(participant);
|
|
if (!name) throw Error('sequence participant is not declared');
|
|
return name;
|
|
};
|
|
|
|
const formatValue = (value: unknown): string => {
|
|
if (typeof value === 'bigint') return `${formatBigInt(value)}n`;
|
|
if (typeof value === 'string') return `'${value}'`;
|
|
return JSON.stringify(value, (_key, item) =>
|
|
typeof item === 'bigint' ? `${item}n` : item,
|
|
);
|
|
};
|
|
|
|
const send = <Target extends object>(target: Target): Target => {
|
|
const extant = proxies.get(target);
|
|
if (extant) return extant as Target;
|
|
participantName(target);
|
|
|
|
const proxy = new Proxy(Object.create(null) as object, {
|
|
get: (_proxyTarget, property) => {
|
|
const method = Reflect.get(target, property);
|
|
if (typeof method !== 'function' || typeof property !== 'string') {
|
|
return method;
|
|
}
|
|
return (...args: unknown[]) => {
|
|
const activeSender = activeActor.getStore();
|
|
const sender = activeSender ?? starter;
|
|
if (!sender) throw Error('ES.start(participant) is required');
|
|
const isStart = activeSender === undefined;
|
|
if (isStart && sender !== target) {
|
|
throw Error('the started participant must receive the first send');
|
|
}
|
|
if (isStart) starter = undefined;
|
|
|
|
const viz = methodViz[property];
|
|
const abbreviated = viz?.args?.(args);
|
|
const shownArgs = abbreviated ?? args.map(formatValue).join(', ');
|
|
const label = viz?.label?.(args) ?? `${property}(${shownArgs})`;
|
|
const renderResultOnly = viz?.resultOnly;
|
|
if (!renderResultOnly) {
|
|
arrows.push(
|
|
harden({
|
|
from: participantName(sender),
|
|
kind: isStart ? '->>' : '-->>',
|
|
to: participantName(target),
|
|
label,
|
|
}),
|
|
);
|
|
}
|
|
|
|
let result: unknown;
|
|
try {
|
|
result = activeActor.run(target, () =>
|
|
Reflect.apply(method, target, args),
|
|
);
|
|
} catch (reason) {
|
|
const message =
|
|
reason instanceof Error ? reason.message : String(reason);
|
|
arrows.push(
|
|
harden({
|
|
from: participantName(target),
|
|
kind: '-->>',
|
|
to: participantName(sender),
|
|
label: `throws ${formatValue(message)}`,
|
|
}),
|
|
);
|
|
throw reason;
|
|
}
|
|
const madeName = made.get(target)?.get(property);
|
|
if (madeName && result !== null && typeof result === 'object') {
|
|
if (names.has(result)) throw Error('participant already declared');
|
|
names.set(result, madeName);
|
|
}
|
|
const renderResult = renderResultOnly ?? viz?.result;
|
|
if (renderResult) {
|
|
arrows.push(
|
|
harden({
|
|
from: participantName(target),
|
|
kind: '-->>',
|
|
to: participantName(sender),
|
|
label: renderResult(result, args),
|
|
}),
|
|
);
|
|
}
|
|
return result;
|
|
};
|
|
},
|
|
});
|
|
proxies.set(target, proxy);
|
|
return proxy as Target;
|
|
};
|
|
|
|
const ES = Object.assign(send, {
|
|
declareParticipants(
|
|
participants: Readonly<Record<string, SequenceParticipant>>,
|
|
) {
|
|
for (const [name, participantOrAliases] of Object.entries(participants)) {
|
|
const aliases = Array.isArray(participantOrAliases)
|
|
? participantOrAliases
|
|
: [participantOrAliases];
|
|
if (aliases.length === 0) {
|
|
throw Error(`sequence participant ${name} has no objects`);
|
|
}
|
|
for (const participant of aliases) {
|
|
if (names.has(participant)) {
|
|
throw Error('participant already declared');
|
|
}
|
|
names.set(participant, name);
|
|
}
|
|
}
|
|
},
|
|
/** name what `maker[method](...)` returns, when called through the tracer */
|
|
declareMade(
|
|
participants: Readonly<Record<string, readonly [object, string]>>,
|
|
) {
|
|
for (const [name, [maker, method]] of Object.entries(participants)) {
|
|
participantName(maker);
|
|
const byMethod = made.get(maker) ?? new Map<string, string>();
|
|
made.set(maker, byMethod);
|
|
byMethod.set(method, name);
|
|
}
|
|
},
|
|
snapshot: () => harden([...arrows]),
|
|
start(participant: object) {
|
|
participantName(participant);
|
|
if (activeActor.getStore() || starter) {
|
|
throw Error('a sequence is already active');
|
|
}
|
|
starter = participant;
|
|
},
|
|
});
|
|
return harden(ES);
|
|
};
|
|
|
|
export type CausalSequenceTracer = ReturnType<typeof makeCausalSequenceTracer>;
|
|
|
|
export const md = {
|
|
skipToH: (level: number, title: string) => (lines: readonly string[]) => {
|
|
const heading = `${'#'.repeat(level)} ${title}`;
|
|
const at = lines.findIndex(line => line === heading);
|
|
if (at < 0) throw Error(`${heading} not found`);
|
|
return lines.slice(at + 1);
|
|
},
|
|
|
|
*eachFence(language: string, lines: readonly string[]) {
|
|
const open = `\`\`\`${language}`;
|
|
for (let at = 0; at < lines.length; at += 1) {
|
|
if (lines[at] !== open) continue;
|
|
const end = lines.indexOf('```', at + 1);
|
|
if (end < 0) throw Error(`unterminated ${language} fence`);
|
|
yield lines.slice(at + 1, end);
|
|
at = end;
|
|
}
|
|
},
|
|
};
|
|
|
|
export const mmd = {
|
|
extractArrows(
|
|
lines: readonly string[],
|
|
nodePattern: RegExp = /\w+/,
|
|
): readonly SequenceArrow[] {
|
|
const arrowPattern = new RegExp(
|
|
`^\\s*(?<from>${nodePattern.source})\\s*(?<kind>--?>>)\\s*(?<to>${nodePattern.source})\\s*:\\s*(?<label>.*?)\\s*$`,
|
|
);
|
|
return harden(
|
|
lines.flatMap(line => {
|
|
const groups = line.match(arrowPattern)?.groups;
|
|
if (!groups) return [];
|
|
const { from, kind, to, label } = groups;
|
|
return [harden({ from, kind, to, label }) as SequenceArrow];
|
|
}),
|
|
);
|
|
},
|
|
};
|