diff --git a/README.md b/README.md index 3a9ee25..6d0c37d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ python3 -m rtut launch # legacy-command packs: start the exis python3 -m rtut maturity # draft | reviewed | rehearsed | proven | stale, from run history python3 -m rtut review --by you # record a review of the current revision python3 -m rtut project list|validate|status|attrs|new|run ... # projects, attribute sets, templates -python3 -m rtut ui [ ...] # local web UI on 127.0.0.1:8877: projects, templates, runbooks, runs +python3 -m rtut ui [ ...] # 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 ... # the same run record from the terminal make test ``` diff --git a/docs/run-spec.md b/docs/run-spec.md index 404e3d2..1979669 100644 --- a/docs/run-spec.md +++ b/docs/run-spec.md @@ -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. diff --git a/rtut/run.py b/rtut/run.py index 057e69d..3e2e0eb 100644 --- a/rtut/run.py +++ b/rtut/run.py @@ -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 diff --git a/rtut/run2.py b/rtut/run2.py index 8e63fdd..54c12b5 100644 --- a/rtut/run2.py +++ b/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 diff --git a/rtut/ui.py b/rtut/ui.py index 4c62ace..63af077 100644 --- a/rtut/ui.py +++ b/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"
  • {e(p.id)}: invalid ({e(p.errors[0])})
  • ") - elif d.get("spec") != "runbook-pack/v0.1" and d["engine"] == "native": - rows.append(f"
  • {e(d['title'])} {e(d.get('spec'))} " - "cannot be run yet (RBT-WP-0007)
  • ") elif d["engine"] != "native": rows.append(f"
  • {e(d['title'])} legacy console " f"start with: python -m rtut launch {e(p.path)}
  • ") @@ -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"
  • {e(x)}
  • " for x in d.get("framing", [])) + return page(d["title"], f"

    {e(d['title'])}

    {UP.pill('v0.2')} {UP.maturity_pill(p)}

    {e(d.get('outcome', ''))}

    " + f"

    {e(score_line(R.scorecard(p)))}

    " + f"
    Keep in mind
      {framing}
    " + f"{UP.maturity_block(self, i, p)}{self.runs_list(i, p)}

    Start a run

    {UI2.start_form(self, i, p)}") prereq = "".join(f"
  • [{e(x['owner'])}] {e(x['text'])}
  • " for x in d.get("prerequisites", [])) fields = "".join( f"

    Change my feedback{form}
    ") return f"

    How was this runbook?

    {form}
    " - 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"

    {e(d['title'])}

    This run uses {e(p.spec)}. The UI cannot walk " - "v0.2 runs yet (RBT-WP-0004); the engine and CLI can.

    ← Back

    ") + 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"" f"{n}. {e(s['title'])} " f"[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}" - f"{state}{block}" + f"{state}{block}" f"{self.capture_html(url, s, st, state, finished)}" f"

    Done when: {e(s['verify']['done_when'])}

    {rb}{act}") 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") diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py index 344b3db..fa07fac 100644 --- a/rtut/ui_projects.py +++ b/rtut/ui_projects.py @@ -13,11 +13,11 @@ def e(s) -> str: def pill(text: str) -> str: - return f"{e(text)}" + return f"{e(text)}" 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"waiting for {e(', '.join(wait))}" else: + mode = (" " + if pack.spec == "runbook-pack/v0.2" else "") action = (f"
    {app.hidden()}" f"" - "
    ") + f" {mode}") link = f" latest run" if ms.get("run") and i is not None else "" dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else "" rows += (f"{e(pack.data.get('title', pack.id))}
    {e(spec['pack'])}" diff --git a/rtut/ui_v2.py b/rtut/ui_v2.py new file mode 100644 index 0000000..c632f63 --- /dev/null +++ b/rtut/ui_v2.py @@ -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"{e(state)}" + + +# ---- 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"" + elif d["type"] == "bool": + ctl = f"" + else: + ctl = f"" + dims = " ".join(f"{e(k)}: {e(v)}" for k, v in (d.get("dimensions") or {}).items()) + fields += f"

    {dims}
    {e(d.get('help', ''))} Never enter secrets.

    " + irrev = [s for s in fl.steps.values() if not s.get("reversible", True)] + pre = "" + if irrev: + pre = ("
    Pre-authorize irreversible steps (auto-confirm only)" + "

    An auto-confirm run never confirms an irreversible step by itself unless you name it here. Steps marked " + "manual-only are never automated.

    " + + "".join(f"
    " for s in irrev) + "
    ") + return (f"
    {app.hidden()}{fields}" + "
    Mode" + "
    " + "
    " + f"{pre}

    ") + + +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 {e(ctx['project'])}" + f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}") + ov = R.overview(run) + prog = ov["progress"] + out = [f"

    {e(pack.data['title'])}

    " + f"

    {sp(d['mode'])} {sp(d['outcome']) if d.get('outcome') else ''} " + f"run {e(rid)} · operator {e(d['operator'])}{where}

    " + f"

    {prog['done']} of {prog['total']} done" + f"{' · ' + str(prog['pushed']) + ' pushed through' if prog['pushed'] else ''}

    " + f""] + if d["deviations"]: + out.append(f"

    ⚑ {len(d['deviations'])} deviation(s) recorded: this run can be finished but will never count towards " + "maturity. See them.

    ") + if msg: + out.append(f"

    {e(msg)}

    ") + tabs = " ".join(f"{label}" + for v, label in (("flow", "Guided flow"), ("data", "Data"), ("overview", "Overview"))) + out.append(f"") + 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("
    Keep in mind
      " + "".join(f"
    • {e(x)}
    • " for x in framing) + "
    ") + 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"
    Manual mode. You confirm every check and gate. Unmet preconditions can be " + f"overridden with a reason; each override is recorded." + f"
    {c.h()}" + "
    ") + ap = run.data["auto_confirm"] + last = ap.get("last") + status = "" + if ap.get("stopped"): + status = f"

    Stopped at {e(ap['stopped']['item'])}: {e(ap['stopped']['reason'])}

    " + elif last: + status = f"

    Last: {e(last['action'])}{' ' + e(last['item']) if last.get('item') else ''} {e(last.get('detail', ''))}

    " + pre = ap.get("preauthorized") or [] + return (f"
    Auto-confirm mode. 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 ''}.{status}" + f"
    {c.h()}" + "
    " + f"
    {c.h()}" + "
    ") + + +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 "" + b = na["blocked"][0] if na["blocked"] else None + return f"" + kind = c.fl.kind(rec) + others = [x for x in na["ready"] + na["running"] if x != rec] + links = " ".join(f"{e(_title_of(c, x))}" for x in others) + verb = "Continue" if rec in na["running"] else ("Evaluate gate" if kind == "gate" else "Next") + return (f"") + + +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"

    Goal: {e(st['goal'])}

    "] + if st.get("framing"): + body.append("
      " + "".join(f"
    • {e(x)}
    • " for x in st["framing"]) + "
    ") + 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"
    " + f"{e(st['title'])} {n_done}/{len(members)} steps" + f"{sp('complete' if done else 'in progress')}{''.join(body)}
    ") + + +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"

    {_input(d, rec)}
    {e(d.get('help', ''))}

    ") + return (f"
    Questions for this stage (values used by its steps; never enter secrets)" + f"
    {c.h()}" + f"{''.join(rows)}
    ") + + +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"" + elif d["type"] == "bool": + ctl = (f"") + else: + ctl = f"" + err = f" {e(rec.get('error', ''))}" if rec["state"] == "err" else "" + return f"{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"{e(eff.get('idempotent') or s.get('idempotent'))}") + 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("irreversible") + if s.get("auto_confirm") == "manual-only": + marks.append("manual-only") + 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"

    {e(s['description'])}

    ") + 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 + " ") + body.append(f"

    Failed: {e(st.get('note') or '(no comment)')}

    " + f"
    {c.h()}

    {ack}

    ") + elif state == "pushed": + body.append("

    Passed over by push-through without verification: not done, and recorded as a deviation.

    ") + elif state == "done" and st.get("note"): + body.append(f"

    Comment: {e(st['note'])}

    ") + if state == "done" or state == "pushed": + cmd = _command(run, sid) + if cmd: + body.insert(0, f"
    {e(cmd)}
    ") + 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"
    " + f"{n}. {e(title)} [{e(actor)} · {e(s['owner'])}] {' '.join(marks)}" + f"{sp(state)}{''.join(body)}
    ") + + +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 = "

    Choose one:
    " + "".join( + f"
    " for a in alts) + "

    " + 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"

    This step is irreversible. Type the confirmation phrase to start it:
    " + f"{e(R.phrase_for(run, sid))}

    ") + if not unmet: + if s.get("auto_confirm") == "manual-only" and not c.manual: + return "

    Manual-only: auto-confirm will not start this step. Take over to do it yourself.

    " + return f"
    {c.h()}{alt_html}{phrase}
    " + risk = R._risk_text(run, sid) + wait = f"

    Waiting for: {e('; '.join(unmet))}

    " + if not c.manual: + return wait + "

    Overrides are made in manual mode.

    " + return (wait + f"
    Start anyway (override)" + f"
    {c.h()}

    Downstream risk: {e(risk)}

    {alt_html}{phrase}" + "

    " + "

    " + "
    ") + + +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"
    {e(cmd)}
    ") + 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"
    {c.h()}

    Is this true? {e(said)}

    " + "
    ") + else: + what = (f"{e(_render_check(run, ver))}" if ver.get("command") else "the datapoint states") + out.append(f"
    {c.h()}" + f"

    Checks: {what}

    ") + if not v: + out.insert(0, "

    1. Do the work, then check  →  2. Mark done to lock it in

    ") + elif v["passed"]: + out.append("

    ✔ Check passed. Press Mark done to lock it in.

    ") + else: + out.append("

    ✘ Check not passed. Fix the cause and check again, or mark the step failed.

    ") + ev = "" + if v and v["passed"]: + ev = "".join(f" " for x in s.get("evidence", [])) + ev = f"

    {ev}

    " if ev else "" + done_btn = f" " if v and v["passed"] else "" + out.append(f"
    {c.h()}{ev}

    {done_btn}" + f" " + "

    " + "
    ") + if eff.get("rollback"): + out.append(f"

    Rollback: {e(_render_text(run, eff['rollback']))}

    ") + 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"
  • {pill} {e(text)}{'
    ' + ctl if ctl else ''}
  • ") + 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"
    Waive this gate (declared waivable by {e(role)})" + f"
    {c.h()}" + f"

    Downstream risk: {e(R._risk_text(run, gid))}

    " + "

    " + "
    ") + extra = "" + if state == "pushed": + extra = "

    Passed over by push-through without passing: recorded as a deviation.

    " + if state == "waived": + extra = f"

    Waived: {e(gs['waiver']['reason'])}

    " + current = " open" if state not in R.DONE_GATE else "" + marker = " id=current" if gid == c.na["recommended"] else "" + return (f"
    " + f"Gate: {e(g['title'])} quality goal: {e(g['goal'])}" + f"{' · blocking' if g.get('blocking', True) else ''}{sp(state)}" + f"
      {''.join(rows)}
    {extra}{waive}
    ") + + +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()}" + if exp == "manual": + ctl = " " + else: + cmd = f" {e(_render_check(run, ck))}" if ck.get("command") else "" + ctl = f"{cmd}" + if not unmet: + return f"
    {hidden}

    {ctl}

    " + if not c.manual: + return f"Waiting for: {e('; '.join(unmet))}" + return (f"Waiting for: {e('; '.join(unmet))}" + f"
    Check anyway (override)" + f"
    {hidden}

    Downstream risk: {e(R._risk_text(run, gid))}

    " + "

    " + "

    " + f"

    {ctl}

    ") + + +# ---- 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"{e(label)}" + + ctl = ("

    Group by " + link(None, flt, "none", " on" if not group else "") + " " + + " ".join(link(k, flt, k, " on" if group == k else "") for k in keys) + "

    ") + if flt: + ctl += f"

    Filter {e(flt)} " + link(group, None, "clear filter") + "

    " + groups = R.datapoint_rows(run, group=group, flt=flt) + out = [f"

    Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.

    {ctl}"] + editable = not c.finished + out.append(f"
    {c.h()}") + for gname, rows in groups.items(): + if gname: + out.append(f"

    {e(group)}: {e(gname)}

    ") + 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'])}
    {e(r['value'] if r['value'] is not None else '')}") + trs += f"{ctl}{dims}{src}" + out.append(f"{trs}
    DatapointDimensionsProvenance
    ") + if not groups: + out.append("

    No datapoints match.

    ") + if editable: + out.append("") + out.append("
    ") + arts = R.artefact_rows(run) + if arts: + out.append("

    Artefacts

    " + "".join( + f"" + f"" for a in arts) + "
    ArtefactWhere it livesProduced byUntil
    {e(a['title'])}{e(a['location'] or '(not known yet)')}{e(a['produced_by'])} {sp(a['produced_state'])}{e(a['lives_until'] or '-')}
    ") + if fl.actors: + counts: dict[str, int] = {} + for s in fl.steps.values(): + counts[s["actor"]] = counts.get(s["actor"], 0) + 1 + out.append("

    Actors

    " + "".join( + f"" + f"" for a in fl.actors.values()) + "
    RoleDescriptionPrivilegesSteps
    {e(a['role'])} ({e(a['id'])}){e(a['description'])}{e(', '.join(a.get('privileges', [])))}{counts.get(a['id'], 0)}
    ") + 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" {s['steps_failed']} failed" + if s["steps_pushed"]: + prob += f" {s['steps_pushed']} pushed" + if s["steps_running"]: + prob += f" {s['steps_running']} running" + rows += (f"{e(s['title'])}" + f"
    {e(s['goal'])}" + f"{s['steps_done']}/{s['steps_total']}{prob}{gates or '-'}" + f"{sp('complete' if s['complete'] else 'in progress')}" + f"{'
    ' + e('; '.join(s['unmet'])) + '' if s['unmet'] else ''}") + dps = " ".join(f"{sp(k)} {v}" for k, v in sorted(ov["datapoints"].items())) + out = ["

    Stages

    " + rows + "
    StageStepsGatesState
    ", + f"

    Datapoints

    {dps}

    Open the data view

    "] + if ov["deviations"]: + out.append("

    Deviations

    " + "".join( + f"" + f"" + f"" for d in ov["deviations"]) + "
    KindItemModeUnmetReasonWhen
    {sp(d['kind'])}{e(d['item'])}{e(d['mode'])}{e('; '.join(d['unmet']))}{e(d['reason'])}{'
    risk: ' + e(d['downstream_risk']) + '' if d.get('downstream_risk') else ''}
    {e(d['at'][:19])}
    ") + else: + out.append("

    Deviations

    None. A run without deviations can count towards maturity.

    ") + if ov["mode_history"]: + out.append("

    Mode changes

      " + "".join(f"
    • {e(m['from'])} → {e(m['to'])} · {e(m['at'][:19])}
    • " for m in ov["mode_history"]) + "
    ") + if ov["stopped"]: + out.append(f"

    Auto-confirm stopped at {e(ov['stopped']['item'])}: {e(ov['stopped']['reason'])}

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

    Run finished: {e(run.data['outcome'])}

    {note}

    " + "

    The outcome is part of this runbook's score and maturity evidence at this revision. " + "← Back to the runbook list

    ") + 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("") + if {"failed", "pushed"} & set(steps) or {"failed", "pushed"} & set(gates): + btns.append("") + btns.append("") + return ("

    Close this run

    " + "

    Finishing records the outcome. A run with deviations can be finished but does not count towards maturity. " + "A finished run is read-only.

    " + f"
    {c.h()}

    " + f"

    {' '.join(btns)}

    ") + + +# ---- 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") diff --git a/tests/test_run2.py b/tests/test_run2.py index 77092d2..d70562f 100644 --- a/tests/test_run2.py +++ b/tests/test_run2.py @@ -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") diff --git a/tests/test_ui_projects.py b/tests/test_ui_projects.py index 53b2f96..e34f324 100644 --- a/tests/test_ui_projects.py +++ b/tests/test_ui_projects.py @@ -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("

    Project templates

    ")[0] - assert "Harbour watch" in projects and "Our own harbour, two lights." in projects - assert "from template lighthouse-watch" 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("

    Project templates

    ")[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 diff --git a/tests/test_ui_v2.py b/tests/test_ui_v2.py new file mode 100644 index 0000000..1b46272 --- /dev/null +++ b/tests/test_ui_v2.py @@ -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 "actor: keeper" 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 "Next: Read the poem over the door" 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 "Goal: 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 "