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

View file

@ -132,6 +132,15 @@ Every attribute has:
Values are never secret (`secret` is not allowed; secret markers are refused). Only references to secrets are allowed.
### The active user: `user-attributes`
One attribute set lives in the app rather than in any project: `user-attributes`, holding the active user's `operator` (one word) and an
optional `display_name`. It comes from the `RTUT_USER` environment variable, else from the user's saved settings (`/user` page,
`~/.config/runbook-tutorials/user-attributes.yaml`), else from the operating-system login. Forms that used to ask "your name" (starting a
run, reviewing a runbook, starting a project run) show who you are running as and link to that page. Any project can reuse the set
(`attribute_sets: [user-attributes]`); a repository may define its own set of that id, which then takes precedence. If the UI later runs
behind a login, the login fills the same attribute.
### Layers, in order (later wins)
attribute sets listed by the project (each after its own `extends`) then the project's own `attributes`, then the target's

View file

@ -183,7 +183,7 @@ generic warning), (2) require an explicit confirmation, and (3) record a
An item passed over by push-through gets the distinct state `pushed` (steps, gates and datapoints): not done, not set, not an error.
Every override, waiver and push-through is a **deviation** in the run record:
Every override, waiver, push-through and change of a value that steps already used (`changed-after-use`) is a **deviation** in the run record:
`kind` (`override|waiver|push-through`), `item` (step or gate id), `mode`,
`unmet` (what was not satisfied), `risk_acknowledged`, `reason` (required for
overrides and waivers; automatic text for push-through), `who`, `at`. A run with

View file

@ -106,6 +106,29 @@ Rules the engine enforces:
- **Modes** are `manual` and `auto-confirm`. Auto-confirm applies confirmation only; running the step's own
command is a separate, later level ("auto-pilot with execution", RBT-WP-0006).
## Changing a value after steps used it
A v0.1 run fixes its parameters when it starts. In a v0.2 run datapoints can be edited (guided flow questions, data view), but a value
that steps have already started with is *in use*. Changing it needs the operator's override (a reason and an acknowledged risk), only in
manual mode, and is recorded as a `changed-after-use` deviation naming the steps and the previous value. A save containing any such
change is refused as a whole unless the override is given, so a refused save applies nothing. Values nobody used yet change freely,
which is what the questionnaire is for. A run with any deviation never counts towards maturity.
## Timestamps and durations
`created` is the start and `finished` (set when the run is closed) the end. `elapsed` is start to finish, or to now while the run is
unfinished; `working` is the time steps were in progress; each step has its own `started` and `finished`. The runbook's score carries
the average, median and fastest **elapsed time of succeeded runs only**: unfinished, failed and abandoned runs say nothing about how
long a working run takes. The UI shows this on the run, on each finished step, in the runs list and next to the score.
## Artefacts: open and download
An artefact is a reference to something a run produced. The UI links it when it is a URL, or when it is exactly the artefact's
resolved local file (`GET /run/<i>/<run>/artefact/<id>`, `?download=1` for an attachment). It refuses, with the reason: a file that
does not exist yet, anything that is not a regular file, files over 5 MB, files in sensitive places (`.ssh`, `.gnupg`, `.aws`, `.kube`,
`.git`, key and certificate files, run records), and files whose beginning looks like a secret. Text is served as plain text, never as a
page, with `nosniff` and a sandboxing CSP; other content is only offered as a download. Symlinks are judged by their real target.
## Context and revision
Every run (v0.1 and v0.2) records `pack_digest`, the revision of the pack directory it used (a hash of its files,

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}, [])

View file

@ -0,0 +1,5 @@
reviews:
- by: Bernd
at: '2026-09-29T13:19:03+00:00'
digest: 18e2c938b35bf59c
note: This is my first try.

View file

@ -116,7 +116,7 @@ stages:
requires: [warn]
uses: [keeper_name, weather, lamp_fuel, motto, logdir]
produces: [logbook-file]
command: mkdir -p {{logdir}} && echo "Night watch by {{keeper_name}} - {{weather}}, {{lamp_fuel}} lamp. {{motto}}." >> {{logdir}}/logbook.txt
command: mkdir -p {{logdir}} && echo "Night watch by {{keeper_name}} at $(date -u +%H:%M:%S) UTC - {{weather}}, {{lamp_fuel}} lamp. {{motto}}." >> {{logdir}}/logbook.txt
reversible: true
rollback: rm -rf {{logdir}}
verify: {done_when: The logbook exists, command: 'test -s {{logdir}}/logbook.txt', expect: exit-0}
@ -130,6 +130,7 @@ stages:
idempotent: conditional
idempotent_note: Safe to repeat only while the log file exists; a second run fails on the read.
title: Close the log for good
description: The entry carries the exact time it was written, to the second. Once the scratch folder is removed it cannot be written again the same way, so this step cannot be undone.
auto_confirm: manual-only # never confirmed by the engine on its own
actor: harbourmaster
owner: the-lighthouse

View file

@ -67,7 +67,7 @@ steps:
title: Write tonight's entry in the logbook
owner: the-lighthouse
description: The only step that changes anything, and only a file under /tmp/rtut-. Reversible, so it needs a confirm, not a typed phrase.
command: mkdir -p {{logdir}} && echo "Night watch by {{keeper}} - {{weather}}, {{lamp}} lamp, signal {{signal}}. {{motto}}." >> {{logdir}}/logbook.txt
command: mkdir -p {{logdir}} && echo "Night watch by {{keeper}} at $(date -u +%H:%M:%S) UTC - {{weather}}, {{lamp}} lamp, signal {{signal}}. {{motto}}." >> {{logdir}}/logbook.txt
risk: changes-state
rollback: rm -rf {{logdir}}
verify:
@ -87,10 +87,10 @@ steps:
- id: dawn
title: Put out the lamp and tidy up
owner: the-lighthouse
description: Removes the scratch folder. A run in which every step is done can offer to mark this pack exercised.
description: Removes the scratch folder. The entry carries the exact time it was written, so once it is gone it cannot be written again the same way.
command: rm -rf {{logdir}}
risk: changes-state
rollback: Repeat write-the-logbook to rebuild the entry.
risk: destructive
rollback: Not reversible. The logbook entry contains the exact time it was written, which cannot be reproduced.
verify:
done_when: The scratch folder is gone
command: test ! -e {{logdir}}

9
tests/conftest.py Normal file
View file

@ -0,0 +1,9 @@
import pytest
@pytest.fixture(autouse=True)
def _isolated_user(tmp_path_factory, monkeypatch):
"""Tests never read or write the real user's app settings."""
monkeypatch.setenv("RTUT_CONFIG_DIR", str(tmp_path_factory.mktemp("rtut-config")))
monkeypatch.delenv("RTUT_USER", raising=False)
monkeypatch.setattr("getpass.getuser", lambda: "tester-login")

View file

