diff --git a/good-fences/docs/lend-the-mower.md b/good-fences/docs/lend-the-mower.md new file mode 100644 index 0000000..d7ebe75 --- /dev/null +++ b/good-fences/docs/lend-the-mower.md @@ -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). diff --git a/good-fences/package.json b/good-fences/package.json new file mode 100644 index 0000000..4b31407 --- /dev/null +++ b/good-fences/package.json @@ -0,0 +1,11 @@ +{ + "name": "good-fences", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.ts" + }, + "dependencies": { + "ses": "^1.14.0" + } +} diff --git a/good-fences/pnpm-lock.yaml b/good-fences/pnpm-lock.yaml new file mode 100644 index 0000000..fb3bbd7 --- /dev/null +++ b/good-fences/pnpm-lock.yaml @@ -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 diff --git a/good-fences/test/lend-the-mower.test.ts b/good-fences/test/lend-the-mower.test.ts new file mode 100644 index 0000000..406afe5 --- /dev/null +++ b/good-fences/test/lend-the-mower.test.ts @@ -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>) => + `{ ${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)})`, + result: quoted, + }, + complete: { + label: args => + `complete([${(args[0] as Record[]).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 }, + }, + ], + ]); +}); diff --git a/good-fences/test/lockdown.ts b/good-fences/test/lockdown.ts new file mode 100644 index 0000000..7864f45 --- /dev/null +++ b/good-fences/test/lockdown.ts @@ -0,0 +1,4 @@ +/** @file Harden the realm before any test module runs: tenants share it. */ +import 'ses'; + +lockdown(); diff --git a/good-fences/tools/sequence-diagram-actor-sim.ts b/good-fences/tools/sequence-diagram-actor-sim.ts new file mode 100644 index 0000000..3b866bd --- /dev/null +++ b/good-fences/tools/sequence-diagram-actor-sim.ts @@ -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>; + +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(); + const proxies = new WeakMap(); + const made = new Map>(); + const activeActor = new AsyncLocalStorage(); + 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: 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>, + ) { + 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>, + ) { + for (const [name, [maker, method]] of Object.entries(participants)) { + participantName(maker); + const byMethod = made.get(maker) ?? new Map(); + 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; + +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*(?${nodePattern.source})\\s*(?--?>>)\\s*(?${nodePattern.source})\\s*:\\s*(?