diff --git a/rtut/run.py b/rtut/run.py
index 9e2b10e..29a822d 100644
--- a/rtut/run.py
+++ b/rtut/run.py
@@ -173,7 +173,7 @@ def verify(run: Run, step_id: str, confirm: bool = False, manual_ok: bool | None
return result
-def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None) -> None:
+def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None, note: str = "") -> None:
sd = run.step_def(step_id)
st = run.state(step_id)
if st["state"] != "running":
@@ -185,6 +185,8 @@ def complete(run: Run, step_id: str, evidence: dict[str, str] | None = None) ->
if missing:
raise RunError(f"evidence required: {', '.join(missing)}")
st["evidence"] = {k: _clean(str(v), f"evidence {k}") for k, v in evidence.items()}
+ if note.strip():
+ st["note"] = _clean(note.strip(), "note")
st.update(state="done", finished=_now())
run.save()
diff --git a/rtut/ui.py b/rtut/ui.py
index dbf0668..69f1131 100644
--- a/rtut/ui.py
+++ b/rtut/ui.py
@@ -1,6 +1,6 @@
"""Local web UI: pick a pack, fill parameters, walk the steps, record evidence.
-Localhost only. Server-rendered forms; the only script is a copy button.
+Localhost only. Server-rendered forms; the only script is small: copy button, fail-comment warning, scroll to current step.
"""
from __future__ import annotations
@@ -19,15 +19,23 @@ h1,h2{font-weight:600}a{color:inherit}hr{border:0;border-top:1px solid #8886}
.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}.note{opacity:.75}.bad{font-weight:600}"""
+button,input,select{font:inherit;padding:.25rem .5rem}.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}"""
def page(title: str, body: str) -> bytes:
return (f" "
f"
✘ Check not passed. Fix the cause, then check again, or mark the step failed.
"
+ ev = ""
if v and v["passed"]:
- ev = "".join(f"{state} {n}. {e(s['title'])} "
+ rb = f"
Rollback: {e(s['rollback'])}
" if s.get("rollback") and state in ("running", "failed") else ""
+ anchor = " id=current" if s["id"] == current else ""
+ out.append(f"
{state} {n}. {e(s['title'])} "
f"
[{e(s['owner'])}] {e(s.get('risk', 'read-only'))} {block}"
f"
Done when: {e(s['verify']['done_when'])}
{rb}{act}
")
if r.complete:
@@ -178,17 +201,17 @@ def make_handler(app: App, port_holder: list):
i = int(parts[2]); p = app.packs[i]
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']}")
+ return self._redirect(f"/run/{i}/{r.data['run_id']}#current")
i, rid, sid, action = int(parts[1]), parts[2], parts[3], parts[4]
p = app.packs[i]; r = R.load(p, rid)
if action == "start": R.start(r, sid)
elif action == "verify":
R.verify(r, sid, confirm=bool(form.get("yes")), manual_ok=(form.get("ok") == "1") if not form.get("yes") else None)
elif action == "done":
- R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")})
+ R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")}, form.get("note", ""))
elif action == "fail": R.fail(r, sid, form.get("note", ""))
elif action == "retry": R.retry(r, sid)
- return self._redirect(f"/run/{i}/{rid}")
+ return self._redirect(f"/run/{i}/{rid}#current")
except (R.RunError, KeyError, ValueError, IndexError) as ex:
msg = str(ex)
try:
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 22401af..318fb8c 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -43,15 +43,15 @@ def test_foreign_host_and_missing_token_refused(srv):
def test_walk_a_run(srv):
t = srv.app.token
st, loc, _ = req(srv, "POST", "/run/new/0", {"_t": t, "operator": "bernd", "p_host": "localhost", "p_count": "1"})
- assert st == 303 and loc.startswith("/run/0/")
- assert req(srv, "POST", loc + "/ping/start", {"_t": t})[0] == 303
- st, _, body = req(srv, "GET", loc)
+ assert st == 303 and loc.startswith("/run/0/") and loc.endswith("#current")
+ assert req(srv, "POST", loc.split("#")[0] + "/ping/start", {"_t": t})[0] == 303
+ st, _, body = req(srv, "GET", loc.split("#")[0])
assert "ping -c 1 localhost" in body and "Run check" in body
- req(srv, "POST", loc + "/ping/verify", {"_t": t, "yes": "1"})
- st, _, body = req(srv, "GET", loc)
+ req(srv, "POST", loc.split("#")[0] + "/ping/verify", {"_t": t, "yes": "1"})
+ st, _, body = req(srv, "GET", loc.split("#")[0])
if "passed" in body and "failed" not in body.split("Threat")[0].replace("Mark failed", ""):
- assert req(srv, "POST", loc + "/ping/done", {"_t": t})[0] == 303
- assert "All steps done" in req(srv, "GET", loc)[2]
+ assert req(srv, "POST", loc.split("#")[0] + "/ping/done", {"_t": t})[0] == 303
+ assert "All steps done" in req(srv, "GET", loc.split("#")[0])[2]
def test_unsafe_parameter_rejected_and_escaped(srv):
@@ -63,8 +63,8 @@ def test_unsafe_parameter_rejected_and_escaped(srv):
def test_secret_in_note_refused(srv):
t = srv.app.token
_, loc, _ = req(srv, "POST", "/run/new/0", {"_t": t, "operator": "bernd"})
- req(srv, "POST", loc + "/ping/start", {"_t": t})
- st, _, body = req(srv, "POST", loc + "/ping/fail", {"_t": t, "note": "hvs.ABCDEFGHIJKLMN"})
+ req(srv, "POST", loc.split("#")[0] + "/ping/start", {"_t": t})
+ st, _, body = req(srv, "POST", loc.split("#")[0] + "/ping/fail", {"_t": t, "note": "hvs.ABCDEFGHIJKLMN"})
assert st == 400 and "secret-looking" in body
@@ -79,18 +79,78 @@ def test_manual_check_cycle_and_blocked_reason(srv):
srv.app.packs.append(P.load(d / "pack.yaml"))
i = len(srv.app.packs) - 1
_, loc, _ = req(srv, "POST", f"/run/new/{i}", {"_t": t, "operator": "bernd"})
- _, _, body = req(srv, "GET", loc)
+ _, _, body = req(srv, "GET", loc.split("#")[0])
assert "0 of 8 steps done" in body and "1. Read the poem" in body
assert "Waiting for: step 1" in body # blocked steps explain themselves
- req(srv, "POST", loc + "/read-the-sign/start", {"_t": t})
- _, _, body = req(srv, "GET", loc)
+ req(srv, "POST", loc.split("#")[0] + "/read-the-sign/start", {"_t": t})
+ _, _, body = req(srv, "GET", loc.split("#")[0])
assert "Yes, it is" in body and "Not yet" in body and "/read-the-sign/done" not in body
- req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "0"})
- _, _, body = req(srv, "GET", loc)
+ req(srv, "POST", loc.split("#")[0] + "/read-the-sign/verify", {"_t": t, "ok": "0"})
+ _, _, body = req(srv, "GET", loc.split("#")[0])
assert "Check not passed" in body and "/read-the-sign/done" not in body
- req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "1"})
- _, _, body = req(srv, "GET", loc)
+ req(srv, "POST", loc.split("#")[0] + "/read-the-sign/verify", {"_t": t, "ok": "1"})
+ _, _, body = req(srv, "GET", loc.split("#")[0])
assert "Check passed" in body and "/read-the-sign/done" in body
- req(srv, "POST", loc + "/read-the-sign/done", {"_t": t})
- _, _, body = req(srv, "GET", loc)
+ req(srv, "POST", loc.split("#")[0] + "/read-the-sign/done", {"_t": t})
+ _, _, body = req(srv, "GET", loc.split("#")[0])
assert "1 of 8 steps done" in body and "Start this step" in body
+
+
+def _showcase_run(srv):
+ import shutil
+ from rtut import pack as P
+ t = srv.app.token
+ d = Path(srv.app.packs[0].path).parents[2] / "runbooks" / "show2"; d.mkdir(parents=True)
+ shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", d / "pack.yaml")
+ srv.app.packs.append(P.load(d / "pack.yaml"))
+ i = len(srv.app.packs) - 1
+ st, loc, _ = req(srv, "POST", f"/run/new/{i}", {"_t": t, "operator": "bernd"})
+ return t, loc, st
+
+
+def test_redirects_keep_focus_on_current_step(srv):
+ t, loc, _ = _showcase_run(srv)
+ assert loc.endswith("#current")
+ base = loc.split("#")[0]
+ st, redirect, _ = req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
+ assert st == 303 and redirect.endswith("#current")
+ _, _, body = req(srv, "GET", base)
+ assert body.count("id=current") == 1 and "scrollIntoView" in body
+
+
+def test_command_and_copy_appear_only_when_step_is_started(srv):
+ t, loc, _ = _showcase_run(srv)
+ base = loc.split("#")[0]
+ _, _, body = req(srv, "GET", base)
+ assert "
" not in body and "" not in body # nothing started yet
+ req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
+ _, _, body = req(srv, "GET", base)
+ assert "" in body # started: command and Copy appear
+ assert "printf" in body
+ req(srv, "POST", base + "/read-the-sign/verify", {"_t": t, "ok": "1"})
+ req(srv, "POST", base + "/read-the-sign/done", {"_t": t, "note": "read it twice"})
+ _, _, body = req(srv, "GET", base)
+ assert "" not in body # done: no copy button
+ assert "" in body and "Comment: read it twice" in body # command kept as a record, comment shown
+
+
+def test_action_buttons_left_of_the_comment(srv):
+ t, loc, _ = _showcase_run(srv)
+ base = loc.split("#")[0]
+ req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
+ req(srv, "POST", base + "/read-the-sign/verify", {"_t": t, "ok": "1"})
+ _, _, body = req(srv, "GET", base)
+ row = body[body.index(""):]
+ assert row.index("Mark done") < row.index("Mark failed") < row.index("name=note")
+
+
+def test_fail_without_comment_warns_first_then_is_allowed(srv):
+ t, loc, _ = _showcase_run(srv)
+ base = loc.split("#")[0]
+ req(srv, "POST", base + "/read-the-sign/start", {"_t": t})
+ _, _, body = req(srv, "GET", base)
+ assert "data-fail" in body and "data-warn hidden" in body and "dataset.warned" in body # client-side first-click warning
+ st, _, _ = req(srv, "POST", base + "/read-the-sign/fail", {"_t": t, "note": ""}) # the second click submits
+ assert st == 303
+ _, _, body = req(srv, "GET", base)
+ assert "(no comment)" in body and "Retry" in body
diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md
index f78e186..9ad6699 100644
--- a/workplans/RBT-WP-0004-local-runbook-ui.md
+++ b/workplans/RBT-WP-0004-local-runbook-ui.md
@@ -81,7 +81,11 @@ state_hub_task_id: "d635f972-0377-5581-a8fe-c01ef89559cd"
Done 2026-09-29 against the lighthouse showcase (tested over HTTP; a browser pass by Bernd is still the real check). Defects found in review: a ticked manual check shows "passed" while the step stays
`running`, and unticking changes nothing; blocked steps show no Start button and no
reason. Make the cycle explicit (check, record, mark done), and show what a blocked
-step is waiting for.
+step is waiting for. Follow-up done 2026-09-29 from Bernd's first hands-on: the command and
+Copy button appear only once a step is started (done steps keep the command as a record);
+"Mark done" and "Mark failed" sit left of the comment field; a first "Mark failed" without a
+comment only warns, a second click fails without one; every action returns to the current
+step instead of the top of the page.
## Questionnaire flow on the v0.2 model