248 lines
8.4 KiB
Python
Executable File
248 lines
8.4 KiB
Python
Executable File
#!/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())
|