"""Local web UI: pick a pack, fill parameters, walk the steps, record evidence. Localhost only. Server-rendered forms; the only script is small: copy button, fail-comment warning, scroll to current step. """ from __future__ import annotations import html import secrets from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse from . import pack as P, run as R CSS = """body{font:15px/1.5 system-ui,sans-serif;max-width:52rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fff} @media(prefers-color-scheme:dark){body{color:#eee;background:#111}pre,input,select{background:#1b1b1b;color:#eee}} h1,h2{font-weight:600}a{color:inherit}hr{border:0;border-top:1px solid #8886} .eyebrow{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em} .step{border:1px solid #8886;padding:.8rem 1rem;margin:.8rem 0}.step.running{border-color:currentColor} .pill{font:12px ui-monospace,monospace;border:1px solid #8886;padding:0 .4rem} pre{border:1px solid #8886;padding:.5rem;overflow:auto;margin:.4rem 0} button,input,select{font:inherit;padding:.25rem .5rem}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}summary{cursor:pointer}.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}#current{scroll-margin-top:1rem}.note{opacity:.75}.bad{font-weight:600}""" def page(title: str, body: str) -> bytes: return (f"" f"
{e(x.get('help', ''))} Never enter secrets.
{e(d['exercise_status'])}
" f"{e(d.get('outcome', ''))}
{e(score_line(R.scorecard(p)))}
" f"{self.runs_list(i, p)}{e(head + c['output'][:4000])}captured {e(c['at'][:19])} ({e(c['source'])})
" if finished or state == "todo": return f"Run finished: {e(outcome)}{note}
" if outcome == "succeeded": if r.data.get("exercise_recorded"): box += f"Pack recorded as {e(r.data['exercise_recorded'])}. Commit the change in the owning repository.
" else: old, new = R.exercise_update(r) line = new.split("exercise_status:", 1)[1].split("\n", 1)[0].strip() box += (f"Current pack status: {e(p.data['exercise_status'])}. "
f"Recording this run would set it to {e(line)} in {e(p.path)} "
"(a reviewable edit; commit it in the owning repository).
Finishing records the outcome in the pack's score. A finished run is read-only. " "Runs left open for a week count as abandoned.
" f"Thank you. Recorded: " f"{face} {e(fb['comment'] or '(no comment)')}
" "" f"{e(d['exercise_status'])}
" f"run {e(rid)} · operator {e(r.data['operator'])}
"] if msg: out.append(f"{e(msg)}
") blocked = False waiting_for = "" finished = bool(r.data.get("outcome")) total = len(d["steps"]) done_n = sum(1 for x in d["steps"] if r.state(x["id"])["state"] in ("done", "skipped")) out.append(f"{done_n} of {total} steps done
" f"") current = next((x["id"] for x in d["steps"] if r.state(x["id"])["state"] == "running"), None) if current is None: current = next((x["id"] for x in d["steps"] if r.state(x["id"])["state"] in ("todo", "failed")), None) for n, s in enumerate(d["steps"], 1): st = r.state(s["id"]); state = st["state"] act = "" cmd = R.command_for(r, s["id"]) block = "" if cmd and state == "running": # the command and Copy appear only once the step is started block = f"{e(cmd)}"
elif cmd and state == "done":
block = f"{e(cmd)}" # kept as a record; no Copy once done
url = f"/run/{i}/{e(rid)}/{e(s['id'])}"
v = st.get("verification")
if state == "todo" and not blocked:
act = f""
elif state == "todo":
act = f"Waiting for: {e(waiting_for)}
" elif state == "running": ver = s["verify"] if ver.get("expect", "manual") == "manual": act = (f"") else: act = (f"") if not v: act = "1. Run the command, then check → 2. Mark done to lock it in
" + act elif v["passed"]: act += "✔ Check passed. Press Mark done to lock it in; the next step opens after that.
" else: act += "✘ Check not passed. Fix the cause, then check again, or mark the step failed.
" ev = "" if v and v["passed"]: ev = "".join(f" " for x in s.get("evidence", [])) ev = f"{ev}
" if ev else "" done_btn = f" " if v and v["passed"] else "" act += (f"") elif state == "failed": act = f"Failed: {e(st.get('note') or '(no comment)')}
" elif state == "done" and st.get("note"): act = f"Comment: {e(st['note'])}
" if finished and state != "done": act = "" # a finished run is read-only if state not in ("done", "skipped") and not blocked: blocked = True waiting_for = f"step {n}, {s['title']}" rb = f"Rollback: {e(s['rollback'])}
" if s.get("rollback") and state in ("running", "failed") else "" anchor = " id=current" if s["id"] == current else "" fold = "" if state in ("done", "skipped") else " open" # finished steps start folded; the triangle toggles out.append(f"Done when: {e(s['verify']['done_when'])}
{rb}{act}All steps done. To record this run as exercising the pack, review and apply: "
f"python -m rtut run finish {e(p.path)} {e(rid)} --apply
Not found.
"), 404) def do_POST(self): if not self._host_ok(): return self._send(b"forbidden", 403) n = int(self.headers.get("Content-Length", 0)) form = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode()).items()} if form.get("_t") != app.token: return self._send(b"bad token", 403) parts = [x for x in urlparse(self.path).path.split("/") if x] msg = "" try: if parts[:2] == ["run", "new"]: i = int(parts[2]); p = app.packs[i] params = {k[2:]: v for k, v in form.items() if k.startswith("p_")} r = R.create(p, form.get("operator", ""), params) return self._redirect(f"/run/{i}/{r.data['run_id']}#current") if len(parts) == 4 and parts[0] == "run" and parts[3] == "feedback": i, rid = int(parts[1]), parts[2] R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", "")) return self._redirect(f"/run/{i}/{rid}#feedback") if len(parts) == 4 and parts[0] == "run" and parts[3] in ("finalize", "exercise"): i, rid = int(parts[1]), parts[2] r = R.load(app.packs[i], rid) if parts[3] == "finalize": R.finalize(r, form.get("outcome", ""), form.get("note", "")) else: R.apply_exercise(r) app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status return self._redirect(f"/run/{i}/{rid}#close") i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4] p = app.packs[i]; r = R.load(p, rid) if action == "start": R.start(r, sid) elif action == "verify": R.verify(r, sid, confirm=bool(form.get("yes")), manual_ok=(form.get("ok") == "1") if not form.get("yes") else None) elif action == "done": R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")}, form.get("note", "")) elif action == "fail": R.fail(r, sid, form.get("note", "")) elif action == "capture": R.add_capture(r, sid, form.get("output", ""), form.get("command", "")) elif action == "verify-pasted": R.verify_pasted(r, sid, form.get("output", ""), form.get("command", "")) elif action == "retry": R.retry(r, sid) return self._redirect(f"/run/{i}/{rid}#current") except (R.RunError, KeyError, ValueError, IndexError) as ex: msg = str(ex) try: if parts[:2] == ["run", "new"]: return self._send(app.pack_page(int(parts[2])).replace(b"{e(msg)}
{e(msg)}
"), 400) def _redirect(self, to: str): self.send_response(303); self.send_header("Location", to); self.end_headers() return H def serve(roots: list[Path], port: int = 8877) -> ThreadingHTTPServer: app = App(roots) srv = ThreadingHTTPServer(("127.0.0.1", port), make_handler(app, [])) srv.app = app # type: ignore[attr-defined] return srv