Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase step
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 15:50:56 +02:00
parent 6716cee921
commit 8d62c1fbe8
22 changed files with 1025 additions and 35 deletions

59
rtut/activity.py Normal file
View file

@ -0,0 +1,59 @@
"""Latest activities across all runs and reviews, newest first. Derived from run records; nothing extra is stored."""
from __future__ import annotations
from . import maturity as M, run as R
def _step_title(run, sid: str) -> str:
try:
return R.Run.step_def(run, sid).get("title", sid)
except Exception:
return sid
def events_for(pack, pack_index: int | None) -> list[dict]:
out = []
for rid in R.list_runs(pack):
run = R.load(pack, rid)
d = run.data
link = f"/run/{pack_index}/{rid}" if pack_index is not None else None
who = d["operator"]
base = {"pack": pack.data.get("title", pack.id), "run": rid, "link": link, "project": (d.get("context") or {}).get("project")}
def ev(at, text, who=who):
if at:
out.append({**base, "at": at, "who": who, "text": text})
ev(d["created"], f"started a run ({d.get('mode', 'manual')} mode)")
for sid, st in d["steps"].items():
title = _step_title(run, sid)
if st.get("finished"):
verb = {"done": "finished", "failed": "failed", "pushed": "pushed through"}.get(st["state"])
if verb:
ev(st["finished"], f"{verb} step “{title}”")
for gid, gs in d.get("gates", {}).items():
when = max([r["at"] for r in gs.get("results", {}).values()] or [""])
if gs["state"] in ("passed", "failed") and when:
ev(when, f"gate “{gid}” {gs['state']}")
if gs["state"] == "waived":
ev(gs["waiver"]["at"], f"waived gate “{gid}”: {gs['waiver']['reason']}")
for dv in d.get("deviations", []):
if dv["kind"] in ("override", "changed-after-use"):
ev(dv["at"], f"{dv['kind'].replace('-', ' ')}: {dv['item']} ({dv['reason']})", dv.get("who", who))
for fb in d.get("feedback", []):
ev(fb["at"], f"rated the run {fb['rating']}" + (f": {fb['comment']}" if fb["comment"] else ""))
if d.get("outcome"):
ev(d.get("finished"), f"finished the run as {d['outcome']}")
for rv in M._reviews(pack):
out.append({"pack": pack.data.get("title", pack.id), "run": None, "link": f"/pack/{pack_index}" if pack_index is not None else None,
"project": None, "at": rv["at"], "who": rv["by"], "text": "reviewed the runbook" + (f": {rv['note']}" if rv.get("note") else "")})
return out
def latest(packs: list, limit: int = 15) -> list[dict]:
events: list[dict] = []
for i, p in enumerate(packs):
if p.ok and p.data.get("engine") == "native":
events += events_for(p, i)
events.sort(key=lambda x: x["at"], reverse=True)
return events[:limit]

63
rtut/artefacts.py Normal file
View file

@ -0,0 +1,63 @@
"""Opening and downloading run artefacts, safely.
An artefact is a *reference* to something a run produced: a file path or a URL held in a datapoint. The UI may serve a local
file only if it is exactly the artefact's resolved location, is a regular file of reasonable size, is not in a sensitive place,
and does not look like it contains a secret. Anything else is refused with a reason.
"""
from __future__ import annotations
import mimetypes
from pathlib import Path
from .pack import SECRET_RE
MAX_BYTES = 5 * 1024 * 1024
SENSITIVE_PARTS = {".ssh", ".gnupg", ".aws", ".kube", ".git", ".password-store", "runbook-runs", "sops", ".docker"}
SENSITIVE_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".kdbx", ".age")
SENSITIVE_NAMES = {"shadow", "gshadow", "id_rsa", "id_ed25519", "keys.txt", ".env", ".netrc", ".pgpass"}
def is_url(location: str) -> bool:
return location.startswith(("https://", "http://"))
def check(location: str | None) -> tuple[Path | None, str]:
"""(path, '') if the file may be served, else (None, reason)."""
if not location:
return None, "the location is not known yet"
if is_url(location):
return None, "this artefact is a link, not a local file"
p = Path(location).expanduser()
if not p.is_absolute():
return None, "only absolute file locations can be opened"
try:
p = p.resolve()
except OSError:
return None, "the location cannot be resolved"
if not p.exists():
return None, "the file does not exist (yet)"
if not p.is_file():
return None, "the location is not a regular file"
parts = {x.lower() for x in p.parts}
if parts & SENSITIVE_PARTS or p.name.lower() in SENSITIVE_NAMES or p.name.lower().endswith(SENSITIVE_SUFFIXES):
return None, "this file is in a sensitive place and is never served"
try:
size = p.stat().st_size
if size > MAX_BYTES:
return None, f"the file is larger than {MAX_BYTES // (1024 * 1024)} MB"
head = p.read_bytes()[: 256 * 1024]
except OSError as e:
return None, f"the file cannot be read: {e.strerror}"
if SECRET_RE.search(head.decode("utf-8", "ignore")):
return None, "the file looks like it contains a secret and is never served"
return p, ""
def content(path: Path) -> tuple[bytes, str, bool]:
"""(bytes, content type, is_text). Text is served as plain text; everything else only as a download."""
data = path.read_bytes()
try:
data.decode("utf-8")
return data, "text/plain; charset=utf-8", True
except UnicodeDecodeError:
return data, mimetypes.guess_type(path.name)[0] or "application/octet-stream", False

View file

@ -70,7 +70,7 @@ def load_set(path: Path) -> Doc:
class Registry:
"""Everything found under one or more repository roots."""
def __init__(self, roots: list[Path]):
def __init__(self, roots: list[Path], extra_sets: dict[str, Doc] | None = None):
self.roots = [Path(r) for r in roots]
self.sets: dict[str, Doc] = {}
self.projects: dict[str, Doc] = {}
@ -85,6 +85,8 @@ class Registry:
d = _load(f, "project.schema.json"); self.templates[d.id] = d
for f in P.discover(r):
pk = P.load(f); self.packs[pk.id] = pk
for sid, doc in (extra_sets or {}).items(): # app-level sets (e.g. the user): repository sets win
self.sets.setdefault(sid, doc)
def set_layers(reg: Registry, set_id: str, _seen: tuple = ()) -> list[tuple[str, dict]]:

View file

@ -389,17 +389,67 @@ def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = S
return "abandoned" if (now - last).days >= stale_days else "stalled"
def _ts(s: str) -> datetime:
return datetime.fromisoformat(s)
def fmt_duration(seconds: float | None) -> str:
"""Human duration: '45 s', '3 min 20 s', '1 h 5 min', '2 d 3 h'."""
if seconds is None:
return "-"
s = int(round(seconds))
if s < 60:
return f"{s} s"
if s < 3600:
return f"{s // 60} min {s % 60} s" if s % 60 else f"{s // 60} min"
if s < 86400:
return f"{s // 3600} h {(s % 3600) // 60} min" if (s % 3600) // 60 else f"{s // 3600} h"
return f"{s // 86400} d {(s % 86400) // 3600} h" if (s % 86400) // 3600 else f"{s // 86400} d"
def run_times(run: Run, now: datetime | None = None) -> dict:
"""Start, finish and durations of one run.
elapsed: start to finish (to now while unfinished); working: the time steps were actually in progress;
step_seconds: per step, from start to finish.
"""
now = now or datetime.now(timezone.utc)
start = _ts(run.data["created"])
fin = run.data.get("finished")
end = _ts(fin) if fin else now
steps = {}
for sid, st in run.data["steps"].items():
if st.get("started") and st.get("finished"):
steps[sid] = max(0.0, (_ts(st["finished"]) - _ts(st["started"])).total_seconds())
return {"started": run.data["created"], "finished": fin, "elapsed": max(0.0, (end - start).total_seconds()),
"finished_run": bool(fin), "working": sum(steps.values()), "step_seconds": steps}
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
"""How often a runbook was tried and how it went, derived from its run records."""
"""How often a runbook was tried and how it went, derived from its run records.
avg_seconds / median_seconds / fastest_seconds are the elapsed times of *succeeded* runs only:
unfinished and failed runs say nothing about how long a working run takes.
"""
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
"feedback": {"good": 0, "neutral": 0, "poor": 0}}
durations: list[float] = []
for rid in list_runs(pack):
r = load(pack, rid)
card["tried"] += 1
card[effective_outcome(r, now, stale_days)] += 1
outcome = effective_outcome(r, now, stale_days)
card[outcome] += 1
if outcome == "succeeded" and r.data.get("finished"):
durations.append(run_times(r)["elapsed"])
fb = latest_feedback(r)
if fb:
card["feedback"][fb["rating"]] += 1
durations.sort()
card["avg_seconds"] = sum(durations) / len(durations) if durations else None
card["median_seconds"] = (durations[len(durations) // 2] if len(durations) % 2 else
(durations[len(durations) // 2 - 1] + durations[len(durations) // 2]) / 2) if durations else None
card["fastest_seconds"] = durations[0] if durations else None
card["timed_runs"] = len(durations)
return card

View file

@ -56,13 +56,49 @@ def _coerce(run: Run, dp_id: str, value: str) -> tuple[str | None, str | None]:
return value, None
def set_datapoint(run: Run, dp_id: str, value: str, how: str = "manual") -> str:
"""Give a datapoint a value. Invalid input leaves it in state 'err' (never stores it)."""
def datapoint_users(run: Run, dp_id: str) -> list[str]:
"""Steps that have already started and use this datapoint: they worked with its current value."""
return [sid for sid, s in flow(run).steps.items()
if dp_id in s.get("uses", []) and run.data["steps"][sid]["state"] in ("running", "done", "failed", "pushed")]
def changes_needing_override(run: Run, changes: dict[str, str]) -> dict[str, list[str]]:
"""Of proposed {datapoint: new value}, those that steps already used and that really change the value."""
out = {}
for dp, v in changes.items():
rec = run.data["datapoints"].get(dp)
if rec is None or rec.get("value") is None or str(v) == str(rec["value"]):
continue
users = datapoint_users(run, dp)
if users:
out[dp] = users
return out
def set_datapoint(run: Run, dp_id: str, value: str, how: str = "manual",
override_reason: str = "", acknowledge_risk: bool = False) -> str:
"""Give a datapoint a value. Invalid input leaves it in state 'err' (never stores it).
Changing a value that steps have already used is a deviation: it needs the operator's override
(reason and acknowledged risk), only in manual mode, and is recorded as 'changed-after-use'.
"""
_writable(run)
if dp_id not in flow(run).datapoints:
raise RunError(f"no such datapoint: {dp_id}")
clean, err = _coerce(run, dp_id, value)
rec = run.data["datapoints"][dp_id]
users = datapoint_users(run, dp_id) if how != "initial" else []
if users and rec.get("value") is not None and str(value) != str(rec["value"]):
risk = "Steps that already ran used the previous value, so their results may no longer match this one."
if run.data["mode"] != "manual":
raise RunError(f"{dp_id} was already used by {', '.join(users)}; take over (manual mode) to change it")
if not override_reason.strip():
raise RunError(f"{dp_id} was already used by {', '.join(users)}. Risk: {risk} "
"To change it give a reason and acknowledge the risk; this is recorded as a deviation.")
if not acknowledge_risk:
raise RunError("changing a value that steps already used needs the operator to acknowledge the risk")
_deviate(run, "changed-after-use", dp_id, [f"{s} already used {dp_id} = {rec['value']!r}" for s in users],
override_reason, risk, True)
clean, err = _coerce(run, dp_id, value)
if err:
rec.update(state="err", error=err, at=_now())
rec.pop("value", None)

View file

@ -13,6 +13,7 @@ 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 . import activity as ACT, user as USER
from .attrs import Registry
CSS = """
@ -65,8 +66,9 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
.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=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}
.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}details>summary{list-style-position:outside}
@media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}}
@ -98,8 +100,12 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
def score_line(c: dict) -> str:
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
line = (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
f"abandoned {c['abandoned']} · stalled {c['stalled']}")
if c.get("avg_seconds") is not None:
line += (f" · average {R.fmt_duration(c['avg_seconds'])} per successful run"
f" (fastest {R.fmt_duration(c['fastest_seconds'])}, {c['timed_runs']} timed)")
return line
class App:
@ -109,8 +115,44 @@ class App:
self.packs = [P.load(p) for r in roots for p in P.discover(r)]
def registry(self) -> Registry:
"""Fresh view of projects, attribute sets, templates and packs (files may have changed)."""
return Registry(self.roots)
"""Fresh view of projects, attribute sets, templates and packs (files may have changed).
The active user is available to every project as the app-level attribute set `user-attributes`."""
return Registry(self.roots, extra_sets={USER.USER_SET_ID: USER.attribute_set()})
def operator(self) -> str:
return USER.current()["operator"]
def who(self, name: str = "operator") -> str:
"""The active user, as a hidden field and a line, instead of asking for a name again."""
u = USER.current()
label = f"{e(u['display_name'])} ({e(u['operator'])})" if u["display_name"] else e(u["operator"])
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) -> str:
events = ACT.latest(self.packs, limit)
if not events:
return "<h2>Latest activities</h2><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"<h2>Latest activities</h2><ul class=feed>{rows}</ul>"
def user_page(self, msg: str = "") -> bytes:
u = USER.current()
env = ("<p class=note>The operator is set by the <code>RTUT_USER</code> environment variable, which takes precedence over "
"anything saved here.</p>" if u["source"] == "environment" else "")
src = {"environment": "the environment", "saved": "your saved settings", "login": "your operating-system login"}[u["source"]]
return page("You", f"<h1>You</h1>{'<p class=bad>' + e(msg) + '</p>' if msg else ''}"
f"<p>Runs, reviews and activity are recorded under <b>{e(u['operator'])}</b>, taken from {src}.</p>{env}"
"<p class=note>These are the attributes of the app-level set <code>user-attributes</code>. Every project can reuse it "
"(<code>attribute_sets: [user-attributes]</code>). If the UI later sits behind a login, the login fills the same attribute.</p>"
f"<form method=post action='/user'>{self.hidden()}"
f"<p><label>Operator (one word)<br><input name=operator value='{e(u['operator'])}' required pattern='[A-Za-z0-9][A-Za-z0-9._-]*' maxlength=40></label></p>"
f"<p><label>Display name (optional)<br><input name=display_name value='{e(u['display_name'])}' maxlength=80></label></p>"
"<button>Save</button></form>")
def pack_index(self, pack_id: str) -> int | None:
return next((i for i, p in enumerate(self.packs) if p.id == pack_id), None)
@ -130,7 +172,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 page("Runbooks", UP.index_sections(self) + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
return page("Runbooks", UP.index_sections(self) + self.activities_html() + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
def pack_page(self, i: int) -> bytes:
p = self.packs[i]; d = p.data
@ -152,15 +194,17 @@ class App:
f"<p>{e(d.get('outcome', ''))}</p><p class=note>{e(score_line(R.scorecard(p)))}</p>"
f"{UP.maturity_block(self, i, p)}{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>")
f"{self.who()}<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)
t = R.run_times(r)
took = (f"took {R.fmt_duration(t['elapsed'])}" if t["finished_run"] else f"running {R.fmt_duration(t['elapsed'])}")
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>")
f"<span class=pill data-s='{e(R.effective_outcome(r))}'>{e(R.effective_outcome(r))}</span> "
f"<span class=note>{e(UI2.stamp(t['started']))} · {e(took)}</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:
@ -242,7 +286,9 @@ class App:
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"]
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
f"<p class=note>{UI2.times_line(r)}</p>"]
step_secs = R.run_times(r)["step_seconds"]
if msg:
out.append(f"<p class=bad>{e(msg)}</p>")
blocked = False
@ -310,7 +356,8 @@ class App:
fold = "" if state in ("done", "skipped") else " open" # finished steps start folded; the triangle toggles
out.append(f"<details{anchor} class='step {state}'{fold}><summary><span class=head>"
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=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}"
f"{' · took ' + e(R.fmt_duration(step_secs[s['id']])) if s['id'] in step_secs and state == 'done' else ''}</span></span>"
f"<span class=pill data-s='{state}'>{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>")
@ -335,6 +382,17 @@ def make_handler(app: App, port_holder: list):
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'")
self.end_headers(); self.wfile.write(body)
def _send_file(self, code: int, body: bytes, ctype: str, download_name: str | None):
"""Artefacts: never rendered as a page (plain text or download), never sniffed, sandboxed."""
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
if download_name:
safe = "".join(ch for ch in download_name if ch.isalnum() or ch in "._-") or "artefact"
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
self.end_headers(); self.wfile.write(body)
def _host_ok(self) -> bool:
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
@ -347,6 +405,12 @@ def make_handler(app: App, port_holder: list):
return self._send(app.index())
if parts[0] == "pack":
return self._send(app.pack_page(int(parts[1])))
if parts == ["user"]:
return self._send(app.user_page())
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))
return self._send_file(status, body, ctype, dl or ("artefact-" + parts[4] if want_dl and status == 200 else None))
if parts[0] == "run":
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:
@ -369,6 +433,16 @@ def make_handler(app: App, port_holder: list):
if form.get("_t") != app.token:
return self._send(b"bad token", 403)
parts = [x for x in urlparse(self.path).path.split("/") if x]
if not form.get("operator", "").strip():
form["operator"] = app.operator() # the active user, unless a form names someone
if not form.get("by", "").strip():
form["by"] = app.operator()
if parts == ["user"]:
try:
USER.save(form.get("operator", ""), form.get("display_name", ""))
except ValueError as ex:
return self._send(app.user_page(str(ex)), 400)
return self._redirect("/user")
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":

View file

@ -52,8 +52,8 @@ def maturity_block(app, i: int, pack) -> str:
"context, and runs with deviations, do not count.</p>"
f"<p>Idempotency: <b>{e(idempotency_text(pack))}</b></p>"
f"{'<ul>' + revs + '</ul>' if revs else ''}"
f"<form method=post action='/pack/{i}/review'>{app.hidden()}<p class=row>"
"<input name=by placeholder='your name (one word)' required> <input name=note class=grow placeholder='review note (optional)'> "
f"<form method=post action='/pack/{i}/review'>{app.hidden()}{app.who('by')}<p class=row>"
"<input name=note class=grow placeholder='review note (optional)'> "
"<button>Record my review of this revision</button></p></form></div>")
@ -149,7 +149,7 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
if pack.spec == "runbook-pack/v0.2" else "")
action = (f"<form method=post action='/project/{e(pid)}/run' class=row>{app.hidden()}"
f"<input type=hidden name=pack value='{e(spec['pack'])}'><input type=hidden name=target value='{e(spec.get('target') or '')}'>"
f"<input name=operator placeholder='your name' size=10 required> {mode}<button>Start run</button></form>")
f"<input type=hidden name=operator value='{e(app.operator())}'> {mode}<button>Start run as {e(app.operator())}</button></form>")
link = f" <a href='/run/{i}/{e(ms['run'])}'>latest run</a>" if ms.get("run") and i is not None else ""
dev = f" · {ms['deviations']} deviation(s)" if ms.get("deviations") else ""
rows += (f"<tr><td>{e(pack.data.get('title', pack.id))}<br><span class=note>{e(spec['pack'])}"

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import html
from urllib.parse import quote
from . import run as R1, run2 as R
from . import artefacts as A, run as R1, run2 as R
from .flow import Flow
DONE = ("done", "skipped", "pushed")
@ -55,7 +55,7 @@ def start_form(app, i: int, pack) -> str:
"<label><input type=radio name=mode value=manual checked> <b>Manual</b>: you confirm every check and gate.</label><br>"
"<label><input type=radio name=mode value=auto-confirm> <b>Auto-confirm</b>: the engine confirms when preconditions hold. "
"It does not run commands; you (or another tool) do the work.</label></fieldset>"
f"{pre}<p><label>Your name (one word)<br><input name=operator required></label></p><button>Start run</button></form>")
f"{pre}{app.who()}<button>Start run</button></form>")
def create_run(app, i: int, form: dict):
@ -102,6 +102,7 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
out = [f"<h1>{e(pack.data['title'])}</h1>"
f"<p>{sp(d['mode'])} {sp(d['outcome']) if d.get('outcome') else ''} "
f"<span class=note>run {e(rid)} · operator {e(d['operator'])}{where}</span></p>"
f"<p class=note>{times_line(run)}</p>"
f"<p class=eyebrow>{prog['done']} of {prog['total']} done"
f"{' · ' + str(prog['pushed']) + ' pushed through' if prog['pushed'] else ''}</p>"
f"<progress max={prog['total']} value={prog['done'] + prog['pushed']} style='width:100%'></progress>"]
@ -122,6 +123,20 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
return page(pack.data["title"], "".join(out))
def stamp(iso: str | None) -> str:
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
def times_line(run) -> str:
"""Started, finished and how long: the run's timestamps at a glance."""
t = R1.run_times(run)
if t["finished_run"]:
return (f"Started {e(stamp(t['started']))} · finished {e(stamp(t['finished']))} · took <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
return (f"Started {e(stamp(t['started']))} · running for <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
# ---- flow view ---------------------------------------------------------------------------------------------------------
def _flow_view(c: Ctx, ov: dict) -> str:
@ -222,15 +237,31 @@ def _questions(c: Ctx, st: dict) -> str:
if not dps or c.finished:
return ""
rows = []
locked = False
for d in dps:
rec = c.run.data["datapoints"][d["id"]]
rows.append(f"<p>{_input(d, rec)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
users = R.datapoint_users(c.run, d["id"])
locked = locked or bool(users and rec.get("value") is not None)
rows.append(f"<p>{_input(d, rec, users=users)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
return (f"<div class=questions><b>Questions for this stage</b> <span class=note>(values used by its steps; never enter secrets)</span>"
f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}#stage-{e(st['id'])}'>"
f"{''.join(rows)}<button>Save answers</button></form></div>")
f"{''.join(rows)}{_override_fields(c, locked)}<button>Save answers</button></form></div>")
def _input(d: dict, rec: dict, editable: bool = True) -> str:
def _override_fields(c: Ctx, locked: bool) -> str:
"""Only shown when some value is already in use by started steps: changing it is a recorded deviation."""
if not locked:
return ""
if not c.manual:
return "<p class=note>Values already in use by steps can only be changed in manual mode: take over first.</p>"
return ("<details class=override><summary>Changing a value that steps already used (override)</summary>"
"<p class=bad>Risk: steps that already ran used the previous value, so their results may no longer match the new one. "
"The change is recorded as a deviation.</p>"
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you change it now (required for values in use)'></p>"
"<p><label><input type=checkbox name=acknowledge_risk value=1> I acknowledge the risk.</label></p></details>")
def _input(d: dict, rec: dict, editable: bool = True, users: list[str] | None = None) -> str:
name = f"d_{d['id']}"
val = rec.get("value", "")
dis = "" if editable else " disabled"
@ -242,7 +273,9 @@ def _input(d: dict, rec: dict, editable: bool = True) -> str:
else:
ctl = f"<input name='{name}' value='{e(val)}'{dis}>"
err = f" <span class=bad>{e(rec.get('error', ''))}</span>" if rec["state"] == "err" else ""
return f"<label>{e(d['label'])} {sp(rec['state'])}<br>{ctl}</label>{err}"
use = (f" <span class=chip title='changing it is a recorded deviation'>in use by {e(', '.join(users))}</span>"
if users and rec.get("value") is not None else "")
return f"<label>{e(d['label'])} {sp(rec['state'])}{use}<br>{ctl}</label>{err}"
# ---- steps ------------------------------------------------------------------------------------------------------------------
@ -262,6 +295,9 @@ def _step(c: Ctx, s: dict) -> str:
marks.append("<span class=chip data-s='irreversible'>irreversible</span>")
if s.get("auto_confirm") == "manual-only":
marks.append("<span class=chip>manual-only</span>")
took = R1.run_times(run)["step_seconds"].get(sid)
if took is not None and state in DONE:
marks.append(f"<span class=note>took {e(R1.fmt_duration(took))}</span>")
title = eff.get("title", s["title"]) if st.get("alternative") else s["title"]
actor = fl.actors.get(s["actor"], {}).get("role", s["actor"])
body = []
@ -461,6 +497,8 @@ def _data_view(c: Ctx) -> str:
groups = R.datapoint_rows(run, group=group, flt=flt)
out = [f"<p class=note>Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.</p>{ctl}"]
editable = not c.finished
any_locked = any(R.datapoint_users(run, x["id"]) and x["value"] is not None and x["editable"]
for rows_ in groups.values() for x in rows_)
out.append(f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}?view=data"
f"{'&group=' + quote(group) if group else ''}{'&filter=' + quote(flt) if flt else ''}'>")
for gname, rows in groups.items():
@ -473,19 +511,20 @@ def _data_view(c: Ctx) -> str:
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 (
ctl = _input(d, rec, editable and r["editable"], R.datapoint_users(run, r["id"])) if (editable and r["editable"]) else (
f"{e(r['label'])} {sp(r['state'])}<br><code>{e(r['value'] if r['value'] is not None else '')}</code>")
trs += f"<tr><td>{ctl}</td><td>{dims}</td><td class=note>{src}</td></tr>"
out.append(f"<table class=data><tr><th>Datapoint</th><th>Dimensions</th><th>Provenance</th></tr>{trs}</table>")
if not groups:
out.append("<p class=note>No datapoints match.</p>")
if editable:
out.append("<button>Save changed values</button>")
out.append(_override_fields(c, any_locked) + "<button>Save changed values</button>")
out.append("</form>")
arts = R.artefact_rows(run)
if arts:
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Produced by</th><th>Until</th></tr>" + "".join(
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Open</th><th>Produced by</th><th>Until</th></tr>" + "".join(
f"<tr><td>{e(a['title'])}</td><td><code>{e(a['location'] or '(not known yet)')}</code></td>"
f"<td>{_artefact_links(c, a)}</td>"
f"<td>{e(a['produced_by'])} {sp(a['produced_state'])}</td><td>{e(a['lives_until'] or '-')}</td></tr>" for a in arts) + "</table>")
if fl.actors:
counts: dict[str, int] = {}
@ -497,6 +536,32 @@ def _data_view(c: Ctx) -> str:
return "".join(out)
def _artefact_links(c: Ctx, a: dict) -> str:
loc = a["location"]
if loc and A.is_url(loc):
return f"<a href='{e(loc)}' target=_blank rel='noopener noreferrer'>Open link</a>"
path, why = A.check(loc)
if path is None:
return f"<span class=note>{e(why)}</span>"
url = f"{c.base}/artefact/{e(a['id'])}"
return f"<a href='{url}' target=_blank rel=noopener>Open</a> · <a href='{url}?download=1'>Download</a>"
def serve_artefact(app, i: int, rid: str, aid: str) -> tuple[int, bytes, str, str | None]:
"""(status, body, content type, download filename or None). Refusals come back as short plain text."""
run = R1.load(app.packs[i], rid)
if run.pack.spec != "runbook-pack/v0.2":
return 404, b"no artefacts", "text/plain; charset=utf-8", None
row = next((a for a in R.artefact_rows(run) if a["id"] == aid), None)
if row is None:
return 404, b"no such artefact", "text/plain; charset=utf-8", None
path, why = A.check(row["location"])
if path is None:
return 403, f"not served: {why}".encode(), "text/plain; charset=utf-8", None
body, ctype, is_text = A.content(path)
return 200, body, ctype, (None if is_text else path.name)
# ---- overview ------------------------------------------------------------------------------------------------------------------
def _overview_view(c: Ctx, ov: dict) -> str:
@ -590,6 +655,7 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
return f"{base}#current"
if parts == ["data"]:
bad = []
changes = {}
for k, v in form.items():
if not k.startswith("d_"):
continue
@ -597,7 +663,14 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
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":
changes[dp] = v
need = R.changes_needing_override(run, changes) # all or nothing: check before applying any
reason, ack = form.get("override_reason", ""), bool(form.get("acknowledge_risk"))
if need and not (reason.strip() and ack):
raise R1.RunError("already in use by started steps: " + "; ".join(f"{d} ({', '.join(u)})" for d, u in need.items())
+ ". To change them give a reason and acknowledge the risk; each change is recorded as a deviation.")
for dp, v in changes.items():
if R.set_datapoint(run, dp, v, override_reason=reason, acknowledge_risk=ack) == "err":
bad.append(f"{dp} ({run.data['datapoints'][dp]['error']})")
if bad:
raise ValueError("invalid value for: " + ", ".join(bad))

89
rtut/user.py Normal file
View file

@ -0,0 +1,89 @@
"""The active user: who is running things. Lives in the app, outside any project, as the attribute set `user-attributes`.
Order of derivation: the RTUT_USER environment variable (for a wrapper that knows the login), the saved
`user-attributes.yaml` in the app's config folder, then the operating-system login. If the UI later sits behind a
login, that login is what should fill the same attribute; forms already read only from here.
"""
from __future__ import annotations
import getpass
import os
import re
from pathlib import Path
import yaml
from .attrs import Doc
from .pack import SECRET_RE
USER_SET_ID = "user-attributes"
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,39}")
def config_dir() -> Path:
return Path(os.environ.get("RTUT_CONFIG_DIR") or (Path.home() / ".config" / "runbook-tutorials"))
def user_file() -> Path:
return config_dir() / "user-attributes.yaml"
def _one_word(text: str) -> str:
word = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip()).strip("-._")
return word[:40] or "operator"
def _saved() -> dict:
f = user_file()
if not f.exists():
return {}
try:
data = yaml.safe_load(f.read_text()) or {}
except yaml.YAMLError:
return {}
out = {}
for k, v in (data.get("attributes") or {}).items():
out[k] = v.get("value") if isinstance(v, dict) else v
return {k: str(v) for k, v in out.items() if v is not None}
def current() -> dict:
"""{'operator': one word, 'display_name': str, 'source': 'environment'|'saved'|'login'}"""
saved = _saved()
env = os.environ.get("RTUT_USER", "").strip()
if env:
return {"operator": _one_word(env), "display_name": saved.get("display_name", ""), "source": "environment"}
if saved.get("operator") and NAME.fullmatch(saved["operator"]):
return {"operator": saved["operator"], "display_name": saved.get("display_name", ""), "source": "saved"}
try:
login = getpass.getuser()
except Exception:
login = "operator"
return {"operator": _one_word(login), "display_name": saved.get("display_name", ""), "source": "login"}
def save(operator: str, display_name: str = "") -> Path:
operator = operator.strip()
if not NAME.fullmatch(operator):
raise ValueError("the operator name is one word: letters, digits, dot, dash or underscore (max 40)")
if SECRET_RE.search(operator) or SECRET_RE.search(display_name):
raise ValueError("names are never secret")
attrs: dict = {"operator": {"value": operator, "requirement": "required",
"note": "One word that identifies you in runs, reviews and activity."}}
if display_name.strip():
attrs["display_name"] = {"value": display_name.strip()[:80], "requirement": "optional"}
f = user_file()
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(yaml.safe_dump({"spec": "attribute-set/v0.1", "id": USER_SET_ID, "title": "User attributes",
"attributes": attrs}, sort_keys=False))
return f
def attribute_set() -> Doc:
"""The active user as an attribute set, for projects and forms to reuse (`attribute_sets: [user-attributes]`)."""
u = current()
attrs = {"operator": {"value": u["operator"], "requirement": "required"}}
if u["display_name"]:
attrs["display_name"] = {"value": u["display_name"], "requirement": "optional"}
return Doc(user_file(), {"spec": "attribute-set/v0.1", "id": USER_SET_ID, "title": "User attributes",
"attributes": attrs}, [])