wai/wai-studio: the panels (Inspect, Verify and receipts, Encode/decode/package, Rooms, Run, Quantum) and the command palette; actions generated from the operation catalogue with a drift check; the core (wai-studio-core) binding the wai crate for the tab. wai dev serves Studio at /_wai/ on its own origin, with the dev stack's routes for it under /_wai/api/. Co-Authored-By: Claude Opus 5.5 <[email protected]>
213 lines
11 KiB
JavaScript
213 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Studio's actions, generated from the WAI operation catalogue, and the check that fails on
|
|
// drift.
|
|
//
|
|
// node scripts/actions.mjs write src/actions.generated.js from the catalogue
|
|
// node scripts/actions.mjs --check write nothing; exit 1 with one line a finding
|
|
// [--ops FILE] the catalogue export to read (wai.ops/1)
|
|
//
|
|
// The catalogue is wai/catalogue/ops.json, which `wai ops export` writes from the table in
|
|
// wai-rs (and CI holds to it). Until that table is on main, ops.pending.json beside this
|
|
// script is a copy of the export from the pull request that brings it, and is read instead;
|
|
// once wai/catalogue/ops.json exists, the copy must go, and the check says so.
|
|
//
|
|
// Every operation becomes an action: its id, its menu and item (the noun and the verb), its
|
|
// palette entry (`noun verb`), its summary, where it is placed, its effect, and the names
|
|
// the other doors give it (the command line, the MCP tool, the service's route), so the
|
|
// palette can say where an operation runs that this Studio cannot run. src/bindings.js says,
|
|
// by hand, which operations Studio runs and on which host. The check fails on:
|
|
//
|
|
// - an action with no operation: a binding, a core call or a server operation whose id the
|
|
// catalogue does not hold;
|
|
// - an operation with a browser placement (`client` or `both`: it runs on this side of a
|
|
// service it names, as a page does) and no action: no binding;
|
|
// - a mismatched name: src/actions.generated.js is not what the catalogue generates; a
|
|
// binding run in the tab whose call is not the core's `noun_verb`; a core call or server
|
|
// operation Studio binds to another host than the one that runs it; an `op: '…'`,
|
|
// `.op('…'` or `data-op="…"` in the source that names no operation.
|
|
//
|
|
// Standard library only.
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
export const HERE = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
export const CATALOGUE = resolve(HERE, '../catalogue/ops.json');
|
|
export const PENDING = join(HERE, 'ops.pending.json');
|
|
export const GENERATED = join(HERE, 'src/actions.generated.js');
|
|
export const CORE = join(HERE, 'core/src/lib.rs');
|
|
export const SERVER = resolve(HERE, '../wai-rs/src/bin/wai_cli/dx/studio.rs');
|
|
export const FORMAT = 'wai.ops/1';
|
|
// The placements whose operations run on this side of a service, as a page does.
|
|
export const BROWSER_PLACEMENTS = ['client', 'both'];
|
|
|
|
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
/** The catalogue to read, and why: `--ops`, else the export on main, else the pending copy. */
|
|
export function catalogueFile(given) {
|
|
if (given) return { file: resolve(given), why: 'given' };
|
|
if (existsSync(CATALOGUE)) return { file: CATALOGUE, why: 'the catalogue' };
|
|
return { file: PENDING, why: 'the pending copy (the catalogue is not on main yet)' };
|
|
}
|
|
|
|
export function readCatalogue(file) {
|
|
const bytes = readFileSync(file);
|
|
const doc = JSON.parse(bytes.toString('utf8'));
|
|
if (doc.format !== FORMAT) throw new Error(`${file}: not a catalogue export (format ${JSON.stringify(doc.format)})`);
|
|
return { doc, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
}
|
|
|
|
/** One operation as Studio's action table holds it. */
|
|
export function action(o) {
|
|
const [noun, verb] = o.id.split('.');
|
|
return {
|
|
id: o.id,
|
|
noun,
|
|
verb,
|
|
menu: cap(noun),
|
|
item: cap(verb),
|
|
palette: `${noun} ${verb}`,
|
|
summary: o.summary,
|
|
placement: o.placement,
|
|
effect: o.effect,
|
|
stability: o.stability,
|
|
toolset: o.toolset,
|
|
cli: o.doors?.cli ? o.cli : null,
|
|
mcp: o.doors?.mcp ? o.names?.mcp ?? null : null,
|
|
route: o.route ?? null,
|
|
call: `${noun}_${verb}`,
|
|
};
|
|
}
|
|
|
|
/** The text of src/actions.generated.js for a catalogue. Internal operations are never published. */
|
|
export function generate(doc, sha256) {
|
|
const ops = doc.operations.filter((o) => o.stability !== 'internal').map(action);
|
|
const lines = [
|
|
'// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the',
|
|
'// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run',
|
|
'// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.',
|
|
`export const CATALOGUE = ${JSON.stringify({ format: doc.format, toolchain: doc.toolchain, sha256 })};`,
|
|
'export const ACTIONS = [',
|
|
...ops.map((a) => ` ${JSON.stringify(a)},`),
|
|
'];',
|
|
'',
|
|
];
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/** The ids the core binds for WebAssembly: each `#[wasm_bindgen] pub fn noun_verb`, and its OPERATIONS list. */
|
|
export function coreCalls(src) {
|
|
const calls = [...src.matchAll(/#\[wasm_bindgen\]\s*\npub fn ([a-z0-9]+_[a-z0-9]+)\s*\(/g)].map((m) => m[1]);
|
|
const listed = [...(src.match(/pub const OPERATIONS: &\[&str\] = &\[([\s\S]*?)\];/)?.[1] ?? '').matchAll(/"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]);
|
|
return { calls, listed };
|
|
}
|
|
|
|
/** The operations the local core runs through `wai dev` (`/_wai/api/op/<id>`): its LOCAL_OPS table. */
|
|
export function serverOps(src) {
|
|
const table = src.match(/const LOCAL_OPS: &\[[^\]]*\] = &\[([\s\S]*?)\n\];/)?.[1] ?? '';
|
|
return [...table.matchAll(/\(\s*"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]);
|
|
}
|
|
|
|
/** Every `op: '…'` and `data-op="…"` in Studio's source, with where it stands. */
|
|
export function sourceOps(files) {
|
|
const out = [];
|
|
for (const [path, text] of files) {
|
|
for (const m of text.matchAll(/(?:\bop:\s*|\.op\(\s*|data-op['"]?\s*[:=]\s*)(["'`])([a-z0-9]+\.[a-z0-9]+)\1/g)) out.push({ path, id: m[2] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Every finding of the drift check, as lines. `bindings` is src/bindings.js's BINDINGS;
|
|
* `generated` the committed text of src/actions.generated.js; `core` and `server` the
|
|
* sources of the core and of `wai dev`'s Studio routes; `files` Studio's source as
|
|
* [path, text].
|
|
*/
|
|
export function findings({ doc, sha256, generated, bindings, core, server, files }) {
|
|
const out = [];
|
|
const ids = new Set(doc.operations.map((o) => o.id));
|
|
const byId = new Map(doc.operations.map((o) => [o.id, o]));
|
|
if (generated !== generate(doc, sha256)) out.push('src/actions.generated.js is not what the catalogue generates: run `node scripts/actions.mjs`');
|
|
for (const [id, b] of Object.entries(bindings)) {
|
|
if (!ids.has(id)) out.push(`the action ${id} has no operation in the catalogue`);
|
|
if (b.panel == null && b.elsewhere == null) out.push(`the action ${id} names neither a panel nor where it runs`);
|
|
}
|
|
for (const o of doc.operations) {
|
|
if (BROWSER_PLACEMENTS.includes(o.placement) && o.stability !== 'internal' && !(o.id in bindings)) {
|
|
out.push(`the operation ${o.id} is placed ${o.placement} (it runs on this side of a service, as a page does) and has no action in src/bindings.js`);
|
|
}
|
|
}
|
|
const { calls, listed } = coreCalls(core);
|
|
const tab = Object.entries(bindings).filter(([, b]) => b.hosted === 'tab' || b.local === 'tab' || b.local === 'tab+local').map(([id]) => id);
|
|
for (const id of tab) {
|
|
const want = id.replace('.', '_');
|
|
if (!calls.includes(want)) out.push(`the action ${id} runs in the tab, and the core has no call ${want}`);
|
|
if (!listed.includes(id)) out.push(`the action ${id} runs in the tab, and the core's OPERATIONS does not list it`);
|
|
}
|
|
for (const c of calls) {
|
|
const [noun] = c.split('_');
|
|
const id = c.replace('_', '.');
|
|
if ([...ids].some((i) => i.startsWith(`${noun}.`)) && !ids.has(id)) out.push(`the core's call ${c} names ${id}, which the catalogue does not hold`);
|
|
}
|
|
for (const id of listed) {
|
|
if (!ids.has(id)) out.push(`the core's OPERATIONS lists ${id}, which the catalogue does not hold`);
|
|
else if (!tab.includes(id)) out.push(`the core runs ${id}, and no action runs it in the tab`);
|
|
}
|
|
const local = serverOps(server);
|
|
for (const id of local) {
|
|
if (!ids.has(id)) out.push(`wai dev's LOCAL_OPS runs ${id}, which the catalogue does not hold`);
|
|
else if (!Object.entries(bindings).some(([b, x]) => b === id && (x.local === 'local' || x.local === 'tab+local'))) out.push(`wai dev's LOCAL_OPS runs ${id}, and no action runs it through the local core`);
|
|
}
|
|
for (const [id, b] of Object.entries(bindings)) {
|
|
if ((b.local === 'local' || b.local === 'tab+local') && !local.includes(id)) out.push(`the action ${id} runs through the local core, and wai dev's LOCAL_OPS does not run it`);
|
|
}
|
|
for (const { path, id } of sourceOps(files)) {
|
|
if (!ids.has(id)) out.push(`${path}: ${id} names no operation of the catalogue`);
|
|
}
|
|
for (const [id, b] of Object.entries(bindings)) {
|
|
const o = byId.get(id);
|
|
if (o && b.elsewhere === 'cli' && !o.doors?.cli) out.push(`the action ${id} says it runs from the command line, and the catalogue gives it no command line`);
|
|
if (o && b.elsewhere === 'mcp' && !o.doors?.mcp) out.push(`the action ${id} says it runs through wai mcp, and the catalogue gives it no MCP tool`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const n of readdirSync(dir)) {
|
|
const p = join(dir, n);
|
|
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
else if (/\.(m?js|html)$/.test(n) && n !== 'actions.generated.js') out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const at = args.indexOf('--ops');
|
|
const { file, why } = catalogueFile(at >= 0 ? args[at + 1] : null);
|
|
const { doc, sha256 } = readCatalogue(file);
|
|
if (!args.includes('--check')) {
|
|
writeFileSync(GENERATED, generate(doc, sha256));
|
|
console.log(`wrote ${relative(process.cwd(), GENERATED)} from ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations`);
|
|
return 0;
|
|
}
|
|
const { BINDINGS } = await import(pathToFileURL(join(HERE, 'src/bindings.js')).href);
|
|
const found = findings({
|
|
doc,
|
|
sha256,
|
|
generated: existsSync(GENERATED) ? readFileSync(GENERATED, 'utf8') : '',
|
|
bindings: BINDINGS,
|
|
core: readFileSync(CORE, 'utf8'),
|
|
server: existsSync(SERVER) ? readFileSync(SERVER, 'utf8') : '',
|
|
files: walk(join(HERE, 'src')).map((p) => [relative(HERE, p), readFileSync(p, 'utf8')]),
|
|
});
|
|
if (at < 0 && file === CATALOGUE && existsSync(PENDING)) found.push('the catalogue is on main (wai/catalogue/ops.json): delete ops.pending.json, the copy read until it landed');
|
|
console.log(`read ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations, ${Object.keys(BINDINGS).length} actions bound`);
|
|
for (const f of found) console.log(`FAIL ${f}`);
|
|
console.log(found.length ? `${found.length} finding(s)` : 'Studio\'s actions hold to the catalogue');
|
|
return found.length ? 1 : 0;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(await main());
|