From 7dd3ffbc5b6c90cc794d0ca48e1bdb87d65d0de8 Mon Sep 17 00:00:00 2001 From: Kinflou <149606337+Kinflou@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:52:01 +0800 Subject: [PATCH] feat(sim): behaviours, session model, engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 1c (docs: design/playground-simulation.md) — the sim is now driveable end to end from a script; no UI yet. - behavior.ts — the six canned server behaviours as specs (label, appliesTo, defaultConfig seeded from the function's return / error types, make): Reply with value, Echo params, Increment field, Delay then reply, Raise error, Drop. `defaultKindFor` picks Reply, or Drop for a one-way function. - model.ts — Session { shape, instances, connection }. addInstance (auto-named `-N`, server instances get a seeded behaviour map), removeInstance (drops the connection if it referenced the instance), setConnection (same protocol + opposite roles, replaces any existing), setBehavior, and rebuild — re-point at a fresh shape, keeping a surviving instance and the configs of functions that remain. - engine.ts — connect(session, connection): a GenericDispatch server bound to the instance's behaviours and a GenericClient, over a tapped duplex(), handshake run. `setBehavior` swaps a live entry; takes effect on the next call. - engine.test.ts — the 1c acceptance: Reply returns the value; Echo returns params and a live swap takes effect next call; Increment bumps once per call; Raise comes back as a mapped SimRemoteError; Drop leaves the call pending; and the model ops (naming, removal, rebuild). 12/12 sim tests pass. No app-bundle change. --- app/src/sim/behavior.ts | 150 ++++++++++++++++++++++++++++++++++++ app/src/sim/engine.test.ts | 139 +++++++++++++++++++++++++++++++++ app/src/sim/engine.ts | 75 ++++++++++++++++++ app/src/sim/model.ts | 154 +++++++++++++++++++++++++++++++++++++ app/src/sim/shape.ts | 61 +++++++++++++++ 5 files changed, 579 insertions(+) create mode 100644 app/src/sim/behavior.ts create mode 100644 app/src/sim/engine.test.ts create mode 100644 app/src/sim/engine.ts create mode 100644 app/src/sim/model.ts diff --git a/app/src/sim/behavior.ts b/app/src/sim/behavior.ts new file mode 100644 index 0000000..f6de136 --- /dev/null +++ b/app/src/sim/behavior.ts @@ -0,0 +1,150 @@ +/// The canned server behaviours a simulated instance can run for one function. +/// Each is a spec — a label, when it applies, a default config, and a factory +/// that closes over the config to produce a runnable `Behavior`. + +import type { Behavior, SimOutcome } from "./generic.ts"; +import type { FnShape, SchemaShape } from "./shape.ts"; +import { zeroValue } from "./shape.ts"; + +export type BehaviorKind = "reply" | "echo" | "increment" | "delay" | "raise" | "drop"; + +export interface BehaviorSpec { + kind: BehaviorKind; + label: string; + /** Whether this behaviour makes sense for `fn` (e.g. `raise` needs throws). */ + appliesTo(fn: FnShape): boolean; + /** A starting config for `fn`, seeded from its return / error types. */ + defaultConfig(fn: FnShape, schema: SchemaShape): Record; + /** Build the runnable behaviour. */ + make(config: Record, fn: FnShape, schema: SchemaShape): Behavior; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** `ok` for a normal function, `none` for a one-way one. */ +function okOrNone(fn: FnShape, value: unknown): SimOutcome { + return fn.oneway ? { kind: "none" } : { kind: "ok", value }; +} + +function getAt(obj: unknown, path: string): unknown { + return path.split(".").reduce((o, k) => (o == null ? undefined : (o as Record)[k]), obj); +} +function setAt(obj: Record, path: string, value: unknown): void { + const keys = path.split("."); + const last = keys.pop()!; + let cur = obj; + for (const k of keys) cur = (cur[k] ??= {}) as Record; + cur[last] = value; +} +/** The first `prim` numeric field path in a struct return, for `increment`. */ +function firstNumericPath(fn: FnShape, schema: SchemaShape): string | undefined { + const ret = fn.returns; + if (ret?.kind !== "ref") return undefined; + const def = schema.types.find((t) => t.name === ret.name); + if (def?.kind !== "struct") return undefined; + const num = def.fields.find( + (f) => f.ty.kind === "prim" && /^[us](8|16|32|64|128)$|^f(32|64)$|^float$/.test(f.ty.name), + ); + return num?.name; +} + +export const BEHAVIORS: Record = { + reply: { + kind: "reply", + label: "Reply with value", + appliesTo: () => true, + defaultConfig: (fn, schema) => ({ + value: fn.returns ? zeroValue(fn.returns, schema.types) : null, + }), + make: (config, fn) => ({ + run: () => okOrNone(fn, config.value ?? null), + }), + }, + + echo: { + kind: "echo", + label: "Echo params", + appliesTo: () => true, + defaultConfig: () => ({}), + make: (_config, fn) => ({ + run: (ctx) => okOrNone(fn, ctx.params), + }), + }, + + increment: { + kind: "increment", + label: "Increment field", + appliesTo: (fn) => !fn.oneway && fn.returns?.kind === "ref", + defaultConfig: (fn, schema) => ({ + base: fn.returns ? zeroValue(fn.returns, schema.types) : {}, + path: firstNumericPath(fn, schema) ?? "", + }), + make: (config, fn) => { + const path = String(config.path ?? ""); + let current: Record | null = null; + return { + run: () => { + current ??= structuredClone(config.base ?? {}) as Record; + if (path) { + const n = getAt(current, path); + setAt(current, path, (typeof n === "number" ? n : 0) + 1); + } + return okOrNone(fn, structuredClone(current)); + }, + }; + }, + }, + + delay: { + kind: "delay", + label: "Delay then reply", + appliesTo: () => true, + defaultConfig: (fn, schema) => ({ + ms: 400, + value: fn.returns ? zeroValue(fn.returns, schema.types) : null, + }), + make: (config, fn) => ({ + run: async () => { + await sleep(Number(config.ms) || 0); + return okOrNone(fn, config.value ?? null); + }, + }), + }, + + raise: { + kind: "raise", + label: "Raise error", + appliesTo: (fn) => fn.throws.length > 0, + defaultConfig: (fn, schema) => { + const first = fn.throws[0]; + const err = schema.errors.find((e) => e.ordinal === first?.ordinal); + const data: Record = {}; + for (const f of err?.fields ?? []) data[f.name] = zeroValue(f.ty, schema.types); + return { ordinal: first?.ordinal ?? 0, data }; + }, + make: (config) => ({ + run: () => ({ + kind: "err", + ordinal: Number(config.ordinal) || 0, + data: config.data ?? null, + }), + }), + }, + + drop: { + kind: "drop", + label: "Drop (never reply)", + appliesTo: () => true, + defaultConfig: () => ({}), + // Never settles — the client's `call` stays pending, like a hung peer. + // The promise is released when the connection is closed and discarded. + make: () => ({ run: () => new Promise(() => {}) }), + }, +}; + +/** The behaviour a freshly-added server function starts on. */ +export function defaultKindFor(fn: FnShape): BehaviorKind { + return fn.oneway ? "drop" : "reply"; +} + +export const BEHAVIOR_KINDS = Object.keys(BEHAVIORS) as BehaviorKind[]; diff --git a/app/src/sim/engine.test.ts b/app/src/sim/engine.test.ts new file mode 100644 index 0000000..7896555 --- /dev/null +++ b/app/src/sim/engine.test.ts @@ -0,0 +1,139 @@ +/// Milestone 1c acceptance, scripted end to end: build a session, add a server +/// and a client, connect, and exercise the behaviours — Reply, Echo, Increment, +/// Raise error, Drop — plus the model ops (naming, removal, rebuild). + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import initWasm, { describe_project } from "../wasm/comline_playground_wasm.js"; +import type { ProjectShape } from "./shape.ts"; +import { addInstance, emptySession, rebuild, removeInstance, setBehavior, setConnection } from "./model.ts"; +import { connect } from "./engine.ts"; +import { SimRemoteError } from "./generic.ts"; + +await initWasm( + readFileSync(fileURLToPath(new URL("../wasm/comline_playground_wasm_bg.wasm", import.meta.url))), +); + +const CHAT = `struct Message { + body: string + seq: u64 +} + +error Rejected { + message = "rejected" + reason: string +} + +protocol Chat { + function send(text: string) -> Message ! Rejected; + function note(text: string); +} +`; + +const shape = () => describe_project([{ path: "chat.ids", source: CHAT }]) as ProjectShape; + +function wired() { + const session = emptySession(shape()); + const server = addInstance(session, { schemaNs: "chat", protocol: "Chat", role: "server" }); + const client = addInstance(session, { schemaNs: "chat", protocol: "Chat", role: "client" }); + setConnection(session, client.id, server.id); + return { session, server, client }; +} + +test("model — instance naming, seeded behaviours, removal drops the connection", () => { + const session = emptySession(shape()); + const a = addInstance(session, { schemaNs: "chat", protocol: "Chat", role: "server" }); + const b = addInstance(session, { schemaNs: "chat", protocol: "Chat", role: "server" }); + assert.deepEqual([a.name, b.name], ["chat-1", "chat-2"]); + assert.deepEqual(Object.keys(a.behaviors).sort(), ["note", "send"]); + assert.equal(a.behaviors.send.kind, "reply"); + assert.equal(a.behaviors.note.kind, "drop"); // one-way default + + const client = addInstance(session, { schemaNs: "chat", protocol: "Chat", role: "client" }); + assert.deepEqual(client.behaviors, {}); + setConnection(session, client.id, a.id); + removeInstance(session, a.id); + assert.equal(session.connection, null); +}); + +test("Reply with value returns the configured value", async () => { + const { session, server } = wired(); + setBehavior(session, server.id, "send", "reply", { value: { body: "HI", seq: 1 } }); + const live = await connect(session, session.connection!); + + assert.deepEqual(await live.call("send", { text: "x" }), { body: "HI", seq: 1 }); + assert.equal(live.tap.frames.filter((f) => f.kind !== "handshake").length, 2); + live.close(); +}); + +test("Echo returns the params; a live behaviour swap takes effect on the next call", async () => { + const { session } = wired(); + const live = await connect(session, session.connection!); + + live.setBehavior("send", { kind: "echo", config: {} }); + assert.deepEqual(await live.call("send", { text: "pong" }), { text: "pong" }); + + live.setBehavior("send", { kind: "reply", config: { value: { body: "z", seq: 9 } } }); + assert.deepEqual(await live.call("send", { text: "x" }), { body: "z", seq: 9 }); + live.close(); +}); + +test("Increment field bumps once per call", async () => { + const { session, server } = wired(); + setBehavior(session, server.id, "send", "increment", { + base: { body: "b", seq: 0 }, + path: "seq", + }); + const live = await connect(session, session.connection!); + + assert.equal((await live.call("send", { text: "x" }) as { seq: number }).seq, 1); + assert.equal((await live.call("send", { text: "x" }) as { seq: number }).seq, 2); + assert.equal((await live.call("send", { text: "x" }) as { seq: number }).seq, 3); + live.close(); +}); + +test("Raise error comes back as SimRemoteError mapped to the ordinal's name", async () => { + const { session, server } = wired(); + setBehavior(session, server.id, "send", "raise", { ordinal: 0, data: { reason: "denied" } }); + const live = await connect(session, session.connection!); + + await assert.rejects( + () => live.call("send", { text: "x" }), + (e: unknown) => { + assert.ok(e instanceof SimRemoteError); + assert.equal(e.ordinal, 0); + assert.equal(e.errorName, "Rejected"); + assert.deepEqual(e.data, { reason: "denied" }); + return true; + }, + ); + live.close(); +}); + +test("Drop leaves the call pending", async () => { + const { session } = wired(); + const live = await connect(session, session.connection!); + live.setBehavior("send", { kind: "drop", config: {} }); + + const race = await Promise.race([ + live.call("send", { text: "x" }).then(() => "settled"), + new Promise((r) => setTimeout(() => r("pending"), 60)), + ]); + assert.equal(race, "pending"); + assert.equal(live.tap.frames.filter((f) => f.kind !== "handshake" && f.from === "server").length, 0); + live.close(); +}); + +test("rebuild keeps a surviving instance's behaviour config", () => { + const { session, server } = wired(); + setBehavior(session, server.id, "send", "raise", { ordinal: 0, data: { reason: "keep me" } }); + rebuild(session, shape()); // same schema, recompiled + + const kept = session.instances.find((i) => i.id === server.id)!; + assert.equal(kept.behaviors.send.kind, "raise"); + assert.deepEqual(kept.behaviors.send.config, { ordinal: 0, data: { reason: "keep me" } }); + assert.ok(session.connection, "connection survives a same-shape rebuild"); +}); diff --git a/app/src/sim/engine.ts b/app/src/sim/engine.ts new file mode 100644 index 0000000..22997fe --- /dev/null +++ b/app/src/sim/engine.ts @@ -0,0 +1,75 @@ +/// Turn a `Connection` into a running wire: a `GenericDispatch` server bound to +/// the client instance's behaviours, a `GenericClient` on the other end, both +/// over a tapped `duplex()`. Behaviours can be swapped without reconnecting. + +import { BEHAVIORS } from "./behavior.ts"; +import { GenericClient, GenericDispatch, type Behavior, type BehaviorMap } from "./generic.ts"; +import { instance, type Connection, type Session } from "./model.ts"; +import { + DatagramFraming, + Handshake, + JsonCodec, + JsonRpcFraming, + Server, + Client, + type Framing, +} from "./runtime/index.ts"; +import { findProtocol } from "./shape.ts"; +import { wire, type Tap } from "./transport.ts"; + +export interface LiveConnection { + tap: Tap; + /** Invoke a function from the client side. */ + call(fnName: string, params: unknown): Promise; + /** Swap a server behaviour; takes effect on the next call. */ + setBehavior(fnName: string, setting: { kind: keyof typeof BEHAVIORS; config: Record }): void; + /** Drop both ends and end the serve loop. */ + close(): void; +} + +const framingFor = (name: "datagram" | "jsonrpc"): (() => Framing) => + name === "jsonrpc" ? () => new JsonRpcFraming() : () => new DatagramFraming(); + +/** Bind a `Connection` to real transports and run its handshake. */ +export async function connect(session: Session, conn: Connection): Promise { + const client = instance(session, conn.clientId); + const server = instance(session, conn.serverId); + if (!client || !server) throw new Error("connect: unknown instance"); + + const found = findProtocol(session.shape, server.schemaNs, server.protocol); + if (!found) throw new Error(`connect: ${server.schemaNs}::${server.protocol} is not compiled`); + const { schema, protocol } = found; + + const codec = new JsonCodec(); + const makeFraming = framingFor(protocol.framing); + const handshake = () => + new Handshake({ irHash: BigInt(schema.ir_hash), wireFormat: codec.name, framing: makeFraming().name }); + + // A mutable map so `setBehavior` can swap an entry live. + const behaviors: BehaviorMap = {}; + const build = (fnName: string): Behavior => { + const fn = protocol.functions.find((f) => f.name === fnName)!; + const setting = server.behaviors[fnName] ?? { kind: "reply" as const, config: {} }; + return BEHAVIORS[setting.kind].make(setting.config, fn, schema); + }; + for (const fn of protocol.functions) behaviors[fn.name] = build(fn.name); + + const w = wire(client.name, server.name); + const rpcServer = new Server(new GenericDispatch(protocol, behaviors), codec, makeFraming()); + void rpcServer.serveHandshaked(w.b, handshake()); + const rpcClient = new GenericClient( + await Client.connect(w.a, codec, handshake(), makeFraming()), + protocol, + ); + + return { + tap: w.tap, + call: (fnName, params) => rpcClient.call(fnName, params), + setBehavior: (fnName, setting) => { + const fn = protocol.functions.find((f) => f.name === fnName); + if (!fn) throw new Error(`setBehavior: no function ${fnName}`); + behaviors[fnName] = BEHAVIORS[setting.kind].make(setting.config, fn, schema); + }, + close: () => w.close(), + }; +} diff --git a/app/src/sim/model.ts b/app/src/sim/model.ts new file mode 100644 index 0000000..544b438 --- /dev/null +++ b/app/src/sim/model.ts @@ -0,0 +1,154 @@ +/// The simulation's state: the compiled `ProjectShape`, the instances placed on +/// the canvas, and (Phase 1) the single connection between them. Plain data + +/// pure-ish operations; the engine turns a `Connection` into a live wire and +/// the UI renders all of it. + +import { BEHAVIORS, defaultKindFor, type BehaviorKind } from "./behavior.ts"; +import { findProtocol, type ProjectShape } from "./shape.ts"; + +export type Role = "server" | "client"; + +export interface BehaviorSetting { + kind: BehaviorKind; + config: Record; +} + +export interface Instance { + id: string; + name: string; + role: Role; + /** The schema namespace + protocol this instance speaks. */ + schemaNs: string; + protocol: string; + /** Server only: one behaviour per function name. Empty for a client. */ + behaviors: Record; + /** Canvas position (the UI owns it; the engine ignores it). */ + x: number; + y: number; +} + +export interface Connection { + clientId: string; + serverId: string; +} + +export interface Session { + shape: ProjectShape; + instances: Instance[]; + connection: Connection | null; +} + +let counter = 0; +const nextId = () => `i${++counter}`; + +export function emptySession(shape: ProjectShape): Session { + return { shape, instances: [], connection: null }; +} + +/** Seed a server's per-function behaviour map from the protocol shape. */ +function seedBehaviors( + session: Session, + schemaNs: string, + protocol: string, +): Record { + const found = findProtocol(session.shape, schemaNs, protocol); + const out: Record = {}; + for (const fn of found?.protocol.functions ?? []) { + const kind = defaultKindFor(fn); + out[fn.name] = { kind, config: BEHAVIORS[kind].defaultConfig(fn, found!.schema) }; + } + return out; +} + +export function addInstance( + session: Session, + spec: { schemaNs: string; protocol: string; role: Role; x?: number; y?: number }, +): Instance { + const n = session.instances.filter((i) => i.protocol === spec.protocol).length + 1; + const inst: Instance = { + id: nextId(), + name: `${spec.protocol.toLowerCase()}-${n}`, + role: spec.role, + schemaNs: spec.schemaNs, + protocol: spec.protocol, + behaviors: spec.role === "server" ? seedBehaviors(session, spec.schemaNs, spec.protocol) : {}, + x: spec.x ?? 0, + y: spec.y ?? 0, + }; + session.instances.push(inst); + return inst; +} + +export function removeInstance(session: Session, id: string): void { + session.instances = session.instances.filter((i) => i.id !== id); + if (session.connection && (session.connection.clientId === id || session.connection.serverId === id)) { + session.connection = null; + } +} + +export function instance(session: Session, id: string): Instance | undefined { + return session.instances.find((i) => i.id === id); +} + +/** Connect a client instance to a server instance of the same protocol. + * Replaces any existing connection. Throws on a role / protocol mismatch. */ +export function setConnection(session: Session, clientId: string, serverId: string): Connection { + const c = instance(session, clientId); + const s = instance(session, serverId); + if (!c || !s) throw new Error("connect: unknown instance"); + if (c.role !== "client" || s.role !== "server") throw new Error("connect: need a client and a server"); + if (c.schemaNs !== s.schemaNs || c.protocol !== s.protocol) { + throw new Error(`connect: ${c.protocol} ≠ ${s.protocol}`); + } + session.connection = { clientId, serverId }; + return session.connection; +} + +export function setBehavior( + session: Session, + instanceId: string, + fnName: string, + kind: BehaviorKind, + config?: Record, +): void { + const inst = instance(session, instanceId); + if (!inst || inst.role !== "server") throw new Error("setBehavior: not a server instance"); + const found = findProtocol(session.shape, inst.schemaNs, inst.protocol); + const fn = found?.protocol.functions.find((f) => f.name === fnName); + if (!fn) throw new Error(`setBehavior: no function ${fnName}`); + inst.behaviors[fnName] = { + kind, + config: config ?? BEHAVIORS[kind].defaultConfig(fn, found!.schema), + }; +} + +/** Re-point the session at a freshly compiled shape. An instance survives if + * its `schemaNs::protocol` still exists; its behaviour map keeps the configs + * of functions that remain and gains defaults for new ones. */ +export function rebuild(session: Session, shape: ProjectShape): void { + session.shape = shape; + const kept: Instance[] = []; + for (const inst of session.instances) { + const found = findProtocol(shape, inst.schemaNs, inst.protocol); + if (!found) continue; + if (inst.role === "server") { + const next: Record = {}; + for (const fn of found.protocol.functions) { + const prev = inst.behaviors[fn.name]; + next[fn.name] = + prev && BEHAVIORS[prev.kind].appliesTo(fn) + ? prev + : { kind: defaultKindFor(fn), config: BEHAVIORS[defaultKindFor(fn)].defaultConfig(fn, found.schema) }; + } + inst.behaviors = next; + } + kept.push(inst); + } + session.instances = kept; + if ( + session.connection && + !(instance(session, session.connection.clientId) && instance(session, session.connection.serverId)) + ) { + session.connection = null; + } +} diff --git a/app/src/sim/shape.ts b/app/src/sim/shape.ts index 70f4424..e291664 100644 --- a/app/src/sim/shape.ts +++ b/app/src/sim/shape.ts @@ -68,3 +68,64 @@ export type TypeRef = | { kind: "array"; of: TypeRef } | { kind: "unit" } | { kind: "union"; of: TypeRef[] }; + +// ── helpers ───────────────────────────────────────────────────────────── + +/** The protocol named `name` in schema `ns`, or `undefined`. */ +export function findProtocol( + shape: ProjectShape, + ns: string, + name: string, +): { schema: SchemaShape; protocol: ProtocolShape } | undefined { + const schema = shape.schemas.find((s) => s.namespace === ns); + const protocol = schema?.protocols.find((p) => p.name === name); + return schema && protocol ? { schema, protocol } : undefined; +} + +/** A short label for a `TypeRef` (`u64`, `Message[]`, `A | B`, `()`). */ +export function typeLabel(ty: TypeRef): string { + switch (ty.kind) { + case "unit": + return "()"; + case "prim": + case "ref": + return ty.name; + case "array": + return `${typeLabel(ty.of)}[]`; + case "union": + return ty.of.map(typeLabel).join(" | "); + } +} + +/** A zero value for `ty`, for seeding "reply with value" and the call form. + * `ref` types recurse through `types`; unknown / recursive → `null`. */ +export function zeroValue(ty: TypeRef, types: TypeDef[], seen: string[] = []): unknown { + switch (ty.kind) { + case "unit": + return null; + case "array": + return []; + case "union": + return ty.of.length ? zeroValue(ty.of[0], types, seen) : null; + case "prim": + return zeroPrim(ty.name); + case "ref": { + if (seen.includes(ty.name)) return null; + const def = types.find((t) => t.name === ty.name); + if (!def) return null; + if (def.kind === "enum") return def.variants[0] ?? null; + const obj: Record = {}; + for (const f of def.fields) obj[f.name] = zeroValue(f.ty, types, [...seen, ty.name]); + return obj; + } + } +} + +function zeroPrim(name: string): unknown { + if (name === "bool") return false; + if (name === "string" || name === "str") return ""; + if (/^[us](8|16|32|64|128)$/.test(name) || name === "f32" || name === "f64" || name === "float") { + return 0; + } + return null; +}