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/door/core.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// The one core both doors stand on: the CLI and the MCP server are
|
|
2
|
+
// projections of it, so the contract never lives in a door's own shape. The
|
|
3
|
+
// index and a call's schemas, read from the manifest; follow, watch and state
|
|
4
|
+
// over a small Api both doors' clients satisfy; the output budget; the closed
|
|
5
|
+
// set of error codes and the one error object. Pure where it can be; the rest
|
|
6
|
+
// reads the state through the Api and writes an over-budget answer to a file.
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import type { Manifest, Call } from "../ecs/baba.ts";
|
|
10
|
+
import type { Snapshot } from "../ecs/state.ts";
|
|
11
|
+
import { follow, type StateAnswer } from "../ecs/snapshot.ts";
|
|
12
|
+
import type { Json } from "../kernel/schema.ts";
|
|
13
|
+
import { exampleOf, keyField } from "../ecs/handles.ts";
|
|
14
|
+
|
|
15
|
+
type O = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
/** The state as a door reaches it: the shell's client and the MCP server's daemon client both are one. */
|
|
18
|
+
export interface Api {
|
|
19
|
+
describe(): Promise<unknown>;
|
|
20
|
+
/** An action; `key` is its idempotency key. */
|
|
21
|
+
fire(name: string, args: unknown, key?: string): Promise<unknown>;
|
|
22
|
+
read(name: string, args: unknown): Promise<unknown>;
|
|
23
|
+
/** The state since round `since`, once a round past it has run or after the daemon's wait: a delta, or the whole for -1; `signal` cuts it short. */
|
|
24
|
+
state(since: number, signal?: AbortSignal): Promise<StateAnswer>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---- errors -----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** The closed set of codes, each with whether the same call may succeed if tried again. The shell adds an exit status to each. */
|
|
30
|
+
export const CODES = {
|
|
31
|
+
failed: false, result: false, down: true, busy: true, timeout: true, unknown: true,
|
|
32
|
+
usage: false, args: false, noaction: false, noquery: false, name: false, owner: false, noproject: false,
|
|
33
|
+
entity: false, gone: false, nokey: false,
|
|
34
|
+
} as const satisfies Record<string, boolean>;
|
|
35
|
+
export type Code = keyof typeof CODES;
|
|
36
|
+
|
|
37
|
+
/** Codes from elsewhere that mean one of ours. */
|
|
38
|
+
const ALIAS: Record<string, Code> = { value: "args", notool: "noaction", notfound: "failed", load: "failed", unauthorized: "down" };
|
|
39
|
+
|
|
40
|
+
export const codeOf = (code: string): Code => code in CODES ? code as Code : ALIAS[code] ?? "failed";
|
|
41
|
+
|
|
42
|
+
/** An error of the set, with the call's usage line or its idempotency key when they help. */
|
|
43
|
+
export class DoorError extends Error {
|
|
44
|
+
constructor(readonly code: Code, msg: string, readonly extra: { usage?: string; key?: string } = {}) { super(msg); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ErrorBody { code: Code; message: string; usage?: string; retry: boolean; key?: string }
|
|
48
|
+
/** The one error object both doors answer. */
|
|
49
|
+
export interface Failure { error: ErrorBody }
|
|
50
|
+
|
|
51
|
+
export function failure(code: string, message: string, extra: { usage?: string; key?: string } = {}): Failure {
|
|
52
|
+
const c = codeOf(code);
|
|
53
|
+
return { error: { code: c, message, ...(extra.usage && (c === "args" || c === "usage") ? { usage: extra.usage } : {}), retry: CODES[c], ...(extra.key ? { key: extra.key } : {}) } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Anything thrown, as the error body: a DoorError's code, any other error's `code` mapped into the set, else failed. */
|
|
57
|
+
export function errorOf(err: unknown): ErrorBody {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
if (err instanceof DoorError) return failure(err.code, message, err.extra).error;
|
|
60
|
+
const e = err as { code?: unknown; key?: unknown } | null;
|
|
61
|
+
const code = typeof e?.code === "string" ? e.code : "failed";
|
|
62
|
+
return failure(code, message, typeof e?.key === "string" ? { key: e.key } : {}).error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- the index and a call's schemas ---------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface Example { cli: string; mcp: { name: string; args: unknown } }
|
|
68
|
+
export interface IndexCall { name: string; kind: "action" | "query"; system: string; summary: string; example: Example }
|
|
69
|
+
export interface Index {
|
|
70
|
+
via?: string;
|
|
71
|
+
round?: number;
|
|
72
|
+
gen?: number;
|
|
73
|
+
error?: string;
|
|
74
|
+
systems: { name: string }[];
|
|
75
|
+
/** What the baba tells agents: whether it has a context, and its skills by name. */
|
|
76
|
+
promptware: { context: boolean; skills: string[] };
|
|
77
|
+
calls: IndexCall[];
|
|
78
|
+
components: Record<string, { system: string; key?: string; accept?: true }>;
|
|
79
|
+
resources: string[];
|
|
80
|
+
counts: { systems: number; actions: number; queries: number; components: number; resources: number; effects: number };
|
|
81
|
+
}
|
|
82
|
+
export interface Schemas { name: string; kind: "action" | "query"; system: string; summary: string; args: Json; result: Json; positional?: string[]; example: Example }
|
|
83
|
+
|
|
84
|
+
/** What the index carries beside the manifest: where the state is, its round and generation, the last load's error; `prog` spells the CLI ("voss baba"). */
|
|
85
|
+
export interface IndexExtra { via?: string; round?: number; gen?: number; error?: string | null; prog?: string }
|
|
86
|
+
|
|
87
|
+
const isObj = (v: unknown): v is O => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
88
|
+
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A call to copy: the CLI as one JSON word, its secrets read from stdin
|
|
92
|
+
* (`--NAME @-`), never on the command line; the MCP as `call`'s arguments,
|
|
93
|
+
* a secret as the redaction mark to replace.
|
|
94
|
+
*/
|
|
95
|
+
export function exampleCall(name: string, c: Call, prog = "voss baba"): Example {
|
|
96
|
+
const ex = exampleOf(c.args);
|
|
97
|
+
const args = isObj(ex) ? ex : {};
|
|
98
|
+
const props = (c.args.properties ?? {}) as Record<string, Json>;
|
|
99
|
+
const secrets = Object.keys(args).filter((k) => props[k]?.format === "secret");
|
|
100
|
+
const word = Object.fromEntries(Object.entries(args).filter(([k]) => !secrets.includes(k)));
|
|
101
|
+
const cli = [prog, name, ...(Object.keys(word).length ? [quote(JSON.stringify(word))] : []), ...secrets.map((k) => `--${k} @-`)].join(" ");
|
|
102
|
+
return { cli, mcp: { name, args } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entries = (m: Manifest) => [
|
|
106
|
+
...Object.entries(m.actions).map(([name, c]) => ({ name, kind: "action" as const, c })),
|
|
107
|
+
...Object.entries(m.queries).map(([name, c]) => ({ name, kind: "query" as const, c })),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** The index both doors answer: every call with an example, the components with their keys, the resources. */
|
|
111
|
+
export function index(m: Manifest, extra: IndexExtra = {}): Index {
|
|
112
|
+
return {
|
|
113
|
+
...(extra.via !== undefined ? { via: extra.via } : {}),
|
|
114
|
+
...(extra.round !== undefined ? { round: extra.round } : {}),
|
|
115
|
+
...(extra.gen !== undefined ? { gen: extra.gen } : {}),
|
|
116
|
+
...(extra.error ? { error: extra.error } : {}),
|
|
117
|
+
systems: m.systems.map((c) => ({ name: c.name })),
|
|
118
|
+
promptware: { context: !!m.promptware.context, skills: Object.keys(m.promptware.skills) },
|
|
119
|
+
calls: entries(m).map(({ name, kind, c }) => ({ name, kind, system: c.owner, summary: c.summary, example: exampleCall(name, c, extra.prog) })),
|
|
120
|
+
components: Object.fromEntries(Object.entries(m.components).map(([n, c]) => {
|
|
121
|
+
const key = keyField(c.schema);
|
|
122
|
+
return [n, { system: c.owner, ...(key !== null ? { key } : {}), ...(c.accept ? { accept: true } : {}) }];
|
|
123
|
+
})),
|
|
124
|
+
resources: Object.keys(m.resources),
|
|
125
|
+
counts: { systems: m.systems.length, actions: Object.keys(m.actions).length, queries: Object.keys(m.queries).length, components: Object.keys(m.components).length, resources: Object.keys(m.resources).length, effects: Object.keys(m.effects).length },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One call's schemas and example, or null when there is no such action or query. */
|
|
130
|
+
export function schemaOf(m: Manifest, name: string, prog?: string): Schemas | null {
|
|
131
|
+
const e = entries(m).find((x) => x.name === name);
|
|
132
|
+
if (!e) return null;
|
|
133
|
+
const { kind, c } = e;
|
|
134
|
+
return { name, kind, system: c.owner, summary: c.summary, args: c.args, result: c.result, ...(c.positional ? { positional: c.positional } : {}), example: exampleCall(name, c, prog) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- the state --------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** How long follow and watch wait by default, in ms. */
|
|
140
|
+
export const FOLLOW_MS = 30_000;
|
|
141
|
+
/** No round for this long, and no job: the state is quiet, so nothing more is coming for the entity. */
|
|
142
|
+
const QUIET_MS = 1_000;
|
|
143
|
+
/** A follow's changes are told at most once per this. */
|
|
144
|
+
const COALESCE_MS = 250;
|
|
145
|
+
|
|
146
|
+
/** How a wait ended: the condition held, the time ran out (the last value with `timedOut`), or the entity is gone. */
|
|
147
|
+
export interface Outcome { outcome: "held" | "timedOut" | "gone"; value: unknown }
|
|
148
|
+
|
|
149
|
+
/** An entity as a row: its components (entity refs expanded when `deep`), its jobs, its last settlement. */
|
|
150
|
+
export function rowOf(snap: Snapshot, m: Manifest | null, e: number, deep = false, seen = new Set<number>()): O | null {
|
|
151
|
+
const parts = snap.entities[String(e)];
|
|
152
|
+
if (!parts) return null;
|
|
153
|
+
seen.add(e);
|
|
154
|
+
const o: O = { entity: e };
|
|
155
|
+
for (const [n, v] of Object.entries(parts)) {
|
|
156
|
+
if (!deep || !m || typeof v !== "object" || v === null) { o[n] = v; continue; }
|
|
157
|
+
const props = (m.components[n]?.schema.properties ?? {}) as Record<string, Json>;
|
|
158
|
+
const copy: O = { ...(v as O) };
|
|
159
|
+
for (const [k, p] of Object.entries(props)) {
|
|
160
|
+
const id = copy[k];
|
|
161
|
+
if (p.description === "an entity" && typeof id === "number" && !seen.has(id) && snap.entities[String(id)]) copy[k] = rowOf(snap, m, id, true, new Set(seen));
|
|
162
|
+
}
|
|
163
|
+
o[n] = copy;
|
|
164
|
+
}
|
|
165
|
+
const jobs = snap.jobs.filter((j) => j.entity === e).map((j) => ({ effect: j.effect, status: j.status, ...(j.attempt ? { attempt: j.attempt } : {}), ...(j.error ? { error: j.error } : {}), ...(j.shape === "source" ? { source: true, phase: j.phase, events: j.events } : {}) }));
|
|
166
|
+
if (jobs.length) o.jobs = jobs;
|
|
167
|
+
const settled = snap.settled?.[String(e)];
|
|
168
|
+
if (settled) o.settled = settled;
|
|
169
|
+
return o;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A missing entity: one that was never made is not found (thrown); one that was is gone, with its last settlement. */
|
|
173
|
+
export function missing(snap: Snapshot, e: number): O {
|
|
174
|
+
if (snap.next !== undefined && e >= snap.next) throw new DoorError("entity", `no entity ${e}; the state has made ${snap.next - 1}`);
|
|
175
|
+
return { entity: e, gone: true, ...(snap.settled?.[String(e)] ? { settled: snap.settled[String(e)] } : {}) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The entity whose COMPONENT's key field is VALUE, in `snap`. */
|
|
179
|
+
export function byKey(snap: Snapshot, m: Manifest, component: string, value: unknown): number {
|
|
180
|
+
const c = m.components[component];
|
|
181
|
+
if (!c) throw new DoorError("name", `no component ${component}; components: ${Object.keys(m.components).join(", ")}`);
|
|
182
|
+
const field = keyField(c.schema);
|
|
183
|
+
if (field === null) throw new DoorError("args", `${component} has no key field, so it is found by entity; state ${component} lists them`);
|
|
184
|
+
for (const [id, parts] of Object.entries(snap.entities)) {
|
|
185
|
+
const v = parts[component];
|
|
186
|
+
if (v === undefined) continue;
|
|
187
|
+
const key = field === "" ? v : (v as O | null)?.[field];
|
|
188
|
+
if (key !== undefined && String(key) === String(value)) return Number(id);
|
|
189
|
+
}
|
|
190
|
+
throw new DoorError("nokey", `no ${component} with ${field || "key"} ${String(value)}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A follow condition: COMPONENT, or COMPONENT.field=value. */
|
|
194
|
+
export interface Until { component: string; field?: string; value?: string }
|
|
195
|
+
|
|
196
|
+
/** COMPONENT or COMPONENT.field=value, parsed. */
|
|
197
|
+
export function parseUntil(until: string): Until {
|
|
198
|
+
const dot = until.indexOf(".");
|
|
199
|
+
if (dot < 0) return { component: until };
|
|
200
|
+
const rest = until.slice(dot + 1);
|
|
201
|
+
const eq = rest.indexOf("=");
|
|
202
|
+
if (eq < 0) throw new DoorError("usage", `until takes COMPONENT or COMPONENT.field=value, got ${until}`);
|
|
203
|
+
return { component: until.slice(0, dot), field: rest.slice(0, eq), value: rest.slice(eq + 1) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A condition parsed and its component checked against the manifest. */
|
|
207
|
+
export function untilOf(m: Manifest, text: string): Until {
|
|
208
|
+
const u = parseUntil(text);
|
|
209
|
+
if (!(u.component in m.components)) throw new DoorError("name", `no component ${u.component}; components: ${Object.keys(m.components).join(", ")}`);
|
|
210
|
+
return u;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A field matches a value written as text: equal, or equal as JSON or as a string. */
|
|
214
|
+
export const matches = (field: unknown, v: unknown) =>
|
|
215
|
+
field === v || (typeof v === "string" && (JSON.stringify(field) === v || String(field) === v)) || JSON.stringify(field) === JSON.stringify(v);
|
|
216
|
+
|
|
217
|
+
/** A long-poll of the state past `since`, bounded by `ms`: null when no round came in time. */
|
|
218
|
+
async function poll(api: Api, since: number, ms: number): Promise<StateAnswer | null> {
|
|
219
|
+
try { return await api.state(since, AbortSignal.timeout(Math.max(1, ms))); }
|
|
220
|
+
catch (err) {
|
|
221
|
+
const e = err as { name?: unknown; code?: unknown } | null;
|
|
222
|
+
if (e && (e.name === "TimeoutError" || e.name === "AbortError" || e.code === "timeout")) return null;
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface FollowOptions {
|
|
228
|
+
/** The condition; without one, until no job runs for it, a round boundary has passed (or a second of quiet), and two snapshots agree. */
|
|
229
|
+
until?: Until | null;
|
|
230
|
+
/** How long to wait at most, in ms; 30 s by default. */
|
|
231
|
+
for?: number;
|
|
232
|
+
/** Tell each change through `onChange`, at most one per 250 ms. */
|
|
233
|
+
changes?: boolean;
|
|
234
|
+
onChange?: (row: O) => void | Promise<void>;
|
|
235
|
+
/** Asked between polls: true ends the wait as timed out (an interrupt). */
|
|
236
|
+
stop?: () => boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Waits on an entity until the condition holds, the time runs out, or it is gone. */
|
|
240
|
+
export async function followEntity(api: Api, entity: number, o: FollowOptions = {}): Promise<Outcome> {
|
|
241
|
+
const until = o.until ?? null;
|
|
242
|
+
const deadline = Date.now() + (o.for ?? FOLLOW_MS);
|
|
243
|
+
let snap = follow(null, await api.state(-1));
|
|
244
|
+
const start = snap.round;
|
|
245
|
+
let lastRound = Date.now();
|
|
246
|
+
let boundary = false;
|
|
247
|
+
let prev: string | null = null;
|
|
248
|
+
let told: string | null = null;
|
|
249
|
+
let toldAt = 0;
|
|
250
|
+
let pending = false;
|
|
251
|
+
const changes = o.changes === true && !!o.onChange;
|
|
252
|
+
const tell = async (row: O) => { await o.onChange!(row); told = JSON.stringify(row); toldAt = Date.now(); pending = false; };
|
|
253
|
+
for (;;) {
|
|
254
|
+
const row = rowOf(snap, null, entity);
|
|
255
|
+
if (!row) return { outcome: "gone", value: missing(snap, entity) };
|
|
256
|
+
const text = JSON.stringify(row);
|
|
257
|
+
if (changes && text !== told) {
|
|
258
|
+
if (Date.now() - toldAt >= COALESCE_MS) await tell(row); else pending = true;
|
|
259
|
+
}
|
|
260
|
+
if (snap.round > start) boundary = true;
|
|
261
|
+
// A source's job runs for as long as the entity is watched: it is not work the entity waits on.
|
|
262
|
+
const busy = (row.jobs as { source?: boolean }[] | undefined)?.some((j) => !j.source) ?? false;
|
|
263
|
+
const holds = until
|
|
264
|
+
? until.component in row && (until.field === undefined || matches((row[until.component] as O | undefined)?.[until.field], until.value))
|
|
265
|
+
: !busy && boundary && prev === text;
|
|
266
|
+
if (holds) return { outcome: "held", value: row };
|
|
267
|
+
prev = text;
|
|
268
|
+
const left = deadline - Date.now();
|
|
269
|
+
if (left <= 0 || o.stop?.()) return { outcome: "timedOut", value: { ...row, timedOut: true } };
|
|
270
|
+
let wait = left;
|
|
271
|
+
if (!until && !busy) wait = Math.min(wait, QUIET_MS);
|
|
272
|
+
if (pending) wait = Math.min(wait, Math.max(1, COALESCE_MS - (Date.now() - toldAt)));
|
|
273
|
+
const next = await poll(api, snap.round, wait);
|
|
274
|
+
if (next && next.round !== snap.round) lastRound = Date.now();
|
|
275
|
+
if (next) snap = follow(snap, next);
|
|
276
|
+
// No round for a while and no job: nothing is due, so nothing more can be scheduled for it.
|
|
277
|
+
else if (!until && !busy && Date.now() - lastRound >= QUIET_MS) boundary = true;
|
|
278
|
+
if (pending && Date.now() - toldAt >= COALESCE_MS) { const r = rowOf(snap, null, entity); if (r) await tell(r); }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Reads a query, then waits until its answer differs from that first read: {value}, or {value, timedOut} with the last read. */
|
|
283
|
+
export async function watchQuery(api: Api, name: string, args: unknown, o: { for?: number; stop?: () => boolean } = {}): Promise<Outcome> {
|
|
284
|
+
const deadline = Date.now() + (o.for ?? FOLLOW_MS);
|
|
285
|
+
const first = await api.read(name, args);
|
|
286
|
+
const firstText = JSON.stringify(first);
|
|
287
|
+
let since = (await api.state(-1)).round;
|
|
288
|
+
let value = first;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const left = deadline - Date.now();
|
|
291
|
+
if (left <= 0 || o.stop?.()) return { outcome: "timedOut", value: { value, timedOut: true } };
|
|
292
|
+
const snap = await poll(api, since, left);
|
|
293
|
+
if (!snap) continue;
|
|
294
|
+
since = snap.round;
|
|
295
|
+
value = await api.read(name, args);
|
|
296
|
+
if (JSON.stringify(value) !== firstText) return { outcome: "held", value: { value } };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface StateOptions {
|
|
301
|
+
/** A component (its rows) or a resource (its value). */
|
|
302
|
+
component?: string;
|
|
303
|
+
entity?: number;
|
|
304
|
+
/** With `component`: the entity whose key field is this. */
|
|
305
|
+
key?: unknown;
|
|
306
|
+
/** With `component`: field = value, every one to hold. */
|
|
307
|
+
where?: Record<string, unknown> | (readonly [string, unknown])[];
|
|
308
|
+
/** With `component`: at most this many rows, 100 by default, 0 for all; past it, the rest is spilled under `dir` (or cut) and a marker ends the list. */
|
|
309
|
+
limit?: number;
|
|
310
|
+
/** The whole state: resources, every entity, the jobs. */
|
|
311
|
+
all?: boolean;
|
|
312
|
+
/** Entity refs in a row expanded into their rows. */
|
|
313
|
+
deep?: boolean;
|
|
314
|
+
/** Where rows past the limit go. */
|
|
315
|
+
dir?: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Reads the state: a summary, all of it, an entity, a component's rows, an entity by key, or a resource's value. */
|
|
319
|
+
export async function stateOf(api: Api, m: Manifest, o: StateOptions = {}): Promise<Outcome> {
|
|
320
|
+
const snap = follow(null, await api.state(-1));
|
|
321
|
+
const row = (e: number) => rowOf(snap, m, e, o.deep === true)!;
|
|
322
|
+
if (o.entity !== undefined) {
|
|
323
|
+
if (!snap.entities[String(o.entity)]) return { outcome: "gone", value: missing(snap, o.entity) };
|
|
324
|
+
return { outcome: "held", value: row(o.entity) };
|
|
325
|
+
}
|
|
326
|
+
const c = o.component;
|
|
327
|
+
if (c === undefined || c === "") {
|
|
328
|
+
if (o.all) return { outcome: "held", value: { round: snap.round, resources: snap.resources, entities: Object.keys(snap.entities).map((id) => row(Number(id))), jobs: snap.jobs } };
|
|
329
|
+
const components: Record<string, number> = Object.fromEntries(Object.keys(m.components).map((n) => [n, 0]));
|
|
330
|
+
for (const parts of Object.values(snap.entities)) for (const n of Object.keys(parts)) components[n] = (components[n] ?? 0) + 1;
|
|
331
|
+
const jobs: Record<string, number> = {};
|
|
332
|
+
for (const j of snap.jobs) jobs[j.status] = (jobs[j.status] ?? 0) + 1;
|
|
333
|
+
return { outcome: "held", value: { round: snap.round, entities: Object.keys(snap.entities).length, components, resources: Object.keys(snap.resources), jobs } };
|
|
334
|
+
}
|
|
335
|
+
if (c in snap.resources && !(c in m.components)) return { outcome: "held", value: snap.resources[c] };
|
|
336
|
+
if (!(c in m.components)) throw new DoorError("name", `no component or resource ${c}; components: ${Object.keys(m.components).join(", ")}`);
|
|
337
|
+
if (o.key !== undefined) return { outcome: "held", value: row(byKey(snap, m, c, o.key)) };
|
|
338
|
+
const conds = Array.isArray(o.where) ? o.where : Object.entries(o.where ?? {});
|
|
339
|
+
const limit = o.limit ?? 100;
|
|
340
|
+
if (!Number.isInteger(limit) || limit < 0) throw new DoorError("usage", `limit takes a whole number, got ${String(o.limit)}`);
|
|
341
|
+
const rows = Object.entries(snap.entities)
|
|
342
|
+
.filter(([, parts]) => c in parts && conds.every(([k, v]) => matches((parts[c] as O | null)?.[k], v)))
|
|
343
|
+
.map(([id]) => row(Number(id)));
|
|
344
|
+
if (limit === 0 || rows.length <= limit) return { outcome: "held", value: rows };
|
|
345
|
+
const marker: O = o.dir ? (({ head: _, ...rest }) => rest)(spill(o.dir, `state-${c}`, rows)) : { truncated: true, count: rows.length };
|
|
346
|
+
return { outcome: "held", value: [...rows.slice(0, limit), marker] };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---- the output budget ----------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/** The default budget, in characters of JSON. */
|
|
352
|
+
export const BUDGET = 20_000;
|
|
353
|
+
const HEAD = 2_000;
|
|
354
|
+
|
|
355
|
+
export interface Truncated { truncated: true; bytes: number; count?: number; path: string; head: unknown }
|
|
356
|
+
|
|
357
|
+
/** The value's first items (an array) or first 2000 characters of its JSON. */
|
|
358
|
+
function headOf(value: unknown, text: string): unknown {
|
|
359
|
+
if (!Array.isArray(value)) return text.slice(0, HEAD);
|
|
360
|
+
const items: unknown[] = [];
|
|
361
|
+
let size = 2;
|
|
362
|
+
for (const x of value) { size += JSON.stringify(x ?? null).length + 1; if (size > HEAD && items.length) break; items.push(x); if (size > HEAD) break; }
|
|
363
|
+
return items;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Writes `value` whole to `<dir>/<timestamp>-<name>.json`, at once, so the answer that names it never comes before it; answers its marker. */
|
|
367
|
+
export function spill(dir: string, name: string, value: unknown, text = JSON.stringify(value ?? null)): Truncated {
|
|
368
|
+
const stem = `${new Date().toISOString().replace(/[:.]/g, "-")}-${name.replace(/[^\w.-]+/g, "_") || "answer"}`;
|
|
369
|
+
mkdirSync(dir, { recursive: true });
|
|
370
|
+
let path = join(dir, `${stem}.json`);
|
|
371
|
+
for (let n = 2; existsSync(path); n++) path = join(dir, `${stem}-${n}.json`);
|
|
372
|
+
writeFileSync(path, text);
|
|
373
|
+
return { truncated: true, bytes: Buffer.byteLength(text), ...(Array.isArray(value) ? { count: value.length } : {}), path, head: headOf(value, text) };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `value` within `limit` characters of JSON, or its marker, the whole written under `dir`; `limit` 0 is no budget, and so is a missing `dir`. */
|
|
377
|
+
export function budget(value: unknown, o: { limit: number; dir?: string; name: string }): unknown {
|
|
378
|
+
if (!o.dir || o.limit <= 0) return value;
|
|
379
|
+
const text = JSON.stringify(value ?? null);
|
|
380
|
+
return text.length <= o.limit ? value : spill(o.dir, o.name, value, text);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Whether a value is a budget's marker. */
|
|
384
|
+
export const isTruncated = (v: unknown): v is Truncated => isObj(v) && v.truncated === true && typeof v.path === "string";
|