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,603 @@
1
+ // The baba running: one state, one inbox, rounds. A round is synchronous:
2
+ // begin, apply the inbox (actions run as their system, raw writes, answers
3
+ // land), then for each system in order land its effects' answers, run its
4
+ // steps, ask for its jobs; start the jobs. Time is the round's. One
5
+ // timer waits for the earliest due moment; nothing runs while nothing is due.
6
+ import { SchemaError, type Json } from "../kernel/schema.ts";
7
+ import type { Context } from "../kernel/context.ts";
8
+ import { hasSecret, mapEntities, redact } from "./handles.ts";
9
+ import { State, StateError, jobKey, type Component, type Resource, type Effect, type Entity, type Job, type Snapshot, type Declaration } from "./state.ts";
10
+ import { isDelta, mergeDeltas, type StateAnswer, type Delta } from "./snapshot.ts";
11
+ import { codecOf, encodeFrame, type Codec } from "./codec.ts";
12
+ import { PRESENT } from "./snapshot.ts";
13
+ import type { Interest } from "../server/protocol.ts";
14
+ import { realScheduler, type Handle, type Scheduler } from "./scheduler.ts";
15
+ import type { Adapter } from "../spec/adapters.ts";
16
+ import { ofSystem, isSystem, dataOf, type Baba, type System, type SystemData } from "./baba.ts";
17
+
18
+ export class ContractError extends Error {
19
+ constructor(readonly code: "noaction" | "noquery" | "args" | "result" | "failed" | "entity" | "name" | "owner", msg: string) { super(msg); }
20
+ }
21
+
22
+ /** How the runtime reaches the port for its jobs: a context per run, named for the effect, with the run's cancel. */
23
+ export type Port = (system: string, effect: string, job: number, signal: AbortSignal, entity?: Entity | null) => Context;
24
+
25
+ /** A write at layer zero: the state's own operations, as the shell and the inspector use them. */
26
+ export type Raw =
27
+ | { op: "spawn"; parts: Record<string, unknown> }
28
+ | { op: "add"; entity: Entity; component: string; value?: unknown }
29
+ | { op: "remove"; entity: Entity; components: string[] }
30
+ | { op: "despawn"; entity: Entity }
31
+ | { op: "set"; resource: string; value: unknown };
32
+
33
+ type Input =
34
+ | { kind: "fire"; name: string; args: unknown; key?: string; resolve: (v: unknown) => void; reject: (err: Error) => void }
35
+ | { kind: "raw"; raw: Raw; resolve: (v: unknown) => void; reject: (err: Error) => void }
36
+ | { kind: "settled"; effect: Effect; entity: Entity | null; instance: number; ok: boolean; value: unknown }
37
+ | { kind: "batch"; effect: Effect; entity: Entity | null; instance: number; batch: Batch };
38
+
39
+ /** What a source delivers: the slice listed, or events watched. */
40
+ export type Batch = { listed: unknown[] } | { events: unknown[] };
41
+
42
+ /** How long a mirror waits after a "changed" before listing again, so a burst becomes one listing. */
43
+ const RELIST_MS = 100;
44
+ /** How many rounds' deltas the runtime keeps made and redacted: a follower within them is answered from the log, one further back from the table. */
45
+ const LOG_ROUNDS = 4;
46
+
47
+ export interface RuntimeOptions {
48
+ port?: Port;
49
+ /** Ticks on its own: a microtask after a fire, a timer for the earliest due moment. A testbed turns it off. */
50
+ auto?: boolean;
51
+ /** A state to continue from. */
52
+ snapshot?: Snapshot;
53
+ /** The time of the first round; the scheduler's clock by default. */
54
+ now?: () => number;
55
+ /** The clock and the timers: the machine's by default; a virtual one a driver advances for a testbed or a simulation. */
56
+ scheduler?: Scheduler;
57
+ /** The outside as a model: an effect's adapter answers its jobs instead of the port, on the scheduler. An effect with none waits for the port, or for a testbed's answer. */
58
+ adapters?: (effect: Effect) => Adapter | undefined;
59
+ /** Every value written in a round checked at its end, so the state holds only what the model admits; on by default. */
60
+ audit?: boolean;
61
+ /** The seed of `w.random()` for a fresh state; the clock by default. A kept state keeps its own. */
62
+ seed?: number;
63
+ /** Hears each round's end: what the round changed, as a delta since the one before, or the whole when there is no before. */
64
+ onRound?: (state: StateAnswer) => void;
65
+ log?: (line: string) => void;
66
+ }
67
+
68
+ export class Runtime {
69
+ readonly baba: Baba;
70
+ readonly w: State;
71
+ private rounds: number;
72
+ private inbox: Input[] = [];
73
+ private scheduled = false;
74
+ private timer: Handle | null = null;
75
+ /** The runs under way, by instance: a job asked again is a new instance, and the old run's answers are dropped. */
76
+ private started = new Map<number, { key: string; ctl: AbortController }>();
77
+ /** How to ask a running source to list again, by its run: what a mirror's landing calls when the slice said "changed". */
78
+ private relisters = new Map<number, () => void>();
79
+ /** The last event each source job landed, by key: what its watch resumes from after a failure. */
80
+ private lastEvent = new Map<string, unknown>();
81
+ /** The batches landed this round, by owning system, waiting for the system's slot. */
82
+ private batches = new Map<string, Extract<Input, { kind: "batch" }>[]>();
83
+ private soon: Handle | null = null;
84
+ private due_ = new Map<string, number>(); // jobs retrying: when they may start again
85
+ private everyDue = new Map<Effect, number>(); // scheduled effects: when they run next
86
+ private tickDue: number | null;
87
+ private waiters: { since: number; resolve: () => void }[] = [];
88
+ private jobs = 1;
89
+ private stopped = false;
90
+ private readonly port: Port | null;
91
+ private readonly adapters: ((effect: Effect) => Adapter | undefined) | null;
92
+ /** Each adapter's state in this state, by effect: what `model` keeps, what the shell shows as the outside. */
93
+ readonly outside = new Map<Effect, Map<string, unknown>>();
94
+ private readonly auto: boolean;
95
+ private readonly sched: Scheduler;
96
+ private readonly audit: boolean;
97
+ private readonly onRound: (state: StateAnswer) => void;
98
+ /** The sequence at which each system's steps last ran: what its change terms measure from. */
99
+ private ran = new Map<string, number>();
100
+ /** The sequence at the last round's end: what the audit and the next round's inbox phase measure from. */
101
+ private ended = 0;
102
+ /** The last rounds' deltas, made once each, redacted once each, laid out as a frame on first demand: what every follower is answered from. */
103
+ private deltas = new Map<number, { delta: Delta; redacted: Delta; frame: Uint8Array | null }>();
104
+ /** The whole as a frame, for the round it was made at. */
105
+ private whole: { round: number; frame: Uint8Array } | null = null;
106
+ /** The frame layout: the model's components and resources, in order. */
107
+ readonly codec: Codec;
108
+ /** Whether any declaration has a secret field: without one, nothing leaves redacted, so nothing is copied. */
109
+ private readonly secrets: boolean;
110
+ private readonly log: (line: string) => void;
111
+
112
+ constructor(v: Baba | System, o: RuntimeOptions = {}) {
113
+ this.baba = isSystem(v) ? ofSystem(v) : v;
114
+ this.port = o.port ?? null;
115
+ this.adapters = o.adapters ?? null;
116
+ this.auto = o.auto ?? true;
117
+ this.sched = o.scheduler ?? (o.now ? { ...realScheduler(), now: o.now } : realScheduler());
118
+ this.audit = o.audit ?? true;
119
+ this.onRound = o.onRound ?? (() => {});
120
+ this.log = o.log ?? (() => {});
121
+ this.w = o.snapshot ? State.restore(o.snapshot, this.baba.model, (what) => this.log(`the kept state was fitted to the code: ${what}`)) : new State(this.baba.model);
122
+ if (!o.snapshot) this.w.seed(o.seed ?? (this.sched.now() ^ (Math.random() * 2 ** 31)) >>> 0);
123
+ this.rounds = o.snapshot?.round ?? 0;
124
+ this.w.round = this.rounds;
125
+ this.w.end();
126
+ this.ended = this.w.seq;
127
+ this.tickDue = this.baba.tick === null ? null : 0;
128
+ for (const e of this.baba.model.effects) if (e.every !== null) this.everyDue.set(e, 0);
129
+ this.codec = codecOf(this.baba.model.components.map((c) => ({ name: c.name, json: c.schema.json })), this.baba.model.resources.map((r) => ({ name: r.name, json: r.schema.json })));
130
+ this.secrets = [...this.baba.model.components, ...this.baba.model.resources].some((d) => hasSecret(d.schema.json)) || this.baba.model.effects.some((e) => hasSecret(e.args.json));
131
+ if (this.auto) this.schedule();
132
+ }
133
+
134
+ get round(): number { return this.rounds; }
135
+
136
+ /** What `name` is: an action, a query, or nothing. */
137
+ kind(name: string): "action" | "query" | null {
138
+ return name in this.baba.actions ? "action" : name in this.baba.queries ? "query" : null;
139
+ }
140
+
141
+ /**
142
+ * Fires an action: it runs at the start of the next round, as its system; the
143
+ * answer is what it returned. With an idempotency `key` the state has seen,
144
+ * the answer is the one remembered, and the action does not run again.
145
+ */
146
+ fire(name: string, args: unknown = {}, coerce = false, key?: string): Promise<unknown> {
147
+ const d = this.baba.actions[name];
148
+ if (!d) return Promise.reject(new ContractError("noaction", `no action ${name}; actions: ${Object.keys(this.baba.actions).join(", ")}`));
149
+ let checked: unknown;
150
+ try { checked = d.args.check(args ?? {}, "", coerce); } catch (e) { return Promise.reject(new ContractError("args", e instanceof SchemaError ? e.message : String(e))); }
151
+ return new Promise((resolve, reject) => { this.inbox.push({ kind: "fire", name, args: checked, ...(key !== undefined ? { key } : {}), resolve, reject }); this.schedule(); });
152
+ }
153
+
154
+ /**
155
+ * Runs an action now, as its system, over the state as it is: the round's and
156
+ * the testbed's. `args` are already checked; an `s.entity(component)` field
157
+ * is resolved from a key and checked to carry its component first. All or
158
+ * nothing: a throw or a bad result undoes every write it made.
159
+ */
160
+ act(name: string, args: unknown): unknown {
161
+ const d = this.baba.actions[name];
162
+ if (!d) throw new ContractError("noaction", `no action ${name}; actions: ${Object.keys(this.baba.actions).join(", ")}`);
163
+ const w = this.w;
164
+ args = this.entities(d.args.json, args);
165
+ return w.transaction(() => {
166
+ const was = w.writer;
167
+ w.writer = d.owner;
168
+ let r: unknown;
169
+ try { r = d.run(w, args); } catch (e) {
170
+ if (e instanceof StateError) throw new ContractError(e.code === "value" ? "args" : e.code, `${name}: ${e.message}`);
171
+ throw new ContractError("failed", `${name}: ${e instanceof Error ? e.message : String(e)}`);
172
+ } finally { w.writer = was; }
173
+ if (r === undefined) r = null;
174
+ try { return d.result.check(r); } catch (e) { throw new ContractError("result", `${name} returned a bad result: ${e instanceof SchemaError ? e.message : String(e)}`); }
175
+ });
176
+ }
177
+
178
+ /** The args with every `s.entity(component)` field an entity carrying it: a key resolved by `w.find`, an id checked. */
179
+ private entities(json: Json, args: unknown): unknown {
180
+ const w = this.w;
181
+ return mapEntities(json, args, (name, v, path) => {
182
+ const c = w.declaration(name);
183
+ if (!c || c.kind !== "component") throw new ContractError("entity", `${path}: no component ${name}`);
184
+ let e = v;
185
+ if (typeof v === "string") {
186
+ let found: number | undefined;
187
+ try { found = w.find(c as Component, v); } catch { throw new ContractError("entity", `${path}: ${name} has no key field; pass the entity id`); }
188
+ if (found === undefined) throw new ContractError("entity", `${path}: no ${name} ${JSON.stringify(v)}`);
189
+ e = found;
190
+ }
191
+ if (typeof e !== "number" || !w.has(e, c as Component)) throw new ContractError("entity", `${path}: entity ${String(e)} has no ${name}`);
192
+ return e;
193
+ });
194
+ }
195
+
196
+ /** Reads a query, now, between rounds. */
197
+ read(name: string, args: unknown = {}, coerce = false): unknown {
198
+ const q = this.baba.queries[name];
199
+ if (!q) throw new ContractError("noquery", `no query ${name}; queries: ${Object.keys(this.baba.queries).join(", ")}`);
200
+ let checked: unknown;
201
+ try { checked = q.args.check(args ?? {}, "", coerce); } catch (e) { throw new ContractError("args", e instanceof SchemaError ? e.message : String(e)); }
202
+ let r: unknown;
203
+ try { r = q.read(this.w, checked); } catch (e) { throw new ContractError("failed", `${name}: ${e instanceof Error ? e.message : String(e)}`); }
204
+ try { return q.result.check(r); } catch (e) { throw new ContractError("result", `${name} read a bad result: ${e instanceof SchemaError ? e.message : String(e)}`); }
205
+ }
206
+
207
+ /** A write at layer zero, applied at the next round: spawn answers the entity, the rest what the entity or resource holds. */
208
+ raw(raw: Raw): Promise<unknown> {
209
+ return new Promise((resolve, reject) => { this.inbox.push({ kind: "raw", raw, resolve, reject }); this.schedule(); });
210
+ }
211
+
212
+ /**
213
+ * The state as kept, with the round. `redacted`: every secret field of a
214
+ * component, a resource or a job's args replaced by •••, and no remembered
215
+ * answers: what leaves the runtime toward a window, the inspector or `state`.
216
+ */
217
+ snapshot(o: { redacted?: boolean } = {}): Snapshot {
218
+ const snap = { ...this.w.snapshot(), round: this.rounds };
219
+ return o.redacted ? this.redacted(snap) as Snapshot : snap;
220
+ }
221
+
222
+ /**
223
+ * The state since round `since`: a delta when the state remembers that
224
+ * round, else the whole; `redacted` as `snapshot`. A follower a few rounds
225
+ * behind is answered from the log of deltas made at each round's end, so
226
+ * what following costs is paid once per round, not once per follower.
227
+ */
228
+ state(since: number, o: { redacted?: boolean } = {}): StateAnswer {
229
+ if (since > this.rounds) since = 0;
230
+ // Since the round just ended: the empty delta the state answers, without a walk.
231
+ if (since > 0 && since === this.rounds) return this.w.delta(since) ?? this.snapshot(o);
232
+ const d = since > 0 ? this.since(since, o.redacted === true) : null;
233
+ return d ?? this.snapshot(o);
234
+ }
235
+
236
+ /** The deltas since `since` as one, from the log when it holds them all, else measured from the table; null when the whole must go. */
237
+ private since(since: number, redacted: boolean): Delta | null {
238
+ if (this.rounds - since <= LOG_ROUNDS) {
239
+ const parts: Delta[] = [];
240
+ for (let r = since + 1; r <= this.rounds; r++) { const l = this.deltas.get(r); if (!l) break; parts.push(redacted ? l.redacted : l.delta); }
241
+ if (parts.length === this.rounds - since) return parts.length === 1 ? parts[0]! : mergeDeltas(parts);
242
+ }
243
+ const d = this.w.delta(since);
244
+ if (!d) return null;
245
+ return redacted ? this.redacted(d) : d;
246
+ }
247
+
248
+ /** The state since `since` as a frame, redacted: the round just ended is laid out once and shared by every follower. */
249
+ frame(since: number): Uint8Array {
250
+ if (since > 0 && since === this.rounds - 1) {
251
+ const l = this.deltas.get(this.rounds);
252
+ if (l) { if (!l.frame) l.frame = encodeFrame(this.codec, l.redacted); return l.frame; }
253
+ }
254
+ const s = this.state(since, { redacted: true });
255
+ if (isDelta(s)) return encodeFrame(this.codec, s);
256
+ if (this.whole?.round !== this.rounds) this.whole = { round: this.rounds, frame: encodeFrame(this.codec, s) };
257
+ return this.whole.frame;
258
+ }
259
+
260
+ /**
261
+ * The parts of the state a window began to ask for, as a frame since the
262
+ * round just ended: the presence of some components on every entity that
263
+ * has them, the values of others, some parts, some resources. Every part
264
+ * is marked as arrived, so a follower of presence alone takes it.
265
+ */
266
+ parts(interest: Interest): Uint8Array {
267
+ const w = this.w;
268
+ const entities: Delta["entities"] = {};
269
+ const added: Record<string, string[]> = {};
270
+ const put = (e: Entity, name: string, v: unknown) => { (entities[String(e)] ??= {})![name] = v; (added[String(e)] ??= []).push(name); };
271
+ const comp = (name: string) => { const c = w.declaration(name); return c?.kind === "component" ? c as Component : null; };
272
+ for (const name of interest.presence ?? []) { const c = comp(name); if (c) for (const e of w.idsOf(c)) put(e, name, PRESENT); }
273
+ for (const name of interest.values ?? []) { const c = comp(name); if (c) for (const e of w.idsOf(c)) put(e, name, w.get(e, c)); }
274
+ for (const [name, es] of Object.entries(interest.parts ?? {})) { const c = comp(name); if (c) for (const e of es) if (w.alive(e) && w.has(e, c)) put(e, name, w.get(e, c)); }
275
+ const resources: Record<string, unknown> = {};
276
+ for (const name of interest.resources ?? []) { const r = w.declaration(name); if (r?.kind === "resource") resources[name] = w.get(r as never); }
277
+ const d: Delta = { kind: "delta", round: this.rounds, since: this.rounds, entities, resources, now: w.now, added };
278
+ return encodeFrame(this.codec, this.redacted(d));
279
+ }
280
+
281
+ private redacted<S extends Snapshot | Delta>(s: S): S {
282
+ if (!this.secrets) { if (isDelta(s) ? s.keys === undefined : s.keys === undefined || Object.keys(s.keys).length === 0) return s; return { ...s, keys: {} }; }
283
+ const decl = (n: string) => this.w.declaration(n);
284
+ const entities: Record<string, Record<string, unknown> | null> = {};
285
+ for (const [id, parts] of Object.entries(s.entities)) entities[id] = parts === null ? null : Object.fromEntries(Object.entries(parts).map(([n, v]) => [n, v === null || v === PRESENT ? v : redact((decl(n) as Component).schema.json, v)]));
286
+ const resources = Object.fromEntries(Object.entries(s.resources).map(([n, v]) => [n, redact((decl(n) as Resource).schema.json, v)]));
287
+ const out = { ...s, entities, resources } as S;
288
+ if (s.jobs !== undefined) out.jobs = s.jobs.map((j) => ({ ...j, args: redact((decl(j.effect) as Effect).args.json, j.args) }));
289
+ if (s.keys !== undefined || !isDelta(s)) out.keys = {};
290
+ return out;
291
+ }
292
+
293
+ /** Resolves once a round past `since` has run, or after `ms`. */
294
+ waitRound(since: number, ms: number): Promise<void> {
295
+ if (this.rounds > since || this.stopped) return Promise.resolve();
296
+ return new Promise<void>((resolve) => {
297
+ const w = { since, resolve: () => { clearTimeout(timer); resolve(); } };
298
+ const timer = setTimeout(() => { this.waiters = this.waiters.filter((x) => x !== w); resolve(); }, ms);
299
+ (timer as unknown as { unref?: () => void }).unref?.();
300
+ this.waiters.push(w);
301
+ });
302
+ }
303
+
304
+ /** Cancels every job and timer; the state is left as it is, for the snapshot. */
305
+ stop(): Snapshot {
306
+ this.stopped = true;
307
+ this.timer?.cancel(); this.timer = null;
308
+ this.soon?.cancel(); this.soon = null;
309
+ for (const s of this.started.values()) s.ctl.abort();
310
+ this.started.clear();
311
+ for (const i of this.inbox.splice(0)) if (i.kind === "fire" || i.kind === "raw") i.reject(new ContractError("failed", "the baba stopped"));
312
+ for (const w of this.waiters.splice(0)) w.resolve();
313
+ return this.snapshot();
314
+ }
315
+
316
+ /** The jobs not yet started, as a testbed sees them. */
317
+ pending(effect?: Effect): Job[] {
318
+ return [...this.w.jobs.values()].filter((j) => (!effect || j.effect === effect) && !this.started.has(j.instance));
319
+ }
320
+
321
+ /** A job's answer, as the port or a testbed gives it: applied at the next round. `instance` says which run answers; another's is dropped. */
322
+ answer(effect: Effect, entity: Entity | null, instance: number, ok: boolean, value: unknown): void {
323
+ this.inbox.push({ kind: "settled", effect, entity, instance, ok, value });
324
+ this.schedule();
325
+ }
326
+
327
+ /** A source's batch, as the driver or a testbed gives it: lands at the next round, in the owner's slot. Every batch in flight lands in the same round. */
328
+ private queuedBytes = new Map<number, number>();
329
+
330
+ deliver(effect: Effect, entity: Entity | null, instance: number, batch: Batch): void {
331
+ const bytes = (this.queuedBytes.get(instance) ?? 0) + (effect.mirror ? 0 : JSON.stringify(batch).length * 2);
332
+ if (bytes > 1024 * 1024) throw new Error(`${effect.name}: source events exceed 1 MiB before a round; reduce or coalesce events`);
333
+ this.queuedBytes.set(instance, bytes);
334
+ this.inbox.push({ kind: "batch", effect, entity, instance, batch });
335
+ this.scheduleSoon();
336
+ }
337
+
338
+ /** The earliest moment a round is due on its own: a retry, a scheduled effect, the tick; null when nothing is. */
339
+ due(): number | null {
340
+ let earliest: number | null = null;
341
+ const consider = (at: number | null | undefined) => { if (at !== null && at !== undefined && (earliest === null || at < earliest)) earliest = at; };
342
+ for (const at of this.due_.values()) consider(at);
343
+ for (const [e, at] of this.everyDue) if (!this.w.running(e)) consider(at);
344
+ consider(this.tickDue);
345
+ return earliest;
346
+ }
347
+
348
+ /** The time, as the scheduler has it. */
349
+ now(): number { return this.sched.now(); }
350
+
351
+ /** One round, at `now`. */
352
+ step(now = this.sched.now()): void {
353
+ if (this.stopped) return;
354
+ this.scheduled = false;
355
+ this.timer?.cancel(); this.timer = null;
356
+ this.soon?.cancel(); this.soon = null;
357
+ this.rounds++;
358
+ const w = this.w;
359
+ w.begin(now, this.rounds);
360
+ w.writer = null;
361
+ const before = this.ended; // the previous round's end: what the inbox phase, and reads between rounds, measure from
362
+ w.since = before;
363
+ // Jobs cut off by a crash, of effects not safe to repeat: failed, once, never retried.
364
+ for (const j of w.interrupted()) {
365
+ w.settle(j.effect, j.entity, false, "interrupted", false);
366
+ if (j.effect.every !== null) this.everyDue.set(j.effect, now + j.effect.every);
367
+ }
368
+ this.queuedBytes.clear();
369
+ // The inbox: actions run as their system, raw writes happen, answers land.
370
+ for (const i of this.inbox.splice(0)) {
371
+ if (i.kind === "fire") {
372
+ const kept = i.key === undefined ? undefined : w.recall(i.key);
373
+ if (kept) { i.resolve(kept.value); continue; }
374
+ try {
375
+ const v = this.act(i.name, i.args);
376
+ if (i.key !== undefined) w.remember(i.key, v);
377
+ i.resolve(v);
378
+ } catch (err) { i.reject(err instanceof Error ? err : new Error(String(err))); }
379
+ } else if (i.kind === "raw") {
380
+ try { i.resolve(w.transaction(() => this.apply(i.raw))); } catch (err) { i.reject(err instanceof Error ? err : new Error(String(err))); }
381
+ } else if (i.kind === "batch") {
382
+ const job = w.jobs.get(jobKey(i.effect, i.entity));
383
+ if (!job || job.instance !== i.instance) continue; // a run that was cancelled or asked again: its batches are not news
384
+ if (!this.batches.has(i.effect.owner)) this.batches.set(i.effect.owner, []);
385
+ this.batches.get(i.effect.owner)!.push(i);
386
+ } else {
387
+ const key = jobKey(i.effect, i.entity);
388
+ const job = w.jobs.get(key);
389
+ if (!job || job.instance !== i.instance) continue;
390
+ this.started.get(i.instance)?.ctl.abort();
391
+ this.started.delete(i.instance);
392
+ const r = w.settle(i.effect, i.entity, i.ok, i.value);
393
+ if (r === "retry") this.due_.set(key, now + i.effect.retry!);
394
+ if (i.effect.every !== null && r !== "retry") this.everyDue.set(i.effect, now + i.effect.every);
395
+ if (r !== "retry") this.lastEvent.delete(key);
396
+ }
397
+ }
398
+ for (const [key, at] of [...this.due_]) if (at <= now) { this.due_.delete(key); const j = w.jobs.get(key); if (j) j.status = "waiting"; }
399
+ if (this.tickDue !== null && this.tickDue <= now) this.tickDue = now + this.baba.tick!;
400
+ // The systems, in order: answers land, steps run, jobs are asked for.
401
+ for (const c of this.baba.systems.map(dataOf)) {
402
+ w.writer = c.name;
403
+ w.since = this.ran.get(c.name) ?? 0;
404
+ // The sources' batches land first, as the owner: a mirror is kept, a stream's events are readable this round.
405
+ for (const b of this.batches.get(c.name) ?? []) {
406
+ // Vetted in the inbox phase, when its run was the job's: a poll's listing lands even when this same round settled its run as done.
407
+ const job = w.jobs.get(jobKey(b.effect, b.entity));
408
+ const { relist } = w.land(b.effect, b.entity, b.batch, (what) => this.log(`${c.name}: ${what}`));
409
+ if (job && job.instance === b.instance) { job.events += "listed" in b.batch ? b.batch.listed.length : b.batch.events.length; job.last = now; }
410
+ if (relist) this.relisters.get(b.instance)?.();
411
+ }
412
+ this.batches.delete(c.name);
413
+ for (const [effect, b] of c.bindings) {
414
+ if (b.done) for (const d of w.done(effect)) this.guard(c, `${effect.name}.done`, () => b.done!(w, d.entity, d.result));
415
+ if (b.failed) for (const f of w.failed(effect)) this.guard(c, `${effect.name}.failed`, () => b.failed!(w, f.entity, f.error));
416
+ }
417
+ for (const st of c.steps) this.guard(c, st.name, () => st.run(w));
418
+ for (const [effect, b] of c.bindings) {
419
+ if (!b.for) continue;
420
+ if (effect.shape !== "source") { this.guard(c, `${effect.name}.for`, () => { for (const [entity, args] of b.for!(w)) w.run(effect, args, entity); }); continue; }
421
+ // A source's `for` is the set wanted: an entity no longer in it loses its job, one whose args changed gets a new one.
422
+ this.guard(c, `${effect.name}.for`, () => {
423
+ const wanted = new Map<Entity, unknown>();
424
+ for (const [entity, args] of b.for!(w)) wanted.set(entity, args ?? {});
425
+ for (const job of [...w.jobs.values()]) {
426
+ if (job.effect !== effect || job.entity === null) continue;
427
+ const args = wanted.get(job.entity);
428
+ if (args === undefined || JSON.stringify(args) !== JSON.stringify(job.args)) w.cancel(effect, job.entity);
429
+ }
430
+ for (const [entity, args] of wanted) w.run(effect, args as never, entity);
431
+ });
432
+ }
433
+ for (const effect of c.model.effects) {
434
+ const at = this.everyDue.get(effect);
435
+ if (at !== undefined && at <= now && !w.running(effect)) w.run(effect);
436
+ }
437
+ this.ran.set(c.name, w.seq);
438
+ }
439
+ w.writer = null;
440
+ if (this.audit && !w.checking) w.audit(this.ended, (what) => this.log(`a write the model refuses: ${what}`));
441
+ this.start();
442
+ w.end();
443
+ this.ended = w.seq;
444
+ w.since = before; // between rounds, change terms answer what the last round changed
445
+ for (const x of this.waiters.splice(0)) if (this.rounds > x.since) x.resolve(); else this.waiters.push(x);
446
+ const d = this.w.delta(this.rounds - 1);
447
+ if (d) {
448
+ this.deltas.set(this.rounds, { delta: d, redacted: this.redacted(d), frame: null });
449
+ for (const r of this.deltas.keys()) if (r <= this.rounds - LOG_ROUNDS) this.deltas.delete(r);
450
+ }
451
+ this.onRound(d ?? this.snapshot());
452
+ this.arm(now);
453
+ }
454
+
455
+ private guard(c: SystemData, what: string, f: () => void): void {
456
+ try { f(); } catch (err) { this.log(`${c.name} ${what}: ${err instanceof Error ? err.message : String(err)}`); }
457
+ }
458
+
459
+ private apply(raw: Raw): unknown {
460
+ const w = this.w;
461
+ const need = (name: string, kind: Declaration["kind"]): Declaration => {
462
+ const d = w.declaration(name);
463
+ if (!d || d.kind !== kind) throw new ContractError("name", `no ${kind} ${name}`);
464
+ return d;
465
+ };
466
+ const alive = (e: Entity) => { if (!w.alive(e)) throw new ContractError("entity", `no entity ${e}`); return e; };
467
+ try {
468
+ switch (raw.op) {
469
+ case "spawn": { const e = w.spawn(); for (const [n, v] of Object.entries(raw.parts)) { const c = need(n, "component") as Component; w.add(e, c as never, w.check(c, v) as never); } return e; }
470
+ case "add": { const c = need(raw.component, "component") as Component; w.add(alive(raw.entity), c as never, w.check(c, raw.value ?? true) as never); return w.of(raw.entity); }
471
+ case "remove": w.remove(alive(raw.entity), ...raw.components.map((n) => need(n, "component") as never)); return w.of(raw.entity);
472
+ case "despawn": w.despawn(alive(raw.entity)); return null;
473
+ case "set": { const r = need(raw.resource, "resource") as Resource; w.set(r as never, w.check(r, raw.value) as never); return w.get(r as never); }
474
+ }
475
+ } catch (err) {
476
+ if (err instanceof StateError) throw new ContractError(err.code === "value" ? "args" : err.code, err.message);
477
+ throw err;
478
+ }
479
+ }
480
+
481
+ /** Starts every job waiting; cancels every run whose job the state no longer has, or has again as a new instance. */
482
+ private start(): void {
483
+ for (const [instance, s] of [...this.started]) if (this.w.jobs.get(s.key)?.instance !== instance) { s.ctl.abort(); this.started.delete(instance); }
484
+ for (const key of [...this.due_.keys()]) if (!this.w.jobs.has(key)) this.due_.delete(key);
485
+ if (!this.port && !this.adapters) return;
486
+ for (const job of this.w.jobs.values()) {
487
+ const key = jobKey(job.effect, job.entity);
488
+ if (this.started.has(job.instance) || job.status === "retrying" || job.status === "interrupted") continue;
489
+ const adapter = this.adapters?.(job.effect);
490
+ if (!adapter && !this.port) continue;
491
+ const ctl = new AbortController();
492
+ this.started.set(job.instance, { key, ctl });
493
+ job.status = "running";
494
+ const { effect, entity, args, instance } = job;
495
+ if (adapter) { this.adapt(job, adapter, ctl); continue; }
496
+ const ctx = this.port!(effect.owner, effect.name, instance, ctl.signal, entity);
497
+ if (effect.shape === "source") { void this.drive(job, key, ctx, ctl); continue; }
498
+ Promise.resolve().then(() => effect.run(args, ctx)).then(
499
+ (v) => { if (!ctl.signal.aborted) this.answer(effect, entity, instance, true, v); },
500
+ (err) => { if (!ctl.signal.aborted) this.answer(effect, entity, instance, false, err instanceof Error ? err.message : String(err)); },
501
+ );
502
+ }
503
+ }
504
+
505
+ /** Runs a job against its adapter: a request's reply lands after its delay; a source's listing at once, its events at their moments, listed again every `relist`. */
506
+ private adapt(job: Job, adapter: Adapter, ctl: AbortController): void {
507
+ const { effect, entity, args, instance } = job;
508
+ const signal = ctl.signal;
509
+ if (!this.outside.has(effect)) this.outside.set(effect, new Map());
510
+ const ctx = { now: this.sched.now(), entity, state: this.outside.get(effect)! };
511
+ // What the outside sends is news: a round follows it, as one follows the port's answers, whether or not the runtime ticks on its own.
512
+ const at = (ms: number, fn: () => void) => { const h = this.sched.at(this.sched.now() + ms, () => { if (signal.aborted) return; fn(); this.roundSoon(); }); signal.addEventListener("abort", () => h.cancel(), { once: true }); };
513
+ try {
514
+ if (adapter.kind === "request") {
515
+ const r = adapter.reply(args, ctx);
516
+ if (r.answer) { const a = r.answer; at(r.after, () => this.answer(effect, entity, instance, a.ok, a.ok ? a.value : a.error)); }
517
+ return;
518
+ }
519
+ if (effect.shape !== "source") { this.answer(effect, entity, instance, false, `${effect.name} is a request; its adapter is a source`); return; }
520
+ job.phase = "list";
521
+ const list = () => { try { this.deliver(effect, entity, instance, { listed: adapter.listing(args, { ...ctx, now: this.sched.now() }) }); } catch (e) { this.answer(effect, entity, instance, false, e instanceof Error ? e.message : String(e)); } };
522
+ if (effect.list) { at(0, list); this.relisters.set(instance, () => at(0, list)); }
523
+ if (adapter.relist !== null && effect.list) { const again = () => at(adapter.relist!, () => { list(); again(); }); again(); }
524
+ if (effect.watch) { job.phase = "watch"; for (const ev of adapter.events(args, ctx)) at(ev.at, () => { this.lastEvent.set(jobKey(effect, entity), ev.event); this.deliver(effect, entity, instance, { events: [ev.event] }); }); }
525
+ } catch (e) { this.answer(effect, entity, instance, false, e instanceof Error ? e.message : String(e)); }
526
+ }
527
+
528
+ /**
529
+ * Drives a source's run: list, then watch, delivering batches; a "changed"
530
+ * from the watch or from the mirror's landing lists again after a short
531
+ * quiet; `every` lists again on a schedule. Ends as a request does: done
532
+ * when the watch ends, failed when anything throws, retried per the effect.
533
+ */
534
+ private async drive(job: Job, key: string, ctx: Context, ctl: AbortController): Promise<void> {
535
+ const { effect, entity, args, instance } = job;
536
+ const signal = ctl.signal;
537
+ const deliver = (batch: Batch) => { if (!signal.aborted) this.deliver(effect, entity, instance, batch); };
538
+ let listing: Promise<void> | null = null;
539
+ const list = () => {
540
+ if (!effect.list || signal.aborted) return Promise.resolve();
541
+ if (!listing) listing = Promise.resolve().then(() => effect.list!(args, ctx)).then((items) => deliver({ listed: items })).finally(() => { listing = null; });
542
+ return listing;
543
+ };
544
+ const timers = new Map<"relist" | "every", Handle>();
545
+ const stop = (k: "relist" | "every") => { timers.get(k)?.cancel(); timers.delete(k); };
546
+ const relistSoon = () => { stop("relist"); timers.set("relist", this.sched.at(this.sched.now() + RELIST_MS, () => { timers.delete("relist"); void list().catch(() => {}); })); };
547
+ this.relisters.set(instance, relistSoon);
548
+ // `every` on a watched source: list again on a schedule, as long as the run lives.
549
+ if (effect.every !== null && effect.watch) { const again = () => { timers.set("every", this.sched.at(this.sched.now() + effect.every!, () => { void list().catch(() => {}); again(); })); }; again(); }
550
+ try {
551
+ job.phase = "list";
552
+ await list();
553
+ if (effect.watch) {
554
+ job.phase = "watch";
555
+ let buffer: unknown[] = [];
556
+ let flushing = false;
557
+ const flush = () => { flushing = false; if (buffer.length) { const b = buffer; buffer = []; try { deliver({ events: b }); } catch (e) { this.answer(effect, entity, instance, false, String(e)); ctl.abort(); } } };
558
+ for await (const ev of effect.watch(args, ctx, this.lastEvent.get(key))) {
559
+ if (signal.aborted) break;
560
+ this.lastEvent.set(key, ev);
561
+ const o = ev as { changed?: unknown; resync?: unknown } | null;
562
+ if (effect.mirror && o && typeof o === "object" && (o.changed === true || o.resync === true)) { relistSoon(); continue; }
563
+ buffer.push(ev);
564
+ if (!flushing) { flushing = true; queueMicrotask(flush); }
565
+ }
566
+ flush();
567
+ }
568
+ if (!signal.aborted) this.answer(effect, entity, instance, true, undefined);
569
+ } catch (err) {
570
+ if (!signal.aborted) this.answer(effect, entity, instance, false, err instanceof Error ? err.message : String(err));
571
+ } finally {
572
+ this.relisters.delete(instance);
573
+ stop("relist");
574
+ stop("every");
575
+ }
576
+ }
577
+
578
+ /** The earliest due moment, if any, as a timer on the scheduler: a round then. With `auto` off the timer is still set, so a driver that advances a virtual clock gets the round at its moment. */
579
+ private arm(_now: number): void {
580
+ if (this.stopped) return;
581
+ const earliest = this.due();
582
+ if (earliest === null) return;
583
+ this.timer = this.sched.at(earliest, () => { this.timer = null; this.step(); });
584
+ }
585
+
586
+ private schedule(): void {
587
+ if (!this.auto || this.scheduled || this.stopped) return;
588
+ this.scheduled = true;
589
+ queueMicrotask(() => { if (this.scheduled) this.step(); });
590
+ }
591
+
592
+ /** A round on the next turn of the event loop, not this one: every message already queued lands in it. */
593
+ private scheduleSoon(): void {
594
+ if (!this.auto) return;
595
+ this.roundSoon();
596
+ }
597
+
598
+ /** A round soon, auto or not: what an adapter's answer asks for, so a simulated state moves when its outside speaks. */
599
+ private roundSoon(): void {
600
+ if (this.soon || this.stopped) return;
601
+ this.soon = this.sched.soon(() => { this.soon = null; this.step(); });
602
+ }
603
+ }