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.
- 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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A lock on a directory: one process at a time. A file created exclusively,
|
|
2
|
+
// holding the holder's pid and what it is; a second taker reads it, and
|
|
3
|
+
// yields to a live holder or takes over from a dead one. Released on stop
|
|
4
|
+
// and at exit, only if the file is still the holder's own.
|
|
5
|
+
import { openSync, closeSync, writeSync, readFileSync, unlinkSync, constants } from "node:fs";
|
|
6
|
+
|
|
7
|
+
export interface Holder { pid: number; [k: string]: unknown }
|
|
8
|
+
|
|
9
|
+
export class LockError extends Error {
|
|
10
|
+
constructor(readonly path: string, readonly holder: Holder, msg: string) { super(msg); }
|
|
11
|
+
readonly code = "busy";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Lock { readonly path: string; release(): void }
|
|
15
|
+
|
|
16
|
+
const alive = (pid: number): boolean => {
|
|
17
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
18
|
+
try { process.kill(pid, 0); return true; } catch (err) { return (err as { code?: string }).code === "EPERM"; }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Takes the lock at `path` for this process, with `what` beside the pid; throws LockError while another live process holds it. */
|
|
22
|
+
export function takeLock(path: string, what: Record<string, unknown> = {}, describe = (h: Holder) => `held by pid ${h.pid}`): Lock {
|
|
23
|
+
const text = JSON.stringify({ pid: process.pid, ...what }) + "\n";
|
|
24
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
27
|
+
try { writeSync(fd, text); } finally { closeSync(fd); }
|
|
28
|
+
break;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
|
31
|
+
let holder: Holder | null = null;
|
|
32
|
+
try { holder = JSON.parse(readFileSync(path, "utf8")) as Holder; } catch { /* unreadable: a dead or half-written lock */ }
|
|
33
|
+
// A live holder holds, this process included: a second opener here is a bug the lock is meant to catch.
|
|
34
|
+
if (holder && alive(holder.pid)) throw new LockError(path, holder, `${path}: ${describe(holder)}`);
|
|
35
|
+
// The holder is gone: take over.
|
|
36
|
+
try { unlinkSync(path); } catch { /* raced away */ }
|
|
37
|
+
if (attempt === 1) throw new LockError(path, holder ?? { pid: 0 }, `${path}: could not take the lock`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const release = () => {
|
|
41
|
+
process.off("exit", release);
|
|
42
|
+
try { if (readFileSync(path, "utf8") === text) unlinkSync(path); } catch { /* already gone */ }
|
|
43
|
+
};
|
|
44
|
+
process.once("exit", release);
|
|
45
|
+
return { path, release };
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// How a build is named for people: its version, or "source" from a checkout;
|
|
2
|
+
// and two builds told apart when they share a name, by the moment each was built.
|
|
3
|
+
export interface Named { version?: string; sha?: string; at?: string; dev?: boolean }
|
|
4
|
+
|
|
5
|
+
/** One word for a build: its version, or "source" when run from a checkout. */
|
|
6
|
+
export const buildName = (b: Named) => b.dev ? "source" : (b.version || b.sha || "unstamped");
|
|
7
|
+
|
|
8
|
+
/** Two builds, each named so a reader can tell them apart: by name, or by the moment when the name is the same. */
|
|
9
|
+
export function tellApart(a: Named, b: Named): [string, string] {
|
|
10
|
+
const na = buildName(a), nb = buildName(b);
|
|
11
|
+
if (na !== nb) return [na, nb];
|
|
12
|
+
const when = (x: Named) => x.at ? `${buildName(x)} built ${x.at.slice(11, 19)}Z` : buildName(x);
|
|
13
|
+
return [when(a), when(b)];
|
|
14
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A small schema language: one declaration gives the TypeScript type, the
|
|
2
|
+
// JSON Schema the edges read, and the check at the edges. No dependency.
|
|
3
|
+
|
|
4
|
+
export type Json = { [k: string]: unknown };
|
|
5
|
+
|
|
6
|
+
export interface Schema<T> {
|
|
7
|
+
readonly json: Json;
|
|
8
|
+
/** Returns the value as T, or throws SchemaError. `coerce` turns shell strings into numbers and booleans. */
|
|
9
|
+
check(value: unknown, path?: string, coerce?: boolean): T;
|
|
10
|
+
describe(text: string): Schema<T>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Infer<S> = S extends Schema<infer T> ? T : never;
|
|
14
|
+
|
|
15
|
+
export class SchemaError extends Error {
|
|
16
|
+
constructor(readonly path: string, msg: string) { super(`${path || "value"}: ${msg}`); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function make<T>(json: Json, check: (v: unknown, path: string, coerce: boolean) => T): Schema<T> {
|
|
20
|
+
const s: Schema<T> = {
|
|
21
|
+
json,
|
|
22
|
+
check: (v, path = "", coerce = false) => check(v, path, coerce),
|
|
23
|
+
describe: (text) => make({ ...json, description: text }, check),
|
|
24
|
+
};
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Exported, and annotated, so a declaration file can name what a contract's optional args carry.
|
|
29
|
+
export const OPTIONAL: unique symbol = Symbol("optional");
|
|
30
|
+
export type Optional<T> = Schema<T | undefined> & { [OPTIONAL]: true };
|
|
31
|
+
|
|
32
|
+
export const s = {
|
|
33
|
+
string: () => make<string>({ type: "string" }, (v, p) => {
|
|
34
|
+
if (typeof v !== "string") throw new SchemaError(p, "expected a string");
|
|
35
|
+
return v;
|
|
36
|
+
}),
|
|
37
|
+
number: () => make<number>({ type: "number" }, (v, p, c) => {
|
|
38
|
+
if (c && typeof v === "string" && v !== "" && !isNaN(Number(v))) v = Number(v);
|
|
39
|
+
if (typeof v !== "number" || !isFinite(v)) throw new SchemaError(p, "expected a number");
|
|
40
|
+
return v;
|
|
41
|
+
}),
|
|
42
|
+
integer: () => make<number>({ type: "integer" }, (v, p, c) => {
|
|
43
|
+
if (c && typeof v === "string" && /^-?\d+$/.test(v)) v = Number(v);
|
|
44
|
+
if (typeof v !== "number" || !Number.isInteger(v)) throw new SchemaError(p, "expected an integer");
|
|
45
|
+
return v;
|
|
46
|
+
}),
|
|
47
|
+
boolean: () => make<boolean>({ type: "boolean" }, (v, p, c) => {
|
|
48
|
+
if (c && (v === "true" || v === "")) v = true;
|
|
49
|
+
if (c && v === "false") v = false;
|
|
50
|
+
if (typeof v !== "boolean") throw new SchemaError(p, "expected a boolean");
|
|
51
|
+
return v;
|
|
52
|
+
}),
|
|
53
|
+
literal: <const L extends string | number | boolean>(value: L) => make<L>({ const: value }, (v, p) => {
|
|
54
|
+
if (v !== value) throw new SchemaError(p, `expected ${JSON.stringify(value)}`);
|
|
55
|
+
return v as L;
|
|
56
|
+
}),
|
|
57
|
+
enum: <const E extends readonly string[]>(...values: E) => make<E[number]>({ type: "string", enum: [...values] }, (v, p) => {
|
|
58
|
+
if (typeof v !== "string" || !values.includes(v)) throw new SchemaError(p, `expected one of ${values.join(", ")}`);
|
|
59
|
+
return v as E[number];
|
|
60
|
+
}),
|
|
61
|
+
/** A tag: a component that is present or not; its only value is true. */
|
|
62
|
+
tag: () => make<true>({ const: true, description: "a tag" }, (v, p) => {
|
|
63
|
+
if (v !== true) throw new SchemaError(p, "expected true");
|
|
64
|
+
return true as const;
|
|
65
|
+
}),
|
|
66
|
+
/**
|
|
67
|
+
* An entity id of the baba's state: an integer from 1. Naming a component
|
|
68
|
+
* (`s.entity("task")`) records it as `x-entity`: in an action's args the
|
|
69
|
+
* runtime checks the entity carries it, and, when the component has a key
|
|
70
|
+
* field, takes that key as a string in place of the id.
|
|
71
|
+
*/
|
|
72
|
+
entity: (component?: string) => make<number>({ type: "integer", minimum: 1, description: "an entity", ...(component ? { "x-entity": component } : {}) }, (v, p, c) => {
|
|
73
|
+
if (c && typeof v === "string" && /^\d+$/.test(v)) v = Number(v);
|
|
74
|
+
if (component && typeof v === "string" && v !== "") return v as unknown as number; // a key, resolved by the runtime
|
|
75
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1) throw new SchemaError(p, component ? `expected an entity id or a ${component} key` : "expected an entity id");
|
|
76
|
+
return v;
|
|
77
|
+
}),
|
|
78
|
+
/** A component's key field: a string or integer by which `w.find` and an action's `s.entity(component)` args find the entity. */
|
|
79
|
+
key: <T extends string | number>(inner: Schema<T>) => {
|
|
80
|
+
if (inner.json.type !== "string" && inner.json.type !== "integer") throw new Error("s.key: a string or an integer schema");
|
|
81
|
+
return make<T>({ ...inner.json, "x-key": true }, (v, p, c) => inner.check(v, p, c));
|
|
82
|
+
},
|
|
83
|
+
/** A secret: a string that the runtime redacts from every snapshot that leaves it. */
|
|
84
|
+
secret: () => make<string>({ type: "string", format: "secret" }, (v, p) => {
|
|
85
|
+
if (typeof v !== "string") throw new SchemaError(p, "expected a string");
|
|
86
|
+
return v;
|
|
87
|
+
}),
|
|
88
|
+
unknown: () => make<unknown>({}, (v) => v),
|
|
89
|
+
void: () => make<void>({ type: "null" }, (v, p) => {
|
|
90
|
+
if (v != null) throw new SchemaError(p, "expected nothing");
|
|
91
|
+
}),
|
|
92
|
+
array: <T>(item: Schema<T>) => make<T[]>({ type: "array", items: item.json }, (v, p, c) => {
|
|
93
|
+
if (c && typeof v === "string") v = [v];
|
|
94
|
+
if (!Array.isArray(v)) throw new SchemaError(p, "expected an array");
|
|
95
|
+
return v.map((x, i) => item.check(x, `${p}[${i}]`, c));
|
|
96
|
+
}),
|
|
97
|
+
nullable: <T>(inner: Schema<T>) => make<T | null>({ ...inner.json, type: [inner.json.type, "null"] }, (v, p, c) =>
|
|
98
|
+
v === null ? null : inner.check(v, p, c)),
|
|
99
|
+
optional: <T>(inner: Schema<T>): Optional<T> => {
|
|
100
|
+
const o = make<T | undefined>(inner.json, (v, p, c) => (v === undefined ? undefined : inner.check(v, p, c))) as Optional<T>;
|
|
101
|
+
o[OPTIONAL] = true;
|
|
102
|
+
return o;
|
|
103
|
+
},
|
|
104
|
+
/** An object of these fields; `open` lets fields the schema does not name through, as the outside sends them. */
|
|
105
|
+
object: <P extends Record<string, Schema<any>>>(props: P, o: { open?: boolean } = {}) => {
|
|
106
|
+
const required = Object.keys(props).filter((k) => !(OPTIONAL in props[k]!));
|
|
107
|
+
const json: Json = {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: Object.fromEntries(Object.entries(props).map(([k, v]) => [k, v.json])),
|
|
110
|
+
...(required.length ? { required } : {}),
|
|
111
|
+
additionalProperties: o.open === true,
|
|
112
|
+
};
|
|
113
|
+
return make<ObjectOf<P> & (typeof o extends { open: true } ? Record<string, unknown> : {})>(json, (v, p, c) => {
|
|
114
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new SchemaError(p, "expected an object");
|
|
115
|
+
const out: Record<string, unknown> = {};
|
|
116
|
+
for (const k of Object.keys(v)) { if (!(k in props)) { if (o.open) out[k] = (v as Record<string, unknown>)[k]; else throw new SchemaError(p ? `${p}.${k}` : k, "unknown field"); } }
|
|
117
|
+
for (const [k, sch] of Object.entries(props)) {
|
|
118
|
+
const r = sch.check((v as Record<string, unknown>)[k], p ? `${p}.${k}` : k, c);
|
|
119
|
+
if (r !== undefined) out[k] = r;
|
|
120
|
+
}
|
|
121
|
+
return out as ObjectOf<P>;
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type OptionalKeys<P> = { [K in keyof P]: P[K] extends { [OPTIONAL]: true } ? K : never }[keyof P];
|
|
127
|
+
export type ObjectOf<P extends Record<string, Schema<any>>> = Flat<
|
|
128
|
+
{ [K in Exclude<keyof P, OptionalKeys<P>>]: Infer<P[K]> } & { [K in OptionalKeys<P>]?: Infer<P[K]> }
|
|
129
|
+
>;
|
|
130
|
+
type Flat<T> = { [K in keyof T]: T[K] } & {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Where this voss runs from. The framework is this package's directory,
|
|
2
|
+
// `packages/babavoss` in the repository, `node_modules/babavoss` installed.
|
|
3
|
+
// A developer checkout keeps voss's state beside the source and links new
|
|
4
|
+
// babas to the tree; an installed package keeps it under ~/.voss and pins
|
|
5
|
+
// new babas to its version. A path under node_modules tells them apart.
|
|
6
|
+
import { basename, dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** The framework's directory: the package, wherever it is. */
|
|
9
|
+
export const framework = dirname(dirname(dirname(Bun.fileURLToPath(import.meta.url))));
|
|
10
|
+
|
|
11
|
+
/** True in a developer checkout of the repository; false installed as a package. */
|
|
12
|
+
export const dev = basename(dirname(framework)) !== "node_modules";
|
package/src/kit/index.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The kit: a baba's components, shadcn's way. The baba owns them as copies
|
|
2
|
+
// under .baba/ui, added by shadcn's CLI and styled by Tailwind over voss's
|
|
3
|
+
// theme tokens; babavoss ships no component and takes no dependency. This
|
|
4
|
+
// module carries what a baba needs to have a kit, the files init writes and
|
|
5
|
+
// the packages it installs, and the kit system, which keeps the kit as objects
|
|
6
|
+
// in the state and adds to it through the port.
|
|
7
|
+
import { effect, source } from "../ecs/baba.ts";
|
|
8
|
+
import { system } from "../ecs/system.ts";
|
|
9
|
+
import { not } from "../ecs/state.ts";
|
|
10
|
+
import { s } from "../kernel/schema.ts";
|
|
11
|
+
import type { Context } from "../kernel/context.ts";
|
|
12
|
+
|
|
13
|
+
/** The packages a kit stands on, written into the baba's package.json. */
|
|
14
|
+
export const kitDependencies: Record<string, string> = {
|
|
15
|
+
"tailwindcss": "^4.3.3",
|
|
16
|
+
"bun-plugin-tailwind": "^0.1.2",
|
|
17
|
+
"cn": "^0.4.0",
|
|
18
|
+
"class-variance-authority": "^0.7.1",
|
|
19
|
+
"radix-ui": "^1.6.7",
|
|
20
|
+
"lucide-react": "^1.52.0",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The files a kit needs under .baba: shadcn's config, the bridge from shadcn's token names to voss's theme, the class helper. */
|
|
24
|
+
export const kitFiles: Record<string, string> = {
|
|
25
|
+
"components.json": `{
|
|
26
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
27
|
+
"style": "new-york",
|
|
28
|
+
"rsc": false,
|
|
29
|
+
"tsx": true,
|
|
30
|
+
"tailwind": {
|
|
31
|
+
"config": "",
|
|
32
|
+
"css": "ui.css",
|
|
33
|
+
"baseColor": "neutral",
|
|
34
|
+
"cssVariables": true,
|
|
35
|
+
"prefix": ""
|
|
36
|
+
},
|
|
37
|
+
"iconLibrary": "lucide",
|
|
38
|
+
"aliases": {
|
|
39
|
+
"components": "@/ui",
|
|
40
|
+
"utils": "@/lib/utils",
|
|
41
|
+
"ui": "@/ui",
|
|
42
|
+
"lib": "@/lib",
|
|
43
|
+
"hooks": "@/hooks"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`,
|
|
47
|
+
"ui.css": `@import "tailwindcss";
|
|
48
|
+
@source "./systems";
|
|
49
|
+
@source "./ui";
|
|
50
|
+
@source "./apps";
|
|
51
|
+
@source "./interface.tsx";
|
|
52
|
+
|
|
53
|
+
/* shadcn's names over voss's theme tokens: the theme stays the one source of color, type and space. */
|
|
54
|
+
@theme inline {
|
|
55
|
+
/* The theme's measures, not rem: a 4px unit whatever the root font, and its two sizes of type, 13/20 and 11/16. */
|
|
56
|
+
--spacing: 4px;
|
|
57
|
+
--text-xs: 11px;
|
|
58
|
+
--text-xs--line-height: 16px;
|
|
59
|
+
--text-sm: 13px;
|
|
60
|
+
--text-sm--line-height: 20px;
|
|
61
|
+
--text-base: 13px;
|
|
62
|
+
--text-base--line-height: 20px;
|
|
63
|
+
--color-background: var(--background);
|
|
64
|
+
--color-foreground: var(--foreground);
|
|
65
|
+
--color-card: var(--surface);
|
|
66
|
+
--color-card-foreground: var(--foreground);
|
|
67
|
+
--color-popover: var(--popover);
|
|
68
|
+
--color-popover-foreground: var(--foreground);
|
|
69
|
+
--color-primary: var(--primary);
|
|
70
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
71
|
+
--color-secondary: var(--muted);
|
|
72
|
+
--color-secondary-foreground: var(--foreground);
|
|
73
|
+
--color-muted: var(--muted);
|
|
74
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
75
|
+
--color-accent: var(--accent);
|
|
76
|
+
--color-accent-foreground: var(--foreground);
|
|
77
|
+
--color-destructive: var(--destructive);
|
|
78
|
+
--color-destructive-foreground: var(--primary-foreground);
|
|
79
|
+
--color-success: var(--success);
|
|
80
|
+
--color-warning: var(--warning);
|
|
81
|
+
--color-border: var(--border);
|
|
82
|
+
--color-input: var(--input);
|
|
83
|
+
--color-ring: var(--primary);
|
|
84
|
+
--color-chrome: var(--chrome);
|
|
85
|
+
--color-desk: var(--desk);
|
|
86
|
+
--radius-sm: var(--radius);
|
|
87
|
+
--radius-md: var(--radius);
|
|
88
|
+
--radius-lg: var(--radius);
|
|
89
|
+
--radius-xl: var(--radius);
|
|
90
|
+
--font-sans: var(--font-sans);
|
|
91
|
+
--font-mono: var(--font-mono);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Markdown, as GitHub flavours it, at the theme's density: 13/20 body, headings the same size and weight 600, code at 12. */
|
|
95
|
+
@layer components {
|
|
96
|
+
.md { font-size: 13px; line-height: 20px; overflow-wrap: anywhere; }
|
|
97
|
+
.md > * + * { margin-top: 8px; }
|
|
98
|
+
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-size: 13px; line-height: 20px; font-weight: 600; margin-top: 16px; }
|
|
99
|
+
.md h1 { font-size: 15px; }
|
|
100
|
+
.md > :first-child { margin-top: 0; }
|
|
101
|
+
.md ul, .md ol { padding-left: 20px; }
|
|
102
|
+
.md ul { list-style: disc; }
|
|
103
|
+
.md ol { list-style: decimal; }
|
|
104
|
+
.md li + li { margin-top: 2px; }
|
|
105
|
+
.md li > ul, .md li > ol { margin-top: 2px; }
|
|
106
|
+
.md li.task-list-item, .md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -20px; }
|
|
107
|
+
.md code { font-family: var(--font-mono); font-size: 12px; padding: 0 4px; border-radius: var(--radius); background: var(--muted); }
|
|
108
|
+
.md pre { padding: 8px 10px; border-radius: var(--radius); background: var(--muted); overflow: auto; }
|
|
109
|
+
.md pre code { padding: 0; background: transparent; }
|
|
110
|
+
.md blockquote { padding-left: 10px; border-left: 2px solid var(--border); color: var(--muted-foreground); }
|
|
111
|
+
.md a { color: var(--primary); text-decoration: none; }
|
|
112
|
+
.md a:hover { text-decoration: underline; }
|
|
113
|
+
.md hr { border: 0; border-top: 1px solid var(--border); }
|
|
114
|
+
.md img { max-width: 100%; }
|
|
115
|
+
.md table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
|
116
|
+
.md th, .md td { border: 1px solid var(--border); padding: 2px 8px; text-align: left; }
|
|
117
|
+
.md th { font-weight: 600; background: var(--chrome); }
|
|
118
|
+
.md del { color: var(--muted-foreground); }
|
|
119
|
+
.md strong { font-weight: 600; }
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
"lib/utils.ts": `export { cn } from "cn";\n`,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The names a component file exports: what an app imports from \`@/ui/NAME\`. */
|
|
126
|
+
export function exportsOf(text: string): string[] {
|
|
127
|
+
const out = new Set<string>();
|
|
128
|
+
for (const m of text.matchAll(/export\s+(?:function|const|class)\s+([A-Za-z0-9_]+)/g)) out.add(m[1]!);
|
|
129
|
+
for (const m of text.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of m[1]!.split(",")) { const name = part.trim().split(/\s+as\s+/).pop()!.trim(); if (/^[A-Za-z0-9_]+$/.test(name)) out.add(name); }
|
|
130
|
+
return [...out].sort();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const UI = ".baba/ui";
|
|
134
|
+
const works = ["add", "remove"] as const;
|
|
135
|
+
|
|
136
|
+
async function listPieces(ctx: Context) {
|
|
137
|
+
const entries = await ctx.fs.list(UI).catch(() => []);
|
|
138
|
+
const files = entries.filter((e) => !e.dir && e.name.endsWith(".tsx"));
|
|
139
|
+
return Promise.all(files.map(async (e) => ({ name: e.name.replace(/\.tsx$/, ""), exports: exportsOf(await ctx.fs.read(`${UI}/${e.name}`)), bytes: e.size, updated: e.updated })));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One change to the kit, by shadcn's CLI for an addition, by the port's fs for a removal. */
|
|
143
|
+
async function runKit(a: { kind: (typeof works)[number]; names: string[] }, ctx: Context) {
|
|
144
|
+
if (a.kind === "remove") {
|
|
145
|
+
for (const n of a.names) await ctx.fs.remove(`${UI}/${n}.tsx`);
|
|
146
|
+
return { output: `removed ${a.names.join(", ")}` };
|
|
147
|
+
}
|
|
148
|
+
const r = await ctx.exec.run("bun", ["x", "shadcn@latest", "add", ...a.names, "-y", "-c", ".baba"], { cwd: ctx.project });
|
|
149
|
+
if (r.code !== 0) throw new Error(`shadcn add: ${(r.stderr || r.stdout).trim().split("\n").slice(-4).join("; ")}`);
|
|
150
|
+
// One path to the class helper whatever the registry wrote: the baba's lib/utils re-exports shadcn's own.
|
|
151
|
+
for (const e of await ctx.fs.list(UI).catch(() => [])) {
|
|
152
|
+
if (e.dir || !e.name.endsWith(".tsx")) continue;
|
|
153
|
+
const text = await ctx.fs.read(`${UI}/${e.name}`);
|
|
154
|
+
if (text.includes('from "cn"')) await ctx.fs.write(`${UI}/${e.name}`, text.replaceAll('from "cn"', 'from "@/lib/utils"'));
|
|
155
|
+
}
|
|
156
|
+
return { output: (r.stdout || "").trim().split("\n").filter((l) => /ui\//.test(l)).join("; ") || `added ${a.names.join(", ")}` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The kit system: the baba's components as objects, one per file under .baba/ui, with what each exports; additions through
|
|
161
|
+
* shadcn's CLI, removals through the port, each a job whose failure stays visible until forgotten.
|
|
162
|
+
*/
|
|
163
|
+
export const kit = system({
|
|
164
|
+
name: "kit",
|
|
165
|
+
model: {
|
|
166
|
+
/** A component of the kit, mirrored from .baba/ui/NAME.tsx: never written by the system. */
|
|
167
|
+
uiPiece: { name: s.key(s.string()), exports: s.array(s.string()), bytes: s.integer(), updated: s.string() },
|
|
168
|
+
/** A change asked of the kit and not yet done. */
|
|
169
|
+
kitWork: { kind: s.enum(...works), names: s.array(s.string()), asked: s.integer() },
|
|
170
|
+
kitTrouble: { error: s.string(), at: s.integer() },
|
|
171
|
+
},
|
|
172
|
+
effects: {
|
|
173
|
+
kitList: source({
|
|
174
|
+
summary: "the kit's components as the files under .baba/ui have them, with what each exports",
|
|
175
|
+
mirror: "uiPiece",
|
|
176
|
+
every: 60_000,
|
|
177
|
+
retry: 10_000,
|
|
178
|
+
list: (_, ctx) => listPieces(ctx),
|
|
179
|
+
watch: async function* (_, ctx) { for await (const _c of ctx.fs.watch(UI)) yield { changed: true as const }; },
|
|
180
|
+
}),
|
|
181
|
+
kitRun: effect({
|
|
182
|
+
summary: "one change to the kit: components added by shadcn's CLI, or removed",
|
|
183
|
+
args: s.object({ kind: s.enum(...works), names: s.array(s.string()) }),
|
|
184
|
+
result: s.object({ output: s.string() }),
|
|
185
|
+
run: (a, ctx) => runKit(a, ctx),
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
}, (kit, p) => [
|
|
189
|
+
p.on(kit.kitRun, {
|
|
190
|
+
for: (w) => w.query(kit.kitWork, not(kit.kitTrouble)).map((r) => [r.entity, { kind: r.kitWork.kind, names: r.kitWork.names }] as const),
|
|
191
|
+
done: (w, e) => { w.despawn(e!); w.cancel(kit.kitList); w.run(kit.kitList, {}); },
|
|
192
|
+
failed: (w, e, error) => { w.add(e!, kit.kitTrouble, { error, at: w.now }); },
|
|
193
|
+
}),
|
|
194
|
+
|
|
195
|
+
p.action("ui-add", {
|
|
196
|
+
summary: "add components to the kit from shadcn's registry, by name: button, dialog, command…; they land under .baba/ui, for an app to import from @/ui/NAME",
|
|
197
|
+
args: { names: s.array(s.string()) },
|
|
198
|
+
result: s.object({ entity: s.entity("kitWork") }),
|
|
199
|
+
run: (w, a) => {
|
|
200
|
+
const names = [...new Set(a.names.map((n) => n.trim()).filter(Boolean))];
|
|
201
|
+
if (!names.length) throw new Error("name at least one component, such as button");
|
|
202
|
+
for (const n of names) if (!/^[a-z0-9@/._-]+$/i.test(n) || n.includes("..")) throw new Error(`${n} is not a component name`);
|
|
203
|
+
return { entity: w.spawn(kit.kitWork, { kind: "add", names, asked: w.now }) };
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
p.action("ui-remove", {
|
|
208
|
+
summary: "take a component out of the kit: its file under .baba/ui goes",
|
|
209
|
+
args: { name: s.entity("uiPiece") },
|
|
210
|
+
result: s.object({ entity: s.entity("kitWork") }),
|
|
211
|
+
run: (w, a) => ({ entity: w.spawn(kit.kitWork, { kind: "remove", names: [w.get(a.name, kit.uiPiece)!.name], asked: w.now }) }),
|
|
212
|
+
}),
|
|
213
|
+
|
|
214
|
+
p.action("ui-forget", {
|
|
215
|
+
summary: "let a failed change to the kit go",
|
|
216
|
+
args: { entity: s.entity("kitWork") },
|
|
217
|
+
result: s.void(),
|
|
218
|
+
run: (w, a) => { w.despawn(a.entity); },
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
p.query("ui", {
|
|
222
|
+
summary: "the kit: every component under .baba/ui with what it exports, and the changes on their way or failed",
|
|
223
|
+
result: s.object({
|
|
224
|
+
pieces: s.array(s.object({ name: s.string(), exports: s.array(s.string()) })),
|
|
225
|
+
work: s.array(s.object({ kind: s.string(), names: s.array(s.string()), error: s.nullable(s.string()) })),
|
|
226
|
+
}),
|
|
227
|
+
read: (w) => ({
|
|
228
|
+
pieces: w.query(kit.uiPiece).map((r) => ({ name: r.uiPiece.name, exports: r.uiPiece.exports })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
229
|
+
work: w.query(kit.kitWork).map((r) => ({ kind: r.kitWork.kind, names: r.kitWork.names, error: w.get(r.entity, kit.kitTrouble)?.error ?? null })),
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
]);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// maker: voss's system behind the Baba Maker. It mirrors the specs, each
|
|
2
|
+
// system's `.baba/systems/NAME/spec.ts` and the whole baba's `.baba/spec.ts`,
|
|
3
|
+
// and their verdicts under .baba/.voss/spec/, as the disk has them; `spec-run` runs a spec's
|
|
4
|
+
// scenarios headless through bun test so the verdicts are remade; `spec-save`
|
|
5
|
+
// writes a scenario file a person made by playing. The live simulation is
|
|
6
|
+
// the page's; this system holds what the page and an agent share: files, and
|
|
7
|
+
// asks. An ask (`spec-step`, `spec-read`) is an entity the page with that
|
|
8
|
+
// spec open answers against its own spec, so an agent acts in and reads
|
|
9
|
+
// the very spec the person is looking at.
|
|
10
|
+
import { effect, resource, source } from "../ecs/baba.ts";
|
|
11
|
+
import { system } from "../ecs/system.ts";
|
|
12
|
+
import { changed, not, removed } from "../ecs/state.ts";
|
|
13
|
+
import { s } from "../kernel/schema.ts";
|
|
14
|
+
|
|
15
|
+
export const SYSTEMS_DIR = ".baba/systems";
|
|
16
|
+
/** The directory of a spec's files: a system's own, or .baba for the whole baba's. */
|
|
17
|
+
export const specDir = (spec: string) => spec === "baba" ? ".baba" : `${SYSTEMS_DIR}/${spec}`;
|
|
18
|
+
export const SPEC_DIR = ".baba/.voss/spec";
|
|
19
|
+
/** How long an ask waits for a page before it fails, and how long an answered one is kept. */
|
|
20
|
+
const ASK_PATIENCE = 15_000, ASK_KEPT = 10 * 60_000;
|
|
21
|
+
|
|
22
|
+
const verdictFields = { run: s.string(), at: s.string(), done: s.boolean(), scenarios: s.array(s.object({ name: s.string(), verdict: s.enum("passed", "failed", "unknown"), intent: s.string(), broke: s.nullable(s.integer()), steps: s.integer(), checks: s.integer(), failed: s.array(s.string()), quiet: s.array(s.string()), at: s.string() })) };
|
|
23
|
+
|
|
24
|
+
/** The newest change under a directory, recursively, as an ISO time; "" when empty. */
|
|
25
|
+
async function newest(fs: { list(path: string): Promise<{ path: string; dir: boolean; updated: string; name: string }[]> }, dir: string): Promise<string> {
|
|
26
|
+
let latest = "";
|
|
27
|
+
const walk = async (d: string) => {
|
|
28
|
+
for (const e of await fs.list(d).catch(() => [])) {
|
|
29
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
30
|
+
if (e.dir) await walk(e.path); else if (e.updated > latest) latest = e.updated;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
await walk(dir);
|
|
34
|
+
return latest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default system({
|
|
38
|
+
name: "maker",
|
|
39
|
+
model: {
|
|
40
|
+
/** A spec as the disk has it: a system's spec.ts (or the whole baba's), when it or its saved scenarios last changed, and when the systems' code did. */
|
|
41
|
+
specFile: { name: s.key(s.string()), updated: s.string(), code: s.string() },
|
|
42
|
+
/** The verdicts of a spec's last headless run, as the file under .baba/.voss/spec says; `stale` when code or spec changed since. */
|
|
43
|
+
specVerdicts: verdictFields,
|
|
44
|
+
specVerdictsBroken: { error: s.string() },
|
|
45
|
+
/** An agent's ask of a page's spec: steps to run or a query to read, what the page answered, or why not. */
|
|
46
|
+
specAsk: { spec: s.string(), kind: s.enum("step", "read"), program: s.string(), status: s.enum("asked", "answered", "failed"), answer: s.string(), error: s.string(), at: s.integer() },
|
|
47
|
+
/** The last headless run asked for: when, which spec, how it ended. */
|
|
48
|
+
specRun: resource(s.object({ at: s.integer(), spec: s.nullable(s.string()), code: s.nullable(s.integer()), output: s.string() }), { at: 0, spec: null, code: null, output: "" }),
|
|
49
|
+
},
|
|
50
|
+
effects: {
|
|
51
|
+
specs: source({
|
|
52
|
+
summary: "the specs as the disk has them: every system directory with a spec.ts, and .baba/spec.ts, with the newest change to each and under the systems",
|
|
53
|
+
mirror: "specFile",
|
|
54
|
+
every: 5_000,
|
|
55
|
+
retry: 2_000,
|
|
56
|
+
list: async (_, ctx) => {
|
|
57
|
+
await ctx.fs.mkdir(SYSTEMS_DIR);
|
|
58
|
+
const code = await newest(ctx.fs, SYSTEMS_DIR);
|
|
59
|
+
const out: { name: string; updated: string; code: string }[] = [];
|
|
60
|
+
// When a spec file or its saved scenarios last changed.
|
|
61
|
+
const updated = async (dir: string, files: { name: string; updated: string }[]) => {
|
|
62
|
+
const spec = files.find((f) => f.name === "spec.ts")!.updated;
|
|
63
|
+
const saved = files.some((f) => f.name === "scenarios") ? await newest(ctx.fs, `${dir}/scenarios`) : "";
|
|
64
|
+
return saved > spec ? saved : spec;
|
|
65
|
+
};
|
|
66
|
+
for (const d of await ctx.fs.list(SYSTEMS_DIR)) {
|
|
67
|
+
if (!d.dir) continue;
|
|
68
|
+
const files = await ctx.fs.list(d.path);
|
|
69
|
+
if (files.some((f) => f.name === "spec.ts")) out.push({ name: d.name, updated: await updated(d.path, files), code });
|
|
70
|
+
}
|
|
71
|
+
const root = await ctx.fs.list(".baba");
|
|
72
|
+
if (root.some((f) => f.name === "spec.ts")) out.push({ name: "baba", updated: await updated(".baba", root), code });
|
|
73
|
+
return out;
|
|
74
|
+
},
|
|
75
|
+
// Any change under .baba but voss.s own files and its packages: a spec, a saved scenario, a system appearing or going.
|
|
76
|
+
watch: async function* (_, ctx) { for await (const c of ctx.fs.watch(".baba")) if (!("path" in c) || !/^\.baba\/(\.voss|node_modules)\//.test(c.path)) yield { changed: true }; },
|
|
77
|
+
}),
|
|
78
|
+
specVerdictFile: source({
|
|
79
|
+
summary: "a spec's verdict file as the disk has it; the run writes it whole",
|
|
80
|
+
event: s.object({ spec: s.string(), text: s.nullable(s.string()) }),
|
|
81
|
+
args: s.object({ spec: s.string() }),
|
|
82
|
+
retry: 2_000,
|
|
83
|
+
list: async ({ spec }, ctx) => [{ spec, text: await ctx.fs.read(`${SPEC_DIR}/${spec}/verdicts.json`).catch(() => null) }],
|
|
84
|
+
watch: async function* ({ spec }, ctx) {
|
|
85
|
+
await ctx.fs.mkdir(`${SPEC_DIR}/${spec}`);
|
|
86
|
+
for await (const _c of ctx.fs.watch(`${SPEC_DIR}/${spec}`)) yield { spec, text: await ctx.fs.read(`${SPEC_DIR}/${spec}/verdicts.json`).catch(() => null) };
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
specTest: effect({
|
|
90
|
+
summary: "a spec's scenarios, or every spec's, run headless under bun test in .baba: the verdicts are remade",
|
|
91
|
+
args: s.object({ spec: s.nullable(s.string()) }),
|
|
92
|
+
result: s.object({ code: s.integer(), output: s.string() }),
|
|
93
|
+
run: async ({ spec }, ctx) => {
|
|
94
|
+
const r = await ctx.exec.run("bun", ["test", "./spec.test.ts", ...(spec ? ["-t", `^${spec}: `] : [])], { cwd: ".baba" });
|
|
95
|
+
return { code: r.code, output: (r.stderr + r.stdout).trim().slice(-4_000) };
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
specWrite: effect({
|
|
99
|
+
summary: "a scenario file written under a spec's scenarios directory",
|
|
100
|
+
args: s.object({ spec: s.string(), file: s.string(), source: s.string() }),
|
|
101
|
+
result: s.object({ path: s.string() }),
|
|
102
|
+
run: async ({ spec, file, source }, ctx) => { const dir = `${specDir(spec)}/scenarios`; const path = `${dir}/${file}`; await ctx.fs.mkdir(dir); await ctx.fs.write(path, source); return { path }; },
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
}, (maker, p) => [
|
|
106
|
+
// A spec the disk took away takes its verdicts with it.
|
|
107
|
+
p.step("sweep", (w) => { for (const r of w.query(removed(maker.specFile))) if (w.alive(r.entity)) w.despawn(r.entity); }),
|
|
108
|
+
|
|
109
|
+
// An ask no page answered in time fails, saying so; an answered one goes after a while.
|
|
110
|
+
p.step("expire", (w) => {
|
|
111
|
+
for (const r of w.query(maker.specAsk)) {
|
|
112
|
+
if (r.specAsk.status === "asked" && w.now - r.specAsk.at > ASK_PATIENCE) w.add(r.entity, maker.specAsk, { ...r.specAsk, status: "failed", error: `no page has the spec ${r.specAsk.spec} open in the Maker` });
|
|
113
|
+
else if (r.specAsk.status !== "asked" && w.now - r.specAsk.at > ASK_KEPT) w.despawn(r.entity);
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
// The verdict file as it arrived, by spec: parsed onto the spec's entity.
|
|
118
|
+
p.step("land", (w) => {
|
|
119
|
+
for (const { event } of w.events(maker.specVerdictFile)) {
|
|
120
|
+
const e = w.find(maker.specFile, event.spec);
|
|
121
|
+
if (e === undefined) continue;
|
|
122
|
+
w.remove(e, maker.specVerdicts, maker.specVerdictsBroken);
|
|
123
|
+
if (event.text === null) continue;
|
|
124
|
+
try {
|
|
125
|
+
const d = JSON.parse(event.text) as { run?: string; at?: string; done?: boolean; scenarios?: Record<string, Record<string, unknown>> };
|
|
126
|
+
w.add(e, maker.specVerdicts, { run: String(d.run ?? ""), at: String(d.at ?? ""), done: !!d.done, scenarios: Object.entries(d.scenarios ?? {}).map(([name, v]) => ({ name, verdict: v.verdict, intent: v.intent, broke: v.broke, steps: v.steps, checks: v.checks, failed: v.failed, quiet: Array.isArray(v.quiet) ? v.quiet : [], at: v.at })) } as never);
|
|
127
|
+
} catch (err) { w.add(e, maker.specVerdictsBroken, { error: err instanceof Error ? err.message : String(err) }); }
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
p.on(maker.specVerdictFile, { for: (w) => w.query(maker.specFile).map((r) => [r.entity, { spec: r.specFile.name }] as const) }),
|
|
132
|
+
|
|
133
|
+
p.on(maker.specTest, {
|
|
134
|
+
done: (w, _, r) => w.update(maker.specRun, (x) => ({ ...x, code: r.code, output: r.output })),
|
|
135
|
+
failed: (w, _, error) => w.update(maker.specRun, (x) => ({ ...x, code: -1, output: error })),
|
|
136
|
+
}),
|
|
137
|
+
|
|
138
|
+
p.on(maker.specWrite, { done: () => {}, failed: (w, _, error) => w.update(maker.specRun, (x) => ({ ...x, output: `save failed: ${error}` })) }),
|
|
139
|
+
|
|
140
|
+
p.action("spec-run", {
|
|
141
|
+
summary: "run a spec's scenarios headless, or every spec's when no spec is named; the verdicts land as the run writes them",
|
|
142
|
+
args: { spec: s.optional(s.string()) }, positional: ["spec"], result: s.void(),
|
|
143
|
+
run: (w, { spec }) => { w.set(maker.specRun, { at: w.now, spec: spec ?? null, code: null, output: "" }); w.run(maker.specTest, { spec: spec ?? null }); },
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
p.action("spec-save", {
|
|
147
|
+
summary: "keep a scenario a person played, as a file under the spec's scenarios directory: `file` its name (NAME.ts), `source` the program",
|
|
148
|
+
args: { spec: s.string(), file: s.string(), source: s.string() }, result: s.void(),
|
|
149
|
+
run: (w, a) => {
|
|
150
|
+
if (w.find(maker.specFile, a.spec) === undefined) throw new Error(`no spec is named ${a.spec}`);
|
|
151
|
+
if (!/^[a-z][a-z0-9-]*\.ts$/.test(a.file)) throw new Error("a file is lowercase words joined by dashes, ending in .ts");
|
|
152
|
+
w.run(maker.specWrite, a);
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
|
|
156
|
+
p.action("spec-step", {
|
|
157
|
+
summary: "act in the spec a person has open in the Maker: steps as `{fire, args}`, `{tick: ms}` or `{read, args}`, run in order by the page; follow the entity for what each answered and what changed",
|
|
158
|
+
args: { spec: s.string(), steps: s.array(s.unknown()) }, result: s.object({ entity: s.entity("specAsk") }),
|
|
159
|
+
run: (w, { spec, steps }) => {
|
|
160
|
+
if (w.find(maker.specFile, spec) === undefined) throw new Error(`no spec is named ${spec}`);
|
|
161
|
+
return { entity: w.spawn(maker.specAsk, { spec, kind: "step", program: JSON.stringify(steps), status: "asked", answer: "", error: "", at: w.now }) };
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
|
|
165
|
+
p.action("spec-read", {
|
|
166
|
+
summary: "read the spec a person has open in the Maker: a query by name with its args, or `take` for what the person did, noted and expected; follow the entity for the answer",
|
|
167
|
+
args: { spec: s.string(), query: s.string(), args: s.optional(s.unknown()) }, result: s.object({ entity: s.entity("specAsk") }),
|
|
168
|
+
run: (w, a) => {
|
|
169
|
+
if (w.find(maker.specFile, a.spec) === undefined) throw new Error(`no spec is named ${a.spec}`);
|
|
170
|
+
return { entity: w.spawn(maker.specAsk, { spec: a.spec, kind: "read", program: JSON.stringify({ query: a.query, args: a.args ?? {} }), status: "asked", answer: "", error: "", at: w.now }) };
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
p.action("spec-answer", {
|
|
175
|
+
summary: "the page's answer to an ask, from the spec it has open: what the page fires, not an agent",
|
|
176
|
+
args: { entity: s.entity("specAsk"), ok: s.boolean(), answer: s.unknown() }, result: s.void(),
|
|
177
|
+
run: (w, { entity, ok, answer }) => {
|
|
178
|
+
const a = w.get(entity, maker.specAsk);
|
|
179
|
+
if (!a) throw new Error(`no ask is #${entity}`);
|
|
180
|
+
if (a.status !== "asked") throw new Error(`ask #${entity} is already ${a.status}`);
|
|
181
|
+
w.add(entity, maker.specAsk, { ...a, status: ok ? "answered" : "failed", answer: ok ? JSON.stringify(answer) : "", error: ok ? "" : String(answer) });
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
|
|
185
|
+
p.query("spec-asks", {
|
|
186
|
+
summary: "the asks a page with this spec open must answer, oldest first",
|
|
187
|
+
args: { spec: s.string() },
|
|
188
|
+
result: s.array(s.object({ entity: s.entity("specAsk"), kind: s.enum("step", "read"), program: s.string() })),
|
|
189
|
+
read: (w, { spec }) => w.query(maker.specAsk).filter((r) => r.specAsk.spec === spec && r.specAsk.status === "asked").sort((a, b) => a.specAsk.at - b.specAsk.at).map((r) => ({ entity: r.entity, kind: r.specAsk.kind, program: r.specAsk.program })),
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
p.query("spec", {
|
|
193
|
+
summary: "every spec with its last verdicts: passed, failed, unknown per scenario, and whether the code or the spec changed since the run",
|
|
194
|
+
result: s.array(s.object({ name: s.string(), updated: s.string(), stale: s.boolean(), run: s.nullable(s.object({ at: s.string(), done: s.boolean(), passed: s.integer(), failed: s.integer(), unknown: s.integer() })), error: s.nullable(s.string()) })),
|
|
195
|
+
read: (w) => w.query(maker.specFile).map((r) => {
|
|
196
|
+
const v = w.get(r.entity, maker.specVerdicts), broken = w.get(r.entity, maker.specVerdictsBroken);
|
|
197
|
+
const count = (k: string) => v?.scenarios.filter((x) => x.verdict === k).length ?? 0;
|
|
198
|
+
return { name: r.specFile.name, updated: r.specFile.updated, stale: !!v && (v.at < r.specFile.updated || v.at < r.specFile.code), run: v ? { at: v.at, done: v.done, passed: count("passed"), failed: count("failed"), unknown: count("unknown") } : null, error: broken?.error ?? null };
|
|
199
|
+
}),
|
|
200
|
+
}),
|
|
201
|
+
|
|
202
|
+
p.query("spec-verdicts", {
|
|
203
|
+
summary: "one spec's verdicts in full: each scenario's verdict, where it broke, what failed, and the quiet effects its jobs asked of (what an adapter would have to play)",
|
|
204
|
+
args: { spec: s.string() },
|
|
205
|
+
result: s.object({ name: s.string(), stale: s.boolean(), verdicts: s.nullable(s.object(verdictFields)) }),
|
|
206
|
+
read: (w, { spec }) => {
|
|
207
|
+
const e = w.find(maker.specFile, spec);
|
|
208
|
+
if (e === undefined) throw new Error(`no spec is named ${spec}`);
|
|
209
|
+
const r = w.get(e, maker.specFile)!, v = w.get(e, maker.specVerdicts) ?? null;
|
|
210
|
+
return { name: spec, stale: !!v && (v.at < r.updated || v.at < r.code), verdicts: v };
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
]);
|