Run outcomes, score, explicit exercise recording, and step captures (RBT-WP-0004-T13)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
288e04c192
commit
8a5cbc1c29
7 changed files with 444 additions and 5 deletions
88
rtut/ui.py
88
rtut/ui.py
|
|
@ -34,7 +34,7 @@ def page(title: str, body: str) -> bytes:
|
|||
"const f=b.closest('form'),n=f.querySelector('[name=note]'),w=f.querySelector('[data-warn]');"
|
||||
"if(n&&!n.value.trim()&&!b.dataset.warned){ev.preventDefault();b.dataset.warned='1';w.hidden=false;n.focus();}}));"
|
||||
# keep the operator on the current step after every action
|
||||
"const c=document.getElementById(location.hash==='#feedback'?'feedback':'current');if(c)c.scrollIntoView({block:'center'});"
|
||||
"const c=document.getElementById(location.hash==='#feedback'?'feedback':location.hash==='#close'?'close':'current');if(c)c.scrollIntoView({block:'center'});"
|
||||
"</script>").encode()
|
||||
|
||||
|
||||
|
|
@ -45,6 +45,11 @@ def e(s) -> str:
|
|||
SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor", "🙁", "Poor"))
|
||||
|
||||
|
||||
def score_line(c: dict) -> str:
|
||||
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
f"abandoned {c['abandoned']} · open {c['open']}")
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, roots: list[Path]):
|
||||
self.token = secrets.token_urlsafe(16)
|
||||
|
|
@ -67,7 +72,8 @@ class App:
|
|||
f"<span class=note>start with: python -m rtut launch {e(p.path)}</span></li>")
|
||||
else:
|
||||
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> "
|
||||
f"<span class=pill>{e(d['exercise_status'])}</span></li>")
|
||||
f"<span class=pill>{e(d['exercise_status'])}</span> "
|
||||
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
|
||||
return page("Runbooks", f"<h1>Runbooks</h1><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
|
||||
|
||||
def pack_page(self, i: int) -> bytes:
|
||||
|
|
@ -81,11 +87,71 @@ class App:
|
|||
+ f"</label><br><span class=note>{e(x.get('help', ''))} Never enter secrets.</span></p>"
|
||||
for x in d.get("parameters", []))
|
||||
return page(d["title"], f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
|
||||
f"<p>{e(d.get('outcome', ''))}</p><h2>Prerequisites</h2><ul>{prereq}</ul>"
|
||||
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
|
||||
f"{self.runs_list(i, p)}<h2>Prerequisites</h2><ul>{prereq}</ul>"
|
||||
f"<form method=post action='/run/new/{i}'>{self.hidden()}{fields}"
|
||||
"<p><label>Your name (one word)<br><input name=operator required></label></p>"
|
||||
"<button>Start run</button></form>")
|
||||
|
||||
def runs_list(self, i: int, p) -> str:
|
||||
rows = []
|
||||
for rid in reversed(R.list_runs(p)):
|
||||
r = R.load(p, rid)
|
||||
rows.append(f"<li><a href='/run/{i}/{e(rid)}'>{e(rid)}</a> · {e(r.data['operator'])} · "
|
||||
f"<span class=pill>{e(R.effective_outcome(r))}</span></li>")
|
||||
return f"<h2>Runs</h2><ul>{''.join(rows)}</ul>" if rows else ""
|
||||
|
||||
def capture_html(self, url: str, s: dict, st: dict, state: str, finished: bool) -> str:
|
||||
saved = ""
|
||||
for c in st.get("captures", []):
|
||||
head = f"$ {c['command']}\n" if c.get("command") else ""
|
||||
saved += f"<pre>{e(head + c['output'][:4000])}</pre><p class=note>captured {e(c['at'][:19])} ({e(c['source'])})</p>"
|
||||
if finished or state == "todo":
|
||||
return f"<details><summary class=note>Captured output</summary>{saved}</details>" if saved else ""
|
||||
paste = ""
|
||||
if state == "running" and s["verify"].get("expect") == "output-contains":
|
||||
paste = f"<button formaction='{url}/verify-pasted' formnovalidate>Check against this output</button>"
|
||||
return (f"<details><summary class=note>Capture command and output (optional)"
|
||||
f"{' - ' + str(len(st.get('captures', []))) + ' saved' if st.get('captures') else ''}</summary>{saved}"
|
||||
f"<form method=post action='{url}/capture'>{self.hidden()}"
|
||||
"<p><input name=command class=grow style='width:100%' placeholder='command you actually ran (optional)'></p>"
|
||||
"<p><textarea name=output rows=5 style='width:100%' placeholder='paste the output'></textarea></p>"
|
||||
f"<p class=row><button>Save capture</button>{paste}</p>"
|
||||
"<p class=note>Kept in the run record as documentation. Never paste secrets: obvious markers are "
|
||||
"refused, but not everything can be detected. Use the check button when the command ran on a system "
|
||||
"this runbook cannot reach.</p></form></details>")
|
||||
|
||||
def close_run_form(self, i: int, rid: str, r, p) -> str:
|
||||
outcome = r.data.get("outcome")
|
||||
if outcome:
|
||||
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else ""
|
||||
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>"
|
||||
if outcome == "succeeded":
|
||||
if r.data.get("exercise_recorded"):
|
||||
box += f"<p>Pack recorded as <b>{e(r.data['exercise_recorded'])}</b>. Commit the change in the owning repository.</p>"
|
||||
else:
|
||||
old, new = R.exercise_update(r)
|
||||
line = new.split("exercise_status:", 1)[1].split("\n", 1)[0].strip()
|
||||
box += (f"<p>Current pack status: <span class=pill>{e(p.data['exercise_status'])}</span>. "
|
||||
f"Recording this run would set it to <b>{e(line)}</b> in <code>{e(p.path)}</code> "
|
||||
"(a reviewable edit; commit it in the owning repository).</p>"
|
||||
f"<form method=post action='/run/{i}/{e(rid)}/exercise'>{self.hidden()}"
|
||||
"<button>Record pack as exercised</button></form>")
|
||||
return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
|
||||
states = [v["state"] for v in r.data["steps"].values()]
|
||||
btns = []
|
||||
if r.complete and all(x == "done" for x in states):
|
||||
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
||||
if "failed" in states:
|
||||
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 (f"<div id=close class=step><h2>Close this run</h2>"
|
||||
"<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. "
|
||||
"Runs left open for a week count as abandoned.</p>"
|
||||
f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}"
|
||||
"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
|
||||
f"<p class=row>{' '.join(btns)}</p></form></div>")
|
||||
|
||||
def feedback_form(self, i: int, rid: str, r) -> str:
|
||||
fb = R.latest_feedback(r)
|
||||
thanks = ""
|
||||
|
|
@ -111,6 +177,7 @@ class App:
|
|||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
blocked = False
|
||||
waiting_for = ""
|
||||
finished = bool(r.data.get("outcome"))
|
||||
total = len(d["steps"])
|
||||
done_n = sum(1 for x in d["steps"] if r.state(x["id"])["state"] in ("done", "skipped"))
|
||||
out.append(f"<p class=eyebrow>{done_n} of {total} steps done</p>"
|
||||
|
|
@ -163,6 +230,8 @@ class App:
|
|||
act = f"<p class=bad>Failed: {e(st.get('note') or '(no comment)')}</p><form method=post action='{url}/retry'>{self.hidden()}<button>Retry</button></form>"
|
||||
elif state == "done" and st.get("note"):
|
||||
act = f"<p class=note>Comment: {e(st['note'])}</p>"
|
||||
if finished and state != "done":
|
||||
act = "" # a finished run is read-only
|
||||
if state not in ("done", "skipped") and not blocked:
|
||||
blocked = True
|
||||
waiting_for = f"step {n}, {s['title']}"
|
||||
|
|
@ -173,12 +242,14 @@ class App:
|
|||
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"{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:
|
||||
out.append("<p><b>All steps done.</b> To record this run as exercising the pack, review and apply: "
|
||||
f"<code>python -m rtut run finish {e(p.path)} {e(rid)} --apply</code></p>")
|
||||
if r.complete:
|
||||
out.append(self.feedback_form(i, rid, r))
|
||||
out.append(self.close_run_form(i, rid, r, p))
|
||||
out.append("<h2>Threat checks</h2><ul>" + "".join(f"<li>{e(t)}</li>" for t in d["threat_checks"]) + "</ul>")
|
||||
return page(d["title"], "".join(out))
|
||||
|
||||
|
|
@ -231,6 +302,15 @@ def make_handler(app: App, port_holder: list):
|
|||
i, rid = int(parts[1]), parts[2]
|
||||
R.add_feedback(R.load(app.packs[i], rid), form.get("rating", ""), form.get("comment", ""))
|
||||
return self._redirect(f"/run/{i}/{rid}#feedback")
|
||||
if len(parts) == 4 and parts[0] == "run" and parts[3] in ("finalize", "exercise"):
|
||||
i, rid = int(parts[1]), parts[2]
|
||||
r = R.load(app.packs[i], rid)
|
||||
if parts[3] == "finalize":
|
||||
R.finalize(r, form.get("outcome", ""), form.get("note", ""))
|
||||
else:
|
||||
R.apply_exercise(r)
|
||||
app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status
|
||||
return self._redirect(f"/run/{i}/{rid}#close")
|
||||
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)
|
||||
|
|
@ -239,6 +319,8 @@ def make_handler(app: App, port_holder: list):
|
|||
elif action == "done":
|
||||
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 == "capture": R.add_capture(r, sid, form.get("output", ""), form.get("command", ""))
|
||||
elif action == "verify-pasted": R.verify_pasted(r, sid, form.get("output", ""), form.get("command", ""))
|
||||
elif action == "retry": R.retry(r, sid)
|
||||
return self._redirect(f"/run/{i}/{rid}#current")
|
||||
except (R.RunError, KeyError, ValueError, IndexError) as ex:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue