"""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"")
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"
")
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"
Continue: {e(row['key'])} (its run is open)
"
f"
Manual mode.You do the member run; when it is closed the next member becomes ready."
f"
")
if v["ready"]:
return (f"
Next: {e(v['ready'][0])} (ready)
"
f"
Manual mode.Start the next member below."
f"
")
if v["done"] == v["total"]:
return "
Every member is done. Close the project run below.
"
return "
Nothing can start. A member it depends on did not succeed. Close the project run, or start a member with an override.
"
last = d.get("last")
status = f"
Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}
")
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""
elif r["status"] == "waiting":
action = (f"Start anyway")
rows += (f"
{n}
{link} {e(r['key'])}{extra}
{pill(r['status'])}
"
f"
{prog}{run}
{action}
")
return ("
Members
#
Runbook
Status
Progress
" + rows + "
")
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"
")
# ---- 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")