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:
1 parent
b4ab820486
commit
9a3e7348c6
6 files changed
+486
No files matched your search
@@ -0,0 +1,63 @@
|
||||
# Lend the mower
|
||||
|
||||
The journey from [Good Fences Make Good Agents](../../../a2030.md): I lend
|
||||
my neighbor a governed mower; the neighbor's tenant, a little tipsy,
|
||||
pushes it until the fence stops it. The design decisions behind each actor are in that
|
||||
doc's Design IBIS (numbers in parentheses below).
|
||||
|
||||
Actors, by authority:
|
||||
|
||||
- **fence page**, **js playground**, **governed mower**, and **yard** run
|
||||
in my browser (3a, 8b, 6b).
|
||||
- **Mower factory** trades a webkey for a mower (3b-ii); each **mower DO**
|
||||
is given the yard and cuts the cells it enters (6a, 6d).
|
||||
- The **neighbor's harness** holds the things I send and shows the
|
||||
**model** only their `about` (1b, 2a); it runs the model's code as a
|
||||
**tenant**, endowed with the things (2c, 5a).
|
||||
|
||||
The yard is 8×8 here and the fence is 2 cells, to keep the diagram short
|
||||
(the story's 10 cells is 6c). The mower starts at (4, 4), facing north.
|
||||
|
||||
## Lend a governed mower to a tipsy tenant
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
%% ->> is my action at the fence page; -->> is a call or reply it causes.
|
||||
participant F as fence page
|
||||
participant MF as Mower factory
|
||||
participant P as js playground
|
||||
participant G as governed mower
|
||||
participant M as mower DO
|
||||
participant Y as yard
|
||||
participant N as neighbor's harness
|
||||
participant L as model
|
||||
participant TL as tenant loader
|
||||
participant T as tenant
|
||||
F->>F: lend({ webkey: 'mower-key', limit: 2, say })
|
||||
F-->>MF: mower('mower-key')
|
||||
MF-->>F: mower
|
||||
F-->>P: govern(mower, 2)
|
||||
P-->>F: governed
|
||||
F-->>N: chat({ role, content, things: { mower }, about: { mower } })
|
||||
N-->>L: complete([{ role, content, about: { mower } }])
|
||||
L-->>N: code
|
||||
N-->>TL: load(code, { mower })
|
||||
TL-->>N: tenant
|
||||
N-->>T: run()
|
||||
T-->>G: start()
|
||||
G-->>M: start()
|
||||
M-->>Y: cut(4, 4)
|
||||
T-->>G: push()
|
||||
G-->>M: push()
|
||||
M-->>Y: cut(4, 3)
|
||||
T-->>G: push()
|
||||
G-->>M: push()
|
||||
M-->>Y: cut(4, 2)
|
||||
T-->>G: push()
|
||||
G-->>T: throws 'fence: 2 cells from start'
|
||||
T-->>N: 'fence: 2 cells from start'
|
||||
N-->>F: 'fence: 2 cells from start'
|
||||
```
|
||||
|
||||
The model never sees the mower itself, only `about.mower`. The third
|
||||
`push()` never reaches the mower DO: the governor refuses it (7b).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "good-fences",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"ses": "^1.14.0"
|
||||
}
|
||||
}
|
||||
Generated
+41
@@ -0,0 +1,41 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
ses:
|
||||
specifier: ^1.14.0
|
||||
version: 1.15.0
|
||||
|
||||
packages:
|
||||
|
||||
'@endo/cache-map@1.1.0':
|
||||
resolution: {integrity: sha512-owFGshs/97PDw9oguZqU/px8Lv1d0KjAUtDUiPwKHNXRVUE/jyettEbRoTbNJR1OaI8biMn6bHr9kVJsOh6dXw==}
|
||||
|
||||
'@endo/env-options@1.1.11':
|
||||
resolution: {integrity: sha512-p9OnAPsdqoX4YJsE98e3NBVhIr2iW9gNZxHhAI2/Ul5TdRfoOViItzHzTqrgUVopw6XxA1u1uS6CykLMDUxarA==}
|
||||
|
||||
'@endo/immutable-arraybuffer@1.1.2':
|
||||
resolution: {integrity: sha512-u+NaYB2aqEugQ3u7w3c5QNkPogf8q/xGgsPaqdY6pUiGWtYiTiFspKFcha6+oeZhWXWQ23rf0KrUq0kfuzqYyQ==}
|
||||
|
||||
ses@1.15.0:
|
||||
resolution: {integrity: sha512-Y7RvgVXhkwQEqVActNn44QBxB2BCUbPfexAaMiUFjbJkDXDnxPisQC5jYQeWOZ3QBOfecnmLfPn0sQKLiYGBAQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@endo/cache-map@1.1.0': {}
|
||||
|
||||
'@endo/env-options@1.1.11': {}
|
||||
|
||||
'@endo/immutable-arraybuffer@1.1.2': {}
|
||||
|
||||
ses@1.15.0:
|
||||
dependencies:
|
||||
'@endo/cache-map': 1.1.0
|
||||
'@endo/env-options': 1.1.11
|
||||
'@endo/immutable-arraybuffer': 1.1.2
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @file The lend-the-mower journey, traced and compared with its sequence diagram. */
|
||||
import './lockdown.ts';
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { makeFencePage } from '../src/fence.ts';
|
||||
import { makePlayground } from '../src/governor.ts';
|
||||
import { aboutMower, makeMowerFactory } from '../src/mower.ts';
|
||||
import { makeNeighbor } from '../src/neighbor.ts';
|
||||
import { makeCompartmentLoader } from '../src/tenant-loader.ts';
|
||||
import { makeYard } from '../src/yard.ts';
|
||||
import {
|
||||
type CausalSequenceViz,
|
||||
makeCausalSequenceTracer,
|
||||
md,
|
||||
mmd,
|
||||
} from '../tools/sequence-diagram-actor-sim.ts';
|
||||
|
||||
const designDoc = new URL('../docs/lend-the-mower.md', import.meta.url);
|
||||
|
||||
/** a tenant that pushes until something stops it, or gives up after 20 */
|
||||
const tipsyTenant = `(env) => {
|
||||
env.mower.start();
|
||||
try {
|
||||
for (let i = 0; i < 20; i += 1) env.mower.push();
|
||||
} catch (err) {
|
||||
return err.message;
|
||||
}
|
||||
return 'mowed 20 cells';
|
||||
}`;
|
||||
|
||||
/** `{ a, b: { c } }`: keys, and the keys of object-valued members */
|
||||
const shape = (record: Readonly<Record<string, unknown>>) =>
|
||||
`{ ${Object.entries(record)
|
||||
.map(([key, value]) =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? `${key}: { ${Object.keys(value).join(', ')} }`
|
||||
: key,
|
||||
)
|
||||
.join(', ')} }`;
|
||||
|
||||
const quoted = (result: unknown) => `'${String(result)}'`;
|
||||
|
||||
const viz = harden({
|
||||
lend: {
|
||||
label: args => {
|
||||
const { webkey, limit } = args[0] as { webkey: string; limit: number };
|
||||
return `lend({ webkey: '${webkey}', limit: ${limit}, say })`;
|
||||
},
|
||||
},
|
||||
mower: { result: () => 'mower' },
|
||||
govern: { args: args => `mower, ${String(args[1])}`, result: () => 'governed' },
|
||||
chat: {
|
||||
label: args => `chat(${shape(args[0] as Record<string, unknown>)})`,
|
||||
result: quoted,
|
||||
},
|
||||
complete: {
|
||||
label: args =>
|
||||
`complete([${(args[0] as Record<string, unknown>[]).map(shape).join(', ')}])`,
|
||||
result: () => 'code',
|
||||
},
|
||||
load: {
|
||||
args: args => `code, { ${Object.keys(args[1] as object).join(', ')} }`,
|
||||
result: () => 'tenant',
|
||||
},
|
||||
run: { result: quoted },
|
||||
}) satisfies CausalSequenceViz;
|
||||
|
||||
test('a governed mower stops a tipsy tenant at the fence', async () => {
|
||||
const lines = (await readFile(designDoc, 'utf8')).split('\n');
|
||||
const section = md.skipToH(2, 'Lend a governed mower to a tipsy tenant')(lines);
|
||||
const diagram = md.eachFence('mermaid', section).next().value;
|
||||
if (!diagram) throw Error('lend-the-mower Mermaid block not found');
|
||||
const documented = mmd.extractArrows(diagram);
|
||||
|
||||
const ES = makeCausalSequenceTracer(viz);
|
||||
const yard = makeYard({ width: 8, height: 8 });
|
||||
const factory = makeMowerFactory(ES, {
|
||||
yard,
|
||||
size: { width: 8, height: 8 },
|
||||
mintKey: () => 'mower-key',
|
||||
});
|
||||
const key = factory.make({ x: 4, y: 4 });
|
||||
const mower = factory.mower(key);
|
||||
|
||||
const prompts: unknown[] = [];
|
||||
const model = harden({
|
||||
complete(messages: readonly unknown[]) {
|
||||
prompts.push(messages);
|
||||
return tipsyTenant;
|
||||
},
|
||||
});
|
||||
const playground = makePlayground(ES);
|
||||
const loader = makeCompartmentLoader(ES);
|
||||
const neighbor = makeNeighbor(ES, { model, loader });
|
||||
const fence = makeFencePage(ES, { factory, playground, neighbor });
|
||||
|
||||
ES.declareParticipants({
|
||||
F: fence,
|
||||
MF: factory,
|
||||
P: playground,
|
||||
M: mower,
|
||||
Y: yard,
|
||||
N: neighbor,
|
||||
L: model,
|
||||
TL: loader,
|
||||
});
|
||||
ES.declareMade({ G: [playground, 'govern'], T: [loader, 'load'] });
|
||||
|
||||
ES.start(fence);
|
||||
const outcome = ES(fence).lend({
|
||||
webkey: key,
|
||||
limit: 2,
|
||||
say: 'you can use this mower for the weekend',
|
||||
});
|
||||
|
||||
assert.deepEqual(ES.snapshot(), documented);
|
||||
assert.equal(outcome, 'fence: 2 cells from start');
|
||||
assert.deepEqual(yard.cutCells(), ['4,4', '4,3', '4,2']);
|
||||
assert.deepEqual(prompts, [
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: 'you can use this mower for the weekend',
|
||||
about: { mower: aboutMower },
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @file Harden the realm before any test module runs: tenants share it. */
|
||||
import 'ses';
|
||||
|
||||
lockdown();
|
||||
@@ -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];
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user