v0.2 walk-through UI: guided flow, data view, overview, modes, overrides, typed phrase; retry confirmation; visual pass
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Work Records / validate (push) Has been cancelled

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:
tegwick 2026-09-29 14:42:09 +02:00
parent f8ce4717a8
commit f070999aa2
12 changed files with 1285 additions and 53 deletions

View file

@ -18,7 +18,7 @@ python3 -m rtut launch <pack.yaml> # legacy-command packs: start the exis
python3 -m rtut maturity <pack.yaml> # draft | reviewed | rehearsed | proven | stale, from run history
python3 -m rtut review <pack.yaml> --by you # record a review of the current revision
python3 -m rtut project list|validate|status|attrs|new|run <repo-root> ... # projects, attribute sets, templates
python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks, runs
python3 -m rtut ui <repo-root> [<repo-root> ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks; v0.1 and v0.2 runs (guided flow, data view, overview)
python3 -m rtut run new|start|verify|done|finish <pack.yaml> ... # the same run record from the terminal
make test
```

View file

@ -115,3 +115,8 @@ state are derived from these (`docs/maturity-and-projects.md`). Run records of a
folder; a pack's runs are those whose `pack_id` matches.
Run timestamps have microsecond precision, so runs and feedback made within the same second still sort correctly.
- **Retry**: a failed step that is not declared `idempotent` (or is undeclared) is retried only with `acknowledge_partial`, the operator's
confirmation that the earlier attempt did not partly apply; it is recorded in `retries`.
- **Mode switch**: `switch_mode` hands a run over to auto-confirm or takes it over again; each change is recorded in `mode_history`.
- **Views for renderers**: `overview`, `datapoint_rows` (grouped or filtered by dimension), `dimension_keys` and `artefact_rows` give a UI
everything the flow, data and overview views show, without knowing the record layout.

View file

@ -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

View file

@ -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

View file

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

View file

@ -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
View 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 &nbsp;→&nbsp; 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('&', '&amp;')}'>{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")

View file

@ -343,3 +343,86 @@ def test_pushed_items_are_a_distinct_state_that_lets_the_run_continue_but_never_
R1.finalize(r, "succeeded")
R1.finalize(r, "failed", "pushed through to collect problems") # a clean-completion failure, finishable as failed
assert r.data["outcome"] == "failed"
# ---- retry policy, mode switch, views ------------------------------------------------------------------------------
def test_retry_of_a_non_idempotent_step_needs_the_partial_application_confirmation(tmp_path):
r = new(tmp_path)
for s in ("read-the-sign", "light-lamp", "read-the-sky"):
do_step(r, s)
pass_gate(r, "lamp-check")
do_step(r, "warn")
R.start(r, "write-logbook"); R.fail(r, "write-logbook", "disk full halfway") # non-idempotent: appends
with pytest.raises(R.RunError, match="did not partly apply"):
R.retry(r, "write-logbook")
R.retry(r, "write-logbook", acknowledge_partial=True)
assert r.data["steps"]["write-logbook"]["retries"][0]["acknowledged_partial"] is True
R.start(r, "read-the-sign") if False else None
r2 = new(tmp_path)
R.start(r2, "read-the-sign"); R.fail(r2, "read-the-sign", "x") # idempotent: free retry
R.retry(r2, "read-the-sign")
assert "retries" not in r2.data["steps"]["read-the-sign"]
def test_undeclared_idempotency_also_needs_the_confirmation(tmp_path):
def m(d):
step(d, "read-the-sign").pop("idempotent")
r = new(tmp_path, m)
R.start(r, "read-the-sign"); R.fail(r, "read-the-sign", "x")
with pytest.raises(R.RunError, match="not declared idempotent"):
R.retry(r, "read-the-sign")
def step(d, sid):
return next(s for st in d["stages"] for s in st.get("steps", []) if s["id"] == sid)
def test_switch_mode_records_history_and_clears_a_stop(tmp_path):
r = new(tmp_path, mode="auto-confirm")
R._stop(r, "x", "why")
assert r.data["auto_confirm"]["stopped"]
R.switch_mode(r, "manual")
assert r.data["mode"] == "manual" and r.data["mode_history"][0] == {**r.data["mode_history"][0], "from": "auto-confirm", "to": "manual"}
with pytest.raises(R.RunError, match="already in manual"):
R.switch_mode(r, "manual")
R.switch_mode(r, "auto-confirm")
assert "stopped" not in r.data["auto_confirm"] and len(r.data["mode_history"]) == 2
def test_overview_counts_progress_pushed_and_datapoint_states(tmp_path):
r = new(tmp_path)
ov = R.overview(r)
assert ov["progress"] == {"done": 0, "pushed": 0, "total": 8} and [s["id"] for s in ov["stages"]] == ["dusk", "night", "dawn"]
assert ov["datapoints"]["set"] == 5 and ov["datapoints"]["nil"] == 1
do_step(r, "light-lamp")
ov = R.overview(r)
assert ov["progress"]["done"] == 1 and ov["stages"][0]["steps_done"] == 1 and ov["datapoints"]["ok"] == 1
assert ov["stages"][1]["unmet"] and not ov["stages"][0]["complete"]
def test_datapoint_rows_group_and_filter_by_dimension(tmp_path):
r = new(tmp_path)
assert "actor" in R.dimension_keys(r) and "kind" in R.dimension_keys(r)
rows = R.datapoint_rows(r)
assert list(rows) == [""] and len(rows[""]) == 6
by_actor = R.datapoint_rows(r, group="actor")
assert set(by_actor) == {"keeper", "(none)"} and {x["id"] for x in by_actor["keeper"]} >= {"keeper_name", "lamp_fuel", "motto", "lamp_lit"}
only = R.datapoint_rows(r, flt="kind:identity")
assert {x["id"] for x in only[""]} == {"keeper_name", "motto"}
lamp = next(x for x in rows[""] if x["id"] == "lamp_lit")
assert lamp["editable"] is False and lamp["produced_by"] == "light-lamp"
def test_artefact_rows_resolve_location_from_a_datapoint(tmp_path):
r = new(tmp_path, values={"logdir": "/tmp/rtut-art-one"})
a = R.artefact_rows(r)[0]
assert a["id"] == "logbook-file" and a["location"] == "/tmp/rtut-art-one/logbook.txt" and a["produced_state"] == "todo"
def test_captures_and_pasted_verification_work_for_v02_runs(tmp_path):
r = new(tmp_path)
R.start(r, "read-the-sky")
R1.add_capture(r, "read-the-sky", "Weather tonight - fog.", "echo ...")
res = R1.verify_pasted(r, "read-the-sky", "Weather tonight - fog.")
assert res["passed"] and r.data["steps"]["read-the-sky"]["captures"][0]["output"].startswith("Weather")

View file

@ -87,7 +87,7 @@ def test_create_project_from_template_and_view_it(psrv):
assert ">planned<" in body and ">rehearsal<" in body
assert "target:north-light" in body # provenance of the fog weather
assert "Still needed" in body and "name='a_host'" in body # hello needs a host
assert "cannot be run in this UI yet" in body # the v0.2 member
assert "waiting for rtut.example-hello" in body # the v0.2 member waits for its predecessor
def test_needed_attributes_can_be_filled_in_and_are_validated(psrv):
@ -114,8 +114,8 @@ def test_start_a_run_from_a_project_uses_its_attributes_and_links_back(psrv):
_, _, body = req(psrv, "GET", "/project/harbour")
assert ">applying<" in body and "latest run" in body
st, loc2, _ = req(psrv, "POST", "/project/harbour/run", {"_t": t, "pack": "rtut.showcase-lighthouse-v2", "operator": "bernd"})
assert st == 303 # the engine accepts it; the UI just cannot walk v0.2 runs yet
assert "cannot walk v0.2 runs yet" in req(psrv, "GET", loc2.split("#")[0])[2]
assert st == 303 # a v0.2 member can be started too, in either mode
assert "Guided flow" in req(psrv, "GET", loc2.split("#")[0])[2]
def test_attribute_table_shows_state_source_modified_and_problems(psrv):
@ -172,27 +172,10 @@ def test_bad_project_id_and_duplicate_are_refused(psrv):
assert st == 400 and "environment" in body
def test_v02_run_shows_a_guard_instead_of_crashing(psrv):
def test_v02_run_is_walkable_in_the_ui(psrv):
reg = psrv.app.registry()
pack = reg.packs["rtut.showcase-lighthouse-v2"]
r = run2.create(pack, "bernd", {"logdir": "/tmp/rtut-ui-v2"})
i = psrv.app.pack_index("rtut.showcase-lighthouse-v2")
st, _, body = req(psrv, "GET", f"/run/{i}/{r.data['run_id']}")
assert st == 200 and "cannot walk v0.2 runs yet" in body
def test_front_page_lists_projects_by_their_own_name_and_shows_the_template_as_origin(psrv):
make_project(psrv)
_, _, body = req(psrv, "GET", "/")
projects = body.split("<h2>Project templates</h2>")[0]
assert "Harbour watch" in projects and "Our own harbour, two lights." in projects
assert "from template <a href='/template/lighthouse-watch'>lighthouse-watch</a>" in projects
assert "The night watch is played through" not in projects and "A watch at a lighthouse" not in projects
assert "A watch at a lighthouse" in body.split("<h2>Project templates</h2>")[1] # the template keeps its own listing
def test_creating_a_project_needs_a_name(psrv):
t = psrv.app.token
st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "nameless", "environment": "rehearsal", "title": "x"})
assert st == 400 and "name" in body
assert not (psrv.repo / "projects/nameless").exists()
assert st == 200 and "Guided flow" in body and "Keep in mind" in body

343
tests/test_ui_v2.py Normal file
View file

@ -0,0 +1,343 @@
import http.client
import shutil
import subprocess
import threading
import uuid
from pathlib import Path
from urllib.parse import urlencode
import pytest
from rtut import run as R1, run2 as R, ui
ROOT = Path(__file__).resolve().parent.parent
PHRASE = "I understand this cannot be undone"
_scratch = []
@pytest.fixture
def s2(tmp_path):
d = tmp_path / "runbooks" / "lh"; d.mkdir(parents=True)
shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.v0.2.yaml", d / "pack.yaml")
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
yield s
s.shutdown()
while _scratch:
shutil.rmtree(_scratch.pop(), ignore_errors=True)
def req(s, method, path, form=None, host="127.0.0.1"):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
body = urlencode(form) if form is not None else None
c.request(method, path, body, {"Host": host, "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode()
def post(s, path, **form):
return req(s, "POST", path, {"_t": s.app.token, **form})
def new_run(s, mode="manual", **extra):
logdir = f"/tmp/rtut-uv2-{uuid.uuid4().hex[:8]}"
_scratch.append(logdir)
st, loc, _ = post(s, "/run/new/0", operator="bernd", mode=mode, p_logdir=logdir, **extra)
assert st == 303, loc
return loc.split("#")[0], logdir
def page(s, base, query=""):
st, _, body = req(s, "GET", base + query)
assert st == 200
return body
def do_step(s, base, sid, alt=None, phrase=None, run_cmd=True, cmd_of=None):
"""What an operator does through the UI: start, do the work, check, mark done."""
form = {}
if alt: form["alt"] = alt
if phrase: form["phrase"] = phrase
assert post(s, f"{base}/{sid}/start", **form)[0] == 303
run = R1.load(s.app.packs[0], base.rsplit("/", 1)[1])
cmd = R.command_for(run, sid)
if cmd and run_cmd:
subprocess.run(cmd, shell=True, check=True, capture_output=True)
eff = R.step_def(run, sid)
manual = (eff["verify"].get("expect") or "manual") == "manual" and not eff["verify"].get("datapoints_ok")
assert (post(s, f"{base}/{sid}/verify", ok="1") if manual else post(s, f"{base}/{sid}/verify", yes="1"))[0] == 303
assert post(s, f"{base}/{sid}/done")[0] == 303
def pass_gate(s, base, gid):
run = R1.load(s.app.packs[0], base.rsplit("/", 1)[1])
for i, ck in enumerate(R.flow(run).gates[gid]["checks"]):
exp = ck.get("expect") or (None if ck.get("datapoints_ok") else "manual")
assert (post(s, f"{base}/gate/{gid}/check", idx=str(i), ok="1") if exp == "manual" else post(s, f"{base}/gate/{gid}/check", idx=str(i), yes="1"))[0] == 303
# ---- starting -----------------------------------------------------------------------------------------------
def test_v02_pack_is_listed_and_has_a_start_form_with_modes_and_preauthorization(s2):
_, _, body = req(s2, "GET", "/")
assert "/pack/0" in body and "cannot be run yet" not in body
body = page(s2, "/pack/0")
assert "Keep in mind" in body and "Start a run" in body
assert "name='p_keeper_name'" in body and "<span class=chip>actor: keeper</span>" in body # datapoints with their dimension tags
assert "name=mode value=manual checked" in body and "name=mode value=auto-confirm" in body
assert "Pre-authorize irreversible steps" in body and "name='pre_close-log'" in body and "manual-only" in body
assert "name='p_lamp_lit'" not in body # produced datapoints are not questions
def test_flow_view_structure_framing_first_next_action_and_parallel_items(s2):
base, _ = new_run(s2)
body = page(s2, base)
assert "Guided flow" in body and "Data" in body and "Overview" in body
assert body.index("Keep in mind") < body.index("Dusk - prepare") # framing comes first, not last
assert "Manual mode." in body and "Hand over to auto-confirm" in body
assert "<b>Next:</b> <a href='#item-read-the-sign'>Read the poem over the door</a>" in body and "(recommended)" in body
assert "Also possible in parallel" in body and "#item-light-lamp" in body and "#item-read-the-sky" in body
assert "<b>Goal:</b> The lamp is lit" in body
assert "Questions for this stage" in body and "name='d_keeper_name'" in body
assert "Gate: The lamp burns steadily" in body and "quality goal: safe-navigation" in body and "blocking" in body
assert "1. Read the poem over the door" in body and "[Lighthouse keeper · the-lighthouse]" in body
assert "<button type=button data-copy>" not in body # nothing started: no command, no Copy
def test_blocked_step_says_why_and_offers_an_override_dialog_with_the_downstream_risk(s2):
base, _ = new_run(s2)
body = page(s2, base)
assert "Waiting for: gate lamp-check is not complete" in body
assert "Start anyway (override)" in body and "Downstream risk:" in body and "name=override_reason" in body and "name=acknowledge_risk" in body
def test_starting_and_running_a_parallel_step_shows_command_and_copy_only_then(s2):
base, _ = new_run(s2, p_weather="fog")
assert post(s2, f"{base}/light-lamp/start")[0] == 303
body = page(s2, base)
assert "<b>Continue:</b> <a href='#item-light-lamp'>Light the lamp</a>" in body
assert "<pre>echo &quot;Ada lights the paraffin lamp.&quot;</pre><button type=button data-copy>" in body
assert "Yes, it is" in body and "Mark failed" in body and "id=current" in body
# ---- alternatives, irreversible steps, overrides, gates ------------------------------------------------------------
def open_night(s, base):
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
do_step(s, base, sid)
pass_gate(s, base, "lamp-check")
def test_alternatives_are_recommended_by_datapoint_and_can_be_overridden_by_the_operator(s2):
base, _ = new_run(s2, p_weather="fog")
open_night(s2, base)
body = page(s2, base)
assert "<b>Choose one:</b>" in body and "value='sound-horn' checked" in body and "Sound the fog horn <span class=note>(recommended)</span>" in body
assert post(s2, f"{base}/warn/start", alt="wave-flags")[0] == 303
body = page(s2, base)
assert "Signal with flags" in body and "signals with flags" in body
def test_irreversible_step_needs_the_typed_phrase_and_shows_it(s2):
base, _ = new_run(s2, p_weather="fog")
open_night(s2, base)
do_step(s2, base, "warn"); do_step(s2, base, "write-logbook")
body = page(s2, base)
assert "irreversible" in body and f"<code>{PHRASE}</code>" in body and "name=phrase" in body
st, _, err = post(s2, f"{base}/close-log/start")
assert st == 400 and "confirmation phrase" in err
assert post(s2, f"{base}/close-log/start", phrase="yes please")[0] == 400
assert post(s2, f"{base}/close-log/start", phrase=PHRASE)[0] == 303
assert "Continue:" in page(s2, base)
def test_override_needs_reason_and_acknowledgement_and_is_recorded_and_shown(s2):
base, _ = new_run(s2)
st, _, err = post(s2, f"{base}/warn/start", alt="sound-horn")
assert st == 400 and "Risk:" in err
st, _, err = post(s2, f"{base}/warn/start", alt="sound-horn", override_reason="just to see")
assert st == 400 and "acknowledge" in err
assert post(s2, f"{base}/warn/start", alt="sound-horn", override_reason="just to see", acknowledge_risk="1")[0] == 303
body = page(s2, base)
assert "1 deviation(s) recorded" in body and "will never count towards maturity" in body
ov = page(s2, base, "?view=overview")
assert "<h2>Deviations</h2>" in ov and ">override<" in ov and "just to see" in ov and "warn" in ov
def test_gate_checks_waiver_and_state_through_the_ui(s2):
base, _ = new_run(s2)
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
do_step(s2, base, sid)
body = page(s2, base)
assert "Yes, it is" in body and "Not yet" in body and "datapoints ok: lamp_lit" in body
assert post(s2, f"{base}/gate/lamp-check/check", idx="0", ok="0")[0] == 303
assert post(s2, f"{base}/gate/lamp-check/check", idx="1", yes="1")[0] == 303
body = page(s2, base)
assert "Waive this gate (declared waivable by Harbour master)" in body and "Ships may steer by a light that is not burning." in body
st, _, err = post(s2, f"{base}/gate/lamp-check/waive", reason=" ")
assert st == 400 and "reason" in err
assert post(s2, f"{base}/gate/lamp-check/waive", reason="harbour master accepts")[0] == 303
body = page(s2, base)
assert "Waived: harbour master accepts" in body and "1 deviation(s)" in body
assert "Start this step" in body # the night can begin after a waiver
def test_a_gate_that_is_not_waivable_offers_no_waiver(s2):
base, _ = new_run(s2)
assert "Waive this gate" in page(s2, base).split("Gate: Nothing left behind")[0] # lamp-check is waivable
assert "Waive this gate" not in page(s2, base).split("Gate: Nothing left behind")[1] # night-on-record is not
assert post(s2, f"{base}/gate/night-on-record/waive", reason="x")[0] == 400
# ---- retry policy -----------------------------------------------------------------------------------------------------
def test_retry_of_non_idempotent_step_asks_for_the_partial_application_confirmation(s2):
base, _ = new_run(s2, p_weather="fog")
open_night(s2, base)
do_step(s2, base, "warn")
assert post(s2, f"{base}/write-logbook/start")[0] == 303
assert post(s2, f"{base}/write-logbook/fail", note="disk full")[0] == 303
body = page(s2, base)
assert "name=acknowledge_partial" in body and "did not partly apply" in body
st, _, err = post(s2, f"{base}/write-logbook/retry")
assert st == 400 and "did not partly apply" in err
assert post(s2, f"{base}/write-logbook/retry", acknowledge_partial="1")[0] == 303
# an idempotent step needs no confirmation
base2, _ = new_run(s2)
post(s2, f"{base2}/read-the-sign/start"); post(s2, f"{base2}/read-the-sign/fail", note="")
body2 = page(s2, base2)
assert "Failed: (no comment)" in body2 and "name=acknowledge_partial" not in body2 # idempotent: free retry
assert post(s2, f"{base2}/read-the-sign/retry")[0] == 303
# ---- data view --------------------------------------------------------------------------------------------------------
def test_data_view_groups_filters_and_edits_datapoints_independently_of_the_flow(s2):
base, logdir = new_run(s2)
body = page(s2, base, "?view=data")
assert "independent of the flow" in body and "<b>Group by</b>" in body
assert "href='?view=data&amp;group=actor'" in body
assert "Scratch folder" in body and "actor: keeper" in body
grouped = page(s2, base, "?view=data&group=actor")
assert "<h3>actor: keeper</h3>" in grouped and "<h3>actor: (none)</h3>" in grouped
only = page(s2, base, "?view=data&filter=kind:identity")
assert "Keeper&#x27;s name" in only and "Motto" in only and "Scratch folder" not in only and "clear filter" in only
assert "Where it lives" in body and f"{logdir}/logbook.txt" in body # artefact location resolved
assert "<h2>Actors</h2>" in body and "Harbour master" in body and "approve-custody" not in body
# editing here needs no step to be running
st, loc, _ = post(s2, f"{base}/data", d_weather="storm", next=f"{base}?view=data")
assert st == 303 and loc == f"{base}?view=data"
assert "selected>storm" in page(s2, base, "?view=data")
st, _, err = post(s2, f"{base}/data", d_weather="hail")
assert st == 400 and "weather" in err
st, loc, _ = post(s2, f"{base}/data", d_motto="Hold fast", next="https://evil.example/x")
assert st == 303 and loc == base # an unsafe next is ignored
st, _, err = post(s2, f"{base}/data", d_motto="<script>alert(1)</script>")
assert st == 400 and "<script>alert(1)" not in err
def test_produced_datapoints_are_shown_not_editable_and_state_changes_are_visible(s2):
base, _ = new_run(s2)
body = page(s2, base, "?view=data")
assert "name='d_lamp_lit'" not in body and ">nil<" in body and "produced by light-lamp" in body
do_step(s2, base, "light-lamp")
body = page(s2, base, "?view=data")
assert ">ok<" in body and "confirmed by light-lamp" in body
# ---- overview -----------------------------------------------------------------------------------------------------------
def test_overview_shows_progress_stages_gates_datapoints_and_no_deviations(s2):
base, _ = new_run(s2)
body = page(s2, base, "?view=overview")
assert "0 of 8 done" in page(s2, base) and "<h2>Stages</h2>" in body
assert "Dusk - prepare" in body and "Night watch - give warning" in body and "Dawn - close the night" in body
assert "0/3" in body and "<h2>Datapoints</h2>" in body and ">set<" in body and ">nil<" in body
assert "None. A run without deviations can count towards maturity." in body
do_step(s2, base, "light-lamp")
assert "1 of 8 done" in page(s2, base)
# ---- auto-confirm -----------------------------------------------------------------------------------------------------
def test_auto_confirm_mode_confirms_waits_and_can_be_taken_over(s2):
base, _ = new_run(s2, mode="auto-confirm", p_weather="fog")
body = page(s2, base)
assert "Auto-confirm mode." in body and "never runs commands" in body and "Run until it waits" in body and "Take over (manual)" in body
assert "Overrides are made in manual mode" in body # blocked items cannot be overridden here
assert "Start anyway" not in body
assert post(s2, f"{base}/tick", n="50")[0] == 303
body = page(s2, base)
assert "Last: waiting write-logbook" in body or "Last: awaiting-operator" in body
run = R1.load(s2.app.packs[0], base.rsplit("/", 1)[1])
assert run.data["steps"]["read-the-sign"]["state"] == "done" and run.data["steps"]["warn"]["state"] == "done"
assert run.data["steps"]["read-the-sign"]["verification"].get("auto_confirmed")
assert post(s2, f"{base}/mode", to="manual")[0] == 303
body = page(s2, base)
assert "Manual mode." in body and "Hand over to auto-confirm" in body
assert "auto-confirm → manual" in page(s2, base, "?view=overview")
assert post(s2, f"{base}/mode", to="manual")[0] == 400 # already manual
def test_auto_confirm_stop_is_shown_and_never_confirms_manual_only_steps(s2):
base, logdir = new_run(s2, mode="auto-confirm", p_weather="fog")
post(s2, f"{base}/tick", n="50") # the engine confirms what it can, then waits for the work
# the operator does the work between ticks
run = R1.load(s2.app.packs[0], base.rsplit("/", 1)[1])
subprocess.run(R.command_for(run, "write-logbook"), shell=True, check=True, capture_output=True)
assert post(s2, f"{base}/tick", n="50")[0] == 303
body = page(s2, base)
assert "Last: awaiting-operator close-log" in body and "step is manual-only" in body
assert "Manual-only: auto-confirm will not start this step" in body
# ---- captures, feedback, closing ------------------------------------------------------------------------------------
def test_captures_and_pasted_output_verification_work_in_v02_runs(s2):
base, _ = new_run(s2, p_weather="fog")
post(s2, f"{base}/read-the-sky/start")
body = page(s2, base)
assert "Capture command and output" in body and "Check against this output" in body
assert post(s2, f"{base}/read-the-sky/capture", command="echo x", output="Weather tonight - fog.")[0] == 303
assert post(s2, f"{base}/read-the-sky/verify-pasted", output="something else")[0] == 303
assert "Check not passed" in page(s2, base)
assert post(s2, f"{base}/read-the-sky/verify-pasted", output="Weather tonight - fog.")[0] == 303
assert "Check passed" in page(s2, base)
st, _, err = post(s2, f"{base}/read-the-sky/capture", output="hvs.ABCDEFGHIJKLMN")
assert st == 400 and "secret-looking" in err
def test_finishing_a_completed_run_feedback_and_the_read_only_result(s2):
base, logdir = new_run(s2, p_weather="fog")
open_night(s2, base)
do_step(s2, base, "warn"); do_step(s2, base, "write-logbook")
do_step(s2, base, "close-log", phrase=PHRASE)
pass_gate(s2, base, "night-on-record")
body = page(s2, base)
assert "Everything is done." in body and "Finish: succeeded" in body and "How was this runbook?" in body
assert post(s2, f"{base}/feedback", rating="good", comment="clear")[0] == 303
assert "Back to the runbook list" in page(s2, base)
assert post(s2, f"{base}/finalize", outcome="succeeded", note="smooth")[0] == 303
body = page(s2, base)
assert "Run finished: succeeded" in body and "Start this step" not in body
assert post(s2, f"{base}/read-the-sign/start")[0] == 400
def test_abandoning_and_failed_outcome_rules(s2):
base, _ = new_run(s2)
body = page(s2, base)
assert "Abandon run" in body and "Finish: succeeded" not in body and "Finish: failed" not in body
assert post(s2, f"{base}/finalize", outcome="succeeded")[0] == 400
assert post(s2, f"{base}/finalize", outcome="abandoned")[0] == 303
assert "Run finished: abandoned" in page(s2, base)
# ---- safety --------------------------------------------------------------------------------------------------------------
def test_csrf_host_and_unknown_actions(s2):
base, _ = new_run(s2)
assert req(s2, "POST", f"{base}/mode", {"to": "manual"})[0] == 403
assert req(s2, "GET", base, host="evil.example")[0] == 403
assert post(s2, f"{base}/read-the-sign/explode")[0] == 400
assert post(s2, f"{base}/nope/start")[0] == 400
assert req(s2, "GET", base + "?view=<script>")[0] == 200 # an unknown view falls back to the flow

View file

@ -91,7 +91,7 @@ step instead of the top of the page.
```task
id: RBT-WP-0004-T06
status: todo
status: done
priority: high
state_hub_task_id: "261bf74f-f5cd-55bb-9a96-12767418c980"
```
@ -104,7 +104,7 @@ start of each stage, and gates shown at stage exit.
```task
id: RBT-WP-0004-T07
status: todo
status: done
priority: high
state_hub_task_id: "16db09c7-7707-5d17-aab3-5d8eab2c1fce"
```
@ -117,7 +117,7 @@ editable independently of the flow, plus the artefacts view.
```task
id: RBT-WP-0004-T08
status: todo
status: done
priority: high
state_hub_task_id: "23d3f5cc-e844-5077-9a4a-0435c189d98b"
```
@ -129,7 +129,7 @@ and waivers, datapoint completeness by dimension.
```task
id: RBT-WP-0004-T09
status: wait
status: done
priority: medium
state_hub_task_id: "53c74197-4747-5bdd-9b7c-8bdc42b7c1f5"
```
@ -142,7 +142,7 @@ in the pack spec.
```task
id: RBT-WP-0004-T10
status: wait
status: done
priority: medium
state_hub_task_id: "ddbca5e1-dcf6-5c2a-b3e2-497d2a82ba23"
```
@ -154,7 +154,7 @@ Confirm button for reversible steps, typed phrase for irreversible ones, per the
```task
id: RBT-WP-0004-T11
status: todo
status: done
priority: high
state_hub_task_id: "99320417-8261-556b-8932-4947cfd8ce11"
```
@ -195,6 +195,25 @@ change before writing it to `pack.yaml`; steps can capture the command run and i
Open: run histories are per machine (`.local/`), so a shared or committed history is still
to be decided.
## Delivered 2026-09-29: the v0.2 walk-through UI
`rtut/ui_v2.py` (tested over HTTP with 19 tests; rendering checked in headless Chromium screenshots of the flow, data and overview
views on a mid-run state). Three views of one run:
- **Guided flow** (main): framing shown first; a banner with the recommended next action and the other items possible in parallel;
nested stages with goals, per-stage questions (datapoints saved with validation), numbered steps with owner and actor, idempotency,
irreversible and manual-only marks; alternatives with the recommended one preselected; typed confirmation phrase for irreversible steps;
override dialog (reason, acknowledged downstream risk, recorded as a deviation) for blocked steps and gates in manual mode; gates with per-check
controls and waivers; retry that asks for the partial-application confirmation on non-idempotent steps; captures and pasted-output
checks; feedback and closing as before.
- **Data**: every datapoint editable independently of the flow, grouped or filtered by dimension (click a tag), with provenance,
plus artefacts (resolved locations) and actors.
- **Overview**: progress, per-stage state, gates, datapoint states, deviations, mode changes.
- **Modes**: manual, and auto-confirm with "check once", "run until it waits" and "take over"; a stop or push-through is shown.
Projects can start v0.2 members in either mode.
- **Visual design**: a restrained stylesheet (hairlines, state pills that never rely on colour alone, highlighter accent for
the recommended action, print rules). Dark theme is written but not verified in a browser.
## Acceptance Criteria
- Bernd can complete the SSH tutorial pack in the UI without pasting outputs to an agent.

View file

@ -99,12 +99,14 @@ Done 2026-09-29: `project.state()`: member status `not-applied | applying | appl
```task
id: RBT-WP-0008-T07
status: todo
status: done
priority: medium
state_hub_task_id: "8b34c57b-3380-5f7b-aaf5-6227a47dcf60"
```
Retrying a failed non-idempotent step requires the operator to confirm the earlier attempt did not partly apply.
Done 2026-09-29: `run2.retry(..., acknowledge_partial=True)` is required unless the step is declared `idempotent` (undeclared
counts as not idempotent); the UI asks with a checkbox; the acknowledgement is recorded on the step. v0.1 runs have no idempotency
declaration and keep their free retry.
## UI for projects, maturity and attributes