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
+112
View File
@@ -0,0 +1,112 @@
import json
import sqlite3
import threading
import ctrl.settings as settings
_local = threading.local()
def _conn():
conn = getattr(_local, "conn", None)
if conn is None:
conn = sqlite3.connect(settings.DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
_local.conn = conn
return conn
def init_db():
settings.ensure_dirs()
conn = _conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
source_type TEXT NOT NULL,
source TEXT,
os_req TEXT,
cpu_req TEXT,
worker_host TEXT,
worker_job_id TEXT,
worker_url TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
log TEXT DEFAULT '',
error TEXT,
artifacts TEXT DEFAULT '[]'
)
"""
)
conn.commit()
def create_job(source_type, source, os_req, cpu_req):
import uuid
import datetime
job_id = "JOB-" + uuid.uuid4().hex[:8]
conn = _conn()
conn.execute(
"""
INSERT INTO jobs (id, status, source_type, source, os_req, cpu_req, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(job_id, "queued", source_type, source, os_req, cpu_req,
datetime.datetime.now().isoformat()),
)
conn.commit()
return job_id
def _row_to_dict(row):
d = dict(row)
try:
d["artifacts"] = json.loads(d.get("artifacts") or "[]")
except (TypeError, ValueError):
d["artifacts"] = []
return d
def get_job(job_id):
row = _conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return _row_to_dict(row) if row else None
def list_jobs(limit=100):
rows = _conn().execute(
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [_row_to_dict(r) for r in rows]
def update_job(job_id, **fields):
if not fields:
return
cols = ", ".join(f"{k} = ?" for k in fields)
values = []
for v in fields.values():
if isinstance(v, (list, dict)):
v = json.dumps(v)
values.append(v)
conn = _conn()
conn.execute(f"UPDATE jobs SET {cols} WHERE id = ?", (*values, job_id))
conn.commit()
def append_log(job_id, text):
conn = _conn()
conn.execute("UPDATE jobs SET log = log || ? WHERE id = ?", (text, job_id))
conn.commit()
def set_queued_jobs_for_worker(worker_url):
"""Reschedule any jobs currently stuck on a worker back to queued."""
conn = _conn()
conn.execute(
"UPDATE jobs SET status = 'queued', worker_url = NULL WHERE worker_url = ? AND status IN ('dispatching', 'building')",
(worker_url,),
)
conn.commit()