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
706 lines
39 KiB
Python
706 lines
39 KiB
Python
"""UI for runbook-pack/v0.2 runs.
|
|
|
|
Three views of one run:
|
|
flow the guided questionnaire: stages, the recommended next action, steps, gates, questions (main)
|
|
data every datapoint, grouped or filtered by dimension, editable independently of the flow
|
|
overview progress, gate states, datapoint states, deviations, mode history
|
|
Server-rendered forms; the only script is the small one in ui.page().
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from urllib.parse import quote
|
|
|
|
from . import artefacts as A, run as R1, run2 as R
|
|
from .flow import Flow
|
|
|
|
DONE = ("done", "skipped", "pushed")
|
|
|
|
|
|
def e(s) -> str:
|
|
return html.escape(str(s))
|
|
|
|
|
|
def sp(state: str) -> str:
|
|
return f"<span class=pill data-s='{e(state)}'>{e(state)}</span>"
|
|
|
|
|
|
# ---- starting a run -------------------------------------------------------------------------------------------
|
|
|
|
def start_form(app, i: int, pack) -> str:
|
|
fl = Flow(pack.data)
|
|
fields = ""
|
|
for d in fl.datapoints.values():
|
|
if d.get("produced_by"):
|
|
continue
|
|
name, default = f"p_{d['id']}", d.get("default", "")
|
|
if d.get("values"):
|
|
ctl = f"<select name='{name}'>" + "".join(f"<option{' selected' if o == default else ''}>{e(o)}</option>" for o in d["values"]) + "</select>"
|
|
elif d["type"] == "bool":
|
|
ctl = f"<select name='{name}'><option value=''>(not set)</option><option>true</option><option>false</option></select>"
|
|
else:
|
|
ctl = f"<input name='{name}' value='{e(default)}'>"
|
|
dims = " ".join(f"<span class=chip>{e(k)}: {e(v)}</span>" for k, v in (d.get("dimensions") or {}).items())
|
|
fields += f"<p><label>{e(d['label'])}<br>{ctl}</label> {dims}<br><span class=note>{e(d.get('help', ''))} Never enter secrets.</span></p>"
|
|
irrev = [s for s in fl.steps.values() if not s.get("reversible", True)]
|
|
pre = ""
|
|
if irrev:
|
|
pre = ("<fieldset><legend>Pre-authorize irreversible steps (auto-confirm only)</legend>"
|
|
"<p class=note>An auto-confirm run never confirms an irreversible step by itself unless you name it here. Steps marked "
|
|
"manual-only are never automated.</p>"
|
|
+ "".join(f"<label><input type=checkbox name='pre_{e(s['id'])}'> {e(s['title'])}"
|
|
f"{' (manual-only)' if s.get('auto_confirm') == 'manual-only' else ''}</label><br>" for s in irrev) + "</fieldset>")
|
|
return (f"<form method=post action='/run/new/{i}'>{app.hidden()}{fields}"
|
|
"<fieldset><legend>Mode</legend>"
|
|
"<label><input type=radio name=mode value=manual checked> <b>Manual</b>: you confirm every check and gate.</label><br>"
|
|
"<label><input type=radio name=mode value=auto-confirm> <b>Auto-confirm</b>: the engine confirms when preconditions hold. "
|
|
"It does not run commands; you (or another tool) do the work.</label></fieldset>"
|
|
f"{pre}{app.who()}<button>Start run</button></form>")
|
|
|
|
|
|
def create_run(app, i: int, form: dict):
|
|
pack = app.packs[i]
|
|
params = {k[2:]: v for k, v in form.items() if k.startswith("p_") and v != ""}
|
|
pre = [k[4:] for k, v in form.items() if k.startswith("pre_")]
|
|
return R.create(pack, form.get("operator", ""), params, mode=form.get("mode", "manual"), preauthorize=pre)
|
|
|
|
|
|
# ---- context -------------------------------------------------------------------------------------------------------
|
|
|
|
class Ctx:
|
|
def __init__(self, app, i: int, rid: str, run, view: str = "flow", query: dict | None = None):
|
|
self.app, self.i, self.rid, self.run, self.view = app, i, rid, run, view
|
|
self.q = query or {}
|
|
self.fl = R.flow(run)
|
|
self.base = f"/run/{i}/{rid}"
|
|
self.na = R.next_actions(run)
|
|
self.num = {sid: k + 1 for k, sid in enumerate(x for x in self.fl.order if self.fl.kind(x) == "step")}
|
|
self.manual = run.data["mode"] == "manual"
|
|
self.finished = bool(run.data.get("outcome"))
|
|
|
|
def h(self) -> str:
|
|
return self.app.hidden()
|
|
|
|
|
|
# ---- page ---------------------------------------------------------------------------------------------------------------
|
|
|
|
def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) -> bytes:
|
|
from .ui import page
|
|
pack = app.packs[i]
|
|
run = R1.load(pack, rid)
|
|
q = {k: (v[0] if isinstance(v, list) else v) for k, v in (query or {}).items()}
|
|
view = q.get("view", "flow") if q.get("view") in ("flow", "data", "overview") else "flow"
|
|
c = Ctx(app, i, rid, run, view, q)
|
|
d = run.data
|
|
ctx = d.get("context") or {}
|
|
where = ""
|
|
if ctx.get("project"):
|
|
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
|
|
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
|
|
ov = R.overview(run)
|
|
prog = ov["progress"]
|
|
out = [f"<h1>{e(pack.data['title'])}</h1>"
|
|
f"<p>{sp(d['mode'])} {sp(d['outcome']) if d.get('outcome') else ''} "
|
|
f"<span class=note>run {e(rid)} · operator {e(d['operator'])}{where}</span></p>"
|
|
f"<p class=note>{times_line(run)}</p>"
|
|
f"<p class=eyebrow>{prog['done']} of {prog['total']} done"
|
|
f"{' · ' + str(prog['pushed']) + ' pushed through' if prog['pushed'] else ''}</p>"
|
|
f"<progress max={prog['total']} value={prog['done'] + prog['pushed']} style='width:100%'></progress>"]
|
|
if d["deviations"]:
|
|
out.append(f"<p class=note>⚑ {len(d['deviations'])} deviation(s) recorded: this run can be finished but will never count towards "
|
|
"maturity. <a href='?view=overview'>See them</a>.</p>")
|
|
if msg:
|
|
out.append(f"<p class=bad>{e(msg)}</p>")
|
|
tabs = " ".join(f"<a class='tab{' on' if view == v else ''}' href='?view={v}'>{label}</a>"
|
|
for v, label in (("flow", "Guided flow"), ("data", "Data"), ("overview", "Overview")))
|
|
out.append(f"<nav class=tabs>{tabs}</nav>")
|
|
if view == "flow":
|
|
out.append(_flow_view(c, ov))
|
|
elif view == "data":
|
|
out.append(_data_view(c))
|
|
else:
|
|
out.append(_overview_view(c, ov))
|
|
return page(pack.data["title"], "".join(out))
|
|
|
|
|
|
def stamp(iso: str | None) -> str:
|
|
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
|
|
|
|
|
|
def times_line(run) -> str:
|
|
"""Started, finished and how long: the run's timestamps at a glance."""
|
|
t = R1.run_times(run)
|
|
if t["finished_run"]:
|
|
return (f"Started {e(stamp(t['started']))} · finished {e(stamp(t['finished']))} · took <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
|
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
|
return (f"Started {e(stamp(t['started']))} · running for <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
|
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
|
|
|
|
|
# ---- flow view ---------------------------------------------------------------------------------------------------------
|
|
|
|
def _flow_view(c: Ctx, ov: dict) -> str:
|
|
run, fl = c.run, c.fl
|
|
out = []
|
|
framing = list(run.pack.data.get("framing", []))
|
|
if framing:
|
|
out.append("<details class=framing open><summary><b>Keep in mind</b></summary><ul>" + "".join(f"<li>{e(x)}</li>" for x in framing) + "</ul></details>")
|
|
out.append(_mode_panel(c, ov))
|
|
out.append(_next_banner(c))
|
|
for st in fl.data["stages"]:
|
|
out.append(_stage(c, st))
|
|
if c.finished or _all_complete(run) or R1.latest_feedback(run):
|
|
out.append(c.app.feedback_form(c.i, c.rid, run))
|
|
out.append(_close(c))
|
|
return "".join(out)
|
|
|
|
|
|
def _all_complete(run) -> bool:
|
|
fl = R.flow(run)
|
|
return all(R.is_complete(run, x) for x in fl.order)
|
|
|
|
|
|
def _mode_panel(c: Ctx, ov: dict) -> str:
|
|
run = c.run
|
|
if c.finished:
|
|
return ""
|
|
if c.manual:
|
|
return (f"<div class=panel><b>Manual mode.</b> <span class=note>You confirm every check and gate. Unmet preconditions can be "
|
|
f"overridden with a reason; each override is recorded.</span>"
|
|
f"<form method=post action='{c.base}/mode' class=row>{c.h()}<input type=hidden name=to value=auto-confirm>"
|
|
"<button>Hand over to auto-confirm</button></form></div>")
|
|
ap = run.data["auto_confirm"]
|
|
last = ap.get("last")
|
|
status = ""
|
|
if ap.get("stopped"):
|
|
status = f"<p class=bad>Stopped at {e(ap['stopped']['item'])}: {e(ap['stopped']['reason'])}</p>"
|
|
elif last:
|
|
status = f"<p class=note>Last: {e(last['action'])}{' ' + e(last['item']) if last.get('item') else ''} {e(last.get('detail', ''))}</p>"
|
|
pre = ap.get("preauthorized") or []
|
|
return (f"<div class=panel data-s='auto'><b>Auto-confirm mode.</b> <span class=note>The engine confirms when preconditions hold. It never "
|
|
"runs commands: do the work yourself, then check again. Irreversible steps wait for you unless pre-authorized"
|
|
f"{' (' + e(', '.join(pre)) + ')' if pre else ''}.</span>{status}"
|
|
f"<form method=post action='{c.base}/tick' class=row>{c.h()}"
|
|
"<button name=n value=1>Check once</button> <button name=n value=50>Run until it waits</button></form>"
|
|
f"<form method=post action='{c.base}/mode' class=row>{c.h()}<input type=hidden name=to value=manual>"
|
|
"<button>Take over (manual)</button></form></div>")
|
|
|
|
|
|
def _title_of(c: Ctx, id_: str) -> str:
|
|
fl = c.fl
|
|
node = fl.node(id_) if fl.kind(id_) != "?" else {}
|
|
return node.get("title", id_)
|
|
|
|
|
|
def _next_banner(c: Ctx) -> str:
|
|
na = c.na
|
|
if c.finished:
|
|
return ""
|
|
rec = na["recommended"]
|
|
if rec is None:
|
|
if _all_complete(c.run):
|
|
return "<div class=banner><b>Everything is done.</b> Close the run below.</div>"
|
|
b = na["blocked"][0] if na["blocked"] else None
|
|
return f"<div class=banner><b>Nothing can start.</b> {e(_title_of(c, b['id'])) if b else ''} waits: {e('; '.join(b['unmet'])) if b else ''}</div>"
|
|
kind = c.fl.kind(rec)
|
|
others = [x for x in na["ready"] + na["running"] if x != rec]
|
|
links = " ".join(f"<a class=chip href='#item-{e(x)}'>{e(_title_of(c, x))}</a>" for x in others)
|
|
verb = "Continue" if rec in na["running"] else ("Evaluate gate" if kind == "gate" else "Next")
|
|
return (f"<div class=banner><b>{verb}:</b> <a href='#item-{e(rec)}'>{e(_title_of(c, rec))}</a> <span class=note>(recommended)</span>"
|
|
f"{'<br><span class=note>Also possible in parallel:</span> ' + links if links else ''}</div>")
|
|
|
|
|
|
def _stage(c: Ctx, st: dict) -> str:
|
|
run, fl = c.run, c.fl
|
|
done = R.is_complete(run, st["id"])
|
|
members = fl.members(st["id"], include_gates=False)
|
|
n_done = sum(run.data["steps"][m]["state"] in DONE for m in members)
|
|
body = [f"<p class=note><b>Goal:</b> {e(st['goal'])}</p>"]
|
|
if st.get("framing"):
|
|
body.append("<ul class=note>" + "".join(f"<li>{e(x)}</li>" for x in st["framing"]) + "</ul>")
|
|
q = _questions(c, st)
|
|
if q:
|
|
body.append(q)
|
|
for s in st.get("steps", []):
|
|
body.append(_step(c, s))
|
|
for child in st.get("stages", []):
|
|
body.append(_stage(c, child))
|
|
for g in st.get("gates", []):
|
|
body.append(_gate(c, g))
|
|
return (f"<details class='stage' id='stage-{e(st['id'])}'{'' if done else ' open'}><summary><span class=head>"
|
|
f"<span><b>{e(st['title'])}</b> <span class=note>{n_done}/{len(members)} steps</span></span>"
|
|
f"{sp('complete' if done else 'in progress')}</span></summary>{''.join(body)}</details>")
|
|
|
|
|
|
def _questions(c: Ctx, st: dict) -> str:
|
|
dps = [d for d in c.fl.datapoints.values() if d.get("ask_in") == st["id"] and not d.get("produced_by")]
|
|
if not dps or c.finished:
|
|
return ""
|
|
rows = []
|
|
locked = False
|
|
for d in dps:
|
|
rec = c.run.data["datapoints"][d["id"]]
|
|
users = R.datapoint_users(c.run, d["id"])
|
|
locked = locked or bool(users and rec.get("value") is not None)
|
|
rows.append(f"<p>{_input(d, rec, users=users)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
|
|
return (f"<div class=questions><b>Questions for this stage</b> <span class=note>(values used by its steps; never enter secrets)</span>"
|
|
f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}#stage-{e(st['id'])}'>"
|
|
f"{''.join(rows)}{_override_fields(c, locked)}<button>Save answers</button></form></div>")
|
|
|
|
|
|
def _override_fields(c: Ctx, locked: bool) -> str:
|
|
"""Only shown when some value is already in use by started steps: changing it is a recorded deviation."""
|
|
if not locked:
|
|
return ""
|
|
if not c.manual:
|
|
return "<p class=note>Values already in use by steps can only be changed in manual mode: take over first.</p>"
|
|
return ("<details class=override><summary>Changing a value that steps already used (override)</summary>"
|
|
"<p class=bad>Risk: steps that already ran used the previous value, so their results may no longer match the new one. "
|
|
"The change is recorded as a deviation.</p>"
|
|
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you change it now (required for values in use)'></p>"
|
|
"<p><label><input type=checkbox name=acknowledge_risk value=1> I acknowledge the risk.</label></p></details>")
|
|
|
|
|
|
def _input(d: dict, rec: dict, editable: bool = True, users: list[str] | None = None) -> str:
|
|
name = f"d_{d['id']}"
|
|
val = rec.get("value", "")
|
|
dis = "" if editable else " disabled"
|
|
if d.get("values"):
|
|
ctl = f"<select name='{name}'{dis}>" + "".join(f"<option{' selected' if o == val else ''}>{e(o)}</option>" for o in d["values"]) + "</select>"
|
|
elif d["type"] == "bool":
|
|
ctl = (f"<select name='{name}'{dis}><option value=''>(not set)</option>"
|
|
+ "".join(f"<option{' selected' if o == val else ''}>{o}</option>" for o in ("true", "false")) + "</select>")
|
|
else:
|
|
ctl = f"<input name='{name}' value='{e(val)}'{dis}>"
|
|
err = f" <span class=bad>{e(rec.get('error', ''))}</span>" if rec["state"] == "err" else ""
|
|
use = (f" <span class=chip title='changing it is a recorded deviation'>in use by {e(', '.join(users))}</span>"
|
|
if users and rec.get("value") is not None else "")
|
|
return f"<label>{e(d['label'])} {sp(rec['state'])}{use}<br>{ctl}</label>{err}"
|
|
|
|
|
|
# ---- steps ------------------------------------------------------------------------------------------------------------------
|
|
|
|
def _step(c: Ctx, s: dict) -> str:
|
|
run, fl = c.run, c.fl
|
|
sid = s["id"]
|
|
st = run.data["steps"][sid]
|
|
state = st["state"]
|
|
eff = R.step_def(run, sid)
|
|
n = c.num[sid]
|
|
url = f"{c.base}/{e(sid)}"
|
|
marks = []
|
|
if s.get("idempotent") or eff.get("idempotent"):
|
|
marks.append(f"<span class=chip>{e(eff.get('idempotent') or s.get('idempotent'))}</span>")
|
|
if not eff.get("reversible", True) or any(not a.get("reversible", s.get("reversible", True)) for a in s.get("one_of", [])):
|
|
marks.append("<span class=chip data-s='irreversible'>irreversible</span>")
|
|
if s.get("auto_confirm") == "manual-only":
|
|
marks.append("<span class=chip>manual-only</span>")
|
|
took = R1.run_times(run)["step_seconds"].get(sid)
|
|
if took is not None and state in DONE:
|
|
marks.append(f"<span class=note>took {e(R1.fmt_duration(took))}</span>")
|
|
title = eff.get("title", s["title"]) if st.get("alternative") else s["title"]
|
|
actor = fl.actors.get(s["actor"], {}).get("role", s["actor"])
|
|
body = []
|
|
if s.get("description"):
|
|
body.append(f"<p class=note>{e(s['description'])}</p>")
|
|
unmet = R.unmet(run, sid) if state == "todo" else []
|
|
if state == "todo":
|
|
body.append(_start_control(c, s, sid, url, unmet))
|
|
elif state == "running":
|
|
body.append(_running(c, s, eff, st, sid, url))
|
|
elif state == "failed":
|
|
idem = eff.get("idempotent") or s.get("idempotent")
|
|
ack = ("" if idem == "idempotent" else
|
|
"<label><input type=checkbox name=acknowledge_partial value=1> I have checked that the earlier attempt did not partly apply</label> ")
|
|
body.append(f"<p class=bad>Failed: {e(st.get('note') or '(no comment)')}</p>"
|
|
f"<form method=post action='{url}/retry'>{c.h()}<p class=row>{ack}<button>Retry</button></p></form>")
|
|
elif state == "pushed":
|
|
body.append("<p class=note>Passed over by push-through without verification: not done, and recorded as a deviation.</p>")
|
|
elif state == "done" and st.get("note"):
|
|
body.append(f"<p class=note>Comment: {e(st['note'])}</p>")
|
|
if state == "done" or state == "pushed":
|
|
cmd = _command(run, sid)
|
|
if cmd:
|
|
body.insert(0, f"<pre>{e(cmd)}</pre>")
|
|
body.append(c.app.capture_html(url, eff, st, state, c.finished))
|
|
current = " id=current" if sid == c.na["recommended"] else ""
|
|
fold = "" if state in DONE else " open"
|
|
return (f"<details class='step {e(state)}' id='item-{e(sid)}'{fold}><summary{current}><span class=head>"
|
|
f"<span><b>{n}. {e(title)}</b> <span class=note>[{e(actor)} · {e(s['owner'])}]</span> {' '.join(marks)}</span>"
|
|
f"{sp(state)}</span></summary>{''.join(body)}</details>")
|
|
|
|
|
|
def _command(run, sid: str) -> str | None:
|
|
try:
|
|
return R.command_for(run, sid)
|
|
except (KeyError, ValueError) as ex:
|
|
return f"(needs a value that is not set: {ex})"
|
|
|
|
|
|
def _start_control(c: Ctx, s: dict, sid: str, url: str, unmet: list[str]) -> str:
|
|
run = c.run
|
|
if c.finished:
|
|
return ""
|
|
alts = s.get("one_of", [])
|
|
rec = R.recommended_alt(run, sid) if alts else None
|
|
alt_html = ""
|
|
if alts:
|
|
alt_html = "<p><b>Choose one:</b><br>" + "".join(
|
|
f"<label><input type=radio name=alt value='{e(a['id'])}'{' checked' if (a['id'] == rec or (rec is None and a is alts[0])) else ''}> "
|
|
f"{e(a['title'])}{' <span class=note>(recommended)</span>' if a['id'] == rec else ''}</label><br>" for a in alts) + "</p>"
|
|
irrev = (not s.get("reversible", True)) or any(not a.get("reversible", s.get("reversible", True)) for a in alts)
|
|
phrase = ""
|
|
if irrev:
|
|
phrase = (f"<p>This step is <b>irreversible</b>. Type the confirmation phrase to start it:<br>"
|
|
f"<code>{e(R.phrase_for(run, sid))}</code><br><input name=phrase autocomplete=off class=grow style='width:100%'></p>")
|
|
if not unmet:
|
|
if s.get("auto_confirm") == "manual-only" and not c.manual:
|
|
return "<p class=note>Manual-only: auto-confirm will not start this step. Take over to do it yourself.</p>"
|
|
return f"<form method=post action='{url}/start'>{c.h()}{alt_html}{phrase}<button>Start this step</button></form>"
|
|
risk = R._risk_text(run, sid)
|
|
wait = f"<p class=note>Waiting for: {e('; '.join(unmet))}</p>"
|
|
if not c.manual:
|
|
return wait + "<p class=note>Overrides are made in manual mode.</p>"
|
|
return (wait + f"<details class=override><summary>Start anyway (override)</summary>"
|
|
f"<form method=post action='{url}/start'>{c.h()}<p class=bad>Downstream risk: {e(risk)}</p>{alt_html}{phrase}"
|
|
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you continue anyway (required)' required></p>"
|
|
"<p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk. Record this as a deviation.</label></p>"
|
|
"<button>Override and start</button></form></details>")
|
|
|
|
|
|
def _running(c: Ctx, s: dict, eff: dict, st: dict, sid: str, url: str) -> str:
|
|
run = c.run
|
|
out = []
|
|
cmd = _command(run, sid)
|
|
if cmd:
|
|
out.append(f"<pre>{e(cmd)}</pre><button type=button data-copy>Copy</button>")
|
|
ver = eff["verify"]
|
|
exp = ver.get("expect") or (None if ver.get("datapoints_ok") else "manual")
|
|
said = ver.get("done_when") or ("datapoints ok: " + ", ".join(ver.get("datapoints_ok", [])))
|
|
v = st.get("verification")
|
|
if exp == "manual":
|
|
out.append(f"<form method=post action='{url}/verify'>{c.h()}<p>Is this true? <b>{e(said)}</b></p>"
|
|
"<button name=ok value=1>Yes, it is</button> <button name=ok value=0>Not yet</button></form>")
|
|
else:
|
|
what = (f"<code>{e(_render_check(run, ver))}</code>" if ver.get("command") else "the datapoint states")
|
|
out.append(f"<form method=post action='{url}/verify'>{c.h()}<input type=hidden name=yes value=1>"
|
|
f"<p class=note>Checks: {what}</p><button>Run check</button></form>")
|
|
if not v:
|
|
out.insert(0, "<p class=note>1. Do the work, then check → 2. Mark done to lock it in</p>")
|
|
elif v["passed"]:
|
|
out.append("<p><b>✔ Check passed.</b> Press <b>Mark done</b> to lock it in.</p>")
|
|
else:
|
|
out.append("<p class=bad>✘ Check not passed. Fix the cause and check again, or mark the step failed.</p>")
|
|
ev = ""
|
|
if v and v["passed"]:
|
|
ev = "".join(f"<label>{e(x)} <input name='e_{e(x)}' required></label> " for x in s.get("evidence", []))
|
|
ev = f"<p>{ev}</p>" if ev else ""
|
|
done_btn = f"<button formaction='{url}/done'>Mark done</button> " if v and v["passed"] else ""
|
|
out.append(f"<form method=post action='{url}/fail'>{c.h()}{ev}<p class=row>{done_btn}"
|
|
f"<button formaction='{url}/fail' formnovalidate data-fail>Mark failed</button> "
|
|
"<input name=note class=grow placeholder='comment (what happened; no secrets)'></p>"
|
|
"<p class='note bad' data-warn hidden>Add a comment about what went wrong, or click <b>Mark failed</b> again to fail without one.</p></form>")
|
|
if eff.get("rollback"):
|
|
out.append(f"<p class=note>Rollback: {e(_render_text(run, eff['rollback']))}</p>")
|
|
return "".join(out)
|
|
|
|
|
|
def _render_text(run, text: str) -> str:
|
|
try:
|
|
return R.render(text, run.data["params"], run.pack)
|
|
except (KeyError, ValueError):
|
|
return text
|
|
|
|
|
|
def _render_check(run, ver: dict) -> str:
|
|
return _render_text(run, ver.get("command", ""))
|
|
|
|
|
|
# ---- gates ---------------------------------------------------------------------------------------------------------------------
|
|
|
|
def _gate(c: Ctx, g: dict) -> str:
|
|
run, fl = c.run, c.fl
|
|
gid = g["id"]
|
|
gs = run.data["gates"][gid]
|
|
state = gs["state"]
|
|
unmet = R.unmet(run, gid) if state not in R.DONE_GATE else []
|
|
rows = []
|
|
for idx, ck in enumerate(g["checks"]):
|
|
res = gs["results"].get(str(idx))
|
|
exp = ck.get("expect") or (None if ck.get("datapoints_ok") else "manual")
|
|
text = ck.get("done_when") or "datapoints ok: " + ", ".join(ck.get("datapoints_ok", []))
|
|
pill = sp("passed" if res and res["passed"] else "failed") if res else sp("not checked")
|
|
ctl = ""
|
|
if state not in R.DONE_GATE and not c.finished:
|
|
ctl = _gate_check_form(c, gid, idx, ck, exp, unmet)
|
|
rows.append(f"<li>{pill} {e(text)}{'<br>' + ctl if ctl else ''}</li>")
|
|
waive = ""
|
|
w = g.get("waivable")
|
|
if w and state in ("open", "failed") and not c.finished:
|
|
role = fl.actors.get(w["by"], {}).get("role", w["by"])
|
|
waive = (f"<details class=override><summary>Waive this gate (declared waivable by {e(role)})</summary>"
|
|
f"<form method=post action='{c.base}/gate/{e(gid)}/waive'>{c.h()}"
|
|
f"<p class=bad>Downstream risk: {e(R._risk_text(run, gid))}</p>"
|
|
"<p><input name=reason class=grow style='width:100%' placeholder='reason (required)' required></p>"
|
|
"<button>Waive and record as a deviation</button></form></details>")
|
|
extra = ""
|
|
if state == "pushed":
|
|
extra = "<p class=note>Passed over by push-through without passing: recorded as a deviation.</p>"
|
|
if state == "waived":
|
|
extra = f"<p class=note>Waived: {e(gs['waiver']['reason'])}</p>"
|
|
current = " open" if state not in R.DONE_GATE else ""
|
|
marker = " id=current" if gid == c.na["recommended"] else ""
|
|
return (f"<details class='step gate {e(state)}' id='item-{e(gid)}'{current}><summary{marker}><span class=head>"
|
|
f"<span><b>Gate: {e(g['title'])}</b> <span class=note>quality goal: {e(g['goal'])}"
|
|
f"{' · blocking' if g.get('blocking', True) else ''}</span></span>{sp(state)}</span></summary>"
|
|
f"<ul class=checks>{''.join(rows)}</ul>{extra}{waive}</details>")
|
|
|
|
|
|
def _gate_check_form(c: Ctx, gid: str, idx: int, ck: dict, exp: str | None, unmet: list[str]) -> str:
|
|
run = c.run
|
|
action = f"{c.base}/gate/{e(gid)}/check"
|
|
hidden = f"{c.h()}<input type=hidden name=idx value={idx}>"
|
|
if exp == "manual":
|
|
ctl = "<button name=ok value=1>Yes, it is</button> <button name=ok value=0>Not yet</button>"
|
|
else:
|
|
cmd = f" <code>{e(_render_check(run, ck))}</code>" if ck.get("command") else ""
|
|
ctl = f"<input type=hidden name=yes value=1><button>Run check</button>{cmd}"
|
|
if not unmet:
|
|
return f"<form method=post action='{action}'>{hidden}<p class=row>{ctl}</p></form>"
|
|
if not c.manual:
|
|
return f"<span class=note>Waiting for: {e('; '.join(unmet))}</span>"
|
|
return (f"<span class=note>Waiting for: {e('; '.join(unmet))}</span>"
|
|
f"<details class=override><summary>Check anyway (override)</summary>"
|
|
f"<form method=post action='{action}'>{hidden}<p class=bad>Downstream risk: {e(R._risk_text(run, gid))}</p>"
|
|
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you continue anyway (required)' required></p>"
|
|
"<p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk. Record this as a deviation.</label></p>"
|
|
f"<p class=row>{ctl}</p></form></details>")
|
|
|
|
|
|
# ---- data view -----------------------------------------------------------------------------------------------------------------
|
|
|
|
def _data_view(c: Ctx) -> str:
|
|
run, fl = c.run, c.fl
|
|
group = c.q.get("group") or None
|
|
flt = c.q.get("filter") or None
|
|
keys = R.dimension_keys(run)
|
|
base = "?view=data"
|
|
|
|
def link(g=None, f=None, label="", cls=""):
|
|
qs = base + (f"&group={quote(g)}" if g else "") + (f"&filter={quote(f)}" if f else "")
|
|
return f"<a class='chip{cls}' href='{qs.replace('&', '&')}'>{e(label)}</a>"
|
|
|
|
ctl = ("<p><b>Group by</b> " + link(None, flt, "none", " on" if not group else "") + " "
|
|
+ " ".join(link(k, flt, k, " on" if group == k else "") for k in keys) + "</p>")
|
|
if flt:
|
|
ctl += f"<p><b>Filter</b> <span class=chip on>{e(flt)}</span> " + link(group, None, "clear filter") + "</p>"
|
|
groups = R.datapoint_rows(run, group=group, flt=flt)
|
|
out = [f"<p class=note>Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.</p>{ctl}"]
|
|
editable = not c.finished
|
|
any_locked = any(R.datapoint_users(run, x["id"]) and x["value"] is not None and x["editable"]
|
|
for rows_ in groups.values() for x in rows_)
|
|
out.append(f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}?view=data"
|
|
f"{'&group=' + quote(group) if group else ''}{'&filter=' + quote(flt) if flt else ''}'>")
|
|
for gname, rows in groups.items():
|
|
if gname:
|
|
out.append(f"<h3>{e(group)}: {e(gname)}</h3>")
|
|
trs = ""
|
|
for r in rows:
|
|
rec = run.data["datapoints"][r["id"]]
|
|
d = fl.datapoints[r["id"]]
|
|
dims = " ".join(link(group, f"{k}:{v}", f"{k}: {v}") for k, v in r["dimensions"].items())
|
|
src = " · ".join(x for x in (f"asked in {e(r['ask_in'])}" if r["ask_in"] else "", f"produced by {e(r['produced_by'])}" if r["produced_by"] else "",
|
|
f"confirmed by {e(r['confirmed_by'])}" if r["confirmed_by"] else "", f"how: {e(r['how'])}" if r["how"] else "") if x)
|
|
ctl = _input(d, rec, editable and r["editable"], R.datapoint_users(run, r["id"])) if (editable and r["editable"]) else (
|
|
f"{e(r['label'])} {sp(r['state'])}<br><code>{e(r['value'] if r['value'] is not None else '')}</code>")
|
|
trs += f"<tr><td>{ctl}</td><td>{dims}</td><td class=note>{src}</td></tr>"
|
|
out.append(f"<table class=data><tr><th>Datapoint</th><th>Dimensions</th><th>Provenance</th></tr>{trs}</table>")
|
|
if not groups:
|
|
out.append("<p class=note>No datapoints match.</p>")
|
|
if editable:
|
|
out.append(_override_fields(c, any_locked) + "<button>Save changed values</button>")
|
|
out.append("</form>")
|
|
arts = R.artefact_rows(run)
|
|
if arts:
|
|
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Open</th><th>Produced by</th><th>Until</th></tr>" + "".join(
|
|
f"<tr><td>{e(a['title'])}</td><td><code>{e(a['location'] or '(not known yet)')}</code></td>"
|
|
f"<td>{_artefact_links(c, a)}</td>"
|
|
f"<td>{e(a['produced_by'])} {sp(a['produced_state'])}</td><td>{e(a['lives_until'] or '-')}</td></tr>" for a in arts) + "</table>")
|
|
if fl.actors:
|
|
counts: dict[str, int] = {}
|
|
for s in fl.steps.values():
|
|
counts[s["actor"]] = counts.get(s["actor"], 0) + 1
|
|
out.append("<h2>Actors</h2><table><tr><th>Role</th><th>Description</th><th>Privileges</th><th>Steps</th></tr>" + "".join(
|
|
f"<tr><td>{e(a['role'])} <span class=note>({e(a['id'])})</span></td><td>{e(a['description'])}</td>"
|
|
f"<td>{e(', '.join(a.get('privileges', [])))}</td><td>{counts.get(a['id'], 0)}</td></tr>" for a in fl.actors.values()) + "</table>")
|
|
return "".join(out)
|
|
|
|
|
|
def _artefact_links(c: Ctx, a: dict) -> str:
|
|
loc = a["location"]
|
|
if loc and A.is_url(loc):
|
|
return f"<a href='{e(loc)}' target=_blank rel='noopener noreferrer'>Open link</a>"
|
|
path, why = A.check(loc)
|
|
if path is None:
|
|
return f"<span class=note>{e(why)}</span>"
|
|
url = f"{c.base}/artefact/{e(a['id'])}"
|
|
return f"<a href='{url}' target=_blank rel=noopener>Open</a> · <a href='{url}?download=1'>Download</a>"
|
|
|
|
|
|
def serve_artefact(app, i: int, rid: str, aid: str) -> tuple[int, bytes, str, str | None]:
|
|
"""(status, body, content type, download filename or None). Refusals come back as short plain text."""
|
|
run = R1.load(app.packs[i], rid)
|
|
if run.pack.spec != "runbook-pack/v0.2":
|
|
return 404, b"no artefacts", "text/plain; charset=utf-8", None
|
|
row = next((a for a in R.artefact_rows(run) if a["id"] == aid), None)
|
|
if row is None:
|
|
return 404, b"no such artefact", "text/plain; charset=utf-8", None
|
|
path, why = A.check(row["location"])
|
|
if path is None:
|
|
return 403, f"not served: {why}".encode(), "text/plain; charset=utf-8", None
|
|
body, ctype, is_text = A.content(path)
|
|
return 200, body, ctype, (None if is_text else path.name)
|
|
|
|
|
|
# ---- overview ------------------------------------------------------------------------------------------------------------------
|
|
|
|
def _overview_view(c: Ctx, ov: dict) -> str:
|
|
rows = ""
|
|
for s in ov["stages"]:
|
|
gates = " ".join(f"{e(g['title'])} {sp(g['state'])}" for g in s["gates"])
|
|
prob = ""
|
|
if s["steps_failed"]:
|
|
prob += f" <span class=bad>{s['steps_failed']} failed</span>"
|
|
if s["steps_pushed"]:
|
|
prob += f" {s['steps_pushed']} pushed"
|
|
if s["steps_running"]:
|
|
prob += f" <span class=note>{s['steps_running']} running</span>"
|
|
rows += (f"<tr><td style='padding-left:{0.5 + s['depth'] * 1.2}rem'><a href='?view=flow#stage-{e(s['id'])}'>{e(s['title'])}</a>"
|
|
f"<br><span class=note>{e(s['goal'])}</span></td>"
|
|
f"<td>{s['steps_done']}/{s['steps_total']}{prob}</td><td>{gates or '-'}</td>"
|
|
f"<td>{sp('complete' if s['complete'] else 'in progress')}"
|
|
f"{'<br><span class=note>' + e('; '.join(s['unmet'])) + '</span>' if s['unmet'] else ''}</td></tr>")
|
|
dps = " ".join(f"{sp(k)} {v}" for k, v in sorted(ov["datapoints"].items()))
|
|
out = ["<h2>Stages</h2><table><tr><th>Stage</th><th>Steps</th><th>Gates</th><th>State</th></tr>" + rows + "</table>",
|
|
f"<h2>Datapoints</h2><p>{dps}</p><p class=note><a href='?view=data'>Open the data view</a></p>"]
|
|
if ov["deviations"]:
|
|
out.append("<h2>Deviations</h2><table><tr><th>Kind</th><th>Item</th><th>Mode</th><th>Unmet</th><th>Reason</th><th>When</th></tr>" + "".join(
|
|
f"<tr><td>{sp(d['kind'])}</td><td>{e(d['item'])}</td><td>{e(d['mode'])}</td><td>{e('; '.join(d['unmet']))}</td>"
|
|
f"<td>{e(d['reason'])}{'<br><span class=note>risk: ' + e(d['downstream_risk']) + '</span>' if d.get('downstream_risk') else ''}</td>"
|
|
f"<td class=note>{e(d['at'][:19])}</td></tr>" for d in ov["deviations"]) + "</table>")
|
|
else:
|
|
out.append("<h2>Deviations</h2><p class=note>None. A run without deviations can count towards maturity.</p>")
|
|
if ov["mode_history"]:
|
|
out.append("<h2>Mode changes</h2><ul>" + "".join(f"<li>{e(m['from'])} → {e(m['to'])} · {e(m['at'][:19])}</li>" for m in ov["mode_history"]) + "</ul>")
|
|
if ov["stopped"]:
|
|
out.append(f"<p class=bad>Auto-confirm stopped at {e(ov['stopped']['item'])}: {e(ov['stopped']['reason'])}</p>")
|
|
return "".join(out)
|
|
|
|
|
|
# ---- closing the run -----------------------------------------------------------------------------------------------------
|
|
|
|
def _close(c: Ctx) -> str:
|
|
run = c.run
|
|
if c.finished:
|
|
note = f"<p>Comment: {e(run.data['outcome_note'])}</p>" if run.data.get("outcome_note") else ""
|
|
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2>{note}{c.app.locked_note(run)}"
|
|
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p></div>")
|
|
fl = c.fl
|
|
steps = [run.data["steps"][s]["state"] for s in fl.steps]
|
|
gates = [run.data["gates"][g]["state"] for g in fl.gates]
|
|
btns = []
|
|
if all(x == "done" for x in steps) and all(x in ("passed", "waived") for x in gates):
|
|
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
|
if {"failed", "pushed"} & set(steps) or {"failed", "pushed"} & set(gates):
|
|
btns.append("<button name=outcome value=failed onclick=\"return confirm('Finish this run as failed?')\">Finish: failed</button>")
|
|
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this run?')\">Abandon run</button>")
|
|
return ("<div id=close class=step><h2>Close this run</h2>"
|
|
"<p class=note>Finishing records the outcome. A run with deviations can be finished but does not count towards maturity. "
|
|
"A finished run is read-only.</p>"
|
|
f"<form method=post action='{c.base}/finalize'>{c.h()}<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
|
|
f"{c.app.close_feedback_fields(run)}<p class=row>{' '.join(btns)}</p></form></div>")
|
|
|
|
|
|
# ---- actions -------------------------------------------------------------------------------------------------------------------
|
|
|
|
def _safe_next(app, form: dict, default: str) -> str:
|
|
n = form.get("next", "")
|
|
return n if n.startswith("/run/") and "//" not in n and "\n" not in n and "\r" not in n else default
|
|
|
|
|
|
def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|
"""Perform a v0.2 run action; return the URL to redirect to. Raises RunError/ValueError/KeyError on a refused action."""
|
|
pack = app.packs[i]
|
|
run = R1.load(pack, rid)
|
|
base = f"/run/{i}/{rid}"
|
|
if parts == ["feedback"]:
|
|
R1.add_feedback(run, form.get("rating", ""), form.get("comment", ""))
|
|
return f"{base}#feedback"
|
|
if parts == ["finalize"]:
|
|
R1.finalize(run, form.get("outcome", ""), form.get("note", ""), rating=form.get("rating") or None, comment=form.get("fb_comment", ""))
|
|
return f"{base}#close"
|
|
if parts == ["mode"]:
|
|
R.switch_mode(run, form.get("to", ""))
|
|
return base
|
|
if parts == ["tick"]:
|
|
n = max(1, min(int(form.get("n", "1") or 1), 50))
|
|
last = None
|
|
for _ in range(n):
|
|
last = R.auto_confirm_tick(run)
|
|
if last["action"] in ("waiting", "awaiting-operator", "stopped", "finished", "push-through"):
|
|
break
|
|
run.data["auto_confirm"]["last"] = last
|
|
run.save()
|
|
return f"{base}#current"
|
|
if parts == ["data"]:
|
|
bad = []
|
|
changes = {}
|
|
for k, v in form.items():
|
|
if not k.startswith("d_"):
|
|
continue
|
|
dp = k[2:]
|
|
cur = run.data["datapoints"].get(dp)
|
|
if cur is None or v == "" or (cur["state"] in ("set", "ok") and cur.get("value") == v):
|
|
continue # unchanged or left empty
|
|
changes[dp] = v
|
|
need = R.changes_needing_override(run, changes) # all or nothing: check before applying any
|
|
reason, ack = form.get("override_reason", ""), bool(form.get("acknowledge_risk"))
|
|
if need and not (reason.strip() and ack):
|
|
raise R1.RunError("already in use by started steps: " + "; ".join(f"{d} ({', '.join(u)})" for d, u in need.items())
|
|
+ ". To change them give a reason and acknowledge the risk; each change is recorded as a deviation.")
|
|
for dp, v in changes.items():
|
|
if R.set_datapoint(run, dp, v, override_reason=reason, acknowledge_risk=ack) == "err":
|
|
bad.append(f"{dp} ({run.data['datapoints'][dp]['error']})")
|
|
if bad:
|
|
raise ValueError("invalid value for: " + ", ".join(bad))
|
|
return _safe_next(app, form, base)
|
|
if len(parts) == 3 and parts[0] == "gate" and parts[2] in ("check", "waive"):
|
|
gid = parts[1]
|
|
if parts[2] == "waive":
|
|
R.waive(run, gid, form.get("reason", ""))
|
|
else:
|
|
R.gate_check(run, gid, int(form.get("idx", "-1")), confirm=bool(form.get("yes")),
|
|
manual_ok=(form.get("ok") == "1") if not form.get("yes") else None,
|
|
override_reason=form.get("override_reason", ""), acknowledge_risk=bool(form.get("acknowledge_risk")))
|
|
return f"{base}#item-{gid}"
|
|
if len(parts) == 2:
|
|
sid, action = parts
|
|
if action == "start":
|
|
R.start(run, sid, alt=form.get("alt") or None, phrase=form.get("phrase") or None,
|
|
override_reason=form.get("override_reason", ""), acknowledge_risk=bool(form.get("acknowledge_risk")))
|
|
elif action == "verify":
|
|
R.verify(run, sid, confirm=bool(form.get("yes")), manual_ok=(form.get("ok") == "1") if not form.get("yes") else None)
|
|
elif action == "done":
|
|
R.complete(run, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")}, form.get("note", ""))
|
|
elif action == "fail":
|
|
R.fail(run, sid, form.get("note", ""))
|
|
elif action == "retry":
|
|
R.retry(run, sid, acknowledge_partial=bool(form.get("acknowledge_partial")))
|
|
elif action == "capture":
|
|
R1.add_capture(run, sid, form.get("output", ""), form.get("command", ""))
|
|
elif action == "verify-pasted":
|
|
R1.verify_pasted(run, sid, form.get("output", ""), form.get("command", ""))
|
|
else:
|
|
raise ValueError(f"unknown action {action}")
|
|
return f"{base}#current"
|
|
raise ValueError("unknown action")
|