Add Canvas command surface
This commit is contained in:
+18
-3
@@ -28,10 +28,19 @@ PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||
SMOKE = [
|
||||
{"path": "/health", "expect": [200], "kind": "vanilla"},
|
||||
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/canvas/status", "expect": [200, 503, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/canvas/tools", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/canvas/proxy?path=/api/v1/capabilities", "expect": [200, 401, 403, 502], "kind": "plugin"},
|
||||
{"path": "/api/canvas/command", "method": "POST", "body": b"{}", "expect": [400, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/canvas/design-context", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/canvas/events", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/umbrella", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/api/umbrella/doc?path=sot/README.md", "expect": [200, 401, 403], "kind": "plugin"},
|
||||
{"path": "/plugins/svrnty/app.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/app.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/canvas.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/canvas.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/canvas.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/umbrella.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/umbrella.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
{"path": "/plugins/svrnty/umbrella.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
|
||||
@@ -61,10 +70,16 @@ def _wait_for(url, timeout=20):
|
||||
return False
|
||||
|
||||
|
||||
def _hit(base, path):
|
||||
def _hit(base, spec):
|
||||
path = spec["path"]
|
||||
url = base.rstrip("/") + path
|
||||
method = spec.get("method", "GET")
|
||||
body = spec.get("body")
|
||||
try:
|
||||
with urlopen(url, timeout=5) as r:
|
||||
req = Request(url, data=body, method=method)
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urlopen(req, timeout=5) as r:
|
||||
return r.status, r.read()[:200]
|
||||
except URLError as e:
|
||||
if hasattr(e, "code"):
|
||||
@@ -78,7 +93,7 @@ def smoke(base):
|
||||
rows = []
|
||||
failed = 0
|
||||
for s in SMOKE:
|
||||
status, _body = _hit(base, s["path"])
|
||||
status, _body = _hit(base, s)
|
||||
ok = status in s["expect"]
|
||||
rows.append({"path": s["path"], "status": status, "kind": s["kind"], "ok": ok})
|
||||
if not ok:
|
||||
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Browser smoke for the Hermes Canvas command-center loop.
|
||||
|
||||
Starts canva-editor in mock mode, starts hermes-webui with the Svrnty plugin,
|
||||
opens the real WebUI shell, switches to Canvas, generates variants, captures a
|
||||
screenshot, and writes a compact JSON proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
|
||||
PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||
HERMES_REPO = PLUGIN_REPO.parent
|
||||
WEBUI_REPO = HERMES_REPO / "hermes-webui"
|
||||
AGENT_REPO = HERMES_REPO / "hermes-agent"
|
||||
CANVA_REPO = HERMES_REPO.parent / "cortex" / "L2-svrnty.tool-canva-editor"
|
||||
CMO_REPO = HERMES_REPO / "cmo"
|
||||
DEFAULT_OUT = HERMES_REPO / ".scratch" / "canvas-visual-proof.json"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
def _wait_for(url: str, timeout: int = 30) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as res:
|
||||
if res.status < 500:
|
||||
return True
|
||||
except URLError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _pixel_stats(path: Path) -> dict:
|
||||
img = Image.open(path).convert("RGB")
|
||||
width, height = img.size
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
pixels = list(img.getdata())
|
||||
total = len(pixels)
|
||||
bright = sum(1 for r, g, b in pixels if max(r, g, b) > 150)
|
||||
distinct = len(set(pixels[:: max(1, total // 20000)]))
|
||||
return {
|
||||
"path": str(path),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"bright_ratio": round(bright / total, 4),
|
||||
"sampled_distinct_colors": distinct,
|
||||
"nonblank": bright > 1000 and distinct > 20,
|
||||
}
|
||||
|
||||
|
||||
def _start_canva(port: int, db_path: Path) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"/home/svrnty/sdk/go1.26.0/bin{os.pathsep}{env.get('PATH', '')}"
|
||||
env["PORT"] = str(port)
|
||||
env["DB_PATH"] = str(db_path)
|
||||
return subprocess.Popen(
|
||||
["go", "run", "./cmd/server", "--mock"],
|
||||
cwd=CANVA_REPO,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
|
||||
def _start_webui(port: int, state_dir: Path, canva_base: str, plugin_module: str) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
env["CANVA_EDITOR_BASE_URL"] = canva_base
|
||||
env["HERMES_WEBUI_PYTHON_PLUGIN"] = plugin_module
|
||||
if plugin_module == "svrnty_hermes_webui_refactor_plugin":
|
||||
env["SVRNTY_WEBUI_DEVELOPMENT_INCLUDE_PROD"] = "1"
|
||||
env["HERMES_WEBUI_PORT"] = str(port)
|
||||
env["HERMES_WEBUI_STATE_DIR"] = str(state_dir)
|
||||
env["HERMES_WEBUI_AGENT_DIR"] = str(AGENT_REPO)
|
||||
env["HERMES_REPO_ROOT"] = str(HERMES_REPO)
|
||||
env["PYTHONPATH"] = (
|
||||
f"{PLUGIN_REPO}{os.pathsep}{HERMES_REPO / 'webui-plugin-development'}{os.pathsep}{AGENT_REPO}"
|
||||
if not env.get("PYTHONPATH")
|
||||
else f"{PLUGIN_REPO}{os.pathsep}{HERMES_REPO / 'webui-plugin-development'}{os.pathsep}{AGENT_REPO}{os.pathsep}{env['PYTHONPATH']}"
|
||||
)
|
||||
py = WEBUI_REPO / "venv" / "bin" / "python"
|
||||
agent_py = AGENT_REPO / "venv" / "bin" / "python"
|
||||
python_exe = str(py) if py.exists() else str(agent_py) if agent_py.exists() else "python3"
|
||||
return subprocess.Popen(
|
||||
[python_exe, str(WEBUI_REPO / "server.py")],
|
||||
cwd=AGENT_REPO if AGENT_REPO.exists() else WEBUI_REPO,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
|
||||
def _stop(proc: subprocess.Popen | None) -> str:
|
||||
if proc is None:
|
||||
return ""
|
||||
output = ""
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
if proc.stdout:
|
||||
try:
|
||||
output = proc.stdout.read(4000)
|
||||
except Exception:
|
||||
output = ""
|
||||
return output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
|
||||
parser.add_argument("--screenshot-dir", default=str(HERMES_REPO / ".scratch" / "screenshots" / "canvas"))
|
||||
parser.add_argument("--plugin-module", default="svrnty_hermes_webui_plugin")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not CANVA_REPO.exists():
|
||||
raise SystemExit(f"missing canva-editor repo: {CANVA_REPO}")
|
||||
|
||||
out_json = Path(args.output_json)
|
||||
screenshot_dir = Path(args.screenshot_dir)
|
||||
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
canva_port = _free_port()
|
||||
webui_port = _free_port()
|
||||
canva_base = f"http://127.0.0.1:{canva_port}"
|
||||
webui_base = f"http://127.0.0.1:{webui_port}"
|
||||
canva_proc: subprocess.Popen | None = None
|
||||
webui_proc: subprocess.Popen | None = None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-canvas-webui-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
canva_proc = _start_canva(canva_port, tmp_path / "canva-editor.db")
|
||||
try:
|
||||
if not _wait_for(f"{canva_base}/health", timeout=45):
|
||||
raise RuntimeError(f"canva-editor did not become healthy at {canva_base}/health")
|
||||
|
||||
webui_proc = _start_webui(webui_port, tmp_path / "webui-state", canva_base, args.plugin_module)
|
||||
if not _wait_for(f"{webui_base}/health", timeout=45):
|
||||
raise RuntimeError(f"webui did not become healthy at {webui_base}/health")
|
||||
|
||||
proof = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"webui_base": webui_base,
|
||||
"canva_base": canva_base,
|
||||
"plugin_module": args.plugin_module,
|
||||
}
|
||||
cmo_helper = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CMO_REPO / "canvas_command.py"),
|
||||
"command",
|
||||
"Create a CMO-triggered draft promo card for Plan B",
|
||||
"--variants",
|
||||
"1",
|
||||
"--brand",
|
||||
"planb",
|
||||
"--project",
|
||||
"Hermes CMO Canvas Smoke",
|
||||
"--wait",
|
||||
],
|
||||
env={**os.environ, "HERMES_WEBUI_URL": webui_base},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
try:
|
||||
cmo_helper_payload = json.loads(cmo_helper.stdout or "{}")
|
||||
except ValueError:
|
||||
cmo_helper_payload = {"parse_error": cmo_helper.stdout}
|
||||
proof["cmo_helper"] = {
|
||||
"returncode": cmo_helper.returncode,
|
||||
"ok": cmo_helper.returncode == 0 and bool(cmo_helper_payload.get("ok")),
|
||||
"command_id": cmo_helper_payload.get("command_id"),
|
||||
"screens": len(cmo_helper_payload.get("screens") or []),
|
||||
"event_wait_ok": bool((cmo_helper_payload.get("event_wait") or {}).get("ok")),
|
||||
"design_context_status": (cmo_helper_payload.get("design_context") or {}).get("status"),
|
||||
"secondbrain_status": (cmo_helper_payload.get("design_context") or {}).get("secondbrain_status"),
|
||||
"stderr": cmo_helper.stderr[-1000:],
|
||||
}
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(
|
||||
headless=True,
|
||||
executable_path="/usr/bin/google-chrome" if Path("/usr/bin/google-chrome").exists() else None,
|
||||
args=["--no-sandbox"],
|
||||
)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 1000})
|
||||
page.goto(webui_base, wait_until="domcontentloaded", timeout=30000)
|
||||
page.wait_for_function(
|
||||
"() => window.SvrntyCanvas && window.SvrntyNav && typeof window.switchPanel === 'function'",
|
||||
timeout=30000,
|
||||
)
|
||||
slash_registered = page.evaluate(
|
||||
"() => typeof COMMANDS !== 'undefined' && COMMANDS.some((cmd) => cmd && cmd.name === 'canvas')"
|
||||
)
|
||||
page.evaluate("() => window.switchPanel('canvas')")
|
||||
page.wait_for_selector("#svrntyCanvasOverlay:not([hidden])", timeout=10000)
|
||||
page.wait_for_function(
|
||||
"() => document.querySelector('#svrntyCanvasStatus')?.textContent.includes('online')",
|
||||
timeout=30000,
|
||||
)
|
||||
page.evaluate(
|
||||
"""() => window.SvrntyCanvas.request({
|
||||
prompt: 'Create a polished CMO launch dashboard with campaign KPIs',
|
||||
variants: 2,
|
||||
brand_id: 'planb',
|
||||
source: 'visual-smoke'
|
||||
})"""
|
||||
)
|
||||
page.wait_for_function(
|
||||
"() => document.querySelectorAll('.svrnty-canvas-artboard').length >= 2",
|
||||
timeout=60000,
|
||||
)
|
||||
editable = page.locator('.svrnty-canvas-artboard:first-child .svrnty-canvas-node[contenteditable="true"]').first
|
||||
editable.fill("Edited KPI panel")
|
||||
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="save"]')
|
||||
try:
|
||||
page.wait_for_function(
|
||||
"() => !!window.SvrntyCanvas.state.lastEditPersisted",
|
||||
timeout=30000,
|
||||
)
|
||||
except Exception:
|
||||
debug_state = page.evaluate(
|
||||
"""() => ({
|
||||
artboards: window.SvrntyCanvas.state.artboards.map((b) => ({id:b.id,title:b.title,dirty:b.dirty,badge:b.badge})),
|
||||
logs: window.SvrntyCanvas.state.log.map((row) => row.message),
|
||||
editableCount: document.querySelectorAll('.svrnty-canvas-node[contenteditable="true"]').length,
|
||||
saveButtonCount: document.querySelectorAll('[data-canvas-action="save"]').length
|
||||
})"""
|
||||
)
|
||||
print(json.dumps({"canvas_edit_debug": debug_state}, indent=2), file=sys.stderr)
|
||||
raise
|
||||
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="prototype-source"]')
|
||||
page.click('.svrnty-canvas-artboard:nth-child(2) [data-canvas-action="prototype-link"]')
|
||||
page.wait_for_function(
|
||||
"() => !!window.SvrntyCanvas.state.lastPrototypeEdge",
|
||||
timeout=30000,
|
||||
)
|
||||
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="export"]')
|
||||
page.wait_for_function(
|
||||
"() => window.SvrntyCanvas.state.exportSummary && window.SvrntyCanvas.state.exportSummary.screens.length >= 2",
|
||||
timeout=30000,
|
||||
)
|
||||
state = page.evaluate(
|
||||
"""(slashRegistered) => ({
|
||||
connected: window.SvrntyCanvas.state.connected,
|
||||
artboards: window.SvrntyCanvas.state.artboards.length,
|
||||
slashCommandRegistered: slashRegistered,
|
||||
editPersisted: !!window.SvrntyCanvas.state.lastEditPersisted,
|
||||
prototypeLinked: !!window.SvrntyCanvas.state.lastPrototypeEdge,
|
||||
exportScreens: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.screens.length : 0,
|
||||
exportPrototypeEdges: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.prototypeEdges.length : 0,
|
||||
designContextReady: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.status === 'ready' : false,
|
||||
designContextSource: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source : '',
|
||||
designContextVersion: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source_version : '',
|
||||
secondbrainStatus: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.secondbrain_status : '',
|
||||
memoryHintCount: window.SvrntyCanvas.state.designContext && window.SvrntyCanvas.state.designContext.memory_hints ? window.SvrntyCanvas.state.designContext.memory_hints.length : 0,
|
||||
logs: window.SvrntyCanvas.state.log.map((row) => row.message).slice(0, 8),
|
||||
status: document.querySelector('#svrntyCanvasStatus')?.textContent || '',
|
||||
mainClass: document.querySelector('main.main')?.className || ''
|
||||
})""",
|
||||
slash_registered,
|
||||
)
|
||||
shot = screenshot_dir / "canvas-desktop-proof.png"
|
||||
page.screenshot(path=str(shot), full_page=True)
|
||||
browser.close()
|
||||
|
||||
proof["state"] = state
|
||||
proof["pixel_stats"] = _pixel_stats(shot)
|
||||
proof["pass"] = bool(
|
||||
state["connected"]
|
||||
and state["artboards"] >= 2
|
||||
and state["slashCommandRegistered"]
|
||||
and state["editPersisted"]
|
||||
and state["prototypeLinked"]
|
||||
and state["exportScreens"] >= 2
|
||||
and state["exportPrototypeEdges"] >= 1
|
||||
and state["designContextReady"]
|
||||
and state["designContextSource"] == "cmo.brand_profile_cache"
|
||||
and state["secondbrainStatus"] in ("ready", "unconfigured")
|
||||
and state["memoryHintCount"] >= 1
|
||||
and proof["cmo_helper"]["ok"]
|
||||
and proof["cmo_helper"]["screens"] >= 1
|
||||
and proof["cmo_helper"]["event_wait_ok"]
|
||||
and proof["cmo_helper"]["design_context_status"] == "ready"
|
||||
and "svrnty-showing-canvas" in state["mainClass"]
|
||||
and proof["pixel_stats"]["nonblank"]
|
||||
)
|
||||
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_json.write_text(json.dumps(proof, indent=2), encoding="utf-8")
|
||||
print(json.dumps(proof, indent=2))
|
||||
return 0 if proof["pass"] else 1
|
||||
finally:
|
||||
webui_log = _stop(webui_proc)
|
||||
canva_log = _stop(canva_proc)
|
||||
if webui_log:
|
||||
print(webui_log, file=sys.stderr)
|
||||
if canva_log:
|
||||
print(canva_log, file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke a natural CMO Hermes turn into the WebUI Canvas bridge.
|
||||
|
||||
This proves the runtime handoff JP cares about:
|
||||
CMO profile -> terminal helper -> Hermes WebUI plugin -> canva-editor -> replayable
|
||||
Canvas events. The canva-editor is mocked here so the proof isolates routing; the
|
||||
real Spark qwen3.6 generator is covered by canva-editor's real-model smoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
PLUGIN_REPO = Path(__file__).resolve().parent.parent
|
||||
HERMES_REPO = PLUGIN_REPO.parent
|
||||
WEBUI_REPO = HERMES_REPO / "hermes-webui"
|
||||
AGENT_REPO = HERMES_REPO / "hermes-agent"
|
||||
CANVA_REPO = HERMES_REPO.parent / "cortex" / "L2-svrnty.tool-canva-editor"
|
||||
CMO_REPO = HERMES_REPO / "cmo"
|
||||
DEFAULT_OUT = HERMES_REPO / ".scratch" / "cmo-natural-canvas-proof.json"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
def _wait_for(url: str, timeout: int = 45) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urlopen(url, timeout=1) as res:
|
||||
if res.status < 500:
|
||||
return True
|
||||
except URLError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: float = 8.0) -> dict:
|
||||
with urlopen(url, timeout=timeout) as res:
|
||||
return json.loads(res.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _start_canva(port: int, db_path: Path) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"/home/svrnty/sdk/go1.26.0/bin{os.pathsep}{env.get('PATH', '')}"
|
||||
env["PORT"] = str(port)
|
||||
env["DB_PATH"] = str(db_path)
|
||||
return subprocess.Popen(
|
||||
["go", "run", "./cmd/server", "--mock"],
|
||||
cwd=CANVA_REPO,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
|
||||
def _start_webui(port: int, state_dir: Path, canva_base: str) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
env["CANVA_EDITOR_BASE_URL"] = canva_base
|
||||
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
|
||||
env["HERMES_WEBUI_PORT"] = str(port)
|
||||
env["HERMES_WEBUI_STATE_DIR"] = str(state_dir)
|
||||
env["HERMES_WEBUI_AGENT_DIR"] = str(AGENT_REPO)
|
||||
env["HERMES_REPO_ROOT"] = str(HERMES_REPO)
|
||||
env["PYTHONPATH"] = (
|
||||
f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}"
|
||||
if not env.get("PYTHONPATH")
|
||||
else f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}{os.pathsep}{env['PYTHONPATH']}"
|
||||
)
|
||||
py = WEBUI_REPO / "venv" / "bin" / "python"
|
||||
agent_py = AGENT_REPO / "venv" / "bin" / "python"
|
||||
python_exe = str(py) if py.exists() else str(agent_py) if agent_py.exists() else "python3"
|
||||
return subprocess.Popen(
|
||||
[python_exe, str(WEBUI_REPO / "server.py")],
|
||||
cwd=AGENT_REPO if AGENT_REPO.exists() else WEBUI_REPO,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
|
||||
def _stop(proc: subprocess.Popen | None) -> str:
|
||||
if proc is None:
|
||||
return ""
|
||||
output = ""
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
if proc.stdout:
|
||||
try:
|
||||
output = proc.stdout.read(4000)
|
||||
except Exception:
|
||||
output = ""
|
||||
return output
|
||||
|
||||
|
||||
def _run_cmo_turn(webui_base: str, timeout_s: int) -> subprocess.CompletedProcess:
|
||||
prompt = f"""
|
||||
You are CMO for Plan B. This is a routing smoke for the Canvas design command center.
|
||||
|
||||
Use the terminal tool to run exactly this command:
|
||||
python3 "{CMO_REPO / "canvas_command.py"}" command "Create one CMO-routed Plan B promo card for a family meal campaign" --variants 1 --brand planb --project "Hermes CMO Natural Smoke" --wait
|
||||
|
||||
After the terminal command returns, reply with a compact summary containing command_id, project_id, number of screens, design_context.status, and secondbrain_status. Do not call canva-editor directly.
|
||||
""".strip()
|
||||
env = os.environ.copy()
|
||||
env["HERMES_WEBUI_URL"] = webui_base
|
||||
env["CMO_LIB"] = str(CMO_REPO)
|
||||
env["HERMES_INFERENCE_PROVIDER"] = "vllm"
|
||||
env["HERMES_INFERENCE_MODEL"] = "qwen3.6-35b-a3b"
|
||||
return subprocess.run(
|
||||
[
|
||||
"hermes",
|
||||
"-p",
|
||||
"cmo-planb",
|
||||
"--provider",
|
||||
"vllm",
|
||||
"--model",
|
||||
"qwen3.6-35b-a3b",
|
||||
"-z",
|
||||
prompt,
|
||||
"--toolsets",
|
||||
"terminal",
|
||||
"--accept-hooks",
|
||||
"--yolo",
|
||||
],
|
||||
cwd=HERMES_REPO,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
def _canvas_event_summary(webui_base: str) -> dict:
|
||||
payload = _fetch_json(f"{webui_base}/api/canvas/events?format=json&since=0")
|
||||
events = payload.get("events") or []
|
||||
completed = [event for event in events if event.get("type") == "canvas.command.completed"]
|
||||
persisted = [event for event in events if event.get("type") == "canvas.screen.persisted"]
|
||||
failed = [event for event in events if event.get("type") == "canvas.command.failed"]
|
||||
command_ids = sorted({event.get("command_id") for event in completed if event.get("command_id")})
|
||||
return {
|
||||
"cursor": payload.get("cursor"),
|
||||
"event_count": len(events),
|
||||
"completed_count": len(completed),
|
||||
"persisted_count": len(persisted),
|
||||
"failed_count": len(failed),
|
||||
"command_ids": command_ids,
|
||||
"last_completed": completed[-1] if completed else None,
|
||||
"last_persisted": persisted[-1] if persisted else None,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
|
||||
parser.add_argument("--timeout", type=int, default=180)
|
||||
args = parser.parse_args()
|
||||
|
||||
out_json = Path(args.output_json)
|
||||
canva_port = _free_port()
|
||||
webui_port = _free_port()
|
||||
canva_base = f"http://127.0.0.1:{canva_port}"
|
||||
webui_base = f"http://127.0.0.1:{webui_port}"
|
||||
canva_proc: subprocess.Popen | None = None
|
||||
webui_proc: subprocess.Popen | None = None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-cmo-canvas-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
proof = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"webui_base": webui_base,
|
||||
"canva_base": canva_base,
|
||||
"profile": "cmo-planb",
|
||||
"provider": "vllm",
|
||||
"model": "qwen3.6-35b-a3b",
|
||||
}
|
||||
try:
|
||||
canva_proc = _start_canva(canva_port, tmp_path / "canva-editor.db")
|
||||
if not _wait_for(f"{canva_base}/health"):
|
||||
raise RuntimeError(f"canva-editor did not become healthy at {canva_base}/health")
|
||||
|
||||
webui_proc = _start_webui(webui_port, tmp_path / "webui-state", canva_base)
|
||||
if not _wait_for(f"{webui_base}/health"):
|
||||
raise RuntimeError(f"webui did not become healthy at {webui_base}/health")
|
||||
|
||||
result = _run_cmo_turn(webui_base, args.timeout)
|
||||
events = _canvas_event_summary(webui_base)
|
||||
proof["cmo_turn"] = {
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout[-4000:],
|
||||
"stderr": result.stderr[-2000:],
|
||||
}
|
||||
proof["events"] = events
|
||||
proof["pass"] = bool(
|
||||
result.returncode == 0
|
||||
and events["completed_count"] >= 1
|
||||
and events["persisted_count"] >= 1
|
||||
and events["failed_count"] == 0
|
||||
and events["command_ids"]
|
||||
)
|
||||
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_json.write_text(json.dumps(proof, indent=2), encoding="utf-8")
|
||||
print(json.dumps(proof, indent=2))
|
||||
return 0 if proof["pass"] else 1
|
||||
finally:
|
||||
webui_log = _stop(webui_proc)
|
||||
canva_log = _stop(canva_proc)
|
||||
if webui_log:
|
||||
print(webui_log, file=sys.stderr)
|
||||
if canva_log:
|
||||
print(canva_log, file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user