Add controller with web UI and retire CLI

- Add ctrl/ package: Flask app (waitress), SQLite job store, background
  scheduler that probes configured workers, dispatches via /upload and
  /checkout_git, fetches artifacts, and serves a single-page UI
- Supporting API: /api/jobs (create/list/detail/cancel), /api/workers,
  /api/capabilities, artifact download, and SSE log stream
- Workers keep the existing HTTP API; add /progress/<job_id> endpoint and
  per-step build tracking for live status
- Retire agent_manager.py and the cross-py-builder CLI; controller UI is
  the primary frontend
- Record design in DESIGN.md; add waitress dependency

Note: /progress is exposed on the worker but the synchronous /upload and
/checkout endpoints block until a build completes, so per-step worker
progress is not yet relayed live in the UI. Live streaming needs an
async-start worker endpoint as a follow-up.
This commit is contained in:
Brett Williams
2026-08-30 21:55:46 -05:00
parent 5f0d2db1a9
commit 0e89919342
14 changed files with 1237 additions and 342 deletions
+56
View File
@@ -0,0 +1,56 @@
import json
import os
def _get_bool(name, default=False):
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")
def _get_json_list(name, default=None):
raw = os.environ.get(name)
if not raw:
return list(default or [])
try:
value = json.loads(raw)
except json.JSONDecodeError:
raise ValueError(f"{name} must be a JSON array string")
if not isinstance(value, list):
raise ValueError(f"{name} must be a JSON array")
return value
def _get_int(name, default):
raw = os.environ.get(name)
if raw is None:
return default
return int(raw)
DATA_DIR = os.environ.get("CROSS_PY_DATA", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data"))
DB_PATH = os.environ.get("CROSS_PY_DB", os.path.join(DATA_DIR, "jobs.db"))
BUILDS_DIR = os.environ.get("CROSS_PY_BUILDS", os.path.join(DATA_DIR, "builds"))
PORT = _get_int("CROSS_PY_PORT", 8080)
# Static worker list: JSON array of "host:port" strings.
WORKERS = _get_json_list("CROSS_PY_WORKERS")
# One job at a time by default; raise to allow parallel dispatches to distinct workers.
MAX_CONCURRENT = _get_int("CROSS_PY_MAX_CONCURRENT", 1)
# Per-request timeouts (seconds) for talking to workers.
WORKER_STATUS_TIMEOUT = _get_int("CROSS_PY_WORKER_STATUS_TIMEOUT", 5)
WORKER_BUILD_TIMEOUT = _get_int("CROSS_PY_WORKER_BUILD_TIMEOUT", 3600)
# Interval (seconds) for the scheduler loop.
SCHEDULER_INTERVAL = 1.0
# Debug mode uses Flask's dev server; otherwise serve through waitress.
DEBUG = _get_bool("CROSS_PY_DEBUG", False)
def ensure_dirs():
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(BUILDS_DIR, exist_ok=True)