feat: unify card runtime and event-driven web ui
This commit is contained in:
parent
0edf8c3fef
commit
4dfb7ca3cc
105 changed files with 17382 additions and 8505 deletions
24
scripts/card_runtime_fixture_card.mjs
Normal file
24
scripts/card_runtime_fixture_card.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export function mount({ root, state, host }) {
|
||||
const render = (nextState) => {
|
||||
const title = typeof nextState.title === "string" ? nextState.title : "";
|
||||
root.innerHTML = `<div data-fixture-title="${title}">${title}</div>`;
|
||||
};
|
||||
|
||||
render(state);
|
||||
host.setLiveContent({ phase: "mount", title: state.title ?? "" });
|
||||
host.setRefreshHandler(() => {
|
||||
root.dataset.refreshed = "1";
|
||||
});
|
||||
|
||||
return {
|
||||
update({ state: nextState, host: nextHost }) {
|
||||
render(nextState);
|
||||
nextHost.setLiveContent({ phase: "update", title: nextState.title ?? "" });
|
||||
},
|
||||
destroy() {
|
||||
root.dataset.destroyed = "1";
|
||||
root.innerHTML = "";
|
||||
host.setRefreshHandler(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
119
scripts/check_card_runtime.py
Normal file
119
scripts/check_card_runtime.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
LIVE_TEMPLATES_DIR = Path.home() / ".nanobot" / "cards" / "templates"
|
||||
EXAMPLE_TEMPLATES_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "examples" / "cards" / "templates"
|
||||
)
|
||||
|
||||
|
||||
LEGACY_MARKERS = (
|
||||
"__nanobot",
|
||||
"document.currentScript",
|
||||
"mountLegacyTemplate",
|
||||
"legacy-template-module",
|
||||
)
|
||||
|
||||
|
||||
def iter_template_dirs(root: Path) -> dict[str, Path]:
|
||||
templates: dict[str, Path] = {}
|
||||
if not root.exists():
|
||||
return templates
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not (child / "manifest.json").exists():
|
||||
continue
|
||||
templates[child.name] = child
|
||||
return templates
|
||||
|
||||
|
||||
def validate_template_dir(template_dir: Path, failures: list[str]) -> None:
|
||||
manifest_path = template_dir / "manifest.json"
|
||||
template_path = template_dir / "template.html"
|
||||
runtime_path = template_dir / "card.js"
|
||||
|
||||
for required in (manifest_path, template_path, runtime_path):
|
||||
if not required.exists():
|
||||
failures.append(f"{template_dir}: missing {required.name}")
|
||||
return
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
failures.append(f"{manifest_path}: invalid JSON ({exc})")
|
||||
return
|
||||
if not isinstance(manifest, dict):
|
||||
failures.append(f"{manifest_path}: manifest must be an object")
|
||||
|
||||
template_html = template_path.read_text(encoding="utf-8")
|
||||
if "<script" in template_html.lower():
|
||||
failures.append(f"{template_path}: inline script tags are not allowed")
|
||||
|
||||
runtime_js = runtime_path.read_text(encoding="utf-8")
|
||||
if "export function mount" not in runtime_js:
|
||||
failures.append(f"{runtime_path}: missing `export function mount`")
|
||||
for marker in LEGACY_MARKERS:
|
||||
if marker in runtime_js:
|
||||
failures.append(f"{runtime_path}: legacy marker `{marker}` still present")
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "--check", str(runtime_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
failures.append(f"{runtime_path}: node --check failed: {detail}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--live-root", type=Path, default=LIVE_TEMPLATES_DIR)
|
||||
parser.add_argument("--example-root", type=Path, default=EXAMPLE_TEMPLATES_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
live_root = args.live_root.expanduser()
|
||||
example_root = args.example_root.expanduser()
|
||||
|
||||
failures: list[str] = []
|
||||
for template_dir in iter_template_dirs(live_root).values():
|
||||
validate_template_dir(template_dir, failures)
|
||||
for template_dir in iter_template_dirs(example_root).values():
|
||||
validate_template_dir(template_dir, failures)
|
||||
|
||||
sync_check = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(Path(__file__).resolve().parent / "sync_card_templates.py"),
|
||||
"--check",
|
||||
"--source",
|
||||
str(live_root),
|
||||
"--dest",
|
||||
str(example_root),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if sync_check.returncode != 0:
|
||||
detail = (sync_check.stdout + sync_check.stderr).strip()
|
||||
if detail:
|
||||
print(detail)
|
||||
failures.append("template mirror drift detected")
|
||||
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(failure)
|
||||
return 1
|
||||
|
||||
print("card runtime ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
57
scripts/check_card_runtime_fixture.mjs
Normal file
57
scripts/check_card_runtime_fixture.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
import { mount } from "./card_runtime_fixture_card.mjs";
|
||||
|
||||
function createFakeRoot() {
|
||||
return {
|
||||
dataset: {},
|
||||
innerHTML: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = createFakeRoot();
|
||||
const liveContentCalls = [];
|
||||
const refreshHandlers = [];
|
||||
|
||||
const host = {
|
||||
setLiveContent(value) {
|
||||
liveContentCalls.push(value);
|
||||
},
|
||||
setRefreshHandler(handler) {
|
||||
refreshHandlers.push(handler);
|
||||
},
|
||||
};
|
||||
|
||||
const mounted = mount({
|
||||
root,
|
||||
state: { title: "alpha" },
|
||||
host,
|
||||
});
|
||||
|
||||
assert.equal(typeof mounted?.update, "function");
|
||||
assert.equal(typeof mounted?.destroy, "function");
|
||||
assert.match(root.innerHTML, /alpha/);
|
||||
assert.deepEqual(liveContentCalls.at(-1), { phase: "mount", title: "alpha" });
|
||||
assert.equal(typeof refreshHandlers.at(-1), "function");
|
||||
|
||||
refreshHandlers.at(-1)?.();
|
||||
assert.equal(root.dataset.refreshed, "1");
|
||||
|
||||
mounted.update({
|
||||
root,
|
||||
state: { title: "beta" },
|
||||
host,
|
||||
});
|
||||
assert.match(root.innerHTML, /beta/);
|
||||
assert.deepEqual(liveContentCalls.at(-1), { phase: "update", title: "beta" });
|
||||
|
||||
mounted.destroy();
|
||||
assert.equal(root.innerHTML, "");
|
||||
assert.equal(root.dataset.destroyed, "1");
|
||||
assert.equal(refreshHandlers.at(-1), null);
|
||||
|
||||
console.log("card runtime fixture ok");
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -5,6 +5,19 @@ status=0
|
|||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
python_files=(
|
||||
"app.py"
|
||||
"app_dependencies.py"
|
||||
"card_store.py"
|
||||
"inbox_service.py"
|
||||
"message_pipeline.py"
|
||||
"nanobot_api_client.py"
|
||||
"route_helpers.py"
|
||||
"rtc_manager.py"
|
||||
"session_store.py"
|
||||
"tool_job_service.py"
|
||||
"ui_event_bus.py"
|
||||
"web_runtime.py"
|
||||
"workbench_store.py"
|
||||
"routes"
|
||||
)
|
||||
|
||||
run_check() {
|
||||
|
|
@ -33,7 +46,7 @@ run_check \
|
|||
uv run --with "deptry>=0.24.0,<1.0.0" \
|
||||
deptry . \
|
||||
--requirements-files requirements.txt \
|
||||
--known-first-party app,supertonic_gateway,voice_rtc,wisper \
|
||||
--known-first-party app,card_store,supertonic_gateway,sync_card_templates,voice_rtc,wisper \
|
||||
--per-rule-ignores DEP002=uvicorn \
|
||||
--extend-exclude ".*/frontend/.*" \
|
||||
--extend-exclude ".*/\\.venv/.*" \
|
||||
|
|
@ -42,5 +55,14 @@ run_check \
|
|||
"Vulture" \
|
||||
uv run --with "vulture>=2.15,<3.0.0" \
|
||||
vulture "${python_files[@]}" --min-confidence 80
|
||||
run_check \
|
||||
"Card Runtime" \
|
||||
python3 scripts/check_card_runtime.py
|
||||
run_check \
|
||||
"Card Runtime Fixture" \
|
||||
node scripts/check_card_runtime_fixture.mjs
|
||||
run_check \
|
||||
"Backend Tests" \
|
||||
.venv/bin/python -m unittest discover -s tests -p "test_*.py"
|
||||
|
||||
exit "${status}"
|
||||
|
|
|
|||
139
scripts/sync_card_templates.py
Normal file
139
scripts/sync_card_templates.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LIVE_TEMPLATES_DIR = Path.home() / ".nanobot" / "cards" / "templates"
|
||||
EXAMPLE_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "examples" / "cards" / "templates"
|
||||
|
||||
|
||||
def iter_template_dirs(root: Path) -> dict[str, Path]:
|
||||
templates: dict[str, Path] = {}
|
||||
if not root.exists():
|
||||
return templates
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not (child / "manifest.json").exists():
|
||||
continue
|
||||
templates[child.name] = child
|
||||
return templates
|
||||
|
||||
|
||||
def sync_directory(source: Path, dest: Path) -> None:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_entries = {path.name: path for path in source.iterdir()}
|
||||
dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {}
|
||||
|
||||
for name, source_path in source_entries.items():
|
||||
dest_path = dest / name
|
||||
if source_path.is_dir():
|
||||
sync_directory(source_path, dest_path)
|
||||
continue
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not dest_path.exists() or not filecmp.cmp(source_path, dest_path, shallow=False):
|
||||
shutil.copy2(source_path, dest_path)
|
||||
|
||||
for name, dest_path in dest_entries.items():
|
||||
if name in source_entries:
|
||||
continue
|
||||
if dest_path.is_dir():
|
||||
shutil.rmtree(dest_path)
|
||||
else:
|
||||
dest_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def compare_directory(source: Path, dest: Path, drifts: list[str], *, prefix: str) -> None:
|
||||
source_entries = {path.name: path for path in source.iterdir()} if source.exists() else {}
|
||||
dest_entries = {path.name: path for path in dest.iterdir()} if dest.exists() else {}
|
||||
|
||||
for name, source_path in source_entries.items():
|
||||
rel = f"{prefix}/{name}" if prefix else name
|
||||
dest_path = dest / name
|
||||
if name not in dest_entries:
|
||||
drifts.append(f"missing mirror: {rel}")
|
||||
continue
|
||||
if source_path.is_dir():
|
||||
if not dest_path.is_dir():
|
||||
drifts.append(f"type mismatch: {rel}")
|
||||
continue
|
||||
compare_directory(source_path, dest_path, drifts, prefix=rel)
|
||||
continue
|
||||
if dest_path.is_dir():
|
||||
drifts.append(f"type mismatch: {rel}")
|
||||
continue
|
||||
if not filecmp.cmp(source_path, dest_path, shallow=False):
|
||||
drifts.append(f"content drift: {rel}")
|
||||
|
||||
for name in dest_entries:
|
||||
if name not in source_entries:
|
||||
rel = f"{prefix}/{name}" if prefix else name
|
||||
drifts.append(f"stale mirror: {rel}")
|
||||
|
||||
|
||||
def run_check(source_root: Path, dest_root: Path) -> int:
|
||||
drifts: list[str] = []
|
||||
source_templates = iter_template_dirs(source_root)
|
||||
dest_templates = iter_template_dirs(dest_root)
|
||||
|
||||
for name, source_dir in source_templates.items():
|
||||
dest_dir = dest_root / name
|
||||
if name not in dest_templates:
|
||||
drifts.append(f"missing mirror: {name}")
|
||||
continue
|
||||
compare_directory(source_dir, dest_dir, drifts, prefix=name)
|
||||
|
||||
for name in dest_templates:
|
||||
if name not in source_templates:
|
||||
drifts.append(f"stale mirror: {name}")
|
||||
|
||||
if drifts:
|
||||
for drift in drifts:
|
||||
print(drift)
|
||||
return 1
|
||||
print("template sync ok")
|
||||
return 0
|
||||
|
||||
|
||||
def run_sync(source_root: Path, dest_root: Path) -> int:
|
||||
source_templates = iter_template_dirs(source_root)
|
||||
dest_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for name, source_dir in source_templates.items():
|
||||
sync_directory(source_dir, dest_root / name)
|
||||
|
||||
for child in list(dest_root.iterdir()):
|
||||
if child.name in source_templates:
|
||||
continue
|
||||
if child.is_dir():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink(missing_ok=True)
|
||||
|
||||
print(f"synced {len(source_templates)} template directories")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--check", action="store_true")
|
||||
parser.add_argument("--source", type=Path, default=LIVE_TEMPLATES_DIR)
|
||||
parser.add_argument("--dest", type=Path, default=EXAMPLE_TEMPLATES_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
source_root = args.source.expanduser()
|
||||
dest_root = args.dest.expanduser()
|
||||
|
||||
if args.check:
|
||||
return run_check(source_root, dest_root)
|
||||
return run_sync(source_root, dest_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue