babavoss 0.0.1 → 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.
- package/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
package/src/spec/run.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// One executor for both runners. A program (a seed, a scenario) is a list of
|
|
2
|
+
// steps; `execute` walks them against a testbed as a generator: every step
|
|
3
|
+
// that needs no time runs at once and is reported; every step that moves
|
|
4
|
+
// the clock is yielded, so the driver decides how: the headless runner ticks
|
|
5
|
+
// at once, the live runner waits the scaled real time first. The state lands
|
|
6
|
+
// the same either way, because the testbed's clock is the only clock.
|
|
7
|
+
import type { Testbed } from "../ecs/testbed.ts";
|
|
8
|
+
import type { Effect } from "../ecs/state.ts";
|
|
9
|
+
import { resolveAnswers, spawnParts, words, type Line, type Words } from "../test/steps.ts";
|
|
10
|
+
import type { StepRecord, CheckRecord } from "../test/records.ts";
|
|
11
|
+
|
|
12
|
+
const TICK = 200;
|
|
13
|
+
|
|
14
|
+
/** What a run reports as it goes: a step done (with its error, if it threw), a check made, or a moment to reach before going on. */
|
|
15
|
+
export type Progress =
|
|
16
|
+
| { kind: "step"; rec: StepRecord; index: number }
|
|
17
|
+
| { kind: "check"; rec: CheckRecord; index: number }
|
|
18
|
+
| { kind: "tick"; to: number; index: number };
|
|
19
|
+
|
|
20
|
+
export interface RunResult { verdict: "passed" | "failed"; steps: StepRecord[]; checks: CheckRecord[]; /** The first step that threw, or null: the run stopped there. */ broke: number | null }
|
|
21
|
+
|
|
22
|
+
/** A program's steps, from its `steps` function, with `params`. */
|
|
23
|
+
export function linesOf<T extends Testbed<any>, P extends Record<string, unknown>>(program: (s: Words<T>, params: P) => Line<T>[], params: P): Line<T>[] {
|
|
24
|
+
return program(words as Words<T>, params);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Executes `steps` against `t`. Yields a tick to the driver, which must
|
|
29
|
+
* `t.tick({ now: to })` (after waiting, if it waits) before resuming; a
|
|
30
|
+
* failed check is reported and the run goes on; a step that throws is
|
|
31
|
+
* reported and the run stops. Returns the result.
|
|
32
|
+
*/
|
|
33
|
+
export function* execute<T extends Testbed<any>>(steps: Line<T>[], t: T, o: { from?: number; answers?: unknown[] } = {}): Generator<Progress, RunResult, void> {
|
|
34
|
+
let now = o.from ?? t.now;
|
|
35
|
+
const answers: unknown[] = o.answers ?? [];
|
|
36
|
+
const done: StepRecord[] = [];
|
|
37
|
+
const checks: CheckRecord[] = [];
|
|
38
|
+
let broke: number | null = null;
|
|
39
|
+
const attempt = (i: number, rec: StepRecord, f: () => void): Progress => { try { f(); } catch (e) { rec.error = msg(e); broke = i; } done.push(rec); return { kind: "step", rec, index: i }; };
|
|
40
|
+
for (let i = 0; i < steps.length; i++) {
|
|
41
|
+
const step = steps[i]!;
|
|
42
|
+
switch (step.kind) {
|
|
43
|
+
case "given": {
|
|
44
|
+
const rec: StepRecord = { kind: "given", label: step.label, ...(typeof step.run === "function" ? { replay: false } : { args: step.run }) };
|
|
45
|
+
yield attempt(i, rec, () => { if (typeof step.run === "function") step.run(t); else spawnParts(t, step.run); });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "fire": {
|
|
49
|
+
const rec: StepRecord = { kind: "fire", label: step.name, args: step.args };
|
|
50
|
+
const at = answers.push(undefined) - 1;
|
|
51
|
+
yield attempt(i, rec, () => { rec.args = resolveAnswers(step.args, answers.slice(0, at)); rec.result = (t.fire as unknown as (n: string, a: unknown) => unknown)(step.name, rec.args); answers[at] = rec.result; });
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "tick": { now = step.at ?? now + (step.after ?? TICK); yield { kind: "tick", to: now, index: i }; done.push({ kind: "tick", label: `+${fmt(now)}` }); break; }
|
|
55
|
+
case "ticks": { for (let k = 0; k < step.count; k++) { now += step.every; yield { kind: "tick", to: now, index: i }; done.push({ kind: "tick", label: `+${fmt(now)}` }); } break; }
|
|
56
|
+
case "done": yield attempt(i, { kind: "done", label: step.effect.name, args: step.where, result: step.result }, () => (t.done as (e: Effect, r: unknown, w?: unknown) => void)(step.effect, step.result, step.where)); break;
|
|
57
|
+
case "failed": yield attempt(i, { kind: "failed", label: step.effect.name, args: step.where, result: { error: step.error } }, () => (t.failed as (e: Effect, r: string, w?: unknown) => void)(step.effect, step.error, step.where)); break;
|
|
58
|
+
case "list": yield attempt(i, { kind: "list", label: step.effect.name, args: step.where, result: step.items }, () => (t.list as (e: Effect, i: unknown[], w?: unknown) => void)(step.effect, step.items, step.where)); break;
|
|
59
|
+
case "watch": yield attempt(i, { kind: "watch", label: step.effect.name, args: step.where, result: step.events }, () => (t.watch as (e: Effect, i: unknown[], w?: unknown) => void)(step.effect, step.events, step.where)); break;
|
|
60
|
+
case "check": {
|
|
61
|
+
const c: CheckRecord = { label: step.label, ok: false, compare: step.compare };
|
|
62
|
+
try {
|
|
63
|
+
const got = step.read(t);
|
|
64
|
+
if (step.compare) { c.got = got; c.want = step.want; c.ok = deepEqual(got, step.want); } else c.ok = got === true;
|
|
65
|
+
} catch (e) { c.error = msg(e); }
|
|
66
|
+
checks.push(c);
|
|
67
|
+
yield { kind: "check", rec: c, index: i };
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (broke !== null) break;
|
|
72
|
+
}
|
|
73
|
+
return { verdict: broke !== null || checks.some((c) => !c.ok) ? "failed" : "passed", steps: done, checks, broke };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Runs a program to its end at once: the headless driver. */
|
|
77
|
+
export function runNow<T extends Testbed<any>>(steps: Line<T>[], t: T, each?: (p: Progress) => void): RunResult {
|
|
78
|
+
const g = execute(steps, t);
|
|
79
|
+
for (;;) {
|
|
80
|
+
const r = g.next();
|
|
81
|
+
if (r.done) return r.value;
|
|
82
|
+
if (r.value.kind === "tick") t.tick({ now: r.value.to });
|
|
83
|
+
each?.(r.value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const msg = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
88
|
+
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(ms % 1000 ? 1 : 0)} s` : `${ms} ms`);
|
|
89
|
+
/** Deep equality by structure, browser-safe. */
|
|
90
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
91
|
+
if (a === b) return true;
|
|
92
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
|
|
93
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
94
|
+
const ka = Object.keys(a as object), kb = Object.keys(b as object);
|
|
95
|
+
if (ka.length !== kb.length) return false;
|
|
96
|
+
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
97
|
+
}
|
package/src/spec/take.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A take: what a person did in the Maker, as a scenario file of the spec.
|
|
2
|
+
// The steps as played, in the program's words; the expectations they added
|
|
3
|
+
// by pointing at an object, as checks on that entity's parts; the effects
|
|
4
|
+
// they answered by hand, imported from their systems. Runs as written.
|
|
5
|
+
import type { StepRecord } from "../test/records.ts";
|
|
6
|
+
|
|
7
|
+
/** One step of a take: the record, and for an answered job the effect's system, so the file can import it. */
|
|
8
|
+
export interface TakeStep { rec: StepRecord; owner?: string }
|
|
9
|
+
/** An expectation added in play: the entity as it was, by its parts, at this point of the take. */
|
|
10
|
+
export interface Expectation { at: number; label: string; entity: number; parts: Record<string, unknown> }
|
|
11
|
+
/** A note added in play: the person's words on an entity, or on the spec, at this point of the take. */
|
|
12
|
+
export interface Note { at: number; entity: number | null; text: string }
|
|
13
|
+
|
|
14
|
+
const json = (v: unknown) => JSON.stringify(v);
|
|
15
|
+
|
|
16
|
+
/** A step in plain words, for a person reading the take. */
|
|
17
|
+
export function wordsOf(rec: StepRecord): string {
|
|
18
|
+
switch (rec.kind) {
|
|
19
|
+
case "fire": return `${rec.label} ${Object.keys((rec.args as object) ?? {}).length ? json(rec.args) : ""}${rec.error ? ` → failed: ${rec.error}` : ""}`.trim();
|
|
20
|
+
case "tick": return rec.label === "a round" ? "a round" : `time passes ${rec.label}`;
|
|
21
|
+
case "done": return `${rec.label} answered ${json(rec.result)}`;
|
|
22
|
+
case "failed": return `${rec.label} failed: ${json((rec.result as { error?: unknown })?.error ?? "")}`;
|
|
23
|
+
case "given": return `given ${rec.label}`;
|
|
24
|
+
case "list": return `${rec.label} listed ${json(rec.result)}`;
|
|
25
|
+
case "watch": return `${rec.label} sent ${json(rec.result)}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The take as a scenario file for `spec`, saved in its scenarios directory beside its spec, with `systems` the import path of each system by name, relative to that directory. */
|
|
30
|
+
export function asProgram(o: { spec: string; name: string; intent: string; steps: TakeStep[]; expectations: Expectation[]; notes?: Note[]; systems: Record<string, string> }): string {
|
|
31
|
+
const owners = new Set<string>();
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
const expectAt = (i: number) => {
|
|
34
|
+
for (const n of (o.notes ?? []).filter((n) => n.at === i)) lines.push(` // ${n.entity !== null ? `on #${n.entity}: ` : ""}${n.text.replace(/\n/g, " ")}`);
|
|
35
|
+
for (const e of o.expectations.filter((e) => e.at === i)) lines.push(` check(${json(e.label)}, (t) => t.w.of(${e.entity}), ${json(e.parts)}),`);
|
|
36
|
+
};
|
|
37
|
+
o.steps.forEach(({ rec, owner }, i) => {
|
|
38
|
+
expectAt(i);
|
|
39
|
+
const where = rec.args && typeof rec.args === "object" && "entity" in (rec.args as object) ? `, { entity: ${json((rec.args as { entity: unknown }).entity)} }` : "";
|
|
40
|
+
const ref = owner ? (owners.add(owner), `${ident(owner)}.${rec.label}`) : `/* ${rec.label} */ undefined as never`;
|
|
41
|
+
if (rec.kind === "fire") lines.push(` fire(${json(rec.label)}, ${json(rec.args)}),`);
|
|
42
|
+
else if (rec.kind === "tick") lines.push(` tick(${rec.args !== undefined ? json(rec.args) : ""}),`);
|
|
43
|
+
else if (rec.kind === "done") lines.push(` done(${ref}, ${json(rec.result)}${where}),`);
|
|
44
|
+
else if (rec.kind === "failed") lines.push(` failed(${ref}, ${json((rec.result as { error?: unknown })?.error ?? "")}${where}),`);
|
|
45
|
+
else if (rec.kind === "list") lines.push(` list(${ref}, ${json(rec.result)}${where}),`);
|
|
46
|
+
else if (rec.kind === "watch") lines.push(` watch(${ref}, ${json(rec.result)}${where}),`);
|
|
47
|
+
else if (rec.kind === "given" && rec.args !== undefined) lines.push(` given(${json(rec.label)}, ${json(rec.args)}),`);
|
|
48
|
+
});
|
|
49
|
+
expectAt(o.steps.length);
|
|
50
|
+
const imports = [...owners].map((n) => `import ${ident(n)} from ${json(o.systems[n] ?? `../../${n}/index.ts`)};`);
|
|
51
|
+
return [`import { scenario } from "babavoss/spec";`, ...imports, "", `// A take from the Maker on the spec ${o.spec}: the steps as played, the expectations as pointed at.`, "export default scenario({", ` name: ${json(o.name)},`, ` intent: ${json(o.intent)},`, " script: ({ fire, tick, done, failed, list, watch, given, check }) => [", ...lines, " ],", "});", ""].join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ident = (name: string) => name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()).replace(/^[^a-zA-Z_$]/, "_");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// babavoss/test: the testbed, and the runner that proves a baba's specs
|
|
2
|
+
// under bun test. `.baba/spec.test.ts` is one call: `await proveAll(import.meta.dir)`.
|
|
3
|
+
export { testbed, type Testbed } from "../ecs/testbed.ts";
|
|
4
|
+
export { spawnParts, answer, resolveAnswers, type Line, type Words, type ScenarioDef } from "./steps.ts";
|
|
5
|
+
export { vossDir } from "./voss-dir.ts";
|
|
6
|
+
export { prove, proveAll, verdictsDir, type Verdicts, type ScenarioVerdict } from "./prove.ts";
|
|
7
|
+
export { specFiles, specsIn, type SpecFiles } from "./specs.ts";
|
|
8
|
+
export type { StepRecord, CheckRecord } from "./records.ts";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The headless runner of specs: under bun test, every scenario of a
|
|
2
|
+
// spec runs from its setup, and the verdicts land under
|
|
3
|
+
// .baba/.voss/spec/SPEC/verdicts.json, where the maker system shows them. A
|
|
4
|
+
// run writes its record first, so a scenario missing from a finished run is
|
|
5
|
+
// unknown, never green; one file per spec, written whole.
|
|
6
|
+
import { test } from "bun:test";
|
|
7
|
+
import { mkdir } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { vossDir } from "./voss-dir.ts";
|
|
10
|
+
import { runScenario, type Spec } from "../spec/index.ts";
|
|
11
|
+
import type { CheckRecord, StepRecord } from "./records.ts";
|
|
12
|
+
import { specsIn } from "./specs.ts";
|
|
13
|
+
|
|
14
|
+
export interface ScenarioVerdict { verdict: "passed" | "failed" | "unknown"; intent: string; /** The step that threw, by index, or null. */ broke: number | null; steps: number; checks: number; failed: string[]; /** The effects the spec left quiet that jobs asked of: what an adapter would have to play. */ quiet: string[]; at: string }
|
|
15
|
+
export interface Verdicts { spec: string; run: string; at: string; done: boolean; scenarios: Record<string, ScenarioVerdict> }
|
|
16
|
+
|
|
17
|
+
/** Where a spec's verdicts go: under the project's .baba/.voss. */
|
|
18
|
+
export const verdictsDir = (from?: string) => join(vossDir(from), "spec");
|
|
19
|
+
|
|
20
|
+
/** Registers every scenario of `sp` with bun test and keeps the spec's verdicts current as they run, under `state` (the project's .baba/.voss/spec by default). */
|
|
21
|
+
export function prove(sp: Spec, state = verdictsDir()): void {
|
|
22
|
+
const file = join(state, sp.name, "verdicts.json");
|
|
23
|
+
const run = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
24
|
+
const kept: Verdicts = { spec: sp.name, run, at: new Date().toISOString(), done: false, scenarios: Object.fromEntries(sp.scenarios.map((sc) => [sc.name, { verdict: "unknown", intent: sc.intent, broke: null, steps: 0, checks: 0, failed: [], quiet: [], at: "" }])) };
|
|
25
|
+
let wrote: Promise<void> = Promise.resolve();
|
|
26
|
+
const write = () => { wrote = wrote.then(async () => { await mkdir(dirname(file), { recursive: true }); await Bun.write(file, JSON.stringify(kept, null, 2)); }); return wrote; };
|
|
27
|
+
let left = sp.scenarios.length;
|
|
28
|
+
if (left === 0) { void write(); return; }
|
|
29
|
+
for (const sc of sp.scenarios) {
|
|
30
|
+
test(`${sp.name}: ${sc.name}`, async () => {
|
|
31
|
+
if (!kept.at) kept.at = new Date().toISOString();
|
|
32
|
+
const r = runScenario(sp, sc);
|
|
33
|
+
const failed = [...r.setup.steps, ...r.steps].filter((s) => s.error).map((s) => `${s.kind} ${s.label}: ${s.error}`).concat(r.checks.filter((c) => !c.ok).map(describe));
|
|
34
|
+
kept.scenarios[sc.name] = { verdict: r.verdict, intent: sc.intent, broke: r.broke, steps: r.steps.length, checks: r.checks.length, failed, quiet: r.quiet, at: new Date().toISOString() };
|
|
35
|
+
if (--left === 0) kept.done = true;
|
|
36
|
+
await write();
|
|
37
|
+
if (r.verdict === "failed") throw new Error(`${sc.intent}\n ${failed.join("\n ")}${r.quiet.length ? `\n quiet: ${r.quiet.join(", ")} (no adapter plays them)` : ""}`);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every spec of the baba at `baba`, its .baba directory: each system's
|
|
44
|
+
* `systems/NAME/spec.ts`, the whole baba's `spec.ts`, and the scenarios saved
|
|
45
|
+
* beside them. The one call in `.baba/spec.test.ts`:
|
|
46
|
+
* `await proveAll(import.meta.dir)`.
|
|
47
|
+
*/
|
|
48
|
+
export async function proveAll(baba: string): Promise<void> {
|
|
49
|
+
const specs = await specsIn(baba);
|
|
50
|
+
const names = new Set<string>();
|
|
51
|
+
for (const sp of specs) { if (names.has(sp.name)) throw new Error(`two specs are named ${sp.name}`); names.add(sp.name); }
|
|
52
|
+
for (const sp of specs) prove(sp, join(baba, ".voss", "spec"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const describe = (c: CheckRecord) => `${c.label}${c.error ? ` (${c.error})` : c.compare ? `: got ${JSON.stringify(c.got)}, want ${JSON.stringify(c.want)}` : ""}`;
|
|
56
|
+
export type { StepRecord };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What a run of a script records, line by line: each line done (with its
|
|
2
|
+
// error, if it threw) and each check made. Pure types, browser-safe: the
|
|
3
|
+
// headless runner keeps them for the verdicts, the Maker shows them live.
|
|
4
|
+
|
|
5
|
+
export interface StepRecord {
|
|
6
|
+
kind: "given" | "fire" | "tick" | "done" | "failed" | "list" | "watch";
|
|
7
|
+
label: string;
|
|
8
|
+
args?: unknown;
|
|
9
|
+
result?: unknown;
|
|
10
|
+
error?: string;
|
|
11
|
+
/** False for a given done by a function: the page cannot replay it. */
|
|
12
|
+
replay?: false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CheckRecord {
|
|
16
|
+
label: string;
|
|
17
|
+
ok: boolean;
|
|
18
|
+
/** Whether `got` was compared to `want`, or taken as a truth. */
|
|
19
|
+
compare?: boolean;
|
|
20
|
+
got?: unknown;
|
|
21
|
+
want?: unknown;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The specs of a baba, found where they live: beside each system,
|
|
2
|
+
// `systems/NAME/spec.ts`, and for the whole baba `spec.ts` beside the entry.
|
|
3
|
+
// A scenario saved in a file of its own, `systems/NAME/scenarios/*.ts` (or
|
|
4
|
+
// `scenarios/*.ts` for the whole baba), joins the spec of the spec beside
|
|
5
|
+
// it. The views bundler lists the same files for the Maker's page, as the
|
|
6
|
+
// module "babavoss:specs".
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { assembleSpec, type SpecFiles } from "../spec/files.ts";
|
|
10
|
+
import type { Spec } from "../spec/index.ts";
|
|
11
|
+
|
|
12
|
+
export type { SpecFiles } from "../spec/files.ts";
|
|
13
|
+
|
|
14
|
+
const isDir = (p: string) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
15
|
+
const scenarioFiles = (baba: string, dir: string) => {
|
|
16
|
+
const d = join(baba, dir, "scenarios");
|
|
17
|
+
return isDir(d) ? readdirSync(d).filter((f) => /\.tsx?$/.test(f) && !/\.test\.tsx?$/.test(f)).sort().map((f) => join(dir, "scenarios", f)) : [];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The files of every spec of the baba at `baba` (its .baba directory): the systems' in name order, then the whole baba's. */
|
|
21
|
+
export function specFiles(baba: string): SpecFiles[] {
|
|
22
|
+
const out: SpecFiles[] = [];
|
|
23
|
+
const systems = join(baba, "systems");
|
|
24
|
+
if (isDir(systems)) {
|
|
25
|
+
for (const name of readdirSync(systems).sort()) {
|
|
26
|
+
const dir = join("systems", name);
|
|
27
|
+
if (existsSync(join(baba, dir, "spec.ts"))) out.push({ dir, spec: join(dir, "spec.ts"), scenarios: scenarioFiles(baba, dir) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(join(baba, "spec.ts"))) out.push({ dir: "", spec: "spec.ts", scenarios: scenarioFiles(baba, "") });
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every spec of the baba at `baba`, imported. */
|
|
35
|
+
export async function specsIn(baba: string): Promise<Spec<any, any>[]> {
|
|
36
|
+
const out: Spec<any, any>[] = [];
|
|
37
|
+
for (const f of specFiles(baba)) {
|
|
38
|
+
const spec = (await import(join(baba, f.spec))).default;
|
|
39
|
+
const scenarios = await Promise.all(f.scenarios.map(async (s) => (await import(join(baba, s))).default));
|
|
40
|
+
out.push(assembleSpec(f, spec, scenarios));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The source of "babavoss:specs" for the baba at `baba`: every spec, imported and assembled, as the default export. */
|
|
46
|
+
export function specsModule(baba: string): string {
|
|
47
|
+
const files = specFiles(baba);
|
|
48
|
+
const imports: string[] = [`import { assembleSpec } from "babavoss/spec";`];
|
|
49
|
+
const specs: string[] = [];
|
|
50
|
+
files.forEach((f, i) => {
|
|
51
|
+
imports.push(`import w${i} from ${JSON.stringify(join(baba, f.spec))};`);
|
|
52
|
+
f.scenarios.forEach((s, j) => imports.push(`import w${i}s${j} from ${JSON.stringify(join(baba, s))};`));
|
|
53
|
+
specs.push(`assembleSpec(${JSON.stringify(f)}, w${i}, [${f.scenarios.map((_, j) => `w${i}s${j}`).join(", ")}])`);
|
|
54
|
+
});
|
|
55
|
+
return `${imports.join("\n")}\nexport default [${specs.join(", ")}];\n`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The words of a program, browser-safe: the step types, the words that make
|
|
2
|
+
// them, the answer references, and a given from parts. A scenario file and
|
|
3
|
+
// the page's live runner both speak them; the bun runner is elsewhere.
|
|
4
|
+
import type { Testbed } from "../ecs/testbed.ts";
|
|
5
|
+
import type { Effect } from "../ecs/state.ts";
|
|
6
|
+
|
|
7
|
+
/** One line of a script: a given, a fire, a tick, an answer from the outside, or a check. */
|
|
8
|
+
export type Line<T> =
|
|
9
|
+
| { kind: "given"; label: string; run: ((t: T) => void) | Record<string, unknown> }
|
|
10
|
+
| { kind: "fire"; name: string; args: unknown }
|
|
11
|
+
| { kind: "tick"; after?: number; at?: number }
|
|
12
|
+
| { kind: "ticks"; count: number; every: number }
|
|
13
|
+
| { kind: "done"; effect: Effect; result: unknown; where?: unknown }
|
|
14
|
+
| { kind: "failed"; effect: Effect; error: string; where?: unknown }
|
|
15
|
+
| { kind: "list"; effect: Effect; items: unknown[]; where?: unknown }
|
|
16
|
+
| { kind: "watch"; effect: Effect; events: unknown[]; where?: unknown }
|
|
17
|
+
| { kind: "check"; label: string; read: (t: T) => unknown; want?: unknown; compare: boolean };
|
|
18
|
+
|
|
19
|
+
/** The words of a scenario, typed by the system under test. */
|
|
20
|
+
export interface Words<T extends Testbed<any>> {
|
|
21
|
+
/** A precondition the contract cannot express. Parts by component name spawn one entity carrying them: the form the page can replay. A function does anything, and cannot be replayed. */
|
|
22
|
+
given(label: string, run: ((t: T) => void) | Record<string, unknown>): Line<T>;
|
|
23
|
+
/** An action, run now as its system. Its args may name an earlier fire's answer: \`answer(0, "entity")\`, or the string \`"$0.entity"\`, the first fire's entity. */
|
|
24
|
+
fire(...a: Parameters<T["fire"]>): Line<T>;
|
|
25
|
+
/** An earlier fire's answer, or a field of it by a dotted path, to put in a later fire's args: \`fire("note-remove", { note: answer(0, "entity") })\`. Fires count from 0, in the order they are listed. */
|
|
26
|
+
answer(index: number, path?: string): any;
|
|
27
|
+
/** One round, `after` ms later (200 by default), or `at` a time. */
|
|
28
|
+
tick(o?: { after?: number; at?: number }): Line<T>;
|
|
29
|
+
/** `count` rounds, `every` ms apart. */
|
|
30
|
+
ticks(count: number, every: number): Line<T>;
|
|
31
|
+
/** An effect's job answered, landing at the next tick. */
|
|
32
|
+
done(...a: Parameters<T["done"]>): Line<T>;
|
|
33
|
+
failed(...a: Parameters<T["failed"]>): Line<T>;
|
|
34
|
+
/** A source's listing, or the events its watch yields, landing at the next tick. */
|
|
35
|
+
list(...a: Parameters<T["list"]>): Line<T>;
|
|
36
|
+
watch(...a: Parameters<T["watch"]>): Line<T>;
|
|
37
|
+
/** A claim about the state now: with `want`, what `read` gives must deep-equal it; without, it must be true. */
|
|
38
|
+
check(label: string, read: (t: T) => unknown, ...want: [unknown?]): Line<T>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ScenarioDef<T extends Testbed<any>, P extends Record<string, unknown> = Record<string, never>> {
|
|
42
|
+
name: string;
|
|
43
|
+
/** What should hold, as a person would say it: the title of the proof. */
|
|
44
|
+
intent: string;
|
|
45
|
+
/** The time of the first round. */
|
|
46
|
+
now?: number;
|
|
47
|
+
/** The seed of `w.random()`; 1 by default, so the run is the same every time. */
|
|
48
|
+
seed?: number;
|
|
49
|
+
/** Knobs with their defaults: the test runs at the defaults, the page lets a person nudge them. */
|
|
50
|
+
params?: P;
|
|
51
|
+
script: (s: Words<T>, params: P) => Line<T>[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const words: Words<any> = {
|
|
55
|
+
given: (label, run) => ({ kind: "given", label, run }),
|
|
56
|
+
fire: (name: string, args?: unknown) => ({ kind: "fire", name, args: args ?? {} }),
|
|
57
|
+
tick: (o = {}) => ({ kind: "tick", ...o }),
|
|
58
|
+
ticks: (count, every) => ({ kind: "ticks", count, every }),
|
|
59
|
+
done: (effect: Effect, result: unknown, where?: unknown) => ({ kind: "done", effect, result, where }),
|
|
60
|
+
failed: (effect: Effect, error: string, where?: unknown) => ({ kind: "failed", effect, error, where }),
|
|
61
|
+
list: (effect: Effect, items: unknown[], where?: unknown) => ({ kind: "list", effect, items, where }),
|
|
62
|
+
watch: (effect: Effect, events: unknown[], where?: unknown) => ({ kind: "watch", effect, events, where }),
|
|
63
|
+
check: (label, read, ...want) => ({ kind: "check", label, read, want: want[0], compare: want.length > 0 }),
|
|
64
|
+
answer: (index: number, path?: string) => `$${index}${path ? `.${path}` : ""}`,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** An earlier fire's answer, for a later fire's args, outside a step list: the same as the \`answer\` a step list destructures. */
|
|
68
|
+
export const answer = (index: number, path?: string): any => words.answer(index, path);
|
|
69
|
+
|
|
70
|
+
const REF = /^\$(\d+)((?:\.[A-Za-z0-9_]+)*)$/;
|
|
71
|
+
/** Args with every "$N.path" string replaced by the Nth fire's answer at that path; an answer that is not there is an error, as the CLI's run says. */
|
|
72
|
+
export function resolveAnswers(args: unknown, answers: readonly unknown[]): unknown {
|
|
73
|
+
if (typeof args === "string") {
|
|
74
|
+
const m = REF.exec(args);
|
|
75
|
+
if (!m) return args;
|
|
76
|
+
const i = Number(m[1]);
|
|
77
|
+
if (i >= answers.length) throw new Error(`${args}: fire ${i} has not run yet; ${answers.length} fire${answers.length === 1 ? " has" : "s have"}`);
|
|
78
|
+
let v: unknown = answers[i];
|
|
79
|
+
for (const key of m[2]!.split(".").filter(Boolean)) {
|
|
80
|
+
if (v === null || typeof v !== "object" || !(key in (v as object))) throw new Error(`${args}: fire ${i}'s answer has no ${key}`);
|
|
81
|
+
v = (v as Record<string, unknown>)[key];
|
|
82
|
+
}
|
|
83
|
+
return v;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(args)) return args.map((a) => resolveAnswers(a, answers));
|
|
86
|
+
if (args && typeof args === "object") return Object.fromEntries(Object.entries(args).map(([k, v]) => [k, resolveAnswers(v, answers)]));
|
|
87
|
+
return args;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
/** A `given` from parts by component name: one entity with those parts, checked; `true` for a tag. */
|
|
92
|
+
export function spawnParts(t: Testbed<any>, parts: Record<string, unknown>): number {
|
|
93
|
+
const e = t.w.spawn();
|
|
94
|
+
for (const [name, value] of Object.entries(parts)) {
|
|
95
|
+
const d = t.w.declaration(name);
|
|
96
|
+
if (!d || d.kind !== "component") throw new Error(`no component ${name}`);
|
|
97
|
+
if (value === true) t.w.add(e, d as never); else t.w.add(e, d as never, value as never);
|
|
98
|
+
}
|
|
99
|
+
return e;
|
|
100
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Where a project's spec verdicts are kept: its .baba/.voss, found from the
|
|
2
|
+
// working directory.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** The project's .baba/.voss, found from the working directory; VOSS_DIR overrides. Where spec verdicts go. */
|
|
7
|
+
export function vossDir(from = process.cwd()): string {
|
|
8
|
+
if (process.env.VOSS_DIR) return resolve(process.env.VOSS_DIR);
|
|
9
|
+
let dir = resolve(from);
|
|
10
|
+
for (;;) {
|
|
11
|
+
if (dir.endsWith(`${"/"}.baba`) || existsSync(join(dir, "package.json")) && existsSync(join(dir, "systems"))) return join(dir, ".voss");
|
|
12
|
+
if (existsSync(join(dir, ".baba"))) return join(dir, ".baba", ".voss");
|
|
13
|
+
const up = dirname(dir);
|
|
14
|
+
if (up === dir) return join(resolve(from), ".baba", ".voss");
|
|
15
|
+
dir = up;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The message schema: what crosses between the kernel and a runtime, and
|
|
2
|
+
// between the kernel and a builder, in either direction, over any
|
|
3
|
+
// transport. One version, said in `hello`. Every transport carries these
|
|
4
|
+
// objects as they are (structured clone).
|
|
5
|
+
import type { Manifest } from "../ecs/baba.ts";
|
|
6
|
+
import type { Snapshot } from "../ecs/state.ts";
|
|
7
|
+
import type { StateAnswer } from "../ecs/snapshot.ts";
|
|
8
|
+
import type { Raw } from "../ecs/runtime.ts";
|
|
9
|
+
import type { Project } from "../baba/project.ts";
|
|
10
|
+
import type { Interest } from "../server/protocol.ts";
|
|
11
|
+
import type { BuildOutput, ViewSpec } from "../build/builder.ts";
|
|
12
|
+
import type { ViewFailure } from "../build/failure.ts";
|
|
13
|
+
|
|
14
|
+
export const PROTOCOL = 6;
|
|
15
|
+
|
|
16
|
+
/** The first message each side sends. A side that cannot serve the other's version answers `failed`. */
|
|
17
|
+
export interface Hello { type: "hello"; protocol: number; role: "kernel" | "runtime" | "server" | "builder" }
|
|
18
|
+
|
|
19
|
+
// ---- kernel → runtime --------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** A baba to run: its code as one bundle of text, its environment, and the state to continue from. */
|
|
22
|
+
export interface Load {
|
|
23
|
+
type: "load";
|
|
24
|
+
project: { name: string; dir: string; baba: string };
|
|
25
|
+
/** The bundle: the baba's entry, the framework and the harness, one module that serves itself. */
|
|
26
|
+
code: string;
|
|
27
|
+
/** The environment effects may read: composed by the kernel from .env and .env.local. */
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
snapshot: Snapshot | null;
|
|
30
|
+
prepare?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fire an action: it runs at the start of the next round, as its system; the answer is what it returned. A `key` the state remembers answers the same again, without running. */
|
|
34
|
+
export interface Fire { type: "fire"; id: number; name: string; args: unknown; coerce?: boolean; key?: string }
|
|
35
|
+
/** Read a query, between rounds. */
|
|
36
|
+
export interface Read { type: "read"; id: number; name: string; args: unknown; coerce?: boolean }
|
|
37
|
+
/** The state since round `since`, once a round past it has run or a wait is over: a delta, or the whole; its secrets redacted. With `frame`, as bytes laid out by the model. */
|
|
38
|
+
export interface StateAsk { type: "state"; id: number; since: number; frame?: true; /** false: answer the state as it is, without waiting for a round past `since`. */ wait?: boolean; /** With `frame`: only these parts of the state, as they are now, for a window that began to ask for them. */ parts?: Interest }
|
|
39
|
+
/** A write at layer zero, applied at the next round. */
|
|
40
|
+
export interface RawOp { type: "raw"; id: number; raw: Raw }
|
|
41
|
+
/** The generation is done: cancel every job, answer `retired` with the state. The sender closes after. */
|
|
42
|
+
export interface Retire { type: "retire"; /** Reload may stop only between nonrepeatable jobs. */ unlessBusy?: boolean }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The answer to a service request the runtime made. A streaming operation
|
|
46
|
+
* answers many times: `more` with a batch of `values`, then one last answer
|
|
47
|
+
* without `more` that ends it, ok or not.
|
|
48
|
+
*/
|
|
49
|
+
export interface Serviced { type: "serviced"; id: number; ok: boolean; value?: unknown; code?: string; error?: string; more?: true; values?: unknown[] }
|
|
50
|
+
|
|
51
|
+
export interface Activate { type: "activate"; snapshot: Snapshot | null }
|
|
52
|
+
export interface Credit { type: "credit"; id: number }
|
|
53
|
+
export type ToRuntime = Activate | Hello | Load | Fire | Read | StateAsk | RawOp | Retire | Serviced;
|
|
54
|
+
|
|
55
|
+
// ---- runtime → kernel --------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** The baba loaded: its contract, and the state it starts from. */
|
|
58
|
+
export interface Loaded { type: "loaded"; manifest: Manifest; snapshot: Snapshot }
|
|
59
|
+
/** The baba did not load; the last generation, if any, keeps serving. */
|
|
60
|
+
export interface Failed { type: "failed"; error: string }
|
|
61
|
+
|
|
62
|
+
export type ResultCode = "noaction" | "noquery" | "args" | "result" | "failed" | "entity" | "name" | "owner" | "down";
|
|
63
|
+
/** The end of a fire, read, state or raw: a value, or a failure with a code the shell and the API understand. */
|
|
64
|
+
export type Result =
|
|
65
|
+
| { type: "result"; id: number; ok: true; value: unknown; /** The round the call ran in, for a fire or a raw write. */ round?: number }
|
|
66
|
+
| { type: "result"; id: number; ok: false; code: ResultCode; error: string };
|
|
67
|
+
|
|
68
|
+
/** A round ended: what it changed since the one before, or the whole, for the kernel to keep; and the same redacted, as the frame every window gets. */
|
|
69
|
+
export interface Round { type: "round"; state: StateAnswer; frame: Uint8Array }
|
|
70
|
+
/** Every job cancelled; `errors` says what went wrong; the state as it was. */
|
|
71
|
+
export interface Retired { type: "retired"; errors: string[]; snapshot: Snapshot; /** The generation remains active; retry retirement later. */ busy?: boolean }
|
|
72
|
+
|
|
73
|
+
/** A request for something outside the runtime, made by a job: the kernel serves it and answers `serviced`. */
|
|
74
|
+
export interface ServiceRequest {
|
|
75
|
+
type: "service";
|
|
76
|
+
id: number;
|
|
77
|
+
system: string;
|
|
78
|
+
effect: string;
|
|
79
|
+
job: number;
|
|
80
|
+
entity?: number | null;
|
|
81
|
+
service: ServiceName;
|
|
82
|
+
op: string;
|
|
83
|
+
args: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ServiceName = "fs" | "http" | "exec" | "secrets";
|
|
87
|
+
|
|
88
|
+
/** The operations that answer many times: known to both sides, so the bridge hands back an iterator and the kernel pumps. */
|
|
89
|
+
export const STREAMS: Record<ServiceName, readonly string[]> = { fs: ["watch"], http: [], exec: ["stream"], secrets: [] };
|
|
90
|
+
export const isStream = (service: ServiceName, op: string) => STREAMS[service]?.includes(op) ?? false;
|
|
91
|
+
|
|
92
|
+
/** The runtime no longer wants a request's answer: the job was cancelled. The kernel ends what it holds for it. */
|
|
93
|
+
export interface Cancel { type: "cancel"; id: number }
|
|
94
|
+
|
|
95
|
+
export type FromRuntime = Hello | Loaded | Failed | Result | Round | Retired | ServiceRequest | Cancel | Credit;
|
|
96
|
+
|
|
97
|
+
export type Message = ToRuntime | FromRuntime;
|
|
98
|
+
|
|
99
|
+
// ---- kernel ↔ builder --------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Bundle the baba's backend: the Project as the kernel opened it. */
|
|
102
|
+
export interface BuildProject { type: "build-project"; id: number; project: Project }
|
|
103
|
+
/** Bundle the view for the browser; URLs inside the bundle start with `publicPath`. */
|
|
104
|
+
export interface BuildView { type: "build-view"; id: number; spec: ViewSpec }
|
|
105
|
+
export type Built =
|
|
106
|
+
| { type: "built"; id: number; ok: true; outputs: BuildOutput[] }
|
|
107
|
+
| { type: "built"; id: number; ok: false; failure: ViewFailure };
|
|
108
|
+
|
|
109
|
+
export type ToBuilder = Hello | BuildProject | BuildView;
|
|
110
|
+
export type FromBuilder = Hello | Built;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A transport carries messages to one peer and delivers its messages back.
|
|
2
|
+
// The kernel talks to a runtime, or to the GUI server, only through one.
|
|
3
|
+
import type { Message } from "./messages.ts";
|
|
4
|
+
|
|
5
|
+
export interface Transport<Out extends { type: string } = Message, In extends { type: string } = Message> {
|
|
6
|
+
send(m: Out): void;
|
|
7
|
+
/** At most one handler; the last one set wins. */
|
|
8
|
+
onMessage(handler: (m: In) => void): void;
|
|
9
|
+
/** Called when the peer goes away, with why. */
|
|
10
|
+
onClose(handler: (reason: string) => void): void;
|
|
11
|
+
/** Ends the peer: a worker is terminated, a process is killed. */
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A transport over a Bun Worker: the peer runs in a thread of this process. */
|
|
16
|
+
export class WorkerTransport<Out extends { type: string } = Message, In extends { type: string } = Message> implements Transport<Out, In> {
|
|
17
|
+
private handler: ((m: In) => void) | null = null;
|
|
18
|
+
private closed: ((reason: string) => void) | null = null;
|
|
19
|
+
constructor(private worker: Worker) {
|
|
20
|
+
worker.onmessage = (e: MessageEvent<In>) => this.handler?.(e.data);
|
|
21
|
+
worker.onerror = (e) => this.closed?.(e.message);
|
|
22
|
+
}
|
|
23
|
+
send(m: Out) { this.worker.postMessage(m); }
|
|
24
|
+
onMessage(h: (m: In) => void) { this.handler = h; }
|
|
25
|
+
onClose(h: (reason: string) => void) { this.closed = h; }
|
|
26
|
+
close() { this.worker.terminate(); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A transport over Bun's process IPC: the peer is a child process the kernel spawned. */
|
|
30
|
+
export class ProcessTransport<Out extends { type: string } = Message, In extends { type: string } = Message> implements Transport<Out, In> {
|
|
31
|
+
private handler: ((m: In) => void) | null = null;
|
|
32
|
+
private closed: ((reason: string) => void) | null = null;
|
|
33
|
+
private proc: import("bun").Subprocess | null = null;
|
|
34
|
+
|
|
35
|
+
/** Spawns `cmd` with IPC; the caller sets cwd, env and stdio through `options`. */
|
|
36
|
+
constructor(cmd: string[], options: { cwd?: string; env?: Record<string, string>; stdout?: "inherit" | "pipe" | "ignore"; stderr?: "inherit" | "pipe" | "ignore" } = {}) {
|
|
37
|
+
this.proc = Bun.spawn(cmd, {
|
|
38
|
+
...options,
|
|
39
|
+
ipc: (m: In) => this.handler?.(m),
|
|
40
|
+
onExit: (_p, code, signal) => this.closed?.(`exited: code ${code}${signal ? `, signal ${signal}` : ""}`),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
get pid() { return this.proc?.pid ?? -1; }
|
|
44
|
+
send(m: Out) { this.proc?.send(m); }
|
|
45
|
+
onMessage(h: (m: In) => void) { this.handler = h; }
|
|
46
|
+
onClose(h: (reason: string) => void) { this.closed = h; }
|
|
47
|
+
close() { this.proc?.kill(); this.proc = null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The child's end of a ProcessTransport: `process.send` and `message` events. */
|
|
51
|
+
export class ParentTransport<Out extends { type: string } = Message, In extends { type: string } = Message> implements Transport<Out, In> {
|
|
52
|
+
private handler: ((m: In) => void) | null = null;
|
|
53
|
+
constructor() {
|
|
54
|
+
if (typeof process.send !== "function") throw new Error("not spawned with IPC");
|
|
55
|
+
process.on("message", (m) => this.handler?.(m as In));
|
|
56
|
+
process.on("disconnect", () => process.exit(0));
|
|
57
|
+
}
|
|
58
|
+
send(m: Out) { process.send!(m); }
|
|
59
|
+
onMessage(h: (m: In) => void) { this.handler = h; }
|
|
60
|
+
onClose(h: (reason: string) => void) { process.on("disconnect", () => h("parent gone")); }
|
|
61
|
+
close() { process.disconnect?.(); }
|
|
62
|
+
}
|