"""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"{e(text)}" 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 = ["

Run the whole project

"] if active: out.append(f"

A project run is in progress ({pill(active.data['mode'])}): " f"continue it. Only one runs at a time.

") elif needs: out.append(f"

Give the project the attributes it still needs first: {e(', '.join(sorted({n['attribute'] for n in needs})))}.

") else: opts = "".join(f"
" for m, t in MODE_HELP.items()) out.append(f"
{app.hidden()}
Mode{opts}
" f"

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.

{app.who()}
") 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"{e(p.id)}{pill(p.data['mode'])}" f"{pill(p.data['outcome'] or 'in progress')}{v['done']} of {v['total']} members" f"{e(stamp(t['started']))} · {e(took)} · {e(p.data['operator'])}") out.append("

Project runs

" + rows + "
RunModeOutcomeProgressWhen
") 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 {e(fmt_duration(t['elapsed']))}" if t["finished"] else f"running for {e(fmt_duration(t['elapsed']))}") out = [f"

Project run: {e(d['title'])}

", f"

{pill(d['mode'])} {pill(d['outcome']) if d['outcome'] else pill('in progress')} {pill(d['environment'])} " f"{e(d['id'])} · operator {e(d['operator'])} · project {e(d['project'])}

", f"

Started {e(stamp(t['started']))} · {took}

", f"

{v['done']} of {v['total']} members done

"] if msg: out.append(f"

{e(msg)}

") if doc is not None and not closed: changed = PR.attributes_changed(reg, doc, pr) if changed: out.append("

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.

") if d["deviations"]: out.append("

⚑ Deviations: " + "; ".join(f"{e(x['kind'])} {e(x['item'])} ({e(x['reason'])})" for x in d["deviations"]) + "

") if not closed: out.append(_controls(app, pr, v)) out.append(_members(app, reg, pr, v, manual and not closed)) out.append("
Log
") 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"" f"
Manual mode. You do the member run; when it is closed the next member becomes ready." f"
{app.hidden()}" "
") if v["ready"]: return (f"" f"
Manual mode. Start the next member below." f"
{app.hidden()}" "
") if v["done"] == v["total"]: return "" return "" last = d.get("last") status = f"

Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}

" if last else "" return (f"
Auto-confirm mode. {e(MODE_HELP['auto-confirm'])}{status}" f"
{app.hidden()}" "
" f"
{app.hidden()}
") 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"{title}" if i is not None else title prog = f"{r['progress']['done']}/{r['progress']['total']}" if r.get("progress") else "-" run = f" · open run" if r.get("run_id") and i is not None else "" extra = "" if r["status"] == "waiting": extra = f"
waiting for {e(', '.join(r['blocked_by']))}" if r.get("detail"): extra += f"
{e(r['detail'])}" if r["deviations"]: extra += f"
⚑ {r['deviations']} deviation(s)" action = "" if manual and not r.get("run_id") and not v["open"]: key = e(r["key"]) if r["status"] == "ready": action = f"
{app.hidden()}
" elif r["status"] == "waiting": action = (f"
Start anyway
{app.hidden()}" f"

It waits for {e(', '.join(r['blocked_by']))}; starting it now is recorded as a deviation.

" "

" "

" "
") rows += (f"{n}{link}
{e(r['key'])}{extra}{pill(r['status'])}" f"{prog}{run}{action}") return ("

Members

" + rows + "
#RunbookStatusProgress
") def _close(app, reg, pr, v: dict) -> str: d = pr.data if pr.closed: state = PR.seal_state(pr) note = f"

Comment: {e(d['outcome_note'])}

" if d.get("outcome_note") else "" broken = ("

⚠ This project run's record was changed outside the app after it was sealed.

" if state == "broken" else "

This project run is closed and locked: its record is sealed and cannot be changed. Start a new project run to try again.

") return f"

Project run finished: {e(d['outcome'])}

{note}{broken}
" btns = [] if all(r["status"] == "succeeded" for r in v["members"]): btns.append("") if v["problems"] or v["open"]: btns.append("") btns.append("") return (f"

Close this project run

Closing records the outcome, abandons an open member run, and locks the record.

" f"
{app.hidden()}" f"

{' '.join(btns)}

") # ---- 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")