babavoss 0.0.1 → 0.12.0

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 (158) hide show
  1. package/NOTICE +7 -0
  2. package/bin/voss.ts +48 -0
  3. package/gui/babavoss-web.js +234 -0
  4. package/gui/chunk-5hpp1ypv.js +11710 -0
  5. package/gui/chunk-9rq662fd.js +8627 -0
  6. package/gui/chunk-k3cm8j6r.js +433 -0
  7. package/gui/chunk-pk09y98y.js +50 -0
  8. package/gui/chunk-smz02qa6.js +186 -0
  9. package/gui/chunk-wwqypxre.js +49 -0
  10. package/gui/gui.js +2015 -0
  11. package/gui/react-compiler-runtime.js +39 -0
  12. package/gui/react-dom-client.js +21 -0
  13. package/gui/react-dom.js +44 -0
  14. package/gui/react-jsx-runtime.js +19 -0
  15. package/gui/react.js +105 -0
  16. package/gui/theme.css +3053 -0
  17. package/index.ts +15 -0
  18. package/package.json +50 -4
  19. package/src/baba/check.ts +90 -0
  20. package/src/baba/config.ts +261 -0
  21. package/src/baba/find.ts +12 -0
  22. package/src/baba/init.ts +176 -0
  23. package/src/baba/node.ts +483 -0
  24. package/src/baba/project.ts +63 -0
  25. package/src/baba/registry.ts +35 -0
  26. package/src/baba/worker.ts +55 -0
  27. package/src/bench/index.ts +6 -0
  28. package/src/bench/measure.ts +132 -0
  29. package/src/bench/scenarios.ts +136 -0
  30. package/src/build/builder.ts +74 -0
  31. package/src/build/failure.ts +78 -0
  32. package/src/build/guard.ts +85 -0
  33. package/src/build/mdx-register.ts +3 -0
  34. package/src/build/mdx.ts +38 -0
  35. package/src/build/project.ts +38 -0
  36. package/src/build/views.ts +146 -0
  37. package/src/builder/main.ts +29 -0
  38. package/src/desktop/bob.ts +76 -0
  39. package/src/desktop/desktop.css +111 -0
  40. package/src/desktop/icons.ts +50 -0
  41. package/src/desktop/index.ts +323 -0
  42. package/src/desktop/routes.ts +98 -0
  43. package/src/desktop/view.tsx +673 -0
  44. package/src/door/core.ts +384 -0
  45. package/src/ecs/baba.ts +431 -0
  46. package/src/ecs/codec.ts +334 -0
  47. package/src/ecs/handles.ts +91 -0
  48. package/src/ecs/replica.ts +150 -0
  49. package/src/ecs/runtime.ts +603 -0
  50. package/src/ecs/scheduler.ts +75 -0
  51. package/src/ecs/snapshot.ts +102 -0
  52. package/src/ecs/state.ts +759 -0
  53. package/src/ecs/system.ts +256 -0
  54. package/src/ecs/table.ts +420 -0
  55. package/src/ecs/testbed.ts +97 -0
  56. package/src/exec/host.ts +177 -0
  57. package/src/exec/main.ts +98 -0
  58. package/src/exec/watch.ts +7 -0
  59. package/src/exec/wire.ts +29 -0
  60. package/src/generated/build.ts +4 -0
  61. package/src/gui/css.d.ts +1 -0
  62. package/src/gui/gui.tsx +245 -0
  63. package/src/gui/index.ts +51 -0
  64. package/src/gui/inspector.tsx +47 -0
  65. package/src/gui/levels.tsx +73 -0
  66. package/src/gui/promptware.tsx +68 -0
  67. package/src/gui/runner.tsx +118 -0
  68. package/src/gui/theme.css +498 -0
  69. package/src/gui/theme.ts +25 -0
  70. package/src/gui/wizard.tsx +227 -0
  71. package/src/guide/add-a-desktop.mdx +100 -0
  72. package/src/guide/compose-an-interface.mdx +84 -0
  73. package/src/guide/index.ts +13 -0
  74. package/src/guide/reach-outside.mdx +112 -0
  75. package/src/guide/spec-a-system.mdx +93 -0
  76. package/src/guide/systems-together.mdx +72 -0
  77. package/src/guide/write-a-system.mdx +183 -0
  78. package/src/guide/write-promptware.mdx +90 -0
  79. package/src/http/server.ts +310 -0
  80. package/src/kernel/build.ts +21 -0
  81. package/src/kernel/builder.ts +105 -0
  82. package/src/kernel/children.ts +117 -0
  83. package/src/kernel/context.ts +90 -0
  84. package/src/kernel/lock.ts +46 -0
  85. package/src/kernel/names.ts +14 -0
  86. package/src/kernel/schema.ts +130 -0
  87. package/src/kernel/where.ts +12 -0
  88. package/src/kit/index.ts +232 -0
  89. package/src/maker/system.ts +213 -0
  90. package/src/mcp/daemon.ts +61 -0
  91. package/src/mcp/main.ts +208 -0
  92. package/src/mcp/rpc.ts +64 -0
  93. package/src/mcp/tools.ts +125 -0
  94. package/src/prompt/evals.ts +42 -0
  95. package/src/prompt/index.ts +242 -0
  96. package/src/prompt/jsx-dev-runtime.ts +1 -0
  97. package/src/prompt/jsx-runtime.ts +49 -0
  98. package/src/prompt/mdx.d.ts +1 -0
  99. package/src/promptware/compile.ts +183 -0
  100. package/src/promptware/define.ts +16 -0
  101. package/src/promptware/disk.ts +72 -0
  102. package/src/promptware/markdown.d.ts +6 -0
  103. package/src/promptware/sync.ts +437 -0
  104. package/src/promptware/system.ts +215 -0
  105. package/src/runtime/bridge.ts +85 -0
  106. package/src/runtime/connect.ts +54 -0
  107. package/src/runtime/env.ts +35 -0
  108. package/src/runtime/harness.ts +80 -0
  109. package/src/runtime/main.ts +119 -0
  110. package/src/runtime/worker.ts +33 -0
  111. package/src/server/edge.ts +332 -0
  112. package/src/server/main.ts +45 -0
  113. package/src/server/messages.ts +97 -0
  114. package/src/server/protocol.ts +37 -0
  115. package/src/services/args.ts +45 -0
  116. package/src/services/exec.ts +69 -0
  117. package/src/services/fs.ts +139 -0
  118. package/src/services/http.ts +30 -0
  119. package/src/services/index.ts +113 -0
  120. package/src/services/secrets.ts +18 -0
  121. package/src/shell/address.ts +21 -0
  122. package/src/shell/args.ts +219 -0
  123. package/src/shell/client.ts +107 -0
  124. package/src/shell/codes.ts +26 -0
  125. package/src/shell/positional.ts +20 -0
  126. package/src/shell/run.ts +470 -0
  127. package/src/shell/service.ts +167 -0
  128. package/src/shell/state.ts +204 -0
  129. package/src/spec/adapters.ts +72 -0
  130. package/src/spec/diff.ts +26 -0
  131. package/src/spec/files.ts +17 -0
  132. package/src/spec/index.ts +155 -0
  133. package/src/spec/run.ts +97 -0
  134. package/src/spec/take.ts +54 -0
  135. package/src/test/index.ts +8 -0
  136. package/src/test/prove.ts +56 -0
  137. package/src/test/records.ts +23 -0
  138. package/src/test/specs.ts +56 -0
  139. package/src/test/steps.ts +100 -0
  140. package/src/test/voss-dir.ts +17 -0
  141. package/src/transport/messages.ts +110 -0
  142. package/src/transport/transport.ts +62 -0
  143. package/src/wall/probe.ts +67 -0
  144. package/src/wall/profile.ts +103 -0
  145. package/src/wall/spawn.ts +59 -0
  146. package/src/web/app.tsx +53 -0
  147. package/src/web/core.tsx +140 -0
  148. package/src/web/form.ts +155 -0
  149. package/src/web/hooks.ts +135 -0
  150. package/src/web/index.tsx +17 -0
  151. package/src/web/list.ts +19 -0
  152. package/src/web/maker.tsx +766 -0
  153. package/src/web/objects.tsx +213 -0
  154. package/src/web/socket.ts +84 -0
  155. package/src/web/state.tsx +69 -0
  156. package/src/web/store.ts +221 -0
  157. package/src/web/ui.tsx +135 -0
  158. package/README.md +0 -5
@@ -0,0 +1,483 @@
1
+ // A project as a running node: its one state in a runtime, fire, read, state
2
+ // and raw over it, reload on change with the state carried across, and its
3
+ // view bundled and cached per generation. The state lives for the run:
4
+ // carried across reloads, gone at stop. Nothing of it is kept on disk;
5
+ // what a baba wants to keep is the baba's own concern.
6
+ import { watch, type FSWatcher } from "node:fs";
7
+ import { join, sep } from "node:path";
8
+ import { mkdir } from "node:fs/promises";
9
+ import type { Manifest } from "../ecs/baba.ts";
10
+ import type { Snapshot } from "../ecs/state.ts";
11
+ import { isDelta, patchSnapshot, type StateAnswer } from "../ecs/snapshot.ts";
12
+ import type { Interest } from "../server/protocol.ts";
13
+ import type { Raw } from "../ecs/runtime.ts";
14
+ import type { Project } from "./project.ts";
15
+ import type { ToWorker, FromWorker } from "./worker.ts";
16
+ import type { Services, Who } from "../kernel/context.ts";
17
+ import { KernelServices, describe, serveRequest } from "../services/index.ts";
18
+ import type { ServiceRequest, Result } from "../transport/messages.ts";
19
+ import { WorkerTransport, type Transport } from "../transport/transport.ts";
20
+ import { InProcessBuilder, type Builder, type BuildOutput, type ViewSpec } from "../build/builder.ts";
21
+ import { ViewFailed, formatFailure, type ViewFailure } from "../build/failure.ts";
22
+ import { moveOldDir, writeEditorConfig } from "./config.ts";
23
+ import { takeLock, LockError, type Lock } from "../kernel/lock.ts";
24
+
25
+ /** What describe answers: the contract, the generation, the view's state. */
26
+ export interface Described { name: string; title: string; gen: number; manifest: Manifest | null; view: { v: number; error: ViewFailure | null } | null; error: string | null }
27
+
28
+ /** PROJECT/.baba/.voss/lock: the pid of the one process running this state. */
29
+ export const lockPath = (p: Project) => join(p.baba, ".voss", "lock");
30
+ /** PROJECT/.baba/.voss/manifest.json: the contract as the last successful load left it. */
31
+ export const manifestPath = (p: Project) => join(p.baba, ".voss", "manifest.json");
32
+
33
+ /** The manifest the last load wrote, or null when there is none or it does not read. */
34
+ export async function readManifest(p: Project): Promise<Manifest | null> {
35
+ try { return (await Bun.file(manifestPath(p)).json()) as Manifest; } catch { return null; }
36
+ }
37
+
38
+ export class NodeError extends Error {
39
+ constructor(readonly code: string, msg: string) { super(msg); }
40
+ }
41
+
42
+ interface Live {
43
+ transport: Transport<ToWorker, FromWorker>;
44
+ manifest: Manifest;
45
+ inflight: number;
46
+ /** The service requests this generation has open, by its ids: each aborted on cancel, and all when the generation goes. */
47
+ open: Map<number, { ctl: AbortController; done: Promise<void>; grant(): void; request: ServiceRequest }>;
48
+ generation: string;
49
+ active: boolean;
50
+ destruction?: Promise<void>;
51
+ activated?: { resolve(m: { manifest: Manifest; snapshot: Snapshot }): void; reject(e: Error): void };
52
+ closed?: Error;
53
+ /** This generation's state as its last round left it: the whole it loaded with, moved by each round's delta. */
54
+ last: Snapshot | null;
55
+ onRetired: ((r: { errors: string[]; snapshot: Snapshot | null; busy?: boolean }) => void) | null;
56
+ }
57
+
58
+ const RETIRE_MS = 3000;
59
+ const ACTIVATE_MS = 10_000;
60
+ const QUIET_MS = 300;
61
+ /** How often a reload that waits for a job asks again. */
62
+ const WAIT_MS = 500;
63
+ // What changes under .baba without changing the code: voss's own files and the dependencies. Promptware is code: a change there reloads.
64
+ const IGNORED = [".voss", "node_modules"];
65
+
66
+ /** Makes the transport a Node loads a project over. The worker is the first; a runtime process is next. */
67
+ export type Connect = () => Transport<ToWorker, FromWorker>;
68
+ export const workerConnect: Connect = () => new WorkerTransport(new Worker(new URL("./worker.ts", import.meta.url).href, { preload: [new URL("../build/mdx-register.ts", import.meta.url).pathname] }));
69
+
70
+ interface Bundle { gen: number; outputs: BuildOutput[] }
71
+
72
+ const failureOf = (err: unknown): ViewFailure => err instanceof ViewFailed ? err.failure : { stage: "build", message: err instanceof Error ? err.message : String(err) };
73
+
74
+ interface Pending { from: Live; resolve: (v: { value: unknown; round: number | undefined }) => void; reject: (e: Error) => void }
75
+ type Unnumbered<T> = T extends unknown ? Omit<T, "id"> : never;
76
+
77
+ export class Node {
78
+ private live: Live | null = null;
79
+ private loading: Promise<void> | null = null;
80
+ private stopping = false;
81
+ private cancelLoad: ((e: Error) => void) | null = null;
82
+ private switching: Promise<void> | null = null;
83
+ private cleanupFailure: Error | null = null;
84
+ private pending = new Map<number, Pending>();
85
+ private nextId = 1;
86
+ private watcher: FSWatcher | null = null;
87
+ private timer: ReturnType<typeof setTimeout> | null = null;
88
+ /** The last load error, when the running generation is older than the files. */
89
+ error: string | null = null;
90
+ /** Why changes on disk are not loaded for now: a newer voss is installed, and this one serves what it has until the restart. */
91
+ frozen: string | null = null;
92
+ /** Whether a change arrived while frozen: the restart will load it. */
93
+ held = false;
94
+ /** Whether a reload is waiting for a job that cannot run twice to finish. */
95
+ private waiting = false;
96
+ /** The state as the last round left it: what a reload continues from. */
97
+ private last: Snapshot | null = null;
98
+ /** Hears each of the live generation's rounds as the frame every window gets: the GUI server fans it out. */
99
+ onRound: ((frame: Uint8Array, round: number) => void) | null = null;
100
+ /** Hears the generation advance: windows re-read the contract and resync. */
101
+ onGeneration: ((gen: number) => void) | null = null;
102
+ /** The state is one process's: .baba/.voss/lock, taken on the first start and held until stop. */
103
+ private lock: Lock | null = null;
104
+
105
+ // Generations: seeded so a restart never repeats a URL, advanced once per settled change.
106
+ private gen = Date.now();
107
+ private viewGen: number;
108
+ private bundle_: Bundle | null = null;
109
+ private bundleError: ViewFailure | null = null;
110
+ private building: Promise<Bundle> | null = null;
111
+ private waiters: (() => void)[] = [];
112
+
113
+ constructor(
114
+ readonly project: Project,
115
+ private log: (line: string) => void = () => {},
116
+ private connect: Connect = workerConnect,
117
+ /** What answers the jobs' service requests: the kernel's services, every reach beyond the project in the log. */
118
+ private services: Services = new KernelServices((e) => { const line = describe(e); if (line) log(`${project.name}: ${line}`); }),
119
+ /** What bundles the view: in-process by default; the kernel gives the project's walled builder. */
120
+ private builder: Builder = new InProcessBuilder(),
121
+ ) { this.viewGen = this.gen; }
122
+
123
+ get name() { return this.project.name; }
124
+ get generation() { return this.gen; }
125
+
126
+ /** Where the view's outputs are served from, at a generation. */
127
+ publicPath(gen: number): string { return `/view/${encodeURIComponent(this.name)}/${gen}/`; }
128
+
129
+ private spec(gen: number): ViewSpec {
130
+ return { entry: this.project.view!, root: this.project.baba, publicPath: this.publicPath(gen), dir: this.project.baba, plugins: this.project.build.plugins, assets: this.project.build.assets };
131
+ }
132
+
133
+ describe(): Described {
134
+ return {
135
+ name: this.name, title: this.project.title, gen: this.gen,
136
+ manifest: this.live?.manifest ?? null,
137
+ view: this.project.view ? { v: this.viewGen, error: this.bundleError } : null,
138
+ error: this.error,
139
+ };
140
+ }
141
+
142
+ /** Loads the project into a fresh runtime over the state it has, and, when that works, makes it the live one. */
143
+ start(): Promise<void> {
144
+ if (this.stopping) return Promise.reject(new NodeError("closed", "node is stopping"));
145
+ return this.loading ??= this.load().finally(() => { this.loading = null; this.cancelLoad = null; });
146
+ }
147
+
148
+ private async load(): Promise<void> {
149
+ // A first load that fails gives the lock back: nothing runs, so nothing holds the state, and the next start is not refused as busy.
150
+ const took = !this.lock;
151
+ if (!this.lock) {
152
+ // Before .voss is made for the lock: a project from before the rename still has its files in state/, and they move first.
153
+ await moveOldDir(this.project).catch((err) => this.log(`${this.name}: .baba/state was not moved: ${err instanceof Error ? err.message : String(err)}`));
154
+ await mkdir(join(this.project.baba, ".voss"), { recursive: true });
155
+ try { this.lock = takeLock(lockPath(this.project), { name: this.name }, (h) => `${this.name} is already run by pid ${h.pid}`); }
156
+ catch (err) { if (err instanceof LockError) throw new NodeError("busy", err.message); throw err; }
157
+ }
158
+ await writeEditorConfig(this.project).catch((err) => this.log(`${this.name}: editor config: ${err instanceof Error ? err.message : String(err)}`));
159
+ if (this.stopping) throw new NodeError("closed", "node is stopping");
160
+ const snapshot = this.last;
161
+ const transport = this.connect();
162
+ const fresh: Live = { transport, manifest: null as never, inflight: 0, open: new Map(), last: null, onRetired: null, generation: crypto.randomUUID(), active: false };
163
+ const loaded = await new Promise<{ manifest: Manifest; snapshot: Snapshot }>((resolve, reject) => {
164
+ const failed = (error: Error) => {
165
+ this.closed(fresh, error);
166
+ reject(error);
167
+ };
168
+ this.cancelLoad = failed;
169
+ transport.onMessage((m) => {
170
+ if (m.type === "loaded") { fresh.last = m.snapshot; resolve(m); fresh.activated?.resolve(m); }
171
+ else if (m.type === "failed") failed(new NodeError("load", m.error));
172
+ else if (m.type === "result") this.settle(m);
173
+ else if (m.type === "round") {
174
+ // Each generation follows its own rounds, in place; the live one's state is the node's, and its frames go to the windows.
175
+ try { fresh.last = isDelta(m.state) ? patchSnapshot(fresh.last!, m.state) : m.state; } catch (err) { this.log(`${this.name}: a round was lost: ${err instanceof Error ? err.message : String(err)}`); }
176
+ if (fresh === this.live || !this.live) { this.last = fresh.last; if (fresh === this.live) this.onRound?.(m.frame, m.state.round); }
177
+ }
178
+ else if (m.type === "service") void this.serve(m, fresh).catch((e) => { this.cleanupFailure = e; this.log(`${this.name}: execution cleanup failed: ${e}`); });
179
+ else if (m.type === "cancel") { fresh.open.get(m.id)?.ctl.abort(); }
180
+ else if (m.type === "credit") fresh.open.get(m.id)?.grant();
181
+ else if (m.type === "retired") fresh.onRetired?.(m);
182
+ });
183
+ transport.onClose((reason) => failed(new NodeError("down", reason)));
184
+ transport.send({ type: "load-dir", dir: this.project.dir, snapshot, prepare: true });
185
+ }).catch((err) => {
186
+ transport.close();
187
+ if (took && !this.live && !this.cleanupFailure) { this.lock?.release(); this.lock = null; }
188
+ throw err;
189
+ });
190
+ fresh.manifest = loaded.manifest;
191
+ // The contract outlives the run: discovery and promptware read it with the baba down.
192
+ await mkdir(join(this.project.baba, ".voss"), { recursive: true })
193
+ .then(() => Bun.write(manifestPath(this.project), JSON.stringify(loaded.manifest, null, 2) + "\n"))
194
+ .catch((err) => this.log(`${this.name}: the manifest was not written: ${err instanceof Error ? err.message : String(err)}`));
195
+ // Preparation did no IO. Hold new calls while the old state reaches its
196
+ // final snapshot and all owned execution groups are drained.
197
+ let resume!: () => void;
198
+ this.switching = new Promise<void>((r) => { resume = r; });
199
+ try {
200
+ const old = this.live;
201
+ if (old) {
202
+ while (old.inflight && !this.stopping) await new Promise((r) => setTimeout(r, 5));
203
+ if (this.stopping) throw new NodeError("closed", "node is stopping");
204
+ await this.destruct(old, true);
205
+ }
206
+ // A crashed generation may already have left `live`, while its OS
207
+ // cleanup still runs. It holds the same activation barrier.
208
+ await Promise.all([...this.retiring].map((retiring) => this.destruct(retiring)));
209
+ if (this.stopping) throw new NodeError("closed", "node is stopping");
210
+ if (fresh.closed) throw fresh.closed;
211
+ if (this.cleanupFailure) throw this.cleanupFailure;
212
+ fresh.active = true;
213
+ this.live = fresh;
214
+ const activated = await new Promise<{ manifest: Manifest; snapshot: Snapshot }>((resolve, reject) => {
215
+ const timer = setTimeout(() => reject(new NodeError("load", "runtime did not activate within 10s")), ACTIVATE_MS);
216
+ fresh.activated = { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } };
217
+ try { fresh.transport.send({ type: "activate", snapshot: old?.last ?? this.last ?? snapshot }); }
218
+ catch (e) { fresh.activated.reject(e instanceof Error ? e : new Error(String(e))); }
219
+ }).finally(() => { fresh.activated = undefined; });
220
+ this.last = activated.snapshot;
221
+ this.error = null;
222
+ this.watch();
223
+ } catch (e) {
224
+ this.closed(fresh, e instanceof Error ? e : new Error(String(e)));
225
+ await this.destruct(fresh);
226
+ if (took && !this.live && !this.cleanupFailure) { this.lock?.release(); this.lock = null; }
227
+ throw e;
228
+ } finally { this.switching = null; resume(); }
229
+ }
230
+
231
+ private ask(m: Unnumbered<Extract<ToWorker, { id: number }>>): Promise<unknown> { return this.askFull(m).then((r) => r.value); }
232
+
233
+ private async askFull(m: Unnumbered<Extract<ToWorker, { id: number }>>): Promise<{ value: unknown; round: number | undefined }> {
234
+ await this.switching;
235
+ const live = this.live;
236
+ if (this.stopping || !live || live.closed) return Promise.reject(new NodeError("down", `${this.name} is not running${this.error ? `: ${this.error}` : ""}`));
237
+ const id = this.nextId++;
238
+ live.inflight++;
239
+ return new Promise<{ value: unknown; round: number | undefined }>((resolve, reject) => {
240
+ this.pending.set(id, { from: live, resolve, reject });
241
+ live.transport.send({ ...m, id } as ToWorker);
242
+ }).finally(() => {
243
+ live.inflight--;
244
+ if (live !== this.live) this.retire(live);
245
+ });
246
+ }
247
+
248
+ /** Fires an action; answers what it returned. `coerce` reads shell strings; a `key` makes a retry answer the same. */
249
+ fire(name: string, args: unknown, coerce = false, key?: string): Promise<unknown> { return this.ask({ type: "fire", name, args, coerce, ...(key !== undefined ? { key } : {}) }); }
250
+ /** Reads a query. */
251
+ read(name: string, args: unknown, coerce = false): Promise<unknown> { return this.ask({ type: "read", name, args, coerce }); }
252
+ /** The state once a round past `since` has run, or after a wait; its secrets redacted. */
253
+ state(since = 0): Promise<StateAnswer> { return this.ask({ type: "state", since }) as Promise<StateAnswer>; }
254
+ /** The same as a frame: bytes laid out by the model, for a window. */
255
+ frame(since = 0, wait = true): Promise<Uint8Array> { return this.ask({ type: "state", since, frame: true, ...(wait ? {} : { wait: false }) }) as Promise<Uint8Array>; }
256
+ /** The parts a window began to ask for, as a frame since the round just ended. */
257
+ parts(interest: Interest): Promise<Uint8Array> { return this.ask({ type: "state", since: 0, frame: true, wait: false, parts: interest }) as Promise<Uint8Array>; }
258
+ /** Fires an action and says which round it ran in. */
259
+ fireIn(name: string, args: unknown, coerce = false, key?: string): Promise<{ value: unknown; round: number | undefined }> { return this.askFull({ type: "fire", name, args, coerce, ...(key !== undefined ? { key } : {}) }); }
260
+ /** A raw write and the round it ran in. */
261
+ rawIn(raw: Raw): Promise<{ value: unknown; round: number | undefined }> { return this.askFull({ type: "raw", raw }); }
262
+ /** A write at layer zero. */
263
+ raw(raw: Raw): Promise<unknown> { return this.ask({ type: "raw", raw }); }
264
+ /** Fire or read by the contract's kind of `name`. */
265
+ async call(name: string, args: unknown, coerce = false): Promise<unknown> {
266
+ const m = this.live?.manifest;
267
+ if (m && name in m.queries) return this.read(name, args, coerce);
268
+ return this.fire(name, args, coerce);
269
+ }
270
+
271
+ /** A job asked for something outside its runtime: the kernel's services answer the generation that asked, once or, for a stream, many times. */
272
+ private async serve(m: ServiceRequest, from: Live): Promise<void> {
273
+ if (!from.active || this.cleanupFailure) {
274
+ from.transport.send({ type: "serviced", id: m.id, ok: false, code: "failed", error: this.cleanupFailure?.message ?? "generation is not active" }); return;
275
+ }
276
+ const who: Who = { project: this.project.dir, system: m.system, effect: m.effect, job: m.job, entity: m.entity, generation: from.generation };
277
+ const ctl = new AbortController();
278
+ let available = true, wake: (() => void) | undefined;
279
+ const grant = () => { available = true; wake?.(); };
280
+ ctl.signal.addEventListener("abort", grant, { once: true });
281
+ const credit = async () => {
282
+ if (!available && !ctl.signal.aborted) await new Promise<void>((r) => { wake = r; });
283
+ available = false; wake = undefined;
284
+ };
285
+ // Replacement of a logical job waits for its cancelled predecessor.
286
+ const predecessors = [...from.open.values()].filter((r) => r.request.service === "exec" && r.request.job !== m.job && r.request.system === m.system && r.request.effect === m.effect && r.request.entity === m.entity).map((r) => r.done);
287
+ const done = Promise.resolve().then(async () => {
288
+ if (m.service === "exec" && m.op !== "write") await Promise.all(predecessors);
289
+ if (!ctl.signal.aborted) await serveRequest(this.services, who, m, (a) => { if (!ctl.signal.aborted) from.transport.send(a); }, ctl.signal, credit);
290
+ }).finally(() => { from.open.delete(m.id); });
291
+ from.open.set(m.id, { ctl, done, grant, request: m });
292
+ await done;
293
+ }
294
+ private retiring = new Set<Live>();
295
+
296
+ /** The view's bundle: what the last change built, or, for a view nobody asked for since the node started, built now. */
297
+ async bundle(): Promise<Bundle> {
298
+ if (!this.project.view) throw new NodeError("noview", `${this.name} has no view`);
299
+ if (this.bundle_) return this.bundle_;
300
+ if (!this.building) {
301
+ const gen = this.viewGen;
302
+ this.building = this.builder.view(this.spec(gen)).then(
303
+ (outputs) => { const b = { gen, outputs }; this.bundle_ = b; this.bundleError = null; return b; },
304
+ (err) => { this.bundleError = failureOf(err); this.wake(); throw new NodeError("bundle", formatFailure(this.bundleError)); },
305
+ ).finally(() => { this.building = null; });
306
+ }
307
+ return this.building;
308
+ }
309
+
310
+ private wake(): void {
311
+ const waiters = this.waiters;
312
+ this.waiters = [];
313
+ for (const w of waiters) w();
314
+ }
315
+
316
+ /** Resolves once the generation passes `since`, or after `ms`. */
317
+ waitFor(since: number, ms: number): Promise<void> {
318
+ if (this.gen > since) return Promise.resolve();
319
+ return new Promise((resolve) => {
320
+ const t = setTimeout(() => { this.waiters = this.waiters.filter((w) => w !== done); resolve(); }, ms);
321
+ const done = () => { clearTimeout(t); resolve(); };
322
+ this.waiters.push(done);
323
+ });
324
+ }
325
+
326
+ /**
327
+ * The settled change: the new code over the current state, the view
328
+ * rebuilt, the generation advanced once. A view that fails to build keeps
329
+ * its old bundle and records the error; the old generation serves
330
+ * throughout, and stays if the new code fails to load.
331
+ */
332
+ async reload(): Promise<void> {
333
+ // Retiring the generation would cut its jobs, and one of an effect that cannot run twice would land as interrupted
334
+ // instead of done: so the reload waits for those, asking again until none runs.
335
+ const busy = this.unrepeatable();
336
+ if (busy.length) {
337
+ if (!this.waiting) this.log(`${this.name}: changed on disk; the reload waits for ${busy.join(", ")} to finish`);
338
+ this.waiting = true;
339
+ this.waitReload();
340
+ return;
341
+ }
342
+ this.waiting = false;
343
+ try {
344
+ await this.start();
345
+ this.log(`${this.name}: reloaded`);
346
+ } catch (err) {
347
+ if (err instanceof NodeError && err.code === "reload-busy") { this.waitReload(); return; }
348
+ this.error = err instanceof Error ? err.message : String(err);
349
+ this.log(`${this.name}: reload failed${this.live ? ", still serving the last generation" : ""}: ${this.error}`);
350
+ }
351
+ if (this.stopping) return;
352
+ const next = this.gen + 1;
353
+ if (this.project.view) {
354
+ try {
355
+ const outputs = await this.builder.view(this.spec(next));
356
+ this.bundle_ = { gen: next, outputs };
357
+ this.bundleError = null;
358
+ this.viewGen = next;
359
+ } catch (err) {
360
+ this.bundleError = failureOf(err);
361
+ this.log(`${this.name}: keeping the last view. ${formatFailure(this.bundleError)}`);
362
+ }
363
+ } else { this.bundle_ = null; this.bundleError = null; }
364
+ this.gen = next;
365
+ this.wake();
366
+ this.onGeneration?.(next);
367
+ }
368
+
369
+ /** Stops everything; the state is written once more. */
370
+ async stop(): Promise<void> {
371
+ this.stopping = true;
372
+ this.cancelLoad?.(new NodeError("closed", "node is stopping"));
373
+ this.builder.stop();
374
+ this.watcher?.close(); this.watcher = null;
375
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
376
+ let cleaned = false;
377
+ try {
378
+ // A rejected load must not prevent cleanup of the old generation.
379
+ await this.loading?.catch(() => {});
380
+ const live = this.live;
381
+ this.live = null;
382
+ if (live) await this.destruct(live);
383
+ await Promise.all([...this.retiring].map((old) => this.destruct(old)));
384
+ if (this.cleanupFailure) throw this.cleanupFailure;
385
+ cleaned = true;
386
+ } finally {
387
+ for (const p of this.pending.values()) p.reject(new NodeError("closed", "node stopped"));
388
+ this.pending.clear();
389
+ if (cleaned) { this.last = null; this.lock?.release(); this.lock = null; }
390
+ this.stopping = false;
391
+ this.wake();
392
+ }
393
+ }
394
+
395
+ /** A file under .baba changed: arm the reload. */
396
+ changed(relative: string): void {
397
+ const top = relative.split(sep)[0] ?? "";
398
+ if (IGNORED.includes(top)) return;
399
+ if (this.frozen) { if (!this.held) this.log(`${this.name}: changed on disk; kept for the restart, since ${this.frozen}`); this.held = true; return; }
400
+ if (this.timer) clearTimeout(this.timer);
401
+ this.timer = setTimeout(() => void this.reload(), QUIET_MS);
402
+ }
403
+
404
+ /** The jobs running now of effects not safe to repeat, named: what a reload must not cut. */
405
+ private unrepeatable(): string[] {
406
+ const m = this.live?.manifest;
407
+ if (!m) return [];
408
+ return (this.last?.jobs ?? []).filter((j) => j.status === "running" && m.effects[j.effect]?.repeat === false).map((j) => `${j.effect}${j.entity === null ? "" : ` on ${j.entity}`}`);
409
+ }
410
+
411
+ /** Stops loading changes: the generation that runs keeps running, and a change on disk waits for the restart. */
412
+ freeze(why: string): void {
413
+ this.frozen = why;
414
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; this.held = true; }
415
+ }
416
+
417
+ private settle(m: Result) {
418
+ const p = this.pending.get(m.id);
419
+ if (!p) return;
420
+ this.pending.delete(m.id);
421
+ if (m.ok) p.resolve({ value: m.value, round: m.round }); else p.reject(new NodeError(m.code, m.error));
422
+ }
423
+
424
+ private waitReload(): void {
425
+ this.waiting = true;
426
+ if (this.timer) clearTimeout(this.timer);
427
+ if (!this.stopping) this.timer = setTimeout(() => void this.reload(), WAIT_MS);
428
+ }
429
+
430
+ /** A dead generation rejects its callers and drains its owned services. */
431
+ private closed(from: Live, error: Error): void {
432
+ if (from.closed) return;
433
+ from.closed = error;
434
+ from.active = false;
435
+ from.activated?.reject(error);
436
+ from.onRetired?.({ errors: [], snapshot: from.last });
437
+ for (const r of from.open.values()) r.ctl.abort();
438
+ for (const [id, p] of this.pending) if (p.from === from) { this.pending.delete(id); p.reject(error); }
439
+ if (this.live === from) { this.live = null; this.error = error.message; }
440
+ from.transport.close();
441
+ this.retire(from);
442
+ }
443
+
444
+ private retire(old: Live) {
445
+ if (old.inflight === 0) void this.destruct(old).catch((e) => { this.cleanupFailure = e; this.log(`${this.name}: cleanup failed: ${e}`); });
446
+ }
447
+
448
+ /** Conditional retirement checks live jobs atomically inside the runtime. */
449
+ private destruct(old: Live, unlessBusy = false): Promise<void> {
450
+ if (old.destruction) return old.destruction;
451
+ this.retiring.add(old);
452
+ old.destruction = (async () => {
453
+ const retired = old.closed ? { errors: [], snapshot: old.last } : await new Promise<{ errors: string[]; snapshot: Snapshot | null; busy?: boolean }>((resolve) => {
454
+ const timer = setTimeout(() => resolve({ errors: ["the baba did not retire within 3s"], snapshot: null }), RETIRE_MS);
455
+ old.onRetired = (r) => { clearTimeout(timer); resolve(r); };
456
+ try { old.transport.send({ type: "retire", ...(unlessBusy ? { unlessBusy: true } : {}) }); }
457
+ catch (e) { clearTimeout(timer); resolve({ errors: [String(e)], snapshot: null }); }
458
+ });
459
+ old.onRetired = null;
460
+ if (retired.snapshot) { old.last = retired.snapshot; if (!this.live || this.live === old) this.last = retired.snapshot; }
461
+ if (retired.busy) throw new NodeError("reload-busy", "reload waits for nonrepeatable jobs");
462
+ old.active = false;
463
+ if (this.live === old) this.live = null;
464
+ for (const e of retired.errors) this.log(`${this.name}: ${e}`);
465
+ for (const r of old.open.values()) r.ctl.abort();
466
+ try { await Promise.all([...old.open.values()].map((r) => r.done)); }
467
+ finally { old.transport.close(); }
468
+ })().finally(() => { this.retiring.delete(old); });
469
+ // A busy response leaves the generation live and allows explicit stop.
470
+ void old.destruction.catch((e) => { if (e instanceof NodeError && e.code === "reload-busy") old.destruction = undefined; });
471
+ return old.destruction;
472
+ }
473
+
474
+ private watch() {
475
+ if (this.watcher) return;
476
+ try {
477
+ this.watcher = watch(this.project.baba, { recursive: true }, (_, file) => { if (file) this.changed(String(file)); });
478
+ this.watcher.unref();
479
+ } catch (err) {
480
+ this.log(`${this.name}: not watching: ${err instanceof Error ? err.message : String(err)}`);
481
+ }
482
+ }
483
+ }
@@ -0,0 +1,63 @@
1
+ // A project as the kernel sees it: a directory with a .baba that holds the
2
+ // baba's package.json, its entry index.ts and, when present, its interface.tsx (or the older view.tsx).
3
+ import { resolve, join } from "node:path";
4
+ import { stat } from "node:fs/promises";
5
+ import type { BabaConfig } from "../kernel/context.ts";
6
+
7
+ export interface Project {
8
+ /** `baba.name`, else the package's name: lowercase letters, digits and dashes. */
9
+ name: string;
10
+ title: string;
11
+ harnesses: string[];
12
+ /** The project directory. */
13
+ dir: string;
14
+ /** PROJECT/.baba */
15
+ baba: string;
16
+ /** The entry, .baba/index.ts: its default export is the baba. */
17
+ entry: string;
18
+ /** The interface, .baba/interface.tsx (or the older view.tsx), when the project has one. */
19
+ view: string | null;
20
+ build: { plugins: string[]; assets: string[] };
21
+ }
22
+
23
+ const NAME = /^[a-z][a-z0-9-]*$/;
24
+ /** The page's own first segments: a baba's address is `/NAME/…`, so no baba is named one of them. */
25
+ export const PAGE_WORDS = new Set(["api", "assets", "view", "ws"]);
26
+
27
+ interface Pkg { name?: unknown; baba?: unknown; dependencies?: unknown; devDependencies?: unknown }
28
+
29
+ async function readPkg(path: string): Promise<Pkg | null> {
30
+ const f = Bun.file(path);
31
+ if (!(await f.exists())) return null;
32
+ try { return (await f.json()) as Pkg; } catch (err) { throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`); }
33
+ }
34
+
35
+ /** Reads PROJECT/.baba/package.json: `name` and the `baba` field; finds the entry and the view. */
36
+ export async function openProject(dir: string): Promise<Project> {
37
+ dir = resolve(dir);
38
+ const baba = join(dir, ".baba");
39
+ const pkg = await readPkg(join(baba, "package.json"));
40
+ if (!pkg) throw new Error(`${dir} has no .baba/package.json`);
41
+ const v = (typeof pkg.baba === "object" && pkg.baba ? pkg.baba : {}) as BabaConfig;
42
+ const name = v.name ?? pkg.name;
43
+ if (typeof name !== "string" || !NAME.test(name)) throw new Error(`${baba}/package.json: "${v.name === undefined ? "name" : "baba.name"}" must be lowercase letters, digits and dashes`);
44
+ if (PAGE_WORDS.has(name)) throw new Error(`${baba}/package.json: a baba cannot be named ${name}, its address /${name}/ is the page's own (${[...PAGE_WORDS].join(", ")})`);
45
+ const title = v.title === undefined ? name : v.title;
46
+ if (typeof title !== "string") throw new Error(`${baba}/package.json: "baba.title" must be a string`);
47
+ const harnesses = v.harnesses === undefined ? ["claude", "codex"] : v.harnesses;
48
+ if (!Array.isArray(harnesses) || !harnesses.every((h) => typeof h === "string")) throw new Error(`${baba}/package.json: "baba.harnesses" must be a list of names`);
49
+ const b = v.build ?? {};
50
+ const strings = (x: unknown, what: string) => { if (x === undefined) return [] as string[]; if (!Array.isArray(x) || !x.every((s) => typeof s === "string")) throw new Error(`${baba}/package.json: "baba.build.${what}" must be a list of strings`); return x as string[]; };
51
+ const entry = join(baba, "index.ts");
52
+ if (!(await exists(entry))) throw new Error(`${baba} has no index.ts; the entry exports default baba({ systems: [...] })`);
53
+ const view = (await exists(join(baba, "interface.tsx"))) ? join(baba, "interface.tsx") : join(baba, "view.tsx");
54
+ // A baba that depends on bun-plugin-tailwind means it: the plugin runs without being declared again.
55
+ const plugins = strings(b.plugins, "plugins");
56
+ const deps = { ...(pkg.devDependencies as Record<string, unknown> | undefined), ...(pkg.dependencies as Record<string, unknown> | undefined) };
57
+ if ("bun-plugin-tailwind" in deps && !plugins.includes("bun-plugin-tailwind")) plugins.push("bun-plugin-tailwind");
58
+ return { name, title, harnesses, dir, baba, entry, view: (await exists(view)) ? view : null, build: { plugins, assets: strings(b.assets, "assets") } };
59
+ }
60
+
61
+ export async function exists(path: string): Promise<boolean> {
62
+ try { await stat(path); return true; } catch { return false; }
63
+ }
@@ -0,0 +1,35 @@
1
+ // The root's registry of projects: DIR/projects.json, a list of directories.
2
+ // Names are read from each project's package.json when it is opened.
3
+ import { join, resolve } from "node:path";
4
+ import { mkdir } from "node:fs/promises";
5
+
6
+ export class Registry {
7
+ constructor(readonly dir: string) {}
8
+ private get path() { return join(this.dir, "projects.json"); }
9
+
10
+ async list(): Promise<string[]> {
11
+ const f = Bun.file(this.path);
12
+ if (!(await f.exists())) return [];
13
+ const j = (await f.json()) as { projects?: unknown };
14
+ return Array.isArray(j.projects) ? j.projects.filter((p): p is string => typeof p === "string") : [];
15
+ }
16
+
17
+ async add(dir: string): Promise<void> {
18
+ dir = resolve(dir);
19
+ const all = await this.list();
20
+ if (!all.includes(dir)) await this.write([...all, dir].sort());
21
+ }
22
+
23
+ async remove(dir: string): Promise<boolean> {
24
+ dir = resolve(dir);
25
+ const all = await this.list();
26
+ if (!all.includes(dir)) return false;
27
+ await this.write(all.filter((d) => d !== dir));
28
+ return true;
29
+ }
30
+
31
+ private async write(projects: string[]): Promise<void> {
32
+ await mkdir(this.dir, { recursive: true });
33
+ await Bun.write(this.path, JSON.stringify({ projects }, null, 2) + "\n");
34
+ }
35
+ }
@@ -0,0 +1,55 @@
1
+ // The worker a baba runs in without the wall: the entry imported from disk.
2
+ // For tests and the in-process path; the walled runtime gets a bundle.
3
+ // Messages in: load-dir {dir, snapshot}, then the kernel's. Out: the runtime's.
4
+ import { plugin as bunPlugin } from "bun";
5
+ import { dirname, join } from "node:path";
6
+ import type { ToRuntime, FromRuntime } from "../transport/messages.ts";
7
+ import type { Snapshot } from "../ecs/state.ts";
8
+ import { isBaba } from "../ecs/baba.ts";
9
+ import { openProject } from "./project.ts";
10
+ import { readEnv } from "../runtime/env.ts";
11
+ import { Harness } from "../runtime/harness.ts";
12
+ import { PROTOCOL } from "../transport/messages.ts";
13
+
14
+ declare const self: Worker;
15
+
16
+ // The running voss is the framework: "babavoss" in a baba resolves to it,
17
+ // never to a second copy under the project's node_modules.
18
+ const root = dirname(dirname(dirname(Bun.fileURLToPath(import.meta.url))));
19
+ const names: Record<string, string> = {
20
+ "babavoss": join(root, "index.ts"),
21
+ "babavoss/desktop": join(root, "src/desktop/index.ts"),
22
+ "babavoss/kit": join(root, "src/kit/index.ts"),
23
+ "babavoss/prompt": join(root, "src/prompt/index.ts"),
24
+ "babavoss/prompt/jsx-runtime": join(root, "src/prompt/jsx-runtime.ts"),
25
+ "babavoss/prompt/jsx-dev-runtime": join(root, "src/prompt/jsx-dev-runtime.ts"),
26
+ "babavoss/test": join(root, "src/test/index.ts"),
27
+ "babavoss/bench": join(root, "src/bench/index.ts"),
28
+ };
29
+ bunPlugin({
30
+ name: "babavoss",
31
+ setup(b) { for (const [name, path] of Object.entries(names)) b.module(name, async () => ({ exports: await import(path), loader: "object" })); },
32
+ });
33
+
34
+ export type ToWorker = Exclude<ToRuntime, { type: "load" }> | { type: "load-dir"; dir: string; snapshot: Snapshot | null; prepare?: boolean };
35
+ export type FromWorker = FromRuntime;
36
+
37
+ const post = (m: FromWorker) => self.postMessage(m);
38
+ const h = new Harness(post);
39
+ post({ type: "hello", protocol: PROTOCOL, role: "runtime" });
40
+
41
+ self.onmessage = async (e: MessageEvent<ToWorker>) => {
42
+ const m = e.data;
43
+ if (m.type === "load-dir") {
44
+ try {
45
+ const project = await openProject(m.dir);
46
+ const mod = (await import(`${project.entry}?t=${Date.now()}`)) as { default?: unknown };
47
+ if (!isBaba(mod.default)) throw new Error(`${project.entry}: the default export is not a baba(); export default baba({ systems: [...] })`);
48
+ h.load(mod.default, { name: project.name, dir: project.dir, baba: project.baba }, await readEnv(project.baba), m.snapshot, m.prepare);
49
+ } catch (err) {
50
+ post({ type: "failed", error: err instanceof Error ? err.message : String(err) });
51
+ }
52
+ return;
53
+ }
54
+ await h.handle(m);
55
+ };
@@ -0,0 +1,6 @@
1
+ // babavoss/bench: the framework's own measuring kit. A scenario is a baba
2
+ // seeded to a size; a measurement runs it for rounds with knobs set and
3
+ // answers the round times, the bytes each round shipped, the heap. The lab
4
+ // baba (babavoss/lab) shows measurements; the Lab’s `bench-start` capability runs them.
5
+ export { measure, type Knobs, type Measurement, type Summary, type Sample } from "./measure.ts";
6
+ export { scenarios, rng, type Scenario } from "./scenarios.ts";