Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
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 18:03:12 +02:00
parent b7edb8f023
commit 711c4e1149
13 changed files with 381 additions and 42 deletions

View file

@ -75,6 +75,7 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
.pill[data-s=failed],.pill[data-s=err],.pill[data-s=invalid]{color:var(--bad);border-color:var(--bad)}
.pill[data-s=changed-after-use],.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}
.hl a{text-decoration:none}.hl a::after{content:" →";opacity:.35;font-weight:400}.hl a:hover::after{opacity:1}
.feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)}
.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}
@ -143,6 +144,13 @@ def e(s) -> str:
return html.escape(str(s))
def heading(level: str, text: str, href: str | None = None, extra: str = "") -> str:
"""A section headline. On the Home page it links to the matching sidebar section, as a shortcut."""
if href:
return f"<{level} class=hl><a href='{href}'>{html.escape(text)}</a>{extra}</{level}>"
return f"<{level}>{html.escape(text)}{extra}</{level}>"
SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor", "🙁", "Poor"))
@ -177,15 +185,15 @@ class App:
return (f"<input type=hidden name={name} value='{e(u['operator'])}'>"
f"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
def activities_html(self, limit: int = 12, level: str = "h2") -> str:
def activities_html(self, limit: int = 12, level: str = "h2", link: bool = False) -> str:
events = ACT.latest(self.packs, limit)
if not events:
return f"<{level}>Latest activities</{level}><p class=note>Nothing has happened yet. Start a run or a project.</p>"
return f"{heading(level, 'Latest activities', '/activity' if link else None)}<p class=note>Nothing has happened yet. Start a run or a project.</p>"
rows = "".join(
f"<li><span class=note>{e(x['at'][:16].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])} "
f"<span class=note>· {e(x['pack'])}{' · project ' + e(x['project']) if x.get('project') else ''}</span>"
f"{' · <a href=' + chr(39) + x['link'] + chr(39) + '>open</a>' if x['link'] else ''}</li>" for x in events)
return (f"<{level}>Latest activities <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></{level}>"
return (f"{heading(level, 'Latest activities', '/activity' if link else None, ' <span class=note style=' + chr(39) + 'font-weight:400;font-size:.85rem' + chr(39) + '>(times in UTC)</span>')}"
f"<ul class=feed>{rows}</ul>")
def user_page(self, msg: str = "") -> bytes:
@ -209,7 +217,7 @@ class App:
return f"<input type=hidden name=_t value='{self.token}'>"
def index(self) -> bytes:
return page("Home", UP.index_sections(self) + self.activities_html() + self.runbooks_html())
return page("Home", UP.index_sections(self, home=True) + self.activities_html(link=True) + self.runbooks_html(link=True))
def runbooks_page(self) -> bytes:
return page("Runbooks", self.runbooks_html("h1"))
@ -220,7 +228,7 @@ class App:
def activity_page(self) -> bytes:
return page("Activity", self.activities_html(100, "h1"))
def runbooks_html(self, level: str = "h2") -> str:
def runbooks_html(self, level: str = "h2", link: bool = False) -> str:
rows = []
for i, p in enumerate(self.packs):
d = p.data
@ -232,7 +240,7 @@ class App:
else:
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> {UP.maturity_pill(p)} "
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
return f"<{level}>Runbooks</{level}><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
return f"{heading(level, 'Runbooks', '/runbooks' if link else None)}<ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
def pack_page(self, i: int) -> bytes:
p = self.packs[i]; d = p.data
@ -291,7 +299,7 @@ class App:
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>"
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
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>"
@ -315,7 +323,7 @@ class App:
"<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. "
"Unfinished runs count as stalled, and as abandoned after a week.</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><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>{self.close_feedback_fields(r)}"
f"<p class=row>{' '.join(btns)}</p></form></div>")
def feedback_form(self, i: int, rid: str, r) -> str:
@ -328,14 +336,41 @@ class App:
"Never include secrets.</p>"
f"<p><textarea name=comment rows=3 style='width:100%' placeholder='optional comment'>"
f"{e(fb['comment']) if fb else ''}</textarea></p><p class=row>{btns}</p></form>")
if fb: # collected: close the section, suggest the next step
if r.data.get("outcome"): # a closed run is locked: show what was said, offer no change
if not fb:
return ""
face = next(f for k, f, _ in SMILEYS if k == fb["rating"])
return (f"<div id=feedback class=step><h2>Feedback</h2><p>{face} {e(fb['comment'] or '(no comment)')}</p>"
"<p class=note>The run is closed and locked, so its feedback can no longer be changed.</p></div>")
if fb: # collected while the run is open: close the section
face = next(f for k, f, _ in SMILEYS if k == fb["rating"])
return ("<div id=feedback class=step><p><b>Thank you.</b> Recorded: "
f"{face} {e(fb['comment'] or '(no comment)')}</p>"
"<p><a href='/'>← Back to the runbook list</a></p>"
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 close_feedback_fields(self, r) -> str:
"""Optional rating and comment inside the closing form: a closed run cannot take feedback afterwards."""
if R.latest_feedback(r):
return ""
faces = " ".join(f"<label title='{e(t)}'><input type=radio name=rating value={k}> <span style='font-size:1.4rem'>{face}</span></label>"
for k, face, t in SMILEYS)
return ("<fieldset><legend>How was this runbook? <span class=note>(optional; it cannot be added after closing)</span></legend>"
f"<p class=row>{faces}</p><textarea name=fb_comment rows=2 style='width:100%' "
"placeholder='anything strange or unclear, or an idea to make it better (no secrets)'></textarea></fieldset>")
def locked_note(self, r) -> str:
"""What 'closed' means: the record is sealed and locked. A record changed outside the app is called out."""
state = R.seal_state(r)
if state == "broken":
return ("<p class=bad>⚠ This closed run's record was changed outside the app after it was sealed. "
"It no longer counts towards maturity.</p>")
base = ("This run is <b>closed and locked</b>: its record is sealed and cannot be changed. "
"Start a new run to try again.")
if state == "unsealed":
base += " (It was closed before sealing existed, so its record carries no seal.)"
return f"<p class=note>{base}</p>"
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.2":
@ -424,7 +459,7 @@ class App:
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:
if r.complete or r.data.get("outcome") or R.latest_feedback(r):
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>")
@ -567,7 +602,8 @@ def make_handler(app: App, port_holder: list):
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", ""))
R.finalize(r, form.get("outcome", ""), form.get("note", ""), rating=form.get("rating") or None,
comment=form.get("fb_comment", ""))
else:
R.apply_exercise(r)
app.packs[i] = P.load(app.packs[i].path) # pick up the new exercise status