v0.2 walk-through UI: guided flow, data view, overview, modes, overrides, typed phrase; retry confirmation; visual pass
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
This commit is contained in:
parent
f8ce4717a8
commit
f070999aa2
12 changed files with 1285 additions and 53 deletions
|
|
@ -50,6 +50,9 @@ class Run:
|
|||
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
def step_def(self, step_id: str) -> dict:
|
||||
if self.pack.spec == "runbook-pack/v0.2": # steps live in stages; alternatives are applied
|
||||
from . import run2
|
||||
return run2.step_def(self, step_id)
|
||||
for s in self.pack.data.get("steps", []):
|
||||
if s["id"] == step_id:
|
||||
return s
|
||||
|
|
|
|||
103
rtut/run2.py
103
rtut/run2.py
|
|
@ -370,11 +370,19 @@ def fail(run: Run, step_id: str, note: str = "") -> None:
|
|||
run.save()
|
||||
|
||||
|
||||
def retry(run: Run, step_id: str) -> None:
|
||||
def retry(run: Run, step_id: str, acknowledge_partial: bool = False) -> None:
|
||||
"""Retry a failed step. Unless it is declared idempotent, the operator must confirm that the earlier
|
||||
attempt did not partly apply (a repeated non-idempotent step can double its effect)."""
|
||||
_writable(run)
|
||||
st = run.data["steps"][step_id]
|
||||
if st["state"] != "failed":
|
||||
raise RunError(f"step {step_id} has not failed")
|
||||
idem = step_def(run, step_id).get("idempotent") or flow(run).steps[step_id].get("idempotent")
|
||||
if idem != "idempotent":
|
||||
if not acknowledge_partial:
|
||||
raise RunError(f"step {step_id} is {idem or 'not declared idempotent'}: confirm that the earlier attempt did not "
|
||||
"partly apply before retrying it")
|
||||
st.setdefault("retries", []).append({"at": _now(), "acknowledged_partial": True})
|
||||
for dp in flow(run).steps[step_id].get("produces", []):
|
||||
rec = run.data["datapoints"].get(dp)
|
||||
if rec and rec["state"] == "err" and rec.get("error", "").startswith("producing step"):
|
||||
|
|
@ -544,3 +552,96 @@ def _auto_confirm_gate(run: Run, gid: str, now: datetime) -> dict:
|
|||
run.save()
|
||||
return out
|
||||
return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"}
|
||||
|
||||
|
||||
# ---- views for renderers: overview, datapoint table with dimensions, artefacts, mode switch -----------------------------
|
||||
|
||||
def switch_mode(run: Run, mode: str) -> None:
|
||||
"""Take over (auto-confirm to manual) or hand over (manual to auto-confirm). The change is recorded."""
|
||||
_writable(run)
|
||||
if mode not in ("manual", "auto-confirm"):
|
||||
raise RunError("mode must be manual or auto-confirm")
|
||||
if mode == run.data["mode"]:
|
||||
raise RunError(f"the run is already in {mode} mode")
|
||||
run.data.setdefault("mode_history", []).append({"from": run.data["mode"], "to": mode, "at": _now(), "who": run.data["operator"]})
|
||||
run.data["mode"] = mode
|
||||
if mode == "auto-confirm":
|
||||
run.data["auto_confirm"].pop("stopped", None)
|
||||
run.save()
|
||||
|
||||
|
||||
def overview(run: Run) -> dict:
|
||||
"""Progress per stage, datapoint states, deviations: the state of the run at a glance."""
|
||||
fl = flow(run)
|
||||
stages = []
|
||||
|
||||
def walk(items: list[dict], depth: int) -> None:
|
||||
for st in items:
|
||||
steps = fl.members(st["id"], include_gates=False)
|
||||
gates = [g for g in fl.members(st["id"]) if fl.kind(g) == "gate"]
|
||||
sstates = [run.data["steps"][s]["state"] for s in steps]
|
||||
stages.append({
|
||||
"id": st["id"], "title": st["title"], "goal": st["goal"], "depth": depth,
|
||||
"steps_total": len(steps), "steps_done": sum(x in ("done", "skipped") for x in sstates),
|
||||
"steps_pushed": sstates.count("pushed"), "steps_failed": sstates.count("failed"),
|
||||
"steps_running": sstates.count("running"),
|
||||
"gates": [{"id": g, "title": fl.gates[g]["title"], "state": run.data["gates"][g]["state"]} for g in gates],
|
||||
"complete": is_complete(run, st["id"]),
|
||||
"unmet": [] if is_complete(run, st["id"]) else unmet(run, st["id"]),
|
||||
})
|
||||
walk(st.get("stages", []), depth + 1)
|
||||
|
||||
walk(fl.data["stages"], 0)
|
||||
dps: dict[str, int] = {}
|
||||
for v in run.data["datapoints"].values():
|
||||
dps[v["state"]] = dps.get(v["state"], 0) + 1
|
||||
items = [i for i in fl.order]
|
||||
done = sum(1 for i in items if is_complete(run, i) and not (fl.kind(i) == "step" and run.data["steps"][i]["state"] == "pushed")
|
||||
and not (fl.kind(i) == "gate" and run.data["gates"][i]["state"] == "pushed"))
|
||||
pushed = sum(1 for i in items if (fl.kind(i) == "step" and run.data["steps"][i]["state"] == "pushed")
|
||||
or (fl.kind(i) == "gate" and run.data["gates"][i]["state"] == "pushed"))
|
||||
return {"stages": stages, "datapoints": dps, "deviations": list(run.data["deviations"]),
|
||||
"progress": {"done": done, "pushed": pushed, "total": len(items)}, "mode": run.data["mode"],
|
||||
"mode_history": run.data.get("mode_history", []), "stopped": run.data["auto_confirm"].get("stopped")}
|
||||
|
||||
|
||||
def dimension_keys(run: Run) -> list[str]:
|
||||
keys: set[str] = set()
|
||||
for d in flow(run).datapoints.values():
|
||||
keys |= set((d.get("dimensions") or {}).keys())
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
def datapoint_rows(run: Run, group: str | None = None, flt: str | None = None) -> dict[str, list[dict]]:
|
||||
"""Datapoints with their state, optionally filtered by 'dimension:value' and grouped by a dimension.
|
||||
|
||||
Returns {group label: [rows]}; without grouping there is one group, ''.
|
||||
"""
|
||||
fl = flow(run)
|
||||
fkey, _, fval = (flt or "").partition(":")
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for d in fl.datapoints.values():
|
||||
dims = d.get("dimensions") or {}
|
||||
if flt and dims.get(fkey) != fval:
|
||||
continue
|
||||
rec = run.data["datapoints"][d["id"]]
|
||||
row = {"id": d["id"], "label": d["label"], "type": d["type"], "values": d.get("values"), "state": rec["state"],
|
||||
"value": rec.get("value"), "how": rec.get("how"), "error": rec.get("error"), "dimensions": dims,
|
||||
"ask_in": d.get("ask_in"), "produced_by": d.get("produced_by"), "confirmed_by": d.get("confirmed_by"),
|
||||
"editable": not d.get("produced_by")}
|
||||
groups.setdefault(dims.get(group, "(none)") if group else "", []).append(row)
|
||||
return dict(sorted(groups.items()))
|
||||
|
||||
|
||||
def artefact_rows(run: Run) -> list[dict]:
|
||||
"""What the run produced or will produce, and where it lives (a reference, never a secret)."""
|
||||
fl = flow(run)
|
||||
out = []
|
||||
for a in run.pack.data.get("artefacts", []):
|
||||
dp = run.data["datapoints"].get(a["location"]["datapoint"], {})
|
||||
base = dp.get("value") if dp.get("state") in ("set", "ok") else None
|
||||
loc = (base.rstrip("/") + "/" + a["location"]["path"]) if base and a["location"].get("path") else base
|
||||
out.append({"id": a["id"], "title": a["title"], "location": loc, "produced_by": a["produced_by"],
|
||||
"produced_state": run.data["steps"][a["produced_by"]]["state"] if a["produced_by"] in run.data["steps"] else "?",
|
||||
"lives_until": a.get("lives_until")})
|
||||
return out
|
||||
|
|
|
|||
93
rtut/ui.py
93
rtut/ui.py
|
|
@ -12,16 +12,66 @@ from urllib.parse import parse_qs, urlparse
|
|||
|
||||
from . import pack as P, run as R
|
||||
from . import ui_projects as UP
|
||||
from . import ui_v2 as UI2
|
||||
from .attrs import Registry
|
||||
|
||||
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}table{border-collapse:collapse;width:100%;margin:.5rem 0}td,th{border-bottom:1px solid #8884;padding:.3rem .5rem;text-align:left;vertical-align:top}"""
|
||||
CSS = """
|
||||
:root{--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--radius:6px}
|
||||
@media(prefers-color-scheme:dark){:root{--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72}}
|
||||
*{box-sizing:border-box}
|
||||
body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:56rem;margin:2rem auto;padding:0 1.2rem;color:var(--fg);background:var(--bg)}
|
||||
h1{font-size:1.6rem;font-weight:650;letter-spacing:-.01em;margin:.4rem 0 .6rem}
|
||||
h2{font-size:1.1rem;font-weight:650;margin:1.8rem 0 .5rem;padding-bottom:.25rem;border-bottom:1px solid var(--line)}
|
||||
h3{font-size:.95rem;margin:1.2rem 0 .3rem}
|
||||
a{color:inherit;text-underline-offset:.15em}a:hover{background:var(--hi);color:#141414}
|
||||
hr{border:0;border-top:1px solid var(--line)}
|
||||
code,pre{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
pre{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:.6rem .8rem;overflow:auto;margin:.5rem 0}
|
||||
.eyebrow{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin:.2rem 0}
|
||||
.note{color:var(--mut)}.bad{color:var(--bad);font-weight:600}
|
||||
progress{height:.45rem;border:0;border-radius:99px;overflow:hidden;background:var(--line);accent-color:var(--fg)}
|
||||
progress::-webkit-progress-bar{background:var(--line)}progress::-webkit-progress-value{background:var(--fg)}
|
||||
button,input,select,textarea{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:.3rem .6rem}
|
||||
button{cursor:pointer;background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
button:hover{background:var(--hi);color:#141414;border-color:var(--hi)}
|
||||
button[name=ok][value="0"],button[data-fail],button[type=button]{background:var(--card);color:var(--fg);border-color:var(--line)}
|
||||
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:2px solid var(--hi);outline-offset:2px}
|
||||
label{display:inline-block}fieldset{border:1px solid var(--line);border-radius:var(--radius);margin:.8rem 0;padding:.4rem .9rem}
|
||||
table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card);border:1px solid var(--line);border-radius:var(--radius)}
|
||||
td,th{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}th{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
|
||||
tr:last-child td{border-bottom:0}
|
||||
table.data td:first-child{width:42%}
|
||||
td label{display:block}
|
||||
td input:not([type=checkbox]):not([type=hidden]):not([type=radio]){width:100%}
|
||||
.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}
|
||||
summary{cursor:pointer}.head{display:inline-flex;width:calc(100% - 1.6rem);justify-content:space-between;align-items:baseline;gap:1rem;vertical-align:top}
|
||||
.step,.stage,.panel,.banner,.questions,details.framing,details.override{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:.7rem 1rem;margin:.7rem 0}
|
||||
.stage{padding:.8rem 1.1rem;background:transparent}.stage>summary{font-size:1.02rem}
|
||||
.stage .step,.stage .questions,.stage .stage{background:var(--card)}
|
||||
.step.running{border-color:var(--fg);box-shadow:0 0 0 1px var(--fg)}.step.failed{border-color:var(--bad)}.step.done,.step.pushed{opacity:.9}
|
||||
.step.gate{border-style:dashed}.step.gate.passed{border-style:solid}
|
||||
details.framing{background:transparent;border-left:3px solid var(--warn)}details.override{background:transparent;border-style:dashed;margin:.5rem 0}
|
||||
.panel{background:transparent}.panel[data-s=auto]{border-color:var(--fg)}
|
||||
.banner{background:var(--hi);color:#141414;border-color:var(--hi)}.banner a{text-decoration-thickness:2px}.banner .note{color:#3d3a10}
|
||||
.banner .chip{background:rgba(255,255,255,.55);color:#141414;border-color:rgba(0,0,0,.2)}
|
||||
.questions{background:transparent;border-left:3px solid var(--fg)}
|
||||
.tabs{display:flex;gap:.2rem;margin:1rem 0;border-bottom:1px solid var(--line)}
|
||||
.tab{padding:.4rem .9rem;text-decoration:none;border:1px solid transparent;border-bottom:0;border-radius:var(--radius) var(--radius) 0 0;color:var(--mut)}
|
||||
.tab.on{color:var(--fg);border-color:var(--line);background:var(--card);font-weight:650;margin-bottom:-1px}
|
||||
.chip{display:inline-block;font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:99px;padding:0 .55rem;margin:.1rem .1rem;text-decoration:none;color:var(--mut)}
|
||||
a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
.chip[data-s=irreversible]{color:var(--bad);border-color:var(--bad)}
|
||||
.pill{font:11.5px ui-monospace,monospace;border:1px solid var(--line);border-radius:99px;padding:.05rem .55rem;white-space:nowrap;color:var(--mut);background:var(--card)}
|
||||
.pill[data-s=done],.pill[data-s=passed],.pill[data-s=ok],.pill[data-s=complete],.pill[data-s=succeeded],.pill[data-s=applied]{color:var(--ok);border-color:var(--ok)}
|
||||
.pill[data-s=running],.pill[data-s="in progress"],.pill[data-s=applying],.pill[data-s=auto],.pill[data-s=auto-confirm]{background:var(--hi);color:#141414;border-color:var(--hi)}
|
||||
.pill[data-s=failed],.pill[data-s=err],.pill[data-s=invalid]{color:var(--bad);border-color:var(--bad)}
|
||||
.pill[data-s=pushed],.pill[data-s=waived],.pill[data-s=stale],.pill[data-s=override],.pill[data-s=push-through],.pill[data-s=waiver],.pill[data-s=modified],.pill[data-s=abandoned]{color:var(--warn);border-color:var(--warn)}
|
||||
.pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed}
|
||||
.checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0}
|
||||
#current{scroll-margin-top:1rem}details>summary{list-style-position:outside}
|
||||
@media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}}
|
||||
@media print{button,.tabs,form{display:none}.step,.stage{break-inside:avoid}}
|
||||
"""
|
||||
|
||||
|
||||
def page(title: str, body: str) -> bytes:
|
||||
|
|
@ -74,9 +124,6 @@ class App:
|
|||
d = p.data
|
||||
if not p.ok:
|
||||
rows.append(f"<li class=bad>{e(p.id)}: invalid ({e(p.errors[0])})</li>")
|
||||
elif d.get("spec") != "runbook-pack/v0.1" and d["engine"] == "native":
|
||||
rows.append(f"<li>{e(d['title'])} <span class=pill>{e(d.get('spec'))}</span> "
|
||||
"<span class=note>cannot be run yet (RBT-WP-0007)</span></li>")
|
||||
elif d["engine"] != "native":
|
||||
rows.append(f"<li>{e(d['title'])} <span class=pill>legacy console</span> "
|
||||
f"<span class=note>start with: python -m rtut launch {e(p.path)}</span></li>")
|
||||
|
|
@ -87,6 +134,12 @@ class App:
|
|||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
p = self.packs[i]; d = p.data
|
||||
if p.spec == "runbook-pack/v0.2":
|
||||
framing = "".join(f"<li>{e(x)}</li>" for x in d.get("framing", []))
|
||||
return page(d["title"], f"<h1>{e(d['title'])}</h1><p>{UP.pill('v0.2')} {UP.maturity_pill(p)}</p><p>{e(d.get('outcome', ''))}</p>"
|
||||
f"<p class=note>{e(score_line(R.scorecard(p)))}</p>"
|
||||
f"<details class=framing open><summary><b>Keep in mind</b></summary><ul>{framing}</ul></details>"
|
||||
f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}<h2>Start a run</h2>{UI2.start_form(self, i, p)}")
|
||||
prereq = "".join(f"<li>[{e(x['owner'])}] {e(x['text'])}</li>" for x in d.get("prerequisites", []))
|
||||
fields = "".join(
|
||||
f"<p><label>{e(x['label'])}<br>"
|
||||
|
|
@ -179,11 +232,10 @@ class App:
|
|||
f"<details><summary class=note>Change my feedback</summary>{form}</details></div>")
|
||||
return f"<div id=feedback class=step><h2>How was this runbook?</h2>{form}</div>"
|
||||
|
||||
def run_page(self, i: int, rid: str, msg: str = "") -> bytes:
|
||||
def run_page(self, i: int, rid: str, msg: str = "", query: dict | None = None) -> bytes:
|
||||
p = self.packs[i]; r = R.load(p, rid); d = p.data
|
||||
if p.spec != "runbook-pack/v0.1":
|
||||
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=bad>This run uses {e(p.spec)}. The UI cannot walk "
|
||||
"v0.2 runs yet (RBT-WP-0004); the engine and CLI can.</p><p><a href='/'>← Back</a></p>")
|
||||
if p.spec == "runbook-pack/v0.2":
|
||||
return UI2.run_page(self, i, rid, msg, query)
|
||||
ctx = r.data.get("context") or {}
|
||||
where = ""
|
||||
if ctx.get("project"):
|
||||
|
|
@ -259,7 +311,7 @@ class App:
|
|||
out.append(f"<details{anchor} class='step {state}'{fold}><summary><span class=head>"
|
||||
f"<span><b>{n}. {e(s['title'])}</b> "
|
||||
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}</span></span>"
|
||||
f"<span class=pill>{state}</span></span></summary>{block}"
|
||||
f"<span class=pill data-s='{state}'>{state}</span></span></summary>{block}"
|
||||
f"{self.capture_html(url, s, st, state, finished)}"
|
||||
f"<p class=note>Done when: {e(s['verify']['done_when'])}</p>{rb}{act}</details>")
|
||||
if r.complete:
|
||||
|
|
@ -296,7 +348,7 @@ def make_handler(app: App, port_holder: list):
|
|||
if parts[0] == "pack":
|
||||
return self._send(app.pack_page(int(parts[1])))
|
||||
if parts[0] == "run":
|
||||
return self._send(app.run_page(int(parts[1]), parts[2]))
|
||||
return self._send(app.run_page(int(parts[1]), parts[2], query=parse_qs(urlparse(self.path).query)))
|
||||
if parts[0] == "project" and len(parts) == 2:
|
||||
body = UP.project_page(app, parts[1])
|
||||
if body:
|
||||
|
|
@ -319,6 +371,8 @@ def make_handler(app: App, port_holder: list):
|
|||
parts = [x for x in urlparse(self.path).path.split("/") if x]
|
||||
msg = ""
|
||||
try:
|
||||
if parts[0] == "run" and len(parts) >= 4 and parts[1].isdigit() and app.packs[int(parts[1])].spec == "runbook-pack/v0.2":
|
||||
return self._redirect(UI2.handle_post(app, int(parts[1]), parts[2], parts[3:], form))
|
||||
if len(parts) == 3 and parts[0] == "pack" and parts[2] == "review":
|
||||
from . import maturity as M
|
||||
i = int(parts[1])
|
||||
|
|
@ -333,7 +387,7 @@ def make_handler(app: App, port_holder: list):
|
|||
if parts[2] == "attributes":
|
||||
J.set_attributes(reg, doc, {k[2:]: v for k, v in form.items() if k.startswith("a_")})
|
||||
return self._redirect(f"/project/{parts[1]}")
|
||||
r = J.start_run(reg, doc, form.get("pack", ""), form.get("operator", ""), form.get("target") or None)
|
||||
r = J.start_run(reg, doc, form.get("pack", ""), form.get("operator", ""), form.get("target") or None, form.get("mode", "manual"))
|
||||
i = app.pack_index(form.get("pack", ""))
|
||||
return self._redirect(f"/run/{i}/{r.data['run_id']}#current")
|
||||
if len(parts) == 3 and parts[0] == "template" and parts[2] == "new":
|
||||
|
|
@ -341,6 +395,9 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._redirect(f"/project/{pid}")
|
||||
if parts[:2] == ["run", "new"]:
|
||||
i = int(parts[2]); p = app.packs[i]
|
||||
if p.spec == "runbook-pack/v0.2":
|
||||
r = UI2.create_run(app, i, form)
|
||||
return self._redirect(f"/run/{i}/{r.data['run_id']}#current")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ def e(s) -> str:
|
|||
|
||||
|
||||
def pill(text: str) -> str:
|
||||
return f"<span class=pill>{e(text)}</span>"
|
||||
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
|
||||
|
||||
|
||||
def _runnable_here(pack) -> bool:
|
||||
return pack.ok and pack.data.get("engine") == "native" and pack.spec == "runbook-pack/v0.1"
|
||||
return pack.ok and pack.data.get("engine") == "native"
|
||||
|
||||
|
||||
# ---- maturity ------------------------------------------------------------------------------------------------
|
||||
|
|
@ -145,9 +145,11 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
|||
elif wait:
|
||||
action = f"<span class=note>waiting for {e(', '.join(wait))}</span>"
|
||||
else:
|
||||
mode = ("<select name=mode><option>manual</option><option>auto-confirm</option></select> "
|
||||
if pack.spec == "runbook-pack/v0.2" else "")
|
||||
action = (f"<form method=post action='/project/{e(pid)}/run' class=row>{app.hidden()}"
|
||||
f"<input type=hidden name=pack value='{e(spec['pack'])}'><input type=hidden name=target value='{e(spec.get('target') or '')}'>"
|
||||
"<input name=operator placeholder='your name' size=10 required> <button>Start run</button></form>")
|
||||
f"<input name=operator placeholder='your name' size=10 required> {mode}<button>Start run</button></form>")
|
||||
link = f" <a href='/run/{i}/{e(ms['run'])}'>latest run</a>" if ms.get("run") and i is not None else ""
|
||||
dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else ""
|
||||
rows += (f"<tr><td>{e(pack.data.get('title', pack.id))}<br><span class=note>{e(spec['pack'])}"
|
||||
|
|
|
|||
634
rtut/ui_v2.py
Normal file
634
rtut/ui_v2.py
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
"""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 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}<p><label>Your name (one word)<br><input name=operator required></label></p><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=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))
|
||||
|
||||
|
||||
# ---- 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):
|
||||
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 = []
|
||||
for d in dps:
|
||||
rec = c.run.data["datapoints"][d["id"]]
|
||||
rows.append(f"<p>{_input(d, rec)}<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)}<button>Save answers</button></form></div>")
|
||||
|
||||
|
||||
def _input(d: dict, rec: dict, editable: bool = True) -> 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 ""
|
||||
return f"<label>{e(d['label'])} {sp(rec['state'])}<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>")
|
||||
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
|
||||
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"]) 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("<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>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>{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)
|
||||
|
||||
|
||||
# ---- 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" - {e(run.data['outcome_note'])}" if run.data.get("outcome_note") else ""
|
||||
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2><p>{note}</p>"
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision. "
|
||||
"<a href='/'>← Back to the runbook list</a></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"<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", ""))
|
||||
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 = []
|
||||
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
|
||||
if R.set_datapoint(run, dp, v) == "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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue