Lock and seal closed runs; feedback with the closing form; Home headlines link to their sections; drop the back link
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
b7edb8f023
commit
711c4e1149
13 changed files with 381 additions and 42 deletions
|
|
@ -53,7 +53,8 @@ def _env_key(ctx: dict) -> str:
|
|||
|
||||
def _evidence(pack: Pack, dig: str, runs: list[R.Run]) -> dict:
|
||||
clean = [r for r in runs if r.data.get("pack_digest") == dig and r.data.get("outcome") == "succeeded"
|
||||
and not r.data.get("deviations") and r.data.get("context", {}).get("environment")]
|
||||
and not r.data.get("deviations") and r.data.get("context", {}).get("environment")
|
||||
and R.seal_state(r) != "broken"] # a record changed after closing is not evidence
|
||||
reviewed = any(rv.get("digest") == dig for rv in _reviews(pack))
|
||||
rehearsal = [r for r in clean if r.data["context"]["environment"] == "rehearsal"]
|
||||
envs = {_env_key(r.data["context"]) for r in clean}
|
||||
|
|
|
|||
68
rtut/run.py
68
rtut/run.py
|
|
@ -44,10 +44,22 @@ class Run:
|
|||
def path(self) -> Path:
|
||||
return runs_dir(self.pack) / f"{self.data['run_id']}.json"
|
||||
|
||||
def save(self) -> None:
|
||||
def save(self, allow: tuple = ()) -> None:
|
||||
"""Write the record. A closed run is locked: nothing may change except the keys named in `allow`."""
|
||||
disk = _read_json(self.path)
|
||||
if disk and disk.get("outcome"):
|
||||
keys = set(disk) | set(self.data)
|
||||
changed = {k for k in keys if disk.get(k) != self.data.get(k)} - {"updated"} - set(allow)
|
||||
if changed:
|
||||
raise RunError(f"this run is closed and locked ({disk['outcome']}); it cannot be changed "
|
||||
f"({', '.join(sorted(changed))})")
|
||||
self.data["updated"] = _now()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.exists():
|
||||
self.path.chmod(0o644) # a sealed file is read-only; the allowed update reopens it briefly
|
||||
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
if self.data.get("outcome"):
|
||||
self.path.chmod(0o444)
|
||||
|
||||
def step_def(self, step_id: str) -> dict:
|
||||
if self.pack.spec == "runbook-pack/v0.2": # steps live in stages; alternatives are applied
|
||||
|
|
@ -280,12 +292,19 @@ def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
|
|||
RATINGS = {"good": ":)", "neutral": ":|", "poor": ":("}
|
||||
|
||||
|
||||
def add_feedback(run: Run, rating: str, comment: str = "") -> dict:
|
||||
"""Record how the runbook felt. Allowed at any time; the latest entry is the current one."""
|
||||
def _feedback_entry(rating: str, comment: str) -> dict:
|
||||
if rating not in RATINGS:
|
||||
raise RunError(f"rating must be one of {', '.join(RATINGS)}")
|
||||
entry = {"rating": rating, "comment": _clean(comment.strip(), "comment"),
|
||||
"at": datetime.now(timezone.utc).isoformat(timespec="microseconds")} # sortable even within one second
|
||||
return {"rating": rating, "comment": _clean(comment.strip(), "comment"),
|
||||
"at": datetime.now(timezone.utc).isoformat(timespec="microseconds")} # sortable even within one second
|
||||
|
||||
|
||||
def add_feedback(run: Run, rating: str, comment: str = "") -> dict:
|
||||
"""Record how the runbook felt. Possible while the run is open (the latest entry counts); a closed run is locked,
|
||||
so give the feedback before closing, or with the closing form."""
|
||||
if run.data.get("outcome"):
|
||||
raise RunError("this run is closed and locked: its feedback can no longer be changed")
|
||||
entry = _feedback_entry(rating, comment)
|
||||
run.data.setdefault("feedback", []).append(entry)
|
||||
run.save()
|
||||
return entry
|
||||
|
|
@ -359,8 +378,37 @@ OUTCOMES = ("succeeded", "failed", "abandoned")
|
|||
STALE_DAYS = 7
|
||||
|
||||
|
||||
def finalize(run: Run, outcome: str, note: str = "") -> None:
|
||||
"""Close a run. 'succeeded' needs every step done; 'failed' needs a failed step."""
|
||||
def _seal_of(data: dict) -> str:
|
||||
"""Fingerprint of a closed record. Timestamps of allowed later updates and the exercise marker are not part of it."""
|
||||
import hashlib
|
||||
body = {k: v for k, v in data.items() if k not in ("seal", "updated", "exercise_recorded")}
|
||||
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def _read_json(path) -> dict | None:
|
||||
try:
|
||||
return json.loads(Path(path).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def seal_state(run: Run) -> str:
|
||||
"""'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed).
|
||||
|
||||
Judged on what is on disk, not on the in-memory copy."""
|
||||
disk = _read_json(run.path) or run.data
|
||||
if not disk.get("outcome"):
|
||||
return "open"
|
||||
if not disk.get("seal"):
|
||||
return "unsealed"
|
||||
return "sealed" if disk["seal"] == _seal_of(disk) else "broken"
|
||||
|
||||
|
||||
def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None:
|
||||
"""Close a run: its outcome is recorded, its record sealed and locked. An optional rating and comment are
|
||||
recorded with it, because a closed run can no longer take feedback.
|
||||
|
||||
'succeeded' needs every step done; 'failed' needs a failed or pushed-through step."""
|
||||
if run.data.get("outcome"):
|
||||
raise RunError(f"run already finished ({run.data['outcome']})")
|
||||
if outcome not in OUTCOMES:
|
||||
|
|
@ -373,10 +421,14 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
|
|||
gstates = [g["state"] for g in run.data.get("gates", {}).values()]
|
||||
if outcome == "failed" and not ({"failed", "pushed"} & set(states) or {"failed", "pushed"} & set(gstates)):
|
||||
raise RunError("a run can be finished as failed only when a step or gate has failed or was pushed through; otherwise abandon it")
|
||||
entry = _feedback_entry(rating, comment) if rating else None # validated before anything changes
|
||||
run.data["outcome"] = outcome
|
||||
run.data["finished"] = _now()
|
||||
if note.strip():
|
||||
run.data["outcome_note"] = _clean(note.strip(), "note")
|
||||
if entry:
|
||||
run.data.setdefault("feedback", []).append(entry)
|
||||
run.data["seal"] = _seal_of(run.data)
|
||||
run.save()
|
||||
|
||||
|
||||
|
|
@ -462,5 +514,5 @@ def apply_exercise(run: Run, today: date | None = None) -> str:
|
|||
status = STATUS_LINE.search(new).group(0).split(": ", 1)[1]
|
||||
run.pack.data["exercise_status"] = status
|
||||
run.data["exercise_recorded"] = status
|
||||
run.save()
|
||||
run.save(allow=("exercise_recorded",)) # the one thing a closed run may still receive
|
||||
return status
|
||||
|
|
|
|||
60
rtut/ui.py
60
rtut/ui.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ def maturity_block(app, i: int, pack) -> str:
|
|||
|
||||
# ---- index sections ---------------------------------------------------------------------------------------------
|
||||
|
||||
def index_sections(app) -> str:
|
||||
def index_sections(app, home: bool = False) -> str:
|
||||
reg = app.registry()
|
||||
prj = []
|
||||
for pid, d in reg.projects.items():
|
||||
|
|
@ -75,8 +75,10 @@ def index_sections(app) -> str:
|
|||
tpl = [f"<li><a href='/template/{e(tid)}'>{e(d.data['title'])}</a> "
|
||||
f"<span class=note>{e(d.data.get('goal', ''))}</span></li>" if d.ok else f"<li class=bad>{e(tid)}: invalid</li>"
|
||||
for tid, d in reg.templates.items()]
|
||||
out = f"<h1>Projects</h1><ul>{''.join(prj) or '<li class=note>No projects yet. Start one from a template.</li>'}</ul>"
|
||||
out += f"<h2>Project templates</h2><ul>{''.join(tpl) or '<li class=note>No templates found.</li>'}</ul>"
|
||||
from .ui import heading
|
||||
link = "/projects" if home else None
|
||||
out = f"{heading('h1', 'Projects', link)}<ul>{''.join(prj) or '<li class=note>No projects yet. Start one from a template.</li>'}</ul>"
|
||||
out += f"{heading('h2', 'Project templates', link)}<ul>{''.join(tpl) or '<li class=note>No templates found.</li>'}</ul>"
|
||||
return out
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ def _flow_view(c: Ctx, ov: dict) -> str:
|
|||
out.append(_next_banner(c))
|
||||
for st in fl.data["stages"]:
|
||||
out.append(_stage(c, st))
|
||||
if c.finished or _all_complete(run):
|
||||
if c.finished or _all_complete(run) or R1.latest_feedback(run):
|
||||
out.append(c.app.feedback_form(c.i, c.rid, run))
|
||||
out.append(_close(c))
|
||||
return "".join(out)
|
||||
|
|
@ -603,9 +603,8 @@ 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>")
|
||||
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2><p>{note}</p>{c.app.locked_note(run)}"
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</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]
|
||||
|
|
@ -619,7 +618,7 @@ def _close(c: Ctx) -> str:
|
|||
"<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>")
|
||||
f"{c.app.close_feedback_fields(run)}<p class=row>{' '.join(btns)}</p></form></div>")
|
||||
|
||||
|
||||
# ---- actions -------------------------------------------------------------------------------------------------------------------
|
||||
|
|
@ -638,7 +637,7 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|||
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", ""))
|
||||
R1.finalize(run, form.get("outcome", ""), form.get("note", ""), rating=form.get("rating") or None, comment=form.get("fb_comment", ""))
|
||||
return f"{base}#close"
|
||||
if parts == ["mode"]:
|
||||
R.switch_mode(run, form.get("to", ""))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue