babavoss 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/bin/voss.ts +48 -0
  4. package/gui/babavoss-web.js +234 -0
  5. package/gui/chunk-5hpp1ypv.js +11710 -0
  6. package/gui/chunk-9rq662fd.js +8627 -0
  7. package/gui/chunk-k3cm8j6r.js +433 -0
  8. package/gui/chunk-pk09y98y.js +50 -0
  9. package/gui/chunk-smz02qa6.js +186 -0
  10. package/gui/chunk-wwqypxre.js +49 -0
  11. package/gui/gui.js +2015 -0
  12. package/gui/react-compiler-runtime.js +39 -0
  13. package/gui/react-dom-client.js +21 -0
  14. package/gui/react-dom.js +44 -0
  15. package/gui/react-jsx-runtime.js +19 -0
  16. package/gui/react.js +105 -0
  17. package/gui/theme.css +3053 -0
  18. package/index.ts +15 -0
  19. package/package.json +52 -4
  20. package/src/baba/check.ts +90 -0
  21. package/src/baba/config.ts +261 -0
  22. package/src/baba/find.ts +12 -0
  23. package/src/baba/init.ts +176 -0
  24. package/src/baba/node.ts +483 -0
  25. package/src/baba/project.ts +63 -0
  26. package/src/baba/registry.ts +35 -0
  27. package/src/baba/worker.ts +55 -0
  28. package/src/bench/index.ts +6 -0
  29. package/src/bench/measure.ts +132 -0
  30. package/src/bench/scenarios.ts +136 -0
  31. package/src/build/builder.ts +74 -0
  32. package/src/build/failure.ts +78 -0
  33. package/src/build/guard.ts +85 -0
  34. package/src/build/mdx-register.ts +3 -0
  35. package/src/build/mdx.ts +38 -0
  36. package/src/build/project.ts +38 -0
  37. package/src/build/views.ts +146 -0
  38. package/src/builder/main.ts +29 -0
  39. package/src/desktop/bob.ts +76 -0
  40. package/src/desktop/desktop.css +111 -0
  41. package/src/desktop/icons.ts +50 -0
  42. package/src/desktop/index.ts +323 -0
  43. package/src/desktop/routes.ts +98 -0
  44. package/src/desktop/view.tsx +673 -0
  45. package/src/door/core.ts +384 -0
  46. package/src/ecs/baba.ts +431 -0
  47. package/src/ecs/codec.ts +334 -0
  48. package/src/ecs/handles.ts +91 -0
  49. package/src/ecs/replica.ts +150 -0
  50. package/src/ecs/runtime.ts +603 -0
  51. package/src/ecs/scheduler.ts +75 -0
  52. package/src/ecs/snapshot.ts +102 -0
  53. package/src/ecs/state.ts +759 -0
  54. package/src/ecs/system.ts +256 -0
  55. package/src/ecs/table.ts +420 -0
  56. package/src/ecs/testbed.ts +97 -0
  57. package/src/exec/host.ts +177 -0
  58. package/src/exec/main.ts +98 -0
  59. package/src/exec/watch.ts +7 -0
  60. package/src/exec/wire.ts +29 -0
  61. package/src/generated/build.ts +4 -0
  62. package/src/gui/css.d.ts +1 -0
  63. package/src/gui/gui.tsx +245 -0
  64. package/src/gui/index.ts +51 -0
  65. package/src/gui/inspector.tsx +47 -0
  66. package/src/gui/levels.tsx +73 -0
  67. package/src/gui/promptware.tsx +68 -0
  68. package/src/gui/runner.tsx +118 -0
  69. package/src/gui/theme.css +498 -0
  70. package/src/gui/theme.ts +25 -0
  71. package/src/gui/wizard.tsx +227 -0
  72. package/src/guide/add-a-desktop.mdx +100 -0
  73. package/src/guide/compose-an-interface.mdx +84 -0
  74. package/src/guide/index.ts +13 -0
  75. package/src/guide/reach-outside.mdx +112 -0
  76. package/src/guide/spec-a-system.mdx +93 -0
  77. package/src/guide/systems-together.mdx +72 -0
  78. package/src/guide/write-a-system.mdx +183 -0
  79. package/src/guide/write-promptware.mdx +90 -0
  80. package/src/http/server.ts +310 -0
  81. package/src/kernel/build.ts +21 -0
  82. package/src/kernel/builder.ts +105 -0
  83. package/src/kernel/children.ts +117 -0
  84. package/src/kernel/context.ts +90 -0
  85. package/src/kernel/lock.ts +46 -0
  86. package/src/kernel/names.ts +14 -0
  87. package/src/kernel/schema.ts +130 -0
  88. package/src/kernel/where.ts +12 -0
  89. package/src/kit/index.ts +232 -0
  90. package/src/maker/system.ts +213 -0
  91. package/src/mcp/daemon.ts +61 -0
  92. package/src/mcp/main.ts +208 -0
  93. package/src/mcp/rpc.ts +64 -0
  94. package/src/mcp/tools.ts +125 -0
  95. package/src/prompt/evals.ts +42 -0
  96. package/src/prompt/index.ts +242 -0
  97. package/src/prompt/jsx-dev-runtime.ts +1 -0
  98. package/src/prompt/jsx-runtime.ts +49 -0
  99. package/src/prompt/mdx.d.ts +1 -0
  100. package/src/promptware/compile.ts +183 -0
  101. package/src/promptware/define.ts +16 -0
  102. package/src/promptware/disk.ts +72 -0
  103. package/src/promptware/markdown.d.ts +6 -0
  104. package/src/promptware/sync.ts +437 -0
  105. package/src/promptware/system.ts +215 -0
  106. package/src/runtime/bridge.ts +85 -0
  107. package/src/runtime/connect.ts +54 -0
  108. package/src/runtime/env.ts +35 -0
  109. package/src/runtime/harness.ts +80 -0
  110. package/src/runtime/main.ts +119 -0
  111. package/src/runtime/worker.ts +33 -0
  112. package/src/server/edge.ts +332 -0
  113. package/src/server/main.ts +45 -0
  114. package/src/server/messages.ts +97 -0
  115. package/src/server/protocol.ts +37 -0
  116. package/src/services/args.ts +45 -0
  117. package/src/services/exec.ts +69 -0
  118. package/src/services/fs.ts +139 -0
  119. package/src/services/http.ts +30 -0
  120. package/src/services/index.ts +113 -0
  121. package/src/services/secrets.ts +18 -0
  122. package/src/shell/address.ts +21 -0
  123. package/src/shell/args.ts +219 -0
  124. package/src/shell/client.ts +107 -0
  125. package/src/shell/codes.ts +26 -0
  126. package/src/shell/positional.ts +20 -0
  127. package/src/shell/run.ts +470 -0
  128. package/src/shell/service.ts +167 -0
  129. package/src/shell/state.ts +204 -0
  130. package/src/spec/adapters.ts +72 -0
  131. package/src/spec/diff.ts +26 -0
  132. package/src/spec/files.ts +17 -0
  133. package/src/spec/index.ts +155 -0
  134. package/src/spec/run.ts +97 -0
  135. package/src/spec/take.ts +54 -0
  136. package/src/test/index.ts +8 -0
  137. package/src/test/prove.ts +56 -0
  138. package/src/test/records.ts +23 -0
  139. package/src/test/specs.ts +56 -0
  140. package/src/test/steps.ts +100 -0
  141. package/src/test/voss-dir.ts +17 -0
  142. package/src/transport/messages.ts +110 -0
  143. package/src/transport/transport.ts +62 -0
  144. package/src/wall/probe.ts +67 -0
  145. package/src/wall/profile.ts +103 -0
  146. package/src/wall/spawn.ts +59 -0
  147. package/src/web/app.tsx +53 -0
  148. package/src/web/core.tsx +140 -0
  149. package/src/web/form.ts +155 -0
  150. package/src/web/hooks.ts +135 -0
  151. package/src/web/index.tsx +17 -0
  152. package/src/web/list.ts +19 -0
  153. package/src/web/maker.tsx +766 -0
  154. package/src/web/objects.tsx +213 -0
  155. package/src/web/socket.ts +84 -0
  156. package/src/web/state.tsx +69 -0
  157. package/src/web/store.ts +221 -0
  158. package/src/web/ui.tsx +135 -0
  159. package/README.md +0 -4
@@ -0,0 +1,167 @@
1
+ // The GUI kept running by the user service, at its configured address.
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { browserUrl } from "./address.ts";
5
+ import { mkdir, writeFile, unlink, stat } from "node:fs/promises";
6
+
7
+ export const label = "org.babavoss.voss";
8
+
9
+ /** How the service reaches the system: replaced in tests. */
10
+ export interface Sys {
11
+ platform: NodeJS.Platform;
12
+ home: string;
13
+ uid: number;
14
+ /** Runs a program and answers its exit code and output. */
15
+ run(cmd: string[], opts?: { inherit?: boolean }): Promise<{ code: number; out: string }>;
16
+ }
17
+
18
+ export const realSys: Sys = {
19
+ platform: process.platform,
20
+ // Read when asked: a walled child has no home and never asks.
21
+ get home() { return process.env.HOME || homedir(); },
22
+ uid: process.getuid?.() ?? 0,
23
+ async run(cmd, opts = {}) {
24
+ const p = Bun.spawn(cmd, { stdout: opts.inherit ? "inherit" : "pipe", stderr: opts.inherit ? "inherit" : "pipe", stdin: opts.inherit ? "inherit" : "ignore" });
25
+ const out = opts.inherit ? "" : (await new Response(p.stdout).text()) + (await new Response(p.stderr).text());
26
+ return { code: await p.exited, out };
27
+ },
28
+ };
29
+
30
+ /** The command the service runs: Bun on the framework's entry. */
31
+ export function program(): string[] {
32
+ return [process.execPath, join(import.meta.dir, "..", "..", "bin", "voss.ts")];
33
+ }
34
+
35
+ export const agentPath = (home: string) => join(home, "Library", "LaunchAgents", `${label}.plist`);
36
+ export const unitPath = (home: string) => join(home, ".config", "systemd", "user", "voss.service");
37
+ export const logPath = (home: string) => join(home, ".voss", "service.log");
38
+
39
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
40
+
41
+ /** The launchd agent: runs at login, restarts when it stops, logs under ~/.voss. */
42
+ export function plist(o: { program: string[]; address: string; home: string }): string {
43
+ const args = [...o.program, "gui", o.address].map((a) => ` <string>${xml(a)}</string>`).join("\n");
44
+ return `<?xml version="1.0" encoding="UTF-8"?>
45
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
46
+ <plist version="1.0"><dict>
47
+ <key>Label</key><string>${label}</string>
48
+ <key>ProgramArguments</key><array>
49
+ ${args}
50
+ </array>
51
+ <key>RunAtLoad</key><true/>
52
+ <key>KeepAlive</key><true/>
53
+ <key>WorkingDirectory</key><string>${xml(o.home)}</string>
54
+ <key>StandardOutPath</key><string>${xml(logPath(o.home))}</string>
55
+ <key>StandardErrorPath</key><string>${xml(logPath(o.home))}</string>
56
+ <key>EnvironmentVariables</key><dict>
57
+ <key>HOME</key><string>${xml(o.home)}</string>
58
+ <key>PATH</key><string>/usr/bin:/bin:/usr/sbin:/sbin</string>
59
+ </dict>
60
+ </dict></plist>
61
+ `;
62
+ }
63
+
64
+ /** The systemd user unit, the same three promises. */
65
+ export function unit(o: { program: string[]; address: string; home: string }): string {
66
+ const exec = [...o.program, "gui", o.address].map((a) => (/[\s"]/.test(a) ? JSON.stringify(a) : a)).join(" ");
67
+ return `[Unit]
68
+ Description=voss gui
69
+
70
+ [Service]
71
+ ExecStart=${exec}
72
+ Restart=always
73
+ RestartSec=2
74
+ WorkingDirectory=${o.home}
75
+ Environment=HOME=${o.home}
76
+ Environment=PATH=/usr/bin:/bin
77
+
78
+ [Install]
79
+ WantedBy=default.target
80
+ `;
81
+ }
82
+
83
+ export interface Status {
84
+ installed: boolean;
85
+ running: boolean;
86
+ pid: number | null;
87
+ address: string | null;
88
+ url: string | null;
89
+ file: string;
90
+ log: string;
91
+ program: string[];
92
+ }
93
+
94
+ async function exists(p: string) { try { await stat(p); return true; } catch { return false; } }
95
+
96
+ /** Reads the address the installed agent or unit runs on. */
97
+ async function installedAddress(sys: Sys): Promise<string | null> {
98
+ const file = sys.platform === "darwin" ? agentPath(sys.home) : unitPath(sys.home);
99
+ if (!(await exists(file))) return null;
100
+ const text = await Bun.file(file).text();
101
+ const m = text.match(/gui(?:<\/string>\s*<string>| )([^<\s]+)/);
102
+ return m?.[1] ?? null;
103
+ }
104
+
105
+ export async function status(sys: Sys = realSys): Promise<Status> {
106
+ const file = sys.platform === "darwin" ? agentPath(sys.home) : unitPath(sys.home);
107
+ const installed = await exists(file);
108
+ let running = false, pid: number | null = null;
109
+ if (installed && sys.platform === "darwin") {
110
+ const r = await sys.run(["launchctl", "print", `gui/${sys.uid}/${label}`]);
111
+ running = r.code === 0 && /state = running/.test(r.out);
112
+ pid = Number(r.out.match(/\bpid = (\d+)/)?.[1]) || null;
113
+ } else if (installed) {
114
+ const r = await sys.run(["systemctl", "--user", "show", "-p", "ActiveState,MainPID", "voss"]);
115
+ running = /ActiveState=active/.test(r.out);
116
+ pid = Number(r.out.match(/MainPID=(\d+)/)?.[1]) || null;
117
+ }
118
+ const address = await installedAddress(sys);
119
+ return { installed, running, pid, address, url: address ? browserUrl(address) : null, file, log: logPath(sys.home), program: program() };
120
+ }
121
+
122
+ export async function install(address: string, sys: Sys = realSys): Promise<Status> {
123
+ const prog = program();
124
+ if (sys.platform === "darwin") {
125
+ const file = agentPath(sys.home);
126
+ await mkdir(join(sys.home, "Library", "LaunchAgents"), { recursive: true });
127
+ await mkdir(join(sys.home, ".voss"), { recursive: true });
128
+ await writeFile(file, plist({ program: prog, address, home: sys.home }), { mode: 0o644 });
129
+ await sys.run(["launchctl", "bootout", `gui/${sys.uid}/${label}`]);
130
+ const r = await sys.run(["launchctl", "bootstrap", `gui/${sys.uid}`, file]);
131
+ if (r.code !== 0) throw new Error(`launchctl bootstrap failed: ${r.out.trim()}`);
132
+ } else if (sys.platform === "linux") {
133
+ const file = unitPath(sys.home);
134
+ await mkdir(join(sys.home, ".config", "systemd", "user"), { recursive: true });
135
+ await writeFile(file, unit({ program: prog, address, home: sys.home }));
136
+ await sys.run(["systemctl", "--user", "daemon-reload"]);
137
+ const r = await sys.run(["systemctl", "--user", "enable", "--now", "voss"]);
138
+ if (r.code !== 0) throw new Error(`systemctl enable failed: ${r.out.trim()}`);
139
+ // enable --now leaves an already-running unit on its old address.
140
+ const restarted = await sys.run(["systemctl", "--user", "restart", "voss"]);
141
+ if (restarted.code !== 0) throw new Error(`systemctl restart failed: ${restarted.out.trim()}`);
142
+ } else throw new Error(`no service on ${sys.platform}`);
143
+ return status(sys);
144
+ }
145
+
146
+ export async function remove(sys: Sys = realSys): Promise<Status> {
147
+ if (sys.platform === "darwin") {
148
+ await sys.run(["launchctl", "bootout", `gui/${sys.uid}/${label}`]);
149
+ await unlink(agentPath(sys.home)).catch(() => {});
150
+ } else if (sys.platform === "linux") {
151
+ await sys.run(["systemctl", "--user", "disable", "--now", "voss"]);
152
+ await unlink(unitPath(sys.home)).catch(() => {});
153
+ await sys.run(["systemctl", "--user", "daemon-reload"]);
154
+ }
155
+ return status(sys);
156
+ }
157
+
158
+ export async function restart(sys: Sys = realSys): Promise<Status> {
159
+ if (sys.platform === "darwin") {
160
+ const r = await sys.run(["launchctl", "kickstart", "-k", `gui/${sys.uid}/${label}`]);
161
+ if (r.code !== 0) throw new Error(`launchctl kickstart failed: ${r.out.trim()}`);
162
+ } else if (sys.platform === "linux") {
163
+ const r = await sys.run(["systemctl", "--user", "restart", "voss"]);
164
+ if (r.code !== 0) throw new Error(`systemctl restart failed: ${r.out.trim()}`);
165
+ }
166
+ return status(sys);
167
+ }
@@ -0,0 +1,204 @@
1
+ // The state at the shell: state, follow, run and raw, each over the client.
2
+ // Each answers its exit status and prints through `out`; an error is thrown
3
+ // for the shell to answer on stderr.
4
+ import { readFile } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import { callArgs, duration, words, UsageError } from "./args.ts";
7
+ import { ShellError, errorLine } from "./codes.ts";
8
+ import { stateOf, followEntity, watchQuery, untilOf, byKey, FOLLOW_MS, type Api, type Until } from "../door/core.ts";
9
+ import type { Client } from "./client.ts";
10
+ import { follow as whole } from "../ecs/snapshot.ts";
11
+ import type { Manifest } from "../ecs/baba.ts";
12
+
13
+ type O = Record<string, unknown>;
14
+
15
+ export interface Io {
16
+ /** One answer, one line, within the budget; `name` names its file when it is over. */
17
+ out: (v: unknown, name?: string) => void;
18
+ /** A value within the budget, or its marker: for a value inside a line. */
19
+ fit: (v: unknown, name: string) => unknown;
20
+ /** Where answers over the budget go. */
21
+ outDir: string;
22
+ cwd: string;
23
+ stdin?: () => Promise<string>;
24
+ stdinTTY?: boolean;
25
+ /** Ctrl-C: calls the handler until the returned function unsubscribes. */
26
+ interrupt?: (handler: () => void) => () => void;
27
+ }
28
+
29
+ /** The shell's client as the core's Api: a fire coerces shell strings by the schema. */
30
+ export const apiOf = (c: Client): Api => ({
31
+ describe: () => c.describe(),
32
+ fire: (n, args, key) => c.fire(n, args, { key, coerce: true }),
33
+ read: (n, args) => c.read(n, args),
34
+ state: (since, signal) => c.state(since, signal),
35
+ });
36
+
37
+ /** The exit status of a wait's outcome: 0 held, 3 time ran out (130 when interrupted), 4 gone. */
38
+ const exitOfOutcome = (outcome: string, stopped = false) => outcome === "held" ? 0 : outcome === "gone" ? 4 : stopped ? 130 : 3;
39
+
40
+ /** state [ENTITY | COMPONENT [VALUE] | RESOURCE] [--where k=v]… [--deep] [--limit N] [--all] */
41
+ export async function state(c: Client, m: Manifest, args: string[], io: Io): Promise<number> {
42
+ const w = words(args, { positional: ["what", "value"], booleans: ["deep", "all"] });
43
+ for (const k of Object.keys(w)) if (!["what", "value", "deep", "all", "where", "limit"].includes(k)) throw new UsageError(`state takes --where, --deep, --limit and --all, not --${k}`);
44
+ const what = typeof w.what === "string" ? w.what : undefined;
45
+ const deep = w.deep === "true";
46
+ const api = apiOf(c);
47
+ if (what === undefined) {
48
+ const all = w.all === "true";
49
+ io.out((await stateOf(api, m, { all })).value, all ? "state" : undefined);
50
+ return 0;
51
+ }
52
+ if (/^\d+$/.test(what)) {
53
+ if (w.value !== undefined) throw new UsageError("state ENTITY takes nothing after it");
54
+ const r = await stateOf(api, m, { entity: Number(what), deep });
55
+ io.out(r.value, r.outcome === "gone" ? undefined : "state");
56
+ return exitOfOutcome(r.outcome);
57
+ }
58
+ if (typeof w.value === "string") { io.out((await stateOf(api, m, { component: what, key: w.value, deep })).value, "state"); return 0; }
59
+ const wheres = w.where === undefined ? [] : Array.isArray(w.where) ? w.where : [w.where];
60
+ const where = wheres.map((cond) => {
61
+ const eq = cond.indexOf("=");
62
+ if (eq < 0) throw new UsageError(`--where takes field=value, got ${cond}`);
63
+ return [cond.slice(0, eq), cond.slice(eq + 1)] as const;
64
+ });
65
+ const limit = w.limit === undefined ? 100 : Number(w.limit);
66
+ if (!Number.isInteger(limit) || limit < 0) throw new UsageError(`--limit takes a whole number, got ${String(w.limit)}`);
67
+ const r = await stateOf(api, m, { component: what, where, limit, deep, dir: io.outDir });
68
+ io.out(r.value, Array.isArray(r.value) ? `state-${what}` : "state");
69
+ return 0;
70
+ }
71
+
72
+ /**
73
+ * follow ENTITY | COMPONENT VALUE [--until COMPONENT | COMPONENT.field=value] [--for 30s] [--changes]
74
+ * follow QUERY [args…] --changed [--for 30s]
75
+ */
76
+ export async function follow(c: Client, m: Manifest, args: string[], io: Io): Promise<number> {
77
+ const first = args.find((a) => !a.startsWith("--"));
78
+ if (first === undefined) throw new UsageError("follow takes an ENTITY, a COMPONENT and its key, or a QUERY with --changed");
79
+ const changed = args.includes("--changed");
80
+ if (!/^\d+$/.test(first) && (changed || (first in m.queries && !(first in m.components)))) return await watch(c, m, args, io);
81
+ const w = words(args, { positional: ["what", "value"], booleans: ["changes"] });
82
+ for (const k of Object.keys(w)) if (!["what", "value", "until", "for", "changes"].includes(k)) throw new UsageError(`follow takes --until, --for and --changes, not --${k}`);
83
+ if (Array.isArray(w.until) || Array.isArray(w.for)) throw new UsageError("--until and --for are given once");
84
+ const forMs = typeof w.for === "string" ? duration(w.for) : FOLLOW_MS;
85
+ const until: Until | null = typeof w.until === "string" ? untilOf(m, w.until) : null;
86
+ const api = apiOf(c);
87
+ let e: number;
88
+ if (/^\d+$/.test(String(w.what))) {
89
+ if (w.value !== undefined) throw new UsageError("follow ENTITY takes nothing after it but flags");
90
+ e = Number(w.what);
91
+ } else if (typeof w.value === "string") e = byKey(whole(null, await api.state(-1)), m, String(w.what), w.value);
92
+ else throw new UsageError(`follow takes an ENTITY, a COMPONENT and its key, or a QUERY with --changed; got ${String(w.what)}`);
93
+
94
+ let stopped = false;
95
+ const off = io.interrupt?.(() => { stopped = true; });
96
+ let printed: string | null = null;
97
+ const show = (row: unknown) => { io.out(row, "follow"); printed = JSON.stringify(row); };
98
+ try {
99
+ const r = await followEntity(api, e, { until, for: forMs, changes: w.changes === "true", onChange: show, stop: () => stopped });
100
+ if (r.outcome === "gone") io.out(r.value);
101
+ else if (JSON.stringify(r.value) !== printed) show(r.value);
102
+ return exitOfOutcome(r.outcome, stopped);
103
+ } finally { off?.(); }
104
+ }
105
+
106
+ /** follow QUERY [args…] --changed [--for]: the query's answer the first time it differs from the first read. */
107
+ async function watch(c: Client, m: Manifest, args: string[], io: Io): Promise<number> {
108
+ let forMs = FOLLOW_MS;
109
+ const rest: string[] = [];
110
+ for (let i = 0; i < args.length; i++) {
111
+ const a = args[i]!;
112
+ if (a === "--changed") continue;
113
+ if (a === "--for") { const v = args[++i]; if (v === undefined || v.startsWith("--")) throw new UsageError("--for needs a value: 30, 30s or 2m"); forMs = duration(v); continue; }
114
+ if (a.startsWith("--for=")) { forMs = duration(a.slice(6)); continue; }
115
+ rest.push(a);
116
+ }
117
+ const [name, ...words] = rest;
118
+ const q = name === undefined ? undefined : m.queries[name];
119
+ if (!q || name === undefined) throw new ShellError("noquery", `no query ${name ?? ""}; queries: ${Object.keys(m.queries).join(", ")}`);
120
+ const { args: a } = await callArgs(words, q, io.stdin);
121
+ const r = await watchQuery(apiOf(c), name, a, { for: forMs });
122
+ io.out(r.value, name);
123
+ return exitOfOutcome(r.outcome);
124
+ }
125
+
126
+ /** "$N" or "$N.a.b" in a line's args: an earlier line's answer, or a field of it. */
127
+ const REF = /^\$(\d+)((?:\.[^.]+)*)$/;
128
+
129
+ /**
130
+ * run [FILE | LINE …]: lines of {"NAME": args, "key"?: K}, actions and
131
+ * queries, from stdin, a file, or the arguments; each sent in order, its
132
+ * answer on its own line as {"i":n,"ok":value} or {"i":n,"error":{…}}.
133
+ */
134
+ export async function batch(c: Client, m: Manifest, args: string[], io: Io): Promise<number> {
135
+ let text: string;
136
+ if (args.length === 1 && !args[0]!.trimStart().startsWith("{")) {
137
+ try { text = await readFile(resolve(io.cwd, args[0]!), "utf8"); } catch { throw new UsageError(`run reads lines from ${args[0]}, and there is no such file`); }
138
+ } else if (args.length) text = args.join("\n");
139
+ else if (!io.stdin || io.stdinTTY) throw new UsageError("run takes lines of {\"NAME\": args}: from stdin, from a FILE, or as arguments");
140
+ else text = await io.stdin();
141
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
142
+ const answers: ({ ok: true; value: unknown } | { ok: false })[] = [];
143
+ const refer = (v: unknown, i: number): unknown => {
144
+ if (typeof v === "string") {
145
+ const r = REF.exec(v);
146
+ if (!r) return v;
147
+ const n = Number(r[1]);
148
+ if (n >= i) throw new ShellError("args", `${v} refers to line ${n}, not an earlier one`);
149
+ const a = answers[n]!;
150
+ if (!a.ok) throw new ShellError("args", `${v} refers to line ${n}, which failed`);
151
+ let x: unknown = a.value;
152
+ for (const k of r[2] ? r[2].slice(1).split(".") : []) {
153
+ if (typeof x !== "object" || x === null || !(k in x)) throw new ShellError("args", `${v}: line ${n}'s answer has no ${k}`);
154
+ x = (x as O)[k];
155
+ }
156
+ return x;
157
+ }
158
+ if (Array.isArray(v)) return v.map((x) => refer(x, i));
159
+ if (typeof v === "object" && v !== null) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, refer(x, i)]));
160
+ return v;
161
+ };
162
+ let failed = false;
163
+ for (const [i, l] of lines.entries()) {
164
+ try {
165
+ let o: unknown;
166
+ try { o = JSON.parse(l); } catch { throw new ShellError("args", `line ${i} is not JSON`); }
167
+ if (typeof o !== "object" || o === null || Array.isArray(o)) throw new ShellError("args", `line ${i} is not {"NAME": args}`);
168
+ const { key, ...call } = o as O;
169
+ const names = Object.keys(call);
170
+ if (names.length !== 1) throw new ShellError("args", `line ${i}: one action or query by name, and "key" if any`);
171
+ if (key !== undefined && typeof key !== "string") throw new ShellError("args", `line ${i}: the key is a string`);
172
+ const name = names[0]!;
173
+ const a = refer(call[name] ?? {}, i);
174
+ let value: unknown;
175
+ if (name in m.actions) value = await c.fire(name, a, { key: key as string | undefined });
176
+ else if (name in m.queries) {
177
+ if (key !== undefined) throw new ShellError("args", `line ${i}: ${name} is a query, and a key is an action's`);
178
+ value = await c.read(name, a, {});
179
+ } else throw new ShellError("noaction", `line ${i}: no action or query ${name}`);
180
+ answers.push({ ok: true, value });
181
+ io.out({ i, ok: io.fit(value, `run-${i}`) });
182
+ } catch (err) {
183
+ failed = true;
184
+ answers.push({ ok: false });
185
+ await io.out({ i, error: errorLine(err) });
186
+ }
187
+ }
188
+ return failed ? 1 : 0;
189
+ }
190
+
191
+ /** raw spawn JSON | add E C [JSON] | remove E C … | despawn E | set R JSON */
192
+ export async function rawOp(c: Client, args: string[], io: Io): Promise<number> {
193
+ const [op, ...rest] = args;
194
+ const parse = (s: string | undefined, what: string) => { if (s === undefined) return true; try { return JSON.parse(s); } catch { throw new UsageError(`${what} is not JSON: ${s}`); } };
195
+ const entity = (s: string | undefined) => { if (!s || !/^\d+$/.test(s)) throw new UsageError(`raw ${op} takes an ENTITY`); return Number(s); };
196
+ switch (op) {
197
+ case "spawn": { if (!rest[0]) throw new UsageError("raw spawn takes JSON: {\"component\": value, …}"); await io.out(await c.raw({ op: "spawn", parts: parse(rest[0], "the parts") as O })); return 0; }
198
+ case "add": { if (!rest[1]) throw new UsageError("raw add takes ENTITY COMPONENT [JSON]"); await io.out(await c.raw({ op: "add", entity: entity(rest[0]), component: rest[1], value: parse(rest[2], "the value") })); return 0; }
199
+ case "remove": { if (!rest[1]) throw new UsageError("raw remove takes ENTITY COMPONENT …"); await io.out(await c.raw({ op: "remove", entity: entity(rest[0]), components: rest.slice(1) })); return 0; }
200
+ case "despawn": { const e = entity(rest[0]); await c.raw({ op: "despawn", entity: e }); await io.out({ entity: e, despawned: true }); return 0; }
201
+ case "set": { if (!rest[1]) throw new UsageError("raw set takes RESOURCE JSON"); await io.out(await c.raw({ op: "set", resource: rest[0]!, value: parse(rest[1], "the value") })); return 0; }
202
+ }
203
+ throw new UsageError("raw: spawn, add, remove, despawn or set");
204
+ }
@@ -0,0 +1,72 @@
1
+ // Adapters: programs that play the outside for a simulated state. An effect
2
+ // asks for a job; instead of the port, an adapter answers it, on the spec's
3
+ // virtual clock, from a model that is part of the spec: seeded with it,
4
+ // reset with it. Composed from primitives; synchronous by design, so a
5
+ // headless run and a live one answer at the same moments.
6
+ import type { Effect, Entity } from "../ecs/state.ts";
7
+
8
+ /** What a request's job gets back. */
9
+ export type Answer = { ok: true; value: unknown } | { ok: false; error: string };
10
+ /** A request's answer, after a delay on the virtual clock. */
11
+ export interface Reply { answer: Answer | null; after: number }
12
+
13
+ /** What an adapter sees of the spec when it answers: the moment, the job's entity, and the adapter's own state, kept per spec. */
14
+ export interface AdapterContext {
15
+ now: number;
16
+ entity: Entity | null;
17
+ /** This adapter's state in this spec: anything serializable; `model` keeps its state here. */
18
+ state: Map<string, unknown>;
19
+ }
20
+
21
+ /** An adapter for a request effect: the answer, and when. */
22
+ export interface RequestAdapter { kind: "request"; reply(args: unknown, ctx: AdapterContext): Reply }
23
+ /** An adapter for a source: its listing, and the events it yields, each at a moment after the job starts; `relist` lists again every so often. */
24
+ export interface SourceAdapter { kind: "source"; listing(args: unknown, ctx: AdapterContext): unknown[]; events(args: unknown, ctx: AdapterContext): { at: number; event: unknown }[]; relist: number | null }
25
+ export type Adapter = RequestAdapter | SourceAdapter;
26
+
27
+ const request = (reply: RequestAdapter["reply"]): RequestAdapter => ({ kind: "request", reply });
28
+
29
+ /** The job succeeds with `value`. */
30
+ export const ok = (value: unknown = null): RequestAdapter => request(() => ({ answer: { ok: true, value }, after: 0 }));
31
+ /** The job fails with `error`. */
32
+ export const fail = (error: string): RequestAdapter => request(() => ({ answer: { ok: false, error }, after: 0 }));
33
+ /** The job never answers: pending work, visible in the shell. */
34
+ export const wait = (): RequestAdapter => request(() => ({ answer: null, after: 0 }));
35
+ /** `inner`'s answer, `ms` later on the virtual clock. */
36
+ export const after = (ms: number, inner: RequestAdapter): RequestAdapter => request((a, c) => { const r = inner.reply(a, c); return { ...r, after: r.after + ms }; });
37
+ /** Successive jobs answer in turn; the last answers every job after. */
38
+ export const sequence = (inner: RequestAdapter[]): RequestAdapter => {
39
+ if (!inner.length) throw new Error("sequence: at least one adapter");
40
+ return request((a, c) => { const n = (c.state.get("sequence") as number | undefined) ?? 0; c.state.set("sequence", n + 1); return inner[Math.min(n, inner.length - 1)]!.reply(a, c); });
41
+ };
42
+ /** `inner` when the args match `where` (every field deep-equal), else `otherwise`, else the default answer of nothing: wait. */
43
+ export const match = (where: Record<string, unknown> | ((args: unknown) => boolean), inner: RequestAdapter, otherwise: RequestAdapter = wait()): RequestAdapter =>
44
+ request((a, c) => (typeof where === "function" ? where(a) : Object.entries(where).every(([k, v]) => JSON.stringify((a as Record<string, unknown>)?.[k]) === JSON.stringify(v))) ? inner.reply(a, c) : otherwise.reply(a, c));
45
+ /** A stateful outside: `step` sees the state so far and the args, answers the next state and the reply. The state is the spec's: seeded, reset, shown. */
46
+ export const model = <S>(initial: S, step: (state: S, args: unknown, ctx: AdapterContext) => [S, Answer | Reply]): RequestAdapter =>
47
+ request((a, c) => { const s = (c.state.has("model") ? c.state.get("model") : initial) as S; const [next, r] = step(s, a, c); c.state.set("model", next); return "answer" in (r as Reply) && "after" in (r as Reply) ? (r as Reply) : { answer: r as Answer, after: 0 }; });
48
+ /** A custom reply. */
49
+ export const custom = (reply: (args: unknown, ctx: AdapterContext) => Answer | Reply): RequestAdapter => request((a, c) => { const r = reply(a, c); return "after" in r ? r : { answer: r, after: 0 }; });
50
+ /** A real job's answer, kept: the data was recorded from the running baba; the caller loaded it. */
51
+ export const recorded = (answer: Answer): RequestAdapter => request(() => ({ answer, after: 0 }));
52
+
53
+ /** A source's listing: the items as its `list` would answer, or a function of the args and the adapter's state. */
54
+ export const listing = (items: unknown[] | ((args: unknown, ctx: AdapterContext) => unknown[]), o: { events?: { at: number; event: unknown }[]; relist?: number } = {}): SourceAdapter =>
55
+ ({ kind: "source", listing: typeof items === "function" ? items : () => items, events: () => o.events ?? [], relist: o.relist ?? null });
56
+ /** A stream's events, each `at` ms after its job starts. */
57
+ export const events = (evs: { at: number; event: unknown }[]): SourceAdapter => ({ kind: "source", listing: () => [], events: () => evs, relist: null });
58
+
59
+ /**
60
+ * How a runtime finds an effect's adapter: by the effect itself. An effect
61
+ * the spec names no adapter for is quiet: its job is never started, so it
62
+ * waits, pending, like an outside that has not answered yet, which is what it
63
+ * is; a scenario may answer it by hand (`done`, `failed`, `list`, `watch`).
64
+ * Nothing is faked: a quiet mirror keeps what was listed by hand.
65
+ */
66
+ export function adaptersOf(pairs: readonly (readonly [Effect, Adapter])[]): (effect: Effect) => Adapter | undefined {
67
+ const map = new Map<Effect, Adapter>(pairs.map(([e, a]) => [e, a] as const));
68
+ return (effect) => map.get(effect);
69
+ }
70
+
71
+ /** Whether a spec plays an effect, or leaves it quiet. */
72
+ export const isPlayed = (pairs: readonly (readonly [Effect, Adapter])[], effect: Effect): boolean => pairs.some(([e]) => e === effect);
@@ -0,0 +1,26 @@
1
+ // What changed between two states, in object terms: an entity that appeared
2
+ // with its parts, one that went, one whose parts moved; a resource that
3
+ // moved. What a person reads after a step of a story.
4
+ import type { Snapshot } from "../ecs/state.ts";
5
+
6
+ export interface Change { kind: "added" | "removed" | "changed"; entity: number; /** The parts after; a part removed is null. */ parts: Record<string, unknown> }
7
+ export interface ResourceChange { name: string; value: unknown }
8
+
9
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
10
+
11
+ export function diffStates(before: Snapshot, after: Snapshot): { entities: Change[]; resources: ResourceChange[] } {
12
+ const entities: Change[] = [];
13
+ const b = before.entities as Record<string, Record<string, unknown> | null>, a = after.entities as Record<string, Record<string, unknown> | null>;
14
+ for (const [id, parts] of Object.entries(a)) {
15
+ const was = b[id];
16
+ if (!was) { entities.push({ kind: "added", entity: Number(id), parts: parts ?? {} }); continue; }
17
+ const moved: Record<string, unknown> = {};
18
+ for (const [n, v] of Object.entries(parts ?? {})) if (!(n in was) || !same(was[n], v)) moved[n] = v;
19
+ for (const n of Object.keys(was)) if (!(n in (parts ?? {}))) moved[n] = null;
20
+ if (Object.keys(moved).length) entities.push({ kind: "changed", entity: Number(id), parts: moved });
21
+ }
22
+ for (const id of Object.keys(b)) if (!(id in a)) entities.push({ kind: "removed", entity: Number(id), parts: {} });
23
+ const resources: ResourceChange[] = [];
24
+ for (const [n, v] of Object.entries(after.resources)) if (!same(before.resources[n], v)) resources.push({ name: n, value: v });
25
+ return { entities, resources };
26
+ }
@@ -0,0 +1,17 @@
1
+ // A spec from where it lives, browser-safe: the spec module beside a system
2
+ // (or beside the entry, for the whole baba) and the scenarios saved in files
3
+ // of their own beside it. The headless runner and the Maker's page both
4
+ // assemble specs with it; how each finds the files is its own.
5
+ import { isSpec, withScenarios, type Spec, type SpecScenario } from "./index.ts";
6
+
7
+ /** One spec's files, relative to the baba's .baba directory: its spec, and its scenarios in files of their own. */
8
+ export interface SpecFiles { dir: string; spec: string; scenarios: string[] }
9
+
10
+ /** A spec from its spec module's default export and its scenario modules' default exports: checked, and named for where it lives. */
11
+ export function assembleSpec(files: SpecFiles, spec: unknown, scenarios: readonly unknown[]): Spec<any, any> {
12
+ if (!isSpec(spec)) throw new Error(`${files.spec}: its default export is not a spec; export default spec(system, { … })`);
13
+ const expected = files.dir === "" ? null : files.dir.split("/").pop()!;
14
+ if (expected !== null && spec.name !== expected) throw new Error(`${files.spec}: the spec is named ${spec.name}; a system's spec is named like its directory, ${expected}`);
15
+ scenarios.forEach((sc, i) => { if (typeof sc !== "object" || sc === null || typeof (sc as SpecScenario).script !== "function") throw new Error(`${files.scenarios[i]}: its default export is not a scenario; export default scenario({ name, intent, script })`); });
16
+ return withScenarios(spec, scenarios as SpecScenario[]);
17
+ }
@@ -0,0 +1,155 @@
1
+ // babavoss/spec: a spec as a program. A spec is of a system (the system
2
+ // and every system it reads) or of the whole baba; it has a setup that brings
3
+ // an empty spec to its initial state, the adapters that play its outside,
4
+ // and its scenarios. One program runs
5
+ // in two places: headless under bun test, which writes the verdicts, and
6
+ // live in the Maker, which draws every round and lets a person act.
7
+ import { baba, isBaba, isSystem, nameOf, closureOf, SYSTEM, type System, type Baba } from "../ecs/baba.ts";
8
+ import { testbed, type Testbed } from "../ecs/testbed.ts";
9
+ import type { Effect } from "../ecs/state.ts";
10
+ import type { Line, Words } from "../test/steps.ts";
11
+ import { adaptersOf, isPlayed, type Adapter } from "./adapters.ts";
12
+ import { execute, runNow, linesOf, type Progress, type RunResult } from "./run.ts";
13
+
14
+ export { ok, fail, wait, after, sequence, match, model, custom, recorded, listing, events, adaptersOf, isPlayed, type Adapter, type Answer, type Reply, type AdapterContext, type RequestAdapter, type SourceAdapter } from "./adapters.ts";
15
+ export { execute, runNow, linesOf, deepEqual, type Progress, type RunResult } from "./run.ts";
16
+ export { asProgram, wordsOf, type Expectation, type Note, type TakeStep } from "./take.ts";
17
+ export { diffStates, type Change, type ResourceChange } from "./diff.ts";
18
+ export type { Line, Words } from "../test/steps.ts";
19
+ export { assembleSpec, type SpecFiles } from "./files.ts";
20
+
21
+ export type Params = Record<string, unknown>;
22
+
23
+ /** A scenario of a spec: what must hold, as a script of steps and checks, from the spec's setup. */
24
+ export interface SpecScenario<V = any, P extends Params = Params> {
25
+ name: string;
26
+ /** What should hold, as a person would say it. */
27
+ intent: string;
28
+ /** Knobs with defaults, on top of the spec's. */
29
+ params?: P;
30
+ script: (s: Words<Testbed<V>>, params: P) => Line<Testbed<V>>[];
31
+ }
32
+
33
+ /** How a spec is made, beside the system (or the baba) it is the spec of. */
34
+ export interface SpecDef<V extends Baba = Baba, P extends Params = Params> {
35
+ /** The spec's name: the system's own, or "baba" for the whole baba, by default. */
36
+ name?: string;
37
+ /** What the spec says it is for. */
38
+ summary?: string;
39
+ /** Specs of the systems it reads: their adapters, setup and params come along, so their outside is played here too. */
40
+ also?: readonly Spec<any, any>[];
41
+ /** Rounds on their own, every so often, so a spec moves while a person watches; the baba's own tick by default. */
42
+ tick?: number | null;
43
+ /** The spec's first time and random seed. */
44
+ now?: number;
45
+ seed?: number;
46
+ /** Knobs with defaults: given to the setup and every scenario; the Maker lets a person nudge them. */
47
+ params?: P;
48
+ /** The script from an empty spec to the spec's initial state, after the setups of `also`. */
49
+ setup?: (s: Words<Testbed<V>>, params: P) => Line<Testbed<V>>[];
50
+ /** The outside: an adapter per effect, over those of `also`. An effect with none is quiet: a mirror lists nothing, a stream sends nothing, a request stays pending. */
51
+ adapters?: readonly (readonly [Effect, Adapter])[];
52
+ scenarios?: SpecScenario<V, P>[];
53
+ }
54
+
55
+ export interface Spec<V extends Baba = Baba, P extends Params = Params> {
56
+ kind: "spec";
57
+ name: string;
58
+ summary: string;
59
+ baba: V;
60
+ params: P;
61
+ tick: number | null;
62
+ now: number;
63
+ seed: number;
64
+ setup: (s: Words<Testbed<V>>, params: P) => Line<Testbed<V>>[];
65
+ adapters: readonly (readonly [Effect, Adapter])[];
66
+ scenarios: SpecScenario<V, P>[];
67
+ }
68
+
69
+ const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
70
+
71
+ type ReadsOf<S> = S extends { readonly [SYSTEM]: { readonly $types?: { readonly reads: infer R extends readonly System[] } } } ? R : readonly [];
72
+ /** The spec of a system, as a baba: the system and the systems it reads. */
73
+ export type SpecBaba<S extends System> = Baba<Closure<S>>;
74
+ /** A system and what it reads, and what those read: the spec's systems, as types. Reads form no cycle, so this ends. */
75
+ type Closure<S> = readonly [S extends System ? S : never, ...ClosureOf<ReadsOf<S>>];
76
+ type ClosureOf<R> = R extends readonly [infer H, ...infer T] ? readonly [...Closure<H>, ...ClosureOf<T>] : readonly [];
77
+
78
+ /**
79
+ * A spec: a definition, not a run. Of a system, beside it in its
80
+ * directory, `systems/pond/spec.ts`: the spec is the system and every system
81
+ * it reads; or of a whole baba, `.baba/spec.ts`. Pure: `.baba/spec.test.ts`
82
+ * proves it under bun test, the Maker runs it live.
83
+ */
84
+ // The baba first: a system typed loosely is any, and would take a baba for itself.
85
+ export function spec<V extends Baba, P extends Params = Params>(baba: V, def?: SpecDef<V, P>): Spec<V, P>;
86
+ export function spec<const S extends System, P extends Params = Params>(system: S, def?: SpecDef<SpecBaba<S>, P>): Spec<SpecBaba<S>, P>;
87
+ export function spec(target: System | Baba, def: SpecDef<any, any> = {}): Spec<any, any> {
88
+ const whole = isBaba(target);
89
+ if (!whole && !isSystem(target)) throw new Error("spec: the first argument is the system the spec is of, or the baba");
90
+ const name = def.name ?? (whole ? "baba" : nameOf(target));
91
+ if (!NAME.test(name)) throw new Error(`spec ${JSON.stringify(name)}: lowercase words joined by single dashes`);
92
+ const base = whole ? (target as Baba) : baba({ systems: closureOf([target as System]) });
93
+ const tick = def.tick === undefined ? base.tick : def.tick;
94
+ const v = tick === base.tick ? base : baba({ systems: base.systems, ...(tick !== null ? { tick } : {}), promptware: base.promptware, apps: base.apps });
95
+ const names = new Set<string>();
96
+ for (const sc of def.scenarios ?? []) { if (names.has(sc.name)) throw new Error(`spec ${name}: two scenarios are named ${JSON.stringify(sc.name)}`); names.add(sc.name); }
97
+ const also = def.also ?? [];
98
+ for (const o of also) if (!isSpec(o)) throw new Error(`spec ${name}: also takes specs`);
99
+ // The outside: also's adapters first, so the spec's own, for the same effect, win.
100
+ const adapters = [...also.flatMap((o) => o.adapters), ...(def.adapters ?? [])];
101
+ const setup = (w: Words<any>, params: Params) => [...also.flatMap((o) => o.setup(w, params)), ...(def.setup?.(w, params) ?? [])];
102
+ const params = Object.assign({}, ...also.map((o) => o.params), def.params ?? {});
103
+ return { kind: "spec", name, summary: def.summary ?? "", baba: v, params, tick, now: def.now ?? 0, seed: def.seed ?? 1, setup, adapters, scenarios: def.scenarios ?? [] };
104
+ }
105
+
106
+ /** A scenario in a file of its own, `systems/pond/scenarios/NAME.ts`: the spec of the system whose directory it is in takes it. Its testbed is untyped; a scenario written in the spec is typed by its spec. */
107
+ export function scenario<P extends Params = Params>(def: SpecScenario<any, P>): SpecScenario<any, P> {
108
+ if (!def.name.trim()) throw new Error("scenario: a name");
109
+ return def;
110
+ }
111
+
112
+ /** The spec with scenarios added: those saved in files of their own beside the spec. */
113
+ export function withScenarios<V extends Baba, P extends Params>(sp: Spec<V, P>, more: readonly SpecScenario<any, any>[]): Spec<V, P> {
114
+ const names = new Set(sp.scenarios.map((x) => x.name));
115
+ for (const sc of more) { if (names.has(sc.name)) throw new Error(`spec ${sp.name}: two scenarios are named ${JSON.stringify(sc.name)}`); names.add(sc.name); }
116
+ return { ...sp, scenarios: [...sp.scenarios, ...more] };
117
+ }
118
+
119
+ export const isSpec = (x: unknown): x is Spec => typeof x === "object" && x !== null && (x as { kind?: unknown }).kind === "spec";
120
+
121
+ /** A fresh spec of the spec: its baba on a virtual clock, its adapters, seeded by its setup with `params` over the defaults. */
122
+ export function seeded<V extends Baba, P extends Params>(sp: Spec<V, P>, o: { params?: Partial<P>; log?: (line: string) => void; each?: (p: Progress) => void } = {}): { t: Testbed<V>; params: P; setup: RunResult } {
123
+ const params = { ...sp.params, ...(o.params ?? {}) } as P;
124
+ const t = testbed(sp.baba, { now: sp.now, seed: sp.seed, adapters: adaptersOf(sp.adapters), ...(o.log ? { log: o.log } : {}) });
125
+ const setup = runNow(linesOf(sp.setup, params), t, o.each);
126
+ return { t, params, setup };
127
+ }
128
+
129
+ /** A scenario's steps for a seeded spec. */
130
+ export function scenarioLines<V extends Baba, P extends Params>(sp: Spec<V, P>, sc: SpecScenario<V, P>, params: P): Line<Testbed<V>>[] {
131
+ return linesOf(sc.script, { ...params, ...(sc.params ?? {}) } as P);
132
+ }
133
+
134
+ /** The effects the spec left quiet that a job asked of during a run: what an adapter would have to play. For the agent. */
135
+ export function quietOf<V extends Baba>(sp: Spec<V, any>, t: Testbed<V>): string[] {
136
+ const names = new Set<string>();
137
+ for (const j of t.jobs) if (!isPlayed(sp.adapters, j.effect)) names.add(j.effect.name);
138
+ return [...names].sort();
139
+ }
140
+
141
+ /** One scenario, headless, from the seed: the canonical run, with the quiet effects its jobs touched. */
142
+ export function runScenario<V extends Baba, P extends Params>(sp: Spec<V, P>, sc: SpecScenario<V, P>, o: { params?: Partial<P>; each?: (p: Progress) => void } = {}): RunResult & { setup: RunResult; quiet: string[] } {
143
+ const { t, params, setup } = seeded(sp, o);
144
+ if (setup.verdict === "failed") return { ...setup, setup, verdict: "failed", quiet: quietOf(sp, t) };
145
+ const r = runNow(scenarioLines(sp, sc, params), t, o.each);
146
+ return { ...r, setup, quiet: quietOf(sp, t) };
147
+ }
148
+
149
+ /** A live run's driver: a generator the caller advances, ticking the testbed itself when told to. */
150
+ export function liveRun<V extends Baba, P extends Params>(sp: Spec<V, P>, sc: SpecScenario<V, P>, t: Testbed<V>, params: P): Generator<Progress, RunResult, void> {
151
+ return execute(scenarioLines(sp, sc, params), t);
152
+ }
153
+
154
+ /** A baba's name for the verdict files: its own, or "baba". */
155
+ export const babaOf = (x: unknown): x is Baba => isBaba(x);