Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
210 lines
13 KiB
Python
210 lines
13 KiB
Python
"""UI for project runs: start one from a project, follow it member by member, in manual or auto-confirm mode."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
|
|
from . import projectrun as PR, run as R
|
|
from .run import fmt_duration
|
|
|
|
MODE_HELP = {
|
|
"manual": "You start each member yourself, one at a time, in the project's order. Every run inside it is manual.",
|
|
"auto-confirm": ("The engine starts the next ready member, confirms what it can, closes each member run when it finishes cleanly, and moves on. "
|
|
"It stops wherever a person is needed. It never runs commands, and v0.1 members are always run by you."),
|
|
}
|
|
|
|
|
|
def e(s) -> str:
|
|
return html.escape(str(s))
|
|
|
|
|
|
def pill(text: str) -> str:
|
|
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
|
|
|
|
|
|
def stamp(iso: str | None) -> str:
|
|
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
|
|
|
|
|
|
# ---- on the project page ---------------------------------------------------------------------------------------------------
|
|
|
|
def project_section(app, reg, doc, needs: list[dict]) -> str:
|
|
pid = doc.id
|
|
active = PR.active_run(reg, pid)
|
|
out = ["<h2>Run the whole project</h2>"]
|
|
if active:
|
|
out.append(f"<p>A project run is in progress ({pill(active.data['mode'])}): "
|
|
f"<a href='/project-run/{e(active.id)}'>continue it</a>. Only one runs at a time.</p>")
|
|
elif needs:
|
|
out.append(f"<p class=note>Give the project the attributes it still needs first: {e(', '.join(sorted({n['attribute'] for n in needs})))}.</p>")
|
|
else:
|
|
opts = "".join(f"<label><input type=radio name=mode value='{m}'{' checked' if m == 'manual' else ''}> <b>{m}</b>: {e(t)}</label><br>"
|
|
for m, t in MODE_HELP.items())
|
|
out.append(f"<form method=post action='/project/{e(pid)}/runall'>{app.hidden()}<fieldset><legend>Mode</legend>{opts}</fieldset>"
|
|
f"<p class=note>The project's attributes are snapshotted when it starts, so every member run uses the same values even if "
|
|
f"you edit the project meanwhile.</p>{app.who()}<button>Start project run</button></form>")
|
|
past = PR.list_runs(reg, pid)
|
|
if past:
|
|
rows = ""
|
|
for p in past:
|
|
v = PR.view(reg, p)
|
|
t = PR.times(p)
|
|
took = f"took {fmt_duration(t['elapsed'])}" if t["finished"] else f"running {fmt_duration(t['elapsed'])}"
|
|
rows += (f"<tr><td><a href='/project-run/{e(p.id)}'>{e(p.id)}</a></td><td>{pill(p.data['mode'])}</td>"
|
|
f"<td>{pill(p.data['outcome'] or 'in progress')}</td><td>{v['done']} of {v['total']} members</td>"
|
|
f"<td class=note>{e(stamp(t['started']))} · {e(took)} · {e(p.data['operator'])}</td></tr>")
|
|
out.append("<h3>Project runs</h3><table><tr><th>Run</th><th>Mode</th><th>Outcome</th><th>Progress</th><th>When</th></tr>" + rows + "</table>")
|
|
return "".join(out)
|
|
|
|
|
|
# ---- the project run page ------------------------------------------------------------------------------------------------------
|
|
|
|
def page_for(app, prid: str, msg: str = "") -> bytes | None:
|
|
from .ui import page
|
|
reg = app.registry()
|
|
try:
|
|
pr = PR.load(reg, prid)
|
|
except R.RunError:
|
|
return None
|
|
doc = reg.projects.get(pr.data["project"])
|
|
d = pr.data
|
|
closed = pr.closed
|
|
v = PR.view(reg, pr)
|
|
t = PR.times(pr)
|
|
manual = d["mode"] == "manual"
|
|
took = (f"finished {e(stamp(t['finished']))} · took <b>{e(fmt_duration(t['elapsed']))}</b>" if t["finished"]
|
|
else f"running for <b>{e(fmt_duration(t['elapsed']))}</b>")
|
|
out = [f"<h1>Project run: {e(d['title'])}</h1>",
|
|
f"<p>{pill(d['mode'])} {pill(d['outcome']) if d['outcome'] else pill('in progress')} {pill(d['environment'])} "
|
|
f"<span class=note>{e(d['id'])} · operator {e(d['operator'])} · <a href='/project/{e(d['project'])}'>project {e(d['project'])}</a></span></p>",
|
|
f"<p class=note>Started {e(stamp(t['started']))} · {took}</p>",
|
|
f"<p class=eyebrow>{v['done']} of {v['total']} members done</p><progress max={v['total']} value={v['done']} style='width:100%'></progress>"]
|
|
if msg:
|
|
out.append(f"<p class=bad>{e(msg)}</p>")
|
|
if doc is not None and not closed:
|
|
changed = PR.attributes_changed(reg, doc, pr)
|
|
if changed:
|
|
out.append("<p class=note>The project's attributes have changed since this project run started. It keeps the values it started with "
|
|
f"({e(', '.join(changed))}); a new project run picks up the new ones.</p>")
|
|
if d["deviations"]:
|
|
out.append("<p class=note>⚑ Deviations: " + "; ".join(f"{e(x['kind'])} {e(x['item'])} ({e(x['reason'])})" for x in d["deviations"]) + "</p>")
|
|
if not closed:
|
|
out.append(_controls(app, pr, v))
|
|
out.append(_members(app, reg, pr, v, manual and not closed))
|
|
out.append("<details><summary><b>Log</b></summary><ul class=feed>" + "".join(
|
|
f"<li><span class=note>{e(x['at'][:19].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])}</li>" for x in reversed(d["events"])) + "</ul></details>")
|
|
out.append(_close(app, reg, pr, v))
|
|
return page(f"Project run {d['id']}", "".join(out))
|
|
|
|
|
|
def _controls(app, pr, v: dict) -> str:
|
|
d = pr.data
|
|
base = f"/project-run/{e(pr.id)}"
|
|
if d["mode"] == "manual":
|
|
if v["open"]:
|
|
row = next(r for r in v["members"] if r["key"] == v["open"])
|
|
return (f"<div class=banner><b>Continue:</b> {e(row['key'])} <span class=note>(its run is open)</span></div>"
|
|
f"<div class=panel><b>Manual mode.</b> <span class=note>You do the member run; when it is closed the next member becomes ready.</span>"
|
|
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
|
|
"<button>Hand over to auto-confirm</button></form></div>")
|
|
if v["ready"]:
|
|
return (f"<div class=banner><b>Next:</b> {e(v['ready'][0])} <span class=note>(ready)</span></div>"
|
|
f"<div class=panel><b>Manual mode.</b> <span class=note>Start the next member below.</span>"
|
|
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
|
|
"<button>Hand over to auto-confirm</button></form></div>")
|
|
if v["done"] == v["total"]:
|
|
return "<div class=banner><b>Every member is done.</b> Close the project run below.</div>"
|
|
return "<div class=banner><b>Nothing can start.</b> A member it depends on did not succeed. Close the project run, or start a member with an override.</div>"
|
|
last = d.get("last")
|
|
status = f"<p class=note>Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}</p>" if last else ""
|
|
return (f"<div class=panel data-s='auto'><b>Auto-confirm mode.</b> <span class=note>{e(MODE_HELP['auto-confirm'])}</span>{status}"
|
|
f"<form method=post action='{base}/tick' class=row>{app.hidden()}"
|
|
"<button name=n value=1>Check once</button> <button name=n value=300>Run until it waits</button></form>"
|
|
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=manual><button>Take over (manual)</button></form></div>")
|
|
|
|
|
|
def _members(app, reg, pr, v: dict, manual: bool) -> str:
|
|
base = f"/project-run/{e(pr.id)}"
|
|
rows = ""
|
|
for n, r in enumerate(v["members"], 1):
|
|
pack = reg.packs.get(r["pack"])
|
|
i = app.pack_index(r["pack"])
|
|
title = e(pack.data["title"]) if pack else e(r["pack"])
|
|
link = f"<a href='/pack/{i}'>{title}</a>" if i is not None else title
|
|
prog = f"{r['progress']['done']}/{r['progress']['total']}" if r.get("progress") else "-"
|
|
run = f" · <a href='/run/{i}/{e(r['run_id'])}'>open run</a>" if r.get("run_id") and i is not None else ""
|
|
extra = ""
|
|
if r["status"] == "waiting":
|
|
extra = f"<br><span class=note>waiting for {e(', '.join(r['blocked_by']))}</span>"
|
|
if r.get("detail"):
|
|
extra += f"<br><span class=note>{e(r['detail'])}</span>"
|
|
if r["deviations"]:
|
|
extra += f"<br><span class=note>⚑ {r['deviations']} deviation(s)</span>"
|
|
action = ""
|
|
if manual and not r.get("run_id") and not v["open"]:
|
|
key = e(r["key"])
|
|
if r["status"] == "ready":
|
|
action = f"<form method=post action='{base}/start'>{app.hidden()}<input type=hidden name=key value='{key}'><button>Start</button></form>"
|
|
elif r["status"] == "waiting":
|
|
action = (f"<details class=override><summary>Start anyway</summary><form method=post action='{base}/start'>{app.hidden()}"
|
|
f"<input type=hidden name=key value='{key}'><p class=bad>It waits for {e(', '.join(r['blocked_by']))}; starting it now is recorded as a deviation.</p>"
|
|
"<p><input name=override_reason placeholder='why (required)' required></p>"
|
|
"<p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk.</label></p>"
|
|
"<button>Override and start</button></form></details>")
|
|
rows += (f"<tr><td>{n}</td><td>{link}<br><span class=note>{e(r['key'])}</span>{extra}</td><td>{pill(r['status'])}</td>"
|
|
f"<td>{prog}{run}</td><td>{action}</td></tr>")
|
|
return ("<h2>Members</h2><table><tr><th>#</th><th>Runbook</th><th>Status</th><th>Progress</th><th></th></tr>" + rows + "</table>")
|
|
|
|
|
|
def _close(app, reg, pr, v: dict) -> str:
|
|
d = pr.data
|
|
if pr.closed:
|
|
state = PR.seal_state(pr)
|
|
note = f"<p>Comment: {e(d['outcome_note'])}</p>" if d.get("outcome_note") else ""
|
|
broken = ("<p class=bad>⚠ This project run's record was changed outside the app after it was sealed.</p>" if state == "broken" else
|
|
"<p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p>")
|
|
return (f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}"
|
|
f"<p><a href='/project-run/{e(pr.id)}/record.json'>Download the project run record (JSON)</a> "
|
|
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div>")
|
|
btns = []
|
|
if all(r["status"] == "succeeded" for r in v["members"]):
|
|
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
|
if v["problems"] or v["open"]:
|
|
btns.append("<button name=outcome value=failed onclick=\"return confirm('Finish this project run as failed? An open member run is abandoned.')\">Finish: failed</button>")
|
|
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this project run? An open member run is abandoned.')\">Abandon project run</button>")
|
|
return (f"<div id=close class=step><h2>Close this project run</h2><p class=note>Closing records the outcome, abandons an open member run, and locks the record.</p>"
|
|
f"<form method=post action='/project-run/{e(pr.id)}/finalize'>{app.hidden()}"
|
|
f"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p><p class=row>{' '.join(btns)}</p></form></div>")
|
|
|
|
|
|
# ---- actions ---------------------------------------------------------------------------------------------------------------------------
|
|
|
|
def handle_post(app, parts: list[str], form: dict) -> str:
|
|
"""Returns the URL to go to; raises RunError/ValueError/KeyError with the reason if the action is refused."""
|
|
reg = app.registry()
|
|
if len(parts) == 3 and parts[0] == "project" and parts[2] == "runall":
|
|
doc = reg.projects[parts[1]]
|
|
pr = PR.create(reg, doc, form.get("operator", ""), form.get("mode", "manual"))
|
|
return f"/project-run/{pr.id}"
|
|
if len(parts) == 3 and parts[0] == "project-run":
|
|
pr = PR.load(reg, parts[1])
|
|
doc = reg.projects[pr.data["project"]]
|
|
action = parts[2]
|
|
if action == "start":
|
|
run = PR.start_member(reg, doc, pr, form.get("key", ""), form.get("override_reason", ""), bool(form.get("acknowledge_risk")))
|
|
i = app.pack_index(run.pack.id)
|
|
return f"/run/{i}/{run.data['run_id']}#current"
|
|
if action == "tick":
|
|
n = max(1, min(int(form.get("n", "1") or 1), 300))
|
|
if n == 1:
|
|
pr.data["last"] = PR.tick(reg, doc, pr)
|
|
pr.save()
|
|
else:
|
|
PR.run_until_waits(reg, doc, pr, n)
|
|
return f"/project-run/{pr.id}"
|
|
if action == "mode":
|
|
PR.switch_mode(reg, pr, form.get("to", ""))
|
|
return f"/project-run/{pr.id}"
|
|
if action == "finalize":
|
|
PR.finalize(reg, pr, form.get("outcome", ""), form.get("note", ""))
|
|
return f"/project-run/{pr.id}#close"
|
|
raise ValueError("unknown action")
|