test(good-fences): lend-the-mower journey (red)

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>
This commit is contained in:
dckcandClaude Opus 5.5 committed 2026-10-08 22:17:48 -05:00
1 parent b4ab820486
commit 9a3e7348c6
6 files changed
+486

No files matched your search

@@ -0,0 +1,236 @@
/**
* @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];
}),
);
},
};