218 lines
6.8 KiB
Python
218 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes Web Builder API — agy builds voor Cockpit Web Builder agent."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
app = FastAPI(title="Hermes Web Builder API", version="1.0.0")
|
|
|
|
PROJECTS_BASE = Path(os.getenv("WEBBUILDER_PROJECTS", "/home/hermes/projects"))
|
|
PREVIEW_PORT = int(os.getenv("WEBBUILDER_PREVIEW_PORT", "8080"))
|
|
PREVIEW_HOST = os.getenv("WEBBUILDER_PREVIEW_HOST", "10.4.7.27")
|
|
NAS_PREFIX = os.getenv("WEBBUILDER_NAS_PREFIX", "//10.4.7.11/share/Websites")
|
|
AGY_BIN = os.getenv("AGY_BIN", "/home/hermes/.local/bin/agy")
|
|
BUILD_TIMEOUT = os.getenv("WEBBUILDER_BUILD_TIMEOUT", "30m")
|
|
|
|
JOBS: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def slugify(name: str) -> str:
|
|
s = (name or "").strip().lower()
|
|
s = re.sub(r"[^a-z0-9_-]+", "-", s)
|
|
s = re.sub(r"-+", "-", s).strip("-")
|
|
return s[:64] or "website"
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _list_files(project_dir: Path) -> list[str]:
|
|
if not project_dir.is_dir():
|
|
return []
|
|
out: list[str] = []
|
|
for p in sorted(project_dir.rglob("*")):
|
|
if p.is_file():
|
|
out.append(str(p.relative_to(project_dir)))
|
|
return out[:50]
|
|
|
|
|
|
def _preview_url() -> str:
|
|
return f"http://{PREVIEW_HOST}:{PREVIEW_PORT}/"
|
|
|
|
|
|
async def _run_shell(cmd: str, *, timeout: float = 120.0) -> tuple[int, str, str]:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
return 124, "", "timeout"
|
|
return proc.returncode or 0, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|
|
|
|
|
|
async def _ensure_project(slug: str) -> Path:
|
|
proj = PROJECTS_BASE / slug
|
|
proj.mkdir(parents=True, exist_ok=True)
|
|
await _run_shell(f"chown hermes:hermes '{proj}'")
|
|
return proj
|
|
|
|
|
|
async def _start_preview(slug: str) -> None:
|
|
cmd = (
|
|
f"pkill -f 'http.server.*{PREVIEW_PORT}' 2>/dev/null || true; "
|
|
f"su - hermes -c 'cd {PROJECTS_BASE}/{slug} && "
|
|
f"nohup python3 -m http.server {PREVIEW_PORT} > /tmp/webbuilder-preview.log 2>&1 &'"
|
|
)
|
|
await _run_shell(cmd, timeout=15.0)
|
|
|
|
|
|
async def _run_build(job_id: str, slug: str, prompt: str) -> None:
|
|
job = JOBS[job_id]
|
|
job["status"] = "running"
|
|
job["message"] = f"agy bouwt {slug}…"
|
|
job["updated_at"] = _now()
|
|
|
|
proj = await _ensure_project(slug)
|
|
safe_prompt = prompt.replace('"', '\\"')[:8000]
|
|
agy_cmd = (
|
|
f'su - hermes -c \'export PATH="$HOME/.local/bin:$PATH"; '
|
|
f"cd '{proj}' && "
|
|
f"{AGY_BIN} --print --dangerously-skip-permissions --print-timeout {BUILD_TIMEOUT} "
|
|
f'"{safe_prompt}"\''
|
|
)
|
|
|
|
job["message"] = "Antigravity CLI gestart…"
|
|
rc, out, err = await _run_shell(agy_cmd, timeout=3600.0)
|
|
log_tail = (out + "\n" + err).strip()[-4000:]
|
|
job["log_tail"] = log_tail
|
|
job["updated_at"] = _now()
|
|
|
|
files = _list_files(proj)
|
|
job["files"] = files
|
|
|
|
if rc == 0 and files:
|
|
await _start_preview(slug)
|
|
job["status"] = "completed"
|
|
job["message"] = f"Website klaar — {len(files)} bestand(en)"
|
|
job["preview_url"] = _preview_url()
|
|
job["completed_at"] = _now()
|
|
elif rc == 0:
|
|
job["status"] = "completed"
|
|
job["message"] = "agy klaar (geen bestanden gevonden)"
|
|
job["preview_url"] = _preview_url() if files else None
|
|
job["completed_at"] = _now()
|
|
else:
|
|
job["status"] = "failed"
|
|
job["message"] = f"agy exit code {rc}"
|
|
job["error"] = log_tail[-800:] or f"exit {rc}"
|
|
job["completed_at"] = _now()
|
|
|
|
|
|
class BuildRequest(BaseModel):
|
|
project: str = Field(..., min_length=1, max_length=64)
|
|
prompt: str = Field(..., min_length=1, max_length=8000)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, Any]:
|
|
return {"ok": True, "service": "hermes-webbuilder", "projects_base": str(PROJECTS_BASE)}
|
|
|
|
|
|
@app.get("/api/projects")
|
|
async def list_projects() -> dict[str, Any]:
|
|
items: list[dict[str, Any]] = []
|
|
if PROJECTS_BASE.is_dir():
|
|
for p in sorted(PROJECTS_BASE.iterdir()):
|
|
if p.is_dir() and not p.name.startswith("."):
|
|
files = _list_files(p)
|
|
items.append({"slug": p.name, "files": len(files), "has_index": (p / "index.html").is_file()})
|
|
return {"ok": True, "items": items, "count": len(items)}
|
|
|
|
|
|
@app.post("/api/build")
|
|
async def start_build(body: BuildRequest) -> dict[str, Any]:
|
|
slug = slugify(body.project)
|
|
if not slug:
|
|
raise HTTPException(400, "Ongeldige projectnaam")
|
|
|
|
await _ensure_project(slug)
|
|
job_id = str(uuid.uuid4())
|
|
JOBS[job_id] = {
|
|
"job_id": job_id,
|
|
"project": slug,
|
|
"status": "queued",
|
|
"message": "Build in wachtrij",
|
|
"prompt": body.prompt[:500],
|
|
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
|
"preview_url": _preview_url(),
|
|
"created_at": _now(),
|
|
"updated_at": _now(),
|
|
}
|
|
JOBS[slug] = JOBS[job_id]
|
|
|
|
asyncio.create_task(_run_build(job_id, slug, body.prompt))
|
|
return {
|
|
"ok": True,
|
|
"job_id": job_id,
|
|
"project": slug,
|
|
"status": "queued",
|
|
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
|
"preview_url": _preview_url(),
|
|
}
|
|
|
|
|
|
@app.get("/api/build/{project}/status")
|
|
async def build_status(project: str) -> dict[str, Any]:
|
|
slug = slugify(project)
|
|
job = JOBS.get(slug) or JOBS.get(project)
|
|
if not job:
|
|
proj = PROJECTS_BASE / slug
|
|
files = _list_files(proj)
|
|
if files:
|
|
return {
|
|
"ok": True,
|
|
"project": slug,
|
|
"status": "completed",
|
|
"message": "Project bestaat (geen actieve job)",
|
|
"files": files,
|
|
"preview_url": _preview_url(),
|
|
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
|
}
|
|
return {"ok": True, "project": slug, "status": "idle", "message": "Geen build actief", "files": []}
|
|
|
|
return {
|
|
"ok": True,
|
|
"project": slug,
|
|
"job_id": job.get("job_id"),
|
|
"status": job.get("status"),
|
|
"message": job.get("message"),
|
|
"files": job.get("files") or [],
|
|
"preview_url": job.get("preview_url"),
|
|
"nas_path": f"{NAS_PREFIX}/{slug}/",
|
|
"log_tail": job.get("log_tail", ""),
|
|
"error": job.get("error"),
|
|
"updated_at": job.get("updated_at"),
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8798")))
|