@ -0,0 +1,93 @@
import os
import shutil
from pathlib import Path
import pytest
from rtut import activity, artefacts as A, maturity as M, pack as P, run as R, run2
ROOT = Path(__file__).resolve().parent.parent
def repo_with(tmp_path, *names):
packs = []
for n in names:
d = tmp_path / "runbooks" / n; d.mkdir(parents=True)
shutil.copy(ROOT / ("runbooks/showcase-lighthouse-v2/pack.yaml" if n.endswith("2") else "runbooks/showcase-lighthouse/pack.yaml"), d / "pack.yaml")
packs.append(P.load(d / "pack.yaml"))
return packs
# ---- activity -----------------------------------------------------------------------------------------------------
def test_latest_activities_are_newest_first_across_packs_and_cover_the_main_events(tmp_path):
v1, v2 = repo_with(tmp_path, "one", "two2")
r = R.create(v1, "ann")
R.start(r, "read-the-sign"); R.verify(r, "read-the-sign", manual_ok=True); R.complete(r, "read-the-sign")
R.add_feedback(r, "good", "smooth")
M.add_review(v1, "bob", "read it")
q = run2.create(v2, "cy", {"logdir": "/tmp/rtut-act-x"})
run2.set_datapoint(q, "weather", "clear")
run2.start(q, "read-the-sky"); run2.fail(q, "read-the-sky", "x")
R.finalize(r, "abandoned")
ev = activity.latest([v1, v2], 50)
assert [e["at"] for e in ev] == sorted((e["at"] for e in ev), reverse=True)
text = " | ".join(f"{e['who']}: {e['text']}" for e in ev)
for frag in ("ann: started a run (manual mode)", "ann: finished step “Read the poem over the door”", "ann: rated the run good: smooth",
"bob: reviewed the runbook: read it", "cy: started a run (manual mode)", "cy: failed step “Read the sky”",
"ann: finished the run as abandoned"):
assert frag in text, frag
assert all(e["link"] for e in ev) and {e["pack"] for e in ev} == {v1.data["title"], v2.data["title"]}
assert len(activity.latest([v1, v2], 3)) == 3
def test_overrides_and_changed_values_are_visible_in_the_feed(tmp_path):
(v2,) = repo_with(tmp_path, "two2")
q = run2.create(v2, "cy", {"logdir": "/tmp/rtut-act-y"})
run2.start(q, "warn", alt="sound-horn", override_reason="want to see", acknowledge_risk=True)
text = " | ".join(e["text"] for e in activity.latest([v2], 20))
assert "override: warn (want to see)" in text
# ---- artefact serving rules ----------------------------------------------------------------------------------------
def test_a_plain_text_file_may_be_served(tmp_path):
f = tmp_path / "logbook.txt"; f.write_text("Night watch by Ada\n")
path, why = A.check(str(f))
assert path == f.resolve() and why == ""
data, ctype, is_text = A.content(path)
assert data == b"Night watch by Ada\n" and ctype.startswith("text/plain") and is_text
@pytest.mark.parametrize("make,frag", [
(lambda t: None, "not known yet"),
(lambda t: str(t / "missing.txt"), "does not exist"),
(lambda t: str(t), "not a regular file"),
(lambda t: "relative/file.txt", "absolute"),
(lambda t: "https://example.org/x", "link"),
])
def test_refused_locations(tmp_path, make, frag):
path, why = A.check(make(tmp_path))
assert path is None and frag in why
def test_sensitive_files_secrets_and_big_files_are_never_served(tmp_path):
ssh = tmp_path / ".ssh"; ssh.mkdir(); (ssh / "notes.txt").write_text("hello")
assert "sensitive" in A.check(str(ssh / "notes.txt"))[1]
key = tmp_path / "server.key"; key.write_text("x")
assert "sensitive" in A.check(str(key))[1]
runs = tmp_path / "runbook-runs"; runs.mkdir(); (runs / "r.json").write_text("{}")
assert "sensitive" in A.check(str(runs / "r.json"))[1]
leaky = tmp_path / "out.txt"; leaky.write_text("token " + "hvs." + "ABCDEFGHIJKLMN")
assert "secret" in A.check(str(leaky))[1]
big = tmp_path / "big.bin"; big.write_bytes(b"0" * (A.MAX_BYTES + 1))
assert "larger" in A.check(str(big))[1]
link = tmp_path / "link.txt"; os.symlink(str(ssh / "notes.txt"), link)
assert "sensitive" in A.check(str(link))[1] # a symlink is judged by where it really points
def test_binary_files_are_offered_only_as_downloads(tmp_path):
f = tmp_path / "blob.bin"; f.write_bytes(b"\xff\xfe\x00\x01")
path, _ = A.check(str(f))
_, ctype, is_text = A.content(path)
assert not is_text and "text/plain" not in ctype

View file

@ -426,3 +426,42 @@ def test_captures_and_pasted_verification_work_for_v02_runs(tmp_path):
R1.add_capture(r, "read-the-sky", "Weather tonight - fog.", "echo ...")
res = R1.verify_pasted(r, "read-the-sky", "Weather tonight - fog.")
assert res["passed"] and r.data["steps"]["read-the-sky"]["captures"][0]["output"].startswith("Weather")
# ---- changing datapoints after steps used them ------------------------------------------------------------------------
def test_changing_an_unused_datapoint_is_free_and_a_used_one_is_a_recorded_deviation(tmp_path):
r = new(tmp_path, values={"weather": "fog"})
assert R.set_datapoint(r, "weather", "clear") == "set" and r.data["deviations"] == [] # nobody used it yet
do_step(r, "read-the-sky") # uses weather
assert R.datapoint_users(r, "weather") == ["read-the-sky"]
with pytest.raises(R.RunError, match="already used by read-the-sky"):
R.set_datapoint(r, "weather", "storm")
with pytest.raises(R.RunError, match="acknowledge"):
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed")
assert r.data["params"]["weather"] == "clear" # refused: nothing changed
R.set_datapoint(r, "weather", "storm", override_reason="the forecast changed", acknowledge_risk=True)
assert r.data["params"]["weather"] == "storm"
dev = r.data["deviations"][0]
assert dev["kind"] == "changed-after-use" and dev["item"] == "weather" and "read-the-sky" in dev["unmet"][0] and "'clear'" in dev["unmet"][0]
assert dev["reason"] == "the forecast changed" and dev["risk_acknowledged"] is True
assert R.set_datapoint(r, "weather", "storm") == "set" and len(r.data["deviations"]) == 1 # the same value is not a change
def test_used_datapoints_cannot_be_changed_in_auto_confirm_mode(tmp_path):
r = new(tmp_path, mode="auto-confirm")
R.auto_confirm_tick(r); R.auto_confirm_tick(r)
r.data["steps"]["read-the-sky"]["state"] = "done"
with pytest.raises(R.RunError, match="take over"):
R.set_datapoint(r, "weather", "clear", override_reason="x", acknowledge_risk=True)
def test_a_run_that_changed_a_used_value_never_counts_towards_maturity(tmp_path):
r = new(tmp_path)
do_step(r, "read-the-sky")
R.set_datapoint(r, "weather", "clear", override_reason="changed my mind", acknowledge_risk=True)
assert r.data["deviations"]
with pytest.raises(R1.RunError, match="deviations"):
for k in r.data["steps"]:
r.data["steps"][k]["state"] = "done"
R1.exercise_update(r)

View file

@ -115,3 +115,39 @@ def test_runs_of_different_packs_in_one_repository_are_not_mixed(tmp_path):
assert R.scorecard(pa)["tried"] == 2 and R.scorecard(pb)["tried"] == 1
assert R.list_runs(pb) != R.list_runs(pa) and len(R.list_runs(pb)) == 1
assert R.scorecard(pb)["feedback"]["good"] == 0 and R.all_feedback(pb) == []
# ---- durations -----------------------------------------------------------------------------------------------
def test_fmt_duration():
assert [R.fmt_duration(x) for x in (None, 0, 45, 60, 200, 3600, 3900, 90000)] == ["-", "0 s", "45 s", "1 min", "3 min 20 s", "1 h", "1 h 5 min", "1 d 1 h"]
def test_run_times_and_average_of_succeeded_runs_only(pk):
from datetime import datetime, timedelta, timezone
import json
def timed(operator, seconds, outcome):
r = R.create(pk, operator)
start = datetime(2026, 9, 29, 10, 0, tzinfo=timezone.utc)
r.data["created"] = start.isoformat()
r.data["outcome"] = outcome
r.data["finished"] = (start + timedelta(seconds=seconds)).isoformat()
first = next(iter(r.data["steps"]))
r.data["steps"][first].update(state="done", started=(start + timedelta(seconds=10)).isoformat(),
finished=(start + timedelta(seconds=40)).isoformat())
r.path.write_text(json.dumps(r.data))
return r
a = timed("a", 600, "succeeded"); timed("b", 1200, "succeeded"); timed("c", 9000, "failed"); timed("d", 300, "abandoned")
t = R.run_times(R.load(pk, a.data["run_id"]))
assert t["elapsed"] == 600 and t["finished_run"] and t["step_seconds"] == {next(iter(a.data["steps"])): 30.0} and t["working"] == 30.0
c = R.scorecard(pk)
assert c["avg_seconds"] == 900 and c["median_seconds"] == 900 and c["fastest_seconds"] == 600 and c["timed_runs"] == 2 # the failed 9000 s run is not counted
assert R.scorecard(pk)["succeeded"] == 2
def test_unfinished_run_elapsed_runs_to_now_and_no_average_without_succeeded_runs(pk):
from datetime import datetime, timedelta, timezone
r = R.create(pk, "x")
t = R.run_times(r, now=datetime.now(timezone.utc) + timedelta(seconds=90))
assert not t["finished_run"] and 89 <= t["elapsed"] <= 91
assert R.scorecard(pk)["avg_seconds"] is None and R.scorecard(pk)["timed_runs"] == 0

263
tests/test_ui_round3.py Normal file
View file

@ -0,0 +1,263 @@
import http.client
import json
import shutil
import subprocess
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode
import pytest
import yaml
from rtut import run as R1, run2 as R, ui
ROOT = Path(__file__).resolve().parent.parent
_scratch = []
@pytest.fixture
def srv(tmp_path):
for name, src in (("v1", "showcase-lighthouse"), ("v2", "showcase-lighthouse-v2")):
d = tmp_path / "runbooks" / name; d.mkdir(parents=True)
shutil.copy(ROOT / f"runbooks/{src}/pack.yaml", d / "pack.yaml")
(tmp_path / "projects/watch").mkdir(parents=True)
(tmp_path / "projects/watch/project.yaml").write_text(yaml.safe_dump({
"spec": "project/v0.1", "id": "watch", "title": "Watch project", "environment": "rehearsal",
"attribute_sets": ["user-attributes"], "attributes": {"logdir": "/tmp/rtut-r3-proj"},
"runbooks": [{"pack": "rtut.showcase-lighthouse"}]}))
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
s.repo = tmp_path
yield s
s.shutdown()
while _scratch:
shutil.rmtree(_scratch.pop(), ignore_errors=True)
def raw(s, method, path, form=None):
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
body = urlencode(form) if form is not None else None
c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse()
return r.status, r.getheader("Location"), r.read().decode(errors="replace"), {k.lower(): v for k, v in r.getheaders()}
def req(s, method, path, form=None):
return raw(s, method, path, form)[:3]
def post(s, path, **form):
return req(s, "POST", path, {"_t": s.app.token, **form})
def pidx(s, pack_id):
return s.app.pack_index(pack_id)
def new_v2(s, **extra):
logdir = f"/tmp/rtut-r3-{uuid.uuid4().hex[:8]}"
_scratch.append(logdir)
st, loc, _ = post(s, f"/run/new/{pidx(s, 'rtut.showcase-lighthouse-v2')}", p_logdir=logdir, **extra) # note: no operator posted
assert st == 303
return loc.split("#")[0], logdir
def step(s, base, sid, **form):
assert post(s, f"{base}/{sid}/start", **form)[0] == 303
run = R1.load(s.app.packs[int(base.split("/")[2])], base.rsplit("/", 1)[1]) # reload: the chosen alternative is known now
cmd = R.command_for(run, sid)
if cmd:
subprocess.run(cmd, shell=True, check=True, capture_output=True)
eff = R.step_def(run, sid)
manual = (eff["verify"].get("expect") or "manual") == "manual" and not eff["verify"].get("datapoints_ok")
assert (post(s, f"{base}/{sid}/verify", ok="1") if manual else post(s, f"{base}/{sid}/verify", yes="1"))[0] == 303
assert post(s, f"{base}/{sid}/done")[0] == 303
# ---- (a) the active user instead of asking for a name ----------------------------------------------------------
def test_user_page_shows_source_and_saves(srv):
_, _, body = req(srv, "GET", "/user")
assert "tester-login" in body and "your operating-system login" in body and "user-attributes" in body
assert post(srv, "/user", operator="bernd", display_name="Bernd W.")[0] == 303
_, _, body = req(srv, "GET", "/user")
assert "<b>bernd</b>" in body and "your saved settings" in body and "Bernd W." in body
st, _, body = post(srv, "/user", operator="two words")
assert st == 400 and "one word" in body
def test_forms_use_the_active_user_and_do_not_ask_for_a_name(srv):
post(srv, "/user", operator="bernd", display_name="Bernd W.")
i1, i2 = pidx(srv, "rtut.showcase-lighthouse"), pidx(srv, "rtut.showcase-lighthouse-v2")
for path in (f"/pack/{i1}", f"/pack/{i2}"):
_, _, body = req(srv, "GET", path)
assert "Running as <b>Bernd W. (bernd)</b>" in body and "<a href='/user'>change</a>" in body
assert "Your name (one word)" not in body and "<input name=operator" not in body
_, _, body = req(srv, "GET", "/project/watch")
assert "Start run as bernd" in body and "name=operator value='bernd'" in body
def test_a_run_started_without_a_posted_name_belongs_to_the_active_user_and_reviews_too(srv):
post(srv, "/user", operator="carol")
base, _ = new_v2(srv)
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["operator"] == "carol"
i1 = pidx(srv, "rtut.showcase-lighthouse")
assert post(srv, f"/pack/{i1}/review", note="ok")[0] == 303
_, _, body = req(srv, "GET", f"/pack/{i1}")
assert "carol" in body and "Reviewed: <b>yes</b>" in body
def test_user_attributes_are_reusable_by_projects_through_the_app_level_set(srv):
post(srv, "/user", operator="dave")
_, _, body = req(srv, "GET", "/project/watch") # the project lists attribute_sets: [user-attributes]
assert "This project has problems" not in body and "Watch project" in body
from rtut import project as J
reg = srv.app.registry()
assert J.validate(reg, reg.projects["watch"]) == [] # the set is found although no repository defines it
member = reg.projects["watch"].data["runbooks"][0]
res = J.member_attributes(reg, reg.projects["watch"].data, member)
assert "operator" in res["unused"] # v0.1 showcase has no operator input: reported, harmless
assert "Unused attributes: " in body and "operator" in body.split("Unused attributes: ")[1].split("</p>")[0]
# ---- (b) the irreversible example is irreversible -------------------------------------------------------------------
def test_the_showcase_logbook_entry_carries_the_time_and_close_log_says_why_it_cannot_be_undone(srv):
base, logdir = new_v2(srv)
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert "$(date -u" in run.pack.data["stages"][1]["steps"][1]["command"]
_, _, body = req(srv, "GET", base)
assert "cannot be undone" in body and "to the second" in body
# ---- (c) latest activities ---------------------------------------------------------------------------------------------
def test_main_page_lists_latest_activities_newest_first_with_links(srv):
_, _, body = req(srv, "GET", "/")
assert "Latest activities" in body and "Nothing has happened yet" in body
post(srv, "/user", operator="erin")
base, _ = new_v2(srv, p_weather="fog")
step(srv, base, "read-the-sign")
step(srv, base, "light-lamp")
_, _, body = req(srv, "GET", "/")
feed = body.split("Latest activities")[1].split("<h2>Runbooks</h2>")[0]
assert "<b>erin</b> started a run (manual mode)" in feed and "finished step “Light the lamp”" in feed
assert feed.index("Light the lamp") < feed.index("Read the poem over the door") < feed.index("started a run") # newest first
assert f"<a href='{base}'>open</a>" in feed
# ---- (d) changing values that steps already used --------------------------------------------------------------------------
def test_changing_a_value_in_use_needs_an_override_shows_the_lock_and_is_recorded(srv):
base, _ = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base + "?view=data")
assert "in use by" not in body and "Changing a value that steps already used" not in body # nothing used yet: no lock, no override
assert post(srv, f"{base}/data", d_weather="clear")[0] == 303 # free change before use
step(srv, base, "read-the-sky")
_, _, body = req(srv, "GET", base + "?view=data")
assert "in use by read-the-sky" in body and "Changing a value that steps already used (override)" in body
st, _, err = post(srv, f"{base}/data", d_weather="storm")
assert st == 400 and "already in use" in err and "read-the-sky" in err
st, _, err = post(srv, f"{base}/data", d_weather="storm", override_reason="forecast changed")
assert st == 400 and "acknowledge" in err
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["params"]["weather"] == "clear" and run.data["deviations"] == [] # refused: nothing changed
assert post(srv, f"{base}/data", d_weather="storm", override_reason="forecast changed", acknowledge_risk="1")[0] == 303
_, _, ov = req(srv, "GET", base + "?view=overview")
assert ">changed-after-use<" in ov and "forecast changed" in ov and "read-the-sky already used weather" in ov
def test_a_refused_save_applies_none_of_its_changes(srv):
base, _ = new_v2(srv, p_weather="fog")
step(srv, base, "read-the-sky")
st, _, _ = post(srv, f"{base}/data", d_weather="storm", d_motto="Hold fast") # weather is locked, motto is free
assert st == 400
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
assert run.data["params"]["motto"] != "Hold fast" and run.data["params"]["weather"] == "fog"
def test_in_auto_confirm_mode_used_values_cannot_be_changed_until_taking_over(srv):
base, _ = new_v2(srv, mode="auto-confirm", p_weather="fog")
post(srv, f"{base}/tick", n="3")
run = R1.load(srv.app.packs[pidx(srv, "rtut.showcase-lighthouse-v2")], base.rsplit("/", 1)[1])
if not R.datapoint_users(run, "weather"):
run.data["steps"]["read-the-sky"]["state"] = "done"; run.save()
_, _, body = req(srv, "GET", base + "?view=data")
assert "can only be changed in manual mode" in body and "name=override_reason" not in body
st, _, err = post(srv, f"{base}/data", d_weather="clear", override_reason="x", acknowledge_risk="1")
assert st == 400 and "take over" in err
# ---- (e) artefacts can be opened and downloaded ---------------------------------------------------------------------------
def finish_logbook(srv, base):
for sid in ("read-the-sign", "light-lamp", "read-the-sky"):
step(srv, base, sid)
for i in (0, 1):
ck = post(srv, f"{base}/gate/lamp-check/check", idx=str(i), ok="1") if i == 0 else post(srv, f"{base}/gate/lamp-check/check", idx="1", yes="1")
assert ck[0] == 303
step(srv, base, "warn")
step(srv, base, "write-logbook")
def test_artefact_is_linked_and_can_be_opened_and_downloaded(srv):
base, logdir = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base + "?view=data")
assert "the file does not exist (yet)" in body and "artefact/logbook-file" not in body # nothing to open before it exists
assert raw(srv, "GET", f"{base}/artefact/logbook-file")[0] == 403
finish_logbook(srv, base)
_, _, body = req(srv, "GET", base + "?view=data")
assert f"<a href='{base}/artefact/logbook-file' target=_blank rel=noopener>Open</a>" in body and "?download=1'>Download</a>" in body
st, _, text, hdr = raw(srv, "GET", f"{base}/artefact/logbook-file")
assert st == 200 and "Night watch by Ada at " in text and hdr["content-type"].startswith("text/plain")
assert hdr["x-content-type-options"] == "nosniff" and "sandbox" in hdr["content-security-policy"] and "content-disposition" not in hdr
st, _, text2, hdr = raw(srv, "GET", f"{base}/artefact/logbook-file?download=1")
assert st == 200 and text2 == text and 'attachment; filename="artefact-logbook-file"' in hdr["content-disposition"]
def test_artefact_route_refuses_unknown_ids_secrets_and_v01_runs(srv):
base, logdir = new_v2(srv, p_weather="fog")
finish_logbook(srv, base)
assert raw(srv, "GET", f"{base}/artefact/nope")[0] == 404
(Path(logdir) / "logbook.txt").write_text("token " + "hvs." + "ABCDEFGHIJKLMN")
st, _, body, _ = raw(srv, "GET", f"{base}/artefact/logbook-file")
assert st == 403 and "secret" in body
_, _, dbody = req(srv, "GET", base + "?view=data")
assert "looks like it contains a secret" in dbody and "artefact/logbook-file" not in dbody
st, loc, _ = post(srv, f"/run/new/{pidx(srv, 'rtut.showcase-lighthouse')}", p_logdir="/tmp/rtut-r3-v1")
assert raw(srv, "GET", loc.split("#")[0] + "/artefact/logbook-file")[0] == 404
# ---- (f) timestamps, durations and the average ---------------------------------------------------------------------------------
def test_run_shows_start_finish_and_duration_and_steps_show_how_long_they_took(srv):
base, _ = new_v2(srv, p_weather="fog")
_, _, body = req(srv, "GET", base)
assert "Started 20" in body and "UTC · running for <b>" in body
step(srv, base, "read-the-sign")
_, _, body = req(srv, "GET", base)
assert "took 0 s" in body or "took 1 s" in body
assert post(srv, f"{base}/finalize", outcome="abandoned")[0] == 303
_, _, body = req(srv, "GET", base)
assert "finished 20" in body and "· took <b>" in body and "working" in body
def test_pack_page_and_list_show_the_average_duration_of_successful_runs(srv):
i1 = pidx(srv, "rtut.showcase-lighthouse")
pack = srv.app.packs[i1]
for secs, who in ((300, "a"), (900, "b")):
r = R1.create(pack, who)
start = datetime(2026, 9, 29, 10, 0, tzinfo=timezone.utc)
r.data.update(created=start.isoformat(), outcome="succeeded", finished=(start + timedelta(seconds=secs)).isoformat())
r.path.write_text(json.dumps(r.data))
slow = R1.create(pack, "c")
slow.data.update(created="2026-09-29T10:00:00+00:00", outcome="failed", finished="2026-09-29T20:00:00+00:00")
slow.path.write_text(json.dumps(slow.data))
_, _, body = req(srv, "GET", f"/pack/{i1}")
assert "average 10 min per successful run (fastest 5 min, 2 timed)" in body # the failed 10-hour run is not in the average
_, _, body = req(srv, "GET", "/")
assert "average 10 min per successful run" in body
assert "took 5 min" in req(srv, "GET", f"/pack/{i1}")[2] # the runs list shows each duration

51
tests/test_user.py Normal file
View file

@ -0,0 +1,51 @@
from pathlib import Path
import pytest
import yaml
from rtut import project as J, user
from rtut.attrs import Registry, fold, set_layers
@pytest.fixture(autouse=True)
def cfg(tmp_path, monkeypatch):
monkeypatch.setenv("RTUT_CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.delenv("RTUT_USER", raising=False)
def test_operator_comes_from_the_login_until_saved_then_from_the_file_and_the_environment_wins(monkeypatch):
monkeypatch.setattr("getpass.getuser", lambda: "Bernd Worsch")
assert user.current() == {"operator": "Bernd-Worsch", "display_name": "", "source": "login"} # always one word
user.save("bernd", "Bernd W.")
assert user.current() == {"operator": "bernd", "display_name": "Bernd W.", "source": "saved"}
monkeypatch.setenv("RTUT_USER", "alice@example.org")
assert user.current()["operator"] == "alice-example.org" and user.current()["source"] == "environment"
def test_save_validates_and_refuses_secrets():
for bad in ("two words", "", "x" * 41, "-lead"):
with pytest.raises(ValueError):
user.save(bad)
with pytest.raises(ValueError, match="secret"):
user.save("ok", "hvs.ABCDEFGHIJKLMN")
assert not user.user_file().exists()
def test_user_attributes_are_a_reusable_attribute_set_outside_projects(tmp_path):
user.save("bernd")
repo = tmp_path / "repo"
(repo / "runbooks/h").mkdir(parents=True)
(repo / "runbooks/h/pack.yaml").write_text((Path(__file__).resolve().parent.parent / "runbooks/example-hello/pack.yaml").read_text())
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
f = fold(set_layers(reg, "user-attributes"))
assert f["operator"]["value"] == "bernd" and f["operator"]["requirement"] == "required"
d = {"spec": "project/v0.1", "id": "p", "title": "Project p", "environment": "rehearsal", "attribute_sets": ["user-attributes"],
"runbooks": [{"pack": "rtut.example-hello"}]}
(repo / "projects/p").mkdir(parents=True)
(repo / "projects/p/project.yaml").write_text(yaml.safe_dump(d))
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
assert J.validate(reg, reg.projects["p"]) == [] # a project can reuse the user's attributes
(repo / "attribute-sets").mkdir()
(repo / "attribute-sets/user-attributes.yaml").write_text(yaml.safe_dump({"spec": "attribute-set/v0.1", "id": "user-attributes", "title": "Repo override", "attributes": {"operator": "someone"}}))
reg = Registry([repo], extra_sets={user.USER_SET_ID: user.attribute_set()})
assert fold(set_layers(reg, "user-attributes"))["operator"]["value"] == "someone" # a repository's own set takes precedence

View file

@ -214,6 +214,21 @@ views on a mid-run state). Three views of one run:
- **Visual design**: a restrained stylesheet (hairlines, state pills that never rely on colour alone, highlighter accent for
the recommended action, print rules). Dark theme is written but not verified in a browser.
## Feedback round after the first v0.2 hands-on (2026-09-29)
```task
id: RBT-WP-0004-T14
status: done
priority: high
```
Bernd's remarks, each handled and tested: (a) forms no longer ask for a name; the active user (`user-attributes`, app-level and reusable by
projects; `RTUT_USER`, saved settings or the OS login; `/user` page); (b) the showcase's irreversible step is now genuinely irreversible (the
logbook entry carries the exact time it was written); (c) a "Latest activities" section on the main page; (d) values that steps already used
can only be changed with an override and are recorded as `changed-after-use`, saves are all-or-nothing; (e) artefacts on the Data tab can be
opened and downloaded, with safety rules; (f) run start, finish and duration, per-step duration, and the average duration of successful
runs next to the score. Documented in `docs/run-spec.md` and `docs/maturity-and-projects.md`.
## Acceptance Criteria
- Bernd can complete the SSH tutorial pack in the UI without pasting outputs to an agent